diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index afaab59..295ca64 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();