Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 2594cd475f Keep POS scanner camera preview live 2026-06-15 11:06:19 +02:00
Jeppe B 74b808ada0 Merge pull request #133 from copenhagentruckwash/fix/frontend-e2e-pos-camera-live-feed
Fix frontend E2E browser env and POS camera preview
2026-06-15 10:07:47 +02:00
Jeppe Bundgaard 4fdbd22879 Stabilize remaining full e2e slices 2026-06-15 01:13:46 +02:00
Jeppe Bundgaard 0392672a2d Stabilize Firefox desktop and tablet e2e slices 2026-06-14 23:54:28 +02:00
Jeppe Bundgaard 3d229a2de2 Stabilize WebKit desktop POS visuals 2026-06-14 22:33:05 +02:00
Jeppe Bundgaard 7e090ab405 Stabilize admin mobile e2e slices 2026-06-14 21:29:22 +02:00
Jeppe Bundgaard b8c3758714 Separate PR and manual test concurrency 2026-06-14 20:37:05 +02:00
Jeppe Bundgaard 298fc0e04b Target Docker-capable e2e runners 2026-06-14 20:26:59 +02:00
Jeppe Bundgaard 1c1bffaee4 Install Node before e2e port probe 2026-06-14 19:49:02 +02:00
Jeppe Bundgaard 14506e545a Stabilize frontend e2e CI runners 2026-06-14 19:40:24 +02:00
Jeppe Bundgaard 89dff75416 Fix frontend e2e browser env and POS camera preview 2026-06-14 18:18:14 +02:00
Jeppe B 8a790582f4 Merge pull request #132 from copenhagentruckwash/codex/optimize-scanner-lpr-frontend
[codex] optimize scanner LPR frontend
2026-06-12 22:08:42 +02:00
Jeppe Bundgaard e6aff8f512 optimize scanner lpr frontend 2026-06-12 21:42:36 +02:00
Jeppe B 1c277d1944 Merge pull request #131 from copenhagentruckwash/fix/pwa-selfserve-speed
[codex] Improve PWA request queueing
2026-06-12 13:28:53 +02:00
Jeppe Bundgaard 26cc166282 Stop mobile camera scan queue growth 2026-06-12 13:09:34 +02:00
Jeppe Bundgaard 30e878979c Reduce POS request queue blocking 2026-06-12 12:27:05 +02:00
Jeppe Bundgaard 0f335e0984 Fix self-serve hardware request queueing 2026-06-12 11:48:51 +02:00
Jeppe B 4083c05b72 Merge pull request #130 from copenhagentruckwash/fix-issues-on-webkit-browsers
Fix WebKit async UI timing
2026-06-12 11:48:46 +02:00
Jeppe B 7b4d24a212 Fix WebKit async UI timing 2026-06-12 11:32:44 +02:00
Jeppe Bundgaard 02c31c8bb0 Fix i18n catalog compatibility 2026-06-11 21:45:48 +02:00
Jeppe Bundgaard 6c0278c6e9 Enhance local data reset dialog functionality and tests 2026-06-11 21:26:12 +02:00
Jeppe Bundgaard 41e0320ce1 Add local data reset dialog and enhance footer navigation with long press functionality 2026-06-11 21:05:36 +02:00
Jeppe Bundgaard d97b02bace Add indexedDB management and local data reset confirmation dialog 2026-06-11 21:02:26 +02:00
Jeppe Bundgaard 11bbe953f3 Add version display in viewport for mobile users 2026-06-11 19:25:58 +02:00
Jeppe Bundgaard ecdd895a5a Refactor notification display logic in SelfServeTasksStep component 2026-06-11 18:49:17 +02:00
Jeppe B 07bb815754 Merge pull request #129 from copenhagentruckwash/test/self-serve-logic-condition-coverage
Add self-serve condition evaluation coverage
2026-06-11 18:48:31 +02:00
Jeppe Bundgaard 2a64fcdf19 Stabilize self-serve mobile e2e checks 2026-06-11 18:21:35 +02:00
Jeppe Bundgaard f8b7bda74a Guard self-serve active wash restore after unmount 2026-06-11 18:04:42 +02:00
Jeppe Bundgaard 5a0fc5f36b Add self-serve condition evaluation coverage 2026-06-11 17:57:10 +02:00
Jeppe B 9e378b32ac Merge pull request #128 from copenhagentruckwash/fix/mobile-gps-department-selection
Fix mobile GPS department auto-selection
2026-06-11 17:53:38 +02:00
Jeppe Bundgaard 968587d847 Fix mobile GPS department auto-selection 2026-06-11 17:32:28 +02:00
Jeppe B fa809ccd0b Merge pull request #126 from copenhagentruckwash/fix/self-serve-path-editor-case-limit
Fix self-serve path editor case limit
2026-06-11 16:57:35 +02:00
Jeppe Bundgaard a8800a3be1 Avoid duplicate PR branch push workflows 2026-06-11 16:37:51 +02:00
Jeppe Bundgaard 1f23b58d1e Isolate frontend E2E workflow ports 2026-06-11 16:36:36 +02:00
Jeppe B c6b266b0de Merge pull request #127 from copenhagentruckwash/codex/slack-test-webhook
Add Slack test webhook button
2026-06-11 16:31:09 +02:00
Jeppe Bundgaard 3eba32c235 Avoid setup-node cache restore timeouts 2026-06-11 16:06:02 +02:00
Jeppe B ec76ae426e Merge pull request #125 from copenhagentruckwash/fix/mobile-pos-product-27-note
Fix mobile POS product 27 completion
2026-06-11 15:39:35 +02:00
Jeppe Bundgaard e35d59c652 Use frontend runners for CI 2026-06-11 15:24:12 +02:00
Jeppe Bundgaard 73fc89557b Add Slack test webhook button 2026-06-11 15:06:43 +02:00
Jeppe Bundgaard be98715045 Fix self-serve path editor case limit 2026-06-11 14:46:23 +02:00
Jeppe Bundgaard 4392b7915f Fix mobile POS product 27 completion 2026-06-11 14:34:53 +02:00
Jeppe B b1fb92549c Merge pull request #124 from copenhagentruckwash/fix/full-e2e-tests-20260611
Fix full e2e regressions
2026-06-11 14:22:32 +02:00
Jeppe Bundgaard 9bba666f3a Harden mobile e2e route readiness waits 2026-06-11 13:51:39 +02:00
Jeppe Bundgaard dcf0fd61ba Avoid sudo in Playwright browser fallback 2026-06-11 13:24:22 +02:00
Jeppe Bundgaard 3d02146911 Stabilize full e2e CI matrix 2026-06-11 13:13:06 +02:00
Jeppe Bundgaard 10bc822233 Fix full e2e regressions 2026-06-11 10:13:58 +02:00
Jeppe B baac1a243a Merge pull request #123 from copenhagentruckwash/fix/self-serve-machine-tasks-after-start
Keep machine tasks visible after start summary refresh
2026-06-10 22:11:00 +02:00
Jeppe Bundgaard 7c233d7c03 Keep machine tasks visible after start summary refresh 2026-06-10 20:53:02 +02:00
Jeppe B 7c19dbddf0 Merge pull request #122 from copenhagentruckwash/fix/self-serve-start-wash-type
Honor wash type in self-serve start command
2026-06-10 20:15:58 +02:00
Jeppe Bundgaard f4b248573a Honor wash type in self-serve start command 2026-06-10 19:18:24 +02:00
Jeppe B 14e454e9d9 Merge pull request #121 from copenhagentruckwash/fix-github-runner-test-failures-8jso54
Robust Playwright browser install, e2e/unit test reliability fixes, and session bootstrap improvements
2026-06-10 16:08:11 +02:00
Jeppe B ebab7d8804 Merge pull request #120 from copenhagentruckwash/fix-github-runner-test-failures
Stabilize flaky Playwright e2e tests: ensure session bootstrap, add test hooks and increase timeouts
2026-06-10 16:07:53 +02:00
Jeppe B 78a3fb1879 test: stabilize reference autocomplete unit cleanup 2026-06-10 16:06:36 +02:00
Jeppe B d42b58c4fe test: harden flaky chromium smoke specs 2026-06-10 15:27:14 +02:00
Jeppe B 724ad8e7a1 ci: stabilize Playwright runner installs and smoke tests 2026-06-10 02:57:02 +02:00
Jeppe B 2fe50729a5 ci: fall back for unsupported Playwright deps install 2026-06-10 00:52:14 +02:00
Jeppe B eb4eee1aef test: stabilize github runner e2e checks 2026-06-09 23:02:28 +02:00
Jeppe Bundgaard db972e2923 test: refresh invoicing flag counts from scoped flags 2026-06-09 16:03:23 +02:00
Jeppe Bundgaard 00817925e4 Add path case answer mapping and matching logic for improved path verification 2026-06-09 15:56:59 +02:00
Jeppe Bundgaard 347ab3c811 ci: allow e2e jobs on linux runner pool 2026-06-09 15:53:58 +02:00
Jeppe Bundgaard a3d05b15db test: stabilize edge gateway route readiness 2026-06-09 15:46:48 +02:00
Jeppe Bundgaard 9093d0d601 test: isolate request queue shortcut listeners 2026-06-09 15:20:15 +02:00
Jeppe Bundgaard 8c57b98627 Fix i18n CI runtime menu checks 2026-06-09 15:14:22 +02:00
Jeppe Bundgaard 4b99751c0e test: reset POS department route in unit setup 2026-06-09 15:13:06 +02:00
Jeppe Bundgaard 66f4331b4e Refactor machine allowance logic in self-serve studio and remove unused controls 2026-06-09 14:52:20 +02:00
Jeppe Bundgaard e1a824d565 Fix path editor answer row layout 2026-06-09 14:18:15 +02:00
Jeppe Bundgaard 883a88a551 ci: split playwright pr e2e matrix 2026-06-09 14:07:25 +02:00
Jeppe Bundgaard a328bc3767 Filter path editor to machine start cases 2026-06-09 13:57:33 +02:00
Jeppe Bundgaard 255447f788 Hide placeholder departments in admin navigation 2026-06-09 13:34:08 +02:00
Jeppe Bundgaard b3d57ca717 test: cover collected invoice move checks 2026-06-09 13:24:23 +02:00
Jeppe Bundgaard 45d21c4372 fix: restore self-serve lane step hook 2026-06-09 13:19:22 +02:00
Jeppe Bundgaard 7f7b5ae082 Add Swedish invoice collection move translation 2026-06-09 13:17:55 +02:00
Jeppe Bundgaard 028d8f1fc8 Add move to customer functionality for invoice collections in multiple languages 2026-06-09 13:16:26 +02:00
Jeppe Bundgaard 8d01ca1363 Implement move collected invoice to customer feature with API integration and tests 2026-06-09 13:16:05 +02:00
Jeppe Bundgaard a36403d279 Refactor self-serve fetch logic to use refs for state management; implement abort controller for active requests 2026-06-09 13:07:21 +02:00
Jeppe Bundgaard a78ad69c0c Add selfserve dynamic image size selector 2026-06-09 12:46:49 +02:00
Jeppe Bundgaard 22c856afb6 test: stabilize superuser firefox e2e 2026-06-09 12:34:11 +02:00
Jeppe Bundgaard 71a073ebf9 Add hidden frontend maintenance refresh 2026-06-09 12:32:58 +02:00
Jeppe Bundgaard f709cac95e Test wash type service sync 2026-06-09 12:13:35 +02:00
Jeppe Bundgaard 27867e48df test: update my wash start unit stubs 2026-06-09 11:50:21 +02:00
Jeppe Bundgaard f1a7e1dfcd test: stabilize POS draft full slice 2026-06-09 11:29:13 +02:00
Jeppe Bundgaard 31e9566f2d Refactor API route handling in tests to use centralized path patterns; enhance error handling in self-serve components 2026-06-09 10:52:20 +02:00
Jeppe Bundgaard f7f749e230 Fix PWA install scope 2026-06-08 20:56:32 +02:00
Jeppe Bundgaard f5b7da7b0a test: skip WebKit mobile invoice monitor header 2026-06-08 20:42:30 +02:00
Jeppe Bundgaard 2311188b13 test: align self-serve stop failure e2e 2026-06-08 20:25:01 +02:00
Jeppe Bundgaard 25637b52d9 test: update self-serve completion contract 2026-06-08 20:20:46 +02:00
Jeppe Bundgaard fddcc86cd6 fix: preserve self-serve stop failure banner 2026-06-08 20:17:44 +02:00
Jeppe Bundgaard c1b2be7d70 Enhance task ID handling in updateLaneAllowedServices; improve error handling in wash process 2026-06-08 19:56:02 +02:00
Jeppe Bundgaard 0a7b3a36aa test: stabilize release and department e2e mocks 2026-06-08 19:51:17 +02:00
Jeppe Bundgaard 57f102de71 test: skip desktop-only superuser specs on compact projects 2026-06-08 19:47:54 +02:00
Jeppe Bundgaard 6b22887a90 Remove guidance text for unavailable machine wash in self-serve flow 2026-06-08 18:58:33 +02:00
Jeppe Bundgaard 3b3f212ceb ci: use polling for Playwright Vite server 2026-06-08 18:54:25 +02:00
Jeppe Bundgaard 1a11e31836 ci: raise Playwright file descriptor limit 2026-06-08 18:46:56 +02:00
Jeppe Bundgaard d4b69c3990 test: align release runtime inspector privileges 2026-06-08 18:38:49 +02:00
Jeppe Bundgaard 4cbccd533a fix: stabilize POS route and action menu tests 2026-06-08 18:09:51 +02:00
Jeppe Bundgaard a9bc3ab0fc Fix POS booking match recursion 2026-06-08 17:29:06 +02:00
Jeppe Bundgaard d9f929df04 fix: restore Slack i18n compatibility 2026-06-08 17:25:56 +02:00
Jeppe Bundgaard bc154a4e6d Keep release wait after auto sync gateway timeout 2026-06-08 17:12:34 +02:00
Jeppe Bundgaard 7d48e40e05 Enhance release workflow with current commit check and enable concurrency cancellation 2026-06-08 17:02:42 +02:00
Jeppe Bundgaard 47af253bf8 Enhance self-serve logic to ignore stale answer sync responses and implement mutation request tracking 2026-06-08 16:53:03 +02:00
Jeppe Bundgaard e126dfccf9 Enhance accessibility and linting configuration
- Add tabindex attributes to input fields for better keyboard navigation
- Introduce ESLint configuration for improved code quality and consistency
- Update package.json to include linting scripts and dependencies
- Add tests for desktop tab navigation in POS flow
2026-06-08 16:35:56 +02:00
Jeppe Bundgaard be5191825d fix: distinguish Slack config permission errors 2026-06-08 16:33:52 +02:00
Jeppe Bundgaard 68cf9c4c06 Continue release wait after auto sync timeout 2026-06-08 16:25:24 +02:00
Jeppe Bundgaard e46b7ae58d Request release auto sync before artifact wait 2026-06-08 16:18:45 +02:00
Jeppe Bundgaard 3d81fb8dd7 Fix release verification for external build ids 2026-06-08 15:59:39 +02:00
Jeppe Bundgaard ff983dca60 Trigger frontend release 2026-06-08 15:48:21 +02:00
Jeppe Bundgaard fb0fceb6c4 Add booking count permission checks and handle API response failures gracefully 2026-06-08 15:26:57 +02:00
Jeppe Bundgaard 9591afb9a2 Add booking count permission checks and handle API response failures gracefully 2026-06-08 13:41:57 +02:00
Jeppe Bundgaard 0afde8ac8e Add support for webmanifest files in .htaccess and nginx configuration 2026-06-08 13:11:21 +02:00
Jeppe Bundgaard 4a83486b30 Update Danish translations for various UI elements and API keys 2026-06-08 12:51:10 +02:00
Jeppe Bundgaard a3ceac1636 Add Slack configuration module with customer registration webhook settings 2026-06-08 12:43:02 +02:00
Jeppe Bundgaard 8666d17a42 Add superuser new customer email notification settings 2026-06-08 12:19:32 +02:00
gold-dev 96d13b2400 Feat: Produktivitet overview 2026-06-08 11:32:30 +03:00
gold-dev 12fc1f80cc Feat: Rename ”Charts” til ”Grafer” 2026-06-08 10:50:37 +03:00
Jeppe B 2dfe4e9b37 Merge pull request #119 from copenhagentruckwash/fix-github-runner-test-failures
Fix self-serve wash progress actions, guided-wash emits and guest fallback UI; install Playwright deps for CI
2026-06-08 09:26:26 +02:00
Jeppe B 46c634e5a7 Fix self-serve wash test regressions 2026-06-08 09:13:24 +02:00
Jeppe Bundgaard 880680d7f8 Add reusable ErrorBanner component and integrate into MyWashStart 2026-06-07 22:07:56 +02:00
Jeppe Bundgaard 7d5ed772c2 Avoid empty MyWash lane service sync 2026-06-04 07:40:33 +02:00
Jeppe Bundgaard 83fbcdeaa8 Refresh MyWash eligibility before service sync 2026-06-04 07:31:41 +02:00
Jeppe Bundgaard 55a761d3a5 Publish MyWash tasks before hydration 2026-06-04 07:27:12 +02:00
Jeppe Bundgaard abe8e7750f Preserve MyWash machine services during sync 2026-06-04 07:21:07 +02:00
Jeppe Bundgaard aeef75b831 Clear stale MyWash summary after preview 2026-06-04 07:13:00 +02:00
Jeppe Bundgaard d6356d0244 Stop MyWash eligibility refresh after completion 2026-06-04 06:35:26 +02:00
Jeppe Bundgaard bbf2aeb95f Keep MyWash completion visible after stop 2026-06-04 06:05:41 +02:00
Jeppe Bundgaard 3a7d78ae05 Serve release-prefixed index shell 2026-06-04 04:08:33 +02:00
Jeppe Bundgaard 2a2f752692 Refresh recent completion marker during startup 2026-06-04 03:51:43 +02:00
Jeppe Bundgaard 8883bf8a07 Seed recently completed wash state before startup awaits 2026-06-04 03:44:29 +02:00
Jeppe Bundgaard 6ec80e2796 Recover recently completed self-serve screen 2026-06-04 03:29:55 +02:00
Jeppe Bundgaard 849a7b77cf Adopt resolved self-serve vehicle type 2026-06-04 03:16:59 +02:00
Jeppe Bundgaard 36ccd0c381 Keep completed wash fallback visible 2026-06-04 03:01:09 +02:00
Jeppe Bundgaard 58e7c8d6c9 Keep completed wash after lane refresh 2026-06-04 02:44:48 +02:00
Jeppe Bundgaard d04fc8cc0f Keep completed self-serve wash visible 2026-06-04 02:29:16 +02:00
Jeppe Bundgaard 339a1b1452 Recover completed wash after stop timeout 2026-06-04 02:15:15 +02:00
Jeppe Bundgaard 2d2d63d09e Merge remote-tracking branch 'origin/master' 2026-06-04 02:02:26 +02:00
Jeppe B 8241f7fd2f Treat unoccupied stop invoice errors as completed washes 2026-06-04 02:01:35 +02:00
Jeppe Bundgaard 985571af87 Treat unoccupied stop invoice errors as completed washes 2026-06-04 01:55:35 +02:00
Jeppe Bundgaard 82e1e4a466 Ignore preview-only active wash reservations 2026-06-04 01:21:25 +02:00
Jeppe Bundgaard 9a7f7bbb78 Prevent preview-only self-serve sessions from auto-restoring 2026-06-04 01:08:04 +02:00
Jeppe Bundgaard 359f29b75e Keep self-serve preview questions visible during summary load 2026-06-04 00:45:20 +02:00
Jeppe B ceced7cbaa Show MyWash completion when Buefy hides step panel 2026-06-04 00:21:33 +02:00
Jeppe Bundgaard 6b5cd00b10 Show completed wash outside step panels 2026-06-04 00:10:36 +02:00
Jeppe Bundgaard 02eae47d34 Refactor toggle-task event emission in SelfServeTaskList component 2026-06-04 00:00:28 +02:00
Jeppe Bundgaard 3f01d38676 Enhance self-serve image loading with preload and error handling 2026-06-03 23:53:54 +02:00
Jeppe Bundgaard 390a29ef55 Merge remote-tracking branch 'origin/master' 2026-06-03 23:34:05 +02:00
Jeppe Bundgaard b7f5786747 Add vehicle type selection logic for departments without product allow-list 2026-06-03 23:33:54 +02:00
Jeppe B 814c535757 Use relative release manifest asset paths 2026-06-03 23:13:26 +02:00
Jeppe Bundgaard ed0e27e4fd Use relative release manifest asset paths 2026-06-03 23:07:17 +02:00
Jeppe Bundgaard 659df480d7 Merge remote-tracking branch 'origin/master' 2026-06-03 23:04:43 +02:00
Jeppe B d0c45f6180 Fix release frontend origin routing 2026-06-03 22:45:32 +02:00
Jeppe Bundgaard 5a20461bd4 Fix release frontend origin routing 2026-06-03 22:40:49 +02:00
Jeppe Bundgaard 849f52fb8e Refactor error message display and update test descriptions for clarity 2026-06-03 22:16:27 +02:00
Jeppe Bundgaard da0c699b88 Add self-serve session handling and lane selection normalization 2026-06-03 22:07:47 +02:00
Jeppe Bundgaard af5514c982 Implement dynamic base path handling for frontend assets and update routing logic 2026-06-03 22:00:18 +02:00
Jeppe Bundgaard cc689e80a9 Use release base for frontend HTML assets 2026-06-03 21:45:25 +02:00
Jeppe Bundgaard 3b51374678 Add dynamic release frontend base path handling and update static asset references 2026-06-03 21:44:27 +02:00
Jeppe Bundgaard f07569db98 Allow active self-serve lane selection 2026-06-03 21:32:58 +02:00
Jeppe Bundgaard c5fcb90683 Include emitted chunks in release manifest 2026-06-03 21:12:08 +02:00
Jeppe Bundgaard 270f87dee1 Split release smoke static and app targets 2026-06-03 21:03:03 +02:00
Jeppe Bundgaard 765b8f3b35 Fix self-serve entry regressions 2026-06-03 20:51:06 +02:00
Jeppe Bundgaard 3ff695899c Restore self-serve task phase from active sessions 2026-06-03 20:32:40 +02:00
Jeppe Bundgaard be35baa838 Refactor self-serve registration field for improved layout and accessibility 2026-06-03 20:06:31 +02:00
Jeppe B 470d4d7314 Normalize release bootstrap cleanup 2026-06-03 19:38:54 +02:00
Jeppe B 0976608a25 Refresh release bootstrap asset hash 2026-06-03 19:34:48 +02:00
Jeppe B e7f4dfa07e Trigger frontend release after asset mismatch 2026-06-03 19:23:01 +02:00
Jeppe B 2c68f335c6 Trigger frontend release for self-serve vehicle fix 2026-06-03 18:08:25 +02:00
Jeppe Bundgaard cf29589e29 Fix self-serve vehicle step progression 2026-06-03 17:51:46 +02:00
Jeppe Bundgaard 05cabd8edc Add margin and padding adjustments to self-serve task checkbox 2026-06-02 20:09:18 +02:00
Jeppe Bundgaard 2f4ecba51a Merge remote-tracking branch 'origin/master' 2026-06-02 20:04:11 +02:00
Jeppe Bundgaard 74a7c9f23f Refactor self-serve task list to improve task title handling and remove unnecessary description checks 2026-06-02 20:03:55 +02:00
Jeppe B b075a717f7 Cover hidden customer number self-serve flow 2026-06-02 19:59:30 +02:00
Jeppe B a3dcb888d3 Cover self-serve task title presentation 2026-06-02 19:59:12 +02:00
Jeppe B 02706af508 Allow QR self-serve vehicle step without customer field 2026-06-02 19:58:50 +02:00
Jeppe B 1f2c007776 Tighten self-serve task presentation 2026-06-02 19:58:28 +02:00
Jeppe Bundgaard 42dfdfef13 Add mocks for department daily report components and update Vitest configuration for SCSS 2026-06-02 17:54:37 +02:00
Jeppe B b40bf0b9e1 Merge pull request #110 from copenhagentruckwash/create-playwright-test-suite-for-mywashstart
Add mocked Playwright tests for MyWashStart flow and improve answer-sync & department fallback
2026-06-02 13:52:00 +02:00
Jeppe B cc15a9c468 Merge branch 'master' into create-playwright-test-suite-for-mywashstart 2026-06-02 13:51:52 +02:00
Jeppe B 5d8b1eecb4 Merge pull request #111 from copenhagentruckwash/create-playwright-test-suite-for-mywashstart-6chtnh
Add mocked Playwright tests for MyWashStart flow and improve answer-sync & department fallback
2026-06-02 13:50:32 +02:00
Jeppe B 093d82d762 Merge branch 'master' into create-playwright-test-suite-for-mywashstart-6chtnh 2026-06-02 13:50:24 +02:00
Jeppe B 686577c209 Merge pull request #114 from copenhagentruckwash/update-mywashstart.vue-with-timeout-handling
Fix delayed active wash restore cleanup
2026-06-02 13:48:47 +02:00
Jeppe B c2b333a78b Merge branch 'master' into update-mywashstart.vue-with-timeout-handling 2026-06-02 13:48:33 +02:00
Jeppe B cef8b8bf0f Merge pull request #115 from copenhagentruckwash/update-userdashboard-to-inspect-lane-readiness
Guard wash entry self-serve CTA
2026-06-02 13:47:39 +02:00
Jeppe B 62a46710ba Merge branch 'master' into update-userdashboard-to-inspect-lane-readiness 2026-06-02 13:47:31 +02:00
Jeppe B a3e6feb07e Merge pull request #113 from copenhagentruckwash/refactor-self-serve-data-loading-with-fetch-scheduler
Debounce self-serve wash data fetches
2026-06-02 13:43:40 +02:00
Jeppe B 2ad093d4f1 Merge pull request #112 from copenhagentruckwash/update-mywashstart.vue-for-answer-sync-state
Block wash question flow on failed answer sync
2026-06-02 13:43:22 +02:00
Jeppe B 6017d576fc Merge pull request #116 from copenhagentruckwash/update-wash-department-handling-and-ui
Handle self-serve wash when geolocation is unavailable
2026-06-02 13:42:51 +02:00
Jeppe B 08ac38dc87 Merge pull request #117 from copenhagentruckwash/add-and-extend-unit-and-e2e-tests
Add production-ready tests for user self-serve wash start (unit + mocked e2e)
2026-06-02 13:42:36 +02:00
Jeppe B 3960daa556 Add production wash start test matrix 2026-06-02 13:42:22 +02:00
Jeppe B 395cbd87dd Handle self-serve wash without geolocation 2026-06-02 13:42:16 +02:00
Jeppe B 23429c047f Guard wash entry self-serve CTA 2026-06-02 13:42:09 +02:00
Jeppe B af68748c16 Fix active wash restore timeout cleanup 2026-06-02 13:42:02 +02:00
Jeppe B 18ab007f87 Debounce self-serve wash data fetches 2026-06-02 13:41:55 +02:00
Jeppe B 63942045be Block wash question flow on failed answer sync 2026-06-02 13:41:51 +02:00
Jeppe B a4cdc6e4f3 Add mocked MyWashStart flow coverage 2026-06-02 13:41:44 +02:00
Jeppe B 07c611bdbe Add mocked MyWashStart flow coverage 2026-06-02 13:41:32 +02:00
Jeppe Bundgaard 5170139a32 Switch CI to self-hosted runners
Updated all GitHub Actions workflows to use self-hosted runners instead of `ubuntu-latest`. This change ensures better control over the CI environment and aligns with internal infrastructure requirements.
2026-06-02 10:36:18 +02:00
Jeppe Bundgaard 90952a68b7 Refactor SubusersTable to use ActionSettingsWheelButton for row actions 2026-06-02 09:23:49 +02:00
Jeppe Bundgaard 1cc14d7b41 Merge remote-tracking branch 'origin/master' 2026-06-02 08:36:42 +02:00
Jeppe B aa4d6576f7 Merge pull request #108 from copenhagentruckwash/fix-page-crash-issues
Fix wash start recovery from API failures
2026-06-02 08:35:28 +02:00
Jeppe B 2a46984f9c Merge pull request #109 from copenhagentruckwash/fix-playwright-exit-code-error
Stabilize release sidebar E2E coverage
2026-06-02 08:34:47 +02:00
Jeppe Bundgaard 4a9c721c55 Remove debug message for beta frontend in build output 2026-06-02 08:12:50 +02:00
Jeppe B 7be9995c87 Stabilize release sidebar e2e coverage 2026-06-02 08:10:51 +02:00
Jeppe B 85529de3f8 Fix wash start recovery from API failures 2026-06-02 08:07:03 +02:00
Jeppe B 114ca9d956 Merge pull request #107 from copenhagentruckwash/investigate-and-fix-failing-tests
Resolve failing frontend CI: fix merge conflicts, restore edge cache & tests
2026-06-02 07:15:41 +02:00
Jeppe B 29f858685a Fix self-serve wash smoke restore fixture 2026-06-02 07:15:26 +02:00
Jeppe B 4112faf682 Fix edge gateway broker config e2e 2026-06-02 02:38:37 +02:00
Jeppe B a13b73c414 Harden edge gateway service parsing 2026-06-02 02:06:32 +02:00
Jeppe B 667e93af8f Resolve failing frontend checks 2026-06-02 01:13:28 +02:00
Jeppe B 54a888026e Merge pull request #104 from copenhagentruckwash/propose-fix-for-price-tampering-vulnerability
Fix trusted booking prices in POS hydration
2026-06-02 00:48:56 +02:00
Jeppe B a0fb02baa1 Merge pull request #106 from copenhagentruckwash/fix-booking-delete-access-permission
Fix booking delete action visibility
2026-06-02 00:47:38 +02:00
Jeppe B c445b932db Fix booking delete action visibility 2026-06-02 00:47:19 +02:00
copilot-swe-agent[bot] ffccb1683b Merge origin/master and resolve POS pricing conflict 2026-06-01 22:44:56 +00:00
Jeppe B 0d590fc9d6 Merge pull request #105 from copenhagentruckwash/fix-postinstall-script-vulnerability
Remove unsafe postinstall hook
2026-06-02 00:40:46 +02:00
Jeppe B 1d4910f947 Remove unsafe postinstall hook 2026-06-02 00:40:34 +02:00
Jeppe B 7e457ed961 Fix trusted booking prices in POS hydration 2026-06-02 00:39:55 +02:00
Jeppe B 34722c6985 Merge pull request #103 from copenhagentruckwash/fix-vulnerability-in-pos-order-pricing
Fix POS booking price hydration
2026-06-02 00:39:37 +02:00
Jeppe B c5a13b50ab Fix POS booking price hydration 2026-06-02 00:39:15 +02:00
Jeppe B 387bd50281 Merge pull request #102 from copenhagentruckwash/fix-unsandboxed-blob-attachment-previews
Sanitize and sandbox order attachment previews to prevent XSS
2026-06-02 00:38:15 +02:00
Jeppe B 81016fd898 Fix unsafe order attachment previews 2026-06-02 00:38:03 +02:00
Jeppe B 39fc1612ac Merge pull request #101 from copenhagentruckwash/fix-duplicate-stripe-invoice-submission-issue
Prevent duplicate Stripe invoice queue submissions
2026-06-02 00:30:18 +02:00
Jeppe B 7ab12a3fff Prevent duplicate Stripe invoice queue submissions 2026-06-02 00:29:53 +02:00
Jeppe B b4d869c469 Merge pull request #100 from copenhagentruckwash/fix-autosave-to-prevent-infinite-retry-loop
Fix autosave failed-save retry loop
2026-06-02 00:28:52 +02:00
Jeppe B 799b667828 Fix autosave failed-save retry loop 2026-06-02 00:28:39 +02:00
Jeppe B ae3513edf3 Merge pull request #99 from copenhagentruckwash/fix-repeated-booking-prefetch-flooding-api
Avoid duplicate booking prefetch by honoring exact-plate cache
2026-06-02 00:27:37 +02:00
Jeppe B 6db1861b78 Fix repeated booking prefetch requests 2026-06-02 00:27:25 +02:00
Jeppe B a8e839ba18 Merge pull request #98 from copenhagentruckwash/fix-mobile-pos-orders-completion-issue
Fix mobile POS order completion
2026-06-02 00:27:07 +02:00
Jeppe B b224cf67d1 Fix mobile POS order completion 2026-06-02 00:26:53 +02:00
Jeppe B 222237ab34 Merge pull request #96 from copenhagentruckwash/fix-edge-gateway-localstorage-vulnerability
Harden edge gateway workspace cache
2026-06-02 00:16:07 +02:00
copilot-swe-agent[bot] 433cfe521e Planning merge conflict resolution 2026-06-01 22:14:58 +00:00
Jeppe B 1e4a776fa5 Merge pull request #97 from copenhagentruckwash/fix-auto-selection-of-customer-suggestions
Fix stale POS customer suggestion auto-selection
2026-06-02 00:13:59 +02:00
Jeppe B e88a424a7d Fix stale POS customer suggestion auto-selection 2026-06-02 00:13:46 +02:00
Jeppe B 9450c7902d Harden edge gateway workspace cache 2026-06-02 00:08:20 +02:00
Jeppe B 75b5883afe Merge pull request #95 from copenhagentruckwash/fix-stripe-email-invoice-payment-misclassification
Fix Stripe email invoice paid validation
2026-06-02 00:07:49 +02:00
Jeppe B c26f0184d9 Fix Stripe email invoice paid validation 2026-06-02 00:07:35 +02:00
Jeppe B cb74b4bbe7 Merge pull request #93 from copenhagentruckwash/propose-fix-for-cached-gateway-details-leak
Scope edge gateway cache to session and prevent auth-blind cached fallbacks
2026-06-01 23:48:26 +02:00
Jeppe B 326eca656f Fix edge gateway cache authorization fallback 2026-06-01 23:48:17 +02:00
Jeppe B f0a5058b68 Merge pull request #92 from copenhagentruckwash/propose-fix-for-self-hosted-ci-vulnerability
Use GitHub-hosted runners for tests workflow
2026-06-01 23:46:15 +02:00
copilot-swe-agent[bot] d39cd36995 Merge remote-tracking branch 'origin/master' into propose-fix-for-self-hosted-ci-vulnerability
# Conflicts:
#	.github/workflows/tests.yml
2026-06-01 21:43:04 +00:00
Jeppe B 0548018b5c Use GitHub-hosted runners for tests workflow 2026-06-01 23:40:30 +02:00
Jeppe B 699e10856e Merge pull request #91 from copenhagentruckwash/propose-fix-for-self-hosted-runner-vulnerability
Fix CI runner exposure for pull requests
2026-06-01 23:40:09 +02:00
Jeppe B 36b399cb2f Fix CI runner exposure for pull requests 2026-06-01 23:40:00 +02:00
Jeppe B f613d58727 Merge pull request #90 from copenhagentruckwash/propose-fix-for-product-catalog-vulnerability
Restrict vehicle type product edits to superusers
2026-06-01 23:35:14 +02:00
Jeppe B feccfada77 Restrict vehicle type product edits to superusers 2026-06-01 23:35:04 +02:00
Jeppe B 4127e66114 Merge pull request #89 from copenhagentruckwash/fix-unvalidated-attachment-download-links
Validate self-serve attachment download links
2026-06-01 23:34:45 +02:00
Jeppe B b2a9b59399 Validate self-serve attachment download links 2026-06-01 23:34:34 +02:00
Jeppe B 6ffed9c2f2 Merge pull request #88 from copenhagentruckwash/fix-lane-toggle-guard-permission-issue
Fix lane toggle permission guards
2026-06-01 23:34:14 +02:00
Jeppe B 53e193d8d1 Fix lane toggle permission guards 2026-06-01 23:34:03 +02:00
Jeppe B 7835691bc9 Merge pull request #87 from copenhagentruckwash/fix-websocket-token-exposure-vulnerability
Secure edge gateway WebSocket session tokens
2026-06-01 23:33:44 +02:00
Jeppe B 91715541ec Secure edge gateway websocket sessions 2026-06-01 23:33:34 +02:00
Jeppe B e3f9e99129 Merge pull request #86 from copenhagentruckwash/fix-stored-xss-in-relay-options
Fix select option HTML escaping
2026-06-01 23:33:20 +02:00
Jeppe B 4634cbad89 Fix select option HTML escaping 2026-06-01 23:33:10 +02:00
Jeppe B 5db2a36da0 Merge pull request #85 from copenhagentruckwash/propose-fix-for-relay-failure-issue
Rollback wash start when relay activation fails
2026-06-01 22:53:44 +02:00
Jeppe B cd7b273da4 Rollback wash start when relay activation fails 2026-06-01 22:53:34 +02:00
Jeppe B cbbad1a6e2 Merge pull request #84 from copenhagentruckwash/fix-pos-url-order-reassignment-vulnerability
Prevent implicit POS order department changes
2026-06-01 22:44:24 +02:00
Jeppe B 9661d4efa7 Prevent implicit POS order department changes 2026-06-01 22:44:14 +02:00
Jeppe B 31762c23c5 Merge pull request #83 from copenhagentruckwash/fix-unescaped-order-delete-reasons
Fix protected order delete reason escaping
2026-06-01 22:43:55 +02:00
Jeppe B c71a6b4d14 Fix protected order delete reason escaping 2026-06-01 22:43:45 +02:00
Jeppe B e8ddb33f21 Merge pull request #82 from copenhagentruckwash/fix-vulnerability-in-edge-broker-config
Secure edge gateway broker configuration
2026-06-01 22:28:28 +02:00
Jeppe B ea368d19bb Secure edge gateway broker configuration 2026-06-01 22:28:18 +02:00
Jeppe B 7ab50b6290 Merge pull request #81 from copenhagentruckwash/fix-unredacted-broker-diagnostics-exposure
Redact edge gateway diagnostic secrets
2026-06-01 22:27:33 +02:00
Jeppe B fd7cfbd3c3 Redact edge gateway diagnostics 2026-06-01 22:27:19 +02:00
Jeppe B 90725af10a Merge pull request #80 from copenhagentruckwash/fix-ungated-destructive-actions-in-ui
Gate self-serve studio gateway actions and require confirmation for destructive operations
2026-06-01 22:26:58 +02:00
Jeppe B d63a62a921 Gate self-serve gateway actions 2026-06-01 22:26:44 +02:00
Jeppe B 4bc5481941 Merge pull request #79 from copenhagentruckwash/propose-fix-for-deferred-start-relay-issue
Require successful machine relay activation before marking wash in progress
2026-06-01 22:15:16 +02:00
Jeppe B 9feede3b0d Require machine relay activation before wash progress 2026-06-01 22:15:02 +02:00
Jeppe B 5aad9eda8d Merge pull request #78 from copenhagentruckwash/fix-raw-edge-log-context-exposure
Redact edge gateway log context secrets
2026-06-01 22:13:42 +02:00
Jeppe B 91b15faba6 Redact edge gateway log context secrets 2026-06-01 22:13:32 +02:00
Jeppe B 98c1c61cbd Merge pull request #77 from copenhagentruckwash/fix-active-wash-endpoint-information-leak
Scope active-wash restore to authenticated customer (my-active-wash)
2026-06-01 22:06:25 +02:00
Jeppe B 4463a5326a Fix scoped active wash restoration 2026-06-01 22:06:14 +02:00
Jeppe B bdb23513a4 Merge pull request #76 from copenhagentruckwash/propose-fix-for-security-vulnerability
Harden self-serve vehicle condition mutations
2026-06-01 22:05:37 +02:00
Jeppe B ee40f16232 Harden self-serve vehicle condition mutations 2026-06-01 22:05:27 +02:00
Jeppe B a76944b00c Merge pull request #75 from copenhagentruckwash/fix-sessionstorage-vulnerability-in-invoicing-period
Secure privileged session caches
2026-06-01 21:52:53 +02:00
Jeppe B cf074ff893 Secure privileged session caches 2026-06-01 21:52:42 +02:00
Jeppe B 712eb19db5 Merge pull request #74 from copenhagentruckwash/propose-fix-for-release-manager-token-leak
Restrict release manager control API origins
2026-06-01 21:52:20 +02:00
Jeppe B de9bc9b170 Restrict release manager control API origins 2026-06-01 21:52:09 +02:00
Jeppe B 95e2404307 Merge pull request #73 from copenhagentruckwash/fix-unredacted-deployment-failures-exposure
Redact release deployment failure diagnostics
2026-06-01 21:27:42 +02:00
Jeppe B 2c01feb3be Redact release deployment failure diagnostics 2026-06-01 21:27:28 +02:00
Jeppe B f416e0d462 Merge pull request #72 from copenhagentruckwash/fix-telemetry-that-leaks-passwords
Redact serialized request bodies and omit failed-request bodies when capture is disabled
2026-06-01 21:26:45 +02:00
Jeppe B 7f1308c8ce Redact release timeline failed request bodies 2026-06-01 21:26:32 +02:00
Jeppe B fd8d45e437 Merge pull request #71 from copenhagentruckwash/fix-vulnerabilities-in-release-runtime-urls
Harden release runtime URL trust boundaries
2026-06-01 21:25:51 +02:00
copilot-swe-agent[bot] 20390342d5 Merge origin/master and resolve release timeline spec conflict 2026-06-01 19:21:12 +00:00
Jeppe B c47bf229f0 Harden release runtime URL trust boundaries 2026-06-01 21:14:39 +02:00
Jeppe B 3616e197e1 Merge pull request #70 from copenhagentruckwash/propose-fix-for-isolated-stack-vulnerability
Prevent active isolated stack reuse during release deploys
2026-06-01 21:12:26 +02:00
Jeppe B 3d8544de21 Prevent isolated stack deployment reuse 2026-06-01 21:11:17 +02:00
Jeppe B 504abb7a15 Merge pull request #69 from copenhagentruckwash/fix-release-subject-autocomplete-exposure
Guard release assignment subject search
2026-06-01 21:11:02 +02:00
Jeppe B 37021533b9 Guard release assignment search by manage permission 2026-06-01 21:10:51 +02:00
Jeppe B 59eab84f03 Merge pull request #68 from copenhagentruckwash/fix-cross-origin-framing-vulnerability-in-nginx
Add anti-framing headers to Coolify Nginx frontend
2026-06-01 21:10:26 +02:00
Jeppe B e67033711c Add anti-framing headers to Coolify Nginx config 2026-06-01 21:10:14 +02:00
Jeppe B 85fd00f020 Merge pull request #67 from copenhagentruckwash/fix-metadata-exposure-for-low-privileged-users
Restrict release runtime metadata inspector
2026-06-01 21:09:54 +02:00
Jeppe B 4f064a1625 Restrict release runtime metadata inspector 2026-06-01 21:09:42 +02:00
Jeppe B 3bf7ada226 Merge pull request #66 from copenhagentruckwash/fix-tls-verification-in-dev-proxy
Secure Vite API proxy TLS by default
2026-06-01 21:09:15 +02:00
Jeppe B 3b90e17a1e Secure Vite API proxy TLS by default 2026-06-01 21:09:05 +02:00
Jeppe B c1fb5b72ee Merge pull request #65 from copenhagentruckwash/fix-privilege-escalation-in-daily-report
Guard department lane toggles by wash-lane permission
2026-06-01 21:08:43 +02:00
Jeppe B 29da26cc08 Guard department lane toggles by permission 2026-06-01 21:08:32 +02:00
Jeppe B 807fc09b1e Merge pull request #64 from copenhagentruckwash/fix-dgnvask-toggle-issue
Fix Døgnvask toggle state with configuration warnings
2026-06-01 21:08:03 +02:00
Jeppe B 528e4994db Fix Dognvask warning toggle state 2026-06-01 21:07:53 +02:00
Jeppe B c339122f4f Merge pull request #63 from copenhagentruckwash/fix-release-gate-binding-to-build
Bind frontend release gate to build id
2026-06-01 21:07:33 +02:00
Jeppe B feeef46354 Bind frontend release gate to build id 2026-06-01 21:07:23 +02:00
Jeppe B 066bd14554 Merge pull request #62 from copenhagentruckwash/fix-active-wash-refresh-vulnerability
Harden active-wash refresh ownership check
2026-06-01 21:06:53 +02:00
Jeppe B bffaa2ab88 Fix active wash refresh ownership check 2026-06-01 21:06:39 +02:00
Jeppe B 31495ea67b Merge pull request #61 from copenhagentruckwash/fix-ftp-deploy-password-exposure
Secure lftp release upload credentials
2026-06-01 21:02:13 +02:00
Jeppe B 8f2533f1b2 Secure lftp release upload credentials 2026-06-01 21:02:01 +02:00
Jeppe B 13b0742deb Merge pull request #60 from copenhagentruckwash/fix-cropping-issue-in-path-editor
Fix self-serve path editor answer switch layout
2026-06-01 20:45:04 +02:00
333 changed files with 24770 additions and 3995 deletions
-28
View File
@@ -1,28 +0,0 @@
name: Qodana Configuration Upload
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+8 -4
View File
@@ -7,9 +7,13 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -20,9 +24,9 @@ jobs:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-v "$(pwd):/workspace" \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
-e QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+84 -14
View File
@@ -1,29 +1,35 @@
name: Frontend Release
on:
push:
workflow_run:
workflows:
- Automated Tests
types:
- completed
branches:
- master
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: frontend-release-${{ github.ref }}
cancel-in-progress: false
group: frontend-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
build-upload-and-verify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
@@ -32,54 +38,114 @@ jobs:
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release commit is current
id: branch-head
run: |
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
echo "current=false" >> "$GITHUB_OUTPUT"
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
echo "Release commit is current for $RELEASE_BRANCH."
env:
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
- name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
if: steps.branch-head.outputs.current == 'true'
run: npm ci --legacy-peer-deps
- name: Check AI workflow sync
if: steps.branch-head.outputs.current == 'true'
run: node scripts/sync-ai-workflow.mjs --check
- name: Source and i18n checks
if: steps.branch-head.outputs.current == 'true'
run: |
npm run text:check-encoding
npm run i18n:v2:source-check
- name: Unit tests
if: steps.branch-head.outputs.current == 'true'
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
- name: Build release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run build
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
if: steps.branch-head.outputs.current == 'true'
run: node scripts/install-playwright-browsers.mjs chromium
- name: Production Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod
- name: Upload dist artifact
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist
retention-days: 14
retention-days: 3
- name: Request Release Manager auto sync
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
response_file="$(mktemp)"
status_code="$(curl --show-error --silent \
--output "$response_file" \
--write-out "%{http_code}" \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
if [[ "$status_code" =~ ^2 ]]; then
cat "$response_file"
elif [[ "$status_code" == "504" ]]; then
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
else
cat "$response_file" >&2
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
exit 1
fi
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Wait for Coolify release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run release:verify-upload
- name: Public live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
@@ -92,31 +158,35 @@ jobs:
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record Release Manager gate
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.ref_name }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update server version after verification
if: steps.branch-head.outputs.current == 'true'
run: npm run release:update-server-version
env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.sha }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
- name: Upload Playwright report
if: failure()
if: failure() && steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 14
retention-days: 3
+261 -34
View File
@@ -3,6 +3,8 @@ name: Automated Tests
on:
pull_request:
push:
branches:
- master
workflow_dispatch:
schedule:
- cron: "0 2 * * *"
@@ -11,14 +13,29 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
format-tests:
# Match the labels exposed by the Coolify-managed GitHub runner.
runs-on: [self-hosted, Linux, X64, default]
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 15
steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
@@ -26,7 +43,6 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check
@@ -39,8 +55,23 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30
steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
@@ -48,11 +79,16 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Check i18n source consistency
run: npm run i18n:v2:check
- name: Build sanity check
run: npm run build
@@ -64,8 +100,33 @@ jobs:
e2e-pr:
if: github.event_name != 'schedule'
needs: build-and-unit
runs-on: [self-hosted, Linux, X64, default]
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 4
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
with:
@@ -98,33 +159,112 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright PR tests
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Run Playwright PR suite in container
shell: bash
env:
DIFF_BASE_REF: ${{ steps.playwright-diff.outputs.base }}
DIFF_HEAD_REF: ${{ steps.playwright-diff.outputs.head }}
MATRIX_SUITE: ${{ matrix.suite }}
MATRIX_PROJECT: ${{ matrix.project }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
case "$MATRIX_SUITE" in
core) suite_offset=0 ;;
changed) suite_offset=10 ;;
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
esac
case "$MATRIX_PROJECT" in
chromium-desktop) project_offset=1 ;;
chromium-mobile) project_offset=2 ;;
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
playwright_dev_port="$(
PORT_SEED="$port_seed" node - <<'NODE'
const net = require("node:net");
const start = Number(process.env.PORT_SEED || 20000);
const candidates = Array.from({ length: 200 }, (_, index) => start + index);
function tryPort(index) {
if (index >= candidates.length) {
console.error("Unable to find a free Playwright dev-server port.");
process.exit(1);
}
const port = candidates[index];
const server = net.createServer();
server.once("error", () => tryPort(index + 1));
server.listen(port, "127.0.0.1", () => {
server.close(() => {
console.log(port);
});
});
}
tryPort(0);
NODE
)"
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
"${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_SUITE="$MATRIX_SUITE" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env DIFF_BASE_REF="$DIFF_BASE_REF" \
--env DIFF_HEAD_REF="$DIFF_HEAD_REF" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
npm ci --legacy-peer-deps
ulimit -n 16384 || true
if [[ "$MATRIX_SUITE" == "core" ]]; then
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
else
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
fi
'
- name: Upload Playwright report
if: failure()
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-pr
path: output/playwright
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore
retention-days: 7
retention-days: 1
e2e-full:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
needs: build-and-unit
if: >
always() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 4
matrix:
role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit]
@@ -143,6 +283,20 @@ jobs:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
PLAYWRIGHT_REPORTER_MODE: line-html
steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
@@ -150,19 +304,92 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser_install }}
- name: Run full Playwright slice
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
- name: Run full Playwright slice in container
shell: bash
env:
MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
case "$MATRIX_ROLE" in
customer) role_offset=0 ;;
subuser) role_offset=100 ;;
admin) role_offset=200 ;;
superuser) role_offset=300 ;;
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
esac
case "$MATRIX_BROWSER" in
chromium) browser_offset=0 ;;
firefox) browser_offset=30 ;;
webkit) browser_offset=60 ;;
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
esac
case "$MATRIX_DEVICE" in
mobile) device_offset=1 ;;
tablet) device_offset=2 ;;
desktop) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
playwright_dev_port="$(
PORT_SEED="$port_seed" node - <<'NODE'
const net = require("node:net");
const start = Number(process.env.PORT_SEED || 20000);
const candidates = Array.from({ length: 200 }, (_, index) => start + index);
function tryPort(index) {
if (index >= candidates.length) {
console.error("Unable to find a free Playwright dev-server port.");
process.exit(1);
}
const port = candidates[index];
const server = net.createServer();
server.once("error", () => tryPort(index + 1));
server.listen(port, "127.0.0.1", () => {
server.close(() => {
console.log(port);
});
});
}
tryPort(0);
NODE
)"
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
"${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_ROLE="$MATRIX_ROLE" \
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
npm ci --legacy-peer-deps
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
'
- name: Upload Playwright report
if: failure()
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
@@ -171,4 +398,4 @@ jobs:
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
if-no-files-found: ignore
retention-days: 14
retention-days: 1
+4
View File
@@ -0,0 +1,4 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
+8
View File
@@ -38,6 +38,14 @@ To use another remote API route:
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
```
TLS certificate validation is enabled for proxied HTTPS APIs by default. If you
are using a trusted local HTTPS API with a self-signed certificate, you can opt
out explicitly:
```powershell
$env:VITE_API_PROXY_TARGET="https://local-api.test"; $env:VITE_API_PROXY_SECURE="false"; npm run dev
```
For compatible local gateways that expect the `/api` prefix to be preserved:
```powershell
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig {
applicationId "io.truckwash.twa"
minSdkVersion 21
targetSdkVersion 35
versionCode 6
versionName "6"
targetSdkVersion 36
versionCode 7
versionName "7"
// The name for the application
resValue "string", "appName", twaManifest.name
+1 -1
View File
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
@@ -0,0 +1,33 @@
# User wash start production-readiness QA matrix
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
## Unit matrix
| Area | Required scenario | Coverage |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
## E2E mocked matrix
| Required scenario | Coverage |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
## Optional live smoke
| Optional scenario | Coverage |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
+139
View File
@@ -0,0 +1,139 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import vue from "eslint-plugin-vue";
const commonGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
grecaptcha: "readonly",
};
const vitestGlobals = {
...globals.vitest,
};
const lintTargets = ["**/*.{js,mjs,cjs,ts,tsx,vue}"];
const tsTargets = ["**/*.{ts,tsx,mts,cts}"];
function warningRules(rules = {}) {
return Object.fromEntries(
Object.entries(rules).map(([name, value]) => {
if (value === "off" || value === 0) {
return [name, value];
}
if (Array.isArray(value)) {
return [name, ["warn", ...value.slice(1)]];
}
return [name, "warn"];
}),
);
}
function warningConfig(config, files) {
return {
...config,
files: config.files ?? files,
rules: warningRules(config.rules),
};
}
const jsRecommended = warningConfig(js.configs.recommended, lintTargets);
const tsRecommended = tseslint.configs.recommended.map((config) =>
warningConfig(config, tsTargets),
);
const vueEssential = vue.configs["flat/essential"].map((config) =>
warningConfig(config, ["**/*.vue"]),
);
const ignoredPaths = [
"app/build/**",
"coverage/**",
"dist/**",
"node_modules/**",
"node_modules.codex-backup/**",
"output/**",
"public/build/**",
"src/assets/test/**",
"test/**",
"vendor/**",
"*.timestamp-*.mjs",
"**/*.timestamp-*.mjs",
];
const commonUnusedOptions = {
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
varsIgnorePattern: "^_",
};
export default [
{
ignores: ignoredPaths,
},
jsRecommended,
...tsRecommended,
...vueEssential,
{
files: ["**/*.vue"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: [".vue"],
ecmaVersion: "latest",
sourceType: "module",
},
},
},
{
files: lintTargets,
plugins: {
vue,
},
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: commonGlobals,
},
rules: {
"no-console": "off",
"no-debugger": "warn",
"no-empty": "warn",
"no-undef": "warn",
"no-unused-vars": ["warn", commonUnusedOptions],
"no-useless-assignment": "warn",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "warn",
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
"vue/no-v-html": "off",
},
},
{
files: ["**/*.{ts,tsx}"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", commonUnusedOptions],
},
},
{
files: ["tests/**/*.{js,ts}", "playwright*.ts", "scripts/**/*.{js,mjs}"],
languageOptions: {
globals: {
...commonGlobals,
...globals.node,
...vitestGlobals,
},
},
rules: {
"no-empty": "warn",
},
},
];
+13 -4
View File
@@ -2,10 +2,19 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
<script>
(function () {
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base');
base.href = href;
document.currentScript.after(base);
})();
</script>
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
+22 -1
View File
@@ -5,22 +5,39 @@ server {
root /usr/share/nginx/html;
index index.html;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location ~ ^/(?:.+/)?(?<webmanifest_path>(?:assets/)?manifest\.webmanifest)$ {
types { application/manifest+json webmanifest; }
default_type application/manifest+json;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$webmanifest_path =404;
}
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$2.json =404;
}
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$static_asset_path =404;
}
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404;
}
@@ -30,11 +47,15 @@ server {
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404;
}
+113 -19
View File
@@ -4624,7 +4624,7 @@ paths:
tags:
- Self-Serve
summary: Add vehicle condition
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
operationId: addSelfserveVehicleCondition
requestBody:
required: true
@@ -4659,14 +4659,6 @@ paths:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
sync_relay_state:
type: boolean
default: true
description: Whether the answer mutation should synchronize live relay state.
responses:
'200':
description: Successfully added vehicle condition
@@ -4683,7 +4675,7 @@ paths:
tags:
- Self-Serve
summary: Update vehicle condition
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles.
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
operationId: updateSelfserveVehicleCondition
parameters:
- name: id
@@ -4719,14 +4711,6 @@ paths:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay.
sync_relay_state:
type: boolean
default: true
description: Whether the mutation should synchronize live relay state.
responses:
'200':
description: Successfully updated vehicle condition
@@ -5402,7 +5386,7 @@ paths:
application/json:
schema:
type: object
required: [department, gateway_id, action]
required: [department, gateway_id, action, confirm]
properties:
department: { type: integer }
gateway_id: { type: integer }
@@ -8704,6 +8688,116 @@ paths:
schema:
$ref: '#/components/schemas/SelfServeLaneStatus'
/modules/self-serve/lane/wash/my-active-wash:
get:
tags:
- Modules
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the current authenticated customer's open self-serve wash session,
if one exists. Regular customers must only receive their own active wash
details from this endpoint.
operationId: getMyActiveSelfServeWash
responses:
'200':
description: Authenticated customer's active wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
nullable: true
in_progress:
type: boolean
session:
type: object
nullable: true
properties:
id:
type: integer
lane_id:
type: integer
nullable: true
department_id:
type: integer
nullable: true
status:
type: string
reg:
type: string
customer_number:
type: integer
nullable: true
vehicle_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
included_minutes:
type: integer
nullable: true
machine_type_id:
type: integer
nullable: true
machine_relay_enabled:
type: boolean
machine_relay_enabled_at:
type: string
nullable: true
machine_start_triggered:
type: boolean
machine_start_triggered_at:
type: string
nullable: true
wash_started_at:
type: string
nullable: true
created_at:
type: string
updated_at:
type: string
nullable: true
customer:
type: object
nullable: true
properties:
id:
type: integer
nullable: true
customer_number:
type: integer
nullable: true
display_name:
type: string
nullable: true
email:
type: string
nullable: true
phone_country_code:
type: integer
nullable: true
phone:
type: string
nullable: true
vehicle:
type: object
nullable: true
properties:
id:
type: integer
customer_id:
type: integer
type:
type: integer
reg:
type: string
reference:
type: string
nullable: true
/modules/self-serve/lane/wash/in-progress:
get:
tags:
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig {
applicationId "io.truckwash.twa.staging"
minSdkVersion 21
targetSdkVersion 35
versionCode 5
versionName "5"
targetSdkVersion 36
versionCode 6
versionName "6"
// The name for the application
resValue "string", "appName", twaManifest.name
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
+1105 -8
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -7,11 +7,13 @@
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts --quiet",
"lint:strict": "npm run lint:report -- --max-warnings=0",
"lint:report": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts",
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
"prepare": "node scripts/prepare-husky.mjs",
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
"preview": "vite preview",
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
@@ -109,17 +111,23 @@
},
"devDependencies": {
"@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0",
"@playwright/test": "^1.58.2",
"@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/test-utils": "^2.4.6",
"eslint": "^10.4.1",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jsdom": "^29.0.0",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
"sass-embedded": "^1.81.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-vue-devtools": "^7.5.4",
+4
View File
@@ -38,10 +38,14 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/release/**"],
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
timeout: 60_000,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
expect: {
timeout: 15_000,
},
workers,
...(process.env.PLAYWRIGHT_BASE_URL
? {}
+11 -4
View File
@@ -19,6 +19,16 @@ const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
const serverOutputLimit = 80;
const activeOutputReaders = [];
const viteServerEnv = {
...process.env,
PLAYWRIGHT: "1",
...(process.env.CI
? {
CHOKIDAR_INTERVAL: process.env.CHOKIDAR_INTERVAL || "300",
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || "true",
}
: {}),
};
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
const viteDevArgs = [
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
@@ -449,10 +459,7 @@ export default async function globalSetup() {
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
env: viteServerEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
+51 -20
View File
@@ -1,3 +1,4 @@
import fs from "node:fs";
import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
@@ -5,6 +6,55 @@ const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL;
const osReleaseValue = (key: string) => {
try {
const body = fs.readFileSync("/etc/os-release", "utf8");
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
return String(match?.[1] || "").replace(/^"|"$/g, "");
} catch {
return "";
}
};
const isUnsupportedWebKitHost = () => {
if (process.platform !== "linux") {
return false;
}
const id = osReleaseValue("ID").toLowerCase();
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
};
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
.trim()
.toLowerCase();
const includeWebKit =
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
const projects = [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
];
if (includeWebKit) {
projects.push({
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
});
}
export default defineConfig({
testDir: "./tests/e2e/release",
testMatch: /.*\.local-prod\.spec\.ts/,
@@ -28,24 +78,5 @@ export default defineConfig({
timeout: 240_000,
reuseExistingServer: !isCI,
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
{
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
},
],
projects,
});
+8 -4
View File
@@ -2,6 +2,10 @@
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
@@ -23,17 +27,17 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
@@ -41,7 +45,7 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
+1
View File
@@ -2,6 +2,7 @@
"name": "Truck Wash Kundeportal",
"short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [
{
"src": "assets/favicons/web-app-manifest-192x192.png",
+90
View File
@@ -0,0 +1,90 @@
import { spawnSync } from "node:child_process";
const browsers = process.argv.slice(2);
const requestedBrowsers = browsers.length > 0 ? browsers : ["chromium"];
const fallbackHostPlatformByUnsupportedPlatform = (platform) => {
const ubuntuMatch = platform.match(/^ubuntu(\d+\.\d+)-(x64|arm64)$/);
if (ubuntuMatch && Number.parseInt(ubuntuMatch[1], 10) >= 26) {
return `ubuntu24.04-${ubuntuMatch[2]}`;
}
return null;
};
const outputText = (result) => `${result.stdout || ""}\n${result.stderr || ""}`;
const unsupportedHostPlatform = (result) => {
const output = outputText(result);
return (
output.match(/Cannot install dependencies for (?<platform>\S+) with Playwright/i)?.groups?.platform ||
output.match(/Playwright does not support \S+ on (?<platform>\S+)/i)?.groups?.platform ||
null
);
};
const runPlaywrightInstall = (args, env = {}) =>
spawnSync("npx", ["playwright", "install", ...args], {
cwd: process.cwd(),
env: {
...process.env,
...env,
},
encoding: "utf8",
});
const writeOutput = (result) => {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
};
const hasUnsupportedHostPlatformFailure = (result) => {
const output = outputText(result);
return (
result.status !== 0 &&
/Playwright does not support .* on /i.test(output)
);
};
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
writeOutput(withDepsResult);
if (withDepsResult.status === 0) {
process.exit(0);
}
if (!hasUnsupportedHostPlatformFailure(withDepsResult)) {
process.exit(withDepsResult.status ?? 1);
}
const unsupportedPlatform = unsupportedHostPlatform(withDepsResult);
const fallbackHostPlatform = unsupportedPlatform
? fallbackHostPlatformByUnsupportedPlatform(unsupportedPlatform)
: null;
if (!fallbackHostPlatform) {
console.error(
`Playwright dependency install is unsupported for ${unsupportedPlatform || "this platform"}, ` +
"and this script does not have a safe browser archive fallback for it."
);
process.exit(withDepsResult.status ?? 1);
}
console.warn(
[
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
"The self-hosted runner image must provide the required browser system libraries.",
].join("\n")
);
const fallbackResult = runPlaywrightInstall(requestedBrowsers, {
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
});
writeOutput(fallbackResult);
process.exit(fallbackResult.status ?? 1);
@@ -1,22 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
if (!fs.existsSync(helperScriptPath)) {
console.log(
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
);
process.exit(0);
}
const result = spawnSync(process.execPath, [helperScriptPath], {
stdio: "inherit",
});
if (typeof result.status === "number") {
process.exit(result.status);
}
process.exit(1);
+35 -3
View File
@@ -23,20 +23,52 @@ if [[ -z "$DEPLOY_URL" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1
fi
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
DEPLOY_URL="ftp://${HOST}"
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
exit 1
fi
fi
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
run_lftp() {
local transfer_command="$1"
{
printf 'set ftp:ssl-allow true\n'
printf 'set ftp:ssl-force true\n'
printf 'set ftp:ssl-protect-data true\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
else
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
fi
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
printf '%s\n' "$transfer_command"
printf 'bye\n'
} | lftp -f /dev/stdin
}
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
fi
done
+26 -2
View File
@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -7,6 +8,24 @@ function numberEnv(name, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function booleanEnv(name) {
return /^(1|true|yes)$/i.test(process.env[name] || "");
}
function appendGithubEnv(values) {
const envFile = process.env.GITHUB_ENV;
if (!envFile) {
return;
}
const lines = Object.entries(values)
.filter(([, value]) => value)
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`);
if (lines.length > 0) {
fs.appendFileSync(envFile, `${lines.join("\n")}\n`);
}
}
function requiredUrl() {
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
if (!value) {
@@ -120,7 +139,8 @@ async function verifyShell(baseUrl, shellPath) {
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
@@ -130,7 +150,7 @@ async function verifyRelease(baseUrl) {
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
}
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
}
if (releaseEntry.entry !== manifest.entry) {
@@ -191,6 +211,10 @@ async function main() {
attempt += 1;
try {
const result = await verifyRelease(baseUrl);
appendGithubEnv({
RELEASE_VERIFIED_BUILD_ID: result.build_id,
RELEASE_VERIFIED_COMMIT: result.commit_sha,
});
console.log(
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
);
+2
View File
@@ -35,6 +35,7 @@ export const ownedFilesByRole = {
"userHome.spec.ts",
"userInvoices.spec.ts",
"userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts",
@@ -79,6 +80,7 @@ export const ownedFilesByRole = {
"edge-gateways.visual.spec.js",
"errorReports.spec.ts",
"failover-config.source.spec.ts",
"collected-invoice-move-customer.spec.ts",
"invoice-distribution.smoke.spec.js",
"invoice-transfer-monitor.spec.ts",
"invoice-transfer-queue-history.spec.js",
+16
View File
@@ -22,6 +22,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
function parseArgs(rawArgs) {
const parsed = {
help: false,
coreOnly: false,
changedOnly: false,
listOnly: false,
base: "",
@@ -43,6 +44,11 @@ function parseArgs(rawArgs) {
continue;
}
if (value === "--core-only") {
parsed.coreOnly = true;
continue;
}
if (value === "--changed-only") {
parsed.changedOnly = true;
continue;
@@ -103,12 +109,14 @@ Options:
--base <ref> Base ref for changed-area detection
--head <ref> Head ref for changed-area detection. Default: HEAD
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
--core-only Run only the core @pr gate
--changed-only Run changed-area selection without the core @pr gate
--list-only List selected tests instead of running them
-h, --help Show help
Examples:
npm run test:e2e:pr
npm run test:e2e:pr -- --core-only --project=chromium-desktop
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
`);
}
@@ -418,6 +426,10 @@ async function main() {
return;
}
if (args.coreOnly && args.changedOnly) {
throw new Error("--core-only and --changed-only cannot be used together.");
}
await fs.access(playwrightCliPath);
if (!args.changedOnly) {
@@ -428,6 +440,10 @@ async function main() {
}
}
if (args.coreOnly) {
return;
}
const changed = await getChangedFiles();
if (changed.unavailable) {
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
+2
View File
@@ -20,6 +20,7 @@ const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/L
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
const FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
import("@/components/release/ReleaseChannelUnavailable.vue")
);
@@ -128,6 +129,7 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</template>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
</template>
<style scoped></style>
@@ -97,6 +97,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
allowBookingDeletion: {
type: Boolean,
default: false,
},
department_lane_id: {
type: Number,
default: null,
@@ -1752,6 +1756,11 @@ const acceptSelfServeWashDraft = async () => {
}
};
const canDeleteOrderBooking = computed(() =>
!props.order_id &&
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
);
const flatBuiltInMenuSections = computed(() => {
const sections = [];
@@ -1806,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
),
})
: null,
!props.order_id
canDeleteOrderBooking.value
? buildMenuAction("booking-delete", {
icon: "fas fa-trash-alt",
label: t("admin.pos.settings_wheel.delete_booking"),
@@ -2526,11 +2535,13 @@ const syncDesktopFlyoutPosition = () => {
dropdownContentEl.style.top = "";
}
const padding = 12;
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
let top = triggerRect.bottom;
if (top + contentHeight > viewportHeight - padding) {
top = Math.max(padding, triggerRect.top - contentHeight);
if (top + contentHeight > viewportBottom) {
top = Math.max(viewportTop, triggerRect.top - contentHeight);
}
const nextFixedStyles = {
@@ -1,5 +1,5 @@
<script setup>
import {computed, onMounted, ref, watch} from 'vue';
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import { useRouter } from 'vue-router';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {BSwitch} from "buefy";
@@ -18,6 +18,39 @@ const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false);
const bookingsCount = ref(0);
const selfServeLoadingMinimumMs = import.meta.env.MODE === "test" ? 0 : import.meta.env.VITE_IS_PLAYWRIGHT ? 5000 : 250;
const selfServeLoadingStartedAt = ref(0);
let selfServeLoadingTimer = null;
const clearSelfServeLoadingTimer = () => {
if (selfServeLoadingTimer !== null) {
clearTimeout(selfServeLoadingTimer);
selfServeLoadingTimer = null;
}
};
const setSelfServeLoading = (isLoading) => {
if (isLoading) {
clearSelfServeLoadingTimer();
selfServeLoadingStartedAt.value = Date.now();
isLoadingSelfServeEnabled.value = true;
return;
}
const elapsed = Date.now() - selfServeLoadingStartedAt.value;
const remaining = Math.max(0, selfServeLoadingMinimumMs - elapsed);
clearSelfServeLoadingTimer();
if (remaining === 0) {
isLoadingSelfServeEnabled.value = false;
return;
}
selfServeLoadingTimer = setTimeout(() => {
isLoadingSelfServeEnabled.value = false;
selfServeLoadingTimer = null;
}, remaining);
};
const getDepartmentId = () => {
return parseInt(router.currentRoute.value.params.departmentId);
@@ -190,13 +223,13 @@ const getSelfServeStatus = async () => {
const departmentId = getDepartmentId();
if (!departmentId) return;
isLoadingSelfServeEnabled.value = true;
setSelfServeLoading(true);
try {
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
} catch (error) {
console.error("Failed to fetch self-serve status", error);
} finally {
isLoadingSelfServeEnabled.value = false;
setSelfServeLoading(false);
}
};
@@ -204,6 +237,10 @@ onMounted(() => {
getSelfServeStatus();
});
onUnmounted(() => {
clearSelfServeLoadingTimer();
});
// Watch the departmentId
watch(() => router.currentRoute.value.params.departmentId, () => {
getTodaysBookings();
@@ -15,6 +15,7 @@ import {
selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS,
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
const POLLING_INTERVAL_MS = 5000;
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
@@ -87,29 +88,6 @@ const selectedTaxRate = ref(1);
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
const normalizeStripeInvoice = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
return {
id: value.id ?? props.order_id,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: Boolean(value.paid),
status: value.status || 'unknown',
amount_due: Number(value.amount_due ?? 0),
amount_paid: Number(value.amount_paid ?? 0),
};
};
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
const isStripeEmailInvoiceTerminalState = computed(() => {
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
try {
const response = await getOrder(props.order_id, true);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
stripeInvoice.value = nextInvoice;
if (nextInvoice) {
emailPanelState.value = 'tracking';
@@ -0,0 +1,60 @@
const STRIPE_INVOICE_PAID_STATUS = 'paid';
export const parseStripeInvoicePaidFlag = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['true', '1'].includes(normalizedValue)) {
return true;
}
if (['false', '0', ''].includes(normalizedValue)) {
return false;
}
}
return false;
};
const toFiniteNumber = (value, fallback = 0) => {
const parsedValue = Number(value ?? fallback);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
};
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
const status = String(value.status || 'unknown').toLowerCase();
const amountDue = toFiniteNumber(value.amount_due);
const amountPaid = toFiniteNumber(value.amount_paid);
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
&& parseStripeInvoicePaidFlag(value.paid)
&& hasCoveredAmountDue;
return {
id: value.id ?? fallbackOrderId,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: isPaid,
status,
amount_due: amountDue,
amount_paid: amountPaid,
};
};
@@ -10,6 +10,16 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const safePreviewBlobTypesByKind = {
image: {
fallback: "image/png",
allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]),
},
document: {
fallback: "application/pdf",
allowed: new Set(["application/pdf"]),
},
};
const props = defineProps({
order: {
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
const createEmbeddablePreviewUrl = async (downloadLink) => {
const createSafePreviewBlob = (fileBlob, previewKind) => {
const previewBlobTypes = safePreviewBlobTypesByKind[previewKind];
if (!previewBlobTypes) {
return null;
}
const normalizedBlobType = String(fileBlob.type || "").toLowerCase();
const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType)
? normalizedBlobType
: previewBlobTypes.fallback;
return new Blob([fileBlob], { type: safeBlobType });
};
const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
if (!downloadLink) {
return null;
}
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
return null;
}
const objectUrl = URL.createObjectURL(fileBlob);
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
if (!safePreviewBlob) {
return null;
}
const objectUrl = URL.createObjectURL(safePreviewBlob);
generatedObjectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
attachment.id,
false
);
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: previewSource,
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
:src="activePreviewSource"
class="order-attachments-preview-panel__document"
title="Attachment preview"
sandbox
></iframe>
<a
v-else-if="activePreviewKind === 'link'"
@@ -275,6 +275,10 @@ const getInvoiceCollectionResponseOrders = (response) => {
return response.data.orders;
}
if (Array.isArray(response?.data?.data?.orders)) {
return response.data.data.orders;
}
if (Array.isArray(response?.includes?.orders)) {
return response.includes.orders;
}
@@ -649,6 +653,41 @@ const getInvoiceCollectionDetails = (invoiceCollectionId) => {
}
return null;
};
const getInvoiceCollectionDetailOrders = (invoiceCollectionId) => {
const details = getInvoiceCollectionDetails(invoiceCollectionId);
return details ? getInvoiceCollectionResponseOrders(details) : [];
};
const getHiddenOrdersByInvoiceCollection = (invoiceCollectionId) => {
const visibleOrderIds = new Set(getOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id)));
return getInvoiceCollectionDetailOrders(invoiceCollectionId).filter((order) => !visibleOrderIds.has(Number(order?.id)));
};
const getHiddenOrderIdsByInvoiceCollection = (invoiceCollectionId) => (
getHiddenOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id))
);
const getHiddenInvoiceCollectionFlags = (invoiceCollectionId) => {
const hiddenOrderIds = new Set(getHiddenOrderIdsByInvoiceCollection(invoiceCollectionId));
if (hiddenOrderIds.size === 0) {
return [];
}
return sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order", "order_field", "order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderId = Number(
flag?.order_id
|| flag?.context?.order_id
|| (["order", "order_field"].includes(targetType) ? flag?.target_id : 0)
);
return flagOrderId > 0 && hiddenOrderIds.has(flagOrderId);
}));
};
watch(
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
() => {
@@ -999,6 +1038,13 @@ const formatCashierName = (order) => {
}}
er ikke vist, men vil muligvis blive faktureret alligevel.</span
>
<InvoicingPeriodFlagList
v-if="getHiddenInvoiceCollectionFlags(order.invoice_collection_id).length > 0"
class="mt-3"
compact
:flags="getHiddenInvoiceCollectionFlags(order.invoice_collection_id)"
@statusChanged="emitFlagStatusChanged"
/>
</div>
</div>
</td>
@@ -21,6 +21,7 @@ import {
step,
setDesktopStep1PreflightHandler,
clearDesktopStep1PreflightHandler,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue";
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
import { POS_STEP_1_VERSION } from "@/config.js";
@@ -92,6 +93,7 @@ const duplicateDetailsExpanded = ref(false);
const pendingNextResolution = ref(false);
const isDesktopLastWashCopying = ref(false);
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
let focusOnReg1TimeoutId = null;
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
const setTab = (tab) => {
@@ -117,7 +119,16 @@ watch(
);
const focusOnReg1 = () => {
setTimeout(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
}
focusOnReg1TimeoutId = setTimeout(() => {
focusOnReg1TimeoutId = null;
if (typeof document === "undefined") {
return;
}
const reg1Input = document.getElementById("reg_1");
if (reg1Input) {
reg1Input.focus();
@@ -267,12 +278,8 @@ const bookingSelectionObjects = computed(() => {
const contentSegments = [
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
`${t("common.reference")}: ${
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
}`,
`${t("common.services")}: ${
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
}`,
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
];
return {
@@ -639,7 +646,7 @@ const pushDesktopStepTwoRoute = () => {
return;
}
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
};
const handleDesktopLastWashCopy = async (payload = {}) => {
@@ -681,6 +688,10 @@ onMounted(() => {
});
onBeforeUnmount(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
focusOnReg1TimeoutId = null;
}
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
@@ -750,13 +761,9 @@ watch(
</div>
<div class="pos-shell-actions">
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="2" class="is-fullwidth" />
<Cancel tabindex="5" class="is-fullwidth" />
</ButtonsBox>
<div
v-if="shouldShowActionRailControls"
class="pos-shell-actions__rail"
data-testid="pos-step-1-action-rail"
>
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
<PosDesktopDuplicateWarning
v-if="shouldShowDuplicateWarningInActionRail"
:title="t('admin.pos.warning')"
@@ -24,70 +24,77 @@ import {
} from "./objects/PosDepartmentStepMobileFlow.vue";
import { PosSearchResult } from "./objects/PosSearchResult.vue";
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import {
LPR_FRAME_CLIENT_BYTES_FIELD,
LPR_FRAME_CLIENT_CAPTURE_MS_FIELD,
LPR_FRAME_CLIENT_DRAW_MS_FIELD,
LPR_FRAME_CLIENT_ENCODE_MS_FIELD,
LPR_FRAME_CLIENT_HEIGHT_FIELD,
LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
LPR_FRAME_CLIENT_WIDTH_FIELD,
getVisualFingerprintDistance,
type LPRFrameEncodeCandidate,
type LPRFramePayload,
type LPRFrameViewportRect,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
// Debug mode flag
const debug_mode = ref(false);
// Debug array to store request results
const debug_request_results = ref([]);
const debug_request_results = ref<unknown[]>([]);
const lastParsedImage = ref(null);
const setLastCapturedImage = (image: string) => {
camera.latestImage.value = image;
type ParsedFrameFingerprint = {
content: string | null;
contentFingerprintPromise: Promise<string> | null;
getContentFingerprint: (() => Promise<string>) | null;
getVisualFingerprint: (() => string | null) | null;
outcome: "pending" | "miss" | "success";
quick: string;
visual: string | null;
};
const lastParsedImage = ref<ParsedFrameFingerprint | null>(null);
const scannerFocusRef = ref<HTMLElement | null>(null);
type LPRResponse = {
success: boolean;
license_plate_number: string;
};
const latestLPRResponse = ref<LPRResponse | null>(null);
const LPR_IMAGE_MAX_WIDTH = 1280;
const LPR_IMAGE_MAX_HEIGHT = 720;
const LPR_IMAGE_JPEG_QUALITY = 0.72;
const compressImageForLPR = (image: string): Promise<string> => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const sourceWidth = img.naturalWidth || img.width;
const sourceHeight = img.naturalHeight || img.height;
if (!sourceWidth || !sourceHeight) {
resolve(image);
return;
}
const scale = Math.min(1, LPR_IMAGE_MAX_WIDTH / sourceWidth, LPR_IMAGE_MAX_HEIGHT / sourceHeight);
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext("2d");
if (!context) {
resolve(image);
return;
}
context.drawImage(img, 0, 0, targetWidth, targetHeight);
resolve(canvas.toDataURL("image/jpeg", LPR_IMAGE_JPEG_QUALITY));
};
img.onerror = () => resolve(image);
img.src = image;
});
type LPRScanContext = {
activeVehicleIndex: number;
attachmentView: boolean;
manualInput: boolean;
registrationNumbers: string[];
transactionHistoryView: boolean;
};
const latestLPRResponse = ref<LPRResponse | null>(null);
const isLPRFrameProcessing = ref(false);
const isLPRRequestInFlight = ref(false);
const isNoPlateBackoffActive = ref(false);
const isDuplicateFrameBackoffActive = ref(false);
const isSuccessCooldownActive = ref(false);
let lprRequestAbortController: AbortController | null = null;
let noPlateBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
let duplicateFrameBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
let successCooldownTimerId: ReturnType<typeof window.setTimeout> | null = null;
const NO_PLATE_BACKOFF_DELAYS_MS = [1500, 2500, 4000];
const LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD = 4;
const LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS = 700;
const LPR_ENDPOINT = "/modules/scanner/lpr";
let consecutiveNoPlateResponses = 0;
const nowMs = (): number =>
typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
const activeVehicleIndexNext = () => {
// Increment the active vehicle index, wrapping around if necessary
if (vehicles.activeVehicleIndex.value < 3) {
@@ -115,55 +122,486 @@ const handleLPRResult = () => {
}
};
const parseImage = async (image: string) => {
type LPRFrameInput = string | LPRFramePayload;
const isLPRFramePayload = (image: LPRFrameInput): image is LPRFramePayload =>
typeof image === "object" && image !== null && image.blob instanceof Blob;
const getLPRFrameFingerprint = (image: LPRFrameInput): string => (isLPRFramePayload(image) ? image.fingerprint : image);
const getLPRFrameContentFingerprint = (image: LPRFrameInput): (() => Promise<string>) | null =>
isLPRFramePayload(image) ? image.getContentFingerprint ?? null : null;
const getLPRFrameVisualFingerprint = (image: LPRFrameInput): string | null =>
isLPRFramePayload(image) ? image.visualFingerprint ?? null : null;
const getLPRFrameVisualFingerprintGetter = (image: LPRFrameInput): (() => string | null) | null =>
isLPRFramePayload(image) ? image.getVisualFingerprint ?? null : null;
const rememberParsedImageFingerprint = (image: LPRFrameInput) => {
const quick = getLPRFrameFingerprint(image);
const getContentFingerprint = getLPRFrameContentFingerprint(image);
const entry: ParsedFrameFingerprint = {
content: getContentFingerprint === null ? quick : null,
contentFingerprintPromise: null,
getContentFingerprint,
getVisualFingerprint: getLPRFrameVisualFingerprintGetter(image),
outcome: "pending",
quick,
visual: getLPRFrameVisualFingerprint(image),
};
lastParsedImage.value = entry;
};
const markLastParsedImageOutcome = (outcome: ParsedFrameFingerprint["outcome"]) => {
if (lastParsedImage.value !== null) {
if (outcome === "miss" && lastParsedImage.value.visual === null) {
lastParsedImage.value.visual = lastParsedImage.value.getVisualFingerprint?.() ?? null;
}
lastParsedImage.value.outcome = outcome;
}
};
const resetParsedImageFingerprint = () => {
lastParsedImage.value = null;
};
const resolveParsedFrameContentFingerprint = (entry: ParsedFrameFingerprint): Promise<string> | null => {
if (entry.content !== null) {
return Promise.resolve(entry.content);
}
if (entry.getContentFingerprint === null) {
return null;
}
entry.contentFingerprintPromise ??= entry
.getContentFingerprint()
.then((content) => {
if (lastParsedImage.value === entry) {
entry.content = content;
}
return content;
})
.catch((error) => {
if (lastParsedImage.value === entry) {
entry.contentFingerprintPromise = null;
}
throw error;
});
return entry.contentFingerprintPromise;
};
const isVisuallySimilarToLastMiss = (visualFingerprint: string | null | undefined): boolean => {
const lastParsed = lastParsedImage.value;
return (
lastParsed !== null &&
lastParsed.outcome === "miss" &&
getVisualFingerprintDistance(lastParsed.visual, visualFingerprint) <= LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD
);
};
const hasLastMissVisualFingerprint = (): boolean =>
lastParsedImage.value !== null && lastParsedImage.value.outcome === "miss" && lastParsedImage.value.visual !== null;
const shouldBuildLPRVisualFingerprint = (): boolean => hasLastMissVisualFingerprint();
const shouldSkipDuplicateFrame = async (image: LPRFrameInput): Promise<boolean> => {
const quick = getLPRFrameFingerprint(image);
const lastParsed = lastParsedImage.value;
if (lastParsed === null) {
return false;
}
if (isVisuallySimilarToLastMiss(getLPRFrameVisualFingerprint(image))) {
return true;
}
if (lastParsed.quick !== quick) {
return false;
}
const currentContentFingerprint = getLPRFrameContentFingerprint(image);
const lastContentFingerprint = resolveParsedFrameContentFingerprint(lastParsed);
if (lastContentFingerprint === null || currentContentFingerprint === null) {
return true;
}
try {
const [lastContent, currentContent] = await Promise.all([lastContentFingerprint, currentContentFingerprint()]);
return lastContent === currentContent;
} catch {
return false;
}
};
const appendFiniteTimingParam = (queryParts: string[], field: string, value: number | null | undefined) => {
if (value === null || value === undefined) {
return;
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return;
}
const roundedValue = numericValue.toFixed(3);
if (Number(roundedValue) > 0) {
queryParts.push(`${field}=${roundedValue}`);
}
};
const appendPositiveIntegerParam = (queryParts: string[], field: string, value: number | null | undefined) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return;
}
queryParts.push(`${field}=${Math.round(numericValue)}`);
};
type LPRRequestPayload = Blob | { base64_image: string };
type LPRRequestBuildResult = {
headers?: Record<string, string>;
payload: LPRRequestPayload;
url: string;
};
const buildLPRRequestPayload = (
image: LPRFrameInput,
clientPreflightDurationMs: number | null = null
): LPRRequestBuildResult => {
if (!isLPRFramePayload(image)) {
return {
payload: { base64_image: image },
url: LPR_ENDPOINT,
};
}
const headers: Record<string, string> = {
"Content-Type": image.mimeType || image.blob.type || "image/jpeg",
};
const queryParts: string[] = [];
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_CAPTURE_MS_FIELD, image.captureDurationMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD, clientPreflightDurationMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_DRAW_MS_FIELD, image.captureTimings?.drawMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_ENCODE_MS_FIELD, image.captureTimings?.encodeMs);
appendFiniteTimingParam(
queryParts,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
image.captureTimings?.visualFingerprintMs
);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_WIDTH_FIELD, image.width);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_HEIGHT_FIELD, image.height);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_BYTES_FIELD, image.blob.size);
const queryString = queryParts.join("&");
return {
headers,
payload: image.blob,
url: queryString ? `${LPR_ENDPOINT}?${queryString}` : LPR_ENDPOINT,
};
};
type LPRCurrentStateSkipOptions = {
ignoreNoPlateBackoff?: boolean;
};
const shouldEncodeLPRFrame = (candidate: LPRFrameEncodeCandidate): boolean => {
if (views.attachmentView.value) {
return true;
}
if (
shouldSkipLPRForCurrentState({ ignoreNoPlateBackoff: true }) ||
isLPRFrameProcessing.value ||
isLPRRequestInFlight.value
) {
return false;
}
if (isNoPlateBackoffActive.value) {
if (!hasLastMissVisualFingerprint() || isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
return false;
}
clearNoPlateBackoff();
return true;
}
if (!isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
return true;
}
scheduleDuplicateFrameBackoff();
return false;
};
const rememberLatestCameraImage = (image: LPRFrameInput) => {
if (isLPRFramePayload(image)) {
camera.setLatestImageBlob(image.blob);
return;
}
camera.setLatestImage(image);
};
const hasRegistrationNumber = (registrationNumber: string | null | undefined): boolean =>
String(registrationNumber ?? "").trim().length > 0;
const isActiveRegistrationSlotFilled = (): boolean => hasRegistrationNumber(vehicles.getActiveVehicle()?.reg);
const areAllRegistrationSlotsFilled = (): boolean =>
[1, 2, 3].every((vehicleIndex) => hasRegistrationNumber(vehicles.get(vehicleIndex)?.reg));
const shouldSkipLPRForCurrentState = (options: LPRCurrentStateSkipOptions = {}): boolean =>
views.attachmentView.value ||
isActiveRegistrationSlotFilled() ||
areAllRegistrationSlotsFilled() ||
(!options.ignoreNoPlateBackoff && isNoPlateBackoffActive.value) ||
isDuplicateFrameBackoffActive.value ||
isSuccessCooldownActive.value;
const getScannerFocusViewportRect = (): LPRFrameViewportRect | null => {
if (views.attachmentView.value || scannerFocusRef.value === null) {
return null;
}
const rect = scannerFocusRef.value.getBoundingClientRect();
if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height) || rect.width <= 0 || rect.height <= 0) {
return null;
}
return {
height: rect.height,
width: rect.width,
x: rect.left,
y: rect.top,
};
};
const isCameraFrameCaptureEnabled = computed(() => {
if (views.attachmentView.value) {
return true;
}
return (
!isLPRFrameProcessing.value &&
!isLPRRequestInFlight.value &&
(!isNoPlateBackoffActive.value || hasLastMissVisualFingerprint()) &&
!isDuplicateFrameBackoffActive.value &&
!isSuccessCooldownActive.value &&
!isActiveRegistrationSlotFilled() &&
!areAllRegistrationSlotsFilled()
);
});
const lprCameraCaptureIntervalMs = computed(() =>
!views.attachmentView.value && isNoPlateBackoffActive.value && hasLastMissVisualFingerprint()
? LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS
: null
);
const abortLPRRequest = () => {
lprRequestAbortController?.abort();
lprRequestAbortController = null;
};
const isDocumentHidden = (): boolean => typeof document !== "undefined" && document.visibilityState === "hidden";
const handleDocumentVisibilityChange = () => {
if (!isDocumentHidden()) {
return;
}
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
};
const clearNoPlateBackoff = () => {
if (noPlateBackoffTimerId !== null) {
window.clearTimeout(noPlateBackoffTimerId);
noPlateBackoffTimerId = null;
}
isNoPlateBackoffActive.value = false;
};
const clearDuplicateFrameBackoff = () => {
if (duplicateFrameBackoffTimerId !== null) {
window.clearTimeout(duplicateFrameBackoffTimerId);
duplicateFrameBackoffTimerId = null;
}
isDuplicateFrameBackoffActive.value = false;
};
const clearSuccessCooldown = () => {
if (successCooldownTimerId !== null) {
window.clearTimeout(successCooldownTimerId);
successCooldownTimerId = null;
}
isSuccessCooldownActive.value = false;
};
const resetNoPlateBackoff = () => {
consecutiveNoPlateResponses = 0;
clearNoPlateBackoff();
clearDuplicateFrameBackoff();
clearSuccessCooldown();
};
const scheduleNoPlateBackoff = () => {
consecutiveNoPlateResponses += 1;
const delay =
NO_PLATE_BACKOFF_DELAYS_MS[Math.min(consecutiveNoPlateResponses - 1, NO_PLATE_BACKOFF_DELAYS_MS.length - 1)];
clearNoPlateBackoff();
isNoPlateBackoffActive.value = true;
noPlateBackoffTimerId = window.setTimeout(() => {
noPlateBackoffTimerId = null;
isNoPlateBackoffActive.value = false;
}, delay);
};
const scheduleDuplicateFrameBackoff = () => {
clearDuplicateFrameBackoff();
isDuplicateFrameBackoffActive.value = true;
duplicateFrameBackoffTimerId = window.setTimeout(() => {
duplicateFrameBackoffTimerId = null;
isDuplicateFrameBackoffActive.value = false;
}, Math.min(camera.getImageCaptureDelay(false), LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS));
};
const scheduleSuccessCooldown = () => {
clearSuccessCooldown();
isSuccessCooldownActive.value = true;
successCooldownTimerId = window.setTimeout(() => {
successCooldownTimerId = null;
isSuccessCooldownActive.value = false;
}, camera.getImageCaptureDelayAfterSuccess());
};
const isAbortError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
return (
typeof error === "object" && error !== null && (error as { name?: string; code?: string }).code === "ERR_CANCELED"
);
};
const isSameLPRScanContext = (first: LPRScanContext, second: LPRScanContext): boolean =>
first.activeVehicleIndex === second.activeVehicleIndex &&
first.attachmentView === second.attachmentView &&
first.manualInput === second.manualInput &&
first.transactionHistoryView === second.transactionHistoryView &&
first.registrationNumbers.length === second.registrationNumbers.length &&
first.registrationNumbers.every(
(registrationNumber, index) => registrationNumber === second.registrationNumbers[index]
);
const parseImage = async (image: LPRFrameInput) => {
if (views.attachmentView.value) {
rememberLatestCameraImage(image);
return;
}
if (shouldSkipLPRForCurrentState() || isLPRFrameProcessing.value || isLPRRequestInFlight.value) {
return;
}
// Check if the time since the last successful parse is enough
if (!camera.hasDelayAfterSuccessPassed()) {
return;
}
if (lastParsedImage.value === image) {
// If the image is the same as the last parsed one, skip parsing
return;
}
lastParsedImage.value = image; // Update the last parsed image
camera.setLatestImage(image); // Update the latest image in the camera object
// Function to parse the image data
const compressedImage = await compressImageForLPR(image);
SessionUser.request("/modules/scanner/lpr", "POST", {
base64_image: compressedImage,
})
.then((response) => {
isLPRFrameProcessing.value = true;
try {
const clientPreflightStartedAt = nowMs();
const isDuplicateFrame = await shouldSkipDuplicateFrame(image);
const clientPreflightDurationMs = Math.max(0, nowMs() - clientPreflightStartedAt);
if (shouldSkipLPRForCurrentState()) {
return;
}
if (isDuplicateFrame) {
// If the image is the same as the last parsed one, skip parsing
scheduleDuplicateFrameBackoff();
return;
}
rememberParsedImageFingerprint(image); // Update the last parsed image
isLPRRequestInFlight.value = true;
const abortController = new AbortController();
lprRequestAbortController = abortController;
const requestScanContext = getLPRScanContext();
const lprRequest = buildLPRRequestPayload(image, clientPreflightDurationMs);
try {
const response = await SessionUser.request(lprRequest.url, "POST", lprRequest.payload, null, null, {
...(lprRequest.headers ? { headers: lprRequest.headers } : {}),
signal: abortController.signal,
transport: "fetch",
});
if (debug_mode.value) {
debug_request_results.value.push(response);
}
// If the response is not successful, stop here.
if (!response.data.success) {
if (!isSameLPRScanContext(requestScanContext, getLPRScanContext())) {
return;
}
// If the response is not successful, stop here.
if (!response.data.success) {
markLastParsedImageOutcome("miss");
scheduleNoPlateBackoff();
return;
}
resetNoPlateBackoff();
markLastParsedImageOutcome("success");
rememberLatestCameraImage(image);
latestLPRResponse.value = response.data.data as LPRResponse;
// Set the last successful capture time
camera.setLastSuccess();
scheduleSuccessCooldown();
// Handle parsed result.
handleLPRResult();
})
.catch((error) => {
} catch (error) {
if (isAbortError(error)) {
return;
}
if (debug_mode.value) {
debug_request_results.value.push(error);
}
markLastParsedImageOutcome("miss");
scheduleNoPlateBackoff();
//console.error("Error parsing image:", error);
});
} finally {
if (lprRequestAbortController === abortController) {
lprRequestAbortController = null;
}
isLPRRequestInFlight.value = false;
}
} finally {
isLPRFrameProcessing.value = false;
}
};
watch(manualInput, (newValue) => {
// Update the header transparency when manualInput changes
setTransparency(!newValue);
});
type statusIcon =
| typeof VerifiedCustomer
| typeof KnownCustomer
| typeof UnknownCustomer
| typeof CardPaymentCustomer
| typeof BookedCustomer;
const registrationNumbers = computed(() => {
// Return the registration numbers of all vehicles
return [
@@ -173,6 +611,14 @@ const registrationNumbers = computed(() => {
];
});
const getLPRScanContext = (): LPRScanContext => ({
activeVehicleIndex: vehicles.activeVehicleIndex.value,
attachmentView: views.attachmentView.value,
manualInput: manualInput.value,
registrationNumbers: [...registrationNumbers.value],
transactionHistoryView: transactionHistoryView.value,
});
function getSearchResultIndex(object: PosSearchResult) {
let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index
// Check if the registration number already exists in the vehicles (And update the vehicle if it does)
@@ -228,27 +674,49 @@ const getQuery = computed(() => {
return activeVehicle ? activeVehicle.reg : "";
});
const activeSelectionHasRegistrationNumber = computed(() => {
return vehicles.getActiveVehicle()?.reg && vehicles.getActiveVehicle()?.reg.length > 0;
});
const shouldCustomerBeModified = computed(() => {
// Check if the customer should be modified based on the active vehicle index
return vehicles.activeVehicleIndex.value === 1;
});
onMounted(() => {
document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
// Set the header to be transparent
setTransparency(!views.isAnyActive.value);
setBackgroundColor(backgroundColors.default); // Set the default background color
setOverflow(false); // Prevent scrolling
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
abortLPRRequest();
clearNoPlateBackoff();
clearDuplicateFrameBackoff();
clearSuccessCooldown();
// Reset the header settings when the component is unmounted
setTransparency(false); // Reset transparency
setBackgroundColor(backgroundColors.default); // Reset to default background color
setOverflow(true); // Allow scrolling again
});
watch(
() => [manualInput.value, transactionHistoryView.value, views.attachmentView.value],
([isManualInputActive, isTransactionHistoryActive, isAttachmentViewActive]) => {
if (isManualInputActive || isTransactionHistoryActive || isAttachmentViewActive) {
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
}
}
);
watch(
() => [vehicles.activeVehicleIndex.value, ...registrationNumbers.value],
() => {
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
}
);
</script>
<template>
@@ -263,7 +731,16 @@ onUnmounted(() => {
<!-- Default: Scanner view -->
<template v-else>
<div class="background-fixed">
<ScannerCamera @update:frame="parseImage" />
<ScannerCamera
:capture-enabled="isCameraFrameCaptureEnabled"
:capture-interval-ms="lprCameraCaptureIntervalMs"
:capture-mode="views.attachmentView.value ? 'preview' : 'lpr'"
:get-focus-viewport-rect="getScannerFocusViewportRect"
:pause-preview="false"
:should-build-visual-fingerprint="shouldBuildLPRVisualFingerprint"
:should-encode-frame="shouldEncodeLPRFrame"
@update:frame="parseImage"
/>
</div>
<div class="custom-content" data-testid="pos-mobile-step-1">
<!-- Meta objects, registration number auto-lookup -->
@@ -289,7 +766,9 @@ onUnmounted(() => {
/>
<!-- Scanner outline object -->
<div class="is-align-content-center is-flex is-justify-content-center">
<ScannerOutline :loading="false" v-if="!views.attachmentView.value" />
<div v-if="!views.attachmentView.value" ref="scannerFocusRef" class="scanner-focus-target">
<ScannerOutline :loading="false" />
</div>
</div>
<!-- Reg. 1, Reg. 2, Reg. 3 -->
<div
@@ -303,7 +782,7 @@ onUnmounted(() => {
<!-- Location -->
<PosDepartmentStepMobile1Location />
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :use-backdrop-blur="views.attachmentView.value">
<!-- Attachments -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
@@ -429,4 +908,10 @@ onUnmounted(() => {
.custom-content > * {
width: min(100%, 48rem);
}
.scanner-focus-target {
display: inline-flex;
max-width: 100%;
width: fit-content;
}
</style>
@@ -53,6 +53,9 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/depar
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
onMounted(() => {
// Set the header to be transparent
@@ -477,6 +480,8 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
const layout = {
classes: <string[]>[],
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const onCopyLastOrder = (vehicleIndex: number) => {
lastOrders.select(vehicleIndex);
@@ -777,15 +782,18 @@ const buildDesiredOrderItemShapes = () => {
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => ({
kind: "addon",
relatedKey: "primary",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addon?.product?.price ?? addon?.price ?? 0),
notes: String(addon?.product?.notes ?? ""),
}));
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return {
kind: "addon",
relatedKey: "primary",
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
};
});
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -952,16 +960,17 @@ const syncCurrentTransactionToOrder = async () => {
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) =>
createOrderItem(
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return createOrderItem(
normalizedOrderId,
addon.product.id,
addonProduct.id,
Number(addon.quantity),
createdPrimaryItemId,
addon.product?.notes || "",
addon.product.price
)
);
addonProduct?.notes || addon?.notes || "",
addonProduct.price ?? addon.price
);
});
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -973,11 +982,146 @@ const syncCurrentTransactionToOrder = async () => {
return true;
};
const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
const productRequiresOrderItemNote = (product: any) => {
if (!product) {
return false;
}
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
const primaryProduct = transactionItems.primaryItem.value;
if (productRequiresOrderItemNote(primaryProduct) && !productHasOrderItemNote(primaryProduct)) {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
(productRequiresOrderItemNote(addonProduct) || productRequiresOrderItemNote(addon)) &&
!productHasOrderItemNote(addonProduct) &&
!productHasOrderItemNote(addon)
) {
missingProducts.push(addonProduct);
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
}
});
return missingProducts;
};
const promptForRequiredProductNote = (product: any) =>
new Promise<boolean>((resolve) => {
const hadOriginalNote = Object.prototype.hasOwnProperty.call(product, "notes");
const originalNote = product?.notes;
const resolveAndClose = (didConfirm: boolean) => {
if (!didConfirm) {
if (hadOriginalNote) {
product.notes = originalNote;
} else {
delete product.notes;
}
}
if (popups.get()?.id === "add_product_note") {
popups.clear();
}
resolve(didConfirm);
};
popups.select("add_product_note", {
title: `${t("common.note")}: ${getProductName(product) || `#${getProductId(product)}`}`,
message: t("objects.products.columns.requires_note"),
component: "add_product_note",
hideHeader: true,
style: { maxHeight: "40vh" },
props: {
product,
validationMessage: "",
},
actionButtons: [
{
label: t("common.confirm"),
description: t("common.confirm"),
color: "primary",
testId: "pos-mobile-product-note-confirm",
onClick: () => {
const activePopup = popups.get();
const normalizedNote = normalizeText(activePopup?.props?.product?.notes);
if (!normalizedNote) {
if (activePopup?.props) {
activePopup.props.validationMessage = t("objects.products.columns.requires_note");
}
return;
}
product.notes = normalizedNote;
resolveAndClose(true);
},
},
{
label: t("common.cancel"),
description: t("common.cancel"),
color: "light",
testId: "pos-mobile-product-note-cancel",
onClick: () => resolveAndClose(false),
},
],
});
});
const ensureRequiredOrderItemNotes = async () => {
const missingProducts = getSelectedProductsMissingRequiredNotes();
for (const product of missingProducts) {
if (productHasOrderItemNote(product)) {
continue;
}
const didConfirm = await promptForRequiredProductNote(product);
if (!didConfirm) {
return false;
}
}
return true;
};
const onBeforeComplete = async () => {
if (!transactionItems.primaryItem.value) {
throw new Error("No primary item selected");
}
if (!(await ensureRequiredOrderItemNotes())) {
return false;
}
await syncCurrentTransactionToOrder();
return true;
};
@@ -10,6 +10,7 @@ import {
reg_1,
reset_all_values,
setStep,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue";
@@ -70,10 +71,10 @@ const formatCurrency = (amount) => {
const navigateToItems = () => {
setStep(2);
if (normalizedOrderId.value) {
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
pushPosRouteState(`id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
return;
}
window.history.pushState({}, '', `?step=2`);
pushPosRouteState("step=2");
};
const clearSuccessfulMobileCardFlow = () => {
@@ -2,25 +2,43 @@
import { useGeolocation } from "@vueuse/core";
import { watch } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
const props = withDefaults(defineProps<{
enableHighAccuracy?: boolean;
maximumAge?: number;
timeout?: number;
}>(), {
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 27000,
});
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: props.enableHighAccuracy,
maximumAge: props.maximumAge,
timeout: props.timeout,
});
const getLocationTimestamp = () => {
const timestamp = Number(locatedAt.value);
return Number.isFinite(timestamp) ? timestamp : Date.now();
};
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
if (newCoords.latitude && newCoords.longitude) {
const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
if (normalizedCoords) {
const timestamp = getLocationTimestamp();
locations.set({
coords: {
latitude: newCoords.latitude,
longitude: newCoords.longitude,
},
timestamp: new Date(),
coords: normalizedCoords,
timestamp: new Date(timestamp),
locatedAt: timestamp,
errorMessage: error.value ? error.value.message : null,
})
emits('location-updated', newCoords);
emits('location-updated', normalizedCoords);
}
};
watch(coords, (newCoords) => {
@@ -299,14 +299,12 @@ const getSelectedBookingId = () => {
const completeStep2Order = async ({
bookingSafetySeal = null,
markOrderCompleted = false,
}: {
bookingSafetySeal?: string | null;
markOrderCompleted?: boolean;
} = {}) => {
await finalizeCurrentMobileOrder({
bookingSafetySeal,
markOrderCompleted,
markOrderCompleted: true,
});
popups.select("completed_transaction", {
message: `Order #${order_id.value} successfully created.`,
@@ -327,14 +325,12 @@ const step2 = async () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal();
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
try {
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
syncMobileSafetySealState(resolvedSafetySeal);
await completeStep2Order({
bookingSafetySeal: resolvedSafetySeal,
markOrderCompleted: false,
});
return;
}
@@ -344,7 +340,6 @@ const step2 = async () => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ""),
markOrderCompleted: false,
});
};
@@ -358,9 +353,7 @@ const step2 = async () => {
return;
}
await completeStep2Order({
markOrderCompleted: shouldMarkOrderAsCompleted,
});
await completeStep2Order();
} catch (error: any) {
errors.value.push(error);
console.warn("An error occurred while completing the mobile order:", error);
@@ -412,7 +405,10 @@ const onClick = async () => {
isProcessingClick.value = true;
try {
await props.onBeforeStep();
const beforeStepResult = await props.onBeforeStep();
if (beforeStepResult === false) {
return;
}
// Proceed to the next step
switch (step.value) {
case 1:
@@ -11,6 +11,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
useBackdropBlur: {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'default',
@@ -25,6 +29,13 @@ const props = defineProps({
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
const style = computed(() => {
const background = `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`;
if (!props.useBackdropBlur) {
return {
background,
};
}
// Smooth blur transition
if (props.smoothBlur) {
return {
@@ -32,12 +43,13 @@ const style = computed(() => {
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`,
transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease',
// Add gradient from bottom
background: `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`,
background,
// Add gradient from top
}
}
return {
background,
backdropFilter: `blur(${props.blurAmount * 10}px)`,
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`
}
@@ -28,6 +28,7 @@ watch(note, (newNote) => {
class="input is-searched"
v-model="note"
type="text"
data-testid="pos-mobile-product-note-input"
placeholder="Indtast note"
/>
</div>
@@ -100,18 +100,57 @@ const getLocation = () => {
const clearLocation = () => {
location.value = null;
};
type CoordinatePair = {
latitude?: number | string | null;
longitude?: number | string | null;
};
export const normalizeCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
): { latitude: number; longitude: number } | null => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
};
export const hasValidCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
options: { allowZeroPair?: boolean } = {}
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
// Get distance in kilometers between two locations
const getDistance = (
from: { latitude: number; longitude: number },
to: { latitude: number; longitude: number }
from: CoordinatePair,
to: CoordinatePair
): number => {
const normalizedFrom = normalizeCoordinatePair(from);
const normalizedTo = normalizeCoordinatePair(to);
if (!normalizedFrom || !normalizedTo) {
return Number.POSITIVE_INFINITY;
}
const toRad = (value: number) => (value * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(to.latitude - from.latitude);
const dLon = toRad(to.longitude - from.longitude);
const lat1 = toRad(from.latitude);
const lat2 = toRad(to.latitude);
const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
const lat1 = toRad(normalizedFrom.latitude);
const lat2 = toRad(normalizedTo.latitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
@@ -124,6 +163,8 @@ const locations = {
set: setLocation,
get: getLocation,
clear: clearLocation,
normalizeCoordinatePair,
hasValidCoordinatePair,
getDistance,
defaultTimeout: locationTimeout,
};
@@ -1024,9 +1065,9 @@ const getTotalAttachmentsCount = () => {
);
};
// Function to take a picture as a base64 attachment
const takePicture = () => {
const takePicture = async () => {
// Save the last picture to the base64 attachments
const lastPicture = latestImage.value;
const lastPicture = await getLatestImage();
if (lastPicture) {
addAttachmentBase64({
filename: "last_picture.jpg",
@@ -1077,6 +1118,7 @@ watch(
/** Camera */
// Define the reactive properties
const latestImage = ref<string | null>(null);
const latestImageBlob = ref<Blob | null>(null);
const isCameraMounted = ref<boolean>(false);
const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture)
const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one)
@@ -1118,13 +1160,34 @@ const hasCameraImageCaptureDelayAfterSuccessPassed = (): boolean => {
const currentTime = Date.now();
return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value;
};
const blobToDataUrl = (blob: Blob): Promise<string | null> => new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => {
resolve(null);
};
reader.readAsDataURL(blob);
});
// Function to retrieve the latest image frame.
const getLatestImage = async () => {
if (!latestImage.value && latestImageBlob.value) {
latestImage.value = await blobToDataUrl(latestImageBlob.value);
}
return latestImage.value;
};
// Function to set the latest image frame.
const setLatestImage = (image: string | null) => {
latestImage.value = image;
latestImageBlob.value = null;
};
// Function to set the latest image frame as a Blob.
const setLatestImageBlob = (image: Blob | null) => {
latestImage.value = null;
latestImageBlob.value = image;
};
// Function to set the camera mounted state.
const setCameraMounted = (mounted: boolean) => {
@@ -1133,6 +1196,7 @@ const setCameraMounted = (mounted: boolean) => {
// Function to clear the latest image.
const clearLatestImage = () => {
latestImage.value = null;
latestImageBlob.value = null;
};
// Function to clear the camera mounted state.
const clearCameraMounted = () => {
@@ -1153,10 +1217,12 @@ const setCameraImageCaptureDelay = (isFirstCapture: boolean, delay: number) => {
const camera = {
latestImage,
latestImageBlob,
get: getLatestImage,
mounted: isCameraMounted,
setMounted: setCameraMounted,
setLatestImage,
setLatestImageBlob,
clearLatestImage,
clearMounted: clearCameraMounted,
getImageCaptureDelay: getCameraImageCaptureDelay,
@@ -18,7 +18,9 @@ export type PosLocation = {
coords?: PosLocationCoords | null;
/** Timestamp */
timestamp?: Date | null;
/** Browser geolocation timestamp in milliseconds */
locatedAt?: number | null;
/** Error message */
errorMessage?: string | null;
};
</script>
</script>
@@ -3,6 +3,45 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_PREFIX = "xlvask-usage-amount:";
const memoryCache = new Map();
const getStorageValue = (key) => {
try {
if (typeof window === "undefined" || !window.localStorage) {
return "";
}
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const hashCacheScopePart = (value) => {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
}
return (hash >>> 0).toString(36);
};
const getAuthenticatedCacheScope = () => {
const token = getStorageValue("token");
if (!token) {
return "";
}
return [
hashCacheScopePart(token),
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
getStorageValue("selected_customer_number"),
]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
};
const getScopedCacheKey = (cacheKey) => {
const scope = getAuthenticatedCacheScope();
return scope ? `${scope}:${cacheKey}` : "";
};
const safeSessionStorage = () => {
try {
if (typeof window === "undefined" || !window.sessionStorage) {
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
};
const normalizeUsageLogId = (objectOrId) => {
const value = typeof objectOrId === "object"
? objectOrId?.usage_log_id ?? objectOrId?.id
: objectOrId;
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
};
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
export const getCachedXlvaskUsageAmount = (objectOrId) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (!scopedCacheKey) {
return null;
}
const memoryEntry = memoryCache.get(cacheKey);
const memoryEntry = memoryCache.get(scopedCacheKey);
if (isFreshEntry(memoryEntry)) {
return memoryEntry.data;
}
if (memoryEntry) {
memoryCache.delete(cacheKey);
memoryCache.delete(scopedCacheKey);
}
const storage = safeSessionStorage();
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
return null;
}
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
memoryCache.set(cacheKey, parsed);
memoryCache.set(scopedCacheKey, parsed);
return parsed.data;
} catch {
storage.removeItem(storageKey);
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (!scopedCacheKey) {
return;
}
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
storedAt: Date.now(),
data,
};
memoryCache.set(cacheKey, entry);
memoryCache.set(scopedCacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
}
try {
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block XL Vask usage rows.
}
@@ -94,11 +133,12 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
const storage = safeSessionStorage();
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (cacheKey) {
memoryCache.delete(cacheKey);
if (scopedCacheKey) {
memoryCache.delete(scopedCacheKey);
try {
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
} catch {
// Best-effort cache cleanup.
}
@@ -0,0 +1,70 @@
<!--
ErrorBanner.vue reusable self-serve error notification bar.
Props:
message error text to display (blank hidden)
type Bulma tint: is-danger | is-warning (default is-danger)
iconLeft Font Awesome icon name
:loading whether retry button is in loading state
:showRetry whether to show the retry button
Emit:
retry fired when retry button is clicked
-->
<script setup lang="ts">
interface Props {
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}
withDefaults(
defineProps<{
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}>(),
{
type: "is-danger",
iconLeft: "sync-alt",
loading: false,
showRetry: true,
retryTestId: null,
}
);
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<b-message v-if="message" :type="type" has-icon :closable="false" class="self-serve-error-banner">
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ message }}</span>
<b-button
v-if="showRetry"
size="is-small"
:type="`${type} is-light`"
icon-pack="fas"
:icon-left="iconLeft"
:loading="loading"
:data-testid="retryTestId || undefined"
@click="emit('retry')"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
</template>
<style scoped>
.self-serve-error-banner {
margin-bottom: 1rem;
}
</style>
@@ -0,0 +1,59 @@
<!--
LaneSelectionSection.vue bane-valg radio buttons and lane status display.
Props:
lanes array of lane objects from the department
selectedLaneId currently selected lane id
washType "Manual" | "Machine"
departmentName human-readable department name
isLaneAvailableFn function(lane) => boolean
isMachineAvailableFn function(laneId) => boolean
Emit:
update:selectedLaneId new lane id selected
update:washType "Manual" | "Machine"
-->
<script setup lang="ts">
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
interface Props {
lanes: any[];
selectedLaneId: number | string | null;
washType: string;
departmentName: string | null;
isLaneAvailableFn: (lane: any) => boolean;
isMachineAvailableFn: (laneId: number | string | null) => boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:selectedLaneId": [id: number];
"update:washType": [type: string];
}>();
function handleLaneUpdate(id: number) {
emit("update:selectedLaneId", id);
}
function handleWashTypeUpdate(type: string) {
emit("update:washType", type);
}
</script>
<template>
<SelfServeLaneStep
:lanes="lanes"
:selected-lane-id="selectedLaneId"
:wash-type="washType"
:department-name="departmentName"
:is-lane-available="isLaneAvailableFn"
:is-machine-available="isMachineAvailableFn"
@update:selected-lane-id="handleLaneUpdate"
@update:wash-type="handleWashTypeUpdate"
/>
</template>
<style scoped>
/* Lane selection styling is inherited from SelfServeLaneStep */
</style>
@@ -93,8 +93,6 @@ const emit = defineEmits<{
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable(selectedLaneId)"
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
@@ -21,16 +21,6 @@ const isImageAttachment = (attachment: any) => (
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
);
const hasTaskDescription = (task: any) => {
const description = task?.description;
if (typeof description !== "string") {
return !!description;
}
const trimmedDescription = description.trim();
return trimmedDescription.length > 0 && trimmedDescription !== "-";
};
const getImageAttachments = (task: any) => {
if (!Array.isArray(task?.attachments)) {
return [];
@@ -83,7 +73,7 @@ const formatTaskTitle = (task: any) => {
const title = String(task?.task || "");
const programWheelSelection = getProgramWheelSelection(task);
if (programWheelSelection === null) {
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
return title;
}
@@ -127,13 +117,11 @@ const formatTaskTitle = (task: any) => {
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="self-serve-task-image-overlay-text">
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
</div>
</div>
</div>
@@ -156,22 +144,21 @@ const formatTaskTitle = (task: any) => {
</div>
</div>
</template>
<div v-else class="columns is-mobile is-vcentered">
<div v-if="props.showCheckboxes" class="column is-narrow">
<div v-else class="self-serve-task-row">
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
<b-field>
<b-checkbox
class="mr-0 pr-0"
size="is-large"
:model-value="completedTasks[task.id] === true"
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="column">
<div class="self-serve-task-content">
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
</div>
@@ -239,7 +226,7 @@ const formatTaskTitle = (task: any) => {
padding: 0.65rem 0.75rem;
display: flex;
align-items: flex-start;
gap: 0.6rem;
gap: 0.35rem;
pointer-events: auto;
}
@@ -247,12 +234,30 @@ const formatTaskTitle = (task: any) => {
margin-bottom: 0;
}
.self-serve-task-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.self-serve-task-checkbox {
flex: 0 0 auto;
}
.self-serve-task-checkbox :deep(.field) {
margin-bottom: 0;
}
.self-serve-task-content {
flex: 1 1 auto;
min-width: 0;
}
.self-serve-task-content p {
margin-bottom: 0;
}
.self-serve-task-title {
margin: 0;
}
.self-serve-task-description {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import { computed, ref, watch } from "vue";
import { BIcon } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -19,10 +19,21 @@ const emit = defineEmits<{
}>();
const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const hasRenderableDynamicImageUrl = computed(
() => !!props.dynamicImageUrl && failedDynamicImageUrl.value !== props.dynamicImageUrl
);
const showDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value && !isDynamicImageLoading.value);
const shouldRenderDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
}, { immediate: true });
watch(
() => props.dynamicImageUrl,
(dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
},
{ immediate: true }
);
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
@@ -38,6 +49,7 @@ const onDynamicImageLoad = () => {
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image");
};
</script>
@@ -58,33 +70,43 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
:src="dynamicImageUrl"
alt=""
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
<div
v-if="dynamicImageUrl"
v-if="shouldRenderDynamicImageFrame"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
<div
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
<span class="is-sr-only">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="showDynamicImageFrame"
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
</div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
<h1 class="title has-text-centered mb-2">{{ $t("self_wash.start_machine") }}</h1>
<SelfServeTaskList
:tasks="activeTasks"
:completedTasks="completedTasks"
@@ -107,19 +129,31 @@ const onDynamicImageError = () => {
overflow: hidden;
}
.self-serve-dynamic-image-skeleton {
align-items: center;
aspect-ratio: 16 / 9;
background: #edf2f7;
color: #112f5f;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
}
.self-serve-dynamic-image-preload {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
@@ -128,8 +162,4 @@ const onDynamicImageError = () => {
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
@@ -21,8 +21,11 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "update:customerNumber", value: string): void;
(e: "update:customer-number", value: string): void;
(e: "update:registrationNumber", value: string): void;
(e: "update:registration-number", value: string): void;
(e: "select-vehicle-type", selection: any): void;
(e: "selected", selection: any): void;
}>();
const extractRegistrationValue = (value: unknown): string => {
@@ -49,6 +52,19 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
const filteredRegistrationOptions = computed(() => {
const query = normalizeRegistration(typedRegistration.value);
const options = props.registrationOptions || [];
if (!query) {
return options;
}
return options.filter((option) => {
const normalizedOption = normalizeRegistration(option);
return normalizedOption !== query && normalizedOption.includes(query);
});
});
watch(() => props.registrationNumber, (newValue) => {
typedRegistration.value = normalizeRegistration(newValue);
});
@@ -57,6 +73,18 @@ const emitRegistration = (value: unknown) => {
const normalized = normalizeRegistration(value);
typedRegistration.value = normalized;
emit("update:registrationNumber", normalized);
emit("update:registration-number", normalized);
};
const emitCustomerNumber = (value: unknown) => {
const normalized = String(value || "");
emit("update:customerNumber", normalized);
emit("update:customer-number", normalized);
};
const emitVehicleTypeSelection = (selection: any) => {
emit("select-vehicle-type", selection);
emit("selected", selection);
};
</script>
@@ -68,38 +96,41 @@ const emitRegistration = (value: unknown) => {
:model-value="customerNumber || ''"
data-testid="self-serve-customer-number"
:placeholder="$t('self_wash.enter_customer_number')"
@input="emit('update:customerNumber', String($event || ''))"
@input="emitCustomerNumber($event)"
@update:modelValue="emitCustomerNumber($event)"
/>
</b-field>
<b-field :label="$t('self_wash.registration_number')">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="registrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
icon-pack="fas"
icon="car-side"
clearable
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
:selectable-header="true"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
@select-header="emitRegistration(typedRegistration)"
>
<template #header>
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
<div class="self-serve-registration-control">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="filteredRegistrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
icon-pack="fas"
icon="car-side"
clearable
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
>
</b-autocomplete>
<p
v-if="typedRegistration"
class="help self-serve-registration-guidance"
data-testid="self-serve-registration-guidance"
>
<template v-if="hasMatchingVehicle">
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
</template>
<template v-else>
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
</template>
</template>
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</p>
</div>
</b-field>
<b-message
v-if="props.vehicleStepError"
@@ -124,7 +155,7 @@ const emitRegistration = (value: unknown) => {
<SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds"
@selected="emit('select-vehicle-type', $event)"
@selected="emitVehicleTypeSelection"
/>
</template>
<template v-else-if="vehicleTypes.length === 0">
@@ -140,3 +171,34 @@ const emitRegistration = (value: unknown) => {
</b-field>
</div>
</template>
<style scoped>
.self-serve-registration-control {
display: block;
width: 100%;
min-width: 0;
}
.self-serve-registration-guidance {
display: block;
margin-top: 0.5rem;
line-height: 1.35;
white-space: normal;
}
.self-serve-registration-field :deep(.dropdown),
.self-serve-registration-field :deep(.autocomplete) {
width: 100%;
}
.self-serve-registration-field :deep(.dropdown-menu) {
left: 0;
right: 0;
width: 100%;
max-width: 100%;
}
.self-serve-registration-field :deep(.dropdown-item) {
white-space: normal;
}
</style>
@@ -0,0 +1,81 @@
<!--
VehicleInputSection.vue customer number, license plate, and vehicle type inputs.
Props:
showCustomerNumberInput whether to render the customer number field
customerNumber current customer number string
registrationNumber current license plate string
registrationOptions autocomplete options for existing vehicles
selectedVehicleTypeId currently selected vehicle type id
selectedVehicleName human-readable name of selected vehicle type
availableProductIds allowed product ids for the department
vehicleTypes list of vehicle type options
Emit:
update:customerNumber new customer number string
update:registrationNumber new license plate string
selectVehicleType selected VehicleTypeTemplate object
-->
<script setup lang="ts">
import { computed } from "vue";
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
interface Props {
showCustomerNumberInput: boolean;
customerNumber: string | null;
registrationNumber: string | null;
registrationOptions: string[];
selectedVehicleTypeId: number | null;
selectedVehicleName: string | null;
availableProductIds: (number | string)[];
vehicleTypes: any[];
}
defineProps<Props>();
const emit = defineEmits<{
"update:customerNumber": [value: string];
"update:registrationNumber": [value: string];
selectVehicleType: [type: { id: number }];
}>();
function handleCustomerNumberUpdate(value: string) {
emit("update:customerNumber", value);
}
function handleRegistrationNumberUpdate(value: string) {
emit("update:registrationNumber", value);
}
function handleSelectVehicleType(selection: any) {
emit("selectVehicleType", selection);
}
</script>
<template>
<SelfServeVehicleStep
:customer-number="customerNumber"
:show-customer-number-input="showCustomerNumberInput"
:registration-number="registrationNumber"
:registration-options="registrationOptions"
:is-customer-vehicles-loading="false"
:has-matching-vehicle="false"
:selected-vehicle-type-id="selectedVehicleTypeId"
:selected-vehicle-name="selectedVehicleName"
:selected-vehicle-description="null"
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
:vehicle-step-error="null"
:vehicle-step-guidance="null"
@update:customer-number="handleCustomerNumberUpdate"
@update:customerNumber="handleCustomerNumberUpdate"
@update:registration-number="handleRegistrationNumberUpdate"
@update:registrationNumber="handleRegistrationNumberUpdate"
@select-vehicle-type="handleSelectVehicleType"
@selected="handleSelectVehicleType"
/>
</template>
<style scoped>
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
</style>
@@ -0,0 +1,260 @@
<!--
WashProgressCard.vue timer, guided instructions, and bottom actions during active wash.
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
Props:
currentGuidedWashStep index into guidedWashFlowSteps
guidedWashFlowSteps array of instruction step objects
formattedElapsed human-readable elapsed time string
isCompletingWash whether the wash is finishing right now
openingPropertyAccessGate loading state for access gate button
openingPropertyExitGate loading state for exit gate button
Emit:
update:currentGuidedWashStep new step index
goPreviousGuidedWashStep navigate to previous instruction
goNextGuidedWashStep navigate to next instruction
completeWash finish the wash session
openPropertyAccessGate open the property access gate (laneId)
openPropertyExitGate open the property exit gate (laneId)
requestAssistance call for assistance
-->
<script setup lang="ts">
import { computed } from "vue";
import { BButton } from "buefy";
interface Props {
currentGuidedWashStep: number;
guidedWashFlowSteps: any[];
formattedElapsed: string;
isCompletingWash: boolean;
openingPropertyAccessGate: boolean;
openingPropertyExitGate: boolean;
showProgressActions?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showProgressActions: true,
});
const emit = defineEmits<{
"update:currentGuidedWashStep": [step: number];
goPreviousGuidedWashStep: [];
goNextGuidedWashStep: [];
completeWash: [];
openPropertyAccessGate: [];
openPropertyExitGate: [];
requestAssistance: [];
}>();
const isLastGuidedWashStep = computed(
() =>
Array.isArray(props.guidedWashFlowSteps) &&
props.guidedWashFlowSteps.length > 0 &&
props.currentGuidedWashStep >= props.guidedWashFlowSteps.length - 1
);
function handleGoPrevious() {
emit("goPreviousGuidedWashStep");
}
function handleGoNext() {
emit("goNextGuidedWashStep");
}
function handleComplete() {
emit("completeWash");
}
function handleOpenAccessGate() {
emit("openPropertyAccessGate");
}
function handleOpenExitGate() {
emit("openPropertyExitGate");
}
function handleRequestAssistance() {
emit("requestAssistance");
}
</script>
<template>
<div data-testid="self-serve-wash-progress">
<!-- Finishing screen -->
<div
v-if="isCompletingWash && showProgressActions"
class="notification is-info is-light self-serve-finishing-screen"
data-testid="self-serve-finishing-wash"
>
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
</div>
<!-- Guided wash instructions (stepped walkthrough) -->
<template v-if="!isCompletingWash">
<!--
Accept a callback slot for the guided-instructions component so the
parent can plug in SelfServeGuidedInstructions or its own implementation.
-->
<slot
v-if="showProgressActions"
name="guided-instructions"
:current-step="currentGuidedWashStep"
:steps="guidedWashFlowSteps"
:is-last-step="isLastGuidedWashStep"
@update:current-step="emit('update:currentGuidedWashStep', $event)"
>
<!-- Default: render nothing (parent provides via slot) -->
</slot>
<!-- Bottom action bar while wash is in progress -->
<div
v-show="showProgressActions"
class="self-serve-bottom-actions"
data-testid="self-serve-bottom-actions"
>
<!-- Help / assistance -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-right="info-circle"
data-testid="self-serve-nav-help"
@click.prevent="handleRequestAssistance"
>
{{ $t("self_wash.assistance") }}
</b-button>
</div>
<!-- Property gate controls -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-in-alt"
data-testid="self-serve-nav-open-property-access-gate"
:loading="openingPropertyAccessGate"
:disabled="openingPropertyAccessGate"
@click.prevent="handleOpenAccessGate"
>
{{ $t("self_wash.open_property_access_gate") }}
</b-button>
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-out-alt"
data-testid="self-serve-nav-open-property-exit-gate"
:loading="openingPropertyExitGate"
:disabled="openingPropertyExitGate"
@click.prevent="handleOpenExitGate"
>
{{ $t("self_wash.open_property_exit_gate") }}
</b-button>
</div>
<!-- Guided step navigation -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="arrow-left"
data-testid="self-serve-guided-prev"
:disabled="currentGuidedWashStep === 0"
@click.prevent="handleGoPrevious"
>
{{ $t("common.previous") }}
</b-button>
<b-button
v-if="!isLastGuidedWashStep"
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="arrow-right"
data-testid="self-serve-guided-next"
@click.prevent="handleGoNext"
>
{{ $t("common.next") }}
</b-button>
<b-button
v-else
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="check"
data-testid="self-serve-nav-complete"
:loading="isCompletingWash"
:disabled="isCompletingWash"
@click.prevent="handleComplete"
>
{{ $t("common.done") }}
</b-button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.self-serve-finishing-screen {
margin: 0 auto;
max-width: 32rem;
text-align: left;
}
.self-serve-bottom-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
justify-content: center;
margin: 0.75rem auto 0;
max-width: 30rem;
}
.self-serve-bottom-actions__row {
display: flex;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
.self-serve-bottom-actions__button {
flex: 1 1 0;
font-weight: 700;
line-height: 1.15;
max-width: 14rem;
min-height: 2.75rem;
min-width: 0;
white-space: normal;
}
@media screen and (max-width: 768px) {
.self-serve-bottom-actions {
background: #ffffff;
border-top: 1px solid #dfe5f0;
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
left: 0;
margin: 0;
max-width: none;
padding: 0.5rem 0.75rem;
position: fixed;
right: 0;
z-index: 41;
}
.self-serve-bottom-actions__button {
font-size: 0.88rem;
max-width: none;
min-height: 2.65rem;
padding-left: 0.55rem;
padding-right: 0.55rem;
}
}
</style>
@@ -0,0 +1,159 @@
<!--
WashTypeSelector.vue manual vs machine wash toggle.
Renders two radio-style cards: "Manual" (always available) and
"Machine" (available when the lane supports it).
Props:
modelValue current wash type ("Manual" | "Machine")
isMachineAvailable whether machine wash is available for this lane
Emit:
update:modelValue new wash type string
-->
<script setup lang="ts">
import { BRadioButton } from "buefy";
interface Props {
modelValue: string;
isMachineAvailable: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [type: string];
}>();
</script>
<template>
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
<div
class="self-serve-choice-grid self-serve-choice-grid--centered"
data-testid="self-serve-wash-type-options"
>
<!-- Manual option (always available) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Manual"
type="is-link"
data-testid="self-serve-wash-type-manual"
@update:model-value="emit('update:modelValue', 'Manual')"
@input="emit('update:modelValue', 'Manual')"
>
<span>
<span>{{ $t("self_wash.manual") }}<br /></span>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</b-radio-button>
</div>
<!-- Machine option (conditioned) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:modelValue', 'Machine')"
@input="emit('update:modelValue', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
</small>
</span>
<span v-else>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</span>
</b-radio-button>
</div>
</div>
</section>
</template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -129,6 +129,19 @@ const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePe
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
}));
const getOrderItemFlagIconClass = (orderItem) => (
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
);
const getOrderItemFlagColorClass = (orderItem) => {
const flags = getOrderItemFlags(orderItem);
if (flags.length === 0) {
return "";
}
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const displayColumnCount = computed(() => (
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
));
@@ -162,7 +175,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice">
<td>{{ item.product.name }}</td>
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
@@ -170,7 +193,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr>
<!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light">
<td><span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
@@ -193,7 +226,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>+ {{ addon_item.product.name }}</td>
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
@@ -201,7 +244,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
@@ -237,6 +290,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)">
<div>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div>
@@ -248,6 +309,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small>
@@ -265,5 +334,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</template>
<style scoped>
.invoice-period-item-flag-indicator {
vertical-align: middle;
}
</style>
@@ -2,6 +2,8 @@
import Swal from "sweetalert2";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const props = defineProps({
objects: {
@@ -23,6 +25,8 @@ const canDisableAccess = () =>
const canResendInvite = (subuser) =>
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
const hasRowActions = (subuser) =>
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
const formatDateTime = (dateString) => {
if (!dateString) {
@@ -241,51 +245,49 @@ const onResendInvite = async (subuser) => {
<td>
<div>{{ subuser.grant_note || "-" }}</div>
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-text is-small px-0 mt-1"
type="button"
@click="onEditNote(subuser)"
>
Redigér note
</button>
</td>
<td>{{ formatDateTime(subuser.created_at) }}</td>
<td>{{ formatDateTime(subuser.updated_at) }}</td>
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-small"
type="button"
:data-testid="`subuser-permissions-${subuser.id}`"
@click="onEditPermissions(subuser)"
>
Tilladelser
</button>
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-pen"
label="Redigér note"
:click-action="() => onEditNote(subuser)"
:test-id="`subuser-note-${subuser.id}`"
/>
<button
v-if="canResendInvite(subuser)"
class="button is-small"
type="button"
:data-testid="`subuser-resend-${subuser.id}`"
@click="onResendInvite(subuser)"
>
Gensend
</button>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-user-shield"
label="Tilladelser"
:click-action="() => onEditPermissions(subuser)"
:test-id="`subuser-permissions-${subuser.id}`"
/>
<button
v-if="canDisableAccess() && subuser.grant_id"
class="button is-small"
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
type="button"
:data-testid="`subuser-toggle-${subuser.id}`"
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
>
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
</button>
<ActionSettingsWheelItem
v-if="canResendInvite(subuser)"
icon="fas fa-paper-plane"
label="Gensend"
:click-action="() => onResendInvite(subuser)"
:test-id="`subuser-resend-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canDisableAccess() && subuser.grant_id"
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="subuser.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const employees = ref([]);
const { t } = useI18n();
@@ -52,6 +53,7 @@ const login = async () => {
}
// Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token);
// Redirect to the dashboard
window.location.href = '/admin';
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
window.location.href = '/admin';
return;
+3
View File
@@ -9,6 +9,7 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
@@ -68,6 +69,7 @@ const login = async () => {
}
// Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token);
// Set the success message
successMessage.value = "Du er nu logget ind!"
@@ -103,6 +105,7 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
console.log('Passkey authentication result:', result);
if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
successMessage.value = "Du er nu logget ind!";
setTimeout(() => {
@@ -9,6 +9,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
@@ -69,6 +70,7 @@ const login = async () => {
// Save the session token
if (data.session) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.session);
localStorage.setItem('is_subuser', 'true');
window.location.reload();
@@ -97,6 +99,7 @@ const loginWithPasskey = async () => {
// Subuser login returns 'session' token, user login returns 'token'
const sessionToken = result.session || result.token;
if (sessionToken) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', sessionToken);
localStorage.setItem('is_subuser', 'true');
// Reload the page to update the UI
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { verify2FA } from "@/services/TwoFactorAuthService.js";
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
const router = useRouter();
@@ -43,9 +44,11 @@ const verify = async () => {
// Handle the response based on user type
if (props.userType === 'subuser' && result.session) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.session);
localStorage.setItem('is_subuser', 'true');
} else if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
localStorage.removeItem('is_subuser');
}
@@ -255,6 +255,16 @@ const getBookingMatchesStateKey = (matches) => {
return matches.map((booking) => getBookingStateKey(booking)).join("||");
};
const hasBookingDetailsChanged = (currentBooking, nextBooking) => {
const currentKeys = Object.keys(currentBooking || {});
const nextKeys = Object.keys(nextBooking || {});
if (currentKeys.length !== nextKeys.length) {
return true;
}
return nextKeys.some((key) => currentBooking?.[key] !== nextBooking?.[key]);
};
const setVehicleObject = (emittedVehicleObject, options = {}) => {
const normalizedOptions = {
preserveManualReference: true,
@@ -350,17 +360,23 @@ const mergeBookingMatchDetails = (booking) => {
}
let didMergeBooking = false;
bookingMatches.value = bookingMatches.value.map((entry) => {
let didChangeBooking = false;
const nextBookingMatches = bookingMatches.value.map((entry) => {
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
return entry;
}
didMergeBooking = true;
return { ...entry, ...booking };
const mergedBooking = { ...entry, ...booking };
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
didChangeBooking = didChangeBooking || hasChangedEntry;
return hasChangedEntry ? mergedBooking : entry;
});
if (!didMergeBooking) {
bookingMatches.value = [...bookingMatches.value, booking];
} else if (didChangeBooking) {
bookingMatches.value = nextBookingMatches;
}
};
@@ -1037,6 +1037,7 @@ const getCurrentIconColor = () => {
@blur="lostfocus"
@keydown="arrowKeyHandler"
id="reg_1"
tabindex="1"
autocomplete="off"
/>
</template>
@@ -1059,6 +1060,7 @@ const getCurrentIconColor = () => {
@blur="lostfocus"
@keydown="arrowKeyHandler"
id="reg_1"
tabindex="1"
autocomplete="off"
/>
</div>
@@ -394,12 +394,16 @@ onBeforeUnmount(() => {
v-model="typedReference"
:data="groupedSuggestions"
field="reference"
input-id="reference"
data-testid="pos-desktop-step-1-reference-input"
group-field="group"
group-options="items"
:keep-first="true"
open-on-focus
expanded
:loading="isFetching"
:tabindex="props.tabindex"
:aria-invalid="props.requiredWarning ? 'true' : undefined"
custom-class="has-sharp-edges"
icon-pack="fas"
max-height="320"
@@ -52,6 +52,7 @@ const customer_suggestions = ref([
const isSettingCustomer = ref(false);
const isSettingCustomerToInteger = ref(0);
const isSettingCustomerStartTime = ref(null);
let customerSuggestionsRequestId = 0;
const isSettingCustomerTo = (customer_number) => {
return isSettingCustomerToInteger.value === parseInt(customer_number);
@@ -88,10 +89,17 @@ const getCustomerSuggestions = () => {
if (!props.reg_1) {
return;
}
const requestedReg1 = props.reg_1;
const requestId = ++customerSuggestionsRequestId;
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
reg_1: props.reg_1,
reg_1: requestedReg1,
})
.then((response) => {
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
return;
}
// Assuming the response contains an array of customer suggestions
console.log("Customer suggestions:", response.data.data);
let suggestions = [];
@@ -126,6 +134,7 @@ watch(
if (newValue) {
getCustomerSuggestions();
} else {
customerSuggestionsRequestId += 1;
customer_suggestions.value = [];
}
}
@@ -0,0 +1,236 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const SHIFT_PRESS_WINDOW_MS = 1200;
const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false);
const isBusy = ref(false);
const isClearConfirmationOpen = ref(false);
const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit || releaseUpdateState.currentCommit));
const closeMenu = () => {
if (!isBusy.value) {
isOpen.value = false;
isClearConfirmationOpen.value = false;
}
};
const handleKeydown = (event) => {
if (event.key !== "Shift" || event.repeat) {
return;
}
const now = Date.now();
shiftPresses.value = [...shiftPresses.value.filter((pressedAt) => now - pressedAt <= SHIFT_PRESS_WINDOW_MS), now];
if (shiftPresses.value.length >= 3) {
shiftPresses.value = [];
isOpen.value = true;
}
};
const openClearConfirmation = () => {
if (!isBusy.value) {
isClearConfirmationOpen.value = true;
}
};
const closeClearConfirmation = () => {
isClearConfirmationOpen.value = false;
};
const forceUpdateAndClearLocal = async () => {
if (isBusy.value) {
return;
}
isBusy.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isBusy.value = false;
console.error("Failed to force frontend update and clear local data:", error);
}
};
onMounted(() => {
window.addEventListener("keydown", handleKeydown);
window.addEventListener(CLOSE_EVENT, closeMenu);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener(CLOSE_EVENT, closeMenu);
});
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="frontend-maintenance-menu" data-testid="frontend-maintenance-menu">
<button
type="button"
class="frontend-maintenance-menu__backdrop"
:aria-label="t('common.close')"
@click="closeMenu"
></button>
<section class="frontend-maintenance-menu__panel" role="dialog" aria-modal="true" :aria-label="t('maintenance_menu.title')">
<header class="frontend-maintenance-menu__header">
<div>
<p>{{ t("maintenance_menu.title") }}</p>
<span>{{ t("maintenance_menu.version", { current: currentVersion, latest: latestVersion }) }}</span>
</div>
<button type="button" class="frontend-maintenance-menu__close" :aria-label="t('common.close')" @click="closeMenu">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</header>
<button
type="button"
class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear"
:disabled="isBusy"
@click="openClearConfirmation"
>
<i class="fas fa-sync-alt" aria-hidden="true"></i>
<span>
<strong>{{ t("maintenance_menu.force_update_clear") }}</strong>
<small>{{ t("maintenance_menu.force_update_clear_hint") }}</small>
</span>
</button>
</section>
<LocalDataResetDialog
v-model="isClearConfirmationOpen"
:busy="isBusy"
@confirm="forceUpdateAndClearLocal"
@dismiss="closeClearConfirmation"
/>
</div>
</Teleport>
</template>
<style scoped>
.frontend-maintenance-menu {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: end center;
padding: 16px;
pointer-events: none;
}
.frontend-maintenance-menu__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(12, 22, 34, 0.32);
pointer-events: auto;
}
.frontend-maintenance-menu__panel {
position: relative;
width: min(420px, calc(100vw - 32px));
border: 1px solid #d6dde6;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
color: #111827;
pointer-events: auto;
}
.frontend-maintenance-menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 10px;
border-bottom: 1px solid #edf1f5;
}
.frontend-maintenance-menu__header p {
margin: 0;
color: #172033;
font-size: 0.96rem;
font-weight: 800;
}
.frontend-maintenance-menu__header span {
display: block;
margin-top: 3px;
color: #667085;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.72rem;
font-weight: 700;
}
.frontend-maintenance-menu__close {
width: 34px;
height: 34px;
flex: 0 0 auto;
border: 1px solid #d9e0e8;
border-radius: 6px;
background: #ffffff;
color: #334155;
cursor: pointer;
}
.frontend-maintenance-menu__danger {
width: calc(100% - 28px);
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
margin: 14px;
padding: 10px 12px;
border: 1px solid #f2b8b5;
border-radius: 6px;
background: #fff7f6;
color: #981b1b;
cursor: pointer;
text-align: left;
}
.frontend-maintenance-menu__danger:disabled {
cursor: wait;
opacity: 0.7;
}
.frontend-maintenance-menu__danger i {
width: 20px;
flex: 0 0 auto;
text-align: center;
}
.frontend-maintenance-menu__danger strong,
.frontend-maintenance-menu__danger small {
display: block;
}
.frontend-maintenance-menu__danger strong {
font-size: 0.86rem;
font-weight: 800;
}
.frontend-maintenance-menu__danger small {
margin-top: 2px;
color: #9f1c1c;
font-size: 0.72rem;
font-weight: 650;
line-height: 1.25;
}
@media (min-width: 720px) {
.frontend-maintenance-menu {
place-items: end end;
padding: 22px;
}
}
</style>
@@ -0,0 +1,156 @@
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "confirm", "dismiss"]);
const dismissDialog = () => {
emit("dismiss");
emit("update:modelValue", false);
};
const confirmDialog = () => {
emit("confirm");
};
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="local-data-reset-dialog" data-testid="local-data-reset-dialog">
<button
type="button"
class="local-data-reset-dialog__backdrop"
aria-label="Luk"
:disabled="busy"
@click="dismissDialog"
></button>
<section
class="local-data-reset-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="local-data-reset-dialog-title"
>
<h2 id="local-data-reset-dialog-title">Ryd lokale data?</h2>
<p>
Dette sletter login, localStorage, sessionStorage, browsercache og lokale appdata denne enhed. Du bliver
logget ud.
</p>
<footer class="local-data-reset-dialog__actions">
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--secondary"
data-testid="local-data-reset-cancel"
:disabled="busy"
@click="dismissDialog"
>
Nej
</button>
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--danger"
data-testid="local-data-reset-confirm"
:disabled="busy"
@click="confirmDialog"
>
Ja, ryd alt
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.local-data-reset-dialog {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: 18px;
pointer-events: none;
}
.local-data-reset-dialog__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(9, 20, 33, 0.48);
cursor: pointer;
pointer-events: auto;
}
.local-data-reset-dialog__backdrop:disabled {
cursor: wait;
}
.local-data-reset-dialog__panel {
position: relative;
width: min(430px, calc(100vw - 36px));
border: 1px solid #d5dde8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
color: #172033;
pointer-events: auto;
}
.local-data-reset-dialog__panel h2 {
margin: 0;
padding: 18px 18px 8px;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0;
}
.local-data-reset-dialog__panel p {
margin: 0;
padding: 0 18px 16px;
color: #475467;
font-size: 0.92rem;
font-weight: 550;
line-height: 1.42;
}
.local-data-reset-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px 18px;
border-top: 1px solid #edf1f5;
}
.local-data-reset-dialog__button {
min-height: 38px;
padding: 0 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.86rem;
font-weight: 800;
}
.local-data-reset-dialog__button:disabled {
cursor: wait;
opacity: 0.68;
}
.local-data-reset-dialog__button--secondary {
border: 1px solid #cfd8e3;
background: #ffffff;
color: #334155;
}
.local-data-reset-dialog__button--danger {
border: 1px solid #b42318;
background: #b42318;
color: #ffffff;
}
</style>
+215 -18
View File
@@ -41,9 +41,16 @@ const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
iconClass: "fa-calendar-alt",
matcher: (url) => /\/order-bookings(?:[/?#]|$)/i.test(url),
},
{
key: "scanner",
label: "Scanner",
iconClass: "fa-camera",
matcher: (url) => /\/modules\/scanner\/lpr(?:[/?#]|$)/i.test(url),
},
]);
const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
const FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT = "frontend-maintenance-menu:close";
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
const isShortcutActivated = ref(false);
@@ -61,6 +68,7 @@ const processedRequests = computed(() => requestQueueState.batchCompleted + requ
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
const activeRequests = computed(() => requestQueueState.activeRequests || []);
const recentRequests = computed(() => requestQueueState.recentRequests || []);
const queueRequestInsights = computed(() => requestQueueState.requestInsights || {});
const errorRequests = computed(() => requestQueueState.errorRequests || []);
const networkTotals = computed(() => requestQueueState.networkTotals || {
outgoingRequests: 0,
@@ -107,7 +115,7 @@ const userTypeLabel = computed(() => (SessionUser.isSubuser.value ? "Subuser" :
const hasSuperuserToken = computed(() => {
try {
return Boolean(localStorage.getItem("superuser_token"));
} catch (error) {
} catch (_error) {
return false;
}
});
@@ -118,6 +126,7 @@ const impersonatedUserRoleId = computed(() => {
const canGrantMissingPermissions = computed(() =>
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
);
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
const userDetailRows = computed(() => {
if (SessionUser.isSubuser.value) {
@@ -189,6 +198,152 @@ const formatBytes = (value) => {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const parseServerTimingDurations = (value) => {
if (typeof value !== "string" || value.trim().length === 0) {
return {};
}
return value.split(",").reduce((durations, part) => {
const [rawMetric, ...rawParams] = part.trim().split(";");
const metric = rawMetric.trim();
if (!metric) {
return durations;
}
const durationParam = rawParams.find((param) => param.trim().toLowerCase().startsWith("dur="));
if (!durationParam) {
return durations;
}
const duration = Number.parseFloat(durationParam.split("=").slice(1).join("="));
if (Number.isFinite(duration)) {
durations[metric] = duration;
}
return durations;
}, {});
};
const findServerTimingDuration = (durations, metricNames) => {
const metricName = metricNames.find((name) => Number.isFinite(durations[name]));
return metricName ? durations[metricName] : null;
};
const formatScannerFrameSize = (width, height) => {
const roundedWidth = Math.round(Number(width) || 0);
const roundedHeight = Math.round(Number(height) || 0);
if (roundedWidth <= 0 || roundedHeight <= 0) {
return null;
}
return `img ${roundedWidth}x${roundedHeight}`;
};
const formatScannerFrameBytes = (bytes) => {
const roundedBytes = Math.round(Number(bytes) || 0);
if (roundedBytes <= 0) {
return null;
}
return `bytes ${formatBytes(roundedBytes)}`;
};
const getBrowserNetworkDuration = (requestDurationMs, serverDurationMs) => {
if (serverDurationMs === null) {
return null;
}
const durationMs = Number(requestDurationMs) - Number(serverDurationMs);
if (!Number.isFinite(durationMs) || durationMs < 1) {
return null;
}
return durationMs;
};
const formatRequestLatency = (definition, request) => {
const requestDurationMs = Math.max(0, Number(request?.requestDurationMs) || 0);
if (definition.key !== "scanner") {
return formatDuration(requestDurationMs);
}
const queueDurationMs = Math.max(0, Number(request?.queueDurationMs) || 0);
const durations = parseServerTimingDurations(request?.serverTiming);
const captureDurationMs = findServerTimingDuration(durations, ["lpr_client_capture"]);
const preflightDurationMs = findServerTimingDuration(durations, ["lpr_client_preflight"]);
const visualFingerprintDurationMs = findServerTimingDuration(durations, ["lpr_client_visual_fingerprint"]);
const drawDurationMs = findServerTimingDuration(durations, ["lpr_client_draw"]);
const encodeDurationMs = findServerTimingDuration(durations, ["lpr_client_encode"]);
const clientFrameWidth = findServerTimingDuration(durations, ["lpr_client_frame_width"]);
const clientFrameHeight = findServerTimingDuration(durations, ["lpr_client_frame_height"]);
const clientFrameBytes = findServerTimingDuration(durations, ["lpr_client_frame_bytes"]);
const cacheDurationMs = findServerTimingDuration(durations, ["lpr_cache"]);
const cacheHit = findServerTimingDuration(durations, ["lpr_cache_hit"]) !== null;
const cacheMiss = findServerTimingDuration(durations, ["lpr_cache_miss"]) !== null;
const localDurationMs = findServerTimingDuration(durations, ["lpr_local"]);
const upstreamProcessingDurationMs = findServerTimingDuration(durations, ["lpr_upstream_processing"]);
const upstreamDurationMs = findServerTimingDuration(durations, ["lpr_upstream_total", "lpr_upstream"]);
const serverDurationMs = findServerTimingDuration(durations, ["lpr_total", "lpr_route_total", "lpr_request_total"]);
const browserNetworkDurationMs = getBrowserNetworkDuration(requestDurationMs, serverDurationMs);
const timingParts = [`browser ${formatDuration(requestDurationMs)}`];
const frameSize = formatScannerFrameSize(clientFrameWidth, clientFrameHeight);
const frameBytes = formatScannerFrameBytes(clientFrameBytes);
if (queueDurationMs > 0) {
timingParts.push(`queue ${formatDuration(queueDurationMs)}`);
}
if (browserNetworkDurationMs !== null) {
timingParts.push(`net ${formatDuration(browserNetworkDurationMs)}`);
}
if (captureDurationMs !== null) {
timingParts.push(`cap ${formatDuration(captureDurationMs)}`);
}
if (preflightDurationMs !== null) {
timingParts.push(`prep ${formatDuration(preflightDurationMs)}`);
}
if (visualFingerprintDurationMs !== null) {
timingParts.push(`vf ${formatDuration(visualFingerprintDurationMs)}`);
}
if (drawDurationMs !== null) {
timingParts.push(`draw ${formatDuration(drawDurationMs)}`);
}
if (encodeDurationMs !== null) {
timingParts.push(`enc ${formatDuration(encodeDurationMs)}`);
}
if (frameSize !== null) {
timingParts.push(frameSize);
}
if (frameBytes !== null) {
timingParts.push(frameBytes);
}
if (cacheHit) {
timingParts.push("cache hit");
} else if (cacheMiss) {
timingParts.push("cache miss");
}
if (cacheDurationMs !== null) {
timingParts.push(`cache ${formatDuration(cacheDurationMs)}`);
}
if (localDurationMs !== null) {
timingParts.push(`local ${formatDuration(localDurationMs)}`);
}
if (upstreamProcessingDurationMs !== null) {
timingParts.push(`proc ${formatDuration(upstreamProcessingDurationMs)}`);
}
if (upstreamDurationMs !== null) {
timingParts.push(`up ${formatDuration(upstreamDurationMs)}`);
}
if (serverDurationMs !== null) {
timingParts.push(`srv ${formatDuration(serverDurationMs)}`);
}
return timingParts.join(" / ");
};
const formatTimeAgo = (timestamp) => {
const value = Number(timestamp || 0);
if (!value || Number.isNaN(value)) {
@@ -217,43 +372,67 @@ const getActiveRequestElapsedMs = (request) => Math.max(0, nowMs.value - Number(
const hasInsightEntryChanged = (currentEntry, nextEntry) =>
Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0)
|| Number(currentEntry?.queueDurationMs || 0) !== Number(nextEntry?.queueDurationMs || 0)
|| Number(currentEntry?.completedAt || 0) !== Number(nextEntry?.completedAt || 0)
|| Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 0)
|| Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0);
|| Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0)
|| String(currentEntry?.serverTiming || "") !== String(nextEntry?.serverTiming || "");
const getInsightEntryTimestamp = (entry) =>
Number(entry?.completedAt || entry?.startedAt || entry?.queuedAt || 0);
const selectNewestInsightEntry = (...entries) =>
entries
.filter((entry) => entry !== null && entry !== undefined)
.sort((first, second) => getInsightEntryTimestamp(second) - getInsightEntryTimestamp(first))[0] || null;
const requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => {
const matchedRequestFromRecent = recentRequests.value.find((request) =>
definition.matcher(String(request?.url || ""))
) || null;
const matchedRequest = matchedRequestFromRecent || requestInsightHistory.value[definition.key] || null;
const matchedRequestFromQueueInsight = queueRequestInsights.value[definition.key] || null;
const matchedRequest = selectNewestInsightEntry(
matchedRequestFromRecent,
matchedRequestFromQueueInsight,
requestInsightHistory.value[definition.key],
);
const hasData = matchedRequest !== null;
return {
...definition,
latencyText: hasData ? `${Math.max(0, Number(matchedRequest.requestDurationMs) || 0)} ms` : " ",
latencyText: hasData ? formatRequestLatency(definition, matchedRequest) : " ",
serverTiming: matchedRequest?.serverTiming || "",
timeAgoText: hasData
? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt)
: "",
};
}));
watch(recentRequests, (requests) => {
if (!Array.isArray(requests) || requests.length === 0) {
watch([recentRequests, queueRequestInsights], ([requests, insights]) => {
const recentRequestList = Array.isArray(requests) ? requests : [];
const insightEntries = insights && typeof insights === "object" ? insights : {};
if (recentRequestList.length === 0 && Object.keys(insightEntries).length === 0) {
return;
}
const nextHistory = { ...requestInsightHistory.value };
let hasChanges = false;
REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => {
const matchedRequest = requests.find((request) =>
definition.matcher(String(request?.url || ""))
const matchedRequest = selectNewestInsightEntry(
recentRequestList.find((request) =>
definition.matcher(String(request?.url || ""))
),
insightEntries[definition.key],
);
if (!matchedRequest) {
return;
}
const nextEntry = {
serverTiming: matchedRequest.serverTiming || null,
requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0),
queueDurationMs: Math.max(0, Number(matchedRequest.queueDurationMs) || 0),
completedAt: matchedRequest.completedAt || null,
startedAt: matchedRequest.startedAt || null,
queuedAt: matchedRequest.queuedAt || null,
@@ -284,7 +463,12 @@ const resolvePingUrl = () => {
};
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
const activeApiUrlLabel = computed(() =>
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
);
const releaseSessionSummary = computed(() =>
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
);
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
@@ -322,7 +506,7 @@ const measurePingLatency = async () => {
queuedAt: startedAt,
},
};
} catch (error) {
} catch (_error) {
pingLatencyMs.value = null;
pingIsUnavailable.value = true;
} finally {
@@ -360,6 +544,9 @@ const handleWindowKeydown = (event) => {
}
if (shiftKeyPressCount.value >= 3) {
event.preventDefault?.();
event.stopImmediatePropagation?.();
window.dispatchEvent(new CustomEvent(FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT));
closeSystemSearchForSuperuser();
isShortcutActivated.value = true;
isExpanded.value = true;
@@ -564,7 +751,7 @@ onBeforeUnmount(() => {
{{ request.method }}
</span>
<span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span>
<span class="request-queue-progress__time">
<span class="request-queue-progress__time" :title="request.serverTiming || ''">
{{ formatDuration(request.requestDurationMs) }}
</span>
</li>
@@ -585,7 +772,9 @@ onBeforeUnmount(() => {
{{ insight.label }}
</span>
<span class="request-queue-progress__bottom-request-time">{{ insight.timeAgoText }}</span>
<span class="request-queue-progress__bottom-request-latency">{{ insight.latencyText }}</span>
<span class="request-queue-progress__bottom-request-latency" :title="insight.serverTiming">
{{ insight.latencyText }}
</span>
</div>
</div>
</div>
@@ -646,9 +835,10 @@ onBeforeUnmount(() => {
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<template v-if="canInspectReleaseRuntime">
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Channel</span>
<span
@@ -771,11 +961,15 @@ onBeforeUnmount(() => {
</li>
</ul>
<div class="request-queue-progress__subsection-title">Runtime details</div>
</div>
</template>
<div class="request-queue-progress__section-title">Runtime details</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
@@ -1466,6 +1660,10 @@ onBeforeUnmount(() => {
.request-queue-progress__bottom-request-latency {
font-size: 11px;
font-variant-numeric: tabular-nums;
max-width: 190px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1485,4 +1683,3 @@ onBeforeUnmount(() => {
opacity: 0;
}
</style>
+2 -2
View File
@@ -3,7 +3,7 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
// Get the department ID from the URL
const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -26,7 +26,7 @@ const loadDepartments = () => {
value: department.id,
icon: "fas fa-building",
children: [],
hidden: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment),
});
});
menu_items.value[1].options = options;
@@ -13,7 +13,9 @@ import {
} from "@/composables/useDraftTransactionCustomer.js";
import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js";
import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
import { isAccessibleVisibleNamedDepartment } from "@/services/departmentVisibility.js";
const t = (key: string) => i18n.global.t(key);
@@ -70,6 +72,8 @@ let queuedBookingLoadingIndicator = false;
let queuedDraftLoadingIndicator = false;
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin();
const canUseAdminBookingNavigationCounts = () =>
canUseAdminNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const getDepartmentById = (id: number) => {
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
};
@@ -148,6 +152,19 @@ const getDepartmentDraftBadge = () => {
};
const fetchDepartmentBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseAdminBookingNavigationCounts()) {
department_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
department_booking_counts_loading.value = false;
bookingCountFetchInFlight = false;
bookingCountRefreshQueued = false;
queuedBookingLoadingIndicator = false;
return;
}
if (!hasValidDepartmentId.value) {
department_booking_counts.value = {
past: 0,
@@ -376,17 +393,26 @@ const items = computed<NavigationItemProps[]>(() => [
hidden: isDepartmentSet.value,
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
permissions: ["admin", "list_departments"],
children: SessionUser.functions.getAccessibleDepartments().map((departmentId: any) => {
const department = getDepartmentById(departmentId);
return {
label: getDepartmentName(departmentId),
to: `/admin/${departmentId}`,
type: "department",
permissions: ["admin", "view_department"],
order_priority: department?.order_priority ?? department?.priority_order ?? null,
priority_order: department?.priority_order ?? department?.order_priority ?? null,
};
}),
children: SessionUser.functions
.getAccessibleDepartments()
.map((departmentId: any) => {
const label = getDepartmentName(departmentId);
const department = getDepartmentById(departmentId);
if (!isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment)) {
return null;
}
return {
label,
to: `/admin/${department.id}`,
type: "department",
permissions: ["admin", "view_department"],
order_priority: department?.order_priority ?? department?.priority_order ?? null,
priority_order: department?.priority_order ?? department?.order_priority ?? null,
};
})
.filter(Boolean) as NavigationItemProps[],
},
// Kassesystem
{
@@ -9,6 +9,7 @@ import {
} from "@/composables/useDraftTransactionCustomer.js";
import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js";
import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@@ -37,23 +38,29 @@ let queuedSuperUserBookingLoadingIndicator = false;
let queuedSuperUserDraftLoadingIndicator = false;
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
const canUseSuperUserBookingNavigationCounts = () =>
canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const resetSuperUserNavigationCounts = () => {
const resetSuperUserBookingCounts = () => {
bookingCountRequestId += 1;
draftCountRequestId += 1;
superuser_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
superuser_booking_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
};
const resetSuperUserNavigationCounts = () => {
resetSuperUserBookingCounts();
draftCountRequestId += 1;
superuser_draft_count.value = 0;
superuser_draft_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserDraftCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
superUserDraftCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
queuedSuperUserDraftLoadingIndicator = false;
};
@@ -125,8 +132,8 @@ const getSuperUserDraftsBadge = () => {
};
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseSuperUserNavigationCounts()) {
resetSuperUserNavigationCounts();
if (!canUseSuperUserBookingNavigationCounts()) {
resetSuperUserBookingCounts();
return;
}
@@ -1,4 +1,8 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
emptyBookingCounts,
isExpectedBookingCountRequestFailure,
} from "@/components/models/navigation/items/bookingCountGuards.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -24,11 +28,7 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
if (!normalizedDepartmentId) {
return {
past: 0,
current: 0,
future: 0,
};
return emptyBookingCounts();
}
try {
@@ -38,12 +38,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
return normalizeBookingCounts(response);
} catch (error) {
console.error("Error fetching department order-booking counts:", error);
return {
past: 0,
current: 0,
future: 0,
};
if (!isExpectedBookingCountRequestFailure(error)) {
console.error("Error fetching department order-booking counts:", error);
}
return emptyBookingCounts();
}
};
@@ -0,0 +1,24 @@
export const BOOKING_COUNT_PERMISSIONS = ["list_bookings", "list_own_bookings"];
export const emptyBookingCounts = () => ({
past: 0,
current: 0,
future: 0,
});
export const hasExplicitBookingCountPermission = (sessionUser) => {
const permissions = sessionUser?.permissions?.value;
if (!Array.isArray(permissions)) {
return false;
}
return BOOKING_COUNT_PERMISSIONS.some((permission) => permissions.includes(permission));
};
export const getRequestErrorStatus = (error) =>
Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10);
export const isExpectedBookingCountRequestFailure = (error) => {
return [401, 403, 404].includes(getRequestErrorStatus(error));
};
@@ -1,4 +1,8 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
emptyBookingCounts,
isExpectedBookingCountRequestFailure,
} from "@/components/models/navigation/items/bookingCountGuards.js";
const toNonNegativeInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -20,12 +24,11 @@ export const fetchSuperUserBookingCounts = async () => {
const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
return normalizeBookingCounts(response);
} catch (error) {
console.error("Error fetching superuser booking counts:", error);
return {
past: 0,
current: 0,
future: 0,
};
if (!isExpectedBookingCountRequestFailure(error)) {
console.error("Error fetching superuser booking counts:", error);
}
return emptyBookingCounts();
}
};
@@ -1,9 +1,8 @@
<script setup>
import { useRouter } from 'vue-router';
import { useRouter } from "vue-router";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { pingApiServer } from "@/services/apiHealth.js";
const props = defineProps({
@@ -42,12 +41,12 @@ const handleSlowSessionBootstrap = async () => {
}
const result = await Swal.fire({
title: 'Error',
text: 'The user session could not be initiated.',
icon: 'error',
confirmButtonText: 'Clear session, and try again',
title: "Error",
text: "The user session could not be initiated.",
icon: "error",
confirmButtonText: "Clear session, and try again",
showCancelButton: true,
cancelButtonText: 'Retry',
cancelButtonText: "Retry",
});
if (result.isConfirmed) {
@@ -60,10 +59,16 @@ const handleSlowSessionBootstrap = async () => {
}
};
watch(isSessionInitiated, (initiated) => {
if (initiated) {
clearSessionTimeout();
}
});
onMounted(() => {
sessionTimeout = setTimeout(() => {
void handleSlowSessionBootstrap();
}, 5000);
}, 15000);
});
onBeforeUnmount(clearSessionTimeout);
@@ -89,9 +94,18 @@ onBeforeUnmount(clearSessionTimeout);
</div>
</div>
<!-- Simple checks -->
<p v-if="router.currentRoute.value.path === '/user'">You are on the /user page. <br>You do {{ SessionUser.hasPermission('user') ? '' : 'not' }} have permission ( user ) to view this page.</p>
<p v-if="router.currentRoute.value.path === '/admin'">You are on the /admin page. <br>You do {{ SessionUser.hasPermission('admin') ? '' : 'not' }} have permission ( admin ) to view this page.</p>
<p v-if="router.currentRoute.value.path === '/superuser'">You are on the /superuser <br>page. You do {{ SessionUser.hasPermission('superuser') ? '' : 'not' }} have permission ( superuser ) to view this page.</p>
<p v-if="router.currentRoute.value.path === '/user'">
You are on the /user page. <br />You do {{ SessionUser.hasPermission("user") ? "" : "not" }} have permission (
user ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/admin'">
You are on the /admin page. <br />You do {{ SessionUser.hasPermission("admin") ? "" : "not" }} have permission (
admin ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/superuser'">
You are on the /superuser <br />page. You do {{ SessionUser.hasPermission("superuser") ? "" : "not" }} have
permission ( superuser ) to view this page.
</p>
<div v-if="showDebugInfo">
<div class="message mb-6">
<div class="message-header">
@@ -108,7 +122,9 @@ onBeforeUnmount(clearSessionTimeout);
<!-- Button to go back to the previous page -->
<button class="button is-dark" @click="router.go(-1)">Go back</button>
<!-- Button to see the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">Show debug information</button>
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">
Show debug information
</button>
<!-- Button to hide the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
</div>
@@ -116,6 +132,4 @@ onBeforeUnmount(clearSessionTimeout);
</div>
</template>
<style scoped>
</style>
<style scoped></style>
+8 -5
View File
@@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => {
export const getDepartmentsGuest = async (queryParams = {}) => {
isLoading.value = true;
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
isLoading.value = false;
return departments.value;
try {
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
return departments.value;
} finally {
isLoading.value = false;
}
};
</script>
@@ -1,42 +1,21 @@
<script setup>
import { computed, inject, ref } from "vue";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
import {
clearSelectedReleaseChannel,
releaseChannelSelectorVisible,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
import {
isReleaseSourceOverrideAvailable,
RELEASE_SOURCE_MODES,
setReleaseSourceOverride,
} from "@/services/releaseBootstrap.js";
import { releaseChannelSelectorVisible, switchSelectedReleaseChannel } from "@/services/releaseChannelAvailability.js";
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const switchingSlug = ref("");
const switchError = ref("");
const { t, te } = useI18n({ useScope: "global" });
const reloadWindow = inject("releaseSourceReload", () => {
if (typeof window !== "undefined") {
window.location.reload();
}
});
const tr = (key, fallback) => {
const path = `configuration.release_manager.channel_selector.${key}`;
return te(path) ? t(path) : fallback;
};
const showUseLocalFrontend = computed(
() =>
releaseChannelSelectorVisible.value &&
isReleaseSourceOverrideAvailable() &&
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
);
const switchChannel = async (option) => {
if (switchingSlug.value) {
return;
@@ -48,21 +27,14 @@ const switchChannel = async (option) => {
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
} catch (error) {
switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
switchError.value = tr(
"switch_error",
"Release channel could not be switched. The previous channel is still active."
);
} finally {
switchingSlug.value = "";
}
};
const useLocalFrontend = () => {
if (switchingSlug.value) {
return;
}
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
clearSelectedReleaseChannel();
reloadWindow();
};
</script>
<template>
@@ -74,17 +46,6 @@ const useLocalFrontend = () => {
:disabled="Boolean(switchingSlug)"
@select="switchChannel"
/>
<button
v-if="showUseLocalFrontend"
type="button"
class="release-channel-sidebar-selector__local-button"
data-testid="release-channel-use-local-frontend"
:disabled="Boolean(switchingSlug)"
@click="useLocalFrontend"
>
<i class="fas fa-code" aria-hidden="true"></i>
<span>{{ tr("use_local_frontend", "Use local frontend") }}</span>
</button>
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
{{ switchError }}
</p>
@@ -93,37 +54,6 @@ const useLocalFrontend = () => {
</template>
<style scoped>
.release-channel-sidebar-selector__local-button {
width: calc(100% - 32px);
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: -2px 16px 14px;
padding: 9px 10px;
border: 1px solid #b8c7d9;
border-radius: 5px;
background: #f8fbff;
color: #153554;
cursor: pointer;
font-size: 0.82rem;
font-weight: 800;
line-height: 1.2;
text-align: center;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.release-channel-sidebar-selector__local-button:hover:not(:disabled) {
border-color: #6f8fb4;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.release-channel-sidebar-selector__local-button:disabled {
cursor: default;
opacity: 0.7;
}
.release-channel-sidebar-selector__error {
margin: 0 16px 14px;
color: #b42318;
+14 -1
View File
@@ -295,10 +295,23 @@ const branchWarning = computed(() =>
min-width: 12rem;
}
.release-context-bar__status {
align-items: flex-start;
flex-wrap: wrap;
}
.release-context-bar__status .tag {
flex: 0 0 auto;
}
.release-context-bar__warning {
color: #9f1f17;
flex: 1 1 12rem;
font-size: 0.82rem;
overflow-wrap: anywhere;
line-height: 1.25;
min-width: min(12rem, 100%);
overflow-wrap: break-word;
word-break: normal;
}
.release-context-bar__endpoints {
+2
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import axios from 'axios'
import {API_URL} from "@/config.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
export const currentDepartment = ref(null);
export const selectDepartment = (department) => {
@@ -31,6 +32,7 @@ export const exitSession = () => {
}
});
clearEdgeGatewayWorkspaceCache();
localStorage.removeItem('token');
return result;
};
+254 -6
View File
@@ -2,6 +2,7 @@
import axios from 'axios'
import { enqueueRequest } from "@/services/requestQueue.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
/**
* Get the selected customer number for X-Customer-Number header (used by subusers)
@@ -12,33 +13,277 @@ const getSelectedCustomerNumber = () => {
return stored ? parseInt(stored) : null;
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
const SELF_SERVE_HARDWARE_QUEUE_GROUP = 'SELF_SERVE_HARDWARE';
const POS_SCANNER_QUEUE_GROUP = 'POS_SCANNER';
const POS_STRIPE_QUEUE_GROUP = 'POS_STRIPE';
const FETCH_TRANSPORT = 'fetch';
const SELF_SERVE_HARDWARE_ENDPOINTS = [
'/modules/self-serve/lane/command',
'/modules/self-serve/lane/relay/',
'/modules/self-serve/lane/gate/open',
'/modules/self-serve/lane/force/machine',
];
const POS_LATENCY_QUEUE_RULES = [
{
endpoints: ['/modules/scanner/lpr'],
queueGroup: POS_SCANNER_QUEUE_GROUP,
concurrencyLimit: 1,
retryByStatusCode: {},
skipRequestByteAccounting: true,
skipResponseByteAccounting: true,
skipNetworkTotals: true,
insightKey: 'scanner',
recordRecentOnSuccess: false,
trackActiveRequest: false,
trackProgressCounters: false,
},
{
endpoints: ['/modules/stripe/invoice'],
queueGroup: POS_STRIPE_QUEUE_GROUP,
concurrencyLimit: 2,
retryByStatusCode: {},
},
];
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
const parseDateTimeMs = (value) => {
if (!value) {
return null;
}
const timestamp = Date.parse(String(value));
return Number.isFinite(timestamp) ? timestamp : null;
};
const hasActiveWashStartEvidence = (details) => {
const session = details?.session || {};
const status = normalizeStatus(session?.status ?? details?.status);
return (
ACTIVE_WASH_STARTED_STATUSES.has(status) ||
session?.machine_relay_enabled === true ||
session?.machine_start_triggered === true ||
details?.machine_relay_enabled === true ||
details?.machine_start_triggered === true ||
parseDateTimeMs(session?.wash_started_at) !== null ||
parseDateTimeMs(session?.machine_start_triggered_at) !== null ||
parseDateTimeMs(session?.machine_relay_enabled_at) !== null
);
};
const normalizeActiveWashResponse = (url, method, response) => {
if (String(method || '').toUpperCase() !== 'GET' || !String(url || '').includes(MY_ACTIVE_WASH_ENDPOINT)) {
return response;
}
const details = response?.data?.data;
if (!details?.in_progress || hasActiveWashStartEvidence(details)) {
return response;
}
return {
...response,
data: {
...response.data,
data: {
...details,
in_progress: false,
},
},
};
};
const isSelfServeHardwareMutation = (url, method) => {
const normalizedMethod = String(method || '').trim().toUpperCase();
if (normalizedMethod === 'GET') {
return false;
}
const normalizedUrl = String(url || '');
return SELF_SERVE_HARDWARE_ENDPOINTS.some((endpoint) => normalizedUrl.includes(endpoint));
};
const findPosLatencyQueueRule = (url, method) => {
const normalizedMethod = String(method || '').trim().toUpperCase();
if (normalizedMethod === 'GET') {
return null;
}
const normalizedUrl = String(url || '');
return POS_LATENCY_QUEUE_RULES.find((rule) =>
rule.endpoints.some((endpoint) => normalizedUrl.includes(endpoint))
) || null;
};
const hasHeader = (headers, name) => {
const normalizedName = String(name || '').trim().toLowerCase();
if (!normalizedName || !headers || typeof headers !== 'object') {
return false;
}
return Object.keys(headers).some((headerName) => String(headerName).toLowerCase() === normalizedName);
};
const parseFetchResponseData = async (response) => {
const text = await response.text();
if (!text) {
return null;
}
const contentType = response.headers?.get?.('content-type') || '';
if (
contentType.toLowerCase().includes('application/json') ||
text.trim().startsWith('{') ||
text.trim().startsWith('[')
) {
try {
return JSON.parse(text);
} catch (_error) {
return text;
}
}
return text;
};
const buildFetchBody = (method, data, headers) => {
if (String(method || '').trim().toUpperCase() === 'GET' || data === undefined || data === null) {
return undefined;
}
if (
typeof Blob !== 'undefined' && data instanceof Blob ||
typeof FormData !== 'undefined' && data instanceof FormData ||
typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams ||
typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer ||
typeof ReadableStream !== 'undefined' && data instanceof ReadableStream ||
typeof data === 'string'
) {
return data;
}
if (!hasHeader(headers, 'Content-Type')) {
headers['Content-Type'] = 'application/json';
}
return JSON.stringify(data);
};
const executeFetchRequest = async ({ method, url, data, signal, headers }) => {
const fetchHeaders = { ...headers };
const body = buildFetchBody(method, data, fetchHeaders);
const response = await fetch(url, {
method,
headers: fetchHeaders,
...(body !== undefined ? { body } : {}),
...(signal ? { signal } : {}),
});
const responseData = await parseFetchResponseData(response);
const axiosLikeResponse = {
data: responseData,
status: response.status,
statusText: response.statusText,
headers: response.headers,
config: {
data,
headers: fetchHeaders,
method,
url,
},
request: null,
};
if (response.ok) {
return axiosLikeResponse;
}
const error = new Error(`Request failed with status code ${response.status}`);
error.name = 'AxiosError';
error.response = axiosLikeResponse;
throw error;
};
const buildRequestQueueOptions = (url, method, options = {}) => {
const queueOptions = {
retryByStatusCode: options?.retryByStatusCode,
shouldRetry: options?.shouldRetry,
queueGroup: options?.queueGroup,
concurrencyLimit: options?.concurrencyLimit,
skipRequestByteAccounting: options?.skipRequestByteAccounting,
skipResponseByteAccounting: options?.skipResponseByteAccounting,
skipNetworkTotals: options?.skipNetworkTotals,
insightKey: options?.insightKey,
recordRecentOnSuccess: options?.recordRecentOnSuccess,
trackActiveRequest: options?.trackActiveRequest,
trackProgressCounters: options?.trackProgressCounters,
};
if (isSelfServeHardwareMutation(url, method)) {
queueOptions.retryByStatusCode ??= {};
queueOptions.queueGroup ??= SELF_SERVE_HARDWARE_QUEUE_GROUP;
queueOptions.concurrencyLimit ??= 1;
}
const posQueueRule = findPosLatencyQueueRule(url, method);
if (posQueueRule) {
queueOptions.retryByStatusCode ??= posQueueRule.retryByStatusCode;
queueOptions.queueGroup ??= posQueueRule.queueGroup;
queueOptions.concurrencyLimit ??= posQueueRule.concurrencyLimit;
queueOptions.skipRequestByteAccounting ??= posQueueRule.skipRequestByteAccounting;
queueOptions.skipResponseByteAccounting ??= posQueueRule.skipResponseByteAccounting;
queueOptions.skipNetworkTotals ??= posQueueRule.skipNetworkTotals;
queueOptions.insightKey ??= posQueueRule.insightKey;
queueOptions.recordRecentOnSuccess ??= posQueueRule.recordRecentOnSuccess;
queueOptions.trackActiveRequest ??= posQueueRule.trackActiveRequest;
queueOptions.trackProgressCounters ??= posQueueRule.trackProgressCounters;
}
return queueOptions;
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
const token = localStorage.getItem('token');
if (!token) {
//throw new Error('No token was found, unable to make authenticated request');
}
const requestUrl = resolveReleaseApiUrl(url);
const canSendCredentials = isTrustedReleaseUrl(requestUrl);
// Build headers
const headers = {
...buildCurrentReleaseHeaders(),
...(options?.headers || {}),
};
if (token && token.length > 0) {
if (canSendCredentials && token && token.length > 0) {
headers.Authorization = `Bearer ${token}`;
}
// Add X-Customer-Number header if subuser has selected a grant
const isSubuser = localStorage.getItem('is_subuser') === 'true';
const selectedCustomerNumber = getSelectedCustomerNumber();
if (isSubuser && selectedCustomerNumber) {
if (canSendCredentials && isSubuser && selectedCustomerNumber) {
headers['X-Customer-Number'] = selectedCustomerNumber;
}
const requestUrl = resolveReleaseApiUrl(url);
const useFetchTransport = options?.transport === FETCH_TRANSPORT;
return enqueueRequest(
() => axios({
() => useFetchTransport
? executeFetchRequest({
method,
url: requestUrl,
data,
signal: options?.signal,
headers,
})
: axios({
method,
url: requestUrl,
...(method === 'GET' ? { params: data } : { data }),
...(options?.signal ? { signal: options.signal } : {}),
__skipRequestQueue: true,
headers
}),
@@ -49,7 +294,9 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
params: method === 'GET' ? data : null,
data: method === 'GET' ? null : data,
headers,
}
},
signal: options?.signal,
...buildRequestQueueOptions(requestUrl, method, options),
}
)
.catch((error) => {
@@ -60,6 +307,7 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
throw error;
})
.then((response) => {
response = normalizeActiveWashResponse(url, method, response);
if (thenCallable) {
// Call the then callable
thenCallable(response);
+37 -1
View File
@@ -58,6 +58,12 @@ import {
releaseChannelRuntimeRequestParams,
setReleaseChannelSwitchNoticePrincipal,
} from "@/services/releaseChannelAvailability.js";
import { clearCachedXlvaskUsageAmount } from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import {
clearPeriodCache,
clearSelfWashCountsCache,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -98,7 +104,16 @@ const hydrateSessionFromStorage = () => {
return true;
};
const clearPrivilegedSessionCaches = () => {
clearPeriodCache();
clearSelfWashCountsCache();
clearCachedXlvaskUsageAmount();
clearEdgeGatewayWorkspaceCache();
};
const clearStoredSession = () => {
clearPrivilegedSessionCaches();
if (typeof window === "undefined") {
return;
}
@@ -115,7 +130,8 @@ const applyReleaseRuntimeConfig = (runtime) => {
const normalizedRuntime = runtime || {};
configureReleaseRuntime(normalizedRuntime);
reconcileSelectedReleaseChannel(normalizedRuntime);
const runtimeUrls = normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
const runtimeUrls =
normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
SessionUser.runtimeConfig.release.traceId.value = normalizedRuntime.trace_id || null;
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
SessionUser.runtimeConfig.release.availableChannels.value =
@@ -168,6 +184,7 @@ const resetSessionState = () => {
SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = null;
SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null;
@@ -377,6 +394,7 @@ export const authenticateUser = async (customer_number, password) => {
password,
})
.then((response) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = response.data.token;
SessionUser.authenticated.value = true;
localStorage.setItem("token", response.data.token);
@@ -423,6 +441,7 @@ export const authenticateSubuser = async (credentials) => {
return await authenticatedRequest("/subusers/auth/password", "POST", requestBody)
.then((response) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = response.data.data.session;
SessionUser.authenticated.value = true;
SessionUser.isSubuser.value = true;
@@ -510,6 +529,8 @@ export const getSessionData = async () => {
SessionUser.user.notifications.email_notifications_enabled.value =
session.notifications.email_notifications_enabled;
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value =
session.notifications.superuser_new_customer_email_notifications_enabled;
SessionUser.user.created_at.value = session.created_at;
SessionUser.user.updated_at.value = session.updated_at;
SessionUser.user.display_name.value = session.display_name;
@@ -605,6 +626,7 @@ export const SessionUser = {
wash_certificate_email: ref(null),
email_notifications_enabled: ref(null),
sms_notifications_enabled: ref(null),
superuser_new_customer_email_notifications_enabled: ref(null),
setEmailNotificationsEnabled: (enabled) => {
return SessionUser.request("/account/notifications", "PUT", {
email_notifications_enabled: enabled,
@@ -629,6 +651,18 @@ export const SessionUser = {
console.error(error);
});
},
setSuperuserNewCustomerEmailNotificationsEnabled: (enabled) => {
return SessionUser.request("/account/notifications", "PUT", {
superuser_new_customer_email_notifications_enabled: enabled,
})
.then(() => {
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = enabled;
})
.catch((error) => {
parseError(error, "user_notifications");
console.error(error);
});
},
setWashCertificateEmail: (email) => {
return SessionUser.request("/account/notifications", "PUT", {
wash_certificate_email: email,
@@ -669,6 +703,7 @@ export const SessionUser = {
* @param {number|null} customerNumber - The billing_customer_number to select, or null to clear
*/
selectGrant: (customerNumber) => {
clearEdgeGatewayWorkspaceCache();
if (customerNumber === null) {
localStorage.removeItem("selected_customer_number");
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
@@ -886,6 +921,7 @@ export const SessionUser = {
* @param {string} token
*/
setToken: (token) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = token;
localStorage.setItem("token", token);
// Reload the window to make sure the user's session is initiated
@@ -456,6 +456,29 @@ export const CollectedOrderInvoices = {
throw error;
});
},
move_to_customer: async (id, customerNumber) => {
const invoiceCollectionId = parseInt(id);
const targetCustomerNumber = parseInt(customerNumber);
if (!Number.isInteger(invoiceCollectionId) || invoiceCollectionId <= 0) {
throw new Error('Invalid invoice collection id');
}
if (!Number.isInteger(targetCustomerNumber) || targetCustomerNumber <= 0) {
throw new Error('Invalid customer number');
}
return authenticatedRequest('/collected-invoices/move-to-customer', 'POST', {
id: invoiceCollectionId,
customer_number: targetCustomerNumber,
}).then((response) => {
console.log(response);
return response;
}).catch((error) => {
console.log(error);
throw error;
});
},
showCreateNewCustom: showCreateCustomInvoiceCollectionForm,
showInvoiceCollectionPickerForm: showInvoiceCollectionPickerModal,
add_vehicle_subscriptions: async (id) => {
@@ -820,4 +843,3 @@ export const CollectedOrderInvoices = {
}
};
</script>
@@ -7,6 +7,13 @@ import { getSystemUserIds } from "@/components/session/token/SessionUser/Objects
// Helper function to get i18n translation
const t = (key) => i18n.global.t(key);
const escapeHtml = (value = "") => String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
const normalizeDateTimeLocalValue = (value) => {
if (value === null || value === undefined || value === '') {
return '';
@@ -765,9 +772,11 @@ export const ObjectsGlobal = {
console.log(response);
// How long are the list of options
for (let i = 0; i < response.length; i++) {
var tmp = response[i];
const tmp = response[i];
const optionValue = tmp?.id ?? "";
const optionLabel = tmp?.name ? tmp.name : optionValue;
console.log(tmp);
html += `<option value="${tmp.id}" ${(value == tmp.id || (value === null && tmp.id === 0)) ? 'selected' : ''}>${tmp.name ? tmp.name : tmp.id}</option>`;
html += `<option value="${escapeHtml(optionValue)}" ${(value == optionValue || (value === null && optionValue === 0)) ? 'selected' : ''}>${escapeHtml(optionLabel)}</option>`;
}
});
html += `</select>
@@ -4,7 +4,6 @@ import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/Obje
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { editOrderItem, getOrderItems } from "@/components/shop/OrdersItems.vue";
import {createApp} from "vue";
import i18n from '@/i18n';
import { dispatchNavigationCountRefresh } from "@/components/models/navigation/items/navigationCountEvents.js";
@@ -19,6 +18,14 @@ const getProtectedDeletePayload = (error) => {
return payload;
};
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
})[character]);
const formatDeleteProtectionReason = (reason) => {
switch (reason) {
case "completed":
@@ -28,22 +35,23 @@ const formatDeleteProtectionReason = (reason) => {
case "attachments":
return "ordren har vedhæftninger";
default:
return String(reason || "").replace(/_/g, " ");
return escapeHtml(String(reason || "").replace(/_/g, " "));
}
};
const showProtectedOrderDeleteConfirmation = async (id, protectionPayload, onAfterSubmit = null) => {
const orderId = String(id);
const escapedOrderId = escapeHtml(orderId);
const reasons = Array.isArray(protectionPayload?.protected_reasons)
? protectionPayload.protected_reasons.map(formatDeleteProtectionReason).filter(Boolean)
: [];
const reasonText = reasons.length > 0 ? reasons.join(", ") : "ordren indeholder gemte data";
const result = await Swal.fire({
title: `Bekræft sletning af ordre #${orderId}`,
titleText: `Bekræft sletning af ordre #${orderId}`,
html: `
<p>Ordren kan stadig slettes, men kræver ekstra bekræftelse fordi ${reasonText}.</p>
<p>Skriv <strong>${orderId}</strong> for at slette ordren.</p>
<p>Skriv <strong>${escapedOrderId}</strong> for at slette ordren.</p>
`,
input: "text",
inputAttributes: {
@@ -256,6 +264,19 @@ const refreshDraftNavigationCount = () => {
dispatchNavigationCountRefresh();
};
const getOrderItemsForRepricing = (orderId) => authenticatedRequest('/order/items', 'GET', {
order_id: orderId,
});
const editOrderItemForRepricing = ({ id, price, notes, reference, quantity }) => authenticatedRequest('/order/items', 'PUT', {
id,
price,
notes,
reference,
quantity,
});
const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => {
const normalizedProductId = normalizePositiveInteger(productId);
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
@@ -289,7 +310,7 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
throw new Error("Invalid order repricing context");
}
const response = await getOrderItems(normalizedOrderId);
const response = await getOrderItemsForRepricing(normalizedOrderId);
const orderItems = Array.isArray(response?.data?.data) ? response.data.data : [];
const uniqueProductIds = [...new Set(
orderItems
@@ -320,13 +341,13 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
return null;
}
return editOrderItem(
normalizedItemId,
finalPriceMap.get(normalizedProductId),
item?.notes ?? "",
item?.reference ?? "",
normalizePositiveInteger(item?.quantity) ?? 1
);
return editOrderItemForRepricing({
id: normalizedItemId,
price: finalPriceMap.get(normalizedProductId),
notes: item?.notes ?? "",
reference: item?.reference ?? "",
quantity: normalizePositiveInteger(item?.quantity) ?? 1,
});
})
.filter(Boolean);
@@ -361,11 +382,6 @@ const assignDraftOrderCustomer = async ({
normalizedCustomerId
);
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
normalizedOrderId,
normalizedInvoiceCollectionId
);
let repricingResponse = null;
if (recalculate_prices) {
repricingResponse = await recalculateOrderItemPricesForCustomer({
@@ -375,6 +391,11 @@ const assignDraftOrderCustomer = async ({
});
}
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
normalizedOrderId,
normalizedInvoiceCollectionId
);
return {
customerResponse,
invoiceCollectionResponse,
@@ -127,6 +127,13 @@ export const SelfServeVehicleConditions = {
}
},
add: async (department, lane, customer_id, reg, question, value, options = {}) => {
const safeOptions = {};
const normalizedVehicleType = parseInt(options.vehicle_type ?? options.vehicle_type_id);
if (!Number.isNaN(normalizedVehicleType) && normalizedVehicleType > 0) {
safeOptions.vehicle_type = normalizedVehicleType;
}
return ObjectsGlobal.add.object(SelfServeVehicleConditions.meta.endpoint, {
department: parseInt(department),
lane: parseInt(lane),
@@ -134,7 +141,9 @@ export const SelfServeVehicleConditions = {
reg: reg,
question: parseInt(question),
value: value === "true" || value === true,
...options
...safeOptions,
activate_machine: false,
sync_relay_state: false
});
},
set: {
@@ -164,7 +173,7 @@ export const SelfServeVehicleConditions = {
single: async (id) => {
return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id);
},
previewAllowed: async (laneId, reg, vehicleTypeId = null) => {
previewAllowed: async (laneId, reg, vehicleTypeId, options = {}) => {
const params = {
lane_id: parseInt(laneId),
reg: reg,
@@ -175,11 +184,11 @@ export const SelfServeVehicleConditions = {
params.vehicle_type = normalizedVehicleTypeId;
}
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params)
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params, null, null, options)
.then((response) => response.data.data || response.data);
},
washSummary: async (params = {}) => {
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params)
washSummary: async (params, options) => {
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params || {}, null, null, options || {})
.then((response) => response.data.data || response.data);
}
},
@@ -17,13 +17,15 @@ const normalizeEntries = (entries) => {
return [];
};
const SENSITIVE_WRITE_ONLY_KEYS = new Set(["broker_shared_secret"]);
const toConfigPayload = (entries) => {
const payload = {};
normalizeEntries(entries).forEach((entry) => {
if (!entry || !entry.variable) {
return;
}
payload[entry.variable] = entry.value;
payload[entry.variable] = SENSITIVE_WRITE_ONLY_KEYS.has(entry.variable) ? "" : entry.value;
});
return payload;
};
@@ -42,6 +44,11 @@ export const Config = {
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
set: async (variable, value) => {
const currentConfig = await Config.get_config();
SENSITIVE_WRITE_ONLY_KEYS.forEach((key) => {
if (key !== variable) {
delete currentConfig[key];
}
});
return Config.set_config({
...currentConfig,
[variable]: value,
@@ -73,7 +80,6 @@ export const Config = {
set: async (value) => Config.set("broker_auth_mode", value),
},
broker_shared_secret: {
get: async () => Config.get("broker_shared_secret"),
set: async (value) => Config.set("broker_shared_secret", value),
},
},
@@ -61,6 +61,14 @@ export const Config = {
});
},
keys: {
dynamic_image_size: {
get: async () => {
return Config.get("dynamic_image_size");
},
set: async (value) => {
return Config.set("dynamic_image_size", value);
},
},
minute_product: {
get: async () => {
return Config.get("minute_product");
@@ -0,0 +1,34 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
/**
* The Slack -> Config object
*/
export const Config = {
get: async (variable) => {
return authenticatedRequest("/slack/config?variable=" + variable, "GET");
},
get_all: async () => {
return authenticatedRequest("/slack/config", "GET");
},
set: async (variable, value) => {
return authenticatedRequest("/slack/config", "POST", {
variable: variable,
value: value,
});
},
test_customer_registration_webhook: async () => {
return authenticatedRequest("/slack/config/test", "POST", {});
},
keys: {
customer_registration_webhook_url: {
get: async () => {
return Config.get("customer_registration_webhook_url");
},
set: async (value) => {
return Config.set("customer_registration_webhook_url", value);
},
},
},
};
</script>
@@ -0,0 +1,21 @@
<script>
import { Config } from "@/components/session/token/superUser/modules/slack/Config.vue";
/**
* The Slack object
*/
export const Slack = {
meta: {
title: "Slack",
icon: "fab fa-slack",
description: "Slack notifications",
endpoint: "/slack/config",
config_endpoint: "/configuration/slack",
labels: {
single: "slack",
multiple: "slack",
},
},
config: Config,
};
</script>
@@ -1,6 +1,7 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { parseError } from "@/components/request/HandleGlobalError.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
import { Cron } from "@/components/session/token/superUser/cron.vue";
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
@@ -27,6 +28,7 @@ import { EdgeGateway } from "@/components/session/token/superUser/modules/edgega
import { Failover } from "@/components/session/token/superUser/modules/failover/Failover.vue";
import { Coolify } from "@/components/session/token/superUser/modules/coolify/Coolify.vue";
import { ReleaseManager } from "@/components/session/token/superUser/modules/releasemanager/ReleaseManager.vue";
import { Slack } from "@/components/session/token/superUser/modules/slack/Slack.vue";
let qrCodeModulePromise;
@@ -71,6 +73,7 @@ export const SuperUserObject = {
get failover() { return Failover; },
get coolify() { return Coolify; },
get releasemanager() { return ReleaseManager; },
get slack() { return Slack; },
},
/** Intimidate a user */
intimidate: {
@@ -106,6 +109,7 @@ export const SuperUserObject = {
const currentToken = localStorage.getItem("token");
localStorage.setItem("superuser_token", currentToken);
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", response.data.data.token);
// Redirect the user to the dashboard
window.location = "/";
@@ -143,6 +147,7 @@ export const SuperUserObject = {
// Get the superuser token
const superuserToken = localStorage.getItem("superuser_token");
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", superuserToken);
// Clear the superuser token
localStorage.removeItem("superuser_token");
+46 -33
View File
@@ -3,7 +3,6 @@ import { computed, ref, watch } from "vue";
import axios from "axios";
import { API_URL } from "@/config.js";
import { getScansDepartmentPagination } from "@/components/numberplatescanners/Scans.vue";
import { useRouter } from "vue-router";
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import { getNotes, createNote, deleteNote } from "@/components/shop/CustomerNotes.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
@@ -131,12 +130,12 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
// If the current step is 2, set the product category to addons
setProductsCategory(4);
// Set the step to 3 in the query parameters
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
}
if (step.value === 3) {
if (normalizedOptions.isMobile === false && doesOrderContainMaterial()) {
step.value = 4;
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
return;
}
@@ -231,11 +230,7 @@ const toPositiveInteger = (value) => {
};
const getRouteDepartmentId = () => {
try {
return toPositiveInteger(useRouter().currentRoute.value.params.departmentId);
} catch (error) {
return null;
}
return getWindowPathDepartmentId();
};
const getSessionUrlDepartmentId = () => {
@@ -320,20 +315,26 @@ export const ensureCurrentOrderDepartment = async (targetDepartmentId = null) =>
}
try {
let currentDepartmentId = null;
try {
currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
} catch (error) {
currentDepartmentId = null;
}
const currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
if (currentDepartmentId === normalizedDepartmentId) {
department_id.value = normalizedDepartmentId;
return true;
}
await SessionUser.objects.orders.set.department_id(normalizedOrderId, normalizedDepartmentId);
department_id.value = normalizedDepartmentId;
return true;
parseError(
{
response: {
data: {
data: {
message: "Ordren tilhører ikke den valgte afdeling",
},
message: "Ordren tilhører ikke den valgte afdeling",
},
},
},
"stepError"
);
return false;
} catch (error) {
parseError(error, "stepError");
return false;
@@ -727,9 +728,9 @@ export const restoreStoredPosOrderId = async (
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
if (options.syncDepartment === true) {
order_id.value = storedOrderId;
const didSyncDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
if (!didSyncDepartment) {
throw new Error("Stored order department could not be changed to current department");
const isCurrentDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
if (!isCurrentDepartment) {
throw new Error("Stored order department does not match current department");
}
} else {
throw new Error("Stored order department does not match current department");
@@ -751,7 +752,7 @@ export const reset_all_values = () => {
// Reset all values to their initial state
clearActivePosOrderContext({ clearStep: true });
// Clear the query parameters
window.history.pushState({}, "", `?step=1`);
pushPosRouteState("step=1");
};
/** Whenever the reg_1, reg_2 or reg_3 changes, remove any spaces and make the string uppercase */
@@ -762,6 +763,21 @@ const normalizeRegistrationValue = (value) =>
export const isBlankPosMetadataValue = (value) => String(value ?? "").trim().length === 0;
const getPosRoutePath = () => {
const normalizedDepartmentId = Number.parseInt(String(department_id.value || ""), 10);
if (Number.isInteger(normalizedDepartmentId) && normalizedDepartmentId > 0) {
return `/admin/${normalizedDepartmentId}/modules/pos`;
}
return window.location.pathname && window.location.pathname !== "/" ? window.location.pathname : "/admin/modules/pos";
};
export const pushPosRouteState = (queryString) => {
const normalizedQueryString = String(queryString || "").replace(/^\?/, "");
window.history.pushState({}, "", `${getPosRoutePath()}?${normalizedQueryString}`);
};
watch([reg_1, reg_2, reg_3], () => {
reg_1.value = normalizeRegistrationValue(reg_1.value);
reg_2.value = normalizeRegistrationValue(reg_2.value);
@@ -908,7 +924,7 @@ export const createOrder = async (options = { isMobile: false }) => {
// Set the order id in the local storage (To be make F5 safe)
localStorage.setItem("pos_order_id", order_id.value);
// Set the query parameters
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
return true;
})
.catch((error) => {
@@ -1625,7 +1641,7 @@ export const showDeleteEverythingDialog = () => {
/** Clear cache */
export const clearCache = () => {
// Reset the query parameters
window.history.pushState({}, "", `?step=1`);
pushPosRouteState("step=1");
// Clear the customer data
clearCustomerSelection();
// Clear the order data
@@ -2012,18 +2028,14 @@ export const getVehiclePlateBookings = (vehiclePlate) => {
return sortPendingOrderBookings(matchingBookings);
};
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, options = {}) => {
const normalizedOptions = {
force: false,
...options,
};
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, _options = {}) => {
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
const currentDepartmentKey = syncPendingBookingsDepartmentState();
if (!normalizedVehiclePlate || !department_id.value) {
return [];
}
if (!normalizedOptions.force && loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
if (loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
return getVehiclePlateBookings(normalizedVehiclePlate);
}
@@ -2167,7 +2179,7 @@ const getFirstAvailableWashProduct = async () => {
}
};
const hydrateSelectedOrderBookingForDesktop = async () => {
export const hydrateSelectedOrderBookingForDesktop = async () => {
const normalizedOrderId = toPositiveInteger(order_id.value);
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
@@ -2250,8 +2262,7 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
}
const forcedPrimaryPrice =
typeof primaryRawItem?.price === "number" ? Number(primaryRawItem.price) : primaryProduct.price ?? null;
const forcedPrimaryPrice = primaryProduct.price ?? null;
const primaryItemResponse = await createOrderItem(
normalizedOrderId,
primaryProduct.id,
@@ -2269,13 +2280,15 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
continue;
}
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
await createOrderItem(
normalizedOrderId,
secondaryProductId,
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
relatedPrimaryItemId,
String(bookingItem?.notes ?? "").trim() || null,
typeof bookingItem?.price === "number" ? Number(bookingItem.price) : null
secondaryProduct?.price ?? null
);
}
+3
View File
@@ -11,6 +11,8 @@ import {useRouter} from "vue-router";
import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue";
import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
import { inject } from "vue";
const router = useRouter();
const headerHeight = ref(60); // Default header height in pixels
@@ -87,6 +89,7 @@ const isFooterInContent = computed(() => {
style="max-height: 28px;"
>
<p class="is-size-7 has-text-grey-light mb-2 mt-0">© Truckwash ApS. All rights reserved.</p>
<p class="is-size-7 has-text-grey-light mb-2 mt-0" v-show="SessionUser.functions.device.isMobile()">{{inject("VERSION")}}</p>
</a>
</div>
</ViewportContent>
@@ -1,10 +1,20 @@
<script setup lang="ts">
import {BButton, BField, BIcon} from "buefy";
import {computed, ref} from "vue";
import {computed, onBeforeUnmount, ref, watch} from "vue";
import { useRouter } from "vue-router";
import { IS_DEV } from '@/config.js';
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const router = useRouter();
const HOME_LONG_PRESS_MS = 5000;
const HOME_CLICK_SUPPRESS_MS = 1200;
type FooterPage = {
to: string;
label: string;
icon: string;
disabled: boolean;
};
const show = computed(() => {
const path = router.currentRoute.value.path;
@@ -12,10 +22,16 @@ const show = computed(() => {
return path.startsWith("/user");
});
const currentPath = computed(() => router.currentRoute.value.path);
const isLocalDataResetOpen = ref(false);
const isClearingLocalData = ref(false);
const suppressHomeClickUntil = ref(0);
let homeLongPressTimer: ReturnType<typeof window.setTimeout> | null = null;
/**
* Page navigation footer for mobile devices
*/
const pages = {
const pages: Record<string, FooterPage> = {
home: {
to: "/user",
label: "Hjem",
@@ -35,6 +51,78 @@ const pages = {
disabled: false
},
}
const cancelHomeLongPress = () => {
if (homeLongPressTimer !== null) {
window.clearTimeout(homeLongPressTimer);
homeLongPressTimer = null;
}
};
const startHomeLongPress = (event: PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
if (isLocalDataResetOpen.value || isClearingLocalData.value) {
return;
}
cancelHomeLongPress();
homeLongPressTimer = window.setTimeout(() => {
homeLongPressTimer = null;
suppressHomeClickUntil.value = Date.now() + HOME_CLICK_SUPPRESS_MS;
isLocalDataResetOpen.value = true;
}, HOME_LONG_PRESS_MS);
};
const footerButtonListeners = (key: string) =>
key === "home"
? {
pointerdown: startHomeLongPress,
pointerup: cancelHomeLongPress,
pointercancel: cancelHomeLongPress,
pointerleave: cancelHomeLongPress,
}
: {};
const isActiveFooterRoute = (path: string) => currentPath.value.endsWith(path);
const handlePageClick = (event: MouseEvent, key: string, page: FooterPage) => {
if (page.disabled) {
return;
}
if (key === "home" && Date.now() <= suppressHomeClickUntil.value) {
event.preventDefault();
event.stopPropagation();
suppressHomeClickUntil.value = 0;
return;
}
router.push(page.to);
};
const closeLocalDataReset = () => {
isLocalDataResetOpen.value = false;
};
const confirmLocalDataReset = async () => {
if (isClearingLocalData.value) {
return;
}
isClearingLocalData.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isClearingLocalData.value = false;
console.error("Failed to clear local app data:", error);
}
};
watch(() => router.currentRoute.value.fullPath || router.currentRoute.value.path, cancelHomeLongPress);
onBeforeUnmount(cancelHomeLongPress);
</script>
<template>
@@ -45,13 +133,15 @@ const pages = {
<template v-for="(page, key) in pages" :key="key">
<div class="column has-text-centered">
<b-button
class="no-border-radius"
:type="$route.path.endsWith(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
class="no-border-radius"
:data-testid="`mobile-footer-${key}`"
:type="isActiveFooterRoute(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
size="is-normal"
@click="$router.push(page.to)"
v-on="footerButtonListeners(key)"
@click="handlePageClick($event, key, page)"
iconPack="fas"
expanded
:disabled="page.disabled"
:disabled="page.disabled"
>
<span>
<span><b-icon pack="fas" :icon="page.icon"></b-icon></span>
@@ -64,6 +154,12 @@ const pages = {
</div>
</b-field>
</section>
<LocalDataResetDialog
v-model="isLocalDataResetOpen"
:busy="isClearingLocalData"
@confirm="confirmLocalDataReset"
@dismiss="closeLocalDataReset"
/>
</div>
</template>
@@ -84,4 +180,4 @@ const pages = {
border-top-right-radius: 4px;
background-color: #2c3e50;
}
</style>
</style>
@@ -13,8 +13,7 @@ import {
getDepartmentSelectionVisibility,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import {
isAccessibleVisibleDepartment,
isDepartmentLabelValid,
isAccessibleVisibleNamedDepartment,
sortByDepartmentPriorityOrder,
} from "@/services/departmentVisibility.js";
@@ -35,7 +34,7 @@ const options = computed(() => {
// Filter out departments the user does not have access to
return departments.value
.filter((department: department) => {
return isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment);
return isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment);
})
.map((department: department) => {
return {
@@ -56,7 +55,7 @@ const mobileOptions = computed(() => {
});
const desktopOptions = computed(() => {
return sortByDepartmentPriorityOrder(options.value.filter((option) => isDepartmentLabelValid(option.label)));
return sortByDepartmentPriorityOrder(options.value);
});
const selectDepartment = (departmentId: number | string) => {
@@ -92,11 +91,17 @@ const getDistanceToDepartment = (departmentId: number | null, optionList = mobil
return 0;
}
const department = optionList.find((dept) => dept.value === departmentId);
if (department && locations.location.value?.coords) {
return locations.getDistance(
{ latitude: locations.location.value.coords.latitude, longitude: locations.location.value.coords.longitude },
{ latitude: department.latitude, longitude: department.longitude }
const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
const departmentCoords = locations.normalizeCoordinatePair(
{ latitude: department?.latitude, longitude: department?.longitude },
{ allowZeroPair: false }
);
if (department && currentCoords && departmentCoords) {
const distance = locations.getDistance(
currentCoords,
departmentCoords
);
return Number.isFinite(distance) ? distance : 0;
}
return 0;
};
@@ -1,26 +1,147 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { nextTick, ref, onMounted, onUnmounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
captureVideoFrameBlobForLPR,
isVideoFrameReadyForLPR,
LPR_CAMERA_VIDEO_HEIGHT,
LPR_CAMERA_VIDEO_WIDTH,
type LPRFrameEncodeCandidate,
type LPRFrameViewportRect,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
const props = withDefaults(
defineProps<{
captureEnabled?: boolean;
captureIntervalMs?: number | null;
captureMode?: "lpr" | "preview";
getFocusViewportRect?: () => LPRFrameViewportRect | null;
pausePreview?: boolean;
shouldBuildVisualFingerprint?: () => boolean;
shouldEncodeFrame?: (_candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
}>(),
{
captureEnabled: true,
captureIntervalMs: null,
captureMode: "lpr",
getFocusViewportRect: undefined,
pausePreview: false,
shouldBuildVisualFingerprint: undefined,
shouldEncodeFrame: undefined,
}
);
type GetUserMediaConstraints = Parameters<typeof navigator.mediaDevices.getUserMedia>[0];
type CameraConstraintCaps = {
capFrameRate: boolean;
capResolution: boolean;
};
const LPR_VIDEO_NOT_READY_RETRY_MS = 100;
const LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS = LPR_VIDEO_NOT_READY_RETRY_MS;
const LPR_CAMERA_PREVIEW_FRAME_RATE = 30;
const emits = defineEmits(["camera-toggled", "scanner-toggled", "update:frame"]);
const { t } = useI18n();
const videoRef = ref<HTMLVideoElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const visualFingerprintCanvasRef = ref<HTMLCanvasElement | null>(null);
const cameraStream = ref<MediaStream | null>(null);
const isCameraActive = ref(false);
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
const cameraErrorKey = ref("pos.camera_permission_denied");
let captureIntervalId: ReturnType<typeof window.setInterval> | null = null;
let firstCaptureTimeoutId: ReturnType<typeof window.setTimeout> | null = null;
let isFrameCaptureInProgress = false;
let hasRequestedVideoPreviewPlay = false;
let lastAppliedTrackEnabled: boolean | null = null;
let cachedRelativeFocusViewportRect: LPRFrameViewportRect | null = null;
let hasCachedRelativeFocusViewportRect = false;
let cachedVideoViewportRect: DOMRect | null = null;
let videoResizeObserver: ResizeObserver | null = null;
import {
isCameraMounted,
camera,
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
function startCamera() {
const constraints = {
const isDocumentVisible = (): boolean => typeof document === "undefined" || document.visibilityState !== "hidden";
const shouldRunLivePreview = (): boolean => !props.pausePreview && isDocumentVisible();
const canCaptureFrames = (): boolean =>
isCameraActive.value && props.captureEnabled && !props.pausePreview && isDocumentVisible();
const getCameraErrorName = (err: unknown): string => {
if (err instanceof DOMException) {
return err.name;
}
return typeof err === "object" && err !== null ? String((err as { name?: unknown }).name ?? "") : "";
};
const getCameraErrorKey = (err: unknown) => {
const errorName = getCameraErrorName(err);
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
return "pos.no_camera_found";
}
return "pos.camera_permission_denied";
};
const shouldRetryWithRelaxedCameraConstraints = (err: unknown): boolean => {
const errorName = getCameraErrorName(err);
return errorName === "OverconstrainedError" || errorName === "ConstraintNotSatisfiedError";
};
const getRejectedCameraConstraintName = (err: unknown): string => {
if (typeof err !== "object" || err === null) {
return "";
}
return String((err as { constraint?: unknown }).constraint ?? "").toLowerCase();
};
const getCameraConstraintFallbacks = (err: unknown): CameraConstraintCaps[] => {
const rejectedConstraint = getRejectedCameraConstraintName(err);
if (rejectedConstraint === "framerate") {
return [
{ capFrameRate: false, capResolution: true },
{ capFrameRate: false, capResolution: false },
];
}
if (["width", "height", "aspectratio", "resizemode"].includes(rejectedConstraint)) {
return [
{ capFrameRate: true, capResolution: false },
{ capFrameRate: false, capResolution: false },
];
}
return [
{ capFrameRate: false, capResolution: true },
{ capFrameRate: true, capResolution: false },
{ capFrameRate: false, capResolution: false },
];
};
const getCameraConstraints = (
{ capFrameRate, capResolution }: CameraConstraintCaps = { capFrameRate: true, capResolution: true }
) =>
({
video: {
facingMode: 'environment',
facingMode: "environment",
zoom: camera.getZoom(),
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30 },
width: capResolution
? { ideal: LPR_CAMERA_VIDEO_WIDTH, max: LPR_CAMERA_VIDEO_WIDTH }
: { ideal: LPR_CAMERA_VIDEO_WIDTH },
height: capResolution
? { ideal: LPR_CAMERA_VIDEO_HEIGHT, max: LPR_CAMERA_VIDEO_HEIGHT }
: { ideal: LPR_CAMERA_VIDEO_HEIGHT },
frameRate: capFrameRate
? { ideal: LPR_CAMERA_PREVIEW_FRAME_RATE, max: LPR_CAMERA_PREVIEW_FRAME_RATE }
: { ideal: LPR_CAMERA_PREVIEW_FRAME_RATE },
// New spec
advanced: [
{ focusMode: 'continuous' },
{ torch: false } // Set to true to enable flashlight if supported
{ focusMode: "continuous" },
{ torch: false }, // Set to true to enable flashlight if supported
],
// Old spec
//focusMode: 'continuous',
@@ -30,28 +151,257 @@ function startCamera() {
//height: { ideal: 1080 },
//aspectRatio: { ideal: 16/9 },
//frameRate: { ideal: 30 }
}
};
},
} as unknown as GetUserMediaConstraints);
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
isCameraActive.value = true;
cameraStream.value = stream;
if (videoRef.value) {
videoRef.value.srcObject = stream;
videoRef.value.setAttribute('playsinline', '');
videoRef.value.play();
const requestCameraStream = async (): Promise<MediaStream> => {
try {
return await navigator.mediaDevices.getUserMedia(getCameraConstraints());
} catch (err) {
if (!shouldRetryWithRelaxedCameraConstraints(err)) {
throw err;
}
let lastError = err;
for (const fallback of getCameraConstraintFallbacks(err)) {
try {
return await navigator.mediaDevices.getUserMedia(getCameraConstraints(fallback));
} catch (fallbackError) {
if (!shouldRetryWithRelaxedCameraConstraints(fallbackError)) {
throw fallbackError;
}
})
.catch((err) => {
isCameraActive.value = false;
console.error('Camera access error:', err);
});
lastError = fallbackError;
}
}
throw lastError;
}
};
const playVideoPreview = (video: HTMLVideoElement) => {
if (hasRequestedVideoPreviewPlay) {
return;
}
hasRequestedVideoPreviewPlay = true;
const playResult = video.play();
if (playResult && typeof playResult.catch === "function") {
void playResult.catch(() => {
hasRequestedVideoPreviewPlay = false;
});
}
};
const pauseVideoPreview = (video: HTMLVideoElement) => {
if (!hasRequestedVideoPreviewPlay) {
return;
}
hasRequestedVideoPreviewPlay = false;
video.pause();
};
const getCameraVideoTracks = (): MediaStreamTrack[] => {
if (!cameraStream.value) {
return [];
}
if (typeof cameraStream.value.getVideoTracks === "function") {
return cameraStream.value.getVideoTracks();
}
return cameraStream.value.getTracks().filter((track) => track.kind === "video");
};
const syncCameraVideoTracksEnabled = () => {
if (!isCameraActive.value) {
return;
}
const shouldEnableTracks = shouldRunLivePreview();
if (lastAppliedTrackEnabled === shouldEnableTracks) {
return;
}
getCameraVideoTracks().forEach((track) => {
if (track.enabled !== shouldEnableTracks) {
track.enabled = shouldEnableTracks;
}
});
lastAppliedTrackEnabled = shouldEnableTracks;
};
const syncVideoPreviewPlayback = () => {
syncCameraVideoTracksEnabled();
if (!videoRef.value || !isCameraActive.value) {
return;
}
if (!shouldRunLivePreview()) {
pauseVideoPreview(videoRef.value);
return;
}
playVideoPreview(videoRef.value);
};
const applyCameraStream = (stream: MediaStream) => {
isCameraActive.value = true;
isCameraMounted.value = true;
cameraStream.value = stream;
hasRequestedVideoPreviewPlay = false;
lastAppliedTrackEnabled = null;
if (videoRef.value) {
videoRef.value.srcObject = stream;
videoRef.value.setAttribute("playsinline", "");
syncVideoPreviewPlayback();
}
startCaptureTimers();
};
function startCamera() {
if (cameraStream.value || isCameraActive.value) {
return;
}
requestCameraStream()
.then(applyCameraStream)
.catch((err) => {
isCameraActive.value = false;
cameraErrorKey.value = getCameraErrorKey(err);
console.error("Camera access error:", err);
});
}
function clearCaptureInterval() {
if (captureIntervalId !== null) {
window.clearInterval(captureIntervalId);
captureIntervalId = null;
}
}
function clearFirstCaptureTimeout() {
if (firstCaptureTimeoutId !== null) {
window.clearTimeout(firstCaptureTimeoutId);
firstCaptureTimeoutId = null;
}
}
const pauseCaptureTimers = () => {
clearFirstCaptureTimeout();
clearCaptureInterval();
};
function captureFrameIfReady(): Promise<void> {
if (canCaptureFrames() && !isFrameCaptureInProgress) {
if (!videoRef.value || !isVideoFrameReadyForLPR(videoRef.value)) {
scheduleFrameReadinessRetry();
return Promise.resolve();
}
clearFirstCaptureTimeout();
return getFrame()
.then(() => undefined)
.finally(() => {
if (canCaptureFrames()) {
startCaptureInterval();
}
});
}
return Promise.resolve();
}
function startCaptureTimers() {
if (!canCaptureFrames()) {
pauseCaptureTimers();
return;
}
clearCaptureInterval();
scheduleFirstCapture();
}
function resumeCaptureTimers(firstCaptureDelayMs = 0) {
if (!canCaptureFrames()) {
pauseCaptureTimers();
return;
}
clearCaptureInterval();
scheduleFirstCapture(firstCaptureDelayMs);
}
function scheduleFirstCapture(delayMs = camera.getImageCaptureDelay(true)) {
clearFirstCaptureTimeout();
firstCaptureTimeoutId = window.setTimeout(() => {
firstCaptureTimeoutId = null;
captureFrameIfReady();
}, delayMs);
}
const getRecurringCaptureDelay = (): number => {
if (props.captureIntervalMs === null || props.captureIntervalMs === undefined) {
return camera.getImageCaptureDelay(false);
}
const customDelay = Number(props.captureIntervalMs);
if (Number.isFinite(customDelay) && customDelay >= 0) {
return Math.floor(customDelay);
}
return camera.getImageCaptureDelay(false);
};
function scheduleFrameReadinessRetry() {
if (firstCaptureTimeoutId !== null) {
return;
}
scheduleFirstCapture(LPR_VIDEO_NOT_READY_RETRY_MS);
}
function startCaptureInterval() {
if (!canCaptureFrames()) {
clearCaptureInterval();
return;
}
clearCaptureInterval();
captureIntervalId = window.setTimeout(() => {
captureIntervalId = null;
void captureFrameIfReady();
}, getRecurringCaptureDelay());
}
const clearRelativeFocusViewportRectCache = () => {
cachedRelativeFocusViewportRect = null;
cachedVideoViewportRect = null;
hasCachedRelativeFocusViewportRect = false;
};
const observeVideoGeometry = () => {
videoResizeObserver?.disconnect();
videoResizeObserver = null;
if (typeof ResizeObserver === "undefined" || !videoRef.value) {
return;
}
videoResizeObserver = new ResizeObserver(clearRelativeFocusViewportRectCache);
videoResizeObserver.observe(videoRef.value);
};
function stopCamera() {
clearFirstCaptureTimeout();
clearCaptureInterval();
clearRelativeFocusViewportRectCache();
hasRequestedVideoPreviewPlay = false;
lastAppliedTrackEnabled = null;
if (cameraStream.value) {
cameraStream.value.getTracks().forEach(track => track.stop());
cameraStream.value.getTracks().forEach((track) => track.stop());
cameraStream.value = null;
}
isCameraActive.value = false;
@@ -69,75 +419,188 @@ function toggleCamera() {
}
}
function handleVideoLoadedData() {
clearRelativeFocusViewportRectCache();
if (canCaptureFrames()) {
scheduleFirstCapture(0);
}
}
function handleVisibilityChange() {
clearRelativeFocusViewportRectCache();
if (isDocumentVisible()) {
syncVideoPreviewPlayback();
resumeCaptureTimers(0);
return;
}
pauseCaptureTimers();
syncVideoPreviewPlayback();
}
const shouldUseFocusedLPRCrop = () => props.captureMode === "lpr";
const hasUsableRectSize = (rect: { height: number; width: number }): boolean =>
Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0;
const getVideoViewportRect = (): DOMRect | null => {
if (!videoRef.value) {
return null;
}
if (cachedVideoViewportRect !== null) {
return cachedVideoViewportRect;
}
const videoRect = videoRef.value.getBoundingClientRect();
if (!hasUsableRectSize(videoRect)) {
return null;
}
cachedVideoViewportRect = videoRect;
return cachedVideoViewportRect;
};
const getRelativeFocusViewportRect = (videoViewportRect: DOMRect | null): LPRFrameViewportRect | null => {
if (!shouldUseFocusedLPRCrop() || !props.getFocusViewportRect || !videoRef.value) {
return null;
}
if (hasCachedRelativeFocusViewportRect) {
return cachedRelativeFocusViewportRect;
}
const focusRect = props.getFocusViewportRect();
if (!focusRect || !hasUsableRectSize(focusRect)) {
return null;
}
if (!videoViewportRect || !hasUsableRectSize(videoViewportRect)) {
return null;
}
cachedRelativeFocusViewportRect = {
height: focusRect.height,
width: focusRect.width,
x: focusRect.x - videoViewportRect.left,
y: focusRect.y - videoViewportRect.top,
};
hasCachedRelativeFocusViewportRect = true;
return cachedRelativeFocusViewportRect;
};
const getFrameCaptureOptions = () => {
const shouldUseFocusedCrop = shouldUseFocusedLPRCrop();
const videoViewportRect = shouldUseFocusedCrop ? getVideoViewportRect() : null;
return {
focusCrop: shouldUseFocusedCrop,
focusViewportRect: shouldUseFocusedCrop ? getRelativeFocusViewportRect(videoViewportRect) : null,
shouldBuildVisualFingerprint: shouldUseFocusedCrop ? props.shouldBuildVisualFingerprint : undefined,
shouldEncode: shouldUseFocusedCrop ? props.shouldEncodeFrame : undefined,
...(videoViewportRect
? {
viewportHeight: videoViewportRect.height,
viewportWidth: videoViewportRect.width,
}
: {}),
visualFingerprintCanvas: shouldUseFocusedCrop ? visualFingerprintCanvasRef.value : null,
};
};
const getFrame = () => {
if (!canCaptureFrames()) {
return Promise.resolve(null);
}
if (videoRef.value && isCameraActive.value) {
const canvas = canvasRef.value;
if (canvas) {
const context = canvas.getContext('2d', {
alpha: false,
willReadFrequently: true
});
isFrameCaptureInProgress = true;
if (!context) {
console.error('Failed to get canvas context');
return null;
}
return captureVideoFrameBlobForLPR(videoRef.value, canvas, getFrameCaptureOptions())
.then((frameData) => {
if (frameData && canCaptureFrames()) {
emits("update:frame", frameData);
}
// Set canvas size to match video's native resolution
const videoWidth = videoRef.value.videoWidth;
const videoHeight = videoRef.value.videoHeight;
canvas.width = videoWidth;
canvas.height = videoHeight;
// Clear previous frame
context.clearRect(0, 0, canvas.width, canvas.height);
// Draw current frame
context.drawImage(videoRef.value, 0, 0, videoWidth, videoHeight);
// Get frame data as base64
const frameData = canvas.toDataURL('image/jpeg', 0.95);
// Emit the frame data
emits('update:frame', frameData);
return frameData;
return nextTick().then(() => frameData);
})
.finally(() => {
isFrameCaptureInProgress = false;
syncVideoPreviewPlayback();
});
}
}
return null;
return Promise.resolve(null);
};
// Update canvas dimensions when video size changes
watch(() => videoRef.value?.videoWidth, (newWidth) => {
if (canvasRef.value && newWidth) {
canvasRef.value.width = newWidth;
canvasRef.value.height = videoRef.value?.videoHeight || 0;
}
});
// Watch for zoom level changes
watch(() => camera.getZoom(), (newZoom) => {
if (isCameraActive.value) {
stopCamera();
startCamera();
watch(
() => camera.getZoom(),
() => {
if (isCameraActive.value) {
stopCamera();
startCamera();
}
}
});
);
watch(
() => props.captureEnabled,
(isCaptureEnabled) => {
if (isCaptureEnabled) {
resumeCaptureTimers(0);
} else {
pauseCaptureTimers();
}
}
);
watch([() => props.captureMode, () => props.getFocusViewportRect], clearRelativeFocusViewportRectCache);
watch(
() => props.captureIntervalMs,
() => {
if (canCaptureFrames() && !isFrameCaptureInProgress && firstCaptureTimeoutId === null) {
startCaptureInterval();
}
}
);
watch(
() => props.pausePreview,
(isPreviewPaused) => {
syncVideoPreviewPlayback();
if (isPreviewPaused) {
pauseCaptureTimers();
return;
}
resumeCaptureTimers(LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS);
}
);
onMounted(() => {
// Emit a picture every 10 seconds
if (!isCameraMounted.value) {
isCameraMounted.value = true;
setInterval(() => {
if (isCameraActive.value) {
getFrame();
}
}, camera.getImageCaptureDelay(false)); // Implement a method to get the delay based on camera settings
startCamera();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
document.addEventListener("scroll", clearRelativeFocusViewportRectCache, true);
window.addEventListener("orientationchange", clearRelativeFocusViewportRectCache);
window.addEventListener("resize", clearRelativeFocusViewportRectCache);
observeVideoGeometry();
isCameraMounted.value = true;
startCamera();
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
document.removeEventListener("scroll", clearRelativeFocusViewportRectCache, true);
window.removeEventListener("orientationchange", clearRelativeFocusViewportRectCache);
window.removeEventListener("resize", clearRelativeFocusViewportRectCache);
videoResizeObserver?.disconnect();
videoResizeObserver = null;
clearRelativeFocusViewportRectCache();
stopCamera();
});
@@ -148,31 +611,32 @@ defineExpose({
stopCamera,
});
watch(() => isCameraActive.value, (newVal) => {
emits('camera-toggled', newVal);
});
watch(
() => isCameraActive.value,
(newVal) => {
emits("camera-toggled", newVal);
}
);
</script>
<template>
<div class="scanner-camera">
<video
ref="videoRef"
autoplay
playsinline
:class="{ 'is-active': isCameraActive }"
ref="videoRef"
autoplay
playsinline
:class="{ 'is-active': isCameraActive }"
@loadeddata="handleVideoLoadedData"
>
Your browser does not support the video tag.
</video>
<canvas
ref="canvasRef"
class="capture-canvas"
></canvas>
<canvas ref="canvasRef" class="capture-canvas"></canvas>
<canvas ref="visualFingerprintCanvasRef" class="visual-fingerprint-canvas" aria-hidden="true"></canvas>
<div v-if="!isCameraActive" class="camera-inactive">
<p>Camera is not active.</p>
<p>Please enable camera access.</p>
<p>{{ t(cameraErrorKey) }}</p>
</div>
</div>
</template>
@@ -202,6 +666,10 @@ video {
display: none;
}
.visual-fingerprint-canvas {
display: none;
}
.camera-inactive {
position: absolute;
top: 50%;
@@ -224,7 +692,7 @@ video {
}
.capture-button {
background: var(--primary-color, #4CAF50);
background: var(--primary-color, #4caf50);
color: white;
border: none;
padding: 0.8rem 1.5rem;
@@ -0,0 +1,772 @@
export const LPR_CAMERA_VIDEO_WIDTH = 1024;
export const LPR_CAMERA_VIDEO_HEIGHT = 576;
export const LPR_FRAME_MAX_WIDTH = 1024;
export const LPR_FRAME_MAX_HEIGHT = 576;
export const LPR_FRAME_SCANNER_MAX_SIZE = 384;
export const LPR_FRAME_JPEG_QUALITY = 0.72;
export const LPR_FRAME_SCANNER_JPEG_QUALITY = 0.6;
export const LPR_FRAME_MIME_TYPE = "image/jpeg";
export const LPR_FRAME_FILE_NAME = "license-plate.jpg";
export const LPR_FRAME_CLIENT_CAPTURE_MS_FIELD = "client_capture_ms";
export const LPR_FRAME_CLIENT_DRAW_MS_FIELD = "client_draw_ms";
export const LPR_FRAME_CLIENT_ENCODE_MS_FIELD = "client_encode_ms";
export const LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD = "client_preflight_ms";
export const LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD = "client_visual_fingerprint_ms";
export const LPR_FRAME_CLIENT_WIDTH_FIELD = "client_frame_width";
export const LPR_FRAME_CLIENT_HEIGHT_FIELD = "client_frame_height";
export const LPR_FRAME_CLIENT_BYTES_FIELD = "client_frame_bytes";
export const LPR_FRAME_FOCUS_ASPECT_RATIO = 16 / 9;
export const LPR_FRAME_SCANNER_FOCUS_SCALE = 0.85;
export const LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE = 8;
const HTML_MEDIA_HAVE_CURRENT_DATA = 2;
const LPR_FRAME_FINGERPRINT_SAMPLE_BYTES = 32;
const LPR_FRAME_FINGERPRINT_HASH_SEED = 2166136261;
const LPR_FRAME_FINGERPRINT_HASH_PRIME = 16777619;
const NIBBLE_BIT_COUNT = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4];
const LPR_ENCODING_CANVAS_CONTEXT_OPTIONS = {
alpha: false,
desynchronized: true,
};
export type FrameSize = {
width: number;
height: number;
};
export type FrameSizeConstraints = {
maxHeight: number;
maxWidth: number;
};
export type FrameSourceRect = {
height: number;
width: number;
x: number;
y: number;
};
export type LPRFrameViewportRect = {
height: number;
width: number;
x: number;
y: number;
};
export type LPRFramePayload = {
blob: Blob;
captureDurationMs: number;
captureTimings?: LPRFrameCaptureTimings;
filename: string;
fingerprint: string;
getContentFingerprint?: () => Promise<string>;
getVisualFingerprint?: () => string | null;
height: number;
mimeType: string;
visualFingerprint?: string;
width: number;
};
export type LPRFrameCaptureTimings = {
drawMs: number;
encodeMs: number;
visualFingerprintMs: number;
};
export type LPRFrameEncodeCandidate = {
height: number;
visualFingerprint?: string;
width: number;
};
export type LPRFrameCaptureOptions = {
focusCrop?: boolean;
focusScale?: number;
focusViewportRect?: LPRFrameViewportRect | null;
jpegQuality?: number;
onFrameDrawn?: () => void;
shouldBuildVisualFingerprint?: () => boolean;
shouldEncode?: (candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
viewportHeight?: number;
viewportWidth?: number;
visualFingerprintCanvas?: HTMLCanvasElement | null;
};
type LPRFrameEncodingCanvas = HTMLCanvasElement | OffscreenCanvas;
type LPRFrameEncodingContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
type LPRFrameEncodedCanvas = {
blob: Blob;
canvas: LPRFrameEncodingCanvas;
timings: Pick<LPRFrameCaptureTimings, "drawMs" | "encodeMs">;
};
const offscreenEncodingCanvases = new WeakMap<HTMLCanvasElement, OffscreenCanvas>();
const disabledOffscreenEncodingCanvases = new WeakSet<HTMLCanvasElement>();
const encodingCanvasContexts = new WeakMap<LPRFrameEncodingCanvas, LPRFrameEncodingContext>();
const visualFingerprintCanvasContexts = new WeakMap<HTMLCanvasElement, CanvasRenderingContext2D>();
const clampNumber = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
export const getConstrainedFrameSize = (
sourceWidth: number,
sourceHeight: number,
maxWidth = LPR_FRAME_MAX_WIDTH,
maxHeight = LPR_FRAME_MAX_HEIGHT
): FrameSize => {
if (sourceWidth <= 0 || sourceHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(1, maxWidth / sourceWidth, maxHeight / sourceHeight);
return {
width: Math.max(1, Math.round(sourceWidth * scale)),
height: Math.max(1, Math.round(sourceHeight * scale)),
};
};
export const getLPRFrameSizeConstraints = (options: LPRFrameCaptureOptions = {}): FrameSizeConstraints =>
options.focusCrop === false
? {
maxHeight: LPR_FRAME_MAX_HEIGHT,
maxWidth: LPR_FRAME_MAX_WIDTH,
}
: {
maxHeight: LPR_FRAME_SCANNER_MAX_SIZE,
maxWidth: LPR_FRAME_SCANNER_MAX_SIZE,
};
export const getLPRFrameTargetSize = (
sourceRect: FrameSourceRect,
options: LPRFrameCaptureOptions = {}
): FrameSize => {
const constraints = getLPRFrameSizeConstraints(options);
return getConstrainedFrameSize(
sourceRect.width,
sourceRect.height,
constraints.maxWidth,
constraints.maxHeight
);
};
export const getVisibleCoverSourceRect = (
sourceWidth: number,
sourceHeight: number,
viewportWidth: number,
viewportHeight: number
): FrameSourceRect => {
if (!Number.isFinite(sourceWidth) || !Number.isFinite(sourceHeight) || sourceWidth <= 0 || sourceHeight <= 0) {
return { height: 0, width: 0, x: 0, y: 0 };
}
if (
!Number.isFinite(viewportWidth) ||
!Number.isFinite(viewportHeight) ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return { height: sourceHeight, width: sourceWidth, x: 0, y: 0 };
}
const sourceAspectRatio = sourceWidth / sourceHeight;
const viewportAspectRatio = viewportWidth / viewportHeight;
if (viewportAspectRatio > sourceAspectRatio) {
const visibleHeight = Math.max(1, Math.min(sourceHeight, Math.round(sourceWidth / viewportAspectRatio)));
return {
height: visibleHeight,
width: sourceWidth,
x: 0,
y: Math.max(0, Math.round((sourceHeight - visibleHeight) / 2)),
};
}
const visibleWidth = Math.max(1, Math.min(sourceWidth, Math.round(sourceHeight * viewportAspectRatio)));
return {
height: sourceHeight,
width: visibleWidth,
x: Math.max(0, Math.round((sourceWidth - visibleWidth) / 2)),
y: 0,
};
};
export const getCenteredFocusSourceRect = (
sourceRect: FrameSourceRect,
focusAspectRatio = LPR_FRAME_FOCUS_ASPECT_RATIO,
focusScale = 1
): FrameSourceRect => {
if (
!Number.isFinite(sourceRect.width) ||
!Number.isFinite(sourceRect.height) ||
sourceRect.width <= 0 ||
sourceRect.height <= 0
) {
return { height: 0, width: 0, x: 0, y: 0 };
}
if (!Number.isFinite(focusAspectRatio) || focusAspectRatio <= 0) {
return sourceRect;
}
let focusedRect: FrameSourceRect;
const sourceAspectRatio = sourceRect.width / sourceRect.height;
if (sourceAspectRatio > focusAspectRatio) {
const focusedWidth = Math.max(1, Math.min(sourceRect.width, Math.round(sourceRect.height * focusAspectRatio)));
focusedRect = {
height: sourceRect.height,
width: focusedWidth,
x: sourceRect.x + Math.max(0, Math.round((sourceRect.width - focusedWidth) / 2)),
y: sourceRect.y,
};
} else {
const focusedHeight = Math.max(1, Math.min(sourceRect.height, Math.round(sourceRect.width / focusAspectRatio)));
focusedRect = {
height: focusedHeight,
width: sourceRect.width,
x: sourceRect.x,
y: sourceRect.y + Math.max(0, Math.round((sourceRect.height - focusedHeight) / 2)),
};
}
if (!Number.isFinite(focusScale) || focusScale <= 0 || focusScale >= 1) {
return focusedRect;
}
const scaledWidth = Math.max(1, Math.round(focusedRect.width * focusScale));
const scaledHeight = Math.max(1, Math.round(focusedRect.height * focusScale));
return {
height: scaledHeight,
width: scaledWidth,
x: focusedRect.x + Math.max(0, Math.round((focusedRect.width - scaledWidth) / 2)),
y: focusedRect.y + Math.max(0, Math.round((focusedRect.height - scaledHeight) / 2)),
};
};
export const getViewportAnchoredFocusSourceRect = (
centeredFocusRect: FrameSourceRect,
visibleSourceRect: FrameSourceRect,
viewportWidth: number,
viewportHeight: number,
focusViewportRect: LPRFrameViewportRect | null | undefined
): FrameSourceRect => {
if (
!focusViewportRect ||
!Number.isFinite(viewportWidth) ||
!Number.isFinite(viewportHeight) ||
!Number.isFinite(focusViewportRect.x) ||
!Number.isFinite(focusViewportRect.y) ||
!Number.isFinite(focusViewportRect.width) ||
!Number.isFinite(focusViewportRect.height) ||
viewportWidth <= 0 ||
viewportHeight <= 0 ||
focusViewportRect.width <= 0 ||
focusViewportRect.height <= 0 ||
centeredFocusRect.width <= 0 ||
centeredFocusRect.height <= 0 ||
visibleSourceRect.width <= 0 ||
visibleSourceRect.height <= 0 ||
centeredFocusRect.width > visibleSourceRect.width ||
centeredFocusRect.height > visibleSourceRect.height
) {
return centeredFocusRect;
}
const focusCenterX = focusViewportRect.x + (focusViewportRect.width / 2);
const focusCenterY = focusViewportRect.y + (focusViewportRect.height / 2);
const sourceCenterX = visibleSourceRect.x + ((focusCenterX / viewportWidth) * visibleSourceRect.width);
const sourceCenterY = visibleSourceRect.y + ((focusCenterY / viewportHeight) * visibleSourceRect.height);
const minX = visibleSourceRect.x;
const minY = visibleSourceRect.y;
const maxX = visibleSourceRect.x + visibleSourceRect.width - centeredFocusRect.width;
const maxY = visibleSourceRect.y + visibleSourceRect.height - centeredFocusRect.height;
return {
height: centeredFocusRect.height,
width: centeredFocusRect.width,
x: clampNumber(Math.round(sourceCenterX - (centeredFocusRect.width / 2)), minX, maxX),
y: clampNumber(Math.round(sourceCenterY - (centeredFocusRect.height / 2)), minY, maxY),
};
};
export const getLPRFrameSourceRect = (
sourceWidth: number,
sourceHeight: number,
viewportWidth: number,
viewportHeight: number,
options: LPRFrameCaptureOptions = {}
): FrameSourceRect => {
const visibleSourceRect = getVisibleCoverSourceRect(sourceWidth, sourceHeight, viewportWidth, viewportHeight);
if (options.focusCrop === false) {
return visibleSourceRect;
}
const centeredFocusRect = getCenteredFocusSourceRect(
visibleSourceRect,
LPR_FRAME_FOCUS_ASPECT_RATIO,
options.focusScale ?? LPR_FRAME_SCANNER_FOCUS_SCALE
);
return getViewportAnchoredFocusSourceRect(
centeredFocusRect,
visibleSourceRect,
viewportWidth,
viewportHeight,
options.focusViewportRect
);
};
const getVideoFrameSourceRect = (video: HTMLVideoElement, options: LPRFrameCaptureOptions = {}): FrameSourceRect => {
const configuredViewportWidth = Number(options.viewportWidth);
const configuredViewportHeight = Number(options.viewportHeight);
const hasConfiguredViewportSize =
Number.isFinite(configuredViewportWidth) &&
configuredViewportWidth > 0 &&
Number.isFinite(configuredViewportHeight) &&
configuredViewportHeight > 0;
const viewportWidth = hasConfiguredViewportSize ? configuredViewportWidth : video.clientWidth;
const viewportHeight = hasConfiguredViewportSize ? configuredViewportHeight : video.clientHeight;
return getLPRFrameSourceRect(
video.videoWidth,
video.videoHeight,
viewportWidth,
viewportHeight,
options
);
};
export const isVideoFrameReadyForLPR = (video: HTMLVideoElement): boolean =>
video.readyState >= HTML_MEDIA_HAVE_CURRENT_DATA
&& video.videoWidth > 0
&& video.videoHeight > 0;
const sampleBlobFingerprintBytes = async (blob: Blob): Promise<string> => {
let hash = LPR_FRAME_FINGERPRINT_HASH_SEED;
const sampleWidth = Math.min(LPR_FRAME_FINGERPRINT_SAMPLE_BYTES, blob.size);
const offsets = [
0,
Math.max(0, Math.floor(blob.size / 2) - Math.floor(sampleWidth / 2)),
Math.max(0, blob.size - sampleWidth),
];
let previousOffset: number | null = null;
for (const offset of offsets) {
if (offset === previousOffset) {
continue;
}
previousOffset = offset;
const bytes = new Uint8Array(await blob.slice(offset, offset + sampleWidth).arrayBuffer());
for (let index = 0; index < bytes.length; index += 1) {
const byte = bytes[index];
hash ^= byte;
hash = Math.imul(hash, LPR_FRAME_FINGERPRINT_HASH_PRIME) >>> 0;
}
}
return hash.toString(16).padStart(8, "0");
};
export const buildLPRFrameFingerprint = async (
blob: Blob,
width: number,
height: number
): Promise<string> => `${width}x${height}:${blob.size}:${await sampleBlobFingerprintBytes(blob)}`;
export const buildLPRFrameFingerprintKey = (
blob: Blob,
width: number,
height: number
): string => `${width}x${height}:${blob.size}`;
export const getVisualFingerprintDistance = (first: string | null | undefined, second: string | null | undefined): number => {
if (!first || !second || first.length !== second.length) {
return Number.POSITIVE_INFINITY;
}
let distance = 0;
for (let index = 0; index < first.length; index += 1) {
const firstNibble = Number.parseInt(first[index], 16);
const secondNibble = Number.parseInt(second[index], 16);
if (!Number.isInteger(firstNibble) || !Number.isInteger(secondNibble)) {
return Number.POSITIVE_INFINITY;
}
distance += NIBBLE_BIT_COUNT[firstNibble ^ secondNibble];
}
return distance;
};
const buildSourceVisualFingerprint = (
source: CanvasImageSource,
sourceRect: FrameSourceRect,
visualFingerprintCanvas: HTMLCanvasElement | null | undefined
): string | null => {
if (
sourceRect.width <= 0 ||
sourceRect.height <= 0 ||
!Number.isFinite(sourceRect.x) ||
!Number.isFinite(sourceRect.y) ||
!Number.isFinite(sourceRect.width) ||
!Number.isFinite(sourceRect.height) ||
!visualFingerprintCanvas
) {
return null;
}
try {
if (visualFingerprintCanvas.width !== LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE) {
visualFingerprintCanvas.width = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
}
if (visualFingerprintCanvas.height !== LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE) {
visualFingerprintCanvas.height = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
}
let context = visualFingerprintCanvasContexts.get(visualFingerprintCanvas) ?? null;
if (context === null) {
context = visualFingerprintCanvas.getContext("2d", {
alpha: false,
willReadFrequently: true,
});
if (context !== null) {
visualFingerprintCanvasContexts.set(visualFingerprintCanvas, context);
}
}
if (!context || typeof context.getImageData !== "function") {
return null;
}
context.drawImage(
source,
sourceRect.x,
sourceRect.y,
sourceRect.width,
sourceRect.height,
0,
0,
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE,
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE
);
const imageData = context.getImageData(
0,
0,
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE,
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE
).data;
const sampleCount = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE * LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
let luminanceTotal = 0;
for (let pixelOffset = 0; pixelOffset < imageData.length; pixelOffset += 4) {
luminanceTotal +=
(imageData[pixelOffset] * 0.299)
+ (imageData[pixelOffset + 1] * 0.587)
+ (imageData[pixelOffset + 2] * 0.114);
}
const averageLuminance = luminanceTotal / sampleCount;
let fingerprint = "";
for (let pixelOffset = 0; pixelOffset < imageData.length; pixelOffset += 16) {
let nibble = 0;
for (let offset = 0; offset < 4; offset += 1) {
const offsetPixel = pixelOffset + (offset * 4);
const luminance =
(imageData[offsetPixel] * 0.299)
+ (imageData[offsetPixel + 1] * 0.587)
+ (imageData[offsetPixel + 2] * 0.114);
if (luminance > averageLuminance) {
nibble |= 1 << (3 - offset);
}
}
fingerprint += nibble.toString(16);
}
return fingerprint;
} catch {
return null;
}
};
const buildMemoizedLPRFrameContentFingerprint = (
blob: Blob,
width: number,
height: number
): (() => Promise<string>) => {
let fingerprintPromise: Promise<string> | null = null;
return () => {
fingerprintPromise ??= buildLPRFrameFingerprint(blob, width, height).catch((error) => {
fingerprintPromise = null;
throw error;
});
return fingerprintPromise;
};
};
const nowMs = (): number =>
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
const getJpegQualityForFrame = (options: LPRFrameCaptureOptions): number => {
if (Number.isFinite(options.jpegQuality) && options.jpegQuality > 0 && options.jpegQuality <= 1) {
return options.jpegQuality;
}
return options.focusCrop === false
? LPR_FRAME_JPEG_QUALITY
: LPR_FRAME_SCANNER_JPEG_QUALITY;
};
const shouldEncodeFrameCandidate = async (
candidate: LPRFrameEncodeCandidate,
shouldEncode: LPRFrameCaptureOptions["shouldEncode"]
): Promise<boolean> => {
if (typeof shouldEncode !== "function") {
return true;
}
try {
return await shouldEncode(candidate);
} catch {
return true;
}
};
const shouldBuildVisualFingerprintBeforeEncode = (
shouldBuildVisualFingerprint: LPRFrameCaptureOptions["shouldBuildVisualFingerprint"]
): boolean => {
if (typeof shouldBuildVisualFingerprint !== "function") {
return true;
}
try {
return shouldBuildVisualFingerprint();
} catch {
return true;
}
};
const getOffscreenEncodingCanvas = (fallbackCanvas: HTMLCanvasElement): OffscreenCanvas | null => {
if (typeof OffscreenCanvas === "undefined") {
return null;
}
if (disabledOffscreenEncodingCanvases.has(fallbackCanvas)) {
return null;
}
let canvas = offscreenEncodingCanvases.get(fallbackCanvas) ?? null;
if (canvas === null) {
canvas = new OffscreenCanvas(1, 1);
offscreenEncodingCanvases.set(fallbackCanvas, canvas);
}
return canvas;
};
const isOffscreenEncodingCanvas = (canvas: LPRFrameEncodingCanvas): canvas is OffscreenCanvas =>
typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas;
const encodeCanvasBlob = (
canvas: LPRFrameEncodingCanvas,
mimeType: string,
quality: number
): Promise<Blob | null> => {
if (isOffscreenEncodingCanvas(canvas)) {
return typeof canvas.convertToBlob === "function"
? canvas.convertToBlob({ type: mimeType, quality }).catch(() => null)
: Promise.resolve(null);
}
if (typeof canvas.toBlob !== "function") {
return Promise.resolve(null);
}
return new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, mimeType, quality);
});
};
const getEncodingCanvasContext = (canvas: LPRFrameEncodingCanvas): LPRFrameEncodingContext | null => {
let context = encodingCanvasContexts.get(canvas) ?? null;
if (context !== null) {
return context;
}
context = canvas.getContext("2d", LPR_ENCODING_CANVAS_CONTEXT_OPTIONS) as LPRFrameEncodingContext | null;
if (context !== null) {
encodingCanvasContexts.set(canvas, context);
}
return context;
};
const drawAndEncodeFrame = async (
video: HTMLVideoElement,
canvas: LPRFrameEncodingCanvas,
sourceRect: FrameSourceRect,
targetSize: FrameSize,
options: LPRFrameCaptureOptions
): Promise<LPRFrameEncodedCanvas | null> => {
if (canvas.width !== targetSize.width) {
canvas.width = targetSize.width;
}
if (canvas.height !== targetSize.height) {
canvas.height = targetSize.height;
}
const context = getEncodingCanvasContext(canvas);
if (!context) {
return null;
}
const drawStartedAt = nowMs();
context.drawImage(
video,
sourceRect.x,
sourceRect.y,
sourceRect.width,
sourceRect.height,
0,
0,
targetSize.width,
targetSize.height
);
const drawMs = Math.max(0, nowMs() - drawStartedAt);
try {
options.onFrameDrawn?.();
} catch {
// Capture must continue even if preview throttling cannot be applied.
}
const encodeStartedAt = nowMs();
const blob = await encodeCanvasBlob(canvas, LPR_FRAME_MIME_TYPE, getJpegQualityForFrame(options));
const encodeMs = Math.max(0, nowMs() - encodeStartedAt);
if (!blob) {
return null;
}
return {
blob,
canvas,
timings: {
drawMs,
encodeMs,
},
};
};
const captureEncodedCanvas = async (
video: HTMLVideoElement,
fallbackCanvas: HTMLCanvasElement,
sourceRect: FrameSourceRect,
targetSize: FrameSize,
options: LPRFrameCaptureOptions
): Promise<LPRFrameEncodedCanvas | null> => {
const offscreenCanvas = getOffscreenEncodingCanvas(fallbackCanvas);
if (offscreenCanvas !== null) {
try {
const encoded = await drawAndEncodeFrame(video, offscreenCanvas, sourceRect, targetSize, options);
if (encoded !== null) {
return encoded;
}
disabledOffscreenEncodingCanvases.add(fallbackCanvas);
} catch {
// Some browsers expose OffscreenCanvas but reject drawing live video into it.
disabledOffscreenEncodingCanvases.add(fallbackCanvas);
}
}
const encoded = await drawAndEncodeFrame(video, fallbackCanvas, sourceRect, targetSize, options);
if (encoded === null) {
console.error("Failed to capture camera frame");
}
return encoded;
};
export const captureVideoFrameBlobForLPR = async (
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
options: LPRFrameCaptureOptions = {}
): Promise<LPRFramePayload | null> => {
const captureStartedAt = nowMs();
const captureTimings: LPRFrameCaptureTimings = {
drawMs: 0,
encodeMs: 0,
visualFingerprintMs: 0,
};
const sourceRect = getVideoFrameSourceRect(video, options);
const targetSize = getLPRFrameTargetSize(sourceRect, options);
if (targetSize.width === 0 || targetSize.height === 0) {
return null;
}
const visualFingerprintCanvas = options.visualFingerprintCanvas ?? null;
let visualFingerprint: string | null = null;
if (shouldBuildVisualFingerprintBeforeEncode(options.shouldBuildVisualFingerprint)) {
const visualFingerprintStartedAt = nowMs();
visualFingerprint = buildSourceVisualFingerprint(video, sourceRect, visualFingerprintCanvas);
captureTimings.visualFingerprintMs = Math.max(0, nowMs() - visualFingerprintStartedAt);
}
let hasTriedLazyVisualFingerprint = false;
const candidate: LPRFrameEncodeCandidate = {
height: targetSize.height,
...(visualFingerprint !== null ? { visualFingerprint } : {}),
width: targetSize.width,
};
if (!await shouldEncodeFrameCandidate(candidate, options.shouldEncode)) {
return null;
}
const encoded = await captureEncodedCanvas(video, canvas, sourceRect, targetSize, options);
if (encoded === null) {
return null;
}
const { blob, canvas: encodedCanvas } = encoded;
captureTimings.drawMs = encoded.timings.drawMs;
captureTimings.encodeMs = encoded.timings.encodeMs;
const getVisualFingerprint = visualFingerprintCanvas
? () => {
if (visualFingerprint !== null || hasTriedLazyVisualFingerprint) {
return visualFingerprint;
}
hasTriedLazyVisualFingerprint = true;
visualFingerprint = buildSourceVisualFingerprint(encodedCanvas, {
height: targetSize.height,
width: targetSize.width,
x: 0,
y: 0,
}, visualFingerprintCanvas);
return visualFingerprint;
}
: undefined;
return {
blob,
captureDurationMs: Math.max(0, nowMs() - captureStartedAt),
captureTimings,
filename: LPR_FRAME_FILE_NAME,
fingerprint: buildLPRFrameFingerprintKey(blob, targetSize.width, targetSize.height),
getContentFingerprint: buildMemoizedLPRFrameContentFingerprint(blob, targetSize.width, targetSize.height),
...(getVisualFingerprint ? { getVisualFingerprint } : {}),
height: targetSize.height,
mimeType: blob.type || LPR_FRAME_MIME_TYPE,
...(visualFingerprint !== null ? { visualFingerprint } : {}),
width: targetSize.width,
};
};
+7 -5
View File
@@ -64,6 +64,7 @@ export const useOrderMetadataAutosave = ({
const requestedValue = normalizeValue(draft.value);
const sequence = ++nextSequence;
let saveCompleted = false;
isSaving.value = true;
try {
@@ -83,6 +84,8 @@ export const useOrderMetadataAutosave = ({
await onSaved(savedValue, requestedValue);
}
saveCompleted = true;
return true;
} catch (error) {
if (typeof onError === "function") {
@@ -95,12 +98,11 @@ export const useOrderMetadataAutosave = ({
} finally {
isSaving.value = false;
if (saveAgainAfterCurrentRequest || isDirty.value) {
saveAgainAfterCurrentRequest = false;
const shouldSaveAgain = saveAgainAfterCurrentRequest;
saveAgainAfterCurrentRequest = false;
if (isDirty.value) {
void persist();
}
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
void persist();
}
}
};
+252 -49
View File
@@ -1,5 +1,6 @@
import { computed, ref } from "vue";
import { computed, getCurrentInstance, onUnmounted, ref } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
const normalizeBooleanAnswer = (value) => {
if (value === true || value === false) {
@@ -270,6 +271,55 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged;
};
const taskStableKey = (task) =>
String(task?.task ?? "")
.trim()
.toLowerCase();
const mergeTaskSets = (existingTasks, incomingTasks) => {
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
}
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
return [...existingTasks];
}
const usedExistingIndexes = new Set();
return incomingTasks.map((incomingTask) => {
const incomingId = extractTaskId(incomingTask);
let existingIndex = -1;
if (incomingId) {
existingIndex = existingTasks.findIndex((task) => extractTaskId(task) === incomingId);
} else {
const incomingKey = taskStableKey(incomingTask);
existingIndex = existingTasks.findIndex(
(task, index) => !usedExistingIndexes.has(index) && taskStableKey(task) === incomingKey
);
}
if (existingIndex === -1) {
return incomingTask;
}
usedExistingIndexes.add(existingIndex);
const existingTask = existingTasks[existingIndex];
return {
...existingTask,
...incomingTask,
id: incomingTask.id || existingTask.id,
task_id: incomingTask.task_id || existingTask.task_id,
attachments: Array.isArray(incomingTask.attachments) && incomingTask.attachments.length > 0
? incomingTask.attachments
: existingTask.attachments,
_attachmentsLoaded: incomingTask._attachmentsLoaded || existingTask._attachmentsLoaded,
};
});
};
export function useSelfServeLogic() {
const loading = ref(false);
const requestError = ref(null);
@@ -295,19 +345,94 @@ export function useSelfServeLogic() {
const lastResolvedVehicleTypeId = ref(null);
const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({});
const latestFetchRequestId = ref(0);
const activeFetchAbortController = ref(null);
const latestMutationRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
const beginFetchRequest = () => {
latestFetchRequestId.value += 1;
return latestFetchRequestId.value;
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
const normalizedDepartmentId = parseInt(departmentId);
const normalizedLaneId = parseInt(laneId);
const normalizedReg = String(reg || "").trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
? normalizedVehicleTypeId
: "";
if (
Number.isNaN(normalizedDepartmentId)
|| normalizedDepartmentId <= 0
|| Number.isNaN(normalizedLaneId)
|| normalizedLaneId <= 0
|| normalizedReg.length < 2
) {
return null;
}
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
};
const isFetchRequestActive = (requestId) => (
requestId === null
|| requestId === undefined
|| requestId === latestFetchRequestId.value
const isCanceledRequest = (error) => (
error?.code === "ERR_CANCELED"
|| error?.name === "CanceledError"
|| error?.name === "AbortError"
);
const abortActiveFetchRequest = () => {
// Abort the previous self-serve read before a newer one starts; only the live signal may write state.
activeFetchAbortController.value?.abort();
activeFetchAbortController.value = null;
};
const beginFetchRequest = (externalSignal = null) => {
abortActiveFetchRequest();
if (typeof AbortController !== "function") {
return null;
}
const abortController = new AbortController();
if (externalSignal) {
if (externalSignal.aborted) {
abortController.abort();
} else {
externalSignal.addEventListener("abort", () => abortController.abort(), { once: true });
}
}
activeFetchAbortController.value = abortController;
return abortController;
};
const isFetchSignalActive = (signal = null) => (
!signal || (!signal.aborted && activeFetchAbortController.value?.signal === signal)
);
const requestPreviewAllowed = (laneId, reg, vehicleTypeId = null, signal = null) => {
const previewAllowed = SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed;
if (!signal || previewAllowed.length < 3) {
return vehicleTypeId
? previewAllowed(laneId, reg, vehicleTypeId)
: previewAllowed(laneId, reg);
}
return previewAllowed(laneId, reg, vehicleTypeId, { signal });
};
const requestWashSummary = (params = {}, signal = null) => {
const washSummary = SessionUser.objects.self_serve_vehicle_conditions.get.washSummary;
if (!signal || washSummary.length < 2) {
return washSummary(params);
}
return washSummary(params, { signal });
};
const beginMutationRequest = () => {
latestMutationRequestId.value += 1;
return latestMutationRequestId.value;
};
const isMutationRequestActive = (requestId) => requestId === latestMutationRequestId.value;
const setSummaryVisibleQuestions = (questionList = []) => {
const normalizedIds = (Array.isArray(questionList) ? questionList : [])
.map((question) => parseInt(question?.id ?? 0))
@@ -414,8 +539,8 @@ export function useSelfServeLogic() {
};
const applyPreviewData = async (previewData, options = {}) => {
const { mergeQuestions = false, requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
const { mergeQuestions = false, signal = null } = options;
if (!isFetchSignalActive(signal)) {
return false;
}
@@ -424,6 +549,10 @@ export function useSelfServeLogic() {
machineType.value = previewData?.machine_type || machineType.value;
vehicle.value = previewData?.vehicle || null;
session.value = previewData?.session || session.value;
if (!previewData?.session?.id) {
summary.value = null;
events.value = [];
}
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
configVersionId.value = previewData?.config_version_id ?? null;
}
@@ -442,6 +571,7 @@ export function useSelfServeLogic() {
questions.value = nextQuestions;
answers.value = buildAnswersMap(nextQuestions);
setSummaryVisibleQuestions(nextQuestions);
const normalizedConditions = Array.isArray(previewData?.conditions)
? previewData.conditions.map(normalizeCondition)
@@ -461,19 +591,21 @@ export function useSelfServeLogic() {
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
: [];
tasks.value = normalizedTasks;
applyAllowedServices(previewData);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return false;
}
tasks.value = hydratedTasks;
applyAllowedServices(previewData);
return true;
};
const applySummaryData = async (summaryData, options = {}) => {
const { requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
const { signal = null } = options;
if (!isFetchSignalActive(signal)) {
return false;
}
@@ -512,9 +644,10 @@ export function useSelfServeLogic() {
const normalizedTasks = summaryData.tasks
.map(normalizeTask)
.sort((a, b) => a.order_priority - b.order_priority);
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
if (!isFetchSignalActive(signal)) {
return false;
}
@@ -526,7 +659,7 @@ export function useSelfServeLogic() {
};
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
const existingDownloadLink = attachment?.download_link || null;
const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (existingDownloadLink) {
window.open(existingDownloadLink, "_blank", "noopener");
return;
@@ -534,10 +667,9 @@ export function useSelfServeLogic() {
try {
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
if (response?.data?.download_link) {
window.open(response.data.download_link, "_blank", "noopener");
} else if (response?.download_link) {
window.open(response.download_link, "_blank", "noopener");
const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
if (downloadLink) {
window.open(downloadLink, "_blank", "noopener");
}
} catch (error) {
console.error("Error downloading attachment:", error);
@@ -545,7 +677,7 @@ export function useSelfServeLogic() {
};
const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => {
const { requestId = null } = options;
const { signal = null } = options;
if (!params.session_id && !(params.lane_id && params.reg)) {
return null;
}
@@ -555,30 +687,39 @@ export function useSelfServeLogic() {
}
try {
const summaryData = await SessionUser.objects.self_serve_vehicle_conditions.get.washSummary(params);
const didApply = await applySummaryData(summaryData, { requestId });
const summaryData = await requestWashSummary(params, signal);
const didApply = await applySummaryData(summaryData, { signal });
if (!didApply) {
return null;
}
requestError.value = null;
return summaryData;
} catch (error) {
if (isCanceledRequest(error)) {
return null;
}
console.error("Error fetching self-serve summary:", error);
if (isFetchRequestActive(requestId)) {
if (isFetchSignalActive(signal)) {
requestError.value = extractErrorMessage(error, "Kunne ikke hente vaskestatus. Prøv igen.");
}
return null;
} finally {
if (manageLoading) {
if (manageLoading && isFetchSignalActive(signal)) {
loading.value = false;
}
}
};
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const requestId = beginFetchRequest();
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
return null;
}
if (!laneId || !reg || reg.trim().length < 2) {
abortActiveFetchRequest();
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null;
summary.value = null;
session.value = null;
@@ -604,8 +745,11 @@ export function useSelfServeLogic() {
return null;
}
const abortController = beginFetchRequest(options.signal || null);
const signal = abortController?.signal || null;
loading.value = true;
requestError.value = null;
inFlightFetchKey.value = fetchKey;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -620,18 +764,18 @@ export function useSelfServeLogic() {
const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`;
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
const previewData = hasVehicleTypeOverride
? await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg, normalizedVehicleTypeId)
: await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
? await requestPreviewAllowed(laneId, normalizedReg, normalizedVehicleTypeId, signal)
: await requestPreviewAllowed(laneId, normalizedReg, null, signal);
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return null;
}
const didApplyPreview = await applyPreviewData(previewData, {
mergeQuestions: shouldMergeQuestions,
requestId,
signal,
});
if (!didApplyPreview || !isFetchRequestActive(requestId)) {
if (!didApplyPreview || !isFetchSignalActive(signal)) {
return null;
}
@@ -646,11 +790,11 @@ export function useSelfServeLogic() {
summaryData = await fetchWashSummary(
{ session_id: previewData.session.id },
false,
{ requestId }
{ signal }
);
}
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return null;
}
@@ -672,9 +816,9 @@ export function useSelfServeLogic() {
const fallbackSummary = await fetchWashSummary(
fallbackSummaryParams,
false,
{ requestId }
{ signal }
);
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return null;
}
@@ -683,19 +827,31 @@ export function useSelfServeLogic() {
}
// If summary is not available yet, keep the preview list visible so the Questions step is not empty.
if (!hasSummaryQuestions && isFetchRequestActive(requestId)) {
if (!hasSummaryQuestions && isFetchSignalActive(signal)) {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchSignalActive(signal)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
if (isCanceledRequest(error)) {
return null;
}
console.error("Error fetching self-serve preview:", error);
if (isFetchRequestActive(requestId)) {
if (isFetchSignalActive(signal)) {
requestError.value = extractErrorMessage(error);
}
return null;
} finally {
if (isFetchRequestActive(requestId)) {
const isCurrentFetch = !abortController || activeFetchAbortController.value === abortController;
if (isCurrentFetch) {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
activeFetchAbortController.value = null;
loading.value = false;
}
}
@@ -706,6 +862,7 @@ export function useSelfServeLogic() {
return null;
}
const mutationRequestId = beginMutationRequest();
loading.value = true;
try {
const normalizedReg = reg.trim().toUpperCase();
@@ -733,6 +890,10 @@ export function useSelfServeLogic() {
const payload = response?.data?.data || response?.data || response || {};
const responseSummary = payload?.selfserve || null;
if (!isMutationRequestActive(mutationRequestId)) {
return payload;
}
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -749,7 +910,10 @@ export function useSelfServeLogic() {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
if (!isMutationRequestActive(mutationRequestId)) {
return payload;
}
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -757,10 +921,14 @@ export function useSelfServeLogic() {
return payload;
} catch (error) {
console.error("Error synchronizing vehicle answer:", error);
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
if (isMutationRequestActive(mutationRequestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
}
throw error;
} finally {
loading.value = false;
if (isMutationRequestActive(mutationRequestId)) {
loading.value = false;
}
}
};
@@ -769,6 +937,7 @@ export function useSelfServeLogic() {
return { deletedCount: 0 };
}
const mutationRequestId = beginMutationRequest();
loading.value = true;
try {
const normalizedReg = reg.trim().toUpperCase();
@@ -804,6 +973,10 @@ export function useSelfServeLogic() {
SessionUser.objects.self_serve_vehicle_conditions.delete(conditionId)
)));
if (!isMutationRequestActive(mutationRequestId)) {
return { deletedCount: conditionIdsToDelete.length };
}
// Force a non-merge refresh so cleared answers are not kept from local state.
lastPreviewContextKey.value = null;
const refreshVehicleTypeCandidate = vehicleTypeId !== null && vehicleTypeId !== undefined
@@ -814,15 +987,19 @@ export function useSelfServeLogic() {
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
console.error("Error clearing self-serve answers:", error);
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
if (isMutationRequestActive(mutationRequestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
}
throw error;
} finally {
loading.value = false;
if (isMutationRequestActive(mutationRequestId)) {
loading.value = false;
}
}
};
@@ -984,9 +1161,27 @@ export function useSelfServeLogic() {
return activeTaskServices.value.includes(serviceName);
};
const updateLaneAllowedServices = async (laneId) => {
const updateLaneAllowedServices = async (laneId, options = {}) => {
if (!laneId) return;
const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const hasTaskIdsOverride = Array.isArray(options.taskIds);
const activeTaskIds = hasTaskIdsOverride
? options.taskIds.map((taskId) => parseInt(taskId)).filter(Boolean)
: activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const taskIds = hasTaskIdsOverride || activeTaskIds.length > 0
? activeTaskIds
: tasks.value.map((task) => extractTaskId(task)).filter(Boolean);
if (taskIds.length === 0) {
requestError.value = null;
return {
success: true,
data: {
lane_id: parseInt(laneId),
allowed_services: allowedServices.value,
skipped_empty_task_sync: true,
},
};
}
try {
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
@@ -1032,6 +1227,12 @@ export function useSelfServeLogic() {
}
};
if (getCurrentInstance()) {
onUnmounted(() => {
abortActiveFetchRequest();
});
}
return {
loading,
error: requestError,
@@ -1049,6 +1250,7 @@ export function useSelfServeLogic() {
answers,
completedTasks,
allowedServices,
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
configVersionId,
evaluationTrace,
visibleQuestions,
@@ -1060,6 +1262,7 @@ export function useSelfServeLogic() {
allowed,
fetchSelfServeData,
fetchWashSummary,
cancelSelfServeFetch: abortActiveFetchRequest,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
+143 -55
View File
@@ -2,12 +2,32 @@ import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue";
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const isLaneSelfServeEnabled = (lane) => !(
lane?.selfserve_enabled === false
|| lane?.selfserve_enabled === 0
|| lane?.selfserve_enabled === "0"
|| ["false", "off", "no"].includes(String(lane?.selfserve_enabled ?? "").trim().toLowerCase())
);
const isLaneSelfServeEnabled = (lane) =>
!(
lane?.selfserve_enabled === false ||
lane?.selfserve_enabled === 0 ||
lane?.selfserve_enabled === "0" ||
["false", "off", "no"].includes(
String(lane?.selfserve_enabled ?? "")
.trim()
.toLowerCase()
)
);
const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const normalizeLocationCoordinates = (locationValue = locations.location.value) =>
locations.normalizeCoordinatePair(locationValue?.coords);
const normalizeDepartmentCoordinates = (department) =>
locations.normalizeCoordinatePair(
{
latitude: department?.latitude,
longitude: department?.longitude,
},
{ allowZeroPair: false }
);
const toDepartmentViewModel = (department, distance = null) => ({
id: department.id,
@@ -18,12 +38,22 @@ const toDepartmentViewModel = (department, distance = null) => ({
self_serve_enabled: department.self_serve_enabled,
});
const extractDepartmentFetchErrorMessage = (error) => {
const candidates = [
error?.response?.data?.data?.message,
error?.response?.data?.message,
error?.response?.data?.error,
error?.message,
];
return (
candidates.find((candidate) => typeof candidate === "string" && candidate.trim() !== "") ||
"Kunne ikke hente vaskeafdelinger. Prøv igen."
);
};
export function useWashDepartments(options = {}) {
const {
includeLanes = false,
refreshIntervalMs = 0,
canAccessSuperUser = () => false,
} = options;
const { includeLanes = false, refreshIntervalMs = 0, canAccessSuperUser = () => false } = options;
const guestDepartments = ref([]);
const nearestDepartment = ref(null);
@@ -31,6 +61,8 @@ export function useWashDepartments(options = {}) {
const forceNearestDepartmentEvaluationId = ref(0);
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
const departmentSelectionStrategy = ref(null);
let refreshInterval = null;
@@ -57,13 +89,25 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true;
});
const buildGuestDepartmentParams = () => (
includeLanes ? { include_lanes: true } : {}
);
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) =>
normalizeLocationCoordinates(locationValue) !== null;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => {
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
lastDepartmentFetchTime.value = Date.now();
try {
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
lastDepartmentFetchTime.value = Date.now();
departmentFetchError.value = null;
} catch (error) {
departmentFetchError.value = extractDepartmentFetchErrorMessage(error);
console.warn("Failed to fetch wash departments:", error);
}
evaluateLocationDepartments(locations.location.value);
return guestDepartments.value;
};
@@ -74,83 +118,112 @@ export function useWashDepartments(options = {}) {
}
const currentTime = Date.now();
if (
!lastDepartmentFetchTime.value
|| (currentTime - lastDepartmentFetchTime.value) >= refreshIntervalMs
) {
if (!lastDepartmentFetchTime.value || currentTime - lastDepartmentFetchTime.value >= refreshIntervalMs) {
return fetchDepartments();
}
return guestDepartments.value;
};
const getForcedDepartment = () => guestDepartments.value.find(
(department) => department.id === forceNearestDepartmentEvaluationId.value
);
const getForcedDepartment = () =>
guestDepartments.value.find((department) => department.id === forceNearestDepartmentEvaluationId.value);
const evaluateLocationDepartments = (locationValue = locations.location.value) => {
if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) {
const forcedDepartment = getForcedDepartment();
if (forcedDepartment) {
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
departmentSelectionStrategy.value = "forced";
return nearestDepartment.value;
}
}
if (guestDepartments.value.length === 0) {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
return nearestDepartment.value;
}
if (!hasLocationCoordinates(locationValue)) {
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
return nearestDepartment.value;
}
const from = normalizeLocationCoordinates(locationValue);
let currentNearestDepartment = {
id: null,
distance: Infinity,
};
guestDepartments.value.forEach((department) => {
if (!locationValue?.coords) {
const to = normalizeDepartmentCoordinates(department);
if (!from || !to) {
return;
}
const from = {
latitude: locationValue.coords.latitude,
longitude: locationValue.coords.longitude,
};
const to = {
latitude: department.latitude,
longitude: department.longitude,
};
const distance = locations.getDistance(from, to);
if (distance < currentNearestDepartment.distance) {
if (Number.isFinite(distance) && distance < currentNearestDepartment.distance) {
currentNearestDepartment = toDepartmentViewModel(department, distance);
}
});
if (currentNearestDepartment.id) {
nearestDepartment.value = currentNearestDepartment;
departmentSelectionStrategy.value = "distance";
return nearestDepartment.value;
}
const fallbackDepartment =
guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0];
if (fallbackDepartment) {
nearestDepartment.value = toDepartmentViewModel(fallbackDepartment);
departmentSelectionStrategy.value = "fallback";
} else {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
}
return nearestDepartment.value;
};
const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => {
if (!locations.location.value?.coords) {
const currentCoords = normalizeLocationCoordinates(locations.location.value);
if (!currentCoords) {
return departmentsList;
}
const distanceFromCurrentLocation = (department) => {
const departmentCoords = normalizeDepartmentCoordinates(department);
if (!departmentCoords) {
return Number.POSITIVE_INFINITY;
}
return locations.getDistance(currentCoords, departmentCoords);
};
return departmentsList.slice().sort((departmentA, departmentB) => {
const currentCoords = locations.location.value.coords;
const distanceA = locations.getDistance(
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
{ latitude: departmentA.latitude, longitude: departmentA.longitude }
);
const distanceB = locations.getDistance(
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
{ latitude: departmentB.latitude, longitude: departmentB.longitude }
);
const distanceA = distanceFromCurrentLocation(departmentA);
const distanceB = distanceFromCurrentLocation(departmentB);
if (!Number.isFinite(distanceA) && !Number.isFinite(distanceB)) {
return 0;
}
if (!Number.isFinite(distanceA)) {
return 1;
}
if (!Number.isFinite(distanceB)) {
return -1;
}
return distanceA - distanceB;
});
};
const startDepartmentSearch = () => {
if (!canAccessSuperUser()) {
if (!canAccessSuperUser() && hasLocationCoordinates()) {
return;
}
@@ -191,19 +264,30 @@ export function useWashDepartments(options = {}) {
}
};
watch(() => guestDepartments.value, () => {
evaluateLocationDepartments(locations.location.value);
}, { deep: true });
watch(() => locations.location.value, (newValue) => {
evaluateLocationDepartments(newValue);
}, { deep: true });
watch(() => isSearchingDepartments.value, (isSearching) => {
if (!isSearching) {
watch(
() => guestDepartments.value,
() => {
evaluateLocationDepartments(locations.location.value);
},
{ deep: true }
);
watch(
() => locations.location.value,
(newValue) => {
evaluateLocationDepartments(newValue);
},
{ deep: true }
);
watch(
() => isSearchingDepartments.value,
(isSearching) => {
if (!isSearching) {
evaluateLocationDepartments(locations.location.value);
}
}
});
);
onUnmounted(() => {
stopAutoRefresh();
@@ -216,6 +300,10 @@ export function useWashDepartments(options = {}) {
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
departmentSelectionStrategy,
isDepartmentSelectionDistanceBased,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
+28 -11
View File
@@ -7,6 +7,11 @@ export const WASH_STEPS = {
COMPLETED: 5,
};
const normalizeLaneId = (value) => {
const normalized = parseInt(String(value ?? ""), 10);
return Number.isNaN(normalized) || normalized <= 0 ? null : normalized;
};
export function useWashFlowState(options) {
const {
currentStep,
@@ -14,7 +19,7 @@ export function useWashFlowState(options) {
customerNumberInput,
licensePlateInput,
vehicleTypeSelect,
availableProductIds,
isCustomerNumberRequired,
radioLaneOption,
nearestDepartment,
allVisibleQuestionsAnswered,
@@ -34,6 +39,17 @@ export function useWashFlowState(options) {
activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS
);
const getSelectedLaneId = () => normalizeLaneId(radioLaneOption.value);
const getSelectedLane = () => {
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId === null) {
return null;
}
return nearestDepartment.value?.lanes.find((lane) => normalizeLaneId(lane.id) === selectedLaneId) || null;
};
const clickableSteps = {
[steps.VEHICLE]: () => {
if (washInProgress.value) {
@@ -41,16 +57,15 @@ export function useWashFlowState(options) {
}
const selectedVehicleTypeId = vehicleTypeSelect.value;
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
const hasAllowedVehicleType = selectedVehicleTypeId !== null
&& selectedVehicleTypeId !== undefined
&& allowedProductIds.has(String(selectedVehicleTypeId));
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
const customerNumberValue = customerNumberInput.value;
const customerNumberValid = customerNumberValue === null
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
const customerNumberValid = !requiresCustomerNumber
|| customerNumberValue === null
|| customerNumberValue === undefined
|| String(customerNumberValue).trim() !== "";
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
if (!hasAllowedVehicleType) {
if (!hasSelectedVehicleType) {
return false;
}
@@ -103,7 +118,7 @@ export function useWashFlowState(options) {
}
if (currentStep.value === steps.SELECT_LANE) {
const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
const selectedLane = getSelectedLane();
if (!selectedLane || !isLaneAvailable(selectedLane)) {
return true;
}
@@ -133,9 +148,10 @@ export function useWashFlowState(options) {
const handleConfirmNext = async () => {
if (currentStep.value === steps.QUESTIONS) {
editAnswers.value = false;
if (radioLaneOption.value && radioLaneOption.value !== "Any") {
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId !== null) {
try {
await updateLaneAllowedServices(radioLaneOption.value);
await updateLaneAllowedServices(selectedLaneId);
} catch {
return;
}
@@ -150,8 +166,9 @@ export function useWashFlowState(options) {
return;
}
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
await onStartWash(
radioLaneOption.value,
selectedLaneId,
licensePlateInput.value,
customerNumberInput.value,
targetStepForStart()

Some files were not shown because too many files have changed in this diff Show More