From 7a15f2e88296812dd3a1bd1fb787784932b19a8b Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 31 Jul 2026 11:33:03 -0400 Subject: [PATCH] chore(form): lint template JavaScript in CI --- .github/workflows/ci.yml | 19 + .gitignore | 1 + eslint.config.mjs | 20 + package-lock.json | 934 ++++++++++++++++++++++++++ package.json | 17 + src/server/templates/admin.js | 1182 +++++++++++++++++---------------- src/server/templates/form.js | 560 +++++++++------- 7 files changed, 1937 insertions(+), 796 deletions(-) create mode 100644 eslint.config.mjs create mode 100644 package-lock.json create mode 100644 package.json 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/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..42998f0 --- /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..0f8e333 --- /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();