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:
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

1
.gitignore vendored
View file

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

View file

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

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 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 = '<option value="">Select your name</option>';
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();