## Summary
Closes (heuristic for) **TRU-99 / SENERE 7**: warn operators in the
customer portal wash flow when a customer is flagged as a red car.
## Detection rule — heuristic, Mads to refine
The canonical rule (plate scan vs. red-car tag) is still TBD by Mads. As
a sensible default that fits the existing data model, this PR uses a
**manual customer-attribute flag**:
- If a customer has one of the configurable attribute keys set, the
warning fires. Default keys: `isRedCar`, `is_red_car`, `redCar`,
`red_car`.
- Detection is **case-insensitive** and the key list is **configurable**
(callers can override) so the rule can be tightened later without
touching the UI.
- Detection lives in `src/composables/redCarDetector.js`; reactive
loading lives in `src/composables/useRedCarWarning.js`.
- The composable reuses the existing `/customer/attributes` endpoint via
`customerAttributeService.js` — no backend change needed.
## UI
- New `RedCarWarning.vue` component renders a dismissable Buefy warning
in the vehicle step of the self-serve flow, with title + reason + care
suggestion.
- Wired into `MyWashStart.vue` via the existing `VehicleInputSection` /
`SelfServeVehicleStep` props. The composable is called with the
effective customer number (authenticated subuser or typed-in).
- Translations added in all 5 locales: `da`, `en`, `sv`, `de`, `no`
(source + regenerated runtime files).
## Tests
- 22 detector unit tests (positive, negative, case-insensitive, custom
keys, dedupe, normalisation).
- 5 i18n key presence tests across all 5 locales.
- 4 `RedCarWarning` component tests (conditional render, dismiss
wiring).
All new + existing related tests pass: `vitest run` on
`red-car-detector`, `red-car-warning-i18n`, `red-car-warning`,
`my-wash-start`, `customer-rule-registry`, `customer-rule-tooltip` →
**84/84 green**.
## Out of scope / not touched
- Backend / API: reused existing `/customer/attributes` endpoint.
- `openclaw.json`, deployment, merge — not touched (per task
constraints).
- Customer-rule registry: not added to `CUSTOMER_RULE_DEFINITIONS`
because the red-car flag is a soft warning, not a product-restriction
rule. If Mads wants it surfaced in the customer rule manager UI, that is
a follow-up.
## Files
- `src/composables/redCarDetector.js` (new)
- `src/composables/useRedCarWarning.js` (new)
- `src/components/displays/selfServe/RedCarWarning.vue` (new)
- `src/components/displays/selfServe/SelfServeVehicleStep.vue` (prop +
render)
-
`src/views/dashboards/userDashboard/wash/components/VehicleInputSection.vue`
(prop pass-through)
- `src/views/dashboards/userDashboard/wash/MyWashStart.vue` (composable
+ prop binding)
- `src/i18n/source/{da,en,sv,de,no}/phrases/compat/self_wash/index.json`
(new keys)
- `src/i18n/generated/{da,en,sv,de,no}-v2.json` (regenerated)
- `tests/unit/red-car-detector.spec.js` (new)
- `tests/unit/red-car-warning-i18n.spec.js` (new)
- `tests/unit/red-car-warning.spec.js` (new)
Refs: TRU-99
---------
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: Frontend Subagent <frontend-agent@openclaw.local>
Co-authored-by: Pleno Bugfix Bot <bugfix-bot@pleno.local>
2838 lines
88 KiB
Vue
2838 lines
88 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
import { BButton, BMessage, BStepItem, BSteps } from "buefy";
|
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
|
|
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
|
|
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
|
import { setShowFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue";
|
|
import SelfServeDepartmentHeader from "@/components/displays/selfServe/SelfServeDepartmentHeader.vue";
|
|
import SelfServeQuestionsStep from "@/components/displays/selfServe/SelfServeQuestionsStep.vue";
|
|
import SelfServeTasksStep from "@/components/displays/selfServe/SelfServeTasksStep.vue";
|
|
import SelfServeGuidedInstructions from "@/components/displays/selfServe/SelfServeGuidedInstructions.vue";
|
|
import SelfServeCompletedStep from "@/components/displays/selfServe/SelfServeCompletedStep.vue";
|
|
import VehicleInputSection from "./components/VehicleInputSection.vue";
|
|
import LaneSelectionSection from "./components/LaneSelectionSection.vue";
|
|
import WashTypeSelector from "./components/WashTypeSelector.vue";
|
|
import ErrorBanner from "./components/ErrorBanner.vue";
|
|
import WashProgressCard from "./components/WashProgressCard.vue";
|
|
import { guidedWashFlowSteps } from "@/constants/guidedWashFlowSteps";
|
|
import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
|
|
import { useWashDepartments } from "@/composables/useWashDepartments";
|
|
import { useWashProgress } from "@/composables/useWashProgress";
|
|
import { useWashFlowState } from "@/composables/useWashFlowState";
|
|
import { useWashSessionActions } from "@/composables/useWashSessionActions";
|
|
import { useRedCarWarning } from "@/composables/useRedCarWarning.js";
|
|
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
|
|
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
|
|
|
type VehicleTypeTemplate = {
|
|
id: number;
|
|
name: string;
|
|
price: number;
|
|
product?: PosProduct;
|
|
selected?: boolean;
|
|
loading?: boolean;
|
|
};
|
|
|
|
const customerNumberInput = ref<string | null>(null);
|
|
const licensePlateInput = ref<string | null>(null);
|
|
const vehicleTypeSelect = ref<number | null>(null);
|
|
const vehicleTypes = ref<any[]>([]);
|
|
const customerVehicles = ref<any[]>([]);
|
|
const isCustomerVehiclesLoading = ref(false);
|
|
const washInProgress = ref(false);
|
|
const washLaneId = ref<number | null>(null);
|
|
const washStartTime = ref<number | null>(null);
|
|
const radioWashType = ref("Manual");
|
|
const radioLaneOption = ref<any>("Any");
|
|
const editAnswers = ref(false);
|
|
const currentGuidedWashStep = ref(0);
|
|
const currentStep = ref(0);
|
|
const hideDynamicImage = ref(false);
|
|
const vehicleStepError = ref<string | null>(null);
|
|
const washActionError = ref<string | null>(null);
|
|
const questionStepError = ref<string | null>(null);
|
|
const answerSyncError = ref<string | null>(null);
|
|
const pendingQuestionSyncs = ref<number[]>([]);
|
|
const failedQuestionSyncs = ref<number[]>([]);
|
|
const isSelfServeRetrying = ref(false);
|
|
const isCompletingWash = ref(false);
|
|
const isVehicleStepNextLoading = ref(false);
|
|
const hasStartFormUserInput = ref(false);
|
|
const hasExplicitWashTypeSelection = ref(false);
|
|
const hasCompletedGuidedWash = ref(false);
|
|
const hasRecentlyCompletedWash = ref(false);
|
|
const shouldRestoreServerActiveWash = ref(false);
|
|
const isRestoringServerActiveWash = ref(false);
|
|
const isSyncingActiveWash = ref(false);
|
|
const MIN_FINISHING_SCREEN_MS = 250;
|
|
const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";
|
|
const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;
|
|
const STOPPING_WASH_KEY = "mywash_stopping_v1";
|
|
const STOPPING_WASH_TTL_MS = 2 * 60 * 1000;
|
|
const ACTIVE_WASH_REFRESH_MS = 5 * 1000;
|
|
const WASH_START_SERVER_SYNC_GRACE_MS = 10 * 1000;
|
|
const SELF_SERVE_FETCH_DEBOUNCE_MS = 300;
|
|
const STOP_FAILURE_COMPLETION_CHECK_ATTEMPTS = 5;
|
|
const STOP_FAILURE_COMPLETION_CHECK_MS = 750;
|
|
const SERVER_TASK_RESTORE_STATUSES = new Set(["MACHINE_RELAY_ENABLED"]);
|
|
const SERVER_MACHINE_WASH_STATUSES = new Set(["MACHINE_RELAY_ENABLED", "MACHINE_STARTED"]);
|
|
|
|
const readRecentlyCompletedWash = () => {
|
|
try {
|
|
const raw = localStorage.getItem(RECENT_COMPLETED_WASH_KEY);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw);
|
|
const completedAt = Number(parsed?.completedAt || 0);
|
|
if (!completedAt || Date.now() - completedAt > RECENT_COMPLETED_WASH_SUPPRESSION_MS) {
|
|
localStorage.removeItem(RECENT_COMPLETED_WASH_KEY);
|
|
return null;
|
|
}
|
|
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const normalizePositiveInteger = (value: any) => {
|
|
const parsed = parseInt(String(value ?? ""), 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
};
|
|
|
|
const normalizeServerSessionStatus = (status: any) =>
|
|
String(status || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
const getStoredSubuserCustomerNumber = () => {
|
|
try {
|
|
if (localStorage.getItem("is_subuser") !== "true") {
|
|
return null;
|
|
}
|
|
|
|
return normalizePositiveInteger(localStorage.getItem("selected_customer_number"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getSelectedSubuserCustomerNumber = () =>
|
|
normalizePositiveInteger(SessionUser.subuser?.selectedGrantCustomerNumber?.value) ?? getStoredSubuserCustomerNumber();
|
|
|
|
const getAuthenticatedCustomerNumber = () =>
|
|
normalizePositiveInteger(SessionUser.user.customer_number.value) ?? getSelectedSubuserCustomerNumber();
|
|
|
|
const getAuthenticatedSubuserId = () => {
|
|
try {
|
|
if (localStorage.getItem("is_subuser") !== "true") {
|
|
return null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return normalizePositiveInteger(SessionUser.subuser?.id?.value);
|
|
};
|
|
|
|
const resolveEffectiveCustomerNumber = (candidate: any = null) =>
|
|
getAuthenticatedCustomerNumber() ?? normalizePositiveInteger(candidate);
|
|
|
|
const normalizeMarkerReg = (value: any) =>
|
|
String(value || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
const clearStoppingWashMarker = () => {
|
|
try {
|
|
localStorage.removeItem(STOPPING_WASH_KEY);
|
|
} catch {}
|
|
};
|
|
|
|
const readStoppingWashMarker = () => {
|
|
try {
|
|
const raw = localStorage.getItem(STOPPING_WASH_KEY);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw);
|
|
const startedAt = Number(parsed?.startedAt || 0);
|
|
if (!startedAt || Date.now() - startedAt > STOPPING_WASH_TTL_MS) {
|
|
clearStoppingWashMarker();
|
|
return null;
|
|
}
|
|
|
|
return parsed;
|
|
} catch {
|
|
clearStoppingWashMarker();
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const markStoppingWash = (laneId: number | string | null) => {
|
|
const normalizedLaneId = normalizePositiveInteger(laneId);
|
|
if (!normalizedLaneId) {
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
laneId: normalizedLaneId,
|
|
reg: normalizeMarkerReg(licensePlateInput.value),
|
|
customerNumber: resolveEffectiveCustomerNumber(customerNumberInput.value),
|
|
startedAt: Date.now(),
|
|
};
|
|
|
|
try {
|
|
localStorage.setItem(STOPPING_WASH_KEY, JSON.stringify(payload));
|
|
} catch {}
|
|
};
|
|
|
|
const doesStoppingWashMarkerMatch = (
|
|
marker: any,
|
|
candidate: { laneId?: any; reg?: any; customerNumber?: any } | null
|
|
) => {
|
|
if (!marker || !candidate) {
|
|
return false;
|
|
}
|
|
|
|
const markerLaneId = normalizePositiveInteger(marker?.laneId);
|
|
const candidateLaneId = normalizePositiveInteger(candidate?.laneId);
|
|
if (!markerLaneId || !candidateLaneId || markerLaneId !== candidateLaneId) {
|
|
return false;
|
|
}
|
|
|
|
const markerReg = normalizeMarkerReg(marker?.reg);
|
|
const candidateReg = normalizeMarkerReg(candidate?.reg);
|
|
if (markerReg && candidateReg && markerReg !== candidateReg) {
|
|
return false;
|
|
}
|
|
|
|
const markerCustomerNumber = normalizePositiveInteger(marker?.customerNumber);
|
|
const candidateCustomerNumber = normalizePositiveInteger(candidate?.customerNumber);
|
|
return !markerCustomerNumber || !candidateCustomerNumber || markerCustomerNumber === candidateCustomerNumber;
|
|
};
|
|
|
|
const restoreStoppingWashIfMatched = (
|
|
candidate: { laneId?: any; reg?: any; customerNumber?: any } | null
|
|
) => {
|
|
const marker = readStoppingWashMarker();
|
|
if (!marker) {
|
|
return false;
|
|
}
|
|
|
|
if (!doesStoppingWashMarkerMatch(marker, candidate)) {
|
|
return false;
|
|
}
|
|
|
|
isCompletingWash.value = true;
|
|
currentStep.value = steps.WASH_IN_PROGRESS;
|
|
return true;
|
|
};
|
|
|
|
const applyEffectiveCustomerNumberInput = (candidate: any = null) => {
|
|
const effectiveCustomerNumber = resolveEffectiveCustomerNumber(candidate);
|
|
customerNumberInput.value = effectiveCustomerNumber ? String(effectiveCustomerNumber) : "";
|
|
return effectiveCustomerNumber;
|
|
};
|
|
|
|
const {
|
|
guestDepartments,
|
|
nearestDepartment,
|
|
isForcingNearestDepartment,
|
|
forceNearestDepartmentEvaluationId,
|
|
isSearchingDepartments,
|
|
departmentFetchError,
|
|
isDepartmentSelectionFallbackBased,
|
|
availableProductIds,
|
|
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
|
fetchDepartments,
|
|
evaluateLocationDepartments,
|
|
startDepartmentSearch,
|
|
selectDepartment,
|
|
clearForcedDepartment,
|
|
startAutoRefresh,
|
|
stopAutoRefresh,
|
|
} = useWashDepartments({
|
|
includeLanes: true,
|
|
refreshIntervalMs: 10 * 1000,
|
|
canAccessSuperUser: () => SessionUser.canAccessSuperUser() || SessionUser.canAccessDeveloper(),
|
|
});
|
|
|
|
const {
|
|
loading: isLoadingSelfServeData,
|
|
lane: selfServeLane,
|
|
answers,
|
|
completedTasks,
|
|
allowedServices,
|
|
resolvedVehicleTypeId: selfServeResolvedVehicleTypeId,
|
|
session: selfServeSession,
|
|
error: selfServeDataError,
|
|
visibleQuestions,
|
|
activeTasks,
|
|
allVisibleQuestionsAnswered: selfServeQuestionsAnswered,
|
|
machineAvailable: selfServeMachineAvailable,
|
|
allowed: selfServeAllowed,
|
|
fetchSelfServeData: fetchSelfServeDataInternal,
|
|
fetchWashSummary,
|
|
cancelSelfServeFetch: cancelSelfServeFetchInternal,
|
|
syncVehicleAnswer,
|
|
isServiceAllowed,
|
|
updateLaneAllowedServices,
|
|
enableMachineRelay,
|
|
downloadAttachment,
|
|
answerQuestion,
|
|
} = useSelfServeLogic();
|
|
|
|
const {
|
|
completedDurationMs,
|
|
now,
|
|
isRestoring,
|
|
formattedElapsed,
|
|
saveProgress,
|
|
clearProgress,
|
|
restoreProgress,
|
|
startElapsedTimer,
|
|
stopElapsedTimer,
|
|
registerBeforeUnload,
|
|
markDestroying,
|
|
} = useWashProgress({
|
|
currentStep,
|
|
washInProgress,
|
|
washLaneId,
|
|
washStartTime,
|
|
licensePlateInput,
|
|
vehicleTypeSelect,
|
|
radioWashType,
|
|
radioLaneOption,
|
|
customerNumberInput,
|
|
answers,
|
|
completedTasks,
|
|
nearestDepartmentId: () => nearestDepartment.value?.id || null,
|
|
forceNearestDepartmentEvaluationId,
|
|
isForcingNearestDepartment,
|
|
applyRestoredState: (savedProgress) => {
|
|
washInProgress.value = savedProgress.washInProgress;
|
|
washLaneId.value = savedProgress.washLaneId;
|
|
washStartTime.value = savedProgress.washStartTime;
|
|
licensePlateInput.value = savedProgress.licensePlateInput;
|
|
vehicleTypeSelect.value = savedProgress.vehicleTypeSelect;
|
|
radioWashType.value = savedProgress.radioWashType || "Manual";
|
|
radioLaneOption.value = savedProgress.radioLaneOption || "Any";
|
|
applyEffectiveCustomerNumberInput(savedProgress.customerNumberInput);
|
|
isForcingNearestDepartment.value = !!savedProgress.isForcingNearestDepartment;
|
|
forceNearestDepartmentEvaluationId.value = savedProgress.forceNearestDepartmentEvaluationId || 0;
|
|
|
|
if (washInProgress.value && savedProgress.nearestDepartmentId) {
|
|
forceNearestDepartmentEvaluationId.value = savedProgress.nearestDepartmentId;
|
|
}
|
|
|
|
evaluateLocationDepartments(locations.location.value);
|
|
answers.value = savedProgress.answers || {};
|
|
completedTasks.value = savedProgress.completedTasks || {};
|
|
currentStep.value = savedProgress.currentStep ?? 0;
|
|
},
|
|
});
|
|
|
|
const isLaneSelfServeEnabled = (lane: any) =>
|
|
!(
|
|
lane?.selfserve_enabled === false ||
|
|
lane?.selfserve_enabled === 0 ||
|
|
lane?.selfserve_enabled === "0" ||
|
|
["false", "off", "no"].includes(
|
|
String(lane?.selfserve_enabled ?? "")
|
|
.trim()
|
|
.toLowerCase()
|
|
)
|
|
);
|
|
|
|
const isCurrentActiveWashLane = (lane: any) =>
|
|
washInProgress.value && normalizeLaneId(lane?.id) === normalizeLaneId(washLaneId.value);
|
|
|
|
const isAllowedSelfServeValue = (value: any) =>
|
|
value === true ||
|
|
value === 1 ||
|
|
value === "1" ||
|
|
String(value ?? "")
|
|
.trim()
|
|
.toLowerCase() === "true";
|
|
|
|
const isExplicitFalseSelfServeValue = (value: any) =>
|
|
value === false ||
|
|
value === 0 ||
|
|
value === "0" ||
|
|
["false", "off", "no"].includes(
|
|
String(value ?? "")
|
|
.trim()
|
|
.toLowerCase()
|
|
);
|
|
|
|
const isSelfServeSessionAllowed = () =>
|
|
isAllowedSelfServeValue(selfServeAllowed.value) || isAllowedSelfServeValue(selfServeSession.value?.allowed);
|
|
|
|
const getAllowedSelfServeLaneIds = () =>
|
|
new Set(
|
|
[
|
|
selfServeLane.value?.id,
|
|
selfServeSession.value?.lane_id,
|
|
selfServeSession.value?.lane?.id,
|
|
selfServeSession.value?.metadata?.lane_id,
|
|
]
|
|
.map(normalizeLaneId)
|
|
.filter((laneId): laneId is number => laneId !== null)
|
|
);
|
|
|
|
const isCurrentAllowedSelfServeLane = (lane: any) => {
|
|
const laneId = normalizeLaneId(lane?.id);
|
|
return laneId !== null && isSelfServeSessionAllowed() && getAllowedSelfServeLaneIds().has(laneId);
|
|
};
|
|
|
|
const isLaneAvailable = (lane: any) => {
|
|
if (isCurrentActiveWashLane(lane) || isCurrentAllowedSelfServeLane(lane)) {
|
|
return isLaneSelfServeEnabled(lane);
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(lane || {}, "selfserve_available")) {
|
|
return isAllowedSelfServeValue(lane?.selfserve_available);
|
|
}
|
|
|
|
return lane?.status === "AVAILABLE" && isLaneSelfServeEnabled(lane);
|
|
};
|
|
|
|
const isMachineAvailable = (laneId: number | string | null) => {
|
|
if (parseInt(selfServeLane.value?.id || 0) === parseInt(laneId || 0)) {
|
|
return (
|
|
isLaneSelfServeEnabled(selfServeLane.value) &&
|
|
!isExplicitFalseSelfServeValue(selfServeLane.value?.selfserve_available) &&
|
|
selfServeMachineAvailable.value === true &&
|
|
allowedServices.value.includes("MACHINE")
|
|
);
|
|
}
|
|
|
|
const selectedLane = nearestDepartment.value?.lanes.find(
|
|
(entry: { id: number }) => normalizeLaneId(entry.id) === normalizeLaneId(laneId)
|
|
);
|
|
if (!selectedLane) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
isLaneSelfServeEnabled(selectedLane) &&
|
|
!isExplicitFalseSelfServeValue(selectedLane.selfserve_available) &&
|
|
selectedLane.machine_available === true &&
|
|
allowedServices.value.includes("MACHINE")
|
|
);
|
|
};
|
|
|
|
const normalizeTaskServices = (task: any) =>
|
|
Array.isArray(task?.services)
|
|
? task.services
|
|
.map((service: any) =>
|
|
String(service || "")
|
|
.trim()
|
|
.toUpperCase()
|
|
)
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const taskButtonList = (task: any) => getSelfServeTaskDynamicImageButtons(task);
|
|
|
|
const taskIdList = (taskList: any[]) =>
|
|
taskList.map((task: any) => parseInt(String(task?.task_id ?? task?.id ?? 0), 10)).filter(Boolean);
|
|
|
|
const dynamicImageVehicleType = (task: any) => {
|
|
const value = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
|
|
if (value === null || value === undefined || value === "") {
|
|
return null;
|
|
}
|
|
|
|
const parsed = Number.parseInt(String(value), 10);
|
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
};
|
|
|
|
const isDynamicImageTask = (task: any) => taskButtonList(task).length > 0 || dynamicImageVehicleType(task) !== null;
|
|
|
|
const isLegacyMachineButtonTask = (task: any) => {
|
|
if (normalizeTaskServices(task).length > 0 || isDynamicImageTask(task)) {
|
|
return false;
|
|
}
|
|
|
|
const label = `${task?.task || ""} ${task?.description || ""}`.toLowerCase();
|
|
return /(reset|start|program|button\s*\d+|knap\s*\d+|tagbørste|tagborste|#\s*\d+)/i.test(label);
|
|
};
|
|
|
|
const isMachineTask = (task: any) => {
|
|
const hasMachineService = normalizeTaskServices(task).includes("MACHINE");
|
|
|
|
return hasMachineService || isDynamicImageTask(task) || isLegacyMachineButtonTask(task);
|
|
};
|
|
|
|
const hasMachineTasks = computed(() => activeTasks.value.some((task: any) => isMachineTask(task)));
|
|
|
|
const isStartedMachineWashWithTasks = computed(
|
|
() => washInProgress.value && radioWashType.value === "Machine" && hasMachineTasks.value
|
|
);
|
|
|
|
const isMachineWashSelectedAndAllowed = computed(
|
|
() =>
|
|
radioWashType.value === "Machine" &&
|
|
(isMachineAvailable(radioLaneOption.value) || isStartedMachineWashWithTasks.value)
|
|
);
|
|
|
|
const displayedActiveTasks = computed(() => {
|
|
if (isMachineWashSelectedAndAllowed.value) {
|
|
return activeTasks.value;
|
|
}
|
|
|
|
return activeTasks.value.filter((task: any) => !isMachineTask(task));
|
|
});
|
|
|
|
const shouldDefaultToMachineWash = computed(
|
|
() =>
|
|
!hasExplicitWashTypeSelection.value &&
|
|
!washInProgress.value &&
|
|
isMachineAvailable(radioLaneOption.value) &&
|
|
hasMachineTasks.value
|
|
);
|
|
|
|
async function syncLaneAllowedServicesForSelection(laneId: number | string | null) {
|
|
if (displayedActiveTasks.value.length === 0 || allowedServices.value.length === 0) {
|
|
await fetchSelfServeData({ immediate: true, force: true });
|
|
}
|
|
|
|
return updateLaneAllowedServices(laneId, { taskIds: taskIdList(displayedActiveTasks.value) });
|
|
}
|
|
|
|
const {
|
|
dynamicImageUrl,
|
|
onStartWash,
|
|
onStopWash,
|
|
openPropertyAccessGate,
|
|
openPropertyExitGate,
|
|
openingPropertyAccessGate,
|
|
openingPropertyExitGate,
|
|
isStartingWash,
|
|
} = useWashSessionActions({
|
|
request: SessionUser.request,
|
|
alertFn: (message: string) => {
|
|
washActionError.value = message;
|
|
},
|
|
nearestDepartment,
|
|
vehicleTypeSelect,
|
|
washLaneId,
|
|
washInProgress,
|
|
washStartTime,
|
|
completedDurationMs,
|
|
now,
|
|
currentStep,
|
|
steps: { VEHICLE: 0, QUESTIONS: 1, SELECT_LANE: 2, TASKS: 3, WASH_IN_PROGRESS: 4, COMPLETED: 5 },
|
|
radioWashType,
|
|
activeTasks: displayedActiveTasks,
|
|
completedTasks,
|
|
saveProgress,
|
|
clearProgress,
|
|
startElapsedTimer,
|
|
stopElapsedTimer,
|
|
updateLaneAllowedServices: syncLaneAllowedServicesForSelection,
|
|
fetchWashSummary,
|
|
enableMachineRelay,
|
|
isServiceAllowed,
|
|
});
|
|
|
|
const normalizeQuestionId = (questionId: any) => Number.parseInt(String(questionId ?? 0), 10);
|
|
|
|
const visibleQuestionIds = computed(
|
|
() =>
|
|
new Set(
|
|
visibleQuestions.value
|
|
.map((question: any) => normalizeQuestionId(question?.id))
|
|
.filter((questionId: number) => Number.isInteger(questionId) && questionId > 0)
|
|
)
|
|
);
|
|
|
|
const hasVisiblePendingQuestionSync = computed(() =>
|
|
pendingQuestionSyncs.value.some((questionId) => visibleQuestionIds.value.has(questionId))
|
|
);
|
|
|
|
const hasVisibleFailedQuestionSync = computed(() =>
|
|
failedQuestionSyncs.value.some((questionId) => visibleQuestionIds.value.has(questionId))
|
|
);
|
|
|
|
const hasBlockingVisibleQuestionSync = computed(
|
|
() => hasVisiblePendingQuestionSync.value || hasVisibleFailedQuestionSync.value
|
|
);
|
|
|
|
const allVisibleQuestionsAnswered = computed(
|
|
() =>
|
|
!hasBlockingVisibleQuestionSync.value &&
|
|
(selfServeQuestionsAnswered.value ||
|
|
visibleQuestions.value.every(
|
|
(question) => answers.value[question.id] === true || answers.value[question.id] === false
|
|
))
|
|
);
|
|
|
|
const { steps, clickableSteps, isNextButtonDisabled, handleConfirmNext } = useWashFlowState({
|
|
currentStep,
|
|
washInProgress,
|
|
customerNumberInput,
|
|
licensePlateInput,
|
|
vehicleTypeSelect,
|
|
availableProductIds,
|
|
radioLaneOption,
|
|
radioWashType,
|
|
nearestDepartment,
|
|
allVisibleQuestionsAnswered,
|
|
isLoadingSelfServeData,
|
|
activeTasks: displayedActiveTasks,
|
|
completedTasks,
|
|
editAnswers,
|
|
isLaneAvailable,
|
|
isMachineAvailable,
|
|
onStartWash,
|
|
updateLaneAllowedServices: syncLaneAllowedServicesForSelection,
|
|
});
|
|
|
|
currentStep.value = steps.VEHICLE;
|
|
|
|
const isCompletedWashState = computed(
|
|
() =>
|
|
hasCompletedGuidedWash.value ||
|
|
hasRecentlyCompletedWash.value ||
|
|
currentStep.value === steps.COMPLETED ||
|
|
!!readRecentlyCompletedWash()
|
|
);
|
|
|
|
const displayedStep = computed({
|
|
get: () => currentStep.value,
|
|
set: (step) => {
|
|
if (isCompletedWashState.value) {
|
|
return;
|
|
}
|
|
|
|
currentStep.value = step;
|
|
},
|
|
});
|
|
|
|
const shouldShowWashFlow = computed(
|
|
() =>
|
|
doesCurrentDepartmentSelectionHaveSelfServeEnabled.value ||
|
|
washInProgress.value ||
|
|
isCompletingWash.value ||
|
|
isCompletedWashState.value
|
|
);
|
|
|
|
const shouldShowDisabledDepartmentWarning = computed(
|
|
() =>
|
|
!!nearestDepartment.value && !doesCurrentDepartmentSelectionHaveSelfServeEnabled.value && !shouldShowWashFlow.value
|
|
);
|
|
|
|
const isLastGuidedWashStep = computed(() => currentGuidedWashStep.value >= guidedWashFlowSteps.length - 1);
|
|
|
|
const goPreviousGuidedWashStep = () => {
|
|
currentGuidedWashStep.value = Math.max(currentGuidedWashStep.value - 1, 0);
|
|
};
|
|
|
|
const goNextGuidedWashStep = () => {
|
|
if (isLastGuidedWashStep.value) {
|
|
return;
|
|
}
|
|
|
|
currentGuidedWashStep.value = Math.min(currentGuidedWashStep.value + 1, guidedWashFlowSteps.length - 1);
|
|
};
|
|
|
|
const resetGuidedWashStep = () => {
|
|
currentGuidedWashStep.value = 0;
|
|
};
|
|
|
|
const finalizeGuidedWashCompletion = (laneId: number | string | null) => {
|
|
clearStoppingWashMarker();
|
|
markRecentlyCompletedWash(laneId, licensePlateInput.value, getNumericCustomerNumber());
|
|
shouldRestoreServerActiveWash.value = false;
|
|
hasCompletedGuidedWash.value = true;
|
|
resetGuidedWashStep();
|
|
currentStep.value = steps.COMPLETED;
|
|
};
|
|
|
|
const waitForStopFailureServerCompletion = async () => {
|
|
for (let attempt = 0; attempt < STOP_FAILURE_COMPLETION_CHECK_ATTEMPTS; attempt += 1) {
|
|
if (!washInProgress.value || currentStep.value === steps.COMPLETED) {
|
|
return true;
|
|
}
|
|
|
|
await syncActiveWashWithServer();
|
|
if (!washInProgress.value || currentStep.value === steps.COMPLETED) {
|
|
return true;
|
|
}
|
|
|
|
if (attempt < STOP_FAILURE_COMPLETION_CHECK_ATTEMPTS - 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, STOP_FAILURE_COMPLETION_CHECK_MS));
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const completeGuidedWash = async () => {
|
|
if (isCompletingWash.value) {
|
|
return;
|
|
}
|
|
|
|
const completingLaneId = washLaneId.value;
|
|
washActionError.value = null;
|
|
markStoppingWash(completingLaneId);
|
|
isCompletingWash.value = true;
|
|
await nextTick();
|
|
|
|
try {
|
|
const finishingDelay = new Promise((resolve) => setTimeout(resolve, MIN_FINISHING_SCREEN_MS));
|
|
const stopSucceeded = await onStopWash(completingLaneId);
|
|
if (!stopSucceeded) {
|
|
const serverCompleted = await waitForStopFailureServerCompletion();
|
|
await finishingDelay;
|
|
if (serverCompleted) {
|
|
finalizeGuidedWashCompletion(completingLaneId);
|
|
try {
|
|
await openPropertyExitGate(completingLaneId, { suppressAlert: true });
|
|
} catch (error) {
|
|
console.error("Error opening property exit gate during wash completion:", error);
|
|
}
|
|
} else {
|
|
clearStoppingWashMarker();
|
|
}
|
|
return;
|
|
}
|
|
|
|
await finishingDelay;
|
|
finalizeGuidedWashCompletion(completingLaneId);
|
|
|
|
try {
|
|
await openPropertyExitGate(completingLaneId, { suppressAlert: true });
|
|
} catch (error) {
|
|
console.error("Error opening property exit gate during wash completion:", error);
|
|
}
|
|
} finally {
|
|
isCompletingWash.value = false;
|
|
}
|
|
};
|
|
|
|
const unregisterBeforeUnload = ref<null | (() => void)>(null);
|
|
const activeWashRefreshInterval = ref<ReturnType<typeof window.setInterval> | null>(null);
|
|
const activeWashRestoreTimeout = ref<ReturnType<typeof window.setTimeout> | null>(null);
|
|
const recentCompletedRefreshTimeout = ref<ReturnType<typeof window.setTimeout> | null>(null);
|
|
const recentCompletedRefreshInterval = ref<ReturnType<typeof window.setInterval> | null>(null);
|
|
const isMyWashStartUnmounted = ref(false);
|
|
const getMyWashWindow = () => (typeof window === "undefined" ? null : window);
|
|
|
|
const registrationOptions = computed(() =>
|
|
customerVehicles.value
|
|
.map((vehicle) => vehicle.reg)
|
|
.filter((reg) => reg && reg.trim() !== "" && reg.toUpperCase() !== (licensePlateInput.value || "").toUpperCase())
|
|
);
|
|
|
|
const selectedVehicleType = computed(() => {
|
|
if (!vehicleTypeSelect.value) {
|
|
return null;
|
|
}
|
|
|
|
return vehicleTypes.value.find((type) => parseInt(type.id) === parseInt(vehicleTypeSelect.value));
|
|
});
|
|
|
|
const selectedVehicleTypeName = computed(() => selectedVehicleType.value?.name || null);
|
|
const selectedVehicleTypeDescription = computed(() => selectedVehicleType.value?.product?.description || null);
|
|
const selectedVehicleTypeProductName = computed(
|
|
() => selectedVehicleType.value?.product?.name || selectedVehicleType.value?.name || null
|
|
);
|
|
|
|
const selectedDepartmentLanes = computed(() => nearestDepartment.value?.lanes || []);
|
|
const hasMatchingVehicleForInput = computed(() => doesUserVehicleExist(licensePlateInput.value));
|
|
const isSelectedLaneMachineAvailable = computed(() => isMachineAvailable(radioLaneOption.value));
|
|
|
|
const isQuestionsStepVisible = computed(
|
|
() =>
|
|
currentStep.value === steps.VEHICLE ||
|
|
currentStep.value === steps.QUESTIONS ||
|
|
currentStep.value === steps.SELECT_LANE ||
|
|
currentStep.value === steps.TASKS ||
|
|
currentStep.value === steps.WASH_IN_PROGRESS ||
|
|
currentStep.value === steps.COMPLETED
|
|
);
|
|
|
|
const isLaneStepVisible = computed(
|
|
() =>
|
|
currentStep.value === steps.VEHICLE ||
|
|
currentStep.value === steps.SELECT_LANE ||
|
|
currentStep.value === steps.QUESTIONS
|
|
);
|
|
|
|
const isTasksStepVisible = computed(
|
|
() =>
|
|
currentStep.value === steps.TASKS ||
|
|
currentStep.value === steps.QUESTIONS ||
|
|
currentStep.value === steps.VEHICLE ||
|
|
currentStep.value === steps.SELECT_LANE
|
|
);
|
|
|
|
const displayedDynamicImageUrl = computed(() =>
|
|
hideDynamicImage.value || !isMachineWashSelectedAndAllowed.value ? null : dynamicImageUrl.value
|
|
);
|
|
|
|
const normalizeLicensePlate = (value: string | null) => (value || "").trim().toUpperCase();
|
|
|
|
const getNumericCustomerNumber = () => resolveEffectiveCustomerNumber(customerNumberInput.value);
|
|
|
|
const unwrapApiData = (response: any) => response?.data?.data ?? response?.data ?? response;
|
|
|
|
const parseServerDateTimeMs = (value: any) => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = String(value).trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const timestamp = Date.parse(normalized.includes("T") ? normalized : normalized.replace(" ", "T"));
|
|
return Number.isFinite(timestamp) ? timestamp : null;
|
|
};
|
|
|
|
const extractErrorMessage = (error: any, fallback: string) => {
|
|
if (error?.response?.data?.data?.message) {
|
|
return error.response.data.data.message;
|
|
}
|
|
if (error?.response?.data?.error) {
|
|
return error.response.data.error;
|
|
}
|
|
if (error?.message) {
|
|
return error.message;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
const getRequestStatus = (error: any) => Number(error?.response?.status ?? error?.status ?? 0);
|
|
|
|
watch(dynamicImageUrl, () => {
|
|
hideDynamicImage.value = false;
|
|
});
|
|
|
|
const hasAllowedVehicleTypeSelection = computed(() => {
|
|
const selectedVehicleTypeId = vehicleTypeSelect.value;
|
|
if (selectedVehicleTypeId === null || selectedVehicleTypeId === undefined) {
|
|
return false;
|
|
}
|
|
|
|
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
|
|
return allowedProductIds.has(String(selectedVehicleTypeId));
|
|
});
|
|
|
|
const isVehicleTypeEligibleForSelectedDepartment = (vehicleTypeId: any) => {
|
|
const normalizedVehicleTypeId = normalizePositiveInteger(vehicleTypeId);
|
|
if (!normalizedVehicleTypeId) {
|
|
return false;
|
|
}
|
|
|
|
const allowedProductIdList = availableProductIds.value || [];
|
|
if (allowedProductIdList.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const allowedProductIds = new Set(allowedProductIdList.map((productId) => String(productId)));
|
|
return allowedProductIds.has(String(normalizedVehicleTypeId));
|
|
};
|
|
|
|
const applyResolvedVehicleTypeSelection = () => {
|
|
const resolvedVehicleTypeId = normalizePositiveInteger(selfServeResolvedVehicleTypeId.value);
|
|
if (
|
|
!resolvedVehicleTypeId ||
|
|
vehicleTypeSelect.value ||
|
|
washInProgress.value ||
|
|
isRestoring.value ||
|
|
!isVehicleTypeEligibleForSelectedDepartment(resolvedVehicleTypeId)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
vehicleTypeSelect.value = resolvedVehicleTypeId;
|
|
};
|
|
|
|
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
|
|
|
|
const effectiveCustomerNumberForRedCar = computed(() => {
|
|
const authenticated = getAuthenticatedCustomerNumber();
|
|
if (authenticated) {
|
|
return authenticated;
|
|
}
|
|
const typed = resolveEffectiveCustomerNumber(customerNumberInput.value);
|
|
return typed ?? null;
|
|
});
|
|
|
|
const { isRedCar: isRedCarCustomerFlag } = useRedCarWarning(effectiveCustomerNumberForRedCar);
|
|
|
|
const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
|
|
|
|
const canUseDepartmentHeaderSelection = computed(
|
|
() =>
|
|
SessionUser.canAccessSuperUser() ||
|
|
SessionUser.canAccessDeveloper() ||
|
|
!hasLocationCoordinates.value ||
|
|
isDepartmentSelectionFallbackBased.value
|
|
);
|
|
|
|
const shouldShowChooseDepartmentMessage = computed(() => !nearestDepartment.value && !hasLocationCoordinates.value);
|
|
|
|
const shouldShowLoadingDataMessage = computed(() => !nearestDepartment.value && hasLocationCoordinates.value);
|
|
|
|
const vehicleStepGuidanceKey = computed(() => {
|
|
if (!nearestDepartment.value) {
|
|
return "self_wash.vehicle_step_missing_department";
|
|
}
|
|
|
|
if (showCustomerNumberInput.value && !resolveEffectiveCustomerNumber(customerNumberInput.value)) {
|
|
return "self_wash.vehicle_step_missing_customer_number";
|
|
}
|
|
|
|
if (!normalizeLicensePlate(licensePlateInput.value)) {
|
|
return ""; //"self_wash.vehicle_step_missing_registration";
|
|
}
|
|
|
|
if ((availableProductIds.value || []).length > 0 && !hasAllowedVehicleTypeSelection.value) {
|
|
return ""; //"self_wash.vehicle_step_missing_vehicle_type";
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
const confirmActionLabelKey = computed(() =>
|
|
currentStep.value === steps.SELECT_LANE ? "self_wash.confirm_and_start" : "common.next"
|
|
);
|
|
|
|
const markStartFormUserInput = () => {
|
|
if (!isRestoring.value && !washInProgress.value) {
|
|
hasStartFormUserInput.value = true;
|
|
}
|
|
};
|
|
|
|
const normalizeLaneId = (value: any) => {
|
|
const normalized = parseInt(String(value ?? ""), 10);
|
|
return Number.isNaN(normalized) || normalized === 0 ? null : normalized;
|
|
};
|
|
|
|
const confirmedQuestionReviewKey = ref<string | null>(null);
|
|
|
|
const questionReviewStateKey = computed(() => {
|
|
const visibleQuestionIds = visibleQuestions.value
|
|
.map((question: any) => parseInt(question?.id ?? 0))
|
|
.filter((questionId: number) => questionId > 0)
|
|
.join(",");
|
|
|
|
if (!visibleQuestionIds) {
|
|
return "";
|
|
}
|
|
|
|
return [normalizeLicensePlate(licensePlateInput.value), vehicleTypeSelect.value ?? "", visibleQuestionIds].join("|");
|
|
});
|
|
|
|
const hasConfirmedCurrentQuestionReview = computed(
|
|
() => questionReviewStateKey.value !== "" && confirmedQuestionReviewKey.value === questionReviewStateKey.value
|
|
);
|
|
|
|
const shouldReturnToQuestionsForReview = computed(
|
|
() =>
|
|
!isCompletingWash.value &&
|
|
!washInProgress.value &&
|
|
visibleQuestions.value.length > 0 &&
|
|
currentStep.value > steps.QUESTIONS &&
|
|
currentStep.value < steps.COMPLETED &&
|
|
!hasConfirmedCurrentQuestionReview.value
|
|
);
|
|
|
|
const markQuestionReviewRequired = () => {
|
|
confirmedQuestionReviewKey.value = null;
|
|
};
|
|
|
|
const markQuestionReviewConfirmed = () => {
|
|
if (questionReviewStateKey.value !== "") {
|
|
confirmedQuestionReviewKey.value = questionReviewStateKey.value;
|
|
}
|
|
};
|
|
|
|
const getEffectiveLaneId = () => {
|
|
const lanes = Array.isArray(nearestDepartment.value?.lanes) ? nearestDepartment.value.lanes : [];
|
|
if (lanes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const selectedLaneId = normalizeLaneId(radioLaneOption.value);
|
|
if (selectedLaneId && lanes.some((entry) => normalizeLaneId(entry.id) === selectedLaneId && isLaneAvailable(entry))) {
|
|
return selectedLaneId;
|
|
}
|
|
|
|
const preferredLane = lanes.find(isLaneAvailable);
|
|
return normalizeLaneId(preferredLane?.id);
|
|
};
|
|
|
|
const ensureEffectiveLaneSelection = () => {
|
|
const effectiveLaneId = getEffectiveLaneId();
|
|
const lanes = Array.isArray(nearestDepartment.value?.lanes) ? nearestDepartment.value.lanes : [];
|
|
const selectedLaneId = normalizeLaneId(radioLaneOption.value);
|
|
const hasSelectedLane = selectedLaneId
|
|
? lanes.some((entry) => normalizeLaneId(entry.id) === selectedLaneId && isLaneAvailable(entry))
|
|
: false;
|
|
|
|
if (!hasSelectedLane && !isRestoring.value) {
|
|
radioLaneOption.value = effectiveLaneId;
|
|
}
|
|
|
|
return effectiveLaneId;
|
|
};
|
|
|
|
const doesUserVehicleExist = (licensePlate: string | null) => {
|
|
if (!licensePlate || licensePlate.trim() === "") {
|
|
return false;
|
|
}
|
|
|
|
return customerVehicles.value.some((vehicle) => vehicle.reg.toUpperCase() === licensePlate.trim().toUpperCase());
|
|
};
|
|
|
|
const addVehicleIfMissing = async () => {
|
|
const normalizedPlate = normalizeLicensePlate(licensePlateInput.value);
|
|
if (!normalizedPlate || !vehicleTypeSelect.value) {
|
|
return false;
|
|
}
|
|
|
|
if (doesUserVehicleExist(normalizedPlate)) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const customerNumber = getNumericCustomerNumber();
|
|
if (customerNumber !== null) {
|
|
await SessionUser.objects.vehicles.add(vehicleTypeSelect.value, normalizedPlate, false, customerNumber);
|
|
} else {
|
|
await SessionUser.request("/user/vehicles", "POST", {
|
|
reg: normalizedPlate,
|
|
type: String(vehicleTypeSelect.value),
|
|
notes: null,
|
|
});
|
|
}
|
|
|
|
await fetchCustomerVehicles();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error adding vehicle during self-serve step:", error);
|
|
vehicleStepError.value = extractErrorMessage(
|
|
error,
|
|
"Kunne ikke tilføje køretøjet. Kontroller registreringsnummeret og prøv igen."
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const fetchCustomerVehicles = async () => {
|
|
isCustomerVehiclesLoading.value = true;
|
|
try {
|
|
const response = await SessionUser.objects.vehicles.get.all();
|
|
customerVehicles.value = response || [];
|
|
} catch (error) {
|
|
console.error("Error fetching customer vehicles:", error);
|
|
} finally {
|
|
isCustomerVehiclesLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const fetchVehicleTypes = async () => {
|
|
try {
|
|
const response = await SessionUser.objects.vehicles.columns.type.options(true, {
|
|
isWash: true,
|
|
addDefaultOption: true,
|
|
restrictToCategory4: null,
|
|
includeProductRaw: true,
|
|
});
|
|
vehicleTypes.value = response || [];
|
|
} catch (error) {
|
|
console.error("Error fetching vehicle types:", error);
|
|
}
|
|
};
|
|
|
|
type SelfServeFetchRequest = {
|
|
key: string;
|
|
departmentId: number;
|
|
laneId: number;
|
|
registration: string;
|
|
vehicleTypeId: number | null;
|
|
};
|
|
|
|
const selfServeFetchDebounceTimer = ref<ReturnType<typeof window.setTimeout> | null>(null);
|
|
const selfServeFetchResolvers = ref<Array<(_value: any) => void>>([]);
|
|
const latestSuccessfulSelfServeFetchKey = ref<string | null>(null);
|
|
const inFlightSelfServeFetchKey = ref<string | null>(null);
|
|
const selfServeFetchAbortController = ref<AbortController | null>(null);
|
|
const isSelfServeFetchUnmounted = ref(false);
|
|
|
|
const resolvePendingSelfServeFetches = (value: any = null) => {
|
|
const resolvers = selfServeFetchResolvers.value;
|
|
selfServeFetchResolvers.value = [];
|
|
resolvers.forEach((resolve) => resolve(value));
|
|
};
|
|
|
|
const clearScheduledSelfServeFetch = (resolveValue: any = null) => {
|
|
if (selfServeFetchDebounceTimer.value) {
|
|
window.clearTimeout(selfServeFetchDebounceTimer.value);
|
|
selfServeFetchDebounceTimer.value = null;
|
|
}
|
|
|
|
if (selfServeFetchResolvers.value.length > 0) {
|
|
resolvePendingSelfServeFetches(resolveValue);
|
|
}
|
|
};
|
|
|
|
const abortActiveSelfServeFetch = () => {
|
|
// Abort the obsolete request before starting another so stale responses cannot update this instance.
|
|
if (selfServeFetchAbortController.value) {
|
|
selfServeFetchAbortController.value.abort();
|
|
selfServeFetchAbortController.value = null;
|
|
}
|
|
cancelSelfServeFetchInternal?.();
|
|
};
|
|
|
|
const createSelfServeFetchRequest = (): SelfServeFetchRequest | null => {
|
|
if (isRestoring.value || isCompletedWashState.value || !nearestDepartment.value) {
|
|
return null;
|
|
}
|
|
|
|
const departmentId = normalizePositiveInteger(nearestDepartment.value.id);
|
|
if (!departmentId) {
|
|
return null;
|
|
}
|
|
|
|
const registration = normalizeLicensePlate(licensePlateInput.value);
|
|
if (registration.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const laneId = ensureEffectiveLaneSelection();
|
|
if (!laneId) {
|
|
return null;
|
|
}
|
|
|
|
const vehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
|
|
const key = [departmentId, laneId, registration, vehicleTypeId ?? ""].join("|");
|
|
|
|
return {
|
|
key,
|
|
departmentId,
|
|
laneId,
|
|
registration,
|
|
vehicleTypeId,
|
|
};
|
|
};
|
|
|
|
const executeSelfServeFetch = async (
|
|
request: SelfServeFetchRequest | null = createSelfServeFetchRequest(),
|
|
options: { force?: boolean } = {}
|
|
) => {
|
|
if (!request || isSelfServeFetchUnmounted.value) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
!options.force &&
|
|
(request.key === latestSuccessfulSelfServeFetchKey.value || request.key === inFlightSelfServeFetchKey.value)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
abortActiveSelfServeFetch();
|
|
const abortController = typeof AbortController === "function" ? new AbortController() : null;
|
|
selfServeFetchAbortController.value = abortController;
|
|
inFlightSelfServeFetchKey.value = request.key;
|
|
|
|
try {
|
|
const fetchOptions: { force?: true } = {};
|
|
if (options.force) {
|
|
fetchOptions.force = true;
|
|
}
|
|
|
|
const fetchArgs: [number, number | null, number, string, { force?: true }?] = [
|
|
request.departmentId,
|
|
request.vehicleTypeId,
|
|
request.laneId,
|
|
request.registration,
|
|
];
|
|
if (Object.keys(fetchOptions).length > 0) {
|
|
fetchArgs.push(fetchOptions);
|
|
}
|
|
|
|
const result = await fetchSelfServeDataInternal(...fetchArgs);
|
|
|
|
if (
|
|
!isSelfServeFetchUnmounted.value &&
|
|
selfServeFetchAbortController.value === abortController &&
|
|
!abortController?.signal.aborted
|
|
) {
|
|
latestSuccessfulSelfServeFetchKey.value = request.key;
|
|
}
|
|
|
|
return result;
|
|
} finally {
|
|
const isCurrentFetch = selfServeFetchAbortController.value === abortController;
|
|
if (isCurrentFetch) {
|
|
selfServeFetchAbortController.value = null;
|
|
if (inFlightSelfServeFetchKey.value === request.key) {
|
|
inFlightSelfServeFetchKey.value = null;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const fetchSelfServeData = async (options: { immediate?: boolean; force?: boolean } = {}) => {
|
|
const request = createSelfServeFetchRequest();
|
|
|
|
if (options.immediate) {
|
|
clearScheduledSelfServeFetch(null);
|
|
if (!request || isSelfServeFetchUnmounted.value) {
|
|
abortActiveSelfServeFetch();
|
|
}
|
|
return executeSelfServeFetch(request, { force: options.force === true });
|
|
}
|
|
|
|
if (!request || isSelfServeFetchUnmounted.value) {
|
|
clearScheduledSelfServeFetch(null);
|
|
abortActiveSelfServeFetch();
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
selfServeFetchResolvers.value.push(resolve);
|
|
|
|
if (selfServeFetchDebounceTimer.value) {
|
|
window.clearTimeout(selfServeFetchDebounceTimer.value);
|
|
}
|
|
|
|
selfServeFetchDebounceTimer.value = window.setTimeout(async () => {
|
|
selfServeFetchDebounceTimer.value = null;
|
|
const latestRequest = createSelfServeFetchRequest();
|
|
const result = await executeSelfServeFetch(latestRequest, { force: options.force === true });
|
|
resolvePendingSelfServeFetches(result);
|
|
}, SELF_SERVE_FETCH_DEBOUNCE_MS);
|
|
});
|
|
};
|
|
|
|
const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";
|
|
|
|
const findServerActiveWashCandidate = (laneId: number | string | null) => {
|
|
const normalizedLaneId = normalizeLaneId(laneId);
|
|
if (!normalizedLaneId) {
|
|
return null;
|
|
}
|
|
|
|
const departments = Array.isArray(guestDepartments.value) ? guestDepartments.value : [];
|
|
for (const department of departments) {
|
|
const lanes = Array.isArray(department?.lanes) ? department.lanes : [];
|
|
const lane = lanes.find((candidateLane: any) => normalizeLaneId(candidateLane?.id) === normalizedLaneId);
|
|
if (lane) {
|
|
return {
|
|
department,
|
|
lane,
|
|
laneId: normalizedLaneId,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { laneId: normalizedLaneId };
|
|
};
|
|
|
|
const hasServerActiveWashStartEvidence = (details: any) => {
|
|
const session = details?.session || {};
|
|
const sessionStatus = normalizeServerSessionStatus(session?.status ?? details?.status);
|
|
|
|
return (
|
|
SERVER_MACHINE_WASH_STATUSES.has(sessionStatus) ||
|
|
session?.machine_relay_enabled === true ||
|
|
session?.machine_start_triggered === true ||
|
|
parseServerDateTimeMs(session?.wash_started_at) !== null ||
|
|
parseServerDateTimeMs(session?.machine_start_triggered_at) !== null ||
|
|
parseServerDateTimeMs(session?.machine_relay_enabled_at) !== null
|
|
);
|
|
};
|
|
|
|
const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => {
|
|
if (!details?.in_progress) {
|
|
return false;
|
|
}
|
|
|
|
if (!hasServerActiveWashStartEvidence(details)) {
|
|
return false;
|
|
}
|
|
|
|
const sessionCustomerNumber = normalizePositiveInteger(
|
|
details?.session?.customer_number ?? details?.customer?.customer_number
|
|
);
|
|
const authenticatedSubuserId = getAuthenticatedSubuserId();
|
|
const sessionSubuserId = normalizePositiveInteger(
|
|
details?.session?.subuser_id ?? details?.session?.subuser?.id ?? details?.subuser?.id
|
|
);
|
|
|
|
return sessionCustomerNumber === customerNumber && (!authenticatedSubuserId || sessionSubuserId === authenticatedSubuserId);
|
|
};
|
|
|
|
|
|
|
|
const applyRecentlyCompletedWashState = (recentlyCompleted: any = readRecentlyCompletedWash()) => {
|
|
if (!recentlyCompleted) {
|
|
if (!hasCompletedGuidedWash.value && currentStep.value !== steps.COMPLETED) {
|
|
hasRecentlyCompletedWash.value = false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
hasRecentlyCompletedWash.value = true;
|
|
hasCompletedGuidedWash.value = true;
|
|
shouldRestoreServerActiveWash.value = false;
|
|
|
|
if (washStartTime.value && completedDurationMs.value === null) {
|
|
completedDurationMs.value = now.value - washStartTime.value;
|
|
}
|
|
|
|
if (
|
|
washInProgress.value ||
|
|
washLaneId.value !== null ||
|
|
washStartTime.value !== null ||
|
|
currentStep.value !== steps.COMPLETED
|
|
) {
|
|
washInProgress.value = false;
|
|
washLaneId.value = null;
|
|
washStartTime.value = null;
|
|
currentStep.value = steps.COMPLETED;
|
|
stopElapsedTimer();
|
|
clearProgress();
|
|
resetGuidedWashStep();
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const markRecentlyCompletedWash = (
|
|
laneId: number | string | null,
|
|
reg: string | null,
|
|
customerNumber: number | null
|
|
) => {
|
|
const normalizedLaneId = normalizeLaneId(laneId);
|
|
if (!normalizedLaneId) {
|
|
return;
|
|
}
|
|
const payload = {
|
|
laneId: normalizedLaneId,
|
|
reg: normalizeLicensePlate(reg),
|
|
customerNumber: normalizePositiveInteger(customerNumber),
|
|
completedAt: Date.now(),
|
|
};
|
|
|
|
try {
|
|
localStorage.setItem(RECENT_COMPLETED_WASH_KEY, JSON.stringify(payload));
|
|
} catch {}
|
|
|
|
applyRecentlyCompletedWashState(payload);
|
|
};
|
|
|
|
const refreshRecentlyCompletedWashState = () => {
|
|
return applyRecentlyCompletedWashState();
|
|
};
|
|
|
|
refreshRecentlyCompletedWashState();
|
|
|
|
const clearRecentCompletedRefreshTimeout = () => {
|
|
if (recentCompletedRefreshTimeout.value !== null) {
|
|
const timeoutId = recentCompletedRefreshTimeout.value;
|
|
const hostWindow = getMyWashWindow();
|
|
if (hostWindow) {
|
|
hostWindow.clearTimeout(timeoutId);
|
|
} else {
|
|
globalThis.clearTimeout(timeoutId);
|
|
}
|
|
recentCompletedRefreshTimeout.value = null;
|
|
}
|
|
};
|
|
|
|
const clearRecentCompletedRefreshInterval = () => {
|
|
if (recentCompletedRefreshInterval.value !== null) {
|
|
const intervalId = recentCompletedRefreshInterval.value;
|
|
const hostWindow = getMyWashWindow();
|
|
if (hostWindow) {
|
|
hostWindow.clearInterval(intervalId);
|
|
} else {
|
|
globalThis.clearInterval(intervalId);
|
|
}
|
|
recentCompletedRefreshInterval.value = null;
|
|
}
|
|
};
|
|
|
|
const scheduleRecentCompletedWashRefresh = (attempt = 0) => {
|
|
if (isMyWashStartUnmounted.value || typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
clearRecentCompletedRefreshTimeout();
|
|
|
|
if (refreshRecentlyCompletedWashState() || attempt >= 20 || isMyWashStartUnmounted.value) {
|
|
return;
|
|
}
|
|
|
|
const hostWindow = getMyWashWindow();
|
|
if (!hostWindow) {
|
|
return;
|
|
}
|
|
|
|
recentCompletedRefreshTimeout.value = hostWindow.setTimeout(() => {
|
|
recentCompletedRefreshTimeout.value = null;
|
|
if (isMyWashStartUnmounted.value) {
|
|
return;
|
|
}
|
|
|
|
scheduleRecentCompletedWashRefresh(attempt + 1);
|
|
}, 250);
|
|
};
|
|
|
|
const startRecentCompletedWashRefresh = () => {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
clearRecentCompletedRefreshInterval();
|
|
|
|
const hostWindow = getMyWashWindow();
|
|
if (!hostWindow) {
|
|
return;
|
|
}
|
|
|
|
recentCompletedRefreshInterval.value = hostWindow.setInterval(() => {
|
|
if (isMyWashStartUnmounted.value) {
|
|
clearRecentCompletedRefreshInterval();
|
|
return;
|
|
}
|
|
|
|
refreshRecentlyCompletedWashState();
|
|
}, 1000);
|
|
};
|
|
|
|
const isRecentlyCompletedActiveWash = (activeWash: any) => {
|
|
const recentlyCompleted = readRecentlyCompletedWash();
|
|
if (!recentlyCompleted) {
|
|
return false;
|
|
}
|
|
|
|
const details = activeWash?.details || {};
|
|
const session = details?.session || {};
|
|
const vehicle = details?.vehicle || {};
|
|
const laneId = normalizeLaneId(details?.lane_id ?? session?.lane_id ?? activeWash?.laneId);
|
|
const reg = normalizeLicensePlate(session?.reg ?? vehicle?.reg);
|
|
const customerNumber = normalizePositiveInteger(session?.customer_number ?? details?.customer?.customer_number);
|
|
|
|
if (normalizeLaneId(recentlyCompleted.laneId) !== laneId) {
|
|
return false;
|
|
}
|
|
|
|
if (recentlyCompleted.reg && reg && recentlyCompleted.reg !== reg) {
|
|
return false;
|
|
}
|
|
|
|
return !(
|
|
recentlyCompleted.customerNumber &&
|
|
customerNumber &&
|
|
Number(recentlyCompleted.customerNumber) !== customerNumber
|
|
);
|
|
};
|
|
|
|
const fetchServerActiveWash = async () => {
|
|
const customerNumber = getAuthenticatedCustomerNumber();
|
|
if (!customerNumber) {
|
|
return null;
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await SessionUser.request(SERVER_ACTIVE_WASH_ENDPOINT, "GET");
|
|
} catch (error) {
|
|
if (getRequestStatus(error) === 404) {
|
|
return null;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
const details = unwrapApiData(response);
|
|
if (!isAuthenticatedCustomerActiveWash(details, customerNumber)) {
|
|
return null;
|
|
}
|
|
|
|
const laneId = normalizeLaneId(details?.lane_id ?? details?.session?.lane_id);
|
|
const activeWashCandidate = findServerActiveWashCandidate(laneId);
|
|
const activeWash = {
|
|
...(activeWashCandidate || {}),
|
|
details,
|
|
};
|
|
|
|
if (isRecentlyCompletedActiveWash(activeWash)) {
|
|
return null;
|
|
}
|
|
|
|
return activeWash;
|
|
};
|
|
|
|
const applyServerActiveWash = async (activeWash: any) => {
|
|
const details = activeWash?.details;
|
|
const session = details?.session || {};
|
|
const vehicle = details?.vehicle || {};
|
|
const laneId = normalizeLaneId(details?.lane_id ?? session?.lane_id ?? activeWash?.laneId);
|
|
const departmentId = normalizeLaneId(
|
|
activeWash?.department?.id ?? session?.department_id ?? activeWash?.lane?.department
|
|
);
|
|
const reg = normalizeLicensePlate(session?.reg ?? vehicle?.reg ?? licensePlateInput.value);
|
|
const sessionStatus = normalizeServerSessionStatus(session?.status ?? details?.status);
|
|
const shouldRestoreTaskStep = SERVER_TASK_RESTORE_STATUSES.has(sessionStatus);
|
|
const activeWashCustomerNumber = session?.customer_number ?? details?.customer?.customer_number;
|
|
|
|
if (!laneId || !reg) {
|
|
return false;
|
|
}
|
|
|
|
if (departmentId && nearestDepartment.value?.id !== departmentId) {
|
|
isForcingNearestDepartment.value = true;
|
|
forceNearestDepartmentEvaluationId.value = departmentId;
|
|
evaluateLocationDepartments(locations.location.value);
|
|
}
|
|
|
|
washInProgress.value = true;
|
|
washLaneId.value = laneId;
|
|
radioLaneOption.value = laneId;
|
|
licensePlateInput.value = reg;
|
|
applyEffectiveCustomerNumberInput(session?.customer_number ?? details?.customer?.customer_number);
|
|
vehicleTypeSelect.value =
|
|
normalizePositiveInteger(session?.vehicle_type_id ?? vehicle?.type) ?? vehicleTypeSelect.value;
|
|
radioWashType.value =
|
|
session?.machine_relay_enabled || SERVER_MACHINE_WASH_STATUSES.has(sessionStatus) ? "Machine" : "Manual";
|
|
washStartTime.value =
|
|
parseServerDateTimeMs(session?.wash_started_at) ??
|
|
parseServerDateTimeMs(session?.machine_start_triggered_at) ??
|
|
parseServerDateTimeMs(session?.machine_relay_enabled_at) ??
|
|
Date.now();
|
|
completedDurationMs.value = null;
|
|
editAnswers.value = false;
|
|
washActionError.value = null;
|
|
vehicleStepError.value = null;
|
|
hasCompletedGuidedWash.value = false;
|
|
currentStep.value = shouldRestoreTaskStep ? steps.TASKS : steps.WASH_IN_PROGRESS;
|
|
restoreStoppingWashIfMatched({
|
|
laneId,
|
|
reg,
|
|
customerNumber: activeWashCustomerNumber,
|
|
});
|
|
|
|
startElapsedTimer();
|
|
|
|
const summaryParams: Record<string, any> = session?.id ? { session_id: session.id } : { lane_id: laneId, reg };
|
|
const selectedVehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
|
|
if (selectedVehicleTypeId) {
|
|
summaryParams.vehicle_type = selectedVehicleTypeId;
|
|
}
|
|
|
|
const summary = await fetchWashSummary(summaryParams, false);
|
|
if (!summary) {
|
|
const fallbackDepartmentId = departmentId || nearestDepartment.value?.id;
|
|
if (fallbackDepartmentId) {
|
|
await fetchSelfServeDataInternal(fallbackDepartmentId, selectedVehicleTypeId, laneId, reg);
|
|
}
|
|
}
|
|
|
|
if (shouldRestoreTaskStep && hasMachineTasks.value) {
|
|
radioWashType.value = "Machine";
|
|
}
|
|
if (shouldRestoreTaskStep) {
|
|
currentStep.value = displayedActiveTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS;
|
|
}
|
|
|
|
saveProgress("serverActiveWash");
|
|
return true;
|
|
};
|
|
|
|
const restoreServerActiveWash = async () => {
|
|
if (isMyWashStartUnmounted.value || isRestoringServerActiveWash.value) {
|
|
return;
|
|
}
|
|
|
|
if (hasStartFormUserInput.value && !washInProgress.value) {
|
|
return;
|
|
}
|
|
|
|
const hadAuthenticatedCustomerNumber = !!getAuthenticatedCustomerNumber();
|
|
if (!hadAuthenticatedCustomerNumber) {
|
|
return;
|
|
}
|
|
|
|
isRestoringServerActiveWash.value = true;
|
|
try {
|
|
const activeWash = await fetchServerActiveWash();
|
|
if (isMyWashStartUnmounted.value) {
|
|
return;
|
|
}
|
|
|
|
if (activeWash) {
|
|
shouldRestoreServerActiveWash.value = false;
|
|
await applyServerActiveWash(activeWash);
|
|
} else {
|
|
shouldRestoreServerActiveWash.value = false;
|
|
}
|
|
} catch (error) {
|
|
console.warn("Failed to restore active self-serve wash:", error);
|
|
} finally {
|
|
isRestoringServerActiveWash.value = false;
|
|
}
|
|
};
|
|
|
|
const clearServerActiveWashRestoreTimeout = () => {
|
|
if (activeWashRestoreTimeout.value !== null) {
|
|
window.clearTimeout(activeWashRestoreTimeout.value);
|
|
activeWashRestoreTimeout.value = null;
|
|
}
|
|
};
|
|
|
|
const scheduleServerActiveWashRestore = (delayMs = 0) => {
|
|
clearServerActiveWashRestoreTimeout();
|
|
|
|
if (!shouldRestoreServerActiveWash.value || washInProgress.value || isMyWashStartUnmounted.value) {
|
|
return;
|
|
}
|
|
|
|
activeWashRestoreTimeout.value = window.setTimeout(() => {
|
|
activeWashRestoreTimeout.value = null;
|
|
|
|
if (isMyWashStartUnmounted.value) {
|
|
return;
|
|
}
|
|
|
|
restoreServerActiveWash();
|
|
}, delayMs);
|
|
};
|
|
|
|
const clearLocalActiveWashFromServer = (options: { preserveActionError?: boolean } = {}) => {
|
|
const completedLaneId = washLaneId.value;
|
|
completedDurationMs.value = washStartTime.value ? now.value - washStartTime.value : completedDurationMs.value ?? 0;
|
|
clearStoppingWashMarker();
|
|
markRecentlyCompletedWash(completedLaneId, licensePlateInput.value, getNumericCustomerNumber());
|
|
if (!options.preserveActionError) {
|
|
washActionError.value = null;
|
|
}
|
|
washInProgress.value = false;
|
|
washLaneId.value = null;
|
|
shouldRestoreServerActiveWash.value = false;
|
|
stopElapsedTimer();
|
|
clearProgress();
|
|
};
|
|
|
|
const syncActiveWashWithServer = async () => {
|
|
if (isSyncingActiveWash.value || !washInProgress.value || !washLaneId.value) {
|
|
return;
|
|
}
|
|
|
|
const laneId = normalizeLaneId(washLaneId.value);
|
|
if (!laneId) {
|
|
clearLocalActiveWashFromServer();
|
|
return;
|
|
}
|
|
|
|
isSyncingActiveWash.value = true;
|
|
try {
|
|
const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", {
|
|
lane_id: laneId,
|
|
});
|
|
const details = unwrapApiData(response);
|
|
if (!Object.prototype.hasOwnProperty.call(details || {}, "in_progress")) {
|
|
return;
|
|
}
|
|
|
|
const customerNumber = getNumericCustomerNumber();
|
|
const serverCustomerNumber = normalizePositiveInteger(
|
|
details?.session?.customer_number ?? details?.customer?.customer_number
|
|
);
|
|
const authenticatedSubuserId = getAuthenticatedSubuserId();
|
|
const serverSubuserId = normalizePositiveInteger(
|
|
details?.session?.subuser_id ?? details?.session?.subuser?.id ?? details?.subuser?.id
|
|
);
|
|
const serverStillMatchesCurrentWash =
|
|
!!details?.in_progress &&
|
|
(!customerNumber || serverCustomerNumber === customerNumber) &&
|
|
(!authenticatedSubuserId || serverSubuserId === authenticatedSubuserId);
|
|
const recentlyCompletedCurrentWash = isRecentlyCompletedActiveWash({ details, laneId });
|
|
|
|
if (recentlyCompletedCurrentWash) {
|
|
clearLocalActiveWashFromServer({ preserveActionError: isCompletingWash.value });
|
|
return;
|
|
}
|
|
|
|
if (!serverStillMatchesCurrentWash) {
|
|
const isWithinStartGracePeriod =
|
|
washStartTime.value && Date.now() - washStartTime.value < WASH_START_SERVER_SYNC_GRACE_MS;
|
|
const isBeforeCustomerWashCompletionPhase = currentStep.value < steps.WASH_IN_PROGRESS;
|
|
if (isWithinStartGracePeriod || isBeforeCustomerWashCompletionPhase || !isCompletingWash.value) {
|
|
return;
|
|
}
|
|
|
|
clearLocalActiveWashFromServer({ preserveActionError: isCompletingWash.value });
|
|
return;
|
|
}
|
|
|
|
const session = details?.session || {};
|
|
const vehicle = details?.vehicle || {};
|
|
const serverReg = normalizeLicensePlate(session?.reg ?? vehicle?.reg);
|
|
if (serverReg) {
|
|
licensePlateInput.value = serverReg;
|
|
}
|
|
|
|
applyEffectiveCustomerNumberInput(session?.customer_number ?? details?.customer?.customer_number ?? customerNumber);
|
|
vehicleTypeSelect.value =
|
|
normalizePositiveInteger(session?.vehicle_type_id ?? vehicle?.type) ?? vehicleTypeSelect.value;
|
|
radioWashType.value = session?.machine_relay_enabled ? "Machine" : radioWashType.value;
|
|
washStartTime.value =
|
|
parseServerDateTimeMs(session?.wash_started_at) ??
|
|
parseServerDateTimeMs(session?.machine_start_triggered_at) ??
|
|
parseServerDateTimeMs(session?.machine_relay_enabled_at) ??
|
|
washStartTime.value;
|
|
|
|
const summaryParams: Record<string, any> = session?.id
|
|
? { session_id: session.id }
|
|
: { lane_id: laneId, reg: normalizeLicensePlate(licensePlateInput.value) };
|
|
const selectedVehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
|
|
if (selectedVehicleTypeId) {
|
|
summaryParams.vehicle_type = selectedVehicleTypeId;
|
|
}
|
|
await fetchWashSummary(summaryParams, false);
|
|
saveProgress("serverActiveWashRefresh");
|
|
} catch (error) {
|
|
console.warn("Failed to refresh active self-serve wash:", error);
|
|
} finally {
|
|
isSyncingActiveWash.value = false;
|
|
}
|
|
};
|
|
|
|
const stopActiveWashRefresh = () => {
|
|
if (activeWashRefreshInterval.value) {
|
|
window.clearInterval(activeWashRefreshInterval.value);
|
|
activeWashRefreshInterval.value = null;
|
|
}
|
|
};
|
|
|
|
const startActiveWashRefresh = () => {
|
|
stopActiveWashRefresh();
|
|
if (!washInProgress.value || !washLaneId.value) {
|
|
return;
|
|
}
|
|
|
|
syncActiveWashWithServer();
|
|
activeWashRefreshInterval.value = window.setInterval(() => {
|
|
syncActiveWashWithServer();
|
|
}, ACTIVE_WASH_REFRESH_MS);
|
|
};
|
|
|
|
const retrySelfServeData = async () => {
|
|
if (isSelfServeRetrying.value) {
|
|
return;
|
|
}
|
|
|
|
isSelfServeRetrying.value = true;
|
|
try {
|
|
await fetchSelfServeData({ immediate: true });
|
|
} finally {
|
|
isSelfServeRetrying.value = false;
|
|
}
|
|
};
|
|
|
|
const retryWashAction = async () => {
|
|
washActionError.value = null;
|
|
if (currentStep.value === steps.SELECT_LANE) {
|
|
await handleConfirmNext();
|
|
return;
|
|
}
|
|
|
|
await retrySelfServeData();
|
|
};
|
|
|
|
const onSelectVehicleType = (selection: VehicleTypeTemplate) => {
|
|
if (isRestoring.value) {
|
|
return;
|
|
}
|
|
|
|
markStartFormUserInput();
|
|
vehicleStepError.value = null;
|
|
washActionError.value = null;
|
|
answerSyncError.value = null;
|
|
if (vehicleTypeSelect.value !== selection.id) {
|
|
hasExplicitWashTypeSelection.value = false;
|
|
}
|
|
vehicleTypeSelect.value = selection.id;
|
|
};
|
|
|
|
const onUpdateCustomerNumber = (value: string) => {
|
|
markStartFormUserInput();
|
|
vehicleStepError.value = null;
|
|
washActionError.value = null;
|
|
answerSyncError.value = null;
|
|
const authenticatedCustomerNumber = getAuthenticatedCustomerNumber();
|
|
customerNumberInput.value = authenticatedCustomerNumber ? String(authenticatedCustomerNumber) : value;
|
|
};
|
|
|
|
const onUpdateRegistrationNumber = (value: string) => {
|
|
markStartFormUserInput();
|
|
vehicleStepError.value = null;
|
|
washActionError.value = null;
|
|
answerSyncError.value = null;
|
|
const normalized = normalizeLicensePlate(value);
|
|
if (licensePlateInput.value !== normalized) {
|
|
hasExplicitWashTypeSelection.value = false;
|
|
}
|
|
licensePlateInput.value = normalized;
|
|
};
|
|
|
|
const onUpdateSelectedLaneId = (laneId: number) => {
|
|
const selectedLane = nearestDepartment.value?.lanes.find(
|
|
(entry: { id: number }) => normalizeLaneId(entry.id) === normalizeLaneId(laneId)
|
|
);
|
|
if (!selectedLane || !isLaneAvailable(selectedLane)) {
|
|
return;
|
|
}
|
|
|
|
markStartFormUserInput();
|
|
washActionError.value = null;
|
|
answerSyncError.value = null;
|
|
if (radioLaneOption.value !== laneId) {
|
|
hasExplicitWashTypeSelection.value = false;
|
|
}
|
|
radioLaneOption.value = laneId;
|
|
};
|
|
|
|
const onUpdateWashType = (washType: string) => {
|
|
markStartFormUserInput();
|
|
washActionError.value = null;
|
|
answerSyncError.value = null;
|
|
hasExplicitWashTypeSelection.value = true;
|
|
radioWashType.value = washType;
|
|
};
|
|
|
|
const onVehicleStepNext = async () => {
|
|
if (isVehicleStepNextLoading.value) {
|
|
return;
|
|
}
|
|
|
|
isVehicleStepNextLoading.value = true;
|
|
try {
|
|
hasCompletedGuidedWash.value = false;
|
|
vehicleStepError.value = null;
|
|
licensePlateInput.value = normalizeLicensePlate(licensePlateInput.value);
|
|
ensureEffectiveLaneSelection();
|
|
|
|
const addedOrExisting = await addVehicleIfMissing();
|
|
if (!addedOrExisting) {
|
|
return;
|
|
}
|
|
|
|
await fetchSelfServeData({ immediate: true });
|
|
await nextTick();
|
|
markQuestionReviewRequired();
|
|
currentStep.value = visibleQuestions.value.length > 0 ? steps.QUESTIONS : steps.SELECT_LANE;
|
|
} finally {
|
|
isVehicleStepNextLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const onPreviousStep = (fallbackPrevious: { action?: () => void } | null = null) => {
|
|
if (washInProgress.value || currentStep.value === steps.COMPLETED) {
|
|
return;
|
|
}
|
|
|
|
if (currentStep.value === steps.QUESTIONS) {
|
|
currentStep.value = steps.VEHICLE;
|
|
return;
|
|
}
|
|
|
|
if (currentStep.value === steps.SELECT_LANE) {
|
|
currentStep.value = visibleQuestions.value.length > 0 ? steps.QUESTIONS : steps.VEHICLE;
|
|
return;
|
|
}
|
|
|
|
if (currentStep.value === steps.TASKS) {
|
|
currentStep.value = steps.SELECT_LANE;
|
|
return;
|
|
}
|
|
|
|
fallbackPrevious?.action?.();
|
|
};
|
|
|
|
const onToggleTask = (taskId: number, value: boolean) => {
|
|
completedTasks.value = {
|
|
...completedTasks.value,
|
|
[taskId]: value,
|
|
};
|
|
};
|
|
|
|
const addQuestionSyncId = (target: typeof pendingQuestionSyncs, questionId: number) => {
|
|
if (!target.value.includes(questionId)) {
|
|
target.value = [...target.value, questionId];
|
|
}
|
|
};
|
|
|
|
const removeQuestionSyncId = (target: typeof pendingQuestionSyncs, questionId: number) => {
|
|
target.value = target.value.filter((candidateId) => candidateId !== questionId);
|
|
};
|
|
|
|
const buildQuestionSyncPayload = (questionId: number, value: boolean) => {
|
|
const laneId = getEffectiveLaneId();
|
|
if (!nearestDepartment.value || !laneId || !licensePlateInput.value?.trim()) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
departmentId: nearestDepartment.value.id,
|
|
laneId,
|
|
customerNumber: getNumericCustomerNumber(),
|
|
reg: licensePlateInput.value,
|
|
questionId,
|
|
value,
|
|
vehicleTypeId: vehicleTypeSelect.value || null,
|
|
};
|
|
};
|
|
|
|
const syncQuestionAnswer = async (questionId: number, value: boolean) => {
|
|
const normalizedQuestionId = normalizeQuestionId(questionId);
|
|
if (!normalizedQuestionId) {
|
|
return false;
|
|
}
|
|
|
|
const payload = buildQuestionSyncPayload(normalizedQuestionId, value);
|
|
if (!payload) {
|
|
return true;
|
|
}
|
|
|
|
addQuestionSyncId(pendingQuestionSyncs, normalizedQuestionId);
|
|
removeQuestionSyncId(failedQuestionSyncs, normalizedQuestionId);
|
|
|
|
try {
|
|
await syncVehicleAnswer(payload);
|
|
removeQuestionSyncId(failedQuestionSyncs, normalizedQuestionId);
|
|
if (!hasVisibleFailedQuestionSync.value) {
|
|
questionStepError.value = null;
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error synchronizing self-serve answer:", error);
|
|
addQuestionSyncId(failedQuestionSyncs, normalizedQuestionId);
|
|
questionStepError.value = extractErrorMessage(error, "Kunne ikke gemme dit svar. Prøv igen, før du fortsætter.");
|
|
return false;
|
|
} finally {
|
|
removeQuestionSyncId(pendingQuestionSyncs, normalizedQuestionId);
|
|
}
|
|
};
|
|
|
|
const submitQuestionAnswer = async (questionId: number, value: boolean) => {
|
|
answerQuestion(questionId, value);
|
|
await syncQuestionAnswer(questionId, value);
|
|
};
|
|
|
|
const retryFailedQuestionSyncs = async () => {
|
|
const retryQuestionIds = failedQuestionSyncs.value.filter((questionId) => visibleQuestionIds.value.has(questionId));
|
|
if (retryQuestionIds.length === 0) {
|
|
questionStepError.value = null;
|
|
return;
|
|
}
|
|
|
|
for (const questionId of retryQuestionIds) {
|
|
const value = answers.value[questionId];
|
|
if (value !== true && value !== false) {
|
|
removeQuestionSyncId(failedQuestionSyncs, questionId);
|
|
continue;
|
|
}
|
|
|
|
await syncQuestionAnswer(questionId, value);
|
|
}
|
|
};
|
|
|
|
const toggleEditAnswers = () => {
|
|
editAnswers.value = !editAnswers.value;
|
|
};
|
|
|
|
const isCurrentStepNextButtonDisabled = () =>
|
|
isNextButtonDisabled() || (currentStep.value === steps.QUESTIONS && hasBlockingVisibleQuestionSync.value);
|
|
|
|
const onConfirmNext = async () => {
|
|
if (isCurrentStepNextButtonDisabled()) {
|
|
return;
|
|
}
|
|
|
|
const wasQuestionsStep = currentStep.value === steps.QUESTIONS;
|
|
const previousQuestionReviewKey = confirmedQuestionReviewKey.value;
|
|
|
|
if (wasQuestionsStep) {
|
|
markQuestionReviewConfirmed();
|
|
}
|
|
|
|
await handleConfirmNext();
|
|
|
|
if (wasQuestionsStep && currentStep.value === steps.QUESTIONS) {
|
|
confirmedQuestionReviewKey.value = previousQuestionReviewKey;
|
|
}
|
|
};
|
|
|
|
const onCloseCompleted = () => {
|
|
stopElapsedTimer();
|
|
washInProgress.value = false;
|
|
washLaneId.value = null;
|
|
washStartTime.value = null;
|
|
completedDurationMs.value = null;
|
|
licensePlateInput.value = "";
|
|
answers.value = {};
|
|
editAnswers.value = false;
|
|
vehicleStepError.value = null;
|
|
washActionError.value = null;
|
|
answerSyncError.value = null;
|
|
hasStartFormUserInput.value = false;
|
|
isCompletingWash.value = false;
|
|
hasCompletedGuidedWash.value = false;
|
|
hasRecentlyCompletedWash.value = false;
|
|
try {
|
|
localStorage.removeItem(RECENT_COMPLETED_WASH_KEY);
|
|
} catch {}
|
|
clearStoppingWashMarker();
|
|
markQuestionReviewRequired();
|
|
resetGuidedWashStep();
|
|
currentStep.value = steps.VEHICLE;
|
|
clearProgress();
|
|
};
|
|
|
|
const onClearDynamicImage = () => {
|
|
hideDynamicImage.value = true;
|
|
};
|
|
|
|
onMounted(async () => {
|
|
isMyWashStartUnmounted.value = false;
|
|
isSelfServeFetchUnmounted.value = false;
|
|
setShowFooterInContent(false);
|
|
unregisterBeforeUnload.value = registerBeforeUnload();
|
|
scheduleRecentCompletedWashRefresh();
|
|
startRecentCompletedWashRefresh();
|
|
|
|
await fetchDepartments();
|
|
startAutoRefresh();
|
|
|
|
applyEffectiveCustomerNumberInput(customerNumberInput.value);
|
|
await fetchCustomerVehicles();
|
|
await fetchVehicleTypes();
|
|
const restoredProgress = restoreProgress();
|
|
if (restoredProgress?.washInProgress) {
|
|
restoreStoppingWashIfMatched({
|
|
laneId: restoredProgress.washLaneId,
|
|
reg: restoredProgress.licensePlateInput,
|
|
customerNumber: restoredProgress.customerNumberInput,
|
|
});
|
|
}
|
|
shouldRestoreServerActiveWash.value = !restoredProgress?.washInProgress;
|
|
scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
isMyWashStartUnmounted.value = true;
|
|
clearServerActiveWashRestoreTimeout();
|
|
clearRecentCompletedRefreshTimeout();
|
|
clearRecentCompletedRefreshInterval();
|
|
|
|
if (unregisterBeforeUnload.value) {
|
|
unregisterBeforeUnload.value();
|
|
}
|
|
|
|
isSelfServeFetchUnmounted.value = true;
|
|
clearScheduledSelfServeFetch(null);
|
|
abortActiveSelfServeFetch();
|
|
stopAutoRefresh();
|
|
stopActiveWashRefresh();
|
|
markDestroying();
|
|
setShowFooterInContent(true);
|
|
});
|
|
|
|
watch(
|
|
[
|
|
() => nearestDepartment.value?.id,
|
|
() => radioLaneOption.value,
|
|
() => vehicleTypeSelect.value,
|
|
() => licensePlateInput.value,
|
|
],
|
|
() => {
|
|
if (isRestoring.value) {
|
|
return;
|
|
}
|
|
fetchSelfServeData();
|
|
}
|
|
);
|
|
|
|
watch(
|
|
[
|
|
() => selfServeResolvedVehicleTypeId.value,
|
|
() => (availableProductIds.value || []).map((productId) => String(productId)).join(","),
|
|
],
|
|
() => {
|
|
applyResolvedVehicleTypeSelection();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => licensePlateInput.value,
|
|
(newValue, oldValue) => {
|
|
if (oldValue !== null && newValue !== oldValue && !washInProgress.value && !isRestoring.value) {
|
|
answers.value = {};
|
|
completedTasks.value = {};
|
|
}
|
|
|
|
const normalizedNewValue = normalizeLicensePlate(newValue);
|
|
if (normalizedNewValue && doesUserVehicleExist(normalizedNewValue)) {
|
|
const vehicle = customerVehicles.value.find((entry) => entry.reg.toUpperCase() === normalizedNewValue);
|
|
if (vehicle && !washInProgress.value && !isRestoring.value) {
|
|
vehicleTypeSelect.value = vehicle.type || null;
|
|
}
|
|
} else if (!normalizedNewValue && !washInProgress.value && !isRestoring.value) {
|
|
vehicleTypeSelect.value = null;
|
|
}
|
|
|
|
if (normalizedNewValue.length >= 2 && !isRestoring.value) {
|
|
fetchSelfServeData();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => currentStep.value,
|
|
(newStep, oldStep) => {
|
|
if (shouldReturnToQuestionsForReview.value) {
|
|
currentStep.value = steps.QUESTIONS;
|
|
return;
|
|
}
|
|
|
|
if (
|
|
isRestoring.value &&
|
|
washInProgress.value &&
|
|
newStep === steps.VEHICLE &&
|
|
oldStep !== steps.VEHICLE &&
|
|
typeof oldStep !== "undefined"
|
|
) {
|
|
currentStep.value = oldStep;
|
|
return;
|
|
}
|
|
|
|
if (oldStep === steps.QUESTIONS && newStep !== steps.QUESTIONS) {
|
|
editAnswers.value = false;
|
|
}
|
|
|
|
if (!isRestoring.value) {
|
|
saveProgress("watch-currentStep");
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => shouldDefaultToMachineWash.value,
|
|
(shouldUseMachineWash) => {
|
|
if (shouldUseMachineWash) {
|
|
radioWashType.value = "Machine";
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => questionReviewStateKey.value,
|
|
(newValue, oldValue) => {
|
|
if (newValue !== oldValue) {
|
|
markQuestionReviewRequired();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => SessionUser.user.customer_number.value,
|
|
(newValue) => {
|
|
applyEffectiveCustomerNumberInput(newValue);
|
|
|
|
if (newValue && shouldRestoreServerActiveWash.value && !washInProgress.value) {
|
|
restoreServerActiveWash();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => customerNumberInput.value,
|
|
(newValue, oldValue) => {
|
|
if (oldValue !== null && newValue !== oldValue && !washInProgress.value && !isRestoring.value) {
|
|
answers.value = {};
|
|
completedTasks.value = {};
|
|
}
|
|
|
|
if (licensePlateInput.value && newValue && !isRestoring.value) {
|
|
fetchSelfServeData();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
[
|
|
() => washInProgress.value,
|
|
() => washLaneId.value,
|
|
() => washStartTime.value,
|
|
() => currentStep.value,
|
|
() => radioLaneOption.value,
|
|
() => radioWashType.value,
|
|
() => licensePlateInput.value,
|
|
() => vehicleTypeSelect.value,
|
|
() => customerNumberInput.value,
|
|
() => answers.value,
|
|
() => completedTasks.value,
|
|
],
|
|
() => {
|
|
if (!isRestoring.value) {
|
|
saveProgress("watch-deep");
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
watch([() => washInProgress.value, () => washLaneId.value], ([inProgress]) => {
|
|
if (inProgress) {
|
|
startActiveWashRefresh();
|
|
return;
|
|
}
|
|
|
|
stopActiveWashRefresh();
|
|
});
|
|
|
|
watch(
|
|
() => nearestDepartment.value,
|
|
(newValue) => {
|
|
if (newValue && newValue.lanes && newValue.lanes.length > 0) {
|
|
const selectedLaneId = normalizeLaneId(radioLaneOption.value);
|
|
const selectedLane = selectedLaneId
|
|
? newValue.lanes.find((lane) => normalizeLaneId(lane.id) === selectedLaneId)
|
|
: null;
|
|
if (!selectedLane || !isLaneAvailable(selectedLane)) {
|
|
const availableLane = newValue.lanes.find(isLaneAvailable);
|
|
if (!washInProgress.value) {
|
|
radioLaneOption.value = availableLane?.id ?? null;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
watch(
|
|
() => radioLaneOption.value,
|
|
(newLaneId) => {
|
|
if (newLaneId && newLaneId !== "Any" && !isRestoring.value) {
|
|
fetchSelfServeData().then(() => {
|
|
if (
|
|
!allVisibleQuestionsAnswered.value &&
|
|
!washInProgress.value &&
|
|
currentStep.value > steps.QUESTIONS &&
|
|
currentStep.value < steps.COMPLETED
|
|
) {
|
|
currentStep.value = steps.QUESTIONS;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
[() => currentStep.value, () => questionReviewStateKey.value, () => washInProgress.value],
|
|
() => {
|
|
if (shouldReturnToQuestionsForReview.value) {
|
|
currentStep.value = steps.QUESTIONS;
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => isRestoring.value,
|
|
(restoring) => {
|
|
if (!restoring) {
|
|
ensureEffectiveLaneSelection();
|
|
fetchSelfServeData();
|
|
}
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => isForcingNearestDepartment.value,
|
|
(newValue) => {
|
|
if (!newValue) {
|
|
forceNearestDepartmentEvaluationId.value = 0;
|
|
}
|
|
|
|
evaluateLocationDepartments(locations.location.value);
|
|
saveProgress("isForcingNearestDepartment");
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => forceNearestDepartmentEvaluationId.value,
|
|
() => {
|
|
saveProgress("forceNearestDepartmentEvaluationId");
|
|
}
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<UserDashboardPageWrapper
|
|
:title="$t('user_dashboard.wash.title')"
|
|
:subtitle="$t('user_dashboard.wash.subtitle')"
|
|
required-feature="selfserve"
|
|
required-action="add"
|
|
>
|
|
<PosDepartmentStepMobile1Location @location-updated="evaluateLocationDepartments" />
|
|
<div class="columns is-multiline">
|
|
<div class="column is-12">
|
|
<section class="self-serve-start-page" data-testid="self-serve-start-page">
|
|
<SelfServeDepartmentHeader
|
|
:nearest-department="nearestDepartment"
|
|
:is-forcing-nearest-department="isForcingNearestDepartment"
|
|
:is-searching-departments="isSearchingDepartments"
|
|
:is-searching-loading="guestDepartments.length === 0"
|
|
:guest-departments="guestDepartments"
|
|
:can-access-super-user="canUseDepartmentHeaderSelection"
|
|
:show-progress="currentStep === steps.TASKS || currentStep === steps.WASH_IN_PROGRESS"
|
|
:progress-label="$t('self_wash.wash_in_progress')"
|
|
:progress-value="formattedElapsed"
|
|
:progress-registration="licensePlateInput"
|
|
:progress-vehicle-product="selectedVehicleTypeProductName"
|
|
@start-search="startDepartmentSearch"
|
|
@select-department="selectDepartment"
|
|
@clear-forced="clearForcedDepartment"
|
|
/>
|
|
<section v-if="shouldShowDisabledDepartmentWarning" class="mb-5">
|
|
<div class="notification is-warning is-light has-text-centered" data-testid="self-serve-disabled-warning">
|
|
<span class="icon is-large">
|
|
<i class="fas fa-exclamation-triangle fa-2x"></i>
|
|
</span>
|
|
<p class="mt-2 mb-0">{{ $t("self_wash.department_no_self_wash_in_staffed_hours") }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<ErrorBanner
|
|
:department-fetch-error="departmentFetchError"
|
|
:self-serve-data-error="selfServeDataError"
|
|
:wash-action-error="washActionError"
|
|
:is-self-serve-retrying="isSelfServeRetrying"
|
|
:is-starting-wash="isStartingWash"
|
|
@retry-departments="fetchDepartments"
|
|
@retry-self-serve-data="retrySelfServeData"
|
|
@retry-wash-action="retryWashAction"
|
|
/>
|
|
|
|
<div
|
|
v-show="isCompletedWashState"
|
|
class="self-serve-completed-fallback"
|
|
data-testid="self-serve-completed-fallback"
|
|
>
|
|
<SelfServeCompletedStep :completed-duration-ms="completedDurationMs" />
|
|
<div class="buttons is-centered self-serve-prewash-actions" data-testid="self-serve-completed-actions">
|
|
<div class="self-serve-bottom-actions__row self-serve-bottom-actions__row--prewash">
|
|
<b-button
|
|
class="self-serve-bottom-actions__button"
|
|
type="is-link"
|
|
icon-pack="fas"
|
|
icon-left="times"
|
|
data-testid="self-serve-nav-close"
|
|
@click.prevent="onCloseCompleted"
|
|
>
|
|
{{ $t("common.close") }}
|
|
</b-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="shouldShowWashFlow && !isCompletedWashState">
|
|
<b-steps
|
|
:key="currentStep"
|
|
v-model="displayedStep"
|
|
class="self-serve-flow-steps"
|
|
:class="{
|
|
'self-serve-flow-steps--question-review': currentStep === steps.QUESTIONS,
|
|
'self-serve-flow-steps--wash-active': currentStep === steps.WASH_IN_PROGRESS,
|
|
}"
|
|
type="is-link"
|
|
size="is-small"
|
|
mobile-mode="compact"
|
|
:has-navigation="true"
|
|
data-testid="self-serve-steps"
|
|
>
|
|
<b-step-item
|
|
:label="$t('self_wash.select_vehicle')"
|
|
icon-pack="fas"
|
|
icon="car-side"
|
|
:step="steps.VEHICLE"
|
|
:clickable="clickableSteps[steps.VEHICLE]()"
|
|
>
|
|
<VehicleInputSection
|
|
:customer-number="customerNumberInput"
|
|
:show-customer-number-input="showCustomerNumberInput"
|
|
:registration-number="licensePlateInput"
|
|
:registration-options="registrationOptions"
|
|
:is-customer-vehicles-loading="isCustomerVehiclesLoading"
|
|
:has-matching-vehicle="hasMatchingVehicleForInput"
|
|
:selected-vehicle-type-id="vehicleTypeSelect"
|
|
:selected-vehicle-name="selectedVehicleTypeName"
|
|
:selected-vehicle-description="selectedVehicleTypeDescription"
|
|
:available-product-ids="availableProductIds"
|
|
:vehicle-types="vehicleTypes"
|
|
:vehicle-step-error="vehicleStepError"
|
|
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
|
|
:is-red-car="isRedCarCustomerFlag"
|
|
@update:customer-number="onUpdateCustomerNumber"
|
|
@update:registration-number="onUpdateRegistrationNumber"
|
|
@select-vehicle-type="onSelectVehicleType"
|
|
/>
|
|
</b-step-item>
|
|
|
|
<b-step-item
|
|
:label="$t('self_wash.configure_wash')"
|
|
icon-pack="fas"
|
|
icon="droplet"
|
|
:step="steps.QUESTIONS"
|
|
:clickable="clickableSteps[steps.QUESTIONS]()"
|
|
:visible="isQuestionsStepVisible"
|
|
>
|
|
<b-message
|
|
v-if="questionStepError"
|
|
type="is-danger"
|
|
has-icon
|
|
:closable="false"
|
|
data-testid="self-serve-question-sync-error"
|
|
>
|
|
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
|
|
<span class="mr-3">{{ questionStepError }}</span>
|
|
<b-button
|
|
size="is-small"
|
|
type="is-danger is-light"
|
|
icon-pack="fas"
|
|
icon-left="sync-alt"
|
|
data-testid="self-serve-question-sync-retry"
|
|
:loading="hasVisiblePendingQuestionSync"
|
|
@click="retryFailedQuestionSyncs"
|
|
>
|
|
{{ $t("common.try_again") }}
|
|
</b-button>
|
|
</div>
|
|
</b-message>
|
|
|
|
<SelfServeQuestionsStep
|
|
:is-loading="isLoadingSelfServeData"
|
|
:visible-questions="visibleQuestions"
|
|
:answers="answers"
|
|
:edit-answers="editAnswers"
|
|
@toggle-edit="toggleEditAnswers"
|
|
@answer-question="submitQuestionAnswer"
|
|
/>
|
|
|
|
<div v-if="answerSyncError" data-testid="self-serve-answer-sync-error">
|
|
<b-message type="is-danger" :aria-close-label="$t('common.close')">
|
|
{{ answerSyncError }}
|
|
</b-message>
|
|
</div>
|
|
</b-step-item>
|
|
|
|
<b-step-item
|
|
:label="$t('self_wash.configure_wash')"
|
|
icon-pack="fas"
|
|
icon="droplet"
|
|
:step="steps.SELECT_LANE"
|
|
:clickable="clickableSteps[steps.SELECT_LANE]()"
|
|
:visible="isLaneStepVisible"
|
|
>
|
|
<div data-testid="self-serve-lane-step">
|
|
<LaneSelectionSection
|
|
:lanes="selectedDepartmentLanes"
|
|
:selected-lane-id="radioLaneOption"
|
|
:is-lane-available="isLaneAvailable"
|
|
@update:selected-lane-id="onUpdateSelectedLaneId"
|
|
/>
|
|
|
|
<WashTypeSelector
|
|
:model-value="radioWashType"
|
|
:is-machine-available="isSelectedLaneMachineAvailable"
|
|
@update:model-value="onUpdateWashType"
|
|
/>
|
|
</div>
|
|
</b-step-item>
|
|
|
|
<b-step-item
|
|
:label="$t('self_wash.perform_wash')"
|
|
icon-pack="fas"
|
|
icon="soap"
|
|
:step="steps.TASKS"
|
|
:clickable="clickableSteps[steps.TASKS]()"
|
|
:visible="isTasksStepVisible"
|
|
>
|
|
<SelfServeTasksStep
|
|
:is-loading="isLoadingSelfServeData"
|
|
:dynamic-image-url="displayedDynamicImageUrl"
|
|
:active-tasks="displayedActiveTasks"
|
|
:completed-tasks="completedTasks"
|
|
:all-visible-questions-answered="allVisibleQuestionsAnswered"
|
|
:edit-answers="editAnswers"
|
|
@toggle-task="onToggleTask"
|
|
@download-attachment="downloadAttachment"
|
|
@clear-dynamic-image="onClearDynamicImage"
|
|
/>
|
|
</b-step-item>
|
|
|
|
<b-step-item
|
|
:label="$t('self_wash.perform_wash')"
|
|
icon-pack="fas"
|
|
icon="soap"
|
|
:step="steps.WASH_IN_PROGRESS"
|
|
:clickable="clickableSteps[steps.WASH_IN_PROGRESS]()"
|
|
:visible="currentStep === steps.WASH_IN_PROGRESS"
|
|
>
|
|
</b-step-item>
|
|
|
|
<b-step-item
|
|
:label="$t('self_wash.perform_wash')"
|
|
icon-pack="fas"
|
|
icon="soap"
|
|
:step="steps.COMPLETED"
|
|
:visible="currentStep === steps.COMPLETED"
|
|
>
|
|
</b-step-item>
|
|
|
|
<template #navigation="{ previous }">
|
|
<div
|
|
v-show="currentStep !== steps.WASH_IN_PROGRESS"
|
|
class="buttons is-centered self-serve-prewash-actions"
|
|
:class="{ 'self-serve-prewash-actions--question-review': currentStep === steps.QUESTIONS }"
|
|
data-testid="self-serve-prewash-bottom-actions"
|
|
>
|
|
<div
|
|
class="self-serve-bottom-actions__row self-serve-bottom-actions__row--prewash"
|
|
data-testid="self-serve-prewash-navigation-actions"
|
|
>
|
|
<b-button
|
|
v-show="currentStep !== steps.WASH_IN_PROGRESS && currentStep !== steps.COMPLETED"
|
|
class="self-serve-bottom-actions__button"
|
|
type="is-link is-light"
|
|
icon-pack="fas"
|
|
icon-left="arrow-left"
|
|
data-testid="self-serve-nav-previous"
|
|
:disabled="previous.disabled || currentStep === steps.COMPLETED || washInProgress"
|
|
@click.prevent="onPreviousStep(previous)"
|
|
>
|
|
{{ $t("common.previous") }}
|
|
</b-button>
|
|
|
|
<b-button
|
|
v-if="currentStep !== steps.COMPLETED"
|
|
v-show="currentStep === steps.VEHICLE"
|
|
class="self-serve-bottom-actions__button"
|
|
type="is-link"
|
|
icon-pack="fas"
|
|
icon-right="arrow-right"
|
|
data-testid="self-serve-nav-next"
|
|
:loading="isVehicleStepNextLoading"
|
|
:disabled="isVehicleStepNextLoading || isCurrentStepNextButtonDisabled()"
|
|
@click.prevent="onVehicleStepNext"
|
|
>
|
|
{{ $t("common.next") }}
|
|
</b-button>
|
|
|
|
<b-button
|
|
v-if="currentStep === steps.COMPLETED"
|
|
class="self-serve-bottom-actions__button"
|
|
type="is-link"
|
|
icon-pack="fas"
|
|
icon-left="times"
|
|
data-testid="self-serve-nav-close"
|
|
@click.prevent="onCloseCompleted"
|
|
>
|
|
{{ $t("common.close") }}
|
|
</b-button>
|
|
|
|
<b-button
|
|
v-show="
|
|
currentStep === steps.SELECT_LANE ||
|
|
currentStep === steps.QUESTIONS ||
|
|
currentStep === steps.TASKS
|
|
"
|
|
class="self-serve-bottom-actions__button"
|
|
type="is-link"
|
|
icon-pack="fas"
|
|
icon-right="arrow-right"
|
|
data-testid="self-serve-nav-confirm"
|
|
:loading="isStartingWash"
|
|
:disabled="isStartingWash || isCurrentStepNextButtonDisabled()"
|
|
@click="onConfirmNext"
|
|
>
|
|
{{ $t(confirmActionLabelKey) }}
|
|
</b-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</b-steps>
|
|
|
|
<div
|
|
class="self-serve-wash-progress-panel"
|
|
:class="{ 'self-serve-wash-progress-panel--active': currentStep === steps.WASH_IN_PROGRESS }"
|
|
>
|
|
<WashProgressCard
|
|
:current-guided-wash-step="currentGuidedWashStep"
|
|
:guided-wash-flow-steps="guidedWashFlowSteps"
|
|
:formatted-elapsed="formattedElapsed"
|
|
:is-completing-wash="isCompletingWash"
|
|
:opening-property-access-gate="openingPropertyAccessGate"
|
|
:opening-property-exit-gate="openingPropertyExitGate"
|
|
:show-progress-actions="currentStep === steps.WASH_IN_PROGRESS"
|
|
@update:currentGuidedWashStep="currentGuidedWashStep = $event"
|
|
@goPreviousGuidedWashStep="goPreviousGuidedWashStep()"
|
|
@goNextGuidedWashStep="goNextGuidedWashStep()"
|
|
@completeWash="completeGuidedWash()"
|
|
@openPropertyAccessGate="openPropertyAccessGate(washLaneId)"
|
|
@openPropertyExitGate="openPropertyExitGate(washLaneId)"
|
|
@requestAssistance="SessionUser.functions.contact.onClickCallPhoneNumber()"
|
|
>
|
|
<template #guided-instructions="{ currentStep: cs, steps: gs }">
|
|
<SelfServeGuidedInstructions
|
|
:steps="gs"
|
|
:current-step="cs"
|
|
:show-actions="false"
|
|
@update:current-step="currentGuidedWashStep = $event"
|
|
/>
|
|
</template>
|
|
</WashProgressCard>
|
|
</div>
|
|
</template>
|
|
|
|
<b-message
|
|
v-else-if="shouldShowChooseDepartmentMessage"
|
|
type="is-info"
|
|
:aria-close-label="$t('common.close')"
|
|
data-testid="self-serve-choose-department-message"
|
|
>
|
|
Vælg en afdeling for at starte vask. Din placering er ikke tilgængelig, så vi kan ikke finde den nærmeste
|
|
afdeling automatisk.
|
|
</b-message>
|
|
|
|
<b-message v-else-if="shouldShowLoadingDataMessage" type="is-info" :aria-close-label="$t('common.close')">
|
|
{{ $t("self_wash.loading_data") }}
|
|
</b-message>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</UserDashboardPageWrapper>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.self-serve-flow-steps {
|
|
--bulma-steps-details-background-color: #ffffff;
|
|
--bulma-steps-maker-default-color: #dfe5f0;
|
|
--bulma-steps-default-color: #dfe5f0;
|
|
--bulma-steps-divider-height: 0.16rem;
|
|
margin-top: 0.25rem;
|
|
max-width: 100%;
|
|
overflow-x: clip;
|
|
}
|
|
|
|
.self-serve-start-page {
|
|
max-width: 100%;
|
|
overflow-x: clip;
|
|
}
|
|
|
|
.self-serve-wash-progress-panel {
|
|
max-width: 100%;
|
|
overflow-x: clip;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps) {
|
|
margin-bottom: 0.85rem;
|
|
min-height: 2rem;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps .step-items) {
|
|
align-items: flex-start;
|
|
flex-wrap: nowrap;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps .step-items .step-item) {
|
|
min-width: 0;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps .step-items .step-item .step-link) {
|
|
min-width: 0;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps .step-items .step-item .step-marker) {
|
|
box-shadow: 0 0 0 2px #ffffff;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps .step-items .step-item .step-details) {
|
|
max-width: 6.25rem;
|
|
padding-top: 0.25rem;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps .step-items .step-item .step-details .step-title) {
|
|
color: #112f5f;
|
|
font-size: 0.76rem;
|
|
line-height: 1.15;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps + .step-content) {
|
|
max-width: 100%;
|
|
overflow-x: clip;
|
|
padding: 0.45rem 0 0;
|
|
}
|
|
|
|
.self-serve-prewash-actions {
|
|
justify-content: center;
|
|
margin: 0.75rem auto 0;
|
|
max-width: 30rem;
|
|
width: 100%;
|
|
}
|
|
|
|
.self-serve-prewash-actions :deep(.button) {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.self-serve-bottom-actions__row--prewash {
|
|
max-width: 30rem;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Wash progress card bottom actions (during active wash) */
|
|
.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%;
|
|
}
|
|
|
|
@media screen and (max-width: 768px) {
|
|
.self-serve-flow-steps {
|
|
padding-bottom: 14rem;
|
|
}
|
|
|
|
.self-serve-flow-steps--question-review {
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.self-serve-flow-steps--wash-active {
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.self-serve-wash-progress-panel--active {
|
|
padding-bottom: 14rem;
|
|
}
|
|
|
|
.self-serve-prewash-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-prewash-actions--question-review {
|
|
background: transparent;
|
|
border-top: 0;
|
|
bottom: auto;
|
|
box-shadow: none;
|
|
left: auto;
|
|
margin: 0.75rem auto 0;
|
|
max-width: 30rem;
|
|
padding: 0;
|
|
position: static;
|
|
right: auto;
|
|
width: 100%;
|
|
z-index: auto;
|
|
}
|
|
|
|
.self-serve-bottom-actions__row--prewash {
|
|
max-width: none;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps.mobile-compact .step-items .step-item .step-marker) {
|
|
height: 1.7rem;
|
|
width: 1.7rem;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps.mobile-compact .step-items .step-item .step-marker .icon *) {
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps.mobile-compact .step-items .step-item .step-details) {
|
|
max-width: 5.75rem;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps.mobile-compact .step-items .step-item .step-details .step-title) {
|
|
font-size: 0.74rem;
|
|
}
|
|
|
|
.self-serve-flow-steps :deep(.steps.mobile-compact .step-items .step-item:not(.is-active) .step-link) {
|
|
cursor: default;
|
|
}
|
|
}
|
|
</style>
|