chore(form): add template JavaScript checks (#92)

* fix(auth): require Google authentication in cloud mode

Remove the public shared-key mechanism and fail closed on Google auth while adding submit-route throttling.

* chore(form): lint template JavaScript in CI

* docs(form): record frontend delivery decisions

* fix: resolve remaining merge conflicts
This commit is contained in:
Adam Moussa 2026-08-03 14:15:25 -04:00 • committed by GitHub
parent 311eab35c0
commit bcf255b17f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1946 additions and 796 deletions

View file

@ -12,6 +12,25 @@ jobs:
with: with:
run-tests: false 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: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ output/
.DS_Store .DS_Store
.aws-sam/ .aws-sam/
samconfig.toml samconfig.toml
node_modules/

View file

@ -30,6 +30,15 @@ reminders to employees └──────
who haven't ordered who haven't ordered
``` ```
### Form frontend decisions
- **Theme:** Retain the current order-form palette and typography. There is no shared Sea Haven web design system to adopt, and changing the theme without one would be an isolated visual redesign. Future theme changes should remap the existing CSS custom properties instead of adding scattered color values or inline styles.
- **Deployment:** Keep generating and publishing one self-contained HTML file. Inlining the template CSS and JavaScript at build time preserves the current atomic S3 upload and avoids introducing asset versioning, cache coordination, and additional CloudFront invalidation paths. Revisit a multi-file deploy only when asset size, cross-page sharing, or independent caching provides a concrete benefit.
- **Visual regression:** Do not add visual-regression CI now. Structural and Playwright coverage remain the active safeguards. Revisit snapshots, computed-style assertions, or a hosted service only after a layout or token regression reaches production without those checks catching it.
ESLint and Prettier check the JavaScript template sources in CI. They do not lint
the generated HTML, and the generated deployment artifact remains self-contained.
## Weekly Flow ## Weekly Flow
| When | What | How | | When | What | How |

20
eslint.config.mjs Normal file
View file

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

934
package-lock.json generated Normal file
View file

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

17
package.json Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@ const ADMIN_PDF_URL = CONFIG.adminPdfUrl;
const WEEK = CONFIG.week; const WEEK = CONFIG.week;
const BULK_DISCOUNT = CONFIG.bulkDiscount; const BULK_DISCOUNT = CONFIG.bulkDiscount;
const COMPANY_SUBSIDY = CONFIG.companySubsidy; const COMPANY_SUBSIDY = CONFIG.companySubsidy;
const GOOGLE_CLIENT_ID = CONFIG.googleClientId || ''; const GOOGLE_CLIENT_ID = CONFIG.googleClientId || "";
window.MEALS = MEALS; window.MEALS = MEALS;
window.WEEK = WEEK; window.WEEK = WEEK;
@ -34,33 +34,35 @@ window.googleCredential = null;
function employeePrice(retail) { function employeePrice(retail) {
if (!retail) return 0; if (!retail) return 0;
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100; const bulkPrice =
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100; 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; return empPrice;
} }
function clearStatus(scope) { function clearStatus(scope) {
const prefix = scope === 'admin' ? 'admin-status' : 'form-status'; const prefix = scope === "admin" ? "admin-status" : "form-status";
const alertEl = document.getElementById(prefix + '-alert'); const alertEl = document.getElementById(prefix + "-alert");
const infoEl = document.getElementById(prefix + '-info'); const infoEl = document.getElementById(prefix + "-info");
if (alertEl) alertEl.textContent = ''; if (alertEl) alertEl.textContent = "";
if (infoEl) infoEl.textContent = ''; if (infoEl) infoEl.textContent = "";
} }
function showStatus(message, options) { function showStatus(message, options) {
const opts = options || {}; const opts = options || {};
const tone = opts.tone || 'error'; const tone = opts.tone || "error";
const scope = opts.scope || 'form'; const scope = opts.scope || "form";
const prefix = scope === 'admin' ? 'admin-status' : 'form-status'; const prefix = scope === "admin" ? "admin-status" : "form-status";
const alertEl = document.getElementById(prefix + '-alert'); const alertEl = document.getElementById(prefix + "-alert");
const infoEl = document.getElementById(prefix + '-info'); const infoEl = document.getElementById(prefix + "-info");
if (!alertEl || !infoEl) return; if (!alertEl || !infoEl) return;
if (tone === 'info') { if (tone === "info") {
alertEl.textContent = ''; alertEl.textContent = "";
infoEl.textContent = message || ''; infoEl.textContent = message || "";
} else { } else {
infoEl.textContent = ''; infoEl.textContent = "";
alertEl.textContent = message || ''; alertEl.textContent = message || "";
} }
} }
@ -79,16 +81,17 @@ function buildItems() {
} }
function waitForGoogleAuth(attempt = 0) { function waitForGoogleAuth(attempt = 0) {
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) { if (typeof google !== "undefined" && google.accounts && google.accounts.id) {
initGoogleAuth(); initGoogleAuth();
} else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) { } else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) {
setTimeout(() => waitForGoogleAuth(attempt + 1), 50); setTimeout(() => waitForGoogleAuth(attempt + 1), 50);
} else { } else {
const signIn = document.getElementById('g-signin-btn'); const signIn = document.getElementById("g-signin-btn");
if (signIn) { 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, callback: handleCredentialResponse,
auto_select: true, auto_select: true,
}); });
google.accounts.id.renderButton( google.accounts.id.renderButton(document.getElementById("g-signin-btn"), {
document.getElementById('g-signin-btn'), theme: "outline",
{ theme: 'outline', size: 'large', text: 'signin_with', width: 300 } size: "large",
); text: "signin_with",
width: 300,
});
} }
function handleCredentialResponse(response) { function handleCredentialResponse(response) {
googleCredential = response.credential; googleCredential = response.credential;
syncGoogleCredential(); syncGoogleCredential();
const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); const b64 = response.credential
const payloadBytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); .split(".")[1]
.replace(/-/g, "+")
.replace(/_/g, "/");
const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder().decode(payloadBytes)); const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
googleUser = { name: payload.name, email: payload.email }; googleUser = { name: payload.name, email: payload.email };
document.getElementById('auth-overlay').classList.add('is-hidden'); document.getElementById("auth-overlay").classList.add("is-hidden");
document.getElementById('app').classList.remove('is-hidden'); document.getElementById("app").classList.remove("is-hidden");
const footer = document.getElementById('sticky-footer'); const footer = document.getElementById("sticky-footer");
if (footer) footer.classList.remove('is-hidden'); if (footer) footer.classList.remove("is-hidden");
document.getElementById('user-name').textContent = payload.name; document.getElementById("user-name").textContent = payload.name;
document.getElementById('user-email').textContent = payload.email; document.getElementById("user-email").textContent = payload.email;
if (payload.picture) { if (payload.picture) {
const avatar = document.getElementById('user-avatar'); const avatar = document.getElementById("user-avatar");
avatar.src = payload.picture; avatar.src = payload.picture;
avatar.classList.remove('is-hidden'); avatar.classList.remove("is-hidden");
} }
updateTotal(); updateTotal();
@ -133,37 +141,49 @@ function handleCredentialResponse(response) {
function signOut() { function signOut() {
isAdmin = false; isAdmin = false;
const ab = document.getElementById('admin-btn'); const ab = document.getElementById("admin-btn");
if (ab) ab.classList.add('is-hidden'); if (ab) ab.classList.add("is-hidden");
const banner = document.getElementById('admin-closed-banner'); const banner = document.getElementById("admin-closed-banner");
if (banner) banner.remove(); if (banner) banner.remove();
googleCredential = null; googleCredential = null;
googleUser = null; googleUser = null;
syncGoogleCredential(); syncGoogleCredential();
if (CONFIG.authMode === 'google' && typeof google !== 'undefined' && google.accounts) { if (
CONFIG.authMode === "google" &&
typeof google !== "undefined" &&
google.accounts
) {
google.accounts.id.disableAutoSelect(); google.accounts.id.disableAutoSelect();
} }
document.getElementById('auth-overlay').classList.remove('is-hidden'); document.getElementById("auth-overlay").classList.remove("is-hidden");
document.getElementById('app').classList.add('is-hidden'); document.getElementById("app").classList.add("is-hidden");
const footer = document.getElementById('sticky-footer'); const footer = document.getElementById("sticky-footer");
if (footer) footer.classList.add('is-hidden'); if (footer) footer.classList.add("is-hidden");
if (formClosed) showClosedOverlay(); if (formClosed) showClosedOverlay();
} }
function checkDuplicateOrder() { function checkDuplicateOrder() {
try { try {
if (localStorage.getItem('lastOrderWeek') === WEEK) { if (localStorage.getItem("lastOrderWeek") === WEEK) {
document.getElementById('duplicate-warning').classList.remove('is-hidden'); document
.getElementById("duplicate-warning")
.classList.remove("is-hidden");
} }
} catch (e) {} } catch (e) {}
} }
function getActiveFilter() { 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) { 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; if (search && !meal.name.toLowerCase().includes(search)) return false;
return true; return true;
} }
@ -171,37 +191,45 @@ function mealMatchesFilter(meal, activeFilter, search) {
function buildMealMetaHtml(meal) { function buildMealMetaHtml(meal) {
const hasDiscount = CONFIG.hasDiscount && meal.price; const hasDiscount = CONFIG.hasDiscount && meal.price;
const empPrice = employeePrice(meal.price); const empPrice = employeePrice(meal.price);
const meta = document.createElement('div'); const meta = document.createElement("div");
meta.className = 'meal-meta'; meta.className = "meal-meta";
if (hasDiscount) { if (hasDiscount) {
const retail = document.createElement('span'); const retail = document.createElement("span");
retail.className = 'price-retail'; retail.className = "price-retail";
retail.textContent = '$' + meal.price.toFixed(2); retail.textContent = "$" + meal.price.toFixed(2);
meta.appendChild(retail); meta.appendChild(retail);
const employee = document.createElement('span'); const employee = document.createElement("span");
employee.className = 'price-employee'; employee.className = "price-employee";
employee.textContent = '$' + empPrice.toFixed(2); employee.textContent = "$" + empPrice.toFixed(2);
meta.appendChild(employee); meta.appendChild(employee);
} else if (meal.price) { } else if (meal.price) {
meta.appendChild(document.createTextNode('$' + meal.price.toFixed(2))); meta.appendChild(document.createTextNode("$" + meal.price.toFixed(2)));
} else { } 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; return meta;
} }
function buildMealTagsEl(meal) { function buildMealTagsEl(meal) {
const tagsEl = document.createElement('div'); const tagsEl = document.createElement("div");
tagsEl.className = 'meal-tags'; tagsEl.className = "meal-tags";
if (meal.is_new) { if (meal.is_new) {
const span = document.createElement('span'); const span = document.createElement("span");
span.className = 'new'; span.className = "new";
span.textContent = 'NEW'; span.textContent = "NEW";
tagsEl.appendChild(span); tagsEl.appendChild(span);
} }
(meal.dietary_tags || []).forEach(t => { (meal.dietary_tags || []).forEach((t) => {
const span = document.createElement('span'); const span = document.createElement("span");
span.textContent = t; span.textContent = t;
tagsEl.appendChild(span); tagsEl.appendChild(span);
}); });
@ -209,25 +237,25 @@ function buildMealTagsEl(meal) {
} }
function removeDescToggle(card) { function removeDescToggle(card) {
const existing = card.querySelector('.meal-desc-toggle'); const existing = card.querySelector(".meal-desc-toggle");
if (existing) existing.remove(); if (existing) existing.remove();
} }
function attachDescToggleIfOverflow(card) { function attachDescToggleIfOverflow(card) {
const descEl = card.querySelector('.meal-desc'); const descEl = card.querySelector(".meal-desc");
if (!descEl || descEl.classList.contains('is-hidden')) return; if (!descEl || descEl.classList.contains("is-hidden")) return;
removeDescToggle(card); removeDescToggle(card);
if (descEl.scrollHeight <= descEl.clientHeight + 1) return; if (descEl.scrollHeight <= descEl.clientHeight + 1) return;
const btn = document.createElement('button'); const btn = document.createElement("button");
btn.type = 'button'; btn.type = "button";
btn.className = 'meal-desc-toggle'; btn.className = "meal-desc-toggle";
btn.textContent = 'More'; btn.textContent = "More";
btn.setAttribute('aria-expanded', 'false'); btn.setAttribute("aria-expanded", "false");
btn.addEventListener('click', () => { btn.addEventListener("click", () => {
const expanded = descEl.classList.toggle('is-expanded'); const expanded = descEl.classList.toggle("is-expanded");
btn.textContent = expanded ? 'Less' : 'More'; btn.textContent = expanded ? "Less" : "More";
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); btn.setAttribute("aria-expanded", expanded ? "true" : "false");
}); });
descEl.after(btn); descEl.after(btn);
} }
@ -241,57 +269,59 @@ function scheduleDescToggle(card) {
function updateMealCard(card, meal, i) { function updateMealCard(card, meal, i) {
const qty = quantities[i] || 0; const qty = quantities[i] || 0;
card.dataset.mealIndex = String(i); 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)) { if (meal.image_url && /^https?:\/\//i.test(meal.image_url)) {
img.src = meal.image_url; img.src = meal.image_url;
img.alt = meal.name; img.alt = meal.name;
img.classList.remove('is-hidden'); img.classList.remove("is-hidden");
} else { } else {
img.removeAttribute('src'); img.removeAttribute("src");
img.alt = ''; img.alt = "";
img.classList.add('is-hidden'); 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); removeDescToggle(card);
descEl.classList.remove('is-expanded'); descEl.classList.remove("is-expanded");
if (meal.description) { if (meal.description) {
descEl.textContent = meal.description; descEl.textContent = meal.description;
descEl.classList.remove('is-hidden'); descEl.classList.remove("is-hidden");
scheduleDescToggle(card); scheduleDescToggle(card);
} else { } else {
descEl.textContent = ''; descEl.textContent = "";
descEl.classList.add('is-hidden'); descEl.classList.add("is-hidden");
} }
const metaEl = card.querySelector('.meal-meta'); const metaEl = card.querySelector(".meal-meta");
const newMeta = buildMealMetaHtml(meal); const newMeta = buildMealMetaHtml(meal);
metaEl.replaceWith(newMeta); metaEl.replaceWith(newMeta);
const tagsEl = card.querySelector('.meal-tags'); const tagsEl = card.querySelector(".meal-tags");
const newTags = buildMealTagsEl(meal); const newTags = buildMealTagsEl(meal);
tagsEl.replaceWith(newTags); tagsEl.replaceWith(newTags);
const qtyInput = card.querySelector('.qty'); const qtyInput = card.querySelector(".qty");
qtyInput.value = 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 minusBtn = card.querySelector('[data-qty-delta="-1"]');
const plusBtn = 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 (minusBtn) minusBtn.setAttribute("aria-label", "Remove one " + meal.name);
if (plusBtn) plusBtn.setAttribute('aria-label', 'Add one ' + meal.name); if (plusBtn) plusBtn.setAttribute("aria-label", "Add one " + meal.name);
const disabled = formClosed && !isAdmin; 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; qtyInput.disabled = disabled;
} }
function createMealCard(meal, i) { 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); const card = tpl.content.firstElementChild.cloneNode(true);
updateMealCard(card, meal, i); updateMealCard(card, meal, i);
return card; return card;
@ -299,9 +329,9 @@ function createMealCard(meal, i) {
function renderMeals() { function renderMeals() {
const activeFilter = getActiveFilter(); const activeFilter = getActiveFilter();
const search = document.getElementById('search').value.toLowerCase(); const search = document.getElementById("search").value.toLowerCase();
const list = document.getElementById('meals-list'); const list = document.getElementById("meals-list");
list.innerHTML = ''; list.innerHTML = "";
MEALS.forEach((meal, i) => { MEALS.forEach((meal, i) => {
if (!mealMatchesFilter(meal, activeFilter, search)) return; if (!mealMatchesFilter(meal, activeFilter, search)) return;
@ -319,8 +349,8 @@ function updateMealCardQty(index) {
const card = findMealCard(index); const card = findMealCard(index);
if (!card) return; if (!card) return;
const qty = quantities[index] || 0; const qty = quantities[index] || 0;
card.classList.toggle('selected', qty > 0); card.classList.toggle("selected", qty > 0);
const qtyInput = card.querySelector('.qty'); const qtyInput = card.querySelector(".qty");
if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty; if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty;
} }
@ -344,29 +374,29 @@ function changeQty(index, delta) {
function bindMealsListDelegation() { function bindMealsListDelegation() {
if (mealsListDelegationBound) return; if (mealsListDelegationBound) return;
mealsListDelegationBound = true; mealsListDelegationBound = true;
const list = document.getElementById('meals-list'); const list = document.getElementById("meals-list");
list.addEventListener('click', (e) => { list.addEventListener("click", (e) => {
const btn = e.target.closest('[data-qty-delta]'); const btn = e.target.closest("[data-qty-delta]");
if (!btn) return; if (!btn) return;
const card = btn.closest('[data-meal-index]'); const card = btn.closest("[data-meal-index]");
if (!card) return; if (!card) return;
const index = parseInt(card.dataset.mealIndex, 10); const index = parseInt(card.dataset.mealIndex, 10);
const delta = parseInt(btn.dataset.qtyDelta, 10); const delta = parseInt(btn.dataset.qtyDelta, 10);
changeQty(index, delta); changeQty(index, delta);
}); });
list.addEventListener('input', (e) => { list.addEventListener("input", (e) => {
const input = e.target.closest('.qty'); const input = e.target.closest(".qty");
if (!input) return; if (!input) return;
const card = input.closest('[data-meal-index]'); const card = input.closest("[data-meal-index]");
if (!card) return; if (!card) return;
const index = parseInt(card.dataset.mealIndex, 10); const index = parseInt(card.dataset.mealIndex, 10);
setQtyState(index, input.value); setQtyState(index, input.value);
updateTotal(); updateTotal();
}); });
list.addEventListener('focusout', (e) => { list.addEventListener("focusout", (e) => {
const input = e.target.closest('.qty'); const input = e.target.closest(".qty");
if (!input) return; if (!input) return;
const card = input.closest('[data-meal-index]'); const card = input.closest("[data-meal-index]");
if (!card) return; if (!card) return;
updateMealCardQty(parseInt(card.dataset.mealIndex, 10)); updateMealCardQty(parseInt(card.dataset.mealIndex, 10));
}); });
@ -379,98 +409,118 @@ function updateTotal() {
total += employeePrice(MEALS[i].price || 0) * qty; total += employeePrice(MEALS[i].price || 0) * qty;
count += qty; count += qty;
}); });
document.getElementById('total-display').textContent = '$' + total.toFixed(2); document.getElementById("total-display").textContent = "$" + total.toFixed(2);
document.getElementById('count-display').textContent = count + ' meal' + (count !== 1 ? 's' : ''); document.getElementById("count-display").textContent =
const submitBtn = document.getElementById('submit-btn'); count + " meal" + (count !== 1 ? "s" : "");
submitBtn.disabled = count === 0 || (CONFIG.authMode === 'google' && !googleCredential); const submitBtn = document.getElementById("submit-btn");
submitBtn.disabled =
count === 0 || (CONFIG.authMode === "google" && !googleCredential);
} }
async function submitOrder() { async function submitOrder() {
clearStatus('form'); clearStatus("form");
if (formClosed && !isAdmin) { if (formClosed && !isAdmin) {
showStatus('Orders are closed.', { tone: 'error', scope: 'form' }); showStatus("Orders are closed.", { tone: "error", scope: "form" });
return; return;
} }
let payload; let payload;
let successName; let successName;
if (CONFIG.authMode === 'google') { if (CONFIG.authMode === "google") {
if (!googleCredential || !googleUser) { 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; return;
} }
payload = { google_id_token: googleCredential, items: buildItems() }; payload = { google_id_token: googleCredential, items: buildItems() };
successName = googleUser.name; successName = googleUser.name;
} else { } else {
const nameEl = document.getElementById('emp-name'); const nameEl = document.getElementById("emp-name");
const emailEl = document.getElementById('emp-email'); const emailEl = document.getElementById("emp-email");
const name = nameEl.value.trim(); const name = nameEl.value.trim();
const email = emailEl.value.trim(); const email = emailEl.value.trim();
if (!name) { if (!name) {
showStatus('Please enter your name.', { tone: 'error', scope: 'form' }); showStatus("Please enter your name.", { tone: "error", scope: "form" });
nameEl.focus(); nameEl.focus();
return; return;
} }
if (!email) { if (!email) {
showStatus('Please enter your email.', { tone: 'error', scope: 'form' }); showStatus("Please enter your email.", { tone: "error", scope: "form" });
emailEl.focus(); emailEl.focus();
return; return;
} }
payload = { employee_name: name, employee_email: email, items: buildItems() }; payload = {
employee_name: name,
employee_email: email,
items: buildItems(),
};
successName = name; successName = name;
} }
const btn = document.getElementById('submit-btn'); const btn = document.getElementById("submit-btn");
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Submitting...'; btn.textContent = "Submitting...";
try { try {
const res = await fetch(SUBMIT_URL, { const res = await fetch(SUBMIT_URL, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
for (const el of document.getElementById('app').children) { for (const el of document.getElementById("app").children) {
if (el.id !== 'success') el.classList.add('is-hidden'); if (el.id !== "success") el.classList.add("is-hidden");
} }
document.getElementById('success').classList.remove('is-hidden'); document.getElementById("success").classList.remove("is-hidden");
document.getElementById('success-detail').textContent = document.getElementById("success-detail").textContent =
successName + ' — $' + (data.total || 0).toFixed(2) + ' total. You\'re all set!'; successName +
const footer = document.querySelector('.sticky-footer'); " — $" +
if (footer) footer.classList.add('is-hidden'); (data.total || 0).toFixed(2) +
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {} " total. You're all set!";
const successHeading = document.getElementById('success-heading'); 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(); if (successHeading) successHeading.focus();
} else { } 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.disabled = false;
btn.textContent = 'Submit Order'; btn.textContent = "Submit Order";
} }
} catch (e) { } 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.disabled = false;
btn.textContent = 'Submit Order'; btn.textContent = "Submit Order";
} }
} }
function bindEmpNameChange(select) { function bindEmpNameChange(select) {
if (select.dataset.boundChange === '1') return; if (select.dataset.boundChange === "1") return;
select.dataset.boundChange = '1'; select.dataset.boundChange = "1";
select.addEventListener('change', () => { select.addEventListener("change", () => {
const selected = select.options[select.selectedIndex]; const selected = select.options[select.selectedIndex];
document.getElementById('emp-email').value = selected?.dataset?.email || ''; document.getElementById("emp-email").value = selected?.dataset?.email || "";
clearStatus('form'); clearStatus("form");
}); });
} }
function populateEmpSelect(select, employees) { function populateEmpSelect(select, employees) {
select.innerHTML = '<option value="">Select your name</option>'; select.innerHTML = '<option value="">Select your name</option>';
employees.forEach(emp => { employees.forEach((emp) => {
const opt = document.createElement('option'); const opt = document.createElement("option");
opt.value = emp.name; opt.value = emp.name;
opt.dataset.email = emp.email; opt.dataset.email = emp.email;
opt.textContent = emp.name; opt.textContent = emp.name;
@ -485,43 +535,55 @@ async function loadRoster() {
const res = await fetch(ROSTER_URL); const res = await fetch(ROSTER_URL);
const data = await res.json(); const data = await res.json();
const candidate = data.employees; 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; employees = candidate;
} else { } else {
employees = ROSTER; employees = ROSTER;
} }
} catch (e) {} } catch (e) {}
const select = document.getElementById('emp-name'); const select = document.getElementById("emp-name");
populateEmpSelect(select, employees); populateEmpSelect(select, employees);
} }
function showClosedOverlay() { function showClosedOverlay() {
const overlay = document.getElementById('closed-overlay'); const overlay = document.getElementById("closed-overlay");
overlay.classList.add('is-visible'); overlay.classList.add("is-visible");
overlay.style.display = 'flex'; overlay.style.display = "flex";
document.querySelectorAll('.qty-control button, .qty-control .qty').forEach(el => { el.disabled = true; }); document
const submitBtn = document.getElementById('submit-btn'); .querySelectorAll(".qty-control button, .qty-control .qty")
.forEach((el) => {
el.disabled = true;
});
const submitBtn = document.getElementById("submit-btn");
if (submitBtn) submitBtn.disabled = true; if (submitBtn) submitBtn.disabled = true;
if (formStatusData) startCountdown(formStatusData); if (formStatusData) startCountdown(formStatusData);
} }
function adminBypassClosed() { function adminBypassClosed() {
const overlay = document.getElementById('closed-overlay'); const overlay = document.getElementById("closed-overlay");
overlay.classList.remove('is-visible'); overlay.classList.remove("is-visible");
overlay.style.display = 'none'; overlay.style.display = "none";
document.querySelectorAll('.qty-control button').forEach(b => { b.disabled = false; }); document.querySelectorAll(".qty-control button").forEach((b) => {
const submitBtn = document.getElementById('submit-btn'); b.disabled = false;
});
const submitBtn = document.getElementById("submit-btn");
if (submitBtn) submitBtn.disabled = Object.keys(quantities).length === 0; if (submitBtn) submitBtn.disabled = Object.keys(quantities).length === 0;
if (!document.getElementById('admin-closed-banner')) { if (!document.getElementById("admin-closed-banner")) {
const banner = document.createElement('div'); const banner = document.createElement("div");
banner.id = 'admin-closed-banner'; banner.id = "admin-closed-banner";
banner.className = 'closed-banner'; banner.className = "closed-banner";
banner.textContent = 'Form is closed — admin override active'; banner.textContent = "Form is closed — admin override active";
const deadline = document.querySelector('.deadline'); const deadline = document.querySelector(".deadline");
if (deadline) deadline.parentNode.insertBefore(banner, deadline.nextSibling); 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 index = parseInt(card.dataset.mealIndex, 10);
const meal = MEALS[index]; const meal = MEALS[index];
if (meal) updateMealCard(card, meal, index); if (meal) updateMealCard(card, meal, index);
@ -532,17 +594,17 @@ function adminBypassClosed() {
function checkAdmin() { function checkAdmin() {
if (!ADMIN_URL) return Promise.resolve(); if (!ADMIN_URL) return Promise.resolve();
const headers = {}; const headers = {};
if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential; if (googleCredential) headers["Authorization"] = "Bearer " + googleCredential;
return fetch(ADMIN_URL, { headers }) return fetch(ADMIN_URL, { headers })
.then(r => r.ok ? r.json() : null) .then((r) => (r.ok ? r.json() : null))
.then(data => { .then((data) => {
if (data && data.weeks) { if (data && data.weeks) {
isAdmin = true; isAdmin = true;
window.isAdmin = true; window.isAdmin = true;
adminWeeks = data.weeks; adminWeeks = data.weeks;
window.adminWeeks = adminWeeks; window.adminWeeks = adminWeeks;
const ab = document.getElementById('admin-btn'); const ab = document.getElementById("admin-btn");
if (ab) ab.classList.remove('is-hidden'); if (ab) ab.classList.remove("is-hidden");
if (formClosed) adminBypassClosed(); if (formClosed) adminBypassClosed();
} else if (formClosed) { } else if (formClosed) {
showClosedOverlay(); showClosedOverlay();
@ -554,62 +616,71 @@ function checkAdmin() {
} }
function showAdmin() { function showAdmin() {
document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => { document.querySelectorAll("#app > :not(#admin-panel)").forEach((el) => {
if (el.id !== 'admin-panel') { if (el.id !== "admin-panel") {
el.dataset.prevHidden = el.classList.contains('is-hidden') ? '1' : ''; el.dataset.prevHidden = el.classList.contains("is-hidden") ? "1" : "";
el.classList.add('is-hidden'); el.classList.add("is-hidden");
} }
}); });
document.getElementById('admin-panel').classList.remove('is-hidden'); document.getElementById("admin-panel").classList.remove("is-hidden");
const footer = document.querySelector('.sticky-footer'); const footer = document.querySelector(".sticky-footer");
if (footer) footer.classList.add('is-hidden'); if (footer) footer.classList.add("is-hidden");
const select = document.getElementById('admin-week'); const select = document.getElementById("admin-week");
select.innerHTML = ''; select.innerHTML = "";
adminWeeks.forEach(w => { adminWeeks.forEach((w) => {
const opt = document.createElement('option'); const opt = document.createElement("option");
opt.value = w.week; 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); select.appendChild(opt);
}); });
if (adminWeeks.length > 0 && typeof loadWeekOrders === 'function') { if (adminWeeks.length > 0 && typeof loadWeekOrders === "function") {
loadWeekOrders(adminWeeks[0].week); loadWeekOrders(adminWeeks[0].week);
} }
} }
function hideAdmin() { function hideAdmin() {
document.getElementById('admin-panel').classList.add('is-hidden'); document.getElementById("admin-panel").classList.add("is-hidden");
document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => { document.querySelectorAll("#app > :not(#admin-panel)").forEach((el) => {
if (el.dataset.prevHidden === '1') el.classList.add('is-hidden'); if (el.dataset.prevHidden === "1") el.classList.add("is-hidden");
else el.classList.remove('is-hidden'); else el.classList.remove("is-hidden");
delete el.dataset.prevHidden; delete el.dataset.prevHidden;
}); });
const footer = document.querySelector('.sticky-footer'); const footer = document.querySelector(".sticky-footer");
if (footer) footer.classList.remove('is-hidden'); if (footer) footer.classList.remove("is-hidden");
} }
const easternFormatter = new Intl.DateTimeFormat('en-US', { const easternFormatter = new Intl.DateTimeFormat("en-US", {
timeZone: 'America/New_York', timeZone: "America/New_York",
year: 'numeric', year: "numeric",
month: '2-digit', month: "2-digit",
day: '2-digit', day: "2-digit",
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
second: '2-digit', second: "2-digit",
hourCycle: 'h23', hourCycle: "h23",
}); });
function getEasternParts(date) { function getEasternParts(date) {
const values = {}; const values = {};
easternFormatter.formatToParts(date).forEach(part => { easternFormatter.formatToParts(date).forEach((part) => {
if (part.type !== 'literal') values[part.type] = Number(part.value); if (part.type !== "literal") values[part.type] = Number(part.value);
}); });
return values; return values;
} }
function getEasternOffsetMs(date) { function getEasternOffsetMs(date) {
const parts = getEasternParts(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) { function startCountdown(data) {
@ -627,24 +698,36 @@ function updateCountdown() {
target = new Date(reopenAtMs); target = new Date(reopenAtMs);
} else { } else {
const etNow = getEasternParts(now); 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; const hour = etNow.hour;
let daysUntil = (1 - day + 7) % 7; let daysUntil = (1 - day + 7) % 7;
if (daysUntil === 0 && hour >= 8) daysUntil = 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))); target = new Date(targetEtMs - getEasternOffsetMs(new Date(targetEtMs)));
} }
let diff = Math.max(0, Math.floor((target - now) / 1000)); let diff = Math.max(0, Math.floor((target - now) / 1000));
const d = Math.floor(diff / 86400); diff %= 86400; const d = Math.floor(diff / 86400);
const h = Math.floor(diff / 3600); diff %= 3600; diff %= 86400;
const h = Math.floor(diff / 3600);
diff %= 3600;
const m = Math.floor(diff / 60); const m = Math.floor(diff / 60);
const s = diff % 60; const s = diff % 60;
const pad = n => String(n).padStart(2, '0'); const pad = (n) => String(n).padStart(2, "0");
document.getElementById('countdown').textContent = d + 'd ' + pad(h) + 'h ' + pad(m) + 'm ' + pad(s) + 's'; document.getElementById("countdown").textContent =
d + "d " + pad(h) + "h " + pad(m) + "m " + pad(s) + "s";
} }
async function checkFormStatus() { async function checkFormStatus() {
@ -652,12 +735,12 @@ async function checkFormStatus() {
try { try {
const res = await fetch(STATUS_URL); const res = await fetch(STATUS_URL);
const data = await res.json(); const data = await res.json();
if (data.status === 'closed') { if (data.status === "closed") {
formClosed = true; formClosed = true;
formStatusData = data; formStatusData = data;
if (isAdmin) { if (isAdmin) {
adminBypassClosed(); adminBypassClosed();
} else if (CONFIG.authMode !== 'google' || googleCredential) { } else if (CONFIG.authMode !== "google" || googleCredential) {
showClosedOverlay(); showClosedOverlay();
} }
} }
@ -666,7 +749,7 @@ async function checkFormStatus() {
function init() { function init() {
checkFormStatus(); checkFormStatus();
if (CONFIG.authMode === 'google') { if (CONFIG.authMode === "google") {
waitForGoogleAuth(); waitForGoogleAuth();
} else { } else {
loadRoster(); loadRoster();
@ -674,39 +757,40 @@ function init() {
} }
const tags = new Set(); const tags = new Set();
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t))); MEALS.forEach((m) => (m.dietary_tags || []).forEach((t) => tags.add(t)));
const cats = ['All Meals', ...Array.from(tags).sort()]; const cats = ["All Meals", ...Array.from(tags).sort()];
const filtersEl = document.getElementById('filters'); const filtersEl = document.getElementById("filters");
cats.forEach(cat => { cats.forEach((cat) => {
const btn = document.createElement('button'); const btn = document.createElement("button");
btn.type = 'button'; btn.type = "button";
const isActive = cat === 'All Meals'; const isActive = cat === "All Meals";
btn.className = 'btn btn-chip' + (isActive ? ' active' : ''); btn.className = "btn btn-chip" + (isActive ? " active" : "");
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); btn.setAttribute("aria-pressed", isActive ? "true" : "false");
btn.textContent = cat; btn.textContent = cat;
btn.addEventListener('click', () => { btn.addEventListener("click", () => {
filtersEl.querySelectorAll('button').forEach(b => { filtersEl.querySelectorAll("button").forEach((b) => {
b.classList.remove('active'); b.classList.remove("active");
b.setAttribute('aria-pressed', 'false'); b.setAttribute("aria-pressed", "false");
}); });
btn.classList.add('active'); btn.classList.add("active");
btn.setAttribute('aria-pressed', 'true'); btn.setAttribute("aria-pressed", "true");
renderMeals(); renderMeals();
}); });
filtersEl.appendChild(btn); filtersEl.appendChild(btn);
}); });
document.getElementById('search').addEventListener('input', renderMeals); document.getElementById("search").addEventListener("input", renderMeals);
document.getElementById('submit-btn').addEventListener('click', submitOrder); document.getElementById("submit-btn").addEventListener("click", submitOrder);
const adminBtn = document.getElementById('admin-btn'); const adminBtn = document.getElementById("admin-btn");
if (adminBtn) adminBtn.addEventListener('click', showAdmin); if (adminBtn) adminBtn.addEventListener("click", showAdmin);
const signOutBtn = document.getElementById('sign-out-btn'); const signOutBtn = document.getElementById("sign-out-btn");
if (signOutBtn) signOutBtn.addEventListener('click', signOut); if (signOutBtn) signOutBtn.addEventListener("click", signOut);
const successBackBtn = document.getElementById('success-back-btn'); const successBackBtn = document.getElementById("success-back-btn");
if (successBackBtn) successBackBtn.addEventListener('click', () => location.reload()); if (successBackBtn)
successBackBtn.addEventListener("click", () => location.reload());
bindMealsListDelegation(); bindMealsListDelegation();
renderMeals(); renderMeals();