diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10e78df..54ea5f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,25 @@ jobs: with: run-tests: false + template-js: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.x + with: + persist-credentials: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + cache: npm + + - name: Install JavaScript tooling + run: npm ci + + - name: Check template JavaScript + run: npm run check:templates + test: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.gitignore b/.gitignore index 31bd40a..59e120c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ output/ .DS_Store .aws-sam/ samconfig.toml +node_modules/ diff --git a/README.md b/README.md index 46d001d..070167d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,15 @@ reminders to employees └────── who haven't ordered ``` +### Form frontend decisions + +- **Theme:** Retain the current order-form palette and typography. There is no shared Sea Haven web design system to adopt, and changing the theme without one would be an isolated visual redesign. Future theme changes should remap the existing CSS custom properties instead of adding scattered color values or inline styles. +- **Deployment:** Keep generating and publishing one self-contained HTML file. Inlining the template CSS and JavaScript at build time preserves the current atomic S3 upload and avoids introducing asset versioning, cache coordination, and additional CloudFront invalidation paths. Revisit a multi-file deploy only when asset size, cross-page sharing, or independent caching provides a concrete benefit. +- **Visual regression:** Do not add visual-regression CI now. Structural and Playwright coverage remain the active safeguards. Revisit snapshots, computed-style assertions, or a hosted service only after a layout or token regression reaches production without those checks catching it. + +ESLint and Prettier check the JavaScript template sources in CI. They do not lint +the generated HTML, and the generated deployment artifact remains self-contained. + ## Weekly Flow | When | What | How | diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..ad49751 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,20 @@ +import eslint from '@eslint/js'; +import globals from 'globals'; + +export default [ + { + files: ['src/server/templates/*.js'], + ...eslint.configs.recommended, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'script', + globals: { + ...globals.browser, + google: 'readonly', + }, + }, + rules: { + 'no-empty': ['error', { allowEmptyCatch: true }], + }, + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..23f61ca --- /dev/null +++ b/package-lock.json @@ -0,0 +1,934 @@ +{ + "name": "meal-order-manager", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meal-order-manager", + "version": "1.0.0", + "devDependencies": { + "@eslint/js": "10.0.1", + "eslint": "10.8.0", + "globals": "17.8.0", + "prettier": "3.9.6" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1fbe656 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "meal-order-manager", + "version": "1.0.0", + "private": true, + "scripts": { + "check:templates": "npm run lint:templates && npm run format:templates", + "format:templates": "prettier --check \"src/server/templates/*.js\"", + "format:templates:write": "prettier --write \"src/server/templates/*.js\"", + "lint:templates": "eslint \"src/server/templates/*.js\"" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "eslint": "10.8.0", + "globals": "17.8.0", + "prettier": "3.9.6" + } +} diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js index 897a5da..9466468 100644 --- a/src/server/templates/admin.js +++ b/src/server/templates/admin.js @@ -1,581 +1,647 @@ /* ADMIN_JS_MARKER */ (() => { -const ADMIN_URL = window.ADMIN_URL; -const ADMIN_PDF_URL = window.ADMIN_PDF_URL; -const MEALS = window.MEALS; -const WEEK = window.WEEK; -const ADMIN_REQUEST_TIMEOUT_MS = 10000; + const ADMIN_URL = window.ADMIN_URL; + const ADMIN_PDF_URL = window.ADMIN_PDF_URL; + const MEALS = window.MEALS; + const WEEK = window.WEEK; + const ADMIN_REQUEST_TIMEOUT_MS = 10000; -let currentAdminWeek = ''; -let editingOrder = null; -let editQuantities = {}; -let lastAdminData = null; -let latestAdminRequest = 0; + let currentAdminWeek = ""; + let editingOrder = null; + let editQuantities = {}; + let lastAdminData = null; + let latestAdminRequest = 0; -function adminHeaders() { - const h = {}; - const cred = window.googleCredential; - if (cred) h['Authorization'] = 'Bearer ' + cred; - return h; -} - -function adminFetch(url, options = {}) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), ADMIN_REQUEST_TIMEOUT_MS); - return fetch(url, { ...options, signal: controller.signal }) - .finally(() => clearTimeout(timeout)); -} - -function setAdminActionDisabled(action, disabled) { - document.querySelectorAll('[data-admin-action="' + action + '"]').forEach(btn => { - btn.disabled = disabled; - }); -} - -function loadWeekOrders(week) { - if (!week) return; - const requestId = ++latestAdminRequest; - window.FormUtils.clearStatus('admin'); - currentAdminWeek = week; - editingOrder = null; - editQuantities = {}; - const tbody = document.getElementById('admin-tbody'); - tbody.innerHTML = 'Loading...'; - const cards = document.getElementById('admin-cards'); - cards.innerHTML = '
Loading...
'; - document.getElementById('admin-empty').classList.add('is-hidden'); - document.getElementById('admin-stats').textContent = ''; - - adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) - .then(r => { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - }) - .then(data => { - if (requestId === latestAdminRequest && week === currentAdminWeek) { - renderAdminTable(data); - } - }) - .catch(() => { - if (requestId === latestAdminRequest && week === currentAdminWeek) { - tbody.innerHTML = 'Failed to load orders.'; - cards.innerHTML = ''; - window.FormUtils.showStatus('Failed to load orders.', { - tone: 'error', - scope: 'admin', - }); - } - }); -} - -window.loadWeekOrders = loadWeekOrders; - -function buildEditQtyRow(name, qty) { - const wrap = document.createElement('div'); - wrap.className = qty === 0 ? 'edit-qty removed' : 'edit-qty'; - - const minusBtn = document.createElement('button'); - minusBtn.type = 'button'; - minusBtn.dataset.adminAction = 'edit-qty'; - minusBtn.dataset.mealName = name; - minusBtn.dataset.delta = '-1'; - minusBtn.setAttribute('aria-label', 'Remove one ' + name); - minusBtn.innerHTML = '−'; - - const span = document.createElement('span'); - span.textContent = qty; - span.setAttribute('aria-label', 'Quantity of ' + name); - - const plusBtn = document.createElement('button'); - plusBtn.type = 'button'; - plusBtn.dataset.adminAction = 'edit-qty'; - plusBtn.dataset.mealName = name; - plusBtn.dataset.delta = '1'; - plusBtn.setAttribute('aria-label', 'Add one ' + name); - plusBtn.textContent = '+'; - - wrap.appendChild(minusBtn); - wrap.appendChild(span); - wrap.appendChild(plusBtn); - wrap.appendChild(document.createTextNode(' ' + name)); - return wrap; -} - -function buildAddItemSelect(availableMeals) { - const wrap = document.createElement('div'); - wrap.className = 'admin-add-item'; - const select = document.createElement('select'); - select.dataset.adminAction = 'add-item-select'; - - const defaultOpt = document.createElement('option'); - defaultOpt.value = ''; - defaultOpt.textContent = '+ Add item...'; - select.appendChild(defaultOpt); - - availableMeals.forEach(m => { - const opt = document.createElement('option'); - const price = m.price || 0; - opt.value = m.name; - opt.textContent = m.name + ' ($' + price.toFixed(2) + ')'; - select.appendChild(opt); - }); - - wrap.appendChild(select); - return wrap; -} - -function buildActionButton(label, action, extra) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.dataset.adminAction = action; - if (extra) { - Object.entries(extra).forEach(([key, val]) => { - btn.dataset[key] = val; - }); + function adminHeaders() { + const h = {}; + const cred = window.googleCredential; + if (cred) h["Authorization"] = "Bearer " + cred; + return h; } - btn.textContent = label; - return btn; -} -function buildEditActions(email) { - const wrap = document.createElement('div'); - wrap.className = 'admin-actions'; - const saveBtn = buildActionButton('Save', 'save-edit', { email }); - saveBtn.className = 'btn btn-sm btn-save'; - const cancelBtn = buildActionButton('Cancel', 'cancel-edit'); - cancelBtn.className = 'btn btn-sm btn-cancel'; - wrap.appendChild(saveBtn); - wrap.appendChild(cancelBtn); - return wrap; -} + function adminFetch(url, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + ADMIN_REQUEST_TIMEOUT_MS, + ); + return fetch(url, { ...options, signal: controller.signal }).finally(() => + clearTimeout(timeout), + ); + } -function buildViewActions(email, name) { - const wrap = document.createElement('div'); - wrap.className = 'admin-actions'; - const editBtn = buildActionButton('Edit', 'start-edit', { email }); - editBtn.className = 'btn btn-sm btn-edit'; - const deleteBtn = buildActionButton('Delete', 'delete', { email, name }); - deleteBtn.className = 'btn btn-sm btn-delete'; - wrap.appendChild(editBtn); - wrap.appendChild(deleteBtn); - return wrap; -} + function setAdminActionDisabled(action, disabled) { + document + .querySelectorAll('[data-admin-action="' + action + '"]') + .forEach((btn) => { + btn.disabled = disabled; + }); + } -function formatSubmittedAt(submittedAt) { - return submittedAt - ? new Date(submittedAt).toLocaleString('en-US', { - timeZone: 'America/New_York', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', + function loadWeekOrders(week) { + if (!week) return; + const requestId = ++latestAdminRequest; + window.FormUtils.clearStatus("admin"); + currentAdminWeek = week; + editingOrder = null; + editQuantities = {}; + const tbody = document.getElementById("admin-tbody"); + tbody.innerHTML = + 'Loading...'; + const cards = document.getElementById("admin-cards"); + cards.innerHTML = '
Loading...
'; + document.getElementById("admin-empty").classList.add("is-hidden"); + document.getElementById("admin-stats").textContent = ""; + + adminFetch(ADMIN_URL + "?week=" + encodeURIComponent(week), { + headers: adminHeaders(), + }) + .then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); }) - : ''; -} - -function editTotal() { - const menuPrices = {}; - MEALS.forEach(m => { menuPrices[m.name] = m.price; }); - return Object.keys(editQuantities).reduce((sum, name) => { - const price = menuPrices[name] || 0; - return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0); - }, 0); -} - -function populateOrderItems(container, order, isEditing, separatorFactory) { - if (isEditing) { - Object.keys(editQuantities).forEach(name => { - container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0)); - if (separatorFactory) container.appendChild(separatorFactory()); - }); - const availableMeals = MEALS.filter( - m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name) - ); - if (availableMeals.length > 0) { - container.appendChild(buildAddItemSelect(availableMeals)); - } - return '$' + editTotal().toFixed(2); - } - - order.items.forEach((item, itemIdx) => { - if (separatorFactory && itemIdx > 0) { - container.appendChild(separatorFactory()); - } - const row = separatorFactory ? container : document.createElement('div'); - if (!separatorFactory) row.className = 'admin-item'; - row.appendChild(document.createTextNode(item.name + ' ×' + item.quantity + ' ')); - const sub = document.createElement('span'); - sub.className = 'admin-muted'; - sub.textContent = '($' + item.subtotal.toFixed(2) + ')'; - row.appendChild(sub); - if (!separatorFactory) container.appendChild(row); - }); - return '$' + order.total.toFixed(2); -} - -function buildAdminCard(order) { - const card = document.createElement('article'); - card.className = 'admin-order-card'; - card.dataset.email = order.employee_email; - const isEditing = editingOrder === order.employee_email; - - const header = document.createElement('div'); - header.className = 'admin-card-header'; - const employee = document.createElement('div'); - employee.className = 'admin-card-employee'; - const name = document.createElement('strong'); - name.textContent = order.employee_name; - const email = document.createElement('span'); - email.className = 'admin-muted'; - email.textContent = order.employee_email; - employee.appendChild(name); - employee.appendChild(email); - const time = document.createElement('span'); - time.className = 'admin-card-time'; - time.textContent = formatSubmittedAt(order.submitted_at); - header.appendChild(employee); - header.appendChild(time); - - const items = document.createElement('div'); - items.className = 'admin-card-items'; - const total = populateOrderItems(items, order, isEditing); - - const footer = document.createElement('div'); - footer.className = 'admin-card-footer'; - const totalWrap = document.createElement('div'); - totalWrap.className = 'admin-card-total'; - const totalLabel = document.createElement('span'); - totalLabel.textContent = 'Total'; - const totalValue = document.createElement('strong'); - totalValue.textContent = total; - totalWrap.appendChild(totalLabel); - totalWrap.appendChild(totalValue); - footer.appendChild(totalWrap); - footer.appendChild( - isEditing - ? buildEditActions(order.employee_email) - : buildViewActions(order.employee_email, order.employee_name) - ); - - card.appendChild(header); - card.appendChild(items); - card.appendChild(footer); - return card; -} - -function renderAdminCards(data) { - const cards = document.getElementById('admin-cards'); - cards.innerHTML = ''; - (data.orders || []).forEach(order => { - cards.appendChild(buildAdminCard(order)); - }); -} - -function renderAdminTable(data) { - lastAdminData = data; - const hasOrders = !!(data.orders && data.orders.length > 0); - const dlBtn = document.getElementById('admin-download-btn'); - if (dlBtn) dlBtn.disabled = !hasOrders; - const tbody = document.getElementById('admin-tbody'); - tbody.innerHTML = ''; - const cards = document.getElementById('admin-cards'); - cards.innerHTML = ''; - - if (!hasOrders) { - document.getElementById('admin-empty').classList.remove('is-hidden'); - document.getElementById('admin-stats').textContent = ''; - return; - } - - document.getElementById('admin-empty').classList.add('is-hidden'); - const statsEl = document.getElementById('admin-stats'); - statsEl.replaceChildren(); - const empStrong = document.createElement('strong'); - empStrong.textContent = String(data.total_employees); - statsEl.appendChild(empStrong); - statsEl.appendChild( - document.createTextNode( - ' employee' + (data.total_employees !== 1 ? 's' : '') + ' ordered · ' - ) - ); - const totalStrong = document.createElement('strong'); - totalStrong.textContent = '$' + data.grand_total.toFixed(2); - statsEl.appendChild(totalStrong); - statsEl.appendChild(document.createTextNode(' total payroll deductions')); - - data.orders.forEach((order, idx) => { - const tr = document.createElement('tr'); - tr.id = 'admin-row-' + idx; - const isEditing = editingOrder === order.employee_email; - - const empTd = document.createElement('td'); - const nameStrong = document.createElement('strong'); - nameStrong.textContent = order.employee_name; - empTd.appendChild(nameStrong); - empTd.appendChild(document.createElement('br')); - const emailSpan = document.createElement('span'); - emailSpan.className = 'admin-muted'; - emailSpan.textContent = order.employee_email; - empTd.appendChild(emailSpan); - - const itemsTd = document.createElement('td'); - itemsTd.className = 'items-cell'; - - const totalHtml = populateOrderItems( - itemsTd, - order, - isEditing, - () => document.createElement('br') - ); - tr.appendChild(empTd); - tr.appendChild(itemsTd); - - const totalTd = document.createElement('td'); - totalTd.className = 'total-cell'; - totalTd.textContent = totalHtml; - - const timeTd = document.createElement('td'); - timeTd.className = 'time-cell'; - timeTd.textContent = formatSubmittedAt(order.submitted_at); - - const actionsTd = document.createElement('td'); - if (isEditing) { - actionsTd.appendChild(buildEditActions(order.employee_email)); - } else { - actionsTd.appendChild(buildViewActions(order.employee_email, order.employee_name)); - } - - tr.appendChild(totalTd); - tr.appendChild(timeTd); - tr.appendChild(actionsTd); - tbody.appendChild(tr); - }); - - const totalRow = document.createElement('tr'); - totalRow.className = 'admin-total-row'; - const mealCount = data.orders.reduce((s, o) => s + o.items.reduce((a, i) => a + i.quantity, 0), 0); - const t1 = document.createElement('td'); - t1.textContent = 'Total'; - const t2 = document.createElement('td'); - t2.textContent = mealCount + (mealCount === 1 ? ' meal' : ' meals'); - const t3 = document.createElement('td'); - t3.className = 'total-cell'; - t3.textContent = '$' + data.grand_total.toFixed(2); - totalRow.appendChild(t1); - totalRow.appendChild(t2); - totalRow.appendChild(t3); - totalRow.appendChild(document.createElement('td')); - totalRow.appendChild(document.createElement('td')); - tbody.appendChild(totalRow); - renderAdminCards(data); -} - -function downloadOrderList() { - const data = lastAdminData; - if (!data || !data.orders || data.orders.length === 0) return; - const totals = {}; - data.orders.forEach(o => (o.items || []).forEach(i => { - const name = i.name || ''; - totals[name] = (totals[name] || 0) + (i.quantity || 0); - })); - const names = Object.keys(totals).sort((a, b) => a.localeCompare(b)); - const esc = v => { - const s = String(v); - const safe = /^[=+\-@\t\r]/.test(s) ? "'" + s : s; - return /[",\n]/.test(safe) ? '"' + safe.replace(/"/g, '""') + '"' : safe; - }; - const rows = [['Item', 'Quantity']].concat(names.map(n => [n, totals[n]])); - const csv = rows.map(r => r.map(esc).join(',')).join('\r\n'); - const week = data.week || currentAdminWeek || WEEK; - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'order-list-' + week + '.csv'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -function downloadSummaryPdf() { - const week = currentAdminWeek || WEEK; - const btn = document.getElementById('admin-pdf-btn'); - if (btn) btn.disabled = true; - window.FormUtils.clearStatus('admin'); - adminFetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() }) - .then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data }))) - .then(({ ok, status, data }) => { - if (ok && data.url) { - // Only follow https presigned URLs from our admin API. - if (typeof data.url === 'string' && data.url.startsWith('https://')) { - window.location.href = data.url; - } else { - window.FormUtils.showStatus('Could not fetch the summary PDF: invalid URL', { - tone: 'error', - scope: 'admin', + .then((data) => { + if (requestId === latestAdminRequest && week === currentAdminWeek) { + renderAdminTable(data); + } + }) + .catch(() => { + if (requestId === latestAdminRequest && week === currentAdminWeek) { + tbody.innerHTML = + 'Failed to load orders.'; + cards.innerHTML = ""; + window.FormUtils.showStatus("Failed to load orders.", { + tone: "error", + scope: "admin", }); } - } else if (status === 404) { - window.FormUtils.showStatus( - 'No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.', - { tone: 'info', scope: 'admin' } - ); - } else { - window.FormUtils.showStatus( - 'Could not fetch the summary PDF: ' + (data.error || 'unknown error'), - { tone: 'error', scope: 'admin' } - ); - } - }) - .catch(() => { - window.FormUtils.showStatus('Could not fetch the summary PDF.', { - tone: 'error', - scope: 'admin', }); - }) - .finally(() => { if (btn) btn.disabled = false; }); -} - -function adminStartEdit(email) { - window.FormUtils.clearStatus('admin'); - const requestedWeek = currentAdminWeek; - setAdminActionDisabled('start-edit', true); - adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(requestedWeek), { headers: adminHeaders() }) - .then(r => { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - }) - .then(data => { - if (currentAdminWeek !== requestedWeek) return; - lastAdminData = data; - const order = data.orders.find(o => o.employee_email === email); - if (!order) return; - editingOrder = email; - editQuantities = {}; - order.items.forEach(i => { editQuantities[i.name] = i.quantity; }); - renderAdminTable(data); - }) - .catch(() => { - window.FormUtils.showStatus('Failed to load this order for editing.', { - tone: 'error', - scope: 'admin', - }); - }) - .finally(() => setAdminActionDisabled('start-edit', false)); -} - -function adminCancelEdit() { - editingOrder = null; - editQuantities = {}; - if (lastAdminData) renderAdminTable(lastAdminData); -} - -function adminEditQty(name, delta) { - const current = editQuantities[name] || 0; - editQuantities[name] = Math.max(0, current + delta); - if (lastAdminData) renderAdminTable(lastAdminData); -} - -function adminAddItem(name) { - if (!name) return; - editQuantities[name] = 1; - if (lastAdminData) renderAdminTable(lastAdminData); -} - -function adminSaveEdit(email) { - const items = Object.entries(editQuantities) - .filter(([, q]) => q > 0) - .map(([name, quantity]) => ({ name, quantity })); - if (items.length === 0) { - // confirm() retained deliberately for destructive zero-qty → delete guard. - if (confirm('All quantities are zero. Delete this order instead?')) { - adminDeleteOrder(email, ''); - } - return; } - window.FormUtils.clearStatus('admin'); - setAdminActionDisabled('save-edit', true); - adminFetch(ADMIN_URL, { - method: 'PUT', - headers: { ...adminHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ week: currentAdminWeek, email, items }), - }) - .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); }) - .then(() => { editingOrder = null; editQuantities = {}; loadWeekOrders(currentAdminWeek); }) - .catch(e => { - window.FormUtils.showStatus('Failed to update: ' + e.message, { - tone: 'error', - scope: 'admin', - }); - }) - .finally(() => setAdminActionDisabled('save-edit', false)); -} -function adminDeleteOrder(email, name) { - const label = name ? name + ' (' + email + ')' : email; - // confirm() retained deliberately for destructive delete guard. - if (!confirm('Delete order for ' + label + '?')) return; - window.FormUtils.clearStatus('admin'); - setAdminActionDisabled('delete', true); - adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), { - method: 'DELETE', - headers: adminHeaders(), - }) - .then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); }) - .then(() => loadWeekOrders(currentAdminWeek)) - .catch(e => { - window.FormUtils.showStatus('Failed to delete: ' + e.message, { - tone: 'error', - scope: 'admin', - }); - }) - .finally(() => setAdminActionDisabled('delete', false)); -} + window.loadWeekOrders = loadWeekOrders; -function handleAdminPanelClick(e) { - const btn = e.target.closest('[data-admin-action]'); - if (btn) { - const action = btn.dataset.adminAction; - if (action === 'start-edit') { - adminStartEdit(btn.dataset.email); - } else if (action === 'delete') { - adminDeleteOrder(btn.dataset.email, btn.dataset.name || ''); - } else if (action === 'save-edit') { - adminSaveEdit(btn.dataset.email); - } else if (action === 'cancel-edit') { - adminCancelEdit(); - } else if (action === 'edit-qty') { - adminEditQty(btn.dataset.mealName, parseInt(btn.dataset.delta, 10)); - } else if (action === 'download-list') { - downloadOrderList(); - } else if (action === 'download-pdf') { - downloadSummaryPdf(); - } else if (action === 'hide-admin') { - window.hideAdmin(); - } - return; + function buildEditQtyRow(name, qty) { + const wrap = document.createElement("div"); + wrap.className = qty === 0 ? "edit-qty removed" : "edit-qty"; + + const minusBtn = document.createElement("button"); + minusBtn.type = "button"; + minusBtn.dataset.adminAction = "edit-qty"; + minusBtn.dataset.mealName = name; + minusBtn.dataset.delta = "-1"; + minusBtn.setAttribute("aria-label", "Remove one " + name); + minusBtn.innerHTML = "−"; + + const span = document.createElement("span"); + span.textContent = qty; + span.setAttribute("aria-label", "Quantity of " + name); + + const plusBtn = document.createElement("button"); + plusBtn.type = "button"; + plusBtn.dataset.adminAction = "edit-qty"; + plusBtn.dataset.mealName = name; + plusBtn.dataset.delta = "1"; + plusBtn.setAttribute("aria-label", "Add one " + name); + plusBtn.textContent = "+"; + + wrap.appendChild(minusBtn); + wrap.appendChild(span); + wrap.appendChild(plusBtn); + wrap.appendChild(document.createTextNode(" " + name)); + return wrap; } -} -function adminInit() { - const panel = document.getElementById('admin-panel'); - if (panel) { - panel.addEventListener('click', handleAdminPanelClick); - panel.addEventListener('change', (e) => { - const select = e.target.closest('[data-admin-action="add-item-select"]'); - if (select && select.value) { - adminAddItem(select.value); - select.selectedIndex = 0; + function buildAddItemSelect(availableMeals) { + const wrap = document.createElement("div"); + wrap.className = "admin-add-item"; + const select = document.createElement("select"); + select.dataset.adminAction = "add-item-select"; + + const defaultOpt = document.createElement("option"); + defaultOpt.value = ""; + defaultOpt.textContent = "+ Add item..."; + select.appendChild(defaultOpt); + + availableMeals.forEach((m) => { + const opt = document.createElement("option"); + const price = m.price || 0; + opt.value = m.name; + opt.textContent = m.name + " ($" + price.toFixed(2) + ")"; + select.appendChild(opt); + }); + + wrap.appendChild(select); + return wrap; + } + + function buildActionButton(label, action, extra) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.dataset.adminAction = action; + if (extra) { + Object.entries(extra).forEach(([key, val]) => { + btn.dataset[key] = val; + }); + } + btn.textContent = label; + return btn; + } + + function buildEditActions(email) { + const wrap = document.createElement("div"); + wrap.className = "admin-actions"; + const saveBtn = buildActionButton("Save", "save-edit", { email }); + saveBtn.className = "btn btn-sm btn-save"; + const cancelBtn = buildActionButton("Cancel", "cancel-edit"); + cancelBtn.className = "btn btn-sm btn-cancel"; + wrap.appendChild(saveBtn); + wrap.appendChild(cancelBtn); + return wrap; + } + + function buildViewActions(email, name) { + const wrap = document.createElement("div"); + wrap.className = "admin-actions"; + const editBtn = buildActionButton("Edit", "start-edit", { email }); + editBtn.className = "btn btn-sm btn-edit"; + const deleteBtn = buildActionButton("Delete", "delete", { email, name }); + deleteBtn.className = "btn btn-sm btn-delete"; + wrap.appendChild(editBtn); + wrap.appendChild(deleteBtn); + return wrap; + } + + function formatSubmittedAt(submittedAt) { + return submittedAt + ? new Date(submittedAt).toLocaleString("en-US", { + timeZone: "America/New_York", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }) + : ""; + } + + function editTotal() { + const menuPrices = {}; + MEALS.forEach((m) => { + menuPrices[m.name] = m.price; + }); + return Object.keys(editQuantities).reduce((sum, name) => { + const price = menuPrices[name] || 0; + return ( + sum + + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0) + ); + }, 0); + } + + function populateOrderItems(container, order, isEditing, separatorFactory) { + if (isEditing) { + Object.keys(editQuantities).forEach((name) => { + container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0)); + if (separatorFactory) container.appendChild(separatorFactory()); + }); + const availableMeals = MEALS.filter( + (m) => !Object.prototype.hasOwnProperty.call(editQuantities, m.name), + ); + if (availableMeals.length > 0) { + container.appendChild(buildAddItemSelect(availableMeals)); } + return "$" + editTotal().toFixed(2); + } + + order.items.forEach((item, itemIdx) => { + if (separatorFactory && itemIdx > 0) { + container.appendChild(separatorFactory()); + } + const row = separatorFactory ? container : document.createElement("div"); + if (!separatorFactory) row.className = "admin-item"; + row.appendChild( + document.createTextNode(item.name + " ×" + item.quantity + " "), + ); + const sub = document.createElement("span"); + sub.className = "admin-muted"; + sub.textContent = "($" + item.subtotal.toFixed(2) + ")"; + row.appendChild(sub); + if (!separatorFactory) container.appendChild(row); + }); + return "$" + order.total.toFixed(2); + } + + function buildAdminCard(order) { + const card = document.createElement("article"); + card.className = "admin-order-card"; + card.dataset.email = order.employee_email; + const isEditing = editingOrder === order.employee_email; + + const header = document.createElement("div"); + header.className = "admin-card-header"; + const employee = document.createElement("div"); + employee.className = "admin-card-employee"; + const name = document.createElement("strong"); + name.textContent = order.employee_name; + const email = document.createElement("span"); + email.className = "admin-muted"; + email.textContent = order.employee_email; + employee.appendChild(name); + employee.appendChild(email); + const time = document.createElement("span"); + time.className = "admin-card-time"; + time.textContent = formatSubmittedAt(order.submitted_at); + header.appendChild(employee); + header.appendChild(time); + + const items = document.createElement("div"); + items.className = "admin-card-items"; + const total = populateOrderItems(items, order, isEditing); + + const footer = document.createElement("div"); + footer.className = "admin-card-footer"; + const totalWrap = document.createElement("div"); + totalWrap.className = "admin-card-total"; + const totalLabel = document.createElement("span"); + totalLabel.textContent = "Total"; + const totalValue = document.createElement("strong"); + totalValue.textContent = total; + totalWrap.appendChild(totalLabel); + totalWrap.appendChild(totalValue); + footer.appendChild(totalWrap); + footer.appendChild( + isEditing + ? buildEditActions(order.employee_email) + : buildViewActions(order.employee_email, order.employee_name), + ); + + card.appendChild(header); + card.appendChild(items); + card.appendChild(footer); + return card; + } + + function renderAdminCards(data) { + const cards = document.getElementById("admin-cards"); + cards.innerHTML = ""; + (data.orders || []).forEach((order) => { + cards.appendChild(buildAdminCard(order)); }); } - const weekSelect = document.getElementById('admin-week'); - if (weekSelect) { - weekSelect.addEventListener('change', () => loadWeekOrders(weekSelect.value)); - } -} + function renderAdminTable(data) { + lastAdminData = data; + const hasOrders = !!(data.orders && data.orders.length > 0); + const dlBtn = document.getElementById("admin-download-btn"); + if (dlBtn) dlBtn.disabled = !hasOrders; + const tbody = document.getElementById("admin-tbody"); + tbody.innerHTML = ""; + const cards = document.getElementById("admin-cards"); + cards.innerHTML = ""; -adminInit(); + if (!hasOrders) { + document.getElementById("admin-empty").classList.remove("is-hidden"); + document.getElementById("admin-stats").textContent = ""; + return; + } + + document.getElementById("admin-empty").classList.add("is-hidden"); + const statsEl = document.getElementById("admin-stats"); + statsEl.replaceChildren(); + const empStrong = document.createElement("strong"); + empStrong.textContent = String(data.total_employees); + statsEl.appendChild(empStrong); + statsEl.appendChild( + document.createTextNode( + " employee" + (data.total_employees !== 1 ? "s" : "") + " ordered · ", + ), + ); + const totalStrong = document.createElement("strong"); + totalStrong.textContent = "$" + data.grand_total.toFixed(2); + statsEl.appendChild(totalStrong); + statsEl.appendChild(document.createTextNode(" total payroll deductions")); + + data.orders.forEach((order, idx) => { + const tr = document.createElement("tr"); + tr.id = "admin-row-" + idx; + const isEditing = editingOrder === order.employee_email; + + const empTd = document.createElement("td"); + const nameStrong = document.createElement("strong"); + nameStrong.textContent = order.employee_name; + empTd.appendChild(nameStrong); + empTd.appendChild(document.createElement("br")); + const emailSpan = document.createElement("span"); + emailSpan.className = "admin-muted"; + emailSpan.textContent = order.employee_email; + empTd.appendChild(emailSpan); + + const itemsTd = document.createElement("td"); + itemsTd.className = "items-cell"; + + const totalHtml = populateOrderItems(itemsTd, order, isEditing, () => + document.createElement("br"), + ); + tr.appendChild(empTd); + tr.appendChild(itemsTd); + + const totalTd = document.createElement("td"); + totalTd.className = "total-cell"; + totalTd.textContent = totalHtml; + + const timeTd = document.createElement("td"); + timeTd.className = "time-cell"; + timeTd.textContent = formatSubmittedAt(order.submitted_at); + + const actionsTd = document.createElement("td"); + if (isEditing) { + actionsTd.appendChild(buildEditActions(order.employee_email)); + } else { + actionsTd.appendChild( + buildViewActions(order.employee_email, order.employee_name), + ); + } + + tr.appendChild(totalTd); + tr.appendChild(timeTd); + tr.appendChild(actionsTd); + tbody.appendChild(tr); + }); + + const totalRow = document.createElement("tr"); + totalRow.className = "admin-total-row"; + const mealCount = data.orders.reduce( + (s, o) => s + o.items.reduce((a, i) => a + i.quantity, 0), + 0, + ); + const t1 = document.createElement("td"); + t1.textContent = "Total"; + const t2 = document.createElement("td"); + t2.textContent = mealCount + (mealCount === 1 ? " meal" : " meals"); + const t3 = document.createElement("td"); + t3.className = "total-cell"; + t3.textContent = "$" + data.grand_total.toFixed(2); + totalRow.appendChild(t1); + totalRow.appendChild(t2); + totalRow.appendChild(t3); + totalRow.appendChild(document.createElement("td")); + totalRow.appendChild(document.createElement("td")); + tbody.appendChild(totalRow); + renderAdminCards(data); + } + + function downloadOrderList() { + const data = lastAdminData; + if (!data || !data.orders || data.orders.length === 0) return; + const totals = {}; + data.orders.forEach((o) => + (o.items || []).forEach((i) => { + const name = i.name || ""; + totals[name] = (totals[name] || 0) + (i.quantity || 0); + }), + ); + const names = Object.keys(totals).sort((a, b) => a.localeCompare(b)); + const esc = (v) => { + const s = String(v); + const safe = /^[=+\-@\t\r]/.test(s) ? "'" + s : s; + return /[",\n]/.test(safe) ? '"' + safe.replace(/"/g, '""') + '"' : safe; + }; + const rows = [["Item", "Quantity"]].concat( + names.map((n) => [n, totals[n]]), + ); + const csv = rows.map((r) => r.map(esc).join(",")).join("\r\n"); + const week = data.week || currentAdminWeek || WEEK; + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "order-list-" + week + ".csv"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + function downloadSummaryPdf() { + const week = currentAdminWeek || WEEK; + const btn = document.getElementById("admin-pdf-btn"); + if (btn) btn.disabled = true; + window.FormUtils.clearStatus("admin"); + adminFetch(ADMIN_PDF_URL + "?week=" + encodeURIComponent(week), { + headers: adminHeaders(), + }) + .then((r) => + r.json().then((data) => ({ ok: r.ok, status: r.status, data })), + ) + .then(({ ok, status, data }) => { + if (ok && data.url) { + // Only follow https presigned URLs from our admin API. + if (typeof data.url === "string" && data.url.startsWith("https://")) { + window.location.href = data.url; + } else { + window.FormUtils.showStatus( + "Could not fetch the summary PDF: invalid URL", + { + tone: "error", + scope: "admin", + }, + ); + } + } else if (status === 404) { + window.FormUtils.showStatus( + "No summary PDF for " + + week + + " yet — it is generated when the week closes on Thursday.", + { tone: "info", scope: "admin" }, + ); + } else { + window.FormUtils.showStatus( + "Could not fetch the summary PDF: " + + (data.error || "unknown error"), + { tone: "error", scope: "admin" }, + ); + } + }) + .catch(() => { + window.FormUtils.showStatus("Could not fetch the summary PDF.", { + tone: "error", + scope: "admin", + }); + }) + .finally(() => { + if (btn) btn.disabled = false; + }); + } + + function adminStartEdit(email) { + window.FormUtils.clearStatus("admin"); + const requestedWeek = currentAdminWeek; + setAdminActionDisabled("start-edit", true); + adminFetch(ADMIN_URL + "?week=" + encodeURIComponent(requestedWeek), { + headers: adminHeaders(), + }) + .then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then((data) => { + if (currentAdminWeek !== requestedWeek) return; + lastAdminData = data; + const order = data.orders.find((o) => o.employee_email === email); + if (!order) return; + editingOrder = email; + editQuantities = {}; + order.items.forEach((i) => { + editQuantities[i.name] = i.quantity; + }); + renderAdminTable(data); + }) + .catch(() => { + window.FormUtils.showStatus("Failed to load this order for editing.", { + tone: "error", + scope: "admin", + }); + }) + .finally(() => setAdminActionDisabled("start-edit", false)); + } + + function adminCancelEdit() { + editingOrder = null; + editQuantities = {}; + if (lastAdminData) renderAdminTable(lastAdminData); + } + + function adminEditQty(name, delta) { + const current = editQuantities[name] || 0; + editQuantities[name] = Math.max(0, current + delta); + if (lastAdminData) renderAdminTable(lastAdminData); + } + + function adminAddItem(name) { + if (!name) return; + editQuantities[name] = 1; + if (lastAdminData) renderAdminTable(lastAdminData); + } + + function adminSaveEdit(email) { + const items = Object.entries(editQuantities) + .filter(([, q]) => q > 0) + .map(([name, quantity]) => ({ name, quantity })); + if (items.length === 0) { + // confirm() retained deliberately for destructive zero-qty → delete guard. + if (confirm("All quantities are zero. Delete this order instead?")) { + adminDeleteOrder(email, ""); + } + return; + } + window.FormUtils.clearStatus("admin"); + setAdminActionDisabled("save-edit", true); + adminFetch(ADMIN_URL, { + method: "PUT", + headers: { ...adminHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ week: currentAdminWeek, email, items }), + }) + .then((r) => { + if (!r.ok) + return r.json().then((d) => { + throw new Error(d.error); + }); + return r.json(); + }) + .then(() => { + editingOrder = null; + editQuantities = {}; + loadWeekOrders(currentAdminWeek); + }) + .catch((e) => { + window.FormUtils.showStatus("Failed to update: " + e.message, { + tone: "error", + scope: "admin", + }); + }) + .finally(() => setAdminActionDisabled("save-edit", false)); + } + + function adminDeleteOrder(email, name) { + const label = name ? name + " (" + email + ")" : email; + // confirm() retained deliberately for destructive delete guard. + if (!confirm("Delete order for " + label + "?")) return; + window.FormUtils.clearStatus("admin"); + setAdminActionDisabled("delete", true); + adminFetch( + ADMIN_URL + + "?week=" + + encodeURIComponent(currentAdminWeek) + + "&email=" + + encodeURIComponent(email), + { + method: "DELETE", + headers: adminHeaders(), + }, + ) + .then((r) => { + if (!r.ok) + return r.json().then((d) => { + throw new Error(d.error); + }); + return r.json(); + }) + .then(() => loadWeekOrders(currentAdminWeek)) + .catch((e) => { + window.FormUtils.showStatus("Failed to delete: " + e.message, { + tone: "error", + scope: "admin", + }); + }) + .finally(() => setAdminActionDisabled("delete", false)); + } + + function handleAdminPanelClick(e) { + const btn = e.target.closest("[data-admin-action]"); + if (btn) { + const action = btn.dataset.adminAction; + if (action === "start-edit") { + adminStartEdit(btn.dataset.email); + } else if (action === "delete") { + adminDeleteOrder(btn.dataset.email, btn.dataset.name || ""); + } else if (action === "save-edit") { + adminSaveEdit(btn.dataset.email); + } else if (action === "cancel-edit") { + adminCancelEdit(); + } else if (action === "edit-qty") { + adminEditQty(btn.dataset.mealName, parseInt(btn.dataset.delta, 10)); + } else if (action === "download-list") { + downloadOrderList(); + } else if (action === "download-pdf") { + downloadSummaryPdf(); + } else if (action === "hide-admin") { + window.hideAdmin(); + } + return; + } + } + + function adminInit() { + const panel = document.getElementById("admin-panel"); + if (panel) { + panel.addEventListener("click", handleAdminPanelClick); + panel.addEventListener("change", (e) => { + const select = e.target.closest( + '[data-admin-action="add-item-select"]', + ); + if (select && select.value) { + adminAddItem(select.value); + select.selectedIndex = 0; + } + }); + } + + const weekSelect = document.getElementById("admin-week"); + if (weekSelect) { + weekSelect.addEventListener("change", () => + loadWeekOrders(weekSelect.value), + ); + } + } + + adminInit(); })(); diff --git a/src/server/templates/form.js b/src/server/templates/form.js index 78456d2..e05e700 100644 --- a/src/server/templates/form.js +++ b/src/server/templates/form.js @@ -10,7 +10,7 @@ const ADMIN_PDF_URL = CONFIG.adminPdfUrl; const WEEK = CONFIG.week; const BULK_DISCOUNT = CONFIG.bulkDiscount; const COMPANY_SUBSIDY = CONFIG.companySubsidy; -const GOOGLE_CLIENT_ID = CONFIG.googleClientId || ''; +const GOOGLE_CLIENT_ID = CONFIG.googleClientId || ""; window.MEALS = MEALS; window.WEEK = WEEK; @@ -34,33 +34,35 @@ window.googleCredential = null; function employeePrice(retail) { if (!retail) return 0; - const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100; - const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100; + const bulkPrice = + Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100; + const empPrice = + Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100; return empPrice; } function clearStatus(scope) { - const prefix = scope === 'admin' ? 'admin-status' : 'form-status'; - const alertEl = document.getElementById(prefix + '-alert'); - const infoEl = document.getElementById(prefix + '-info'); - if (alertEl) alertEl.textContent = ''; - if (infoEl) infoEl.textContent = ''; + const prefix = scope === "admin" ? "admin-status" : "form-status"; + const alertEl = document.getElementById(prefix + "-alert"); + const infoEl = document.getElementById(prefix + "-info"); + if (alertEl) alertEl.textContent = ""; + if (infoEl) infoEl.textContent = ""; } function showStatus(message, options) { const opts = options || {}; - const tone = opts.tone || 'error'; - const scope = opts.scope || 'form'; - const prefix = scope === 'admin' ? 'admin-status' : 'form-status'; - const alertEl = document.getElementById(prefix + '-alert'); - const infoEl = document.getElementById(prefix + '-info'); + const tone = opts.tone || "error"; + const scope = opts.scope || "form"; + const prefix = scope === "admin" ? "admin-status" : "form-status"; + const alertEl = document.getElementById(prefix + "-alert"); + const infoEl = document.getElementById(prefix + "-info"); if (!alertEl || !infoEl) return; - if (tone === 'info') { - alertEl.textContent = ''; - infoEl.textContent = message || ''; + if (tone === "info") { + alertEl.textContent = ""; + infoEl.textContent = message || ""; } else { - infoEl.textContent = ''; - alertEl.textContent = message || ''; + infoEl.textContent = ""; + alertEl.textContent = message || ""; } } @@ -79,16 +81,17 @@ function buildItems() { } function waitForGoogleAuth(attempt = 0) { - if (typeof google !== 'undefined' && google.accounts && google.accounts.id) { + if (typeof google !== "undefined" && google.accounts && google.accounts.id) { initGoogleAuth(); } else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) { setTimeout(() => waitForGoogleAuth(attempt + 1), 50); } else { - const signIn = document.getElementById('g-signin-btn'); + const signIn = document.getElementById("g-signin-btn"); if (signIn) { - signIn.textContent = 'Google sign-in failed to load. Refresh the page to try again.'; + signIn.textContent = + "Google sign-in failed to load. Refresh the page to try again."; } - console.error('Google Identity Services failed to load.'); + console.error("Google Identity Services failed to load."); } } @@ -98,30 +101,35 @@ function initGoogleAuth() { callback: handleCredentialResponse, auto_select: true, }); - google.accounts.id.renderButton( - document.getElementById('g-signin-btn'), - { theme: 'outline', size: 'large', text: 'signin_with', width: 300 } - ); + google.accounts.id.renderButton(document.getElementById("g-signin-btn"), { + theme: "outline", + size: "large", + text: "signin_with", + width: 300, + }); } function handleCredentialResponse(response) { googleCredential = response.credential; syncGoogleCredential(); - const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); - const payloadBytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); + const b64 = response.credential + .split(".")[1] + .replace(/-/g, "+") + .replace(/_/g, "/"); + const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const payload = JSON.parse(new TextDecoder().decode(payloadBytes)); googleUser = { name: payload.name, email: payload.email }; - document.getElementById('auth-overlay').classList.add('is-hidden'); - document.getElementById('app').classList.remove('is-hidden'); - const footer = document.getElementById('sticky-footer'); - if (footer) footer.classList.remove('is-hidden'); - document.getElementById('user-name').textContent = payload.name; - document.getElementById('user-email').textContent = payload.email; + document.getElementById("auth-overlay").classList.add("is-hidden"); + document.getElementById("app").classList.remove("is-hidden"); + const footer = document.getElementById("sticky-footer"); + if (footer) footer.classList.remove("is-hidden"); + document.getElementById("user-name").textContent = payload.name; + document.getElementById("user-email").textContent = payload.email; if (payload.picture) { - const avatar = document.getElementById('user-avatar'); + const avatar = document.getElementById("user-avatar"); avatar.src = payload.picture; - avatar.classList.remove('is-hidden'); + avatar.classList.remove("is-hidden"); } updateTotal(); @@ -133,37 +141,49 @@ function handleCredentialResponse(response) { function signOut() { isAdmin = false; - const ab = document.getElementById('admin-btn'); - if (ab) ab.classList.add('is-hidden'); - const banner = document.getElementById('admin-closed-banner'); + const ab = document.getElementById("admin-btn"); + if (ab) ab.classList.add("is-hidden"); + const banner = document.getElementById("admin-closed-banner"); if (banner) banner.remove(); googleCredential = null; googleUser = null; syncGoogleCredential(); - if (CONFIG.authMode === 'google' && typeof google !== 'undefined' && google.accounts) { + if ( + CONFIG.authMode === "google" && + typeof google !== "undefined" && + google.accounts + ) { google.accounts.id.disableAutoSelect(); } - document.getElementById('auth-overlay').classList.remove('is-hidden'); - document.getElementById('app').classList.add('is-hidden'); - const footer = document.getElementById('sticky-footer'); - if (footer) footer.classList.add('is-hidden'); + document.getElementById("auth-overlay").classList.remove("is-hidden"); + document.getElementById("app").classList.add("is-hidden"); + const footer = document.getElementById("sticky-footer"); + if (footer) footer.classList.add("is-hidden"); if (formClosed) showClosedOverlay(); } function checkDuplicateOrder() { try { - if (localStorage.getItem('lastOrderWeek') === WEEK) { - document.getElementById('duplicate-warning').classList.remove('is-hidden'); + if (localStorage.getItem("lastOrderWeek") === WEEK) { + document + .getElementById("duplicate-warning") + .classList.remove("is-hidden"); } } catch (e) {} } function getActiveFilter() { - return document.querySelector('.filters button.active')?.textContent || 'All Meals'; + return ( + document.querySelector(".filters button.active")?.textContent || "All Meals" + ); } function mealMatchesFilter(meal, activeFilter, search) { - if (activeFilter !== 'All Meals' && !(meal.dietary_tags || []).includes(activeFilter)) return false; + if ( + activeFilter !== "All Meals" && + !(meal.dietary_tags || []).includes(activeFilter) + ) + return false; if (search && !meal.name.toLowerCase().includes(search)) return false; return true; } @@ -171,37 +191,45 @@ function mealMatchesFilter(meal, activeFilter, search) { function buildMealMetaHtml(meal) { const hasDiscount = CONFIG.hasDiscount && meal.price; const empPrice = employeePrice(meal.price); - const meta = document.createElement('div'); - meta.className = 'meal-meta'; + const meta = document.createElement("div"); + meta.className = "meal-meta"; if (hasDiscount) { - const retail = document.createElement('span'); - retail.className = 'price-retail'; - retail.textContent = '$' + meal.price.toFixed(2); + const retail = document.createElement("span"); + retail.className = "price-retail"; + retail.textContent = "$" + meal.price.toFixed(2); meta.appendChild(retail); - const employee = document.createElement('span'); - employee.className = 'price-employee'; - employee.textContent = '$' + empPrice.toFixed(2); + const employee = document.createElement("span"); + employee.className = "price-employee"; + employee.textContent = "$" + empPrice.toFixed(2); meta.appendChild(employee); } else if (meal.price) { - meta.appendChild(document.createTextNode('$' + meal.price.toFixed(2))); + meta.appendChild(document.createTextNode("$" + meal.price.toFixed(2))); } else { - meta.appendChild(document.createTextNode('—')); + meta.appendChild(document.createTextNode("—")); } - meta.appendChild(document.createTextNode(' · ' + (meal.calories || '?') + ' cal · ' + (meal.protein || '?') + ' protein')); + meta.appendChild( + document.createTextNode( + " · " + + (meal.calories || "?") + + " cal · " + + (meal.protein || "?") + + " protein", + ), + ); return meta; } function buildMealTagsEl(meal) { - const tagsEl = document.createElement('div'); - tagsEl.className = 'meal-tags'; + const tagsEl = document.createElement("div"); + tagsEl.className = "meal-tags"; if (meal.is_new) { - const span = document.createElement('span'); - span.className = 'new'; - span.textContent = 'NEW'; + const span = document.createElement("span"); + span.className = "new"; + span.textContent = "NEW"; tagsEl.appendChild(span); } - (meal.dietary_tags || []).forEach(t => { - const span = document.createElement('span'); + (meal.dietary_tags || []).forEach((t) => { + const span = document.createElement("span"); span.textContent = t; tagsEl.appendChild(span); }); @@ -209,25 +237,25 @@ function buildMealTagsEl(meal) { } function removeDescToggle(card) { - const existing = card.querySelector('.meal-desc-toggle'); + const existing = card.querySelector(".meal-desc-toggle"); if (existing) existing.remove(); } function attachDescToggleIfOverflow(card) { - const descEl = card.querySelector('.meal-desc'); - if (!descEl || descEl.classList.contains('is-hidden')) return; + const descEl = card.querySelector(".meal-desc"); + if (!descEl || descEl.classList.contains("is-hidden")) return; removeDescToggle(card); if (descEl.scrollHeight <= descEl.clientHeight + 1) return; - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'meal-desc-toggle'; - btn.textContent = 'More'; - btn.setAttribute('aria-expanded', 'false'); - btn.addEventListener('click', () => { - const expanded = descEl.classList.toggle('is-expanded'); - btn.textContent = expanded ? 'Less' : 'More'; - btn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "meal-desc-toggle"; + btn.textContent = "More"; + btn.setAttribute("aria-expanded", "false"); + btn.addEventListener("click", () => { + const expanded = descEl.classList.toggle("is-expanded"); + btn.textContent = expanded ? "Less" : "More"; + btn.setAttribute("aria-expanded", expanded ? "true" : "false"); }); descEl.after(btn); } @@ -241,57 +269,59 @@ function scheduleDescToggle(card) { function updateMealCard(card, meal, i) { const qty = quantities[i] || 0; card.dataset.mealIndex = String(i); - card.classList.toggle('selected', qty > 0); + card.classList.toggle("selected", qty > 0); - const img = card.querySelector('.meal-img'); + const img = card.querySelector(".meal-img"); if (meal.image_url && /^https?:\/\//i.test(meal.image_url)) { img.src = meal.image_url; img.alt = meal.name; - img.classList.remove('is-hidden'); + img.classList.remove("is-hidden"); } else { - img.removeAttribute('src'); - img.alt = ''; - img.classList.add('is-hidden'); + img.removeAttribute("src"); + img.alt = ""; + img.classList.add("is-hidden"); } - card.querySelector('.meal-name').textContent = meal.name; + card.querySelector(".meal-name").textContent = meal.name; - const descEl = card.querySelector('.meal-desc'); + const descEl = card.querySelector(".meal-desc"); removeDescToggle(card); - descEl.classList.remove('is-expanded'); + descEl.classList.remove("is-expanded"); if (meal.description) { descEl.textContent = meal.description; - descEl.classList.remove('is-hidden'); + descEl.classList.remove("is-hidden"); scheduleDescToggle(card); } else { - descEl.textContent = ''; - descEl.classList.add('is-hidden'); + descEl.textContent = ""; + descEl.classList.add("is-hidden"); } - const metaEl = card.querySelector('.meal-meta'); + const metaEl = card.querySelector(".meal-meta"); const newMeta = buildMealMetaHtml(meal); metaEl.replaceWith(newMeta); - const tagsEl = card.querySelector('.meal-tags'); + const tagsEl = card.querySelector(".meal-tags"); const newTags = buildMealTagsEl(meal); tagsEl.replaceWith(newTags); - const qtyInput = card.querySelector('.qty'); + const qtyInput = card.querySelector(".qty"); qtyInput.value = qty; - qtyInput.setAttribute('aria-label', 'Quantity of ' + meal.name); + qtyInput.setAttribute("aria-label", "Quantity of " + meal.name); const minusBtn = card.querySelector('[data-qty-delta="-1"]'); const plusBtn = card.querySelector('[data-qty-delta="1"]'); - if (minusBtn) minusBtn.setAttribute('aria-label', 'Remove one ' + meal.name); - if (plusBtn) plusBtn.setAttribute('aria-label', 'Add one ' + meal.name); + if (minusBtn) minusBtn.setAttribute("aria-label", "Remove one " + meal.name); + if (plusBtn) plusBtn.setAttribute("aria-label", "Add one " + meal.name); const disabled = formClosed && !isAdmin; - card.querySelectorAll('[data-qty-delta]').forEach(btn => { btn.disabled = disabled; }); + card.querySelectorAll("[data-qty-delta]").forEach((btn) => { + btn.disabled = disabled; + }); qtyInput.disabled = disabled; } function createMealCard(meal, i) { - const tpl = document.getElementById('meal-card-tpl'); + const tpl = document.getElementById("meal-card-tpl"); const card = tpl.content.firstElementChild.cloneNode(true); updateMealCard(card, meal, i); return card; @@ -299,9 +329,9 @@ function createMealCard(meal, i) { function renderMeals() { const activeFilter = getActiveFilter(); - const search = document.getElementById('search').value.toLowerCase(); - const list = document.getElementById('meals-list'); - list.innerHTML = ''; + const search = document.getElementById("search").value.toLowerCase(); + const list = document.getElementById("meals-list"); + list.innerHTML = ""; MEALS.forEach((meal, i) => { if (!mealMatchesFilter(meal, activeFilter, search)) return; @@ -319,8 +349,8 @@ function updateMealCardQty(index) { const card = findMealCard(index); if (!card) return; const qty = quantities[index] || 0; - card.classList.toggle('selected', qty > 0); - const qtyInput = card.querySelector('.qty'); + card.classList.toggle("selected", qty > 0); + const qtyInput = card.querySelector(".qty"); if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty; } @@ -344,29 +374,29 @@ function changeQty(index, delta) { function bindMealsListDelegation() { if (mealsListDelegationBound) return; mealsListDelegationBound = true; - const list = document.getElementById('meals-list'); - list.addEventListener('click', (e) => { - const btn = e.target.closest('[data-qty-delta]'); + const list = document.getElementById("meals-list"); + list.addEventListener("click", (e) => { + const btn = e.target.closest("[data-qty-delta]"); if (!btn) return; - const card = btn.closest('[data-meal-index]'); + const card = btn.closest("[data-meal-index]"); if (!card) return; const index = parseInt(card.dataset.mealIndex, 10); const delta = parseInt(btn.dataset.qtyDelta, 10); changeQty(index, delta); }); - list.addEventListener('input', (e) => { - const input = e.target.closest('.qty'); + list.addEventListener("input", (e) => { + const input = e.target.closest(".qty"); if (!input) return; - const card = input.closest('[data-meal-index]'); + const card = input.closest("[data-meal-index]"); if (!card) return; const index = parseInt(card.dataset.mealIndex, 10); setQtyState(index, input.value); updateTotal(); }); - list.addEventListener('focusout', (e) => { - const input = e.target.closest('.qty'); + list.addEventListener("focusout", (e) => { + const input = e.target.closest(".qty"); if (!input) return; - const card = input.closest('[data-meal-index]'); + const card = input.closest("[data-meal-index]"); if (!card) return; updateMealCardQty(parseInt(card.dataset.mealIndex, 10)); }); @@ -379,98 +409,118 @@ function updateTotal() { total += employeePrice(MEALS[i].price || 0) * qty; count += qty; }); - document.getElementById('total-display').textContent = '$' + total.toFixed(2); - document.getElementById('count-display').textContent = count + ' meal' + (count !== 1 ? 's' : ''); - const submitBtn = document.getElementById('submit-btn'); - submitBtn.disabled = count === 0 || (CONFIG.authMode === 'google' && !googleCredential); + document.getElementById("total-display").textContent = "$" + total.toFixed(2); + document.getElementById("count-display").textContent = + count + " meal" + (count !== 1 ? "s" : ""); + const submitBtn = document.getElementById("submit-btn"); + submitBtn.disabled = + count === 0 || (CONFIG.authMode === "google" && !googleCredential); } async function submitOrder() { - clearStatus('form'); + clearStatus("form"); if (formClosed && !isAdmin) { - showStatus('Orders are closed.', { tone: 'error', scope: 'form' }); + showStatus("Orders are closed.", { tone: "error", scope: "form" }); return; } let payload; let successName; - if (CONFIG.authMode === 'google') { + if (CONFIG.authMode === "google") { if (!googleCredential || !googleUser) { - showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' }); + showStatus("Please sign in with Google first.", { + tone: "error", + scope: "form", + }); return; } payload = { google_id_token: googleCredential, items: buildItems() }; successName = googleUser.name; } else { - const nameEl = document.getElementById('emp-name'); - const emailEl = document.getElementById('emp-email'); + const nameEl = document.getElementById("emp-name"); + const emailEl = document.getElementById("emp-email"); const name = nameEl.value.trim(); const email = emailEl.value.trim(); if (!name) { - showStatus('Please enter your name.', { tone: 'error', scope: 'form' }); + showStatus("Please enter your name.", { tone: "error", scope: "form" }); nameEl.focus(); return; } if (!email) { - showStatus('Please enter your email.', { tone: 'error', scope: 'form' }); + showStatus("Please enter your email.", { tone: "error", scope: "form" }); emailEl.focus(); return; } - payload = { employee_name: name, employee_email: email, items: buildItems() }; + payload = { + employee_name: name, + employee_email: email, + items: buildItems(), + }; successName = name; } - const btn = document.getElementById('submit-btn'); + const btn = document.getElementById("submit-btn"); btn.disabled = true; - btn.textContent = 'Submitting...'; + btn.textContent = "Submitting..."; try { const res = await fetch(SUBMIT_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await res.json(); if (res.ok) { - for (const el of document.getElementById('app').children) { - if (el.id !== 'success') el.classList.add('is-hidden'); + for (const el of document.getElementById("app").children) { + if (el.id !== "success") el.classList.add("is-hidden"); } - document.getElementById('success').classList.remove('is-hidden'); - document.getElementById('success-detail').textContent = - successName + ' — $' + (data.total || 0).toFixed(2) + ' total. You\'re all set!'; - const footer = document.querySelector('.sticky-footer'); - if (footer) footer.classList.add('is-hidden'); - try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {} - const successHeading = document.getElementById('success-heading'); + document.getElementById("success").classList.remove("is-hidden"); + document.getElementById("success-detail").textContent = + successName + + " — $" + + (data.total || 0).toFixed(2) + + " total. You're all set!"; + const footer = document.querySelector(".sticky-footer"); + if (footer) footer.classList.add("is-hidden"); + try { + localStorage.setItem("lastOrderWeek", WEEK); + } catch (e) {} + const successHeading = document.getElementById("success-heading"); if (successHeading) successHeading.focus(); } else { - showStatus(data.error || 'Something went wrong.', { tone: 'error', scope: 'form' }); + showStatus(data.error || "Something went wrong.", { + tone: "error", + scope: "form", + }); btn.disabled = false; - btn.textContent = 'Submit Order'; + btn.textContent = "Submit Order"; } } catch (e) { - showStatus('Failed to submit. Check your connection and try again.', { tone: 'error', scope: 'form' }); + showStatus("Failed to submit. Check your connection and try again.", { + tone: "error", + scope: "form", + }); btn.disabled = false; - btn.textContent = 'Submit Order'; + btn.textContent = "Submit Order"; } } function bindEmpNameChange(select) { - if (select.dataset.boundChange === '1') return; - select.dataset.boundChange = '1'; - select.addEventListener('change', () => { + if (select.dataset.boundChange === "1") return; + select.dataset.boundChange = "1"; + select.addEventListener("change", () => { const selected = select.options[select.selectedIndex]; - document.getElementById('emp-email').value = selected?.dataset?.email || ''; - clearStatus('form'); + document.getElementById("emp-email").value = selected?.dataset?.email || ""; + clearStatus("form"); }); } function populateEmpSelect(select, employees) { select.innerHTML = ''; - employees.forEach(emp => { - const opt = document.createElement('option'); + employees.forEach((emp) => { + const opt = document.createElement("option"); opt.value = emp.name; opt.dataset.email = emp.email; opt.textContent = emp.name; @@ -485,43 +535,55 @@ async function loadRoster() { const res = await fetch(ROSTER_URL); const data = await res.json(); const candidate = data.employees; - if (Array.isArray(candidate) && candidate.every(e => typeof e.name === 'string' && typeof e.email === 'string')) { + if ( + Array.isArray(candidate) && + candidate.every( + (e) => typeof e.name === "string" && typeof e.email === "string", + ) + ) { employees = candidate; } else { employees = ROSTER; } } catch (e) {} - const select = document.getElementById('emp-name'); + const select = document.getElementById("emp-name"); populateEmpSelect(select, employees); } function showClosedOverlay() { - const overlay = document.getElementById('closed-overlay'); - overlay.classList.add('is-visible'); - overlay.style.display = 'flex'; - document.querySelectorAll('.qty-control button, .qty-control .qty').forEach(el => { el.disabled = true; }); - const submitBtn = document.getElementById('submit-btn'); + const overlay = document.getElementById("closed-overlay"); + overlay.classList.add("is-visible"); + overlay.style.display = "flex"; + document + .querySelectorAll(".qty-control button, .qty-control .qty") + .forEach((el) => { + el.disabled = true; + }); + const submitBtn = document.getElementById("submit-btn"); if (submitBtn) submitBtn.disabled = true; if (formStatusData) startCountdown(formStatusData); } function adminBypassClosed() { - const overlay = document.getElementById('closed-overlay'); - overlay.classList.remove('is-visible'); - overlay.style.display = 'none'; - document.querySelectorAll('.qty-control button').forEach(b => { b.disabled = false; }); - const submitBtn = document.getElementById('submit-btn'); + const overlay = document.getElementById("closed-overlay"); + overlay.classList.remove("is-visible"); + overlay.style.display = "none"; + document.querySelectorAll(".qty-control button").forEach((b) => { + b.disabled = false; + }); + const submitBtn = document.getElementById("submit-btn"); if (submitBtn) submitBtn.disabled = Object.keys(quantities).length === 0; - if (!document.getElementById('admin-closed-banner')) { - const banner = document.createElement('div'); - banner.id = 'admin-closed-banner'; - banner.className = 'closed-banner'; - banner.textContent = 'Form is closed — admin override active'; - const deadline = document.querySelector('.deadline'); - if (deadline) deadline.parentNode.insertBefore(banner, deadline.nextSibling); + if (!document.getElementById("admin-closed-banner")) { + const banner = document.createElement("div"); + banner.id = "admin-closed-banner"; + banner.className = "closed-banner"; + banner.textContent = "Form is closed — admin override active"; + const deadline = document.querySelector(".deadline"); + if (deadline) + deadline.parentNode.insertBefore(banner, deadline.nextSibling); } - document.querySelectorAll('[data-meal-index]').forEach(card => { + document.querySelectorAll("[data-meal-index]").forEach((card) => { const index = parseInt(card.dataset.mealIndex, 10); const meal = MEALS[index]; if (meal) updateMealCard(card, meal, index); @@ -532,17 +594,17 @@ function adminBypassClosed() { function checkAdmin() { if (!ADMIN_URL) return Promise.resolve(); const headers = {}; - if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential; + if (googleCredential) headers["Authorization"] = "Bearer " + googleCredential; return fetch(ADMIN_URL, { headers }) - .then(r => r.ok ? r.json() : null) - .then(data => { + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (data && data.weeks) { isAdmin = true; window.isAdmin = true; adminWeeks = data.weeks; window.adminWeeks = adminWeeks; - const ab = document.getElementById('admin-btn'); - if (ab) ab.classList.remove('is-hidden'); + const ab = document.getElementById("admin-btn"); + if (ab) ab.classList.remove("is-hidden"); if (formClosed) adminBypassClosed(); } else if (formClosed) { showClosedOverlay(); @@ -554,62 +616,71 @@ function checkAdmin() { } function showAdmin() { - document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => { - if (el.id !== 'admin-panel') { - el.dataset.prevHidden = el.classList.contains('is-hidden') ? '1' : ''; - el.classList.add('is-hidden'); + document.querySelectorAll("#app > :not(#admin-panel)").forEach((el) => { + if (el.id !== "admin-panel") { + el.dataset.prevHidden = el.classList.contains("is-hidden") ? "1" : ""; + el.classList.add("is-hidden"); } }); - document.getElementById('admin-panel').classList.remove('is-hidden'); - const footer = document.querySelector('.sticky-footer'); - if (footer) footer.classList.add('is-hidden'); + document.getElementById("admin-panel").classList.remove("is-hidden"); + const footer = document.querySelector(".sticky-footer"); + if (footer) footer.classList.add("is-hidden"); - const select = document.getElementById('admin-week'); - select.innerHTML = ''; - adminWeeks.forEach(w => { - const opt = document.createElement('option'); + const select = document.getElementById("admin-week"); + select.innerHTML = ""; + adminWeeks.forEach((w) => { + const opt = document.createElement("option"); opt.value = w.week; - opt.textContent = w.week + (w.form_status === 'open' ? ' (open)' : ''); + opt.textContent = w.week + (w.form_status === "open" ? " (open)" : ""); select.appendChild(opt); }); - if (adminWeeks.length > 0 && typeof loadWeekOrders === 'function') { + if (adminWeeks.length > 0 && typeof loadWeekOrders === "function") { loadWeekOrders(adminWeeks[0].week); } } function hideAdmin() { - document.getElementById('admin-panel').classList.add('is-hidden'); - document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => { - if (el.dataset.prevHidden === '1') el.classList.add('is-hidden'); - else el.classList.remove('is-hidden'); + document.getElementById("admin-panel").classList.add("is-hidden"); + document.querySelectorAll("#app > :not(#admin-panel)").forEach((el) => { + if (el.dataset.prevHidden === "1") el.classList.add("is-hidden"); + else el.classList.remove("is-hidden"); delete el.dataset.prevHidden; }); - const footer = document.querySelector('.sticky-footer'); - if (footer) footer.classList.remove('is-hidden'); + const footer = document.querySelector(".sticky-footer"); + if (footer) footer.classList.remove("is-hidden"); } -const easternFormatter = new Intl.DateTimeFormat('en-US', { - timeZone: 'America/New_York', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', +const easternFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", }); function getEasternParts(date) { const values = {}; - easternFormatter.formatToParts(date).forEach(part => { - if (part.type !== 'literal') values[part.type] = Number(part.value); + easternFormatter.formatToParts(date).forEach((part) => { + if (part.type !== "literal") values[part.type] = Number(part.value); }); return values; } function getEasternOffsetMs(date) { const parts = getEasternParts(date); - return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - date.getTime(); + return ( + Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ) - date.getTime() + ); } function startCountdown(data) { @@ -627,24 +698,36 @@ function updateCountdown() { target = new Date(reopenAtMs); } else { const etNow = getEasternParts(now); - const day = new Date(Date.UTC(etNow.year, etNow.month - 1, etNow.day)).getUTCDay(); + const day = new Date( + Date.UTC(etNow.year, etNow.month - 1, etNow.day), + ).getUTCDay(); const hour = etNow.hour; let daysUntil = (1 - day + 7) % 7; if (daysUntil === 0 && hour >= 8) daysUntil = 7; - const targetEtMs = Date.UTC(etNow.year, etNow.month - 1, etNow.day + daysUntil, 8, 0, 0); + const targetEtMs = Date.UTC( + etNow.year, + etNow.month - 1, + etNow.day + daysUntil, + 8, + 0, + 0, + ); target = new Date(targetEtMs - getEasternOffsetMs(new Date(targetEtMs))); } let diff = Math.max(0, Math.floor((target - now) / 1000)); - const d = Math.floor(diff / 86400); diff %= 86400; - const h = Math.floor(diff / 3600); diff %= 3600; + const d = Math.floor(diff / 86400); + diff %= 86400; + const h = Math.floor(diff / 3600); + diff %= 3600; const m = Math.floor(diff / 60); const s = diff % 60; - const pad = n => String(n).padStart(2, '0'); - document.getElementById('countdown').textContent = d + 'd ' + pad(h) + 'h ' + pad(m) + 'm ' + pad(s) + 's'; + const pad = (n) => String(n).padStart(2, "0"); + document.getElementById("countdown").textContent = + d + "d " + pad(h) + "h " + pad(m) + "m " + pad(s) + "s"; } async function checkFormStatus() { @@ -652,12 +735,12 @@ async function checkFormStatus() { try { const res = await fetch(STATUS_URL); const data = await res.json(); - if (data.status === 'closed') { + if (data.status === "closed") { formClosed = true; formStatusData = data; if (isAdmin) { adminBypassClosed(); - } else if (CONFIG.authMode !== 'google' || googleCredential) { + } else if (CONFIG.authMode !== "google" || googleCredential) { showClosedOverlay(); } } @@ -666,7 +749,7 @@ async function checkFormStatus() { function init() { checkFormStatus(); - if (CONFIG.authMode === 'google') { + if (CONFIG.authMode === "google") { waitForGoogleAuth(); } else { loadRoster(); @@ -674,39 +757,40 @@ function init() { } const tags = new Set(); - MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t))); - const cats = ['All Meals', ...Array.from(tags).sort()]; - const filtersEl = document.getElementById('filters'); - cats.forEach(cat => { - const btn = document.createElement('button'); - btn.type = 'button'; - const isActive = cat === 'All Meals'; - btn.className = 'btn btn-chip' + (isActive ? ' active' : ''); - btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + MEALS.forEach((m) => (m.dietary_tags || []).forEach((t) => tags.add(t))); + const cats = ["All Meals", ...Array.from(tags).sort()]; + const filtersEl = document.getElementById("filters"); + cats.forEach((cat) => { + const btn = document.createElement("button"); + btn.type = "button"; + const isActive = cat === "All Meals"; + btn.className = "btn btn-chip" + (isActive ? " active" : ""); + btn.setAttribute("aria-pressed", isActive ? "true" : "false"); btn.textContent = cat; - btn.addEventListener('click', () => { - filtersEl.querySelectorAll('button').forEach(b => { - b.classList.remove('active'); - b.setAttribute('aria-pressed', 'false'); + btn.addEventListener("click", () => { + filtersEl.querySelectorAll("button").forEach((b) => { + b.classList.remove("active"); + b.setAttribute("aria-pressed", "false"); }); - btn.classList.add('active'); - btn.setAttribute('aria-pressed', 'true'); + btn.classList.add("active"); + btn.setAttribute("aria-pressed", "true"); renderMeals(); }); filtersEl.appendChild(btn); }); - document.getElementById('search').addEventListener('input', renderMeals); - document.getElementById('submit-btn').addEventListener('click', submitOrder); + document.getElementById("search").addEventListener("input", renderMeals); + document.getElementById("submit-btn").addEventListener("click", submitOrder); - const adminBtn = document.getElementById('admin-btn'); - if (adminBtn) adminBtn.addEventListener('click', showAdmin); + const adminBtn = document.getElementById("admin-btn"); + if (adminBtn) adminBtn.addEventListener("click", showAdmin); - const signOutBtn = document.getElementById('sign-out-btn'); - if (signOutBtn) signOutBtn.addEventListener('click', signOut); + const signOutBtn = document.getElementById("sign-out-btn"); + if (signOutBtn) signOutBtn.addEventListener("click", signOut); - const successBackBtn = document.getElementById('success-back-btn'); - if (successBackBtn) successBackBtn.addEventListener('click', () => location.reload()); + const successBackBtn = document.getElementById("success-back-btn"); + if (successBackBtn) + successBackBtn.addEventListener("click", () => location.reload()); bindMealsListDelegation(); renderMeals();