Files
pleno-vue/src/views/dashboards/userDashboard/wash/MyWashStart.vue
T
a345d91ae4 feat(pleno-vue): warning on red cars (TRU-99) (#318)
## 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>
2026-08-16 16:22:05 +02:00

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>