Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f671024ab | ||
|
|
605a7ca433 |
@@ -19,10 +19,9 @@ jobs:
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
env:
|
||||
RELEASE_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_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
|
||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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. |
|
||||
+4
-13
@@ -2,19 +2,10 @@
|
||||
<html lang="" class="theme-light" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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" />
|
||||
<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" />
|
||||
<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">
|
||||
|
||||
@@ -29,7 +29,7 @@ server {
|
||||
try_files /$static_asset_path =404;
|
||||
}
|
||||
|
||||
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)$ {
|
||||
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)$ {
|
||||
try_files /$static_file_path =404;
|
||||
}
|
||||
|
||||
|
||||
Generated
+29
-10
@@ -41,6 +41,7 @@
|
||||
"dayspan-vuetify": "^0.4.0",
|
||||
"flow": "^0.2.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "11.22.4",
|
||||
@@ -5069,7 +5070,6 @@
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.9",
|
||||
@@ -5085,7 +5085,6 @@
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
@@ -5737,7 +5736,6 @@
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
@@ -6425,7 +6423,6 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-what": "^5.2.0"
|
||||
@@ -8988,7 +8985,6 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9571,7 +9567,6 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
@@ -10109,7 +10104,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
@@ -10171,6 +10165,34 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.5.0",
|
||||
"vue": "^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia/node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/pixelmatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
|
||||
@@ -10728,7 +10750,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
@@ -11646,7 +11667,6 @@
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -11918,7 +11938,6 @@
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^4"
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"dayspan-vuetify": "^0.4.0",
|
||||
"flow": "^0.2.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.81.1",
|
||||
"sweetalert2": "11.22.4",
|
||||
|
||||
+4
-4
@@ -23,17 +23,17 @@
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
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]
|
||||
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]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||
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]
|
||||
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]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||
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]
|
||||
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]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
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]
|
||||
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]
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
@@ -35,7 +35,6 @@ export const ownedFilesByRole = {
|
||||
"userHome.spec.ts",
|
||||
"userInvoices.spec.ts",
|
||||
"userMyWashStart.spec.ts",
|
||||
"userMyWashStartFlow.spec.ts",
|
||||
"userProfileInvoicing.spec.ts",
|
||||
"userProfileNotifications.spec.ts",
|
||||
"userProfileSecurity.spec.ts",
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<!--
|
||||
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;
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
message: string | null;
|
||||
type?: "is-danger" | "is-warning";
|
||||
iconLeft?: string;
|
||||
loading?: boolean;
|
||||
showRetry?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: "is-danger",
|
||||
iconLeft: "sync-alt",
|
||||
loading: false,
|
||||
showRetry: true,
|
||||
}
|
||||
);
|
||||
|
||||
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"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ $t("common.try_again") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-message>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-error-banner {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { BSkeleton } from "buefy";
|
||||
|
||||
const props = defineProps<{
|
||||
src: string | null;
|
||||
alt?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "error"): void;
|
||||
(e: "load"): void;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
(src) => {
|
||||
isLoading.value = !!src;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const onLoad = () => {
|
||||
isLoading.value = false;
|
||||
emit("load");
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
isLoading.value = false;
|
||||
emit("error");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="src"
|
||||
class="self-serve-dynamic-image-frame mb-4"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
data-testid="self-serve-dynamic-image-frame"
|
||||
>
|
||||
<b-skeleton
|
||||
v-if="isLoading"
|
||||
class="self-serve-dynamic-image-skeleton"
|
||||
width="100%"
|
||||
height="100%"
|
||||
data-testid="self-serve-dynamic-image-skeleton"
|
||||
/>
|
||||
<img
|
||||
:key="src"
|
||||
:src="src"
|
||||
:alt="alt || 'Machine status'"
|
||||
data-testid="self-serve-dynamic-image"
|
||||
class="self-serve-dynamic-image"
|
||||
:class="{ 'is-loading': isLoading }"
|
||||
@load="onLoad"
|
||||
@error="onError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-dynamic-image-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@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%;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image.is-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { BIcon, BMessage, BRadioButton } from "buefy";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
lanes: Array<any>;
|
||||
selectedLaneId: number | string | null;
|
||||
washType: string;
|
||||
@@ -14,6 +15,8 @@ const emit = defineEmits<{
|
||||
(e: "update:selectedLaneId", value: number): void;
|
||||
(e: "update:washType", value: string): void;
|
||||
}>();
|
||||
|
||||
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -92,16 +95,16 @@ const emit = defineEmits<{
|
||||
:model-value="washType"
|
||||
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"
|
||||
:disabled="!isSelectedMachineAvailable"
|
||||
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
|
||||
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
|
||||
data-testid="self-serve-wash-type-machine"
|
||||
@update:model-value="emit('update:washType', 'Machine')"
|
||||
@input="emit('update:washType', 'Machine')"
|
||||
>
|
||||
<span>
|
||||
<span>{{ $t("self_wash.machine") }}<br /></span>
|
||||
<span v-if="!isMachineAvailable(selectedLaneId)">
|
||||
<span v-if="!isSelectedMachineAvailable">
|
||||
<small>
|
||||
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||
{{ $t("self_wash.unavailable") }}
|
||||
@@ -117,14 +120,6 @@ const emit = defineEmits<{
|
||||
</b-radio-button>
|
||||
</div>
|
||||
</div>
|
||||
<b-message
|
||||
v-if="!isMachineAvailable(selectedLaneId)"
|
||||
type="is-warning"
|
||||
aria-close-label="Luk besked"
|
||||
data-testid="self-serve-machine-unavailable-guidance"
|
||||
>
|
||||
{{ $t("self_wash.machine_unavailable_for_lane") }}
|
||||
</b-message>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,16 @@ 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 [];
|
||||
@@ -73,7 +83,7 @@ const formatTaskTitle = (task: any) => {
|
||||
const title = String(task?.task || "");
|
||||
const programWheelSelection = getProgramWheelSelection(task);
|
||||
|
||||
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
|
||||
if (programWheelSelection === null) {
|
||||
return title;
|
||||
}
|
||||
|
||||
@@ -117,11 +127,13 @@ 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>
|
||||
@@ -144,21 +156,22 @@ const formatTaskTitle = (task: any) => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="self-serve-task-row">
|
||||
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
|
||||
<div v-else class="columns is-mobile is-vcentered">
|
||||
<div v-if="props.showCheckboxes" class="column is-narrow">
|
||||
<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="self-serve-task-content">
|
||||
<div class="column">
|
||||
<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>
|
||||
@@ -226,7 +239,7 @@ const formatTaskTitle = (task: any) => {
|
||||
padding: 0.65rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
gap: 0.6rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -234,30 +247,12 @@ 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 { computed, ref, watch } from "vue";
|
||||
import { BIcon } from "buefy";
|
||||
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
|
||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -18,19 +18,6 @@ const emit = defineEmits<{
|
||||
(e: "clear-dynamic-image"): void;
|
||||
}>();
|
||||
|
||||
const isDynamicImageLoading = ref(false);
|
||||
const failedDynamicImageUrl = ref<string | null>(null);
|
||||
const showDynamicImageFrame = computed(
|
||||
() => !!props.dynamicImageUrl
|
||||
&& !isDynamicImageLoading.value
|
||||
&& failedDynamicImageUrl.value !== props.dynamicImageUrl
|
||||
);
|
||||
|
||||
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
|
||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||
failedDynamicImageUrl.value = null;
|
||||
}, { immediate: true });
|
||||
|
||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
emit("toggle-task", taskId, value);
|
||||
};
|
||||
@@ -39,13 +26,7 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
|
||||
emit("download-attachment", taskId, attachmentId, attachment);
|
||||
};
|
||||
|
||||
const onDynamicImageLoad = () => {
|
||||
isDynamicImageLoading.value = false;
|
||||
};
|
||||
|
||||
const onDynamicImageError = () => {
|
||||
isDynamicImageLoading.value = false;
|
||||
failedDynamicImageUrl.value = props.dynamicImageUrl;
|
||||
emit("clear-dynamic-image");
|
||||
};
|
||||
</script>
|
||||
@@ -66,32 +47,11 @@ const onDynamicImageError = () => {
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-if="dynamicImageUrl && isDynamicImageLoading"
|
||||
:key="`${dynamicImageUrl}:preload`"
|
||||
<SelfServeDynamicImageFrame
|
||||
:src="dynamicImageUrl"
|
||||
alt=""
|
||||
data-testid="self-serve-dynamic-image-preload"
|
||||
class="self-serve-dynamic-image-preload"
|
||||
@load="onDynamicImageLoad"
|
||||
alt="Machine status"
|
||||
@error="onDynamicImageError"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showDynamicImageFrame"
|
||||
class="self-serve-dynamic-image-frame mb-4"
|
||||
data-testid="self-serve-dynamic-image-frame"
|
||||
>
|
||||
<img
|
||||
:key="dynamicImageUrl"
|
||||
:src="dynamicImageUrl"
|
||||
alt="Machine status"
|
||||
data-testid="self-serve-dynamic-image"
|
||||
class="self-serve-dynamic-image"
|
||||
@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>
|
||||
@@ -105,38 +65,3 @@ const onDynamicImageError = () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-dynamic-image-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
||||
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
||||
|
||||
@@ -21,11 +21,8 @@ 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 => {
|
||||
@@ -52,19 +49,6 @@ 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);
|
||||
});
|
||||
@@ -73,18 +57,6 @@ 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>
|
||||
|
||||
@@ -96,41 +68,38 @@ const emitVehicleTypeSelection = (selection: any) => {
|
||||
:model-value="customerNumber || ''"
|
||||
data-testid="self-serve-customer-number"
|
||||
:placeholder="$t('self_wash.enter_customer_number')"
|
||||
@input="emitCustomerNumber($event)"
|
||||
@update:modelValue="emitCustomerNumber($event)"
|
||||
@input="emit('update:customerNumber', String($event || ''))"
|
||||
/>
|
||||
</b-field>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
|
||||
</b-autocomplete>
|
||||
</b-field>
|
||||
<b-message
|
||||
v-if="props.vehicleStepError"
|
||||
@@ -155,7 +124,7 @@ const emitVehicleTypeSelection = (selection: any) => {
|
||||
<SelfServeVehicleTypeSelector
|
||||
:selectedVehicleTypeId="selectedVehicleTypeId"
|
||||
:restrictToProductIds="availableProductIds"
|
||||
@selected="emitVehicleTypeSelection"
|
||||
@selected="emit('select-vehicle-type', $event)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="vehicleTypes.length === 0">
|
||||
@@ -171,34 +140,3 @@ const emitVehicleTypeSelection = (selection: any) => {
|
||||
</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>
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -1,255 +0,0 @@
|
||||
<!--
|
||||
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;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:currentGuidedWashStep": [step: number];
|
||||
goPreviousGuidedWashStep: [];
|
||||
goNextGuidedWashStep: [];
|
||||
completeWash: [];
|
||||
openPropertyAccessGate: [laneId: number | null];
|
||||
openPropertyExitGate: [laneId: number | null];
|
||||
requestAssistance: [];
|
||||
}>();
|
||||
|
||||
const isLastGuidedWashStep = computed(
|
||||
() =>
|
||||
typeof guidedWashFlowSteps !== "undefined" &&
|
||||
guidedWashFlowSteps.length > 0 &&
|
||||
currentGuidedWashStep >= guidedWashFlowSteps.length - 1
|
||||
);
|
||||
|
||||
function handleGoPrevious() {
|
||||
emit("goPreviousGuidedWashStep");
|
||||
}
|
||||
|
||||
function handleGoNext() {
|
||||
emit("goNextGuidedWashStep");
|
||||
}
|
||||
|
||||
function handleComplete() {
|
||||
emit("completeWash");
|
||||
}
|
||||
|
||||
function handleOpenAccessGate(laneId: number | null) {
|
||||
emit("openPropertyAccessGate", laneId);
|
||||
}
|
||||
|
||||
function handleOpenExitGate(laneId: number | null) {
|
||||
emit("openPropertyExitGate", laneId);
|
||||
}
|
||||
|
||||
function handleRequestAssistance() {
|
||||
emit("requestAssistance");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="self-serve-wash-progress">
|
||||
<!-- Finishing screen -->
|
||||
<div
|
||||
v-if="isCompletingWash"
|
||||
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
|
||||
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
|
||||
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="$emit('openPropertyAccessGate', $props)"
|
||||
>
|
||||
{{ $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="$emit('openPropertyExitGate', $props)"
|
||||
>
|
||||
{{ $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>
|
||||
@@ -1,170 +0,0 @@
|
||||
<!--
|
||||
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"
|
||||
:title="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
|
||||
:aria-label="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
|
||||
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>
|
||||
|
||||
<b-message
|
||||
v-if="!isMachineAvailable"
|
||||
type="is-warning"
|
||||
aria-close-label="Luk besked"
|
||||
data-testid="self-serve-machine-unavailable-guidance"
|
||||
>
|
||||
{{ $t("self_wash.machine_unavailable_for_lane") }}
|
||||
</b-message>
|
||||
</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>
|
||||
@@ -252,7 +252,7 @@ const onResendInvite = async (subuser) => {
|
||||
|
||||
<td>
|
||||
<div class="buttons is-justify-content-flex-end action-buttons">
|
||||
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
|
||||
<ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
|
||||
@@ -13,58 +13,6 @@ const getSelectedCustomerNumber = () => {
|
||||
return stored ? parseInt(stored) : 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 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
@@ -115,7 +63,6 @@ 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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { createPinia, defineStore, getActivePinia, setActivePinia, storeToRefs } from "pinia";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||
|
||||
@@ -271,56 +272,26 @@ const mergeByNumericId = (existingItems, incomingItems) => {
|
||||
return merged;
|
||||
};
|
||||
|
||||
const taskStableKey = (task) =>
|
||||
String(task?.task ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
let selfServeLogicStoreInstance = 0;
|
||||
let fallbackSelfServePinia = null;
|
||||
|
||||
const mergeTaskSets = (existingTasks, incomingTasks) => {
|
||||
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
|
||||
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
|
||||
const ensureSelfServePinia = () => {
|
||||
if (typeof document === "undefined") {
|
||||
fallbackSelfServePinia ||= createPinia();
|
||||
setActivePinia(fallbackSelfServePinia);
|
||||
return fallbackSelfServePinia;
|
||||
}
|
||||
|
||||
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
|
||||
return [...existingTasks];
|
||||
const activePinia = getActivePinia();
|
||||
if (activePinia) {
|
||||
return activePinia;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
fallbackSelfServePinia ||= createPinia();
|
||||
setActivePinia(fallbackSelfServePinia);
|
||||
return fallbackSelfServePinia;
|
||||
};
|
||||
|
||||
export function useSelfServeLogic() {
|
||||
const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
|
||||
const loading = ref(false);
|
||||
const requestError = ref(null);
|
||||
const preview = ref(null);
|
||||
@@ -346,30 +317,6 @@ export function useSelfServeLogic() {
|
||||
const summaryVisibleQuestionIds = ref([]);
|
||||
const summaryQuestionOrder = ref({});
|
||||
const latestFetchRequestId = ref(0);
|
||||
const latestSuccessfulFetchKey = ref(null);
|
||||
const inFlightFetchKey = ref(null);
|
||||
|
||||
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 beginFetchRequest = () => {
|
||||
latestFetchRequestId.value += 1;
|
||||
@@ -498,10 +445,6 @@ 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;
|
||||
}
|
||||
@@ -520,7 +463,6 @@ export function useSelfServeLogic() {
|
||||
|
||||
questions.value = nextQuestions;
|
||||
answers.value = buildAnswersMap(nextQuestions);
|
||||
setSummaryVisibleQuestions(nextQuestions);
|
||||
|
||||
const normalizedConditions = Array.isArray(previewData?.conditions)
|
||||
? previewData.conditions.map(normalizeCondition)
|
||||
@@ -540,15 +482,13 @@ 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)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
tasks.value = hydratedTasks;
|
||||
applyAllowedServices(previewData);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -593,9 +533,8 @@ 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(mergedTasks);
|
||||
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
return false;
|
||||
}
|
||||
@@ -656,17 +595,10 @@ export function useSelfServeLogic() {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
|
||||
const requestId = beginFetchRequest();
|
||||
|
||||
if (!laneId || !reg || reg.trim().length < 2) {
|
||||
latestSuccessfulFetchKey.value = null;
|
||||
inFlightFetchKey.value = null;
|
||||
preview.value = null;
|
||||
summary.value = null;
|
||||
session.value = null;
|
||||
@@ -694,7 +626,6 @@ export function useSelfServeLogic() {
|
||||
|
||||
loading.value = true;
|
||||
requestError.value = null;
|
||||
inFlightFetchKey.value = fetchKey;
|
||||
try {
|
||||
const normalizedReg = reg.trim().toUpperCase();
|
||||
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
|
||||
@@ -776,10 +707,6 @@ export function useSelfServeLogic() {
|
||||
setSummaryVisibleQuestions(questions.value);
|
||||
}
|
||||
|
||||
if (isFetchRequestActive(requestId)) {
|
||||
latestSuccessfulFetchKey.value = fetchKey;
|
||||
}
|
||||
|
||||
return previewData;
|
||||
} catch (error) {
|
||||
console.error("Error fetching self-serve preview:", error);
|
||||
@@ -788,9 +715,6 @@ export function useSelfServeLogic() {
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (inFlightFetchKey.value === fetchKey) {
|
||||
inFlightFetchKey.value = null;
|
||||
}
|
||||
if (isFetchRequestActive(requestId)) {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -845,7 +769,7 @@ export function useSelfServeLogic() {
|
||||
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
|
||||
}
|
||||
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
|
||||
answers.value = {
|
||||
...answers.value,
|
||||
[parseInt(questionId)]: value,
|
||||
@@ -910,7 +834,7 @@ export function useSelfServeLogic() {
|
||||
? normalizedRefreshVehicleTypeId
|
||||
: null;
|
||||
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
|
||||
|
||||
return { deletedCount: conditionIdsToDelete.length };
|
||||
} catch (error) {
|
||||
@@ -1082,22 +1006,7 @@ export function useSelfServeLogic() {
|
||||
|
||||
const updateLaneAllowedServices = async (laneId) => {
|
||||
if (!laneId) return;
|
||||
const activeTaskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
|
||||
const taskIds = 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
|
||||
|
||||
try {
|
||||
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
|
||||
@@ -1160,7 +1069,6 @@ export function useSelfServeLogic() {
|
||||
answers,
|
||||
completedTasks,
|
||||
allowedServices,
|
||||
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
|
||||
configVersionId,
|
||||
evaluationTrace,
|
||||
visibleQuestions,
|
||||
@@ -1187,4 +1095,50 @@ export function useSelfServeLogic() {
|
||||
answerQuestion,
|
||||
removeAnswer,
|
||||
};
|
||||
});
|
||||
|
||||
export function useSelfServeLogic(options = {}) {
|
||||
const pinia = ensureSelfServePinia();
|
||||
const explicitStoreId = typeof options === "string" ? options : options?.storeId;
|
||||
const storeId = explicitStoreId || `selfServeLogic:${++selfServeLogicStoreInstance}`;
|
||||
const store = createSelfServeLogicStore(storeId)(pinia);
|
||||
const refs = storeToRefs(store);
|
||||
const {
|
||||
fetchSelfServeData,
|
||||
fetchWashSummary,
|
||||
syncVehicleAnswer,
|
||||
clearVehicleAnswers,
|
||||
evaluateRule,
|
||||
evaluateCondition,
|
||||
isQuestionVisible,
|
||||
isTaskActive,
|
||||
isServiceAllowed,
|
||||
updateLaneAllowedServices,
|
||||
enableMachineRelay,
|
||||
isImage,
|
||||
downloadAttachment,
|
||||
reset,
|
||||
answerQuestion,
|
||||
removeAnswer,
|
||||
} = store;
|
||||
|
||||
return {
|
||||
...refs,
|
||||
fetchSelfServeData,
|
||||
fetchWashSummary,
|
||||
syncVehicleAnswer,
|
||||
clearVehicleAnswers,
|
||||
evaluateRule,
|
||||
evaluateCondition,
|
||||
isQuestionVisible,
|
||||
isTaskActive,
|
||||
isServiceAllowed,
|
||||
updateLaneAllowedServices,
|
||||
enableMachineRelay,
|
||||
isImage,
|
||||
downloadAttachment,
|
||||
reset,
|
||||
answerQuestion,
|
||||
removeAnswer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ const isLaneSelfServeEnabled = (lane) =>
|
||||
)
|
||||
);
|
||||
|
||||
const hasSelfServeEnabledLane = (department) =>
|
||||
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
|
||||
|
||||
const toDepartmentViewModel = (department, distance = null) => ({
|
||||
id: department.id,
|
||||
distance,
|
||||
@@ -50,7 +47,6 @@ export function useWashDepartments(options = {}) {
|
||||
const isSearchingDepartments = ref(false);
|
||||
const lastDepartmentFetchTime = ref(null);
|
||||
const departmentFetchError = ref(null);
|
||||
const departmentSelectionStrategy = ref(null);
|
||||
|
||||
let refreshInterval = null;
|
||||
|
||||
@@ -77,12 +73,6 @@ export function useWashDepartments(options = {}) {
|
||||
return nearestDepartment.value.self_serve_enabled === true;
|
||||
});
|
||||
|
||||
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
|
||||
|
||||
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
|
||||
|
||||
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
|
||||
|
||||
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
@@ -120,30 +110,20 @@ export function useWashDepartments(options = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
let currentNearestDepartment = {
|
||||
id: null,
|
||||
distance: Infinity,
|
||||
};
|
||||
|
||||
guestDepartments.value.forEach((department) => {
|
||||
if (!locationValue?.coords) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = {
|
||||
latitude: locationValue.coords.latitude,
|
||||
longitude: locationValue.coords.longitude,
|
||||
@@ -161,18 +141,6 @@ export function useWashDepartments(options = {}) {
|
||||
|
||||
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;
|
||||
@@ -199,7 +167,7 @@ export function useWashDepartments(options = {}) {
|
||||
};
|
||||
|
||||
const startDepartmentSearch = () => {
|
||||
if (!canAccessSuperUser() && hasLocationCoordinates()) {
|
||||
if (!canAccessSuperUser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,9 +245,6 @@ export function useWashDepartments(options = {}) {
|
||||
isSearchingDepartments,
|
||||
lastDepartmentFetchTime,
|
||||
departmentFetchError,
|
||||
departmentSelectionStrategy,
|
||||
isDepartmentSelectionDistanceBased,
|
||||
isDepartmentSelectionFallbackBased,
|
||||
availableProductIds,
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments,
|
||||
|
||||
@@ -7,11 +7,6 @@ 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,
|
||||
@@ -19,7 +14,7 @@ export function useWashFlowState(options) {
|
||||
customerNumberInput,
|
||||
licensePlateInput,
|
||||
vehicleTypeSelect,
|
||||
isCustomerNumberRequired,
|
||||
availableProductIds,
|
||||
radioLaneOption,
|
||||
nearestDepartment,
|
||||
allVisibleQuestionsAnswered,
|
||||
@@ -39,17 +34,6 @@ 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) {
|
||||
@@ -57,15 +41,16 @@ export function useWashFlowState(options) {
|
||||
}
|
||||
|
||||
const selectedVehicleTypeId = vehicleTypeSelect.value;
|
||||
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
|
||||
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
|
||||
const hasAllowedVehicleType = selectedVehicleTypeId !== null
|
||||
&& selectedVehicleTypeId !== undefined
|
||||
&& allowedProductIds.has(String(selectedVehicleTypeId));
|
||||
const customerNumberValue = customerNumberInput.value;
|
||||
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
|
||||
const customerNumberValid = !requiresCustomerNumber
|
||||
|| customerNumberValue === null
|
||||
const customerNumberValid = customerNumberValue === null
|
||||
|| customerNumberValue === undefined
|
||||
|| String(customerNumberValue).trim() !== "";
|
||||
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
|
||||
if (!hasSelectedVehicleType) {
|
||||
if (!hasAllowedVehicleType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -118,7 +103,7 @@ export function useWashFlowState(options) {
|
||||
}
|
||||
|
||||
if (currentStep.value === steps.SELECT_LANE) {
|
||||
const selectedLane = getSelectedLane();
|
||||
const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
|
||||
if (!selectedLane || !isLaneAvailable(selectedLane)) {
|
||||
return true;
|
||||
}
|
||||
@@ -148,10 +133,9 @@ export function useWashFlowState(options) {
|
||||
const handleConfirmNext = async () => {
|
||||
if (currentStep.value === steps.QUESTIONS) {
|
||||
editAnswers.value = false;
|
||||
const selectedLaneId = getSelectedLaneId();
|
||||
if (selectedLaneId !== null) {
|
||||
if (radioLaneOption.value && radioLaneOption.value !== "Any") {
|
||||
try {
|
||||
await updateLaneAllowedServices(selectedLaneId);
|
||||
await updateLaneAllowedServices(radioLaneOption.value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -166,9 +150,8 @@ export function useWashFlowState(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
|
||||
await onStartWash(
|
||||
selectedLaneId,
|
||||
radioLaneOption.value,
|
||||
licensePlateInput.value,
|
||||
customerNumberInput.value,
|
||||
targetStepForStart()
|
||||
|
||||
@@ -70,10 +70,7 @@ export function useWashSessionActions(options) {
|
||||
|
||||
const isAlreadyStoppedStopError = (message) => {
|
||||
const normalized = String(message || "").toLowerCase();
|
||||
return (
|
||||
normalized.includes("not occupied") &&
|
||||
(normalized.includes("cannot stop lane") || normalized.includes("cannot invoice"))
|
||||
);
|
||||
return normalized.includes("cannot stop lane") && normalized.includes("not occupied");
|
||||
};
|
||||
|
||||
const clearActiveWashState = () => {
|
||||
@@ -253,7 +250,7 @@ export function useWashSessionActions(options) {
|
||||
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
||||
customer_number: parseInt(customerNumber),
|
||||
license_plate: licensePlate.trim().toUpperCase(),
|
||||
defer_relay_side_effects: true,
|
||||
defer_relay_side_effects: false,
|
||||
});
|
||||
if (!startResponse) {
|
||||
return false;
|
||||
|
||||
@@ -66,7 +66,6 @@ export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
|
||||
export const ALLOWED_ORIGINS = [
|
||||
"https://truckwash.io",
|
||||
"https://www.truckwash.io",
|
||||
new URL(DEFAULT_PUBLIC_GATEWAY_API_URL).origin,
|
||||
"http://localhost:5173",
|
||||
"http://localhost:4173",
|
||||
"http://localhost:4174",
|
||||
|
||||
@@ -4595,7 +4595,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||
"machine": "@.capitalize:{'words.generated.maskine'}",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
|
||||
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||
"occupied": "Optaget",
|
||||
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
|
||||
|
||||
@@ -4705,7 +4705,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
|
||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||
"machine": "@:{'words.generated.maschine'}",
|
||||
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
|
||||
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||
"occupied": "Belegt",
|
||||
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
|
||||
|
||||
@@ -4429,7 +4429,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
|
||||
"lane_unavailable": "Lane unavailable",
|
||||
"machine": "@.capitalize:{'words.generated.machine'}",
|
||||
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
|
||||
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||
"occupied": "Occupied",
|
||||
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
|
||||
|
||||
@@ -4224,7 +4224,6 @@
|
||||
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
|
||||
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
|
||||
"machine": "@:{'templates.generated.compat.self_wash.machine'}",
|
||||
"machine_unavailable_for_lane": "@:{'templates.generated.compat.self_wash.machine_unavailable_for_lane'}",
|
||||
"no_departments_found": "@:{'templates.generated.compat.self_wash.no_departments_found'}",
|
||||
"no_vehicles_found": "@:vehicles.no_vehicles",
|
||||
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
|
||||
|
||||
@@ -4706,7 +4706,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||
"machine": "@.capitalize:{'words.generated.maskin'}",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
|
||||
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||
"occupied": "Okkupert",
|
||||
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
|
||||
|
||||
@@ -4756,7 +4756,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
|
||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||
"machine": "@.capitalize:{'words.generated.machine'}",
|
||||
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
|
||||
"no_departments_found": "@.capitalize:{'words.generated.inga'} @:{'words.generated.avdelningar'} @:{'words.generated.hittades'}",
|
||||
"occupied": "Upptagen",
|
||||
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
|
||||
|
||||
@@ -3663,7 +3663,6 @@
|
||||
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||
"machine": "Maskine",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
|
||||
"vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
|
||||
"vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
|
||||
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
|
||||
|
||||
@@ -3663,7 +3663,6 @@
|
||||
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
|
||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||
"machine": "Maschine",
|
||||
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
|
||||
"vehicle_step_missing_customer_number": "Geben Sie Ihre Kundennummer ein, um fortzufahren.",
|
||||
"vehicle_step_missing_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
|
||||
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
|
||||
|
||||
@@ -3662,7 +3662,6 @@
|
||||
"loading_vehicle_types": "Loading available vehicle types...",
|
||||
"lane_unavailable": "Lane unavailable",
|
||||
"machine": "Machine",
|
||||
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
|
||||
"vehicle_step_missing_customer_number": "Enter your customer number to continue.",
|
||||
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
|
||||
"vehicle_step_missing_registration": "Enter your registration number to continue.",
|
||||
|
||||
@@ -3663,7 +3663,6 @@
|
||||
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||
"machine": "Maskin",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
|
||||
"vehicle_step_missing_customer_number": "Skriv inn kundenummer for a fortsette.",
|
||||
"vehicle_step_missing_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
|
||||
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
|
||||
|
||||
@@ -3663,7 +3663,6 @@
|
||||
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
|
||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||
"machine": "Machine",
|
||||
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
|
||||
"vehicle_step_missing_customer_number": "Ange kundnummer for att fortsatta.",
|
||||
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
|
||||
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||
"machine": "@.capitalize:{'terms.glossary.maskine'}",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
|
||||
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||
"occupied": "Optaget",
|
||||
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
|
||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||
"machine": "@:{'terms.glossary.maschine'}",
|
||||
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
|
||||
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||
"occupied": "Belegt",
|
||||
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
|
||||
"lane_unavailable": "Lane unavailable",
|
||||
"machine": "@.capitalize:{'terms.glossary.machine'}",
|
||||
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
|
||||
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||
"occupied": "Occupied",
|
||||
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
|
||||
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
|
||||
"machine": "@:{'phrases.compat.self_wash.machine'}",
|
||||
"machine_unavailable_for_lane": "@:{'phrases.compat.self_wash.machine_unavailable_for_lane'}",
|
||||
"no_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
|
||||
"no_vehicles_found": "@:vehicles.no_vehicles",
|
||||
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.inn'} @:{'terms.glossary.tilgjengelige'} @:{'terms.glossary.kjøretøytyper'}...",
|
||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||
"machine": "@.capitalize:{'terms.glossary.maskin'}",
|
||||
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
|
||||
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||
"occupied": "Okkupert",
|
||||
"open_property_access_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} inngangsport",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laser'} @:{'terms.glossary.in'} @:{'terms.glossary.tillgangliga'} @:{'terms.glossary.fordonstyper'}...",
|
||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||
"machine": "@.capitalize:{'terms.glossary.machine'}",
|
||||
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
|
||||
"no_departments_found": "@.capitalize:{'terms.glossary.inga'} @:{'terms.glossary.avdelningar'} @:{'terms.glossary.hittades'}",
|
||||
"occupied": "Upptagen",
|
||||
"open_property_access_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} ingangsgrind",
|
||||
|
||||
@@ -10,6 +10,7 @@ import applyMiddleware from '@/middleware/index';
|
||||
import store from './store/user.vue'
|
||||
import { createHead } from "@vueuse/head";
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Router from "@/router";
|
||||
import App from "@/App.vue";
|
||||
import Buefy from 'buefy'
|
||||
@@ -108,6 +109,7 @@ applyMiddleware(Router)
|
||||
const app = createApp(App)
|
||||
.use(Router)
|
||||
.use(createHead())
|
||||
.use(createPinia())
|
||||
.use(store)
|
||||
.use(ToastPlugin)
|
||||
.use(Buefy)
|
||||
|
||||
@@ -84,23 +84,8 @@ export const sanitizeRedirectPath = (redirectPath) => {
|
||||
return redirectPath;
|
||||
};
|
||||
|
||||
export const releaseFrontendPathBase = (pathname = '') => {
|
||||
const match = String(pathname || '').match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||
return match ? match[0].replace(/\/+$/, '') : '';
|
||||
};
|
||||
|
||||
export const applyReleaseFrontendBase = (redirectPath, currentPath = '') => {
|
||||
export const buildMigrationUrl = (migrationOrigin, redirectPath) => {
|
||||
const safePath = sanitizeRedirectPath(redirectPath);
|
||||
const releaseBase = releaseFrontendPathBase(currentPath);
|
||||
if (!releaseBase || safePath === releaseBase || safePath.startsWith(`${releaseBase}/`)) {
|
||||
return safePath;
|
||||
}
|
||||
|
||||
return safePath === '/' ? `${releaseBase}/` : `${releaseBase}${safePath}`;
|
||||
};
|
||||
|
||||
export const buildMigrationUrl = (migrationOrigin, redirectPath, currentPath = '') => {
|
||||
const safePath = applyReleaseFrontendBase(redirectPath, currentPath);
|
||||
try {
|
||||
return new URL(safePath, migrationOrigin).toString();
|
||||
} catch (_error) {
|
||||
|
||||
+1
-6
@@ -190,14 +190,9 @@ const EconomicQueuePlaywrightHarness = lazyView('@/views/testing/EconomicQueuePl
|
||||
* departmentSelection: true if the department selection should be possible for the route
|
||||
*/
|
||||
|
||||
export const releaseFrontendRouterBase = (pathname = globalThis?.location?.pathname || '/') => {
|
||||
const match = String(pathname || '/').match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||
return match ? `${match[0].replace(/\/+$/, '')}/` : '/';
|
||||
};
|
||||
|
||||
// Export the router as router
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(releaseFrontendRouterBase()),
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
...((import.meta.env.DEV || import.meta.env.MODE === 'test') ? [
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
|
||||
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
|
||||
export const RELEASE_SOURCE_OVERRIDE_STORAGE_KEY = "release_source_override";
|
||||
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
|
||||
export const RELEASE_BOOTSTRAP_REVISION = "2026-06-03-dev-asset-republish";
|
||||
export const RELEASE_SOURCE_MODES = Object.freeze({
|
||||
LOCAL: "local",
|
||||
DEPLOYMENT: "deployment",
|
||||
@@ -266,7 +265,6 @@ const localReleaseRuntime = ({ requestedSource = RELEASE_SOURCE_MODES.LOCAL, mis
|
||||
return {
|
||||
source: RELEASE_SOURCE_MODES.LOCAL,
|
||||
requested_source: requestedSource,
|
||||
bootstrap_revision: RELEASE_BOOTSTRAP_REVISION,
|
||||
generated_at: new Date().toISOString(),
|
||||
channel: {
|
||||
slug: "stable",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
const createValidationState = () => ({ valid: true, items: [], errors: [], warnings: [] });
|
||||
|
||||
const createGatewayWorkspaceState = () => ({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
|
||||
|
||||
const createSimulatorForm = () => ({
|
||||
lane_id: null,
|
||||
reg: "TEST123",
|
||||
customer_number: null,
|
||||
config_source: "draft",
|
||||
include_hardware: true,
|
||||
hardware_mode: "studio",
|
||||
});
|
||||
|
||||
const createPathEditorQuestionDraft = () => ({
|
||||
question: "New question",
|
||||
description: "",
|
||||
scope: "department_lane",
|
||||
vehicle_mode: "current",
|
||||
vehicle_type_values: [],
|
||||
});
|
||||
|
||||
const createPathEditorForm = () => ({
|
||||
path_key: "",
|
||||
previous_path_key: "",
|
||||
answers: {},
|
||||
machine_allowed: true,
|
||||
task: "Start machine",
|
||||
description: "",
|
||||
services: ["MACHINE"],
|
||||
program_button: "",
|
||||
press_reset: false,
|
||||
press_start: true,
|
||||
extra_buttons: [],
|
||||
dynamic_images_vehicle_type: "0",
|
||||
});
|
||||
|
||||
const createVirtualHardwareForm = () => ({
|
||||
gateway_key: "virtual-main",
|
||||
gateway_label: "Virtual Studio Gateway",
|
||||
relay_id: "",
|
||||
role: "MACHINE",
|
||||
label: "",
|
||||
});
|
||||
|
||||
const createScopeFilters = () => ({
|
||||
lane_id: "",
|
||||
vehicle_type_id: "",
|
||||
});
|
||||
|
||||
const createCanvasFilters = () => ({
|
||||
...createScopeFilters(),
|
||||
machine_type_id: "",
|
||||
dynamic_image_id: "",
|
||||
});
|
||||
|
||||
export const useSelfServeStudioStore = defineStore("selfServeStudio", {
|
||||
state: () => ({
|
||||
flowNodes: [],
|
||||
flowEdges: [],
|
||||
graphPayload: null,
|
||||
lookups: {},
|
||||
validation: createValidationState(),
|
||||
gatewayWorkspace: createGatewayWorkspaceState(),
|
||||
versions: [],
|
||||
activePanel: "flow",
|
||||
selectedNodeId: null,
|
||||
inspectorForm: {},
|
||||
simulatorForm: createSimulatorForm(),
|
||||
simulatorResult: null,
|
||||
simulatorAnswerOverrides: {},
|
||||
simulatorShowJson: false,
|
||||
simulatorDynamicImageHidden: false,
|
||||
simulatorDynamicImageError: "",
|
||||
simulatorCompletedTasks: {},
|
||||
isSimulatorRunning: false,
|
||||
simulatorRunReason: "",
|
||||
simulatorRequestSequence: 0,
|
||||
simulatorLastRequestKey: "",
|
||||
simulatorLastError: "",
|
||||
isRestoringSimulatorStorage: false,
|
||||
pathOutcomes: null,
|
||||
isLoadingPathOutcomes: false,
|
||||
pathOutcomesError: "",
|
||||
pathOutcomesRequestKey: "",
|
||||
selectedPathOutcomeId: null,
|
||||
selectedPathCaseSignature: "",
|
||||
selectedPathCaseIndex: 0,
|
||||
pathCaseRunSignatures: {},
|
||||
pathCaseConfirmingSignature: "",
|
||||
pathEditorDirty: false,
|
||||
isHydratingPathEditor: false,
|
||||
pathEditorTaskTitleOverrides: {},
|
||||
editingPathEditorTaskTitleKey: "",
|
||||
editingPathEditorTaskTitle: "",
|
||||
pathVerificationDynamicImageHidden: false,
|
||||
pathVerificationDynamicImageError: "",
|
||||
isPathVerificationDynamicImageLoading: false,
|
||||
pathEditorQuestionDraftVisible: false,
|
||||
pathEditorQuestionDraft: createPathEditorQuestionDraft(),
|
||||
pathEditorForm: createPathEditorForm(),
|
||||
simulatorCustomerSearchQuery: "",
|
||||
selectedSimulatorCustomer: null,
|
||||
showSimulatorCustomerResults: false,
|
||||
scopeGuideDraft: createScopeFilters(),
|
||||
isScopeGuideManuallyOpen: false,
|
||||
isSyncingScopeFromRoute: false,
|
||||
isWritingScopeToRoute: false,
|
||||
virtualHardwareForm: createVirtualHardwareForm(),
|
||||
laneEditorMode: "edit",
|
||||
selectedLaneManagementId: null,
|
||||
laneForm: {},
|
||||
lanePreviewHidden: false,
|
||||
inspectorLanePreviewHidden: false,
|
||||
inspectorTaskDynamicImageHidden: false,
|
||||
inspectorTaskDynamicImageError: "",
|
||||
searchTerm: "",
|
||||
filters: createCanvasFilters(),
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
isUploadingTaskAttachment: false,
|
||||
deletingTaskAttachmentId: null,
|
||||
isStudioFullscreen: false,
|
||||
hasNativeStudioFullscreen: false,
|
||||
suppressLayoutSave: false,
|
||||
exportText: "",
|
||||
importText: "",
|
||||
draggedTemplateKind: null,
|
||||
unfilteredNodePositions: null,
|
||||
}),
|
||||
actions: {
|
||||
resetPathEditorQuestionDraft() {
|
||||
this.pathEditorQuestionDraft = createPathEditorQuestionDraft();
|
||||
},
|
||||
resetPathEditorForm() {
|
||||
this.pathEditorForm = createPathEditorForm();
|
||||
},
|
||||
applyRouteScope(scopeFilters = {}) {
|
||||
const nextScope = {
|
||||
...createScopeFilters(),
|
||||
...scopeFilters,
|
||||
};
|
||||
this.scopeGuideDraft = nextScope;
|
||||
this.filters = {
|
||||
...this.filters,
|
||||
...nextScope,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+67
-6
@@ -95,6 +95,25 @@ const getDominantWeather = (entries) => {
|
||||
return dominant;
|
||||
};
|
||||
|
||||
const getProductivityStatus = (washes, hours) => {
|
||||
if (hours <= 0) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const productivity = washes / hours;
|
||||
return productivity >= 1.3 ? "healthy" : productivity >= 1.0 ? "degraded" : "unhealthy";
|
||||
};
|
||||
|
||||
const getNormalizedTime = (entry) => {
|
||||
const timeValue = typeof entry?.time === "string" ? entry.time : "";
|
||||
const normalizedTime = timeValue.slice(0, 5);
|
||||
return /^\d{2}:\d{2}$/.test(normalizedTime) ? normalizedTime : null;
|
||||
};
|
||||
|
||||
const formatMergedNumber = (value) => {
|
||||
return Number.isInteger(value) ? value : Number(value.toFixed(2));
|
||||
};
|
||||
|
||||
const summarizeEntriesByDay = (entries) => {
|
||||
const groupedByDate = new Map();
|
||||
|
||||
@@ -120,14 +139,48 @@ const summarizeEntriesByDay = (entries) => {
|
||||
time: "00:00",
|
||||
current: dayEntries.some((entry) => entry?.current === true),
|
||||
weather: getDominantWeather(dayEntries),
|
||||
washes: Number.isInteger(washes) ? washes : Number(washes.toFixed(2)),
|
||||
washes: formatMergedNumber(washes),
|
||||
hours: Number(hours.toFixed(2)),
|
||||
/**
|
||||
* Below 1.0 cars/workhour = unhealthy (red)
|
||||
* 1.0-1.3 cars/workhour = degraded (yellow)
|
||||
* Above 1.3 cars/workhour = healthy (green)
|
||||
*/
|
||||
status: hours > 0 ? (washes / hours >= 1.3 ? "healthy" : washes / hours >= 1.0 ? "degraded" : "unhealthy") : "unknown",
|
||||
status: getProductivityStatus(washes, hours),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const summarizeEntriesByHourOfDay = (entries) => {
|
||||
const groupedByTime = new Map();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const time = getNormalizedTime(entry);
|
||||
if (!time) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!groupedByTime.has(time)) {
|
||||
groupedByTime.set(time, []);
|
||||
}
|
||||
groupedByTime.get(time).push(entry);
|
||||
});
|
||||
|
||||
return Array.from(groupedByTime.entries())
|
||||
.sort(([timeA], [timeB]) => timeA.localeCompare(timeB))
|
||||
.map(([time, hourEntries]) => {
|
||||
const washes = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.washes), 0);
|
||||
const hours = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.hours), 0);
|
||||
|
||||
return {
|
||||
date: null,
|
||||
test_id_date: "multi-day",
|
||||
time,
|
||||
current: hourEntries.some((entry) => entry?.current === true),
|
||||
weather: getDominantWeather(hourEntries),
|
||||
washes: formatMergedNumber(washes),
|
||||
hours: Number(hours.toFixed(2)),
|
||||
status: getProductivityStatus(washes, hours),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -136,6 +189,10 @@ const hourlyDisplayedState = computed(() => {
|
||||
return state.value.filter((entry) => hasOperationalData(entry));
|
||||
});
|
||||
|
||||
const hourlyMergedDisplayedState = computed(() => {
|
||||
return summarizeEntriesByHourOfDay(hourlyDisplayedState.value).filter((entry) => hasOperationalData(entry));
|
||||
});
|
||||
|
||||
const dailyDisplayedState = computed(() => {
|
||||
return summarizeEntriesByDay(state.value).filter((entry) => hasOperationalData(entry));
|
||||
});
|
||||
@@ -144,6 +201,9 @@ const displayedState = computed(() => {
|
||||
if (summarizeDaily.value && isMultiDaySelection.value) {
|
||||
return dailyDisplayedState.value;
|
||||
}
|
||||
if (isMultiDaySelection.value) {
|
||||
return hourlyMergedDisplayedState.value;
|
||||
}
|
||||
return hourlyDisplayedState.value;
|
||||
});
|
||||
|
||||
@@ -167,7 +227,9 @@ const getEntryKey = (entry, index) => {
|
||||
};
|
||||
|
||||
const getEntryTestId = (prefix, entry) => {
|
||||
const date = isDateString(entry?.date) ? entry.date : "no-date";
|
||||
const date = typeof entry?.test_id_date === "string"
|
||||
? entry.test_id_date
|
||||
: isDateString(entry?.date) ? entry.date : "no-date";
|
||||
const time = typeof entry?.time === "string" && entry.time !== "" ? entry.time : "no-time";
|
||||
return `department-weather-${prefix}-${date}-${time}`;
|
||||
};
|
||||
@@ -177,9 +239,8 @@ const getHourDetailsSlotKey = (entry) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeValue = typeof entry?.time === "string" ? entry.time : "";
|
||||
const normalizedTime = timeValue.slice(0, 5);
|
||||
if (!/^\d{2}:\d{2}$/.test(normalizedTime)) {
|
||||
const normalizedTime = getNormalizedTime(entry);
|
||||
if (!normalizedTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+80
-101
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Handle, Position, VueFlow, useVueFlow } from "@vue-flow/core";
|
||||
import "@vue-flow/core/dist/style.css";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
formatSelfServeTaskButtons,
|
||||
normalizeSelfServeTaskButtons,
|
||||
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||
import { useSelfServeStudioStore } from "@/stores/selfServeStudioStore.js";
|
||||
import { resolveAuditIssueNodeIds } from "./auditIssueNavigation.js";
|
||||
|
||||
const currentRoute = useRoute();
|
||||
@@ -367,111 +369,88 @@ const AUTO_LAYOUT_RUNTIME_STAGE_ORDER = {
|
||||
finish: 2,
|
||||
};
|
||||
|
||||
const flowNodes = ref([]);
|
||||
const flowEdges = ref([]);
|
||||
const graphPayload = ref(null);
|
||||
const lookups = ref({});
|
||||
const validation = ref({ valid: true, items: [], errors: [], warnings: [] });
|
||||
const gatewayWorkspace = ref({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
|
||||
const versions = ref([]);
|
||||
const activePanel = ref(panelFromRoute() || "flow");
|
||||
const selectedNodeId = ref(null);
|
||||
const inspectorForm = ref({});
|
||||
const simulatorForm = ref({
|
||||
lane_id: null,
|
||||
reg: "TEST123",
|
||||
customer_number: null,
|
||||
config_source: "draft",
|
||||
include_hardware: true,
|
||||
hardware_mode: "studio",
|
||||
});
|
||||
const simulatorResult = ref(null);
|
||||
const simulatorAnswerOverrides = ref({});
|
||||
const simulatorShowJson = ref(false);
|
||||
const simulatorDynamicImageHidden = ref(false);
|
||||
const simulatorDynamicImageError = ref("");
|
||||
const simulatorCompletedTasks = ref({});
|
||||
const isSimulatorRunning = ref(false);
|
||||
const simulatorRunReason = ref("");
|
||||
const studioStore = useSelfServeStudioStore();
|
||||
const {
|
||||
flowNodes,
|
||||
flowEdges,
|
||||
graphPayload,
|
||||
lookups,
|
||||
validation,
|
||||
gatewayWorkspace,
|
||||
versions,
|
||||
activePanel,
|
||||
selectedNodeId,
|
||||
inspectorForm,
|
||||
simulatorForm,
|
||||
simulatorResult,
|
||||
simulatorAnswerOverrides,
|
||||
simulatorShowJson,
|
||||
simulatorDynamicImageHidden,
|
||||
simulatorDynamicImageError,
|
||||
simulatorCompletedTasks,
|
||||
isSimulatorRunning,
|
||||
simulatorRunReason,
|
||||
simulatorRequestSequence,
|
||||
simulatorLastRequestKey,
|
||||
simulatorLastError,
|
||||
isRestoringSimulatorStorage,
|
||||
pathOutcomes,
|
||||
isLoadingPathOutcomes,
|
||||
pathOutcomesError,
|
||||
pathOutcomesRequestKey,
|
||||
selectedPathOutcomeId,
|
||||
selectedPathCaseSignature,
|
||||
selectedPathCaseIndex,
|
||||
pathCaseRunSignatures,
|
||||
pathCaseConfirmingSignature,
|
||||
pathEditorDirty,
|
||||
isHydratingPathEditor,
|
||||
pathEditorTaskTitleOverrides,
|
||||
editingPathEditorTaskTitleKey,
|
||||
editingPathEditorTaskTitle,
|
||||
pathVerificationDynamicImageHidden,
|
||||
pathVerificationDynamicImageError,
|
||||
isPathVerificationDynamicImageLoading,
|
||||
pathEditorQuestionDraftVisible,
|
||||
pathEditorQuestionDraft,
|
||||
pathEditorForm,
|
||||
simulatorCustomerSearchQuery,
|
||||
selectedSimulatorCustomer,
|
||||
showSimulatorCustomerResults,
|
||||
scopeGuideDraft,
|
||||
isScopeGuideManuallyOpen,
|
||||
isSyncingScopeFromRoute,
|
||||
isWritingScopeToRoute,
|
||||
virtualHardwareForm,
|
||||
laneEditorMode,
|
||||
selectedLaneManagementId,
|
||||
laneForm,
|
||||
lanePreviewHidden,
|
||||
inspectorLanePreviewHidden,
|
||||
inspectorTaskDynamicImageHidden,
|
||||
inspectorTaskDynamicImageError,
|
||||
searchTerm,
|
||||
filters,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isUploadingTaskAttachment,
|
||||
deletingTaskAttachmentId,
|
||||
isStudioFullscreen,
|
||||
hasNativeStudioFullscreen,
|
||||
suppressLayoutSave,
|
||||
exportText,
|
||||
importText,
|
||||
draggedTemplateKind,
|
||||
unfilteredNodePositions,
|
||||
} = storeToRefs(studioStore);
|
||||
|
||||
activePanel.value = panelFromRoute() || "flow";
|
||||
studioStore.applyRouteScope(scopeFiltersFromRoute());
|
||||
|
||||
const simulatorRunTimer = ref(null);
|
||||
const simulatorRequestSequence = ref(0);
|
||||
const simulatorLastRequestKey = ref("");
|
||||
const simulatorLastError = ref("");
|
||||
const isRestoringSimulatorStorage = ref(false);
|
||||
const pathOutcomes = ref(null);
|
||||
const isLoadingPathOutcomes = ref(false);
|
||||
const pathOutcomesError = ref("");
|
||||
const pathOutcomesRequestKey = ref("");
|
||||
const pathOutcomesAbortController = ref(null);
|
||||
const selectedPathOutcomeId = ref(null);
|
||||
const selectedPathCaseSignature = ref("");
|
||||
const selectedPathCaseIndex = ref(0);
|
||||
const pathCaseRunSignatures = ref({});
|
||||
const pathCaseConfirmingSignature = ref("");
|
||||
const pathEditorDirty = ref(false);
|
||||
const isHydratingPathEditor = ref(false);
|
||||
const pathEditorTaskTitleOverrides = ref({});
|
||||
const editingPathEditorTaskTitleKey = ref("");
|
||||
const editingPathEditorTaskTitle = ref("");
|
||||
const pathVerificationDynamicImageHidden = ref(false);
|
||||
const pathVerificationDynamicImageError = ref("");
|
||||
const isPathVerificationDynamicImageLoading = ref(false);
|
||||
const pathEditorQuestionDraftVisible = ref(false);
|
||||
const pathEditorQuestionDraft = ref(emptyPathEditorQuestionDraft());
|
||||
const pathEditorForm = ref({
|
||||
path_key: "",
|
||||
previous_path_key: "",
|
||||
answers: {},
|
||||
machine_allowed: true,
|
||||
task: "Start machine",
|
||||
description: "",
|
||||
services: ["MACHINE"],
|
||||
program_button: "",
|
||||
press_reset: false,
|
||||
press_start: true,
|
||||
extra_buttons: [],
|
||||
dynamic_images_vehicle_type: "0",
|
||||
});
|
||||
const simulatorCustomerSearchQuery = ref("");
|
||||
const selectedSimulatorCustomer = ref(null);
|
||||
const showSimulatorCustomerResults = ref(false);
|
||||
const scopeGuideDraft = ref(scopeFiltersFromRoute());
|
||||
const isScopeGuideManuallyOpen = ref(false);
|
||||
const isSyncingScopeFromRoute = ref(false);
|
||||
const isWritingScopeToRoute = ref(false);
|
||||
const virtualHardwareForm = ref({
|
||||
gateway_key: "virtual-main",
|
||||
gateway_label: "Virtual Studio Gateway",
|
||||
relay_id: "",
|
||||
role: "MACHINE",
|
||||
label: "",
|
||||
});
|
||||
const laneEditorMode = ref("edit");
|
||||
const selectedLaneManagementId = ref(null);
|
||||
const laneForm = ref({});
|
||||
const lanePreviewHidden = ref(false);
|
||||
const inspectorLanePreviewHidden = ref(false);
|
||||
const inspectorTaskDynamicImageHidden = ref(false);
|
||||
const inspectorTaskDynamicImageError = ref("");
|
||||
const searchTerm = ref("");
|
||||
const filters = ref({
|
||||
...scopeFiltersFromRoute(),
|
||||
machine_type_id: "",
|
||||
dynamic_image_id: "",
|
||||
});
|
||||
const isLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isUploadingTaskAttachment = ref(false);
|
||||
const deletingTaskAttachmentId = ref(null);
|
||||
const isStudioFullscreen = ref(false);
|
||||
const hasNativeStudioFullscreen = ref(false);
|
||||
const layoutSaveTimer = ref(null);
|
||||
const suppressLayoutSave = ref(false);
|
||||
const exportText = ref("");
|
||||
const importText = ref("");
|
||||
const draggedTemplateKind = ref(null);
|
||||
const studioRoot = ref(null);
|
||||
const unfilteredNodePositions = ref(null);
|
||||
|
||||
const graphStats = computed(() => {
|
||||
const counts = {};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from "vue";
|
||||
import { BLoading, BMessage } from "buefy";
|
||||
import { BLoading } from "buefy";
|
||||
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
|
||||
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
@@ -10,61 +10,12 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
|
||||
const {
|
||||
guestDepartments,
|
||||
nearestDepartment,
|
||||
isDepartmentSelectionFallbackBased,
|
||||
fetchDepartments,
|
||||
evaluateLocationDepartments,
|
||||
orderDepartmentsByDistance,
|
||||
selectDepartment,
|
||||
} = useWashDepartments({ includeLanes: true });
|
||||
} = useWashDepartments();
|
||||
|
||||
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
|
||||
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
|
||||
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
|
||||
|
||||
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
|
||||
|
||||
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 isLaneReadyForSelfServe = (lane) => lane?.status === "AVAILABLE" && isLaneSelfServeEnabled(lane);
|
||||
|
||||
const canDepartmentStartSelfServe = (department) =>
|
||||
isDepartmentSelfServeEnabled(department) && (department?.lanes || []).some(isLaneReadyForSelfServe);
|
||||
|
||||
const nearestDepartmentCanStartSelfServe = computed(() => canDepartmentStartSelfServe(nearestDepartment.value));
|
||||
|
||||
const eligibleDepartments = computed(() => guestDepartments.value.filter(canDepartmentStartSelfServe));
|
||||
|
||||
const eligibleAlternativeDepartments = computed(() =>
|
||||
eligibleDepartments.value.filter((department) => department.id !== nearestDepartment.value?.id)
|
||||
);
|
||||
|
||||
const hasEligibleAlternativeDepartment = computed(() => eligibleAlternativeDepartments.value.length > 0);
|
||||
|
||||
const selfServeUnavailableMessage = computed(() => {
|
||||
if (!nearestDepartment.value) {
|
||||
return "Vi kan ikke finde din nærmeste afdeling uden en placering. Vælg en selvvask-klar afdeling nedenfor.";
|
||||
}
|
||||
|
||||
if (!isDepartmentSelfServeEnabled(nearestDepartment.value)) {
|
||||
return `${nearestDepartment.value.name} er ikke aktiveret til selvvask lige nu.`;
|
||||
}
|
||||
|
||||
if (!(nearestDepartment.value.lanes || []).some(isLaneReadyForSelfServe)) {
|
||||
return `${nearestDepartment.value.name} har ingen ledige selvvask-baner lige nu.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const getDepartmentDistance = (department) => {
|
||||
if (!locations.location.value?.coords) {
|
||||
@@ -95,65 +46,27 @@ onMounted(async () => {
|
||||
<div class="column is-12">
|
||||
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
|
||||
<template #header>
|
||||
<div
|
||||
class="card-header-title has-text-link has-text-weight-bold"
|
||||
data-testid="self-serve-home-nearest-name"
|
||||
>
|
||||
{{ nearestDepartment ? nearestDepartment.name : $t("global.search_departments") }}
|
||||
<div class="card-header-title has-text-link has-text-weight-bold" data-testid="self-serve-home-nearest-name">
|
||||
{{ nearestDepartment ? nearestDepartment.name : $t("common.loading") }}
|
||||
</div>
|
||||
<div class="card-header-icon has-text-link">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<template v-if="nearestDepartment && isDepartmentSelectionFallbackBased">
|
||||
Vælg en afdeling for at fortsætte. Vi har valgt den første tilgængelige selvvaskeafdeling, fordi din
|
||||
placering ikke er tilgængelig.
|
||||
</template>
|
||||
<template v-else-if="nearestDepartment">
|
||||
{{
|
||||
$t("user_dashboard.wash.nearest_department", {
|
||||
name: nearestDepartment.name,
|
||||
distance: nearestDepartment.distance !== null ? nearestDepartment.distance.toFixed(2) : "-",
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="shouldShowChooseDepartmentMessage">
|
||||
Vælg en afdeling for at starte vask, fordi din placering ikke er tilgængelig.
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("common.loading") }}
|
||||
<b-loading :is-full-page="false" :model-value="true" :can-cancel="true" />
|
||||
</template>
|
||||
<BMessage
|
||||
v-if="selfServeUnavailableMessage"
|
||||
type="is-warning"
|
||||
size="is-small"
|
||||
class="mt-3"
|
||||
data-testid="self-serve-home-unavailable-message"
|
||||
>
|
||||
{{ selfServeUnavailableMessage }}
|
||||
</BMessage>
|
||||
{{ $t("user_dashboard.wash.nearest_department", { name: nearestDepartment ? nearestDepartment.name : "-", distance: nearestDepartment ? nearestDepartment.distance.toFixed(2) : "-" }) }}
|
||||
<b-loading :is-full-page="false" :model-value="!nearestDepartment" :can-cancel="true" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="card-footer-item">
|
||||
<router-link
|
||||
v-if="nearestDepartment && nearestDepartmentCanStartSelfServe"
|
||||
v-if="nearestDepartment"
|
||||
to="/user/wash/start"
|
||||
class="button is-link is-fullwidth"
|
||||
data-testid="self-serve-home-start"
|
||||
>
|
||||
{{ $t("user_dashboard.wash.start_wash") }}
|
||||
</router-link>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="button is-link is-light is-fullwidth"
|
||||
data-testid="self-serve-home-start-disabled"
|
||||
disabled
|
||||
>
|
||||
Selvvask er ikke tilgængelig her
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
@@ -161,7 +74,7 @@ onMounted(async () => {
|
||||
|
||||
<template v-for="department in orderedDepartments" :key="department.id">
|
||||
<div
|
||||
v-if="!nearestDepartment || department.id !== nearestDepartment.id"
|
||||
v-if="nearestDepartment && department.id !== nearestDepartment.id"
|
||||
class="column is-12-mobile is-6-tablet is-4-desktop"
|
||||
:data-testid="`self-serve-home-department-${department.id}`"
|
||||
>
|
||||
@@ -181,20 +94,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="card-footer-item">
|
||||
<button
|
||||
v-if="canDepartmentStartSelfServe(department) && !nearestDepartmentCanStartSelfServe"
|
||||
type="button"
|
||||
class="button is-link is-fullwidth"
|
||||
:data-testid="`self-serve-home-select-department-${department.id}`"
|
||||
@click="selectDepartment(department.id)"
|
||||
>
|
||||
Vælg denne afdeling
|
||||
</button>
|
||||
<router-link
|
||||
v-else
|
||||
:to="{ name: 'pos', params: { departmentId: department.id } }"
|
||||
class="button is-light is-fullwidth"
|
||||
>
|
||||
<router-link :to="{ name: 'pos', params: { departmentId: department.id } }" class="button is-light is-fullwidth">
|
||||
<span class="icon"><i class="fas fa-map-marker-alt"></i></span>
|
||||
<span>{{ department.address }}</span>
|
||||
</router-link>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ const redirectPath = computed(() => {
|
||||
});
|
||||
|
||||
const destinationUrl = computed(() => {
|
||||
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value, window.location.pathname);
|
||||
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value);
|
||||
});
|
||||
|
||||
const clearTimers = () => {
|
||||
|
||||
@@ -265,11 +265,11 @@ test.describe("Admin daily report", () => {
|
||||
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
|
||||
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-06:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-19:00")).toHaveText("19");
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-20:00")).toHaveText("21");
|
||||
await expect(page.getByTestId("department-weather-productivity-2026-03-23-06:00")).toHaveClass(/red/);
|
||||
await expect(page.getByTestId("department-weather-productivity-2026-03-24-08:00")).toHaveClass(/gray/);
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-06:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-19:00")).toHaveText("19");
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-20:00")).toHaveText("21");
|
||||
await expect(page.getByTestId("department-weather-productivity-multi-day-06:00")).toHaveClass(/red/);
|
||||
await expect(page.getByTestId("department-weather-productivity-multi-day-08:00")).toHaveClass(/gray/);
|
||||
|
||||
await page.getByTestId("department-weather-summary-toggle").click();
|
||||
|
||||
@@ -319,9 +319,9 @@ test.describe("Admin daily report", () => {
|
||||
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
|
||||
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-23-12:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-24-13:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-2026-03-24-14:00")).toHaveCount(0);
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-12:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-13:00")).toHaveText("1");
|
||||
await expect(page.getByTestId("department-weather-hours-multi-day-14:00")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("department-weather-summary-toggle").click();
|
||||
|
||||
|
||||
@@ -31,8 +31,6 @@ const expectedFuldfValues = {
|
||||
|
||||
const expectedSelfWashValues = {
|
||||
"self_wash.lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||
"self_wash.machine_unavailable_for_lane":
|
||||
"Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
|
||||
"self_wash.vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
|
||||
"self_wash.vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
|
||||
"self_wash.vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
|
||||
|
||||
@@ -13,7 +13,6 @@ const NGINX_CONFIG_PATH = path.join(PROJECT_ROOT, "nginx.coolify-frontend.conf")
|
||||
|
||||
const STATIC_DIRECTORIES = ["assets", "resources", "favicons", "icons", "img", "sounds", ".well-known"];
|
||||
const STATIC_FILES = [
|
||||
"index.html",
|
||||
"manifest.json",
|
||||
"manifest.webmanifest",
|
||||
"favicon.ico",
|
||||
@@ -24,13 +23,12 @@ const STATIC_FILES = [
|
||||
"registerSW.js",
|
||||
"sw.js",
|
||||
];
|
||||
const RELEASE_FRONTEND_BASE_PATH = "/master/frontend";
|
||||
const GENERATED_HTML_STATIC_PATHS = [
|
||||
"./assets/favicons/favicon-96x96.png",
|
||||
"./assets/favicons/favicon.svg",
|
||||
"./assets/favicons/favicon.ico",
|
||||
"./assets/favicons/apple-touch-icon.png",
|
||||
"./assets/manifest.webmanifest",
|
||||
"/assets/favicons/favicon-96x96.png",
|
||||
"/assets/favicons/favicon.svg",
|
||||
"/assets/favicons/favicon.ico",
|
||||
"/assets/favicons/apple-touch-icon.png",
|
||||
"/assets/manifest.webmanifest",
|
||||
];
|
||||
|
||||
const MIME_TYPES = new Map([
|
||||
@@ -91,22 +89,6 @@ function safePublicPath(relativePath: string) {
|
||||
return safePath(PUBLIC_DIR, relativePath);
|
||||
}
|
||||
|
||||
function releaseRootStaticPath(staticPath: string) {
|
||||
if (staticPath === RELEASE_FRONTEND_BASE_PATH) {
|
||||
return "/";
|
||||
}
|
||||
if (staticPath.startsWith(`${RELEASE_FRONTEND_BASE_PATH}/`)) {
|
||||
return staticPath.slice(RELEASE_FRONTEND_BASE_PATH.length);
|
||||
}
|
||||
if (staticPath.startsWith("./")) {
|
||||
return `/${staticPath.slice(2)}`;
|
||||
}
|
||||
if (!staticPath.startsWith("/") && !/^[a-z][a-z\d+.-]*:/i.test(staticPath)) {
|
||||
return `/${staticPath}`;
|
||||
}
|
||||
return staticPath;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string) {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
@@ -205,7 +187,7 @@ async function distStaticPaths() {
|
||||
const cssFile = bundledStaticFiles.find((fileName) => fileName.endsWith(".css"));
|
||||
const indexStaticPaths = Array.from(indexHtml.matchAll(/\b(?:href|src)="([^"]+)"/g))
|
||||
.map((match) => match[1])
|
||||
.filter((url) => (url.startsWith("/") && !url.startsWith("//")) || url.startsWith("./"));
|
||||
.filter((url) => url.startsWith("/") && !url.startsWith("//"));
|
||||
const manifestIconPaths = (pwaManifest.icons || []).map((icon: { src?: string }) => {
|
||||
if (!icon.src) {
|
||||
return "";
|
||||
@@ -217,8 +199,8 @@ async function distStaticPaths() {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[
|
||||
...indexStaticPaths.map(releaseRootStaticPath),
|
||||
...GENERATED_HTML_STATIC_PATHS.map(releaseRootStaticPath),
|
||||
...indexStaticPaths,
|
||||
...GENERATED_HTML_STATIC_PATHS,
|
||||
...manifestIconPaths,
|
||||
"/manifest.json",
|
||||
"/manifest.webmanifest",
|
||||
@@ -232,13 +214,13 @@ async function distStaticPaths() {
|
||||
workboxFile ? `/${workboxFile}` : "",
|
||||
releaseEntry.entry ? `/${releaseEntry.entry}` : "",
|
||||
...(releaseEntry.css || []).map((fileName: string) => `/${fileName}`),
|
||||
...(releaseManifest.index_asset_urls || []).map(releaseRootStaticPath),
|
||||
...(releaseManifest.pwa_asset_urls || []).map(releaseRootStaticPath),
|
||||
...(releaseManifest.asset_urls || []).map(releaseRootStaticPath),
|
||||
...(releaseManifest.index_asset_urls || []),
|
||||
...(releaseManifest.pwa_asset_urls || []),
|
||||
...(releaseManifest.asset_urls || []),
|
||||
cssFile || "",
|
||||
].filter(Boolean)
|
||||
)
|
||||
);
|
||||
).filter((staticPath) => staticPath !== "/index.html");
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
@@ -305,7 +287,6 @@ test.describe("public .htaccess static fallback", () => {
|
||||
|
||||
test("emits favicon and manifest links from generated assets", async () => {
|
||||
const indexHtml = await fs.readFile(path.join(DIST_DIR, "index.html"), "utf8");
|
||||
const releaseManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-manifest.json"), "utf8"));
|
||||
const legacyManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.json"), "utf8"));
|
||||
const rootWebManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.webmanifest"), "utf8"));
|
||||
|
||||
@@ -313,42 +294,17 @@ test.describe("public .htaccess static fallback", () => {
|
||||
expect(indexHtml).toContain(`href="${staticPath}"`);
|
||||
}
|
||||
|
||||
expect(indexHtml).toContain('src="./assets/');
|
||||
expect(indexHtml).toContain('src="./registerSW.js"');
|
||||
expect(indexHtml).toContain("window.location.pathname.match(/^\\/[^/]+\\/frontend");
|
||||
expect(indexHtml).not.toContain('href="/assets/');
|
||||
expect(indexHtml).not.toContain('src="/assets/');
|
||||
expect(indexHtml).not.toContain('src="/registerSW.js"');
|
||||
expect(indexHtml).not.toContain(`href="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
|
||||
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
|
||||
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/registerSW.js"`);
|
||||
expect(indexHtml).not.toContain('href="/favicons/');
|
||||
expect(indexHtml).not.toContain('href="/favicon.ico"');
|
||||
expect(indexHtml).not.toContain('href="/manifest.json"');
|
||||
|
||||
for (const icon of [...(legacyManifest.icons || []), ...(rootWebManifest.icons || [])]) {
|
||||
expect(icon.src).toMatch(/^(?:assets|\/master\/frontend\/assets)\/favicons\//);
|
||||
}
|
||||
|
||||
for (const assetPath of [
|
||||
releaseManifest.entry,
|
||||
...(releaseManifest.css || []),
|
||||
...(releaseManifest.index_asset_urls || []),
|
||||
...(releaseManifest.pwa_asset_urls || []),
|
||||
...(releaseManifest.asset_urls || []),
|
||||
].filter(Boolean)) {
|
||||
expect(assetPath, "release manifest asset paths must be base-relative").not.toMatch(/^\/(?!master\/frontend\/)/);
|
||||
expect(icon.src).toMatch(/^assets\/favicons\//);
|
||||
}
|
||||
});
|
||||
|
||||
test("serves the app shell from root, deep links, and release-prefixed paths", async ({ request }) => {
|
||||
for (const appPath of [
|
||||
"/",
|
||||
"/guest/book/wash",
|
||||
"/canary/frontend",
|
||||
"/canary/frontend/index.html",
|
||||
"/canary/frontend/guest/book/wash",
|
||||
]) {
|
||||
for (const appPath of ["/", "/guest/book/wash", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
|
||||
const response = await request.get(`${serverBaseUrl}${appPath}`);
|
||||
expect(response.status(), appPath).toBe(200);
|
||||
expect(response.headers()["content-type"], appPath).toContain("text/html");
|
||||
@@ -363,12 +319,7 @@ test.describe("public .htaccess static fallback", () => {
|
||||
for (const prefix of ["", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
|
||||
const response = await request.get(`${serverBaseUrl}${prefix}${staticPath}`);
|
||||
expect(response.status(), `${prefix}${staticPath}`).toBe(200);
|
||||
if (staticPath === "/index.html") {
|
||||
expect(response.headers()["content-type"], `${prefix}${staticPath}`).toContain("text/html");
|
||||
expect(await response.text(), `${prefix}${staticPath}`).toContain('<div id="app"></div>');
|
||||
} else {
|
||||
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
|
||||
}
|
||||
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,11 +14,9 @@ type ReleaseManifest = {
|
||||
};
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
|
||||
const staticBaseURL = process.env.PLAYWRIGHT_RELEASE_STATIC_BASE_URL || process.env.RELEASE_BASE_URL || baseURL;
|
||||
const appBaseURL = process.env.PLAYWRIGHT_APP_BASE_URL || baseURL;
|
||||
|
||||
function liveUrl(assetPath: string, base = staticBaseURL) {
|
||||
const normalizedBase = base.endsWith("/") ? base : `${base}/`;
|
||||
function liveUrl(assetPath: string) {
|
||||
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
|
||||
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
|
||||
}
|
||||
|
||||
@@ -128,7 +126,7 @@ test("@public-live release manifest, shell, and static assets are available", as
|
||||
|
||||
for (const assetPath of assetPaths) {
|
||||
const hashKey = assetPath.startsWith("/") ? assetPath : `/${assetPath}`;
|
||||
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[assetPath] || manifest.asset_hashes?.[hashKey]);
|
||||
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[hashKey]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -144,9 +142,9 @@ test("@public-live api-v2 gateway and channel API prefixes serve JSON ping respo
|
||||
});
|
||||
|
||||
test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
|
||||
const guards = attachPageHealthGuards(page, appBaseURL);
|
||||
const guards = attachPageHealthGuards(page, baseURL);
|
||||
|
||||
await page.goto(liveUrl("/guest/book/wash", appBaseURL));
|
||||
await page.goto("/guest/book/wash");
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
|
||||
await expectBodyHasContent(page);
|
||||
|
||||
@@ -15,7 +15,7 @@ const requireLiveCredentials = process.env.PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS =
|
||||
|
||||
function getLiveSettings() {
|
||||
return {
|
||||
baseURL: process.env.PLAYWRIGHT_APP_BASE_URL || requiredEnv("PLAYWRIGHT_BASE_URL"),
|
||||
baseURL: requiredEnv("PLAYWRIGHT_BASE_URL"),
|
||||
customerCredentials: {
|
||||
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"),
|
||||
password: requiredEnv("PLAYWRIGHT_USER_PASSWORD"),
|
||||
|
||||
@@ -264,53 +264,6 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden();
|
||||
});
|
||||
|
||||
test("start route lets regular users choose a department when geolocation permission is denied", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.clearPermissions();
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "geolocation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getCurrentPosition: (_success, error) =>
|
||||
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }),
|
||||
watchPosition: (_success, error) => {
|
||||
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 });
|
||||
return 1;
|
||||
},
|
||||
clearWatch: () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
customer_number: 12345679,
|
||||
},
|
||||
selfServe: true,
|
||||
});
|
||||
await primeSession(page, {
|
||||
token: "self-serve-denied-geolocation-token",
|
||||
permissions: ["user"],
|
||||
});
|
||||
|
||||
await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
|
||||
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId("self-serve-department-name").click();
|
||||
await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-department-search").locator("input").fill("Odense");
|
||||
await page.getByText("Odense", { exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
|
||||
});
|
||||
|
||||
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
|
||||
const requests = captureSelfServeGatewayRequests(page);
|
||||
|
||||
@@ -773,7 +726,7 @@ test.describe("Self-serve wash", () => {
|
||||
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
|
||||
command: "START",
|
||||
customer_number: 12345679,
|
||||
defer_relay_side_effects: true,
|
||||
defer_relay_side_effects: false,
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false);
|
||||
@@ -1191,9 +1144,7 @@ test.describe("Self-serve wash", () => {
|
||||
expect(requests.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("@dynamic-image tasks flow renders dynamic image, requires completion, and restores after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page }) => {
|
||||
const requests = captureSelfServeGatewayRequests(page);
|
||||
|
||||
await seedSavedProgress(page, {
|
||||
@@ -1559,56 +1510,6 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mocked polling marks active wash completed when backend reports it ended", async ({ page }) => {
|
||||
await seedSavedProgress(page, {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
washStartTime: Date.now() - 30_000,
|
||||
currentStep: 4,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioLaneOption: 7,
|
||||
radioWashType: "Manual",
|
||||
customerNumberInput: "12345679",
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
customer_number: 12345679,
|
||||
},
|
||||
selfServe: {
|
||||
inProgressByLaneId: {
|
||||
7: {
|
||||
lane_id: 7,
|
||||
in_progress: false,
|
||||
session: {
|
||||
id: 801,
|
||||
lane_id: 7,
|
||||
reg: "AB12345",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "AB12345", type: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await primeSession(page, {
|
||||
token: "self-serve-polling-completed-token",
|
||||
permissions: ["user"],
|
||||
});
|
||||
|
||||
await page.goto("/user/wash/start");
|
||||
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull();
|
||||
});
|
||||
|
||||
test("admin preview modal reuses shared question/task rendering", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, primeMockSession } from "./support/network.js";
|
||||
|
||||
async function suppressVueDevtoolsOverlay(page) {
|
||||
await page.addInitScript(() => {
|
||||
const appendStyle = () => {
|
||||
const parent = document.documentElement || document.head || document.body;
|
||||
if (!parent) {
|
||||
document.addEventListener("DOMContentLoaded", appendStyle, { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent =
|
||||
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
||||
parent.appendChild(style);
|
||||
};
|
||||
|
||||
appendStyle();
|
||||
});
|
||||
}
|
||||
|
||||
async function seedGeolocation(context) {
|
||||
await context.grantPermissions(["geolocation"]);
|
||||
await context.setGeolocation({ latitude: 55.6415, longitude: 12.0803 });
|
||||
}
|
||||
|
||||
function buildSavedProgress(overrides = {}) {
|
||||
return {
|
||||
washInProgress: false,
|
||||
washLaneId: null,
|
||||
washStartTime: null,
|
||||
currentStep: 0,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioWashType: "Manual",
|
||||
radioLaneOption: 7,
|
||||
customerNumberInput: "12345679",
|
||||
answers: {},
|
||||
completedTasks: {},
|
||||
nearestDepartmentId: 6,
|
||||
forceNearestDepartmentEvaluationId: 0,
|
||||
isForcingNearestDepartment: false,
|
||||
savedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSavedProgress(page, overrides = {}) {
|
||||
await page.addInitScript((savedProgress) => {
|
||||
window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress));
|
||||
}, buildSavedProgress(overrides));
|
||||
}
|
||||
|
||||
async function fillRegistration(page, value = "ab12345") {
|
||||
const registration = page.locator('input[placeholder*="registreringsnummer"]').first();
|
||||
await registration.fill(value);
|
||||
await expect(registration).toHaveValue(value.toUpperCase());
|
||||
await registration.press("Escape");
|
||||
}
|
||||
|
||||
async function fillRegistrationWithoutClosingAutocomplete(page, value = "ab12345") {
|
||||
const registration = page.locator('input[placeholder*="registreringsnummer"]').first();
|
||||
await registration.fill(value);
|
||||
await expect(registration).toHaveValue(value.toUpperCase());
|
||||
}
|
||||
|
||||
async function selectVehicleType(page, vehicleTypeId = 2) {
|
||||
await page.getByTestId(`self-serve-vehicle-type-${vehicleTypeId}`).click();
|
||||
await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText(/Truck|Van|Car/);
|
||||
}
|
||||
|
||||
async function answerRequiredQuestions(page) {
|
||||
await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-question-11-yes").click();
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function completeGuidedWash(page) {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await page.getByTestId("self-serve-guided-next").click();
|
||||
}
|
||||
}
|
||||
|
||||
function collectSelfServeRequests(page) {
|
||||
const requests = {
|
||||
departments: [],
|
||||
vehicleTypeProducts: [],
|
||||
customerVehicles: [],
|
||||
allowed: [],
|
||||
conditions: [],
|
||||
commands: [],
|
||||
relays: [],
|
||||
summaries: [],
|
||||
activeWash: [],
|
||||
inProgress: [],
|
||||
};
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
const entry = {
|
||||
method: request.method(),
|
||||
url,
|
||||
body: request.method() === "GET" ? null : request.postDataJSON?.() || null,
|
||||
};
|
||||
|
||||
if (url.pathname.endsWith("/guest/departments")) requests.departments.push(entry);
|
||||
if (url.pathname.endsWith("/products")) requests.vehicleTypeProducts.push(entry);
|
||||
if (url.pathname.endsWith("/vehicles")) requests.customerVehicles.push(entry);
|
||||
if (url.pathname.endsWith("/department/selfserve/vehicle/allowed")) requests.allowed.push(entry);
|
||||
if (url.pathname.endsWith("/department/selfserve/vehicle/conditions")) requests.conditions.push(entry);
|
||||
if (url.pathname.endsWith("/modules/self-serve/lane/command")) requests.commands.push(entry);
|
||||
if (url.pathname.endsWith("/modules/self-serve/lane/relay/machine/enable")) requests.relays.push(entry);
|
||||
if (url.pathname.endsWith("/department/selfserve/washes/summary")) requests.summaries.push(entry);
|
||||
if (url.pathname.endsWith("/modules/self-serve/lane/wash/my-active-wash")) requests.activeWash.push(entry);
|
||||
if (url.pathname.endsWith("/modules/self-serve/lane/wash/in-progress")) requests.inProgress.push(entry);
|
||||
});
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
test.describe("User MyWashStart mocked flow", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.skip(({ browserName }) => browserName !== "chromium", "MyWashStart flow coverage is maintained on Chromium.");
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await suppressVueDevtoolsOverlay(page);
|
||||
await seedGeolocation(context);
|
||||
});
|
||||
|
||||
test("manual wash selects vehicle, answers questions, starts, stops, and shows completed step", async ({ page }) => {
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: true,
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-manual-token", bootPath: "/user/wash/start" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
|
||||
await fillRegistration(page);
|
||||
await selectVehicleType(page, 2);
|
||||
await page.getByTestId("self-serve-nav-next").click();
|
||||
await answerRequiredQuestions(page);
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-lane-option-7").click();
|
||||
await page.getByTestId("self-serve-wash-type-manual").click();
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await completeGuidedWash(page);
|
||||
await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible();
|
||||
await page.getByTestId("self-serve-nav-complete").click();
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true");
|
||||
expect(requests.vehicleTypeProducts.length).toBeGreaterThan(0);
|
||||
expect(requests.customerVehicles.length).toBeGreaterThan(0);
|
||||
expect(requests.allowed.length).toBeGreaterThan(0);
|
||||
expect(requests.conditions.some((request) => request.body?.question === 11 && request.body?.value === true)).toBe(
|
||||
true
|
||||
);
|
||||
expect(requests.summaries.length).toBeGreaterThan(0);
|
||||
expect(requests.activeWash.length).toBeGreaterThan(0);
|
||||
expect(requests.inProgress.length).toBeGreaterThan(0);
|
||||
expect(requests.commands.map((request) => request.body?.command)).toEqual(
|
||||
expect.arrayContaining(["START", "STOP", "OPEN_PROPERTY_EXIT_GATE"])
|
||||
);
|
||||
});
|
||||
|
||||
test("registration autocomplete does not cover vehicle type selection", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: true,
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-registration-overlay-token", bootPath: "/user/wash/start" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
|
||||
await fillRegistrationWithoutClosingAutocomplete(page);
|
||||
await selectVehicleType(page, 2);
|
||||
await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("machine wash rolls back when relay enable fails after START", async ({ page }) => {
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await seedSavedProgress(page, {
|
||||
currentStep: 2,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioLaneOption: 7,
|
||||
radioWashType: "Machine",
|
||||
customerNumberInput: "12345679",
|
||||
answers: { 11: true },
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: {
|
||||
relayResponses: [{ success: false, data: { message: "Machine relay unavailable" } }],
|
||||
},
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-machine-token", bootPath: "/user/wash/start" });
|
||||
const needsQuestionReview = await page
|
||||
.getByTestId("self-serve-question-11")
|
||||
.waitFor({ state: "visible", timeout: 10_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (needsQuestionReview) {
|
||||
await page.getByTestId("self-serve-question-11-yes").click();
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
}
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-wash-type-machine").click();
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-action-error")).toContainText("Machine relay unavailable");
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
|
||||
expect(requests.relays).toHaveLength(1);
|
||||
expect(requests.commands.map((request) => request.body?.command)).toEqual(
|
||||
expect.arrayContaining(["START", "STOP"])
|
||||
);
|
||||
});
|
||||
|
||||
test("active wash restore reloads start route and resumes at WASH_IN_PROGRESS", async ({ page }) => {
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: {
|
||||
inProgressByLaneId: {
|
||||
7: {
|
||||
lane_id: 7,
|
||||
in_progress: true,
|
||||
session: {
|
||||
id: 801,
|
||||
lane_id: 7,
|
||||
department_id: 6,
|
||||
reg: "AB12345",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
machine_relay_enabled: false,
|
||||
wash_started_at: "2026-06-02 08:00:00",
|
||||
status: "IN_PROGRESS",
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "AB12345", type: 2 },
|
||||
},
|
||||
},
|
||||
summaryBySessionId: {
|
||||
801: {
|
||||
session: { id: 801, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", vehicle_type_id: 2 },
|
||||
lane: { id: 7, name: "7", department: 6 },
|
||||
questions: [],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [],
|
||||
events: [{ id: 8011, type: "STARTED", created_at: "2026-06-02T08:00:00.000Z" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-active-token", bootPath: "/user/wash/start" });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
|
||||
await expect(page.getByTestId("self-serve-vehicle-step")).toBeHidden();
|
||||
expect(requests.activeWash.length).toBeGreaterThan(0);
|
||||
expect(requests.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("server-completed wash refresh keeps local in-progress state until customer completion", async ({ page }) => {
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await seedSavedProgress(page, {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
washStartTime: Date.now() - 60_000,
|
||||
currentStep: 4,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioLaneOption: 7,
|
||||
radioWashType: "Manual",
|
||||
customerNumberInput: "12345679",
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: {
|
||||
inProgressByLaneId: {
|
||||
7: {
|
||||
lane_id: 7,
|
||||
in_progress: false,
|
||||
session: null,
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-server-completed-token", bootPath: "/user/wash/start" });
|
||||
await expect.poll(() => requests.inProgress.length, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible();
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeHidden();
|
||||
});
|
||||
|
||||
test("answer-sync failure shows an error and blocks progression", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: true,
|
||||
});
|
||||
await page.route(/\/department\/selfserve\/vehicle\/conditions$/i, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Answer sync failed" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-answer-sync-token", bootPath: "/user/wash/start" });
|
||||
await fillRegistration(page);
|
||||
await selectVehicleType(page, 2);
|
||||
await page.getByTestId("self-serve-nav-next").click();
|
||||
await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-question-11-yes").click();
|
||||
|
||||
await expect(page.getByText("Answer sync failed").first()).toBeVisible();
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeHidden();
|
||||
});
|
||||
|
||||
test("geolocation unavailable still provides a department-selection fallback", async ({ browser }) => {
|
||||
const context = await browser.newContext({ permissions: [] });
|
||||
const page = await context.newPage();
|
||||
await suppressVueDevtoolsOverlay(page);
|
||||
const requests = collectSelfServeRequests(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: { customer_number: 12345679 },
|
||||
selfServe: true,
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "user-my-wash-no-geolocation-token", bootPath: "/user/wash/start" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
|
||||
await fillRegistration(page);
|
||||
await selectVehicleType(page, 2);
|
||||
await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled();
|
||||
expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true");
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -124,73 +124,6 @@ describe("authenticatedRequest", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose preview-only active wash reservations as in-progress washes", async () => {
|
||||
const response = {
|
||||
status: 200,
|
||||
data: {
|
||||
data: {
|
||||
lane_id: 3,
|
||||
in_progress: true,
|
||||
status: "READY_FOR_MACHINE_START",
|
||||
session: {
|
||||
id: 379,
|
||||
status: "READY_FOR_MACHINE_START",
|
||||
machine_relay_enabled: false,
|
||||
machine_start_triggered: false,
|
||||
wash_started_at: null,
|
||||
machine_start_triggered_at: null,
|
||||
machine_relay_enabled_at: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const thenCallable = vi.fn();
|
||||
|
||||
axiosMock.mockResolvedValueOnce(response);
|
||||
|
||||
const result = await authenticatedRequest(
|
||||
"/modules/self-serve/lane/wash/my-active-wash",
|
||||
"GET",
|
||||
undefined,
|
||||
null,
|
||||
thenCallable
|
||||
);
|
||||
|
||||
expect(result).not.toBe(response);
|
||||
expect(result.data.data).toMatchObject({
|
||||
lane_id: 3,
|
||||
in_progress: false,
|
||||
status: "READY_FOR_MACHINE_START",
|
||||
});
|
||||
expect(result.data.data.session).toBe(response.data.data.session);
|
||||
expect(thenCallable).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it("keeps relay-enabled active wash responses in progress", async () => {
|
||||
const response = {
|
||||
status: 200,
|
||||
data: {
|
||||
data: {
|
||||
lane_id: 3,
|
||||
in_progress: true,
|
||||
status: "MACHINE_RELAY_ENABLED",
|
||||
session: {
|
||||
id: 380,
|
||||
status: "MACHINE_RELAY_ENABLED",
|
||||
machine_relay_enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
axiosMock.mockResolvedValueOnce(response);
|
||||
|
||||
const result = await authenticatedRequest("/modules/self-serve/lane/wash/my-active-wash", "GET");
|
||||
|
||||
expect(result).toBe(response);
|
||||
expect(result.data.data.in_progress).toBe(true);
|
||||
});
|
||||
|
||||
it("queues requests and executes them sequentially", async () => {
|
||||
const first = createDeferred();
|
||||
const second = createDeferred();
|
||||
|
||||
@@ -278,11 +278,11 @@ describe("DepartmentWeather", () => {
|
||||
|
||||
await flushAll();
|
||||
|
||||
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-06:00").text()).toBe("1");
|
||||
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-19:00").text()).toBe("19");
|
||||
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-20:00").text()).toBe("21");
|
||||
expect(getCellByTestId(wrapper, "department-weather-productivity-2026-03-23-06:00").classes()).toContain("red");
|
||||
expect(getCellByTestId(wrapper, "department-weather-productivity-2026-03-24-08:00").classes()).toContain("gray");
|
||||
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-06:00").text()).toBe("1");
|
||||
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-19:00").text()).toBe("19");
|
||||
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-20:00").text()).toBe("21");
|
||||
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-06:00").classes()).toContain("red");
|
||||
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-08:00").classes()).toContain("gray");
|
||||
|
||||
await wrapper.get('[data-testid="department-weather-summary-toggle"]').trigger("click");
|
||||
await flushAll();
|
||||
@@ -325,11 +325,11 @@ describe("DepartmentWeather", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts in hourly mode and lets users toggle to daily summary in multi-day mode", async () => {
|
||||
it("merges matching hours into one column for multi-day hourly mode", async () => {
|
||||
getObjectsMock.mockResolvedValueOnce([
|
||||
makeEntry({ date: "2026-03-23", time: "06:00", washes: 1, hours: 1 }),
|
||||
makeEntry({ date: "2026-03-23", time: "07:00", washes: 2, hours: 2 }),
|
||||
makeEntry({ date: "2026-03-24", time: "08:00", washes: 3, hours: 3 }),
|
||||
makeEntry({ date: "2026-03-23", time: "06:00", weather: "clear", washes: 1, hours: 0.5, status: "unhealthy" }),
|
||||
makeEntry({ date: "2026-03-24", time: "06:00", weather: "rain", washes: 2, hours: 0.6, status: "healthy" }),
|
||||
makeEntry({ date: "2026-03-24", time: "07:00", weather: "rain", washes: 4, hours: 2, status: "healthy" }),
|
||||
]);
|
||||
|
||||
const wrapper = mount(DepartmentWeather, {
|
||||
@@ -341,7 +341,32 @@ describe("DepartmentWeather", () => {
|
||||
});
|
||||
|
||||
await flushAll();
|
||||
expect(getRenderedHours(wrapper)).toEqual(["06", "07", "08"]);
|
||||
|
||||
expect(getRenderedHours(wrapper)).toEqual(["06", "07"]);
|
||||
expect(getRowValues(wrapper, 1)).toEqual(["3", "4"]);
|
||||
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-06:00").text()).toBe("2");
|
||||
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-06:00").classes()).toContain("green");
|
||||
expect(wrapper.find('[data-testid="department-weather-hours-2026-03-23-06:00"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="department-weather-hours-2026-03-24-06:00"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("starts in hourly mode and lets users toggle to daily summary in multi-day mode", async () => {
|
||||
getObjectsMock.mockResolvedValueOnce([
|
||||
makeEntry({ date: "2026-03-23", time: "06:00", washes: 1, hours: 1 }),
|
||||
makeEntry({ date: "2026-03-23", time: "07:00", washes: 2, hours: 2 }),
|
||||
makeEntry({ date: "2026-03-24", time: "06:00", washes: 3, hours: 3 }),
|
||||
]);
|
||||
|
||||
const wrapper = mount(DepartmentWeather, {
|
||||
props: {
|
||||
department_ids: [1],
|
||||
date_from: "2026-03-23",
|
||||
date_to: "2026-03-24",
|
||||
},
|
||||
});
|
||||
|
||||
await flushAll();
|
||||
expect(getRenderedHours(wrapper)).toEqual(["06", "07"]);
|
||||
|
||||
await wrapper.get('[data-testid="department-weather-summary-toggle"]').trigger("click");
|
||||
await flushAll();
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const asMockRef = (value) => ({ value, __v_isRef: true });
|
||||
|
||||
return {
|
||||
guestDepartments: asMockRef([]),
|
||||
nearestDepartment: asMockRef(null),
|
||||
fetchDepartments: vi.fn(async () => []),
|
||||
evaluateLocationDepartments: vi.fn(),
|
||||
orderDepartmentsByDistance: vi.fn((departments) => departments),
|
||||
selectDepartment: vi.fn(),
|
||||
useWashDepartments: vi.fn(),
|
||||
locationRef: asMockRef({
|
||||
coords: {
|
||||
latitude: 55.5,
|
||||
longitude: 12.4,
|
||||
},
|
||||
}),
|
||||
getDistance: vi.fn(() => 3),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useWashDepartments", () => ({
|
||||
useWashDepartments: mocks.useWashDepartments,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
|
||||
locations: {
|
||||
location: mocks.locationRef,
|
||||
getDistance: mocks.getDistance,
|
||||
},
|
||||
}));
|
||||
|
||||
import MyWash from "@/views/dashboards/userDashboard/wash/MyWash.vue";
|
||||
|
||||
const eligibleLane = { id: 10, name: "10", status: "AVAILABLE", selfserve_enabled: true };
|
||||
|
||||
const makeDepartment = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: "Glostrup",
|
||||
address: "Fabriksparken 18",
|
||||
latitude: 55.5,
|
||||
longitude: 12.4,
|
||||
distance: 1,
|
||||
self_serve_enabled: true,
|
||||
lanes: [eligibleLane],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mountMyWash = async () => {
|
||||
const wrapper = mountWithApp(MyWash, {
|
||||
global: {
|
||||
stubs: {
|
||||
PosDepartmentStepMobile1Location: {
|
||||
emits: ["location-updated"],
|
||||
template: "<button data-testid='location-stub' @click=\"$emit('location-updated', {})\">location</button>",
|
||||
},
|
||||
BLoading: {
|
||||
props: ["modelValue"],
|
||||
template: "<div v-if='modelValue' data-testid='loading-stub' />",
|
||||
},
|
||||
BMessage: {
|
||||
template: "<div class='b-message-stub' v-bind='$attrs'><slot /></div>",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe("MyWash entry", () => {
|
||||
beforeEach(() => {
|
||||
mocks.guestDepartments.value = [];
|
||||
mocks.nearestDepartment.value = null;
|
||||
mocks.locationRef.value = {
|
||||
coords: {
|
||||
latitude: 55.5,
|
||||
longitude: 12.4,
|
||||
},
|
||||
};
|
||||
mocks.fetchDepartments.mockReset();
|
||||
mocks.fetchDepartments.mockImplementation(async () => mocks.guestDepartments.value);
|
||||
mocks.evaluateLocationDepartments.mockReset();
|
||||
mocks.orderDepartmentsByDistance.mockReset();
|
||||
mocks.orderDepartmentsByDistance.mockImplementation((departments) => departments);
|
||||
mocks.selectDepartment.mockReset();
|
||||
mocks.selectDepartment.mockImplementation((departmentId) => {
|
||||
mocks.nearestDepartment.value =
|
||||
mocks.guestDepartments.value.find((department) => department.id === departmentId) || null;
|
||||
});
|
||||
mocks.useWashDepartments.mockReset();
|
||||
mocks.useWashDepartments.mockImplementation(() => ({
|
||||
guestDepartments: mocks.guestDepartments,
|
||||
nearestDepartment: mocks.nearestDepartment,
|
||||
fetchDepartments: mocks.fetchDepartments,
|
||||
evaluateLocationDepartments: mocks.evaluateLocationDepartments,
|
||||
orderDepartmentsByDistance: mocks.orderDepartmentsByDistance,
|
||||
selectDepartment: mocks.selectDepartment,
|
||||
}));
|
||||
mocks.getDistance.mockReset();
|
||||
mocks.getDistance.mockReturnValue(3);
|
||||
});
|
||||
|
||||
it("enables the start CTA when the nearest department is self-serve enabled with an available lane", async () => {
|
||||
const nearest = makeDepartment();
|
||||
mocks.guestDepartments.value = [nearest];
|
||||
mocks.nearestDepartment.value = nearest;
|
||||
|
||||
const wrapper = await mountMyWash();
|
||||
|
||||
expect(mocks.useWashDepartments).toHaveBeenCalledWith({ includeLanes: true });
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').attributes("href")).toBe("/user/wash/start");
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the start CTA when the nearest department is disabled and lets users select another eligible department", async () => {
|
||||
const nearest = makeDepartment({ self_serve_enabled: false });
|
||||
const alternative = makeDepartment({ id: 2, name: "Roskilde", distance: 5 });
|
||||
mocks.guestDepartments.value = [nearest, alternative];
|
||||
mocks.nearestDepartment.value = nearest;
|
||||
|
||||
const wrapper = await mountMyWash();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
|
||||
"Glostrup er ikke aktiveret til selvvask lige nu."
|
||||
);
|
||||
|
||||
await wrapper.find('[data-testid="self-serve-home-select-department-2"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.selectDepartment).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("replaces the start CTA when the nearest department has no enabled and available lanes", async () => {
|
||||
const nearest = makeDepartment({
|
||||
lanes: [
|
||||
{ id: 10, name: "10", status: "MAINTENANCE", selfserve_enabled: true },
|
||||
{ id: 11, name: "11", status: "AVAILABLE", selfserve_enabled: false },
|
||||
],
|
||||
});
|
||||
const alternative = makeDepartment({ id: 3, name: "Køge", distance: 7 });
|
||||
mocks.guestDepartments.value = [nearest, alternative];
|
||||
mocks.nearestDepartment.value = nearest;
|
||||
|
||||
const wrapper = await mountMyWash();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
|
||||
"Glostrup har ingen ledige selvvask-baner lige nu."
|
||||
);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-select-department-3"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("handles missing geolocation by disabling start and offering eligible departments", async () => {
|
||||
const eligible = makeDepartment({ id: 4, name: "Odense" });
|
||||
mocks.locationRef.value = null;
|
||||
mocks.guestDepartments.value = [eligible];
|
||||
mocks.nearestDepartment.value = null;
|
||||
|
||||
const wrapper = await mountMyWash();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
|
||||
"Vi kan ikke finde din nærmeste afdeling uden en placering."
|
||||
);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-select-department-4"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = fs.readFileSync("src/views/dashboards/userDashboard/wash/MyWashStart.vue", "utf8");
|
||||
|
||||
describe("MyWashStart.vue production recovery contracts", () => {
|
||||
it("restores local progress before scheduling a server active-wash restore", () => {
|
||||
expect(source).toContain("const restoredProgress = restoreProgress();");
|
||||
expect(source).toContain("shouldRestoreServerActiveWash.value = !restoredProgress?.washInProgress;");
|
||||
expect(source).toContain("scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);");
|
||||
expect(source.indexOf("const restoredProgress = restoreProgress();")).toBeLessThan(
|
||||
source.indexOf("scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);")
|
||||
);
|
||||
});
|
||||
|
||||
it("restores authenticated server active washes into in-progress state and summary data", () => {
|
||||
expect(source).toContain('const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";');
|
||||
expect(source).toContain("if (!isAuthenticatedCustomerActiveWash(details, customerNumber))");
|
||||
expect(source).toContain("await applyServerActiveWash(activeWash);");
|
||||
expect(source).toContain("washInProgress.value = true;");
|
||||
expect(source).toContain("currentStep.value = shouldRestoreTaskStep ? steps.TASKS : steps.WASH_IN_PROGRESS;");
|
||||
expect(source).toContain("await fetchWashSummary(summaryParams, false);");
|
||||
expect(source).toContain('saveProgress("serverActiveWash");');
|
||||
});
|
||||
|
||||
it("restores relay-enabled server sessions to the task checklist", () => {
|
||||
expect(source).toContain('const SERVER_TASK_RESTORE_STATUSES = new Set(["MACHINE_RELAY_ENABLED"]);');
|
||||
expect(source).toContain(
|
||||
'const SERVER_MACHINE_WASH_STATUSES = new Set(["MACHINE_RELAY_ENABLED", "MACHINE_STARTED"]);'
|
||||
);
|
||||
expect(source).toContain("const shouldRestoreTaskStep = SERVER_TASK_RESTORE_STATUSES.has(sessionStatus);");
|
||||
expect(source).toContain("if (shouldRestoreTaskStep && hasMachineTasks.value)");
|
||||
expect(source).toContain(
|
||||
"currentStep.value = displayedActiveTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS;"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore preview-only ready sessions without start evidence", () => {
|
||||
expect(source).toContain("const hasServerActiveWashStartEvidence = (details: any) =>");
|
||||
expect(source).toContain("SERVER_MACHINE_WASH_STATUSES.has(sessionStatus)");
|
||||
expect(source).toContain("session?.machine_relay_enabled === true");
|
||||
expect(source).toContain("session?.machine_start_triggered === true");
|
||||
expect(source).toContain("parseServerDateTimeMs(session?.wash_started_at) !== null");
|
||||
expect(source).toContain("if (!hasServerActiveWashStartEvidence(details))");
|
||||
});
|
||||
|
||||
it("suppresses recent completions so polling and restore do not resurrect a just-finished wash", () => {
|
||||
expect(source).toContain('const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";');
|
||||
expect(source).toContain("const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;");
|
||||
expect(source).toContain("const finalizeGuidedWashCompletion = (laneId: number | string | null) =>");
|
||||
expect(source).toContain("markRecentlyCompletedWash(laneId, licensePlateInput.value, getNumericCustomerNumber());");
|
||||
expect(source).toContain("if (isRecentlyCompletedActiveWash(activeWash))");
|
||||
expect(source).toContain(
|
||||
"const recentlyCompletedCurrentWash = isRecentlyCompletedActiveWash({ details, laneId });"
|
||||
);
|
||||
expect(source).toContain("if (recentlyCompletedCurrentWash) {");
|
||||
expect(source).toContain("if (!serverStillMatchesCurrentWash) {");
|
||||
expect(source).toContain("if (isRestoring.value || isCompletedWashState.value || !nearestDepartment.value)");
|
||||
});
|
||||
|
||||
it("verifies server completion before recovering from a failed stop command", () => {
|
||||
expect(source).toContain("const waitForStopFailureServerCompletion = async () =>");
|
||||
expect(source).toContain("const serverCompleted = await waitForStopFailureServerCompletion();");
|
||||
expect(source).toContain("if (serverCompleted) {");
|
||||
expect(source).toContain("finalizeGuidedWashCompletion(completingLaneId);");
|
||||
});
|
||||
|
||||
it("keeps active and completed washes visible even if department availability changes", () => {
|
||||
expect(source).toContain("const shouldShowWashFlow = computed(");
|
||||
expect(source).toContain("const isCompletedWashState = computed(");
|
||||
expect(source).toContain("const hasCompletedGuidedWash = ref(false);");
|
||||
expect(source).toContain("const hasRecentlyCompletedWash = ref(false);");
|
||||
expect(source).toContain("washInProgress.value ||");
|
||||
expect(source).toContain("isCompletingWash.value ||");
|
||||
expect(source).toContain("isCompletedWashState.value");
|
||||
expect(source).toContain("hasRecentlyCompletedWash.value");
|
||||
expect(source).toContain('data-testid="self-serve-completed-fallback"');
|
||||
expect(source).toContain('v-show="isCompletedWashState"');
|
||||
expect(source).toContain('<template v-if="shouldShowWashFlow && !isCompletedWashState">');
|
||||
expect(source).toContain('v-model="displayedStep"');
|
||||
expect(source).toContain("currentStep.value < steps.COMPLETED");
|
||||
});
|
||||
|
||||
it("does not treat the customer's active occupied lane as unavailable", () => {
|
||||
expect(source).toContain("session: selfServeSession,");
|
||||
expect(source).toContain("const isCurrentActiveWashLane = (lane: any) =>");
|
||||
expect(source).toContain("const isSelfServeSessionAllowed = () =>");
|
||||
expect(source).toContain("const isCurrentAllowedSelfServeLane = (lane: any) =>");
|
||||
expect(source).toContain("isAllowedSelfServeValue(selfServeSession.value?.allowed)");
|
||||
expect(source).toContain("selfServeSession.value?.lane_id");
|
||||
expect(source).toContain("getAllowedSelfServeLaneIds().has(laneId)");
|
||||
expect(source).toContain(
|
||||
'(isCurrentActiveWashLane(lane) || isCurrentAllowedSelfServeLane(lane) || lane.status === "AVAILABLE")'
|
||||
);
|
||||
});
|
||||
|
||||
it("does not auto-complete active washes from polling before the customer completes them", () => {
|
||||
expect(source).toContain(
|
||||
"const recentlyCompletedCurrentWash = isRecentlyCompletedActiveWash({ details, laneId });"
|
||||
);
|
||||
expect(source).toContain("if (recentlyCompletedCurrentWash) {");
|
||||
expect(source).toContain("!isCompletingWash.value");
|
||||
expect(source.indexOf("!isCompletingWash.value")).toBeLessThan(
|
||||
source.indexOf("clearLocalActiveWashFromServer();", source.indexOf("if (!serverStillMatchesCurrentWash)"))
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to machine wash when configured tasks are machine-only and the customer has not chosen manually", () => {
|
||||
expect(source).toContain("const hasExplicitWashTypeSelection = ref(false);");
|
||||
expect(source).toContain(
|
||||
"const hasMachineTasks = computed(() => activeTasks.value.some((task: any) => isMachineTask(task)));"
|
||||
);
|
||||
expect(source).toContain("const shouldDefaultToMachineWash = computed(");
|
||||
expect(source).toContain('radioWashType.value = "Machine";');
|
||||
});
|
||||
|
||||
it("cleans up unload handlers, department refresh, active-wash polling, and footer state on unmount", () => {
|
||||
expect(source).toContain("unregisterBeforeUnload.value();");
|
||||
expect(source).toContain("stopAutoRefresh();");
|
||||
expect(source).toContain("stopActiveWashRefresh();");
|
||||
expect(source).toContain("markDestroying();");
|
||||
expect(source).toContain("setShowFooterInContent(true);");
|
||||
});
|
||||
|
||||
it("prevents duplicate fetches and overlapping active-wash sync requests", () => {
|
||||
expect(source).toContain("if (isVehicleStepNextLoading.value)");
|
||||
expect(source).toContain("isVehicleStepNextLoading.value = true;");
|
||||
expect(source).toContain("if (isSyncingActiveWash.value || !washInProgress.value || !washLaneId.value)");
|
||||
expect(source).toContain("isSyncingActiveWash.value = true;");
|
||||
expect(source).toContain("isSyncingActiveWash.value = false;");
|
||||
expect(source).toContain("stopActiveWashRefresh();");
|
||||
expect(source).toContain("activeWashRefreshInterval.value = window.setInterval");
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,6 @@ const mocks = vi.hoisted(() => {
|
||||
const conditions = { value: [] };
|
||||
const rules = { value: [] };
|
||||
const allowedServices = { value: ["MACHINE"] };
|
||||
const selfServeAllowed = { value: false };
|
||||
const selfServeLane = { value: { id: 7 } };
|
||||
const selfServeSession = { value: null };
|
||||
const resolvedVehicleTypeId = { value: null };
|
||||
|
||||
return {
|
||||
nearestDepartment,
|
||||
@@ -40,10 +36,6 @@ const mocks = vi.hoisted(() => {
|
||||
conditions,
|
||||
rules,
|
||||
allowedServices,
|
||||
selfServeAllowed,
|
||||
selfServeLane,
|
||||
selfServeSession,
|
||||
resolvedVehicleTypeId,
|
||||
fetchDepartments: vi.fn(async () => guestDepartments.value),
|
||||
startAutoRefresh: vi.fn(),
|
||||
stopAutoRefresh: vi.fn(),
|
||||
@@ -93,7 +85,6 @@ vi.mock("@/components/session/token/SessionUser.vue", async () => {
|
||||
customer_number: mocks.sessionCustomerNumber,
|
||||
},
|
||||
canAccessSuperUser: () => true,
|
||||
canAccessDeveloper: () => false,
|
||||
request: vi.fn(async (...args) => {
|
||||
const response = await mocks.sessionRequest(...args);
|
||||
return response ?? { status: 200, data: { data: {} } };
|
||||
@@ -153,8 +144,6 @@ vi.mock("@/composables/useWashDepartments", () => ({
|
||||
isForcingNearestDepartment: { value: false },
|
||||
forceNearestDepartmentEvaluationId: { value: 0 },
|
||||
isSearchingDepartments: { value: false },
|
||||
departmentFetchError: { value: null },
|
||||
isDepartmentSelectionFallbackBased: { value: false },
|
||||
availableProductIds: { value: [2] },
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled: mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments: mocks.fetchDepartments,
|
||||
@@ -167,43 +156,35 @@ vi.mock("@/composables/useWashDepartments", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useSelfServeLogic", async () => {
|
||||
const { ref } = await import("vue");
|
||||
mocks.resolvedVehicleTypeId = ref(mocks.resolvedVehicleTypeId.value);
|
||||
|
||||
return {
|
||||
useSelfServeLogic: () => ({
|
||||
loading: { value: false },
|
||||
error: { value: null },
|
||||
lane: mocks.selfServeLane,
|
||||
questions: { value: [] },
|
||||
conditions: mocks.conditions,
|
||||
rules: mocks.rules,
|
||||
tasks: { value: [] },
|
||||
answers: mocks.answers,
|
||||
completedTasks: mocks.completedTasks,
|
||||
allowedServices: mocks.allowedServices,
|
||||
resolvedVehicleTypeId: mocks.resolvedVehicleTypeId,
|
||||
allowed: mocks.selfServeAllowed,
|
||||
session: mocks.selfServeSession,
|
||||
visibleQuestions: mocks.visibleQuestions,
|
||||
activeTasks: mocks.activeTasks,
|
||||
allVisibleQuestionsAnswered: { value: false },
|
||||
machineAvailable: { value: true },
|
||||
fetchSelfServeData: mocks.fetchSelfServeDataInternal,
|
||||
fetchWashSummary: mocks.fetchWashSummary,
|
||||
syncVehicleAnswer: mocks.syncVehicleAnswer,
|
||||
evaluateRule: () => true,
|
||||
evaluateCondition: () => true,
|
||||
isQuestionVisible: () => true,
|
||||
isServiceAllowed: () => true,
|
||||
updateLaneAllowedServices: mocks.updateLaneAllowedServices,
|
||||
enableMachineRelay: mocks.enableMachineRelay,
|
||||
downloadAttachment: mocks.downloadAttachment,
|
||||
answerQuestion: mocks.answerQuestion,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/composables/useSelfServeLogic", () => ({
|
||||
useSelfServeLogic: () => ({
|
||||
loading: { value: false },
|
||||
error: { value: null },
|
||||
lane: { value: { id: 7 } },
|
||||
questions: { value: [] },
|
||||
conditions: mocks.conditions,
|
||||
rules: mocks.rules,
|
||||
tasks: { value: [] },
|
||||
answers: mocks.answers,
|
||||
completedTasks: mocks.completedTasks,
|
||||
allowedServices: mocks.allowedServices,
|
||||
visibleQuestions: mocks.visibleQuestions,
|
||||
activeTasks: mocks.activeTasks,
|
||||
allVisibleQuestionsAnswered: { value: false },
|
||||
machineAvailable: { value: true },
|
||||
fetchSelfServeData: mocks.fetchSelfServeDataInternal,
|
||||
fetchWashSummary: mocks.fetchWashSummary,
|
||||
syncVehicleAnswer: mocks.syncVehicleAnswer,
|
||||
evaluateRule: () => true,
|
||||
evaluateCondition: () => true,
|
||||
isQuestionVisible: () => true,
|
||||
isServiceAllowed: () => true,
|
||||
updateLaneAllowedServices: mocks.updateLaneAllowedServices,
|
||||
enableMachineRelay: mocks.enableMachineRelay,
|
||||
downloadAttachment: mocks.downloadAttachment,
|
||||
answerQuestion: mocks.answerQuestion,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useWashProgress", () => ({
|
||||
useWashProgress: (options = {}) => ({
|
||||
@@ -269,7 +250,6 @@ vi.mock("@/composables/useWashSessionActions", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import MyWashStart from "@/views/dashboards/userDashboard/wash/MyWashStart.vue";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
@@ -323,11 +303,9 @@ const stubComponents = {
|
||||
`,
|
||||
},
|
||||
SelfServeLaneStep: {
|
||||
props: ["isLaneAvailable"],
|
||||
emits: ["update:selected-lane-id", "update:washType"],
|
||||
template: `
|
||||
<div>
|
||||
<span data-testid="lane-7-available">{{ String(isLaneAvailable({ id: 7, status: 'OCCUPIED', selfserve_enabled: true })) }}</span>
|
||||
<button data-testid="emit-lane" @click="$emit('update:selected-lane-id', 7)">lane</button>
|
||||
<button data-testid="emit-machine-wash" @click="$emit('update:washType', 'Machine')">machine wash</button>
|
||||
</div>
|
||||
@@ -385,10 +363,6 @@ describe("MyWashStart", () => {
|
||||
mocks.visibleQuestions.value = [{ id: 11, question: "Question 11" }];
|
||||
mocks.activeTasks.value = [];
|
||||
mocks.allowedServices.value = ["MACHINE"];
|
||||
mocks.selfServeAllowed.value = false;
|
||||
mocks.selfServeLane.value = { id: 7 };
|
||||
mocks.selfServeSession.value = null;
|
||||
mocks.resolvedVehicleTypeId.value = null;
|
||||
mocks.conditions.value = [];
|
||||
mocks.rules.value = [];
|
||||
mocks.sessionCustomerNumber.value = 12345679;
|
||||
@@ -400,8 +374,7 @@ describe("MyWashStart", () => {
|
||||
mocks.markDestroying.mockClear();
|
||||
mocks.fetchCustomerVehicles.mockClear();
|
||||
mocks.fetchVehicleTypeOptions.mockClear();
|
||||
mocks.fetchSelfServeDataInternal.mockReset();
|
||||
mocks.fetchSelfServeDataInternal.mockResolvedValue(undefined);
|
||||
mocks.fetchSelfServeDataInternal.mockClear();
|
||||
mocks.fetchWashSummary.mockClear();
|
||||
mocks.saveProgress.mockClear();
|
||||
mocks.clearProgress.mockClear();
|
||||
@@ -414,10 +387,6 @@ describe("MyWashStart", () => {
|
||||
mocks.addVehicle.mockClear();
|
||||
mocks.sessionRequest.mockClear();
|
||||
mocks.sessionRequest.mockResolvedValue(undefined);
|
||||
SessionUser.request.mockClear();
|
||||
mocks.handleConfirmNext.mockClear();
|
||||
mocks.isNextButtonDisabled.mockClear();
|
||||
mocks.isNextButtonDisabled.mockReturnValue(false);
|
||||
mocks.onStartWash.mockClear();
|
||||
mocks.onStopWash.mockReset();
|
||||
mocks.onStopWash.mockResolvedValue(true);
|
||||
@@ -450,8 +419,6 @@ describe("MyWashStart", () => {
|
||||
});
|
||||
|
||||
it("wires child updates back into the self-serve runtime", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
@@ -463,7 +430,6 @@ describe("MyWashStart", () => {
|
||||
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
|
||||
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
|
||||
await nextTick();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
|
||||
@@ -483,107 +449,6 @@ describe("MyWashStart", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("debounces registration, lane, and vehicle type updates into one self-serve fetch", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
mocks.fetchSelfServeDataInternal.mockClear();
|
||||
|
||||
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
|
||||
await wrapper.get('[data-testid="emit-lane"]').trigger("click");
|
||||
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(299);
|
||||
await flushPromises();
|
||||
expect(mocks.fetchSelfServeDataInternal).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
|
||||
});
|
||||
|
||||
it("adopts the server-resolved vehicle type after an untyped eligibility lookup", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.fetchSelfServeDataInternal.mockImplementation(async (_departmentId, vehicleTypeId) => {
|
||||
if (!vehicleTypeId) {
|
||||
mocks.resolvedVehicleTypeId.value = 2;
|
||||
}
|
||||
return { vehicle_type_id: 2 };
|
||||
});
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
mocks.fetchSelfServeDataInternal.mockClear();
|
||||
|
||||
await wrapper.get('[data-testid="emit-registration-unknown"]').trigger("click");
|
||||
await nextTick();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, null, 7, "ZZ99999");
|
||||
|
||||
await nextTick();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "ZZ99999");
|
||||
});
|
||||
|
||||
it("treats the current allowed occupied lane as selectable", async () => {
|
||||
mocks.nearestDepartment.value = {
|
||||
...mocks.nearestDepartment.value,
|
||||
lanes: [{ id: 7, name: "7", status: "OCCUPIED", products: [2], machine_available: true }],
|
||||
};
|
||||
mocks.guestDepartments.value = [mocks.nearestDepartment.value];
|
||||
mocks.selfServeLane.value = { id: 7, status: "OCCUPIED", selfserve_enabled: true };
|
||||
mocks.selfServeAllowed.value = true;
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('[data-testid="lane-7-available"]').text()).toBe("true");
|
||||
});
|
||||
|
||||
it("uses the allowed session lane when the lane payload is not populated yet", async () => {
|
||||
mocks.nearestDepartment.value = {
|
||||
...mocks.nearestDepartment.value,
|
||||
lanes: [{ id: 7, name: "7", status: "OCCUPIED", products: [2], machine_available: true }],
|
||||
};
|
||||
mocks.guestDepartments.value = [mocks.nearestDepartment.value];
|
||||
mocks.selfServeLane.value = null;
|
||||
mocks.selfServeAllowed.value = false;
|
||||
mocks.selfServeSession.value = { lane_id: 7, allowed: true };
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('[data-testid="lane-7-available"]').text()).toBe("true");
|
||||
});
|
||||
|
||||
it("hides machine tasks while manual wash is selected", async () => {
|
||||
mocks.activeTasks.value = [
|
||||
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
|
||||
@@ -672,51 +537,6 @@ describe("MyWashStart", () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("surfaces failed question answer syncs and blocks question confirmation until retry succeeds", async () => {
|
||||
mocks.syncVehicleAnswer.mockRejectedValueOnce(new Error("Question sync failed")).mockResolvedValueOnce(undefined);
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-testid="emit-lane"]').trigger("click");
|
||||
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
|
||||
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
|
||||
await nextTick();
|
||||
await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.get('[data-testid="emit-answer"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('[data-testid="self-serve-question-sync-error"]').text()).toContain("Question sync failed");
|
||||
|
||||
const confirmButton = wrapper.get('[data-testid="self-serve-nav-confirm"]');
|
||||
expect(confirmButton.attributes("disabled")).toBeDefined();
|
||||
|
||||
await confirmButton.trigger("click");
|
||||
expect(mocks.handleConfirmNext).not.toHaveBeenCalled();
|
||||
|
||||
await wrapper.get('[data-testid="self-serve-question-sync-retry"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.syncVehicleAnswer).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.syncVehicleAnswer).toHaveBeenNthCalledWith(2, {
|
||||
departmentId: 6,
|
||||
laneId: 7,
|
||||
customerNumber: 12345679,
|
||||
reg: "AB12345",
|
||||
questionId: 11,
|
||||
value: true,
|
||||
vehicleTypeId: 2,
|
||||
});
|
||||
expect(wrapper.find('[data-testid="self-serve-question-sync-error"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="self-serve-nav-confirm"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows disabled warning without showing loading state when department self-serve is unavailable", async () => {
|
||||
mocks.nearestDepartment.value = {
|
||||
...mocks.nearestDepartment.value,
|
||||
@@ -737,221 +557,6 @@ describe("MyWashStart", () => {
|
||||
expect(wrapper.text()).not.toContain("self_wash.loading_data");
|
||||
});
|
||||
|
||||
it("keeps the completed step visible when the department becomes unavailable after completion", async () => {
|
||||
mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled.value = false;
|
||||
mocks.restoredProgressPayload = {
|
||||
washInProgress: false,
|
||||
washLaneId: null,
|
||||
washStartTime: null,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioWashType: "Manual",
|
||||
radioLaneOption: 7,
|
||||
customerNumberInput: 12345679,
|
||||
isForcingNearestDepartment: false,
|
||||
forceNearestDepartmentEvaluationId: 0,
|
||||
answers: {},
|
||||
completedTasks: {},
|
||||
currentStep: 5,
|
||||
};
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a recently completed wash before startup refreshes finish", async () => {
|
||||
localStorage.setItem(
|
||||
"mywash_recent_completed_v1",
|
||||
JSON.stringify({
|
||||
laneId: 7,
|
||||
reg: "AB12345",
|
||||
customerNumber: 12345679,
|
||||
completedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
mocks.fetchDepartments.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.fetchDepartments).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("picks up a recently completed wash marker written just after mount", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.fetchDepartments.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').isVisible()).toBe(false);
|
||||
|
||||
localStorage.setItem(
|
||||
"mywash_recent_completed_v1",
|
||||
JSON.stringify({
|
||||
laneId: 7,
|
||||
reg: "AB12345",
|
||||
customerNumber: 12345679,
|
||||
completedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("restores a recently completed wash marker written after startup refreshes finish", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(5500);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').isVisible()).toBe(false);
|
||||
|
||||
localStorage.setItem(
|
||||
"mywash_recent_completed_v1",
|
||||
JSON.stringify({
|
||||
laneId: 7,
|
||||
reg: "AB12345",
|
||||
customerNumber: 12345679,
|
||||
completedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
expect(wrapper.vm.currentStep).toBe(5);
|
||||
expect(mocks.clearProgress).toHaveBeenCalled();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("does not fetch self-serve eligibility after a recent completion marker is restored", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
mocks.fetchSelfServeDataInternal.mockClear();
|
||||
|
||||
localStorage.setItem(
|
||||
"mywash_recent_completed_v1",
|
||||
JSON.stringify({
|
||||
laneId: 7,
|
||||
reg: "AB12345",
|
||||
customerNumber: 12345679,
|
||||
completedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await nextTick();
|
||||
|
||||
wrapper.vm.licensePlateInput = "CD98765";
|
||||
wrapper.vm.vehicleTypeSelect = 2;
|
||||
await nextTick();
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
|
||||
expect(wrapper.vm.currentStep).toBe(5);
|
||||
expect(mocks.fetchSelfServeDataInternal).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("does not let a late lane refresh move a completed wash back to questions", async () => {
|
||||
mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled.value = false;
|
||||
mocks.restoredProgressPayload = {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
washStartTime: Date.now() - 20_000,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioWashType: "Manual",
|
||||
radioLaneOption: 7,
|
||||
customerNumberInput: 12345679,
|
||||
isForcingNearestDepartment: false,
|
||||
forceNearestDepartmentEvaluationId: 0,
|
||||
answers: {},
|
||||
completedTasks: {},
|
||||
currentStep: 4,
|
||||
};
|
||||
|
||||
const pendingFetchResolvers = [];
|
||||
mocks.fetchSelfServeDataInternal.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
pendingFetchResolvers.push(resolve);
|
||||
})
|
||||
);
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const guidedActions = wrapper
|
||||
.get('[data-testid="self-serve-bottom-actions"]')
|
||||
.get('[data-testid="self-serve-guided-bottom-actions"]');
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await guidedActions.get('[data-testid="self-serve-guided-next"]').trigger("click");
|
||||
}
|
||||
|
||||
await guidedActions.get('[data-testid="self-serve-nav-complete"]').trigger("click");
|
||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
|
||||
pendingFetchResolvers.forEach((resolve) => resolve(null));
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("adds an unknown registration as customer vehicle before continuing from vehicle step", async () => {
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
@@ -992,8 +597,6 @@ describe("MyWashStart", () => {
|
||||
});
|
||||
|
||||
it("falls back to available lane when restored lane is stale for the selected department", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mocks.restoredProgressPayload = {
|
||||
washInProgress: false,
|
||||
washLaneId: null,
|
||||
@@ -1016,8 +619,6 @@ describe("MyWashStart", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
|
||||
@@ -1222,40 +823,6 @@ describe("MyWashStart", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("does not call SessionUser.request for delayed server restore after unmount", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.restoredProgressPayload = {
|
||||
washInProgress: false,
|
||||
washLaneId: null,
|
||||
washStartTime: null,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioWashType: "Manual",
|
||||
radioLaneOption: 7,
|
||||
customerNumberInput: 12345679,
|
||||
isForcingNearestDepartment: false,
|
||||
forceNearestDepartmentEvaluationId: 0,
|
||||
answers: {},
|
||||
completedTasks: {},
|
||||
currentStep: 0,
|
||||
};
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(SessionUser.request).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.unmount();
|
||||
await vi.advanceTimersByTimeAsync(1600);
|
||||
await flushPromises();
|
||||
|
||||
expect(SessionUser.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries server active wash restore when the authenticated customer number arrives after mount", async () => {
|
||||
mocks.sessionCustomerNumber.value = null;
|
||||
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
|
||||
@@ -1448,11 +1015,6 @@ describe("MyWashStart", () => {
|
||||
expect(wrapper.find('[data-testid="self-serve-nav-close"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
wrapper.vm.currentStep = 0;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
|
||||
await wrapper.get('[data-testid="self-serve-nav-close"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
@@ -1533,7 +1095,6 @@ describe("MyWashStart", () => {
|
||||
});
|
||||
|
||||
it("keeps the guided wash in progress when stop fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.restoredProgressPayload = {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
@@ -1567,7 +1128,7 @@ describe("MyWashStart", () => {
|
||||
}
|
||||
|
||||
await guidedActions.get('[data-testid="self-serve-nav-complete"]').trigger("click");
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.onStopWash).toHaveBeenCalledWith(7);
|
||||
@@ -1575,71 +1136,6 @@ describe("MyWashStart", () => {
|
||||
expect(bottomActions.isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows completed when stop fails but the server confirms the wash is no longer in progress", async () => {
|
||||
mocks.restoredProgressPayload = {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
washStartTime: Date.now() - 20_000,
|
||||
licensePlateInput: "AB12345",
|
||||
vehicleTypeSelect: 2,
|
||||
radioWashType: "Manual",
|
||||
radioLaneOption: 7,
|
||||
customerNumberInput: 12345679,
|
||||
isForcingNearestDepartment: false,
|
||||
forceNearestDepartmentEvaluationId: 0,
|
||||
answers: {},
|
||||
completedTasks: {},
|
||||
currentStep: 4,
|
||||
};
|
||||
mocks.onStopWash.mockResolvedValue(false);
|
||||
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
|
||||
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 7) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
lane_id: 7,
|
||||
in_progress: false,
|
||||
session: null,
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const guidedActions = wrapper
|
||||
.get('[data-testid="self-serve-bottom-actions"]')
|
||||
.get('[data-testid="self-serve-guided-bottom-actions"]');
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await guidedActions.get('[data-testid="self-serve-guided-next"]').trigger("click");
|
||||
}
|
||||
|
||||
await guidedActions.get('[data-testid="self-serve-nav-complete"]').trigger("click");
|
||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.onStopWash).toHaveBeenCalledWith(7);
|
||||
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
|
||||
lane_id: 7,
|
||||
});
|
||||
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
|
||||
expect(mocks.clearProgress).toHaveBeenCalled();
|
||||
expect(mocks.openPropertyExitGate).toHaveBeenLastCalledWith(7, { suppressAlert: true });
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("restores an active wash from the server when local progress is missing", async () => {
|
||||
mocks.nearestDepartment.value = {
|
||||
id: 6,
|
||||
@@ -1718,66 +1214,6 @@ describe("MyWashStart", () => {
|
||||
expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(9);
|
||||
});
|
||||
|
||||
it("does not restore a preview-only ready session as an active wash", async () => {
|
||||
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
|
||||
if (path === "/modules/self-serve/lane/wash/my-active-wash" && method === "GET") {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
lane_id: 7,
|
||||
in_progress: true,
|
||||
status: "READY_FOR_MACHINE_START",
|
||||
session: {
|
||||
id: 901,
|
||||
status: "READY_FOR_MACHINE_START",
|
||||
reg: "AB12345",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
machine_relay_enabled: false,
|
||||
machine_start_triggered: false,
|
||||
machine_relay_enabled_at: null,
|
||||
machine_start_triggered_at: null,
|
||||
wash_started_at: null,
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "AB12345", type: 2 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
lane_id: payload?.lane_id,
|
||||
in_progress: false,
|
||||
session: null,
|
||||
customer: null,
|
||||
vehicle: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/my-active-wash", "GET");
|
||||
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
|
||||
expect(mocks.startElapsedTimer).not.toHaveBeenCalled();
|
||||
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWash");
|
||||
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
|
||||
"display: none"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restore a recently completed wash from the server on revisit", async () => {
|
||||
localStorage.setItem(
|
||||
"mywash_recent_completed_v1",
|
||||
@@ -1837,10 +1273,12 @@ describe("MyWashStart", () => {
|
||||
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
|
||||
expect(mocks.startElapsedTimer).not.toHaveBeenCalled();
|
||||
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWash");
|
||||
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
|
||||
"display: none"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps restored in-progress state when active wash refresh cannot verify authenticated ownership before completion", async () => {
|
||||
it("clears restored in-progress state when active wash refresh cannot verify authenticated ownership", async () => {
|
||||
mocks.restoredProgressPayload = {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
@@ -1896,16 +1334,16 @@ describe("MyWashStart", () => {
|
||||
});
|
||||
expect(mocks.fetchWashSummary).not.toHaveBeenCalledWith({ session_id: 909, vehicle_type: 2 }, false);
|
||||
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWashRefresh");
|
||||
expect(mocks.stopElapsedTimer).not.toHaveBeenCalled();
|
||||
expect(mocks.clearProgress).not.toHaveBeenCalled();
|
||||
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").not.toContain(
|
||||
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
|
||||
expect(mocks.clearProgress).toHaveBeenCalled();
|
||||
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
|
||||
"display: none"
|
||||
);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("keeps restored in-progress state when active wash refresh says the lane is no longer in progress before completion", async () => {
|
||||
it("clears restored in-progress state when active wash refresh says the lane is no longer in progress", async () => {
|
||||
mocks.restoredProgressPayload = {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
@@ -1952,10 +1390,10 @@ describe("MyWashStart", () => {
|
||||
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
|
||||
lane_id: 7,
|
||||
});
|
||||
expect(mocks.stopElapsedTimer).not.toHaveBeenCalled();
|
||||
expect(mocks.clearProgress).not.toHaveBeenCalled();
|
||||
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
|
||||
expect(mocks.clearProgress).toHaveBeenCalled();
|
||||
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
|
||||
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").not.toContain(
|
||||
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
|
||||
"display: none"
|
||||
);
|
||||
|
||||
|
||||
@@ -2,14 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
OUTDATED_GATEWAY_ROUTE_NAME,
|
||||
OUTDATED_INSTALLATION_ROUTE_NAME,
|
||||
applyReleaseFrontendBase,
|
||||
buildMigrationUrl,
|
||||
isOriginAllowed,
|
||||
resolveOriginMigrationRoute,
|
||||
sanitizeRedirectPath,
|
||||
startRedirectCountdown,
|
||||
} from "@/middleware/originMigration";
|
||||
import { ALLOWED_ORIGINS } from "@/config";
|
||||
|
||||
describe("origin migration route guard", () => {
|
||||
const allowedOrigins = ["https://truckwash.io", "https://www.truckwash.io"];
|
||||
@@ -37,10 +34,6 @@ describe("origin migration route guard", () => {
|
||||
expect(target).toBeNull();
|
||||
});
|
||||
|
||||
it("allows the public release gateway origin", () => {
|
||||
expect(isOriginAllowed("https://api-v2.truckwash.io", ALLOWED_ORIGINS)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes to outdated installation for disallowed standalone context", () => {
|
||||
const target = resolveOriginMigrationRoute({
|
||||
to: route,
|
||||
@@ -85,16 +78,6 @@ describe("origin migration redirect url", () => {
|
||||
expect(redirectUrl).toBe("https://truckwash.io/user/bookings?tab=open#latest");
|
||||
});
|
||||
|
||||
it("preserves release frontend base when redirecting from a release-prefixed route", () => {
|
||||
expect(applyReleaseFrontendBase("/", "/master/frontend/outdated-gateway")).toBe("/master/frontend/");
|
||||
expect(applyReleaseFrontendBase("/user/wash/start", "/master/frontend/outdated-gateway")).toBe(
|
||||
"/master/frontend/user/wash/start"
|
||||
);
|
||||
expect(buildMigrationUrl("https://truckwash.io", "/", "/master/frontend/outdated-gateway")).toBe(
|
||||
"https://truckwash.io/master/frontend/"
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes invalid redirect paths to root", () => {
|
||||
expect(sanitizeRedirectPath("https://malicious.example")).toBe("/");
|
||||
});
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { releaseFrontendRouterBase } from "@/router.js";
|
||||
|
||||
describe("router release base", () => {
|
||||
it("uses root routing for normal frontend hosts", () => {
|
||||
expect(releaseFrontendRouterBase("/")).toBe("/");
|
||||
expect(releaseFrontendRouterBase("/user/my-wash/start")).toBe("/");
|
||||
expect(releaseFrontendRouterBase("/login/qr")).toBe("/");
|
||||
});
|
||||
|
||||
it("uses the channel frontend prefix only for release artifact URLs", () => {
|
||||
expect(releaseFrontendRouterBase("/master/frontend")).toBe("/master/frontend/");
|
||||
expect(releaseFrontendRouterBase("/master/frontend/")).toBe("/master/frontend/");
|
||||
expect(releaseFrontendRouterBase("/master/frontend/login/qr")).toBe("/master/frontend/");
|
||||
expect(releaseFrontendRouterBase("/stable/frontend/guest/book/wash")).toBe("/stable/frontend/");
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,6 @@ describe("Danish self-serve labels", () => {
|
||||
|
||||
it("uses Danish self-serve validation and availability text", () => {
|
||||
expect(da.self_wash.lane_unavailable).toBe("Vaskebanen er ikke tilgængelig");
|
||||
expect(da.self_wash.machine_unavailable_for_lane).toBe(
|
||||
"Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane."
|
||||
);
|
||||
expect(da.self_wash.vehicle_step_missing_customer_number).toBe("Indtast kundenummer for at fortsætte.");
|
||||
expect(da.self_wash.vehicle_step_missing_department).toBe(
|
||||
"Vent på, at nærmeste afdeling er indlæst, før du fortsætter."
|
||||
|
||||
@@ -78,21 +78,16 @@ describe("SelfServeLaneStep", () => {
|
||||
expect(wrapper.text()).toContain("Tilgængelig");
|
||||
});
|
||||
|
||||
it("shows machine unavailable guidance for the selected lane", () => {
|
||||
it("keeps machine wash disabled without rendering extra guidance", () => {
|
||||
const wrapper = mountLaneStep({
|
||||
isMachineAvailable: () => false,
|
||||
});
|
||||
|
||||
const machineButton = wrapper.get('[data-testid="self-serve-wash-type-machine"]');
|
||||
const washTypeGroup = wrapper.get('[data-testid="self-serve-wash-type-group"]');
|
||||
const washTypeOptions = wrapper.get('[data-testid="self-serve-wash-type-options"]');
|
||||
const guidance = wrapper.get('[data-testid="self-serve-machine-unavailable-guidance"]');
|
||||
expect(machineButton.attributes("disabled")).toBeDefined();
|
||||
expect(machineButton.attributes("title")).toBe("self_wash.machine_unavailable_for_lane");
|
||||
expect(guidance.text()).toContain("self_wash.machine_unavailable_for_lane");
|
||||
expect(washTypeOptions.find('[data-testid="self-serve-machine-unavailable-guidance"]').exists()).toBe(false);
|
||||
expect(Array.from(washTypeGroup.element.children).indexOf(washTypeOptions.element)).toBeLessThan(
|
||||
Array.from(washTypeGroup.element.children).indexOf(guidance.element)
|
||||
);
|
||||
expect(machineButton.attributes("title")).toBe("self_wash.unavailable");
|
||||
expect(machineButton.text()).toContain("self_wash.unavailable");
|
||||
expect(wrapper.find('[data-testid="self-serve-machine-unavailable-guidance"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).not.toContain("self_wash.machine_unavailable_for_lane");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,17 +52,19 @@ describe("SelfServeTaskList", () => {
|
||||
await wrapper.get('[data-testid="self-serve-task-5-attachment-101"]').trigger("click");
|
||||
await wrapper.get('[data-testid="self-serve-task-5-attachment-102"]').trigger("click");
|
||||
|
||||
expect(wrapper.emitted("toggle-task")).toEqual([[5, true]]);
|
||||
expect(wrapper.emitted("toggle-task")).toEqual([
|
||||
[5, true],
|
||||
[5, true],
|
||||
]);
|
||||
expect(wrapper.emitted("download-attachment")).toEqual([
|
||||
[5, 101, attachments[0]],
|
||||
[5, 102, attachments[1]],
|
||||
]);
|
||||
expect(wrapper.text()).not.toContain("MACHINE");
|
||||
expect(wrapper.text()).not.toContain("Button 1");
|
||||
expect(wrapper.text()).not.toContain("Use the soft brush");
|
||||
});
|
||||
|
||||
it("adds the configured wheel selection only when the program title does not already include it", () => {
|
||||
it("prefixes only program picker task titles with the configured wheel selection", () => {
|
||||
const wrapper = mountWithApp(SelfServeTaskList, {
|
||||
props: {
|
||||
tasks: [
|
||||
@@ -74,14 +76,6 @@ describe("SelfServeTaskList", () => {
|
||||
buttons: [],
|
||||
dynamic_images_vehicle_type: 4,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
task: "Vælg program #1 på dial",
|
||||
description: "-",
|
||||
services: ["PROGRAM_PICKER"],
|
||||
buttons: [],
|
||||
dynamic_images_vehicle_type: 1,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
task: "Tryk reset",
|
||||
@@ -119,8 +113,6 @@ describe("SelfServeTaskList", () => {
|
||||
});
|
||||
|
||||
expect(wrapper.get('[data-testid="self-serve-task-6"]').text()).toContain("Choose program #4");
|
||||
expect(wrapper.get('[data-testid="self-serve-task-10"]').text()).toContain("Vælg program #1 på dial");
|
||||
expect(wrapper.get('[data-testid="self-serve-task-10"]').text()).not.toContain("program #1 #1");
|
||||
expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).toContain("Tryk reset");
|
||||
expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).not.toContain("#");
|
||||
expect(wrapper.get('[data-testid="self-serve-task-9"]').text()).toContain("Choose by button #3");
|
||||
|
||||
@@ -25,37 +25,32 @@ const mountTasksStep = (props = {}) =>
|
||||
});
|
||||
|
||||
describe("SelfServeTasksStep", () => {
|
||||
it("preloads the dynamic image without reserving frame space until it loads", async () => {
|
||||
it("shows a Buefy skeleton until the dynamic image loads", async () => {
|
||||
const wrapper = mountTasksStep();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').attributes("src")).toBe(
|
||||
"https://cdn.example.test/dynamic.png"
|
||||
);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').classes()).toContain("is-loading");
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').classes()).toContain("is-loading");
|
||||
|
||||
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("load");
|
||||
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
|
||||
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-preload"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
|
||||
"https://cdn.example.test/dynamic.png"
|
||||
);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').classes()).not.toContain("is-loading");
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').classes()).not.toContain("is-loading");
|
||||
});
|
||||
|
||||
it("preloads again without showing a frame when the dynamic image URL changes", async () => {
|
||||
it("shows the skeleton again when the dynamic image URL changes", async () => {
|
||||
const wrapper = mountTasksStep();
|
||||
|
||||
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("load");
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
|
||||
"https://cdn.example.test/dynamic.png"
|
||||
);
|
||||
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
|
||||
|
||||
await wrapper.setProps({
|
||||
dynamicImageUrl: "https://cdn.example.test/dynamic-step-2.png",
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').attributes("src")).toBe(
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
|
||||
"https://cdn.example.test/dynamic-step-2.png"
|
||||
);
|
||||
});
|
||||
@@ -63,19 +58,18 @@ describe("SelfServeTasksStep", () => {
|
||||
it("clears loading state and emits clear-dynamic-image on image errors", async () => {
|
||||
const wrapper = mountTasksStep();
|
||||
|
||||
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("error");
|
||||
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("error");
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
|
||||
expect(wrapper.emitted("clear-dynamic-image")).toEqual([[]]);
|
||||
});
|
||||
|
||||
it("does not render dynamic image elements when there is no dynamic image URL", () => {
|
||||
it("does not render a skeleton when there is no dynamic image URL", () => {
|
||||
const wrapper = mountTasksStep({
|
||||
dynamicImageUrl: null,
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-preload"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,14 +12,12 @@ vi.mock("@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue", () =
|
||||
}));
|
||||
|
||||
const AutocompleteStub = {
|
||||
props: ["data"],
|
||||
emits: ["select", "typing", "update:modelValue", "select-header"],
|
||||
template: `
|
||||
<div>
|
||||
<button data-testid="auto-update-object" @click="$emit('update:modelValue', { registration_number: 'ec21235' })">object</button>
|
||||
<button data-testid="auto-select-string" @click="$emit('select', 'ab12345')">select</button>
|
||||
<button data-testid="auto-select-header" @click="$emit('select-header', $event)">header</button>
|
||||
<div data-testid="auto-options">{{ (data || []).join(",") }}</div>
|
||||
<slot name="header" />
|
||||
<slot name="empty" />
|
||||
</div>
|
||||
@@ -146,47 +144,4 @@ describe("SelfServeVehicleStep", () => {
|
||||
);
|
||||
expect(wrapper.find('[data-testid="selector-stub"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("filters exact registration matches out of autocomplete overlay content", () => {
|
||||
const wrapper = mount(SelfServeVehicleStep, {
|
||||
props: {
|
||||
customerNumber: "12345",
|
||||
showCustomerNumberInput: false,
|
||||
registrationNumber: "EC21235",
|
||||
registrationOptions: ["AB12345", "EC21235", "ZZ99999"],
|
||||
isCustomerVehiclesLoading: false,
|
||||
hasMatchingVehicle: true,
|
||||
selectedVehicleTypeId: null,
|
||||
selectedVehicleName: null,
|
||||
selectedVehicleDescription: null,
|
||||
availableProductIds: [2],
|
||||
vehicleTypes: [{ id: 2 }],
|
||||
},
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (value) => value,
|
||||
},
|
||||
stubs: {
|
||||
BField: {
|
||||
template: "<div><slot /></div>",
|
||||
},
|
||||
BAutocomplete: AutocompleteStub,
|
||||
BInput: true,
|
||||
BMessage: {
|
||||
template: "<div v-bind='$attrs'><slot /></div>",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('[data-testid="auto-options"]').text()).toBe("");
|
||||
expect(wrapper.get('[data-testid="self-serve-registration-guidance"]').text()).toContain(
|
||||
"self_wash.select_from_vehicles"
|
||||
);
|
||||
expect(wrapper.get(".self-serve-registration-control").exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="self-serve-registration-guidance"]').classes()).toContain(
|
||||
"self-serve-registration-guidance"
|
||||
);
|
||||
expect(wrapper.find('[data-testid="selector-stub"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sessionUserMock = vi.hoisted(() => ({
|
||||
request: vi.fn(),
|
||||
objects: {
|
||||
self_serve_vehicle_conditions: {
|
||||
get: {
|
||||
previewAllowed: vi.fn(),
|
||||
washSummary: vi.fn(),
|
||||
all: vi.fn(),
|
||||
},
|
||||
add: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
self_serve_tasks: {
|
||||
attachments: {
|
||||
list: vi.fn(),
|
||||
download: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: sessionUserMock,
|
||||
}));
|
||||
|
||||
import { useSelfServeLogic } from "@/composables/useSelfServeLogic.js";
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const previewPayload = (overrides = {}) => ({
|
||||
lane: { id: 7, name: "7" },
|
||||
session: { id: 501, vehicle_type_id: 2 },
|
||||
allowed_services: ["manual"],
|
||||
questions: [{ id: 11, question: "Clean?", answer: null, order_priority: 1 }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [{ id: 91, task: "Manual prep", order_priority: 1, services: ["manual"] }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const summaryPayload = (overrides = {}) => ({
|
||||
session: { id: 501, vehicle_type_id: 2, allowed: true },
|
||||
lane: { id: 7, name: "7" },
|
||||
allowed_services: ["machine", "manual"],
|
||||
questions: [{ id: 21, question: "Roof?", answer: true, order_priority: 2 }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [{ id: 92, task: "Machine start", order_priority: 2, services: ["machine"] }],
|
||||
events: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("useSelfServeLogic production behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionUserMock.objects.self_serve_tasks.attachments.list.mockResolvedValue([]);
|
||||
sessionUserMock.objects.self_serve_tasks.attachments.download.mockResolvedValue({ data: { download_link: "" } });
|
||||
});
|
||||
|
||||
it("merges preview and summary questions while preserving preview answers until summary replaces them", async () => {
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(previewPayload());
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(summaryPayload());
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
await logic.fetchSelfServeData(6, 2, 7, "ab12345");
|
||||
|
||||
expect(logic.questions.value.map((question) => question.id)).toEqual([11, 21]);
|
||||
expect(logic.answers.value).toEqual({ 11: null, 21: true });
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([21]);
|
||||
expect(logic.tasks.value.map((task) => task.id)).toEqual([92]);
|
||||
expect(logic.allowedServices.value).toEqual(["MACHINE", "MANUAL"]);
|
||||
});
|
||||
|
||||
it("keeps preview questions visible while session summary is still loading", async () => {
|
||||
const slowSummary = deferred();
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(
|
||||
previewPayload({
|
||||
questions: [{ id: 11, question: "Clean?", answer: true, order_priority: 1 }],
|
||||
tasks: [],
|
||||
})
|
||||
);
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockReturnValue(slowSummary.promise);
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
const fetchPromise = logic.fetchSelfServeData(6, 2, 7, "ab12345");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(logic.loading.value).toBe(true);
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([11]);
|
||||
});
|
||||
|
||||
slowSummary.resolve(summaryPayload());
|
||||
await fetchPromise;
|
||||
|
||||
expect(logic.loading.value).toBe(false);
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([21]);
|
||||
});
|
||||
|
||||
it("ignores stale preview and summary responses when a newer request wins the race", async () => {
|
||||
const slowPreview = deferred();
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed
|
||||
.mockReturnValueOnce(slowPreview.promise)
|
||||
.mockResolvedValueOnce(
|
||||
previewPayload({ session: { id: 502 }, questions: [{ id: 31, question: "Newest?", answer: false }] })
|
||||
);
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(
|
||||
summaryPayload({ session: { id: 502 }, questions: [{ id: 32, question: "Newest summary?", answer: true }] })
|
||||
);
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
const stale = logic.fetchSelfServeData(6, 2, 7, "old123");
|
||||
const fresh = await logic.fetchSelfServeData(6, 2, 7, "new123");
|
||||
slowPreview.resolve(previewPayload({ session: { id: 501 }, questions: [{ id: 11, question: "Old?" }] }));
|
||||
await stale;
|
||||
|
||||
expect(fresh.session.id).toBe(502);
|
||||
expect(logic.questions.value.map((question) => question.id)).toEqual([31, 32]);
|
||||
expect(logic.preview.value.session.id).toBe(502);
|
||||
expect(logic.loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it("updates allowed services from the lane endpoint and falls back to active task services on first failure", async () => {
|
||||
const logic = useSelfServeLogic();
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(
|
||||
previewPayload({ allowed_services: [], tasks: [{ id: 91, task: "Machine", services: ["MACHINE", "manual"] }] })
|
||||
);
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(
|
||||
summaryPayload({ questions: [] })
|
||||
);
|
||||
await logic.fetchSelfServeData(6, 2, 7, "ab12345");
|
||||
|
||||
sessionUserMock.request.mockResolvedValueOnce({
|
||||
data: { success: true, data: { allowed_services: ["relay", "machine"] } },
|
||||
});
|
||||
await logic.updateLaneAllowedServices(7);
|
||||
expect(logic.allowedServices.value).toEqual(["RELAY", "MACHINE"]);
|
||||
|
||||
sessionUserMock.request.mockRejectedValueOnce(new Error("edge offline"));
|
||||
await expect(logic.updateLaneAllowedServices(7)).rejects.toThrow("edge offline");
|
||||
expect(logic.allowedServices.value).toEqual(["RELAY", "MACHINE"]);
|
||||
expect(logic.error.value).toBe("edge offline");
|
||||
});
|
||||
|
||||
it("keeps the local answer optimistic only through successful answer sync and reports failures", async () => {
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.add.mockRejectedValueOnce(new Error("answer sync failed"));
|
||||
const logic = useSelfServeLogic();
|
||||
logic.answerQuestion(11, true);
|
||||
|
||||
await expect(
|
||||
logic.syncVehicleAnswer({
|
||||
departmentId: 6,
|
||||
laneId: 7,
|
||||
reg: "AB12345",
|
||||
questionId: 11,
|
||||
value: false,
|
||||
vehicleTypeId: 2,
|
||||
})
|
||||
).rejects.toThrow("answer sync failed");
|
||||
|
||||
expect(logic.answers.value[11]).toBe(true);
|
||||
expect(logic.error.value).toBe("answer sync failed");
|
||||
expect(logic.loading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -91,225 +91,6 @@ describe("useSelfServeLogic", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears stale wash summary machine availability when a fresh preview has no session", async () => {
|
||||
mocks.previewAllowed
|
||||
.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
machine_available: true,
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
session: { id: 91, status: "READY_FOR_MACHINE_START" },
|
||||
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
|
||||
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
allowed_services: ["MACHINE"],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
machine_available: true,
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
session: null,
|
||||
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
|
||||
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
allowed_services: ["MACHINE"],
|
||||
});
|
||||
mocks.washSummary.mockResolvedValueOnce({
|
||||
machine_available: false,
|
||||
session: { id: 91, status: "READY_FOR_MACHINE_START" },
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
|
||||
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
|
||||
events: [],
|
||||
allowed_services: [],
|
||||
});
|
||||
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
|
||||
expect(logic.machineAvailable.value).toBe(false);
|
||||
|
||||
await logic.fetchSelfServeData(1, 9, 7, "cd12345");
|
||||
|
||||
expect(logic.summary.value).toBeNull();
|
||||
expect(logic.machineAvailable.value).toBe(true);
|
||||
expect(logic.allowedServices.value).toEqual(["MACHINE"]);
|
||||
});
|
||||
|
||||
it("does not clear server-provided allowed services when task ids are temporarily empty", async () => {
|
||||
mocks.previewAllowed.mockResolvedValue({
|
||||
allowed: true,
|
||||
machine_available: true,
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
session: null,
|
||||
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
|
||||
tasks: [],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
allowed_services: ["MACHINE", "PROGRAM_PICKER"],
|
||||
});
|
||||
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
|
||||
const response = await logic.updateLaneAllowedServices(7);
|
||||
|
||||
expect(mocks.request).not.toHaveBeenCalled();
|
||||
expect(response).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
lane_id: 7,
|
||||
allowed_services: ["MACHINE", "PROGRAM_PICKER"],
|
||||
skipped_empty_task_sync: true,
|
||||
},
|
||||
});
|
||||
expect(logic.allowedServices.value).toEqual(["MACHINE", "PROGRAM_PICKER"]);
|
||||
expect(logic.machineAvailable.value).toBe(true);
|
||||
});
|
||||
|
||||
it("does not post empty task ids to the lane allowed-services endpoint", async () => {
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
const response = await logic.updateLaneAllowedServices(7);
|
||||
|
||||
expect(mocks.request).not.toHaveBeenCalled();
|
||||
expect(response).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
lane_id: 7,
|
||||
allowed_services: [],
|
||||
skipped_empty_task_sync: true,
|
||||
},
|
||||
});
|
||||
expect(logic.allowedServices.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows a forced preview refresh for an already successful request key", async () => {
|
||||
mocks.previewAllowed
|
||||
.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
machine_available: true,
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
session: null,
|
||||
questions: [],
|
||||
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
allowed_services: ["MACHINE"],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
machine_available: true,
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
session: null,
|
||||
questions: [],
|
||||
tasks: [{ id: 6, task: "Pick program", order_priority: 1, services: ["PROGRAM_PICKER"] }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
allowed_services: ["PROGRAM_PICKER"],
|
||||
});
|
||||
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
|
||||
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
|
||||
|
||||
expect(mocks.previewAllowed).toHaveBeenCalledTimes(1);
|
||||
expect(logic.tasks.value.map((task) => task.id)).toEqual([5]);
|
||||
|
||||
await logic.fetchSelfServeData(1, 9, 7, "ab12345", { force: true });
|
||||
|
||||
expect(mocks.previewAllowed).toHaveBeenCalledTimes(2);
|
||||
expect(logic.tasks.value.map((task) => task.id)).toEqual([6]);
|
||||
expect(logic.allowedServices.value).toEqual(["PROGRAM_PICKER"]);
|
||||
});
|
||||
|
||||
it("publishes preview task ids before attachment hydration finishes", async () => {
|
||||
let resolveAttachments;
|
||||
mocks.previewAllowed.mockResolvedValue({
|
||||
allowed: true,
|
||||
machine_available: true,
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
session: null,
|
||||
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
|
||||
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
allowed_services: ["MACHINE"],
|
||||
});
|
||||
mocks.attachmentsList.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAttachments = resolve;
|
||||
})
|
||||
);
|
||||
mocks.request.mockResolvedValue({
|
||||
data: { success: true, data: { allowed_services: ["MACHINE"] } },
|
||||
});
|
||||
|
||||
const logic = useSelfServeLogic();
|
||||
const fetchPromise = logic.fetchSelfServeData(1, 9, 7, "ab12345");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(logic.tasks.value.map((task) => task.id)).toEqual([5]);
|
||||
expect(logic.allowedServices.value).toEqual(["MACHINE"]);
|
||||
|
||||
await logic.updateLaneAllowedServices(7);
|
||||
expect(mocks.request).toHaveBeenCalledWith("/modules/self-serve/lane/services/allowed", "post", {
|
||||
lane_id: 7,
|
||||
task_ids: [5],
|
||||
});
|
||||
|
||||
resolveAttachments([]);
|
||||
await fetchPromise;
|
||||
});
|
||||
|
||||
it("preserves preview task ids when wash summary omits task ids", async () => {
|
||||
mocks.previewAllowed.mockResolvedValue({
|
||||
allowed: true,
|
||||
machine_available: true,
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
session: { id: 91, status: "READY_FOR_MACHINE_START" },
|
||||
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
|
||||
tasks: [
|
||||
{
|
||||
id: 802001,
|
||||
task: "Vælg program #2 på dial",
|
||||
condition_id: 702001,
|
||||
order_priority: 10,
|
||||
services: ["MACHINE"],
|
||||
},
|
||||
{
|
||||
id: 802006,
|
||||
task: "Tryk knap #6 (tagbørste program)",
|
||||
condition_id: 702003,
|
||||
order_priority: 36,
|
||||
services: ["MACHINE"],
|
||||
},
|
||||
],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
allowed_services: ["MACHINE"],
|
||||
});
|
||||
mocks.washSummary.mockResolvedValue({
|
||||
session: { id: 91, status: "READY_FOR_MACHINE_START" },
|
||||
lane: { id: 7, name: "Lane 7" },
|
||||
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
|
||||
tasks: [
|
||||
{ task: "Vælg program #2 på dial", condition_id: 702001, order_priority: 10, services: ["MACHINE"] },
|
||||
{ task: "Tryk knap #6 (tagbørste program)", condition_id: 702003, order_priority: 36, services: ["MACHINE"] },
|
||||
],
|
||||
events: [],
|
||||
});
|
||||
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
|
||||
|
||||
expect(logic.tasks.value.map((task) => task.id)).toEqual([802001, 802006]);
|
||||
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([802001, 802006]);
|
||||
});
|
||||
|
||||
it("does not infer machine permission from summary tasks when allowed services are explicitly empty", async () => {
|
||||
const machineButtonTask = {
|
||||
id: 5,
|
||||
@@ -729,7 +510,6 @@ describe("useSelfServeLogic", () => {
|
||||
const logic = useSelfServeLogic();
|
||||
await logic.fetchSelfServeData(1, null, 7, "ab12345");
|
||||
|
||||
expect(logic.resolvedVehicleTypeId.value).toBe(5);
|
||||
expect(mocks.washSummary).toHaveBeenNthCalledWith(1, { session_id: 91 });
|
||||
expect(mocks.washSummary).toHaveBeenNthCalledWith(2, { lane_id: 7, reg: "AB12345", vehicle_type: 5 });
|
||||
});
|
||||
|
||||
@@ -165,138 +165,4 @@ describe("useWashDepartments", () => {
|
||||
lanes: [{ id: 7 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the first self-serve department with an enabled lane as fallback when coordinates are missing", async () => {
|
||||
mocks.locationRef.value = null;
|
||||
mocks.getDepartmentsGuest.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "Disabled module",
|
||||
address: "A",
|
||||
latitude: 55.6,
|
||||
longitude: 12.5,
|
||||
lanes: [{ id: 10, selfserve_enabled: true }],
|
||||
self_serve_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Disabled lane",
|
||||
address: "B",
|
||||
latitude: 55.7,
|
||||
longitude: 12.6,
|
||||
lanes: [{ id: 11, selfserve_enabled: false }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Fallback",
|
||||
address: "C",
|
||||
latitude: 55.8,
|
||||
longitude: 12.7,
|
||||
lanes: [
|
||||
{ id: 12, selfserve_enabled: "off" },
|
||||
{ id: 13, selfserve_enabled: true, products: [4] },
|
||||
],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const departments = mountDepartments({ includeLanes: true });
|
||||
|
||||
await departments.fetchDepartments();
|
||||
|
||||
expect(mocks.getDistance).not.toHaveBeenCalled();
|
||||
expect(departments.nearestDepartment.value).toMatchObject({
|
||||
id: 3,
|
||||
name: "Fallback",
|
||||
distance: null,
|
||||
lanes: [{ id: 13 }],
|
||||
});
|
||||
expect(departments.departmentSelectionStrategy.value).toBe("fallback");
|
||||
expect(departments.isDepartmentSelectionFallbackBased.value).toBe(true);
|
||||
expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the selected department for empty department responses", async () => {
|
||||
mocks.getDepartmentsGuest.mockResolvedValueOnce([
|
||||
{ id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true },
|
||||
]);
|
||||
const departments = mountDepartments();
|
||||
|
||||
await departments.fetchDepartments();
|
||||
expect(departments.nearestDepartment.value).toMatchObject({ id: 1 });
|
||||
|
||||
mocks.getDepartmentsGuest.mockResolvedValueOnce([]);
|
||||
await departments.fetchDepartments();
|
||||
|
||||
expect(departments.guestDepartments.value).toEqual([]);
|
||||
expect(departments.nearestDepartment.value).toBeNull();
|
||||
expect(departments.departmentSelectionStrategy.value).toBeNull();
|
||||
});
|
||||
|
||||
it("does not fallback to disabled departments when coordinates are missing", async () => {
|
||||
mocks.locationRef.value = { value: null };
|
||||
mocks.getDepartmentsGuest.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "Module disabled",
|
||||
address: "A",
|
||||
latitude: 55.6,
|
||||
longitude: 12.5,
|
||||
lanes: [{ id: 10, selfserve_enabled: true }],
|
||||
self_serve_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Lane disabled",
|
||||
address: "B",
|
||||
latitude: 55.7,
|
||||
longitude: 12.6,
|
||||
lanes: [{ id: 11, selfserve_enabled: "no" }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const departments = mountDepartments({ includeLanes: true });
|
||||
|
||||
await departments.fetchDepartments();
|
||||
|
||||
expect(departments.nearestDepartment.value).toBeNull();
|
||||
expect(departments.departmentSelectionStrategy.value).toBeNull();
|
||||
});
|
||||
|
||||
it("allows regular users to force a department when coordinates are missing", async () => {
|
||||
mocks.locationRef.value = {};
|
||||
mocks.getDepartmentsGuest.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "Fallback",
|
||||
address: "A",
|
||||
latitude: 55.6,
|
||||
longitude: 12.5,
|
||||
lanes: [{ id: 10 }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Manual choice",
|
||||
address: "B",
|
||||
latitude: 55.7,
|
||||
longitude: 12.6,
|
||||
lanes: [{ id: 11 }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const departments = mountDepartments({ canAccessSuperUser: () => false });
|
||||
|
||||
await departments.fetchDepartments();
|
||||
departments.startDepartmentSearch();
|
||||
departments.selectDepartment(2);
|
||||
|
||||
expect(departments.isSearchingDepartments.value).toBe(false);
|
||||
expect(departments.isForcingNearestDepartment.value).toBe(true);
|
||||
expect(departments.nearestDepartment.value).toMatchObject({ id: 2, name: "Manual choice" });
|
||||
expect(departments.departmentSelectionStrategy.value).toBe("forced");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useWashFlowState, WASH_STEPS } from "@/composables/useWashFlowState.js";
|
||||
|
||||
function createFlow(overrides = {}) {
|
||||
const state = {
|
||||
currentStep: ref(WASH_STEPS.VEHICLE),
|
||||
washInProgress: ref(false),
|
||||
customerNumberInput: ref("12345679"),
|
||||
licensePlateInput: ref("AB12345"),
|
||||
vehicleTypeSelect: ref(2),
|
||||
availableProductIds: ref([2]),
|
||||
radioLaneOption: ref(7),
|
||||
radioWashType: ref("Manual"),
|
||||
nearestDepartment: ref({ lanes: [{ id: 7, status: "AVAILABLE", machine_available: true }] }),
|
||||
allVisibleQuestionsAnswered: ref(true),
|
||||
isLoadingSelfServeData: ref(false),
|
||||
activeTasks: ref([]),
|
||||
completedTasks: ref({}),
|
||||
editAnswers: ref(true),
|
||||
isLaneAvailable: vi.fn((lane) => lane.status === "AVAILABLE"),
|
||||
isMachineAvailable: vi.fn(() => true),
|
||||
onStartWash: vi.fn(async () => true),
|
||||
updateLaneAllowedServices: vi.fn(async () => true),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { state, flow: useWashFlowState(state) };
|
||||
}
|
||||
|
||||
describe("useWashFlowState production transitions", () => {
|
||||
it("gates vehicle, question, lane, task, in-progress, and completed transitions", async () => {
|
||||
const { state, flow } = createFlow({ activeTasks: ref([{ id: 91 }]) });
|
||||
|
||||
state.vehicleTypeSelect.value = null;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
|
||||
state.vehicleTypeSelect.value = 2;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true);
|
||||
|
||||
state.currentStep.value = WASH_STEPS.QUESTIONS;
|
||||
state.allVisibleQuestionsAnswered.value = false;
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
state.allVisibleQuestionsAnswered.value = true;
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.editAnswers.value).toBe(false);
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.SELECT_LANE);
|
||||
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
|
||||
|
||||
expect(flow.clickableSteps[WASH_STEPS.SELECT_LANE]()).toBe(true);
|
||||
state.currentStep.value = WASH_STEPS.SELECT_LANE;
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345679", WASH_STEPS.TASKS);
|
||||
|
||||
state.currentStep.value = WASH_STEPS.TASKS;
|
||||
expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true);
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
|
||||
|
||||
state.washInProgress.value = true;
|
||||
expect(flow.clickableSteps[WASH_STEPS.WASH_IN_PROGRESS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.COMPLETED]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks lane confirmation for unavailable lanes and unavailable machine service", () => {
|
||||
const { state, flow } = createFlow();
|
||||
state.currentStep.value = WASH_STEPS.SELECT_LANE;
|
||||
|
||||
state.nearestDepartment.value.lanes[0].status = "OCCUPIED";
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
|
||||
state.nearestDepartment.value.lanes[0].status = "AVAILABLE";
|
||||
state.radioWashType.value = "Machine";
|
||||
state.isMachineAvailable.mockReturnValue(false);
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps lane confirmation enabled when the selected lane id is a string", async () => {
|
||||
const { state, flow } = createFlow({ radioLaneOption: ref("7") });
|
||||
state.currentStep.value = WASH_STEPS.SELECT_LANE;
|
||||
|
||||
expect(flow.isNextButtonDisabled()).toBe(false);
|
||||
|
||||
await flow.handleConfirmNext();
|
||||
|
||||
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345679", WASH_STEPS.WASH_IN_PROGRESS);
|
||||
});
|
||||
|
||||
it("stays on questions when lane allowed-service refresh fails", async () => {
|
||||
const { state, flow } = createFlow({
|
||||
updateLaneAllowedServices: vi.fn(async () => {
|
||||
throw new Error("edge down");
|
||||
}),
|
||||
});
|
||||
state.currentStep.value = WASH_STEPS.QUESTIONS;
|
||||
|
||||
await flow.handleConfirmNext();
|
||||
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.QUESTIONS);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ describe("useWashFlowState", () => {
|
||||
};
|
||||
};
|
||||
|
||||
it("validates the vehicle step against customer, registration, and selected vehicle type", () => {
|
||||
it("validates the vehicle step against customer, registration, and allowed product ids", () => {
|
||||
const { flow, state } = createState();
|
||||
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
|
||||
@@ -48,40 +48,9 @@ describe("useWashFlowState", () => {
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
|
||||
|
||||
state.vehicleTypeSelect.value = 9;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
|
||||
|
||||
state.vehicleTypeSelect.value = null;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the selected vehicle type when the department has no product allow-list", () => {
|
||||
const { flow, state } = createState({
|
||||
availableProductIds: ref([]),
|
||||
vehicleTypeSelect: ref(3),
|
||||
});
|
||||
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
|
||||
expect(flow.isNextButtonDisabled()).toBe(false);
|
||||
|
||||
state.vehicleTypeSelect.value = null;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require a customer number when the customer number input is hidden", () => {
|
||||
const { flow, state } = createState({
|
||||
customerNumberInput: ref(""),
|
||||
isCustomerNumberRequired: ref(false),
|
||||
});
|
||||
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
|
||||
expect(flow.isNextButtonDisabled()).toBe(false);
|
||||
|
||||
state.isCustomerNumberRequired.value = true;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("moves from questions to lane selection after updating allowed services", async () => {
|
||||
const { flow, state } = createState({
|
||||
currentStep: ref(WASH_STEPS.QUESTIONS),
|
||||
@@ -128,21 +97,4 @@ describe("useWashFlowState", () => {
|
||||
|
||||
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345", WASH_STEPS.TASKS);
|
||||
});
|
||||
|
||||
it("matches string lane selections against numeric lane ids", async () => {
|
||||
const { flow, state } = createState({
|
||||
currentStep: ref(WASH_STEPS.QUESTIONS),
|
||||
radioLaneOption: ref("7"),
|
||||
});
|
||||
|
||||
await flow.handleConfirmNext();
|
||||
|
||||
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.SELECT_LANE);
|
||||
expect(flow.isNextButtonDisabled()).toBe(false);
|
||||
|
||||
await flow.handleConfirmNext();
|
||||
|
||||
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345", WASH_STEPS.WASH_IN_PROGRESS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useWashSessionActions } from "@/composables/useWashSessionActions.js";
|
||||
import { WASH_STEPS } from "@/composables/useWashFlowState.js";
|
||||
|
||||
function createActions(overrides = {}) {
|
||||
const calls = [];
|
||||
const request = vi.fn(async (_url, _method, body) => {
|
||||
calls.push(body.command);
|
||||
return { data: { success: true } };
|
||||
});
|
||||
const state = {
|
||||
request,
|
||||
alertFn: vi.fn(),
|
||||
nearestDepartment: ref({ id: 6, lanes: [{ id: 7 }] }),
|
||||
vehicleTypeSelect: ref(2),
|
||||
washLaneId: ref(null),
|
||||
washInProgress: ref(false),
|
||||
washStartTime: ref(null),
|
||||
completedDurationMs: ref(null),
|
||||
now: ref(10_000),
|
||||
currentStep: ref(WASH_STEPS.SELECT_LANE),
|
||||
steps: WASH_STEPS,
|
||||
radioWashType: ref("Manual"),
|
||||
activeTasks: ref([]),
|
||||
completedTasks: ref({}),
|
||||
saveProgress: vi.fn(),
|
||||
clearProgress: vi.fn(),
|
||||
startElapsedTimer: vi.fn(),
|
||||
stopElapsedTimer: vi.fn(),
|
||||
updateLaneAllowedServices: vi.fn(async () => true),
|
||||
fetchWashSummary: vi.fn(async () => ({})),
|
||||
enableMachineRelay: vi.fn(async () => ({ data: { success: true } })),
|
||||
isServiceAllowed: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
return { state, actions: useWashSessionActions(state), calls };
|
||||
}
|
||||
|
||||
describe("useWashSessionActions production commands", () => {
|
||||
it("starts a manual wash successfully", async () => {
|
||||
const { state, actions } = createActions();
|
||||
|
||||
await expect(actions.onStartWash(7, "ab12345", "12345679")).resolves.toBe(true);
|
||||
|
||||
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
|
||||
expect(state.request).toHaveBeenCalledWith(
|
||||
"/modules/self-serve/lane/command",
|
||||
"post",
|
||||
expect.objectContaining({ command: "START", license_plate: "AB12345" })
|
||||
);
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
|
||||
expect(state.saveProgress).toHaveBeenCalledWith("onStartWash");
|
||||
});
|
||||
|
||||
it("reports START failure without mutating active wash state", async () => {
|
||||
const { state, actions } = createActions({
|
||||
request: vi.fn(async () => ({ data: { success: false, message: "START failed" } })),
|
||||
});
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false);
|
||||
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("START failed");
|
||||
expect(state.saveProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables machine relay after START when machine service is allowed", async () => {
|
||||
const { state, actions } = createActions({ radioWashType: ref("Machine") });
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true);
|
||||
|
||||
expect(state.enableMachineRelay).toHaveBeenCalledWith(7);
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back with STOP when machine relay enable fails", async () => {
|
||||
const { state, actions, calls } = createActions({
|
||||
radioWashType: ref("Machine"),
|
||||
enableMachineRelay: vi.fn(async () => {
|
||||
throw new Error("relay down");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false);
|
||||
|
||||
expect(calls).toEqual(["START", "STOP"]);
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("relay down");
|
||||
});
|
||||
|
||||
it("keeps active state and alerts when STOP fails", async () => {
|
||||
const request = vi.fn(async (_url, _method, body) =>
|
||||
body.command === "STOP" ? { data: { success: false, message: "STOP failed" } } : { data: { success: true } }
|
||||
);
|
||||
const { state, actions } = createActions({
|
||||
request,
|
||||
washInProgress: ref(true),
|
||||
washLaneId: ref(7),
|
||||
washStartTime: ref(1_000),
|
||||
});
|
||||
|
||||
await expect(actions.onStopWash(7)).resolves.toBe(false);
|
||||
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("STOP failed");
|
||||
expect(state.clearProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers local state when STOP says the lane is already not occupied", async () => {
|
||||
const request = vi.fn(async () => ({ data: { success: false, message: "Cannot stop lane 7: not occupied" } }));
|
||||
const { state, actions } = createActions({
|
||||
request,
|
||||
washInProgress: ref(true),
|
||||
washLaneId: ref(7),
|
||||
washStartTime: ref(1_000),
|
||||
now: ref(6_000),
|
||||
});
|
||||
|
||||
await expect(actions.onStopWash(7)).resolves.toBe(true);
|
||||
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.washLaneId.value).toBeNull();
|
||||
expect(state.completedDurationMs.value).toBe(5_000);
|
||||
expect(state.stopElapsedTimer).toHaveBeenCalled();
|
||||
expect(state.clearProgress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers local state when STOP says an unoccupied lane cannot invoice", async () => {
|
||||
const request = vi.fn(async () => ({
|
||||
data: { success: false, message: "Failed to execute command: Lane ID 7 is not occupied; cannot invoice." },
|
||||
}));
|
||||
const { state, actions } = createActions({
|
||||
request,
|
||||
washInProgress: ref(true),
|
||||
washLaneId: ref(7),
|
||||
washStartTime: ref(1_000),
|
||||
now: ref(6_000),
|
||||
});
|
||||
|
||||
await expect(actions.onStopWash(7)).resolves.toBe(true);
|
||||
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.washLaneId.value).toBeNull();
|
||||
expect(state.completedDurationMs.value).toBe(5_000);
|
||||
expect(state.stopElapsedTimer).toHaveBeenCalled();
|
||||
expect(state.clearProgress).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -207,7 +207,7 @@ describe("useWashSessionActions property gate commands", () => {
|
||||
command: "START",
|
||||
customer_number: 12345679,
|
||||
license_plate: "AB12345",
|
||||
defer_relay_side_effects: true,
|
||||
defer_relay_side_effects: false,
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
@@ -286,7 +286,7 @@ describe("useWashSessionActions property gate commands", () => {
|
||||
command: "START",
|
||||
customer_number: 12345679,
|
||||
license_plate: "AB12345",
|
||||
defer_relay_side_effects: true,
|
||||
defer_relay_side_effects: false,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
|
||||
lane_id: 7,
|
||||
@@ -346,7 +346,7 @@ describe("useWashSessionActions property gate commands", () => {
|
||||
command: "START",
|
||||
customer_number: 12345679,
|
||||
license_plate: "AB12345",
|
||||
defer_relay_side_effects: true,
|
||||
defer_relay_side_effects: false,
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
|
||||
lane_id: 7,
|
||||
@@ -446,51 +446,6 @@ describe("useWashSessionActions property gate commands", () => {
|
||||
expect(clearProgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears active wash state when stop reports the lane cannot invoice because it is unoccupied", async () => {
|
||||
const washInProgress = ref(true);
|
||||
const washLaneId = ref(7);
|
||||
const washStartTime = ref(2_000);
|
||||
const completedDurationMs = ref(null);
|
||||
const now = ref(47_000);
|
||||
const clearProgress = vi.fn();
|
||||
const stopElapsedTimer = vi.fn();
|
||||
const request = vi.fn(async (_url, _method, body) => {
|
||||
if (body.command === "STOP") {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
data: {
|
||||
message: "Failed to execute command: Lane ID 7 is not occupied; cannot invoice.",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { data: { success: true } };
|
||||
});
|
||||
|
||||
const { actions, alertFn } = createActions({
|
||||
request,
|
||||
washInProgress,
|
||||
washLaneId,
|
||||
washStartTime,
|
||||
completedDurationMs,
|
||||
now,
|
||||
clearProgress,
|
||||
stopElapsedTimer,
|
||||
});
|
||||
|
||||
const result = await actions.onStopWash(7);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(alertFn).not.toHaveBeenCalled();
|
||||
expect(completedDurationMs.value).toBe(45_000);
|
||||
expect(washInProgress.value).toBe(false);
|
||||
expect(washLaneId.value).toBeNull();
|
||||
expect(stopElapsedTimer).toHaveBeenCalledTimes(1);
|
||||
expect(clearProgress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears active wash state only after the stop command succeeds", async () => {
|
||||
const washInProgress = ref(true);
|
||||
const washLaneId = ref(7);
|
||||
|
||||
+11
-96
@@ -35,60 +35,6 @@ const PUBLIC_ASSET_ALIASES = [
|
||||
['favicons/web-app-manifest-192x192.png', 'assets/favicons/web-app-manifest-192x192.png'],
|
||||
['favicons/web-app-manifest-512x512.png', 'assets/favicons/web-app-manifest-512x512.png'],
|
||||
]
|
||||
const DEFAULT_RELEASE_FRONTEND_BASE_PATH = './'
|
||||
|
||||
function normalizeReleaseFrontendBasePath(value) {
|
||||
const rawValue = String(value || '').trim()
|
||||
let basePath = rawValue
|
||||
|
||||
if (/^https?:\/\//i.test(rawValue)) {
|
||||
try {
|
||||
basePath = new URL(rawValue).pathname
|
||||
} catch {
|
||||
basePath = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (!basePath || basePath === '/') {
|
||||
return '/'
|
||||
}
|
||||
if (basePath === './') {
|
||||
return './'
|
||||
}
|
||||
|
||||
return `/${basePath.replace(/^\/+|\/+$/g, '')}/`
|
||||
}
|
||||
|
||||
function releaseFrontendBasePath() {
|
||||
return normalizeReleaseFrontendBasePath(
|
||||
process.env.VITE_FRONTEND_BASE_PATH ||
|
||||
process.env.VITE_BASE_PATH ||
|
||||
process.env.RELEASE_FRONTEND_BASE_PATH ||
|
||||
DEFAULT_RELEASE_FRONTEND_BASE_PATH
|
||||
)
|
||||
}
|
||||
|
||||
function releaseManifestPath(pathname) {
|
||||
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`
|
||||
const basePath = releaseFrontendBasePath()
|
||||
if (basePath === '/' || basePath === './') {
|
||||
return normalizedPathname
|
||||
}
|
||||
|
||||
const basePrefix = basePath.replace(/\/+$/g, '')
|
||||
if (normalizedPathname === basePrefix) {
|
||||
return '/'
|
||||
}
|
||||
if (normalizedPathname.startsWith(`${basePrefix}/`)) {
|
||||
return normalizedPathname.slice(basePrefix.length) || '/'
|
||||
}
|
||||
|
||||
return normalizedPathname
|
||||
}
|
||||
|
||||
function releaseManifestAssetPath(value) {
|
||||
return String(value || '').replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
function getGitCommit() {
|
||||
const envCommit = firstReleaseCommitEnv()
|
||||
@@ -152,7 +98,7 @@ function releaseUrlPath(value, basePath = '/') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return releaseManifestPath(url.pathname)
|
||||
return url.pathname
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
@@ -160,9 +106,7 @@ function releaseUrlPath(value, basePath = '/') {
|
||||
|
||||
function releaseManifestDistPath(outputDirectory, urlPath) {
|
||||
try {
|
||||
const normalizedPath = decodeURIComponent(
|
||||
releaseManifestPath(new URL(urlPath, 'https://release.local').pathname)
|
||||
).replace(/^\/+/, '')
|
||||
const normalizedPath = decodeURIComponent(new URL(urlPath, 'https://release.local').pathname).replace(/^\/+/, '')
|
||||
const resolvedPath = path.resolve(outputDirectory, normalizedPath)
|
||||
const resolvedOutputDirectory = path.resolve(outputDirectory)
|
||||
if (
|
||||
@@ -186,32 +130,6 @@ function releaseManifestHash(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function releaseDistAssetUrls(outputDirectory) {
|
||||
const assetUrls = []
|
||||
const walk = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolutePath = path.join(directory, entry.name)
|
||||
const relativePath = path.relative(outputDirectory, absolutePath).replace(/\\/g, '/')
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolutePath)
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue
|
||||
}
|
||||
if (relativePath === 'release-manifest.json' || relativePath === '.htaccess') {
|
||||
continue
|
||||
}
|
||||
|
||||
assetUrls.push(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
walk(outputDirectory)
|
||||
return assetUrls
|
||||
}
|
||||
|
||||
function patchBuefyCssMediaQuery() {
|
||||
return {
|
||||
name: 'patch-buefy-css-media-query',
|
||||
@@ -232,7 +150,7 @@ function patchBuefyCssMediaQuery() {
|
||||
nextCode = nextCode.replaceAll(invalidSnippet, fixedSnippet)
|
||||
didPatch = true
|
||||
}
|
||||
|
||||
|
||||
if (!didPatch) {
|
||||
return null
|
||||
}
|
||||
@@ -357,14 +275,12 @@ function releaseMetadataManifest() {
|
||||
const releaseEntry = JSON.parse(fs.readFileSync(releaseEntryPath, 'utf8'))
|
||||
const indexHtml = fs.readFileSync(indexPath, 'utf8')
|
||||
const rootFiles = fs.readdirSync(outputDirectory)
|
||||
const emittedAssetUrls = releaseDistAssetUrls(outputDirectory)
|
||||
const indexAssetUrls = Array.from(indexHtml.matchAll(/\b(?:href|src)=["']([^"']+)["']/g))
|
||||
.map((match) => releaseUrlPath(match[1], '/index.html'))
|
||||
.map(releaseManifestAssetPath)
|
||||
.filter(Boolean)
|
||||
const releaseEntryAssetUrls = [
|
||||
releaseEntry.entry ? releaseManifestAssetPath(releaseEntry.entry) : '',
|
||||
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map(releaseManifestAssetPath) : [])
|
||||
releaseEntry.entry ? `/${releaseEntry.entry}` : '',
|
||||
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map((fileName) => `/${fileName}`) : [])
|
||||
].filter(Boolean)
|
||||
const pwaAssetUrls = [
|
||||
'manifest.json',
|
||||
@@ -378,7 +294,7 @@ function releaseMetadataManifest() {
|
||||
...rootFiles.filter((fileName) => /^workbox-[^/]+\.js$/.test(fileName))
|
||||
]
|
||||
.filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName)))
|
||||
.map((fileName) => releaseManifestAssetPath(fileName.replace(/\\/g, '/')))
|
||||
.map((fileName) => `/${fileName.replace(/\\/g, '/')}`)
|
||||
|
||||
for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) {
|
||||
const absoluteManifestPath = path.join(outputDirectory, manifestPath)
|
||||
@@ -391,7 +307,7 @@ function releaseMetadataManifest() {
|
||||
for (const icon of pwaManifest.icons || []) {
|
||||
const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`)
|
||||
if (iconPath) {
|
||||
pwaAssetUrls.push(releaseManifestAssetPath(iconPath))
|
||||
pwaAssetUrls.push(iconPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -400,9 +316,8 @@ function releaseMetadataManifest() {
|
||||
}
|
||||
|
||||
const criticalAssetUrls = Array.from(new Set([
|
||||
'index.html',
|
||||
'release-entry.json',
|
||||
...emittedAssetUrls,
|
||||
'/index.html',
|
||||
'/release-entry.json',
|
||||
...indexAssetUrls,
|
||||
...releaseEntryAssetUrls,
|
||||
...pwaAssetUrls
|
||||
@@ -524,8 +439,8 @@ export default defineConfig(({ mode }) => {
|
||||
process.env.APP_VERSION = version
|
||||
process.env.IS_DEV = !isProd ? 'true' : 'false'
|
||||
// Server version updates happen after deploy verification, not during asset builds.
|
||||
// Keep production assets host-agnostic. Release channel prefixes are resolved at runtime.
|
||||
const base = isProd ? releaseFrontendBasePath() : '/'
|
||||
// Keep a relative base for static hosting; align manifest with base
|
||||
const base = '/'
|
||||
const pwaScope = base === './' ? '.' : base
|
||||
|
||||
// Enable single-file build only when explicitly requested (disabled by default for PWA)
|
||||
|
||||
Reference in New Issue
Block a user