diff --git a/src/components/pagination/departmentTabs.vue b/src/components/pagination/departmentTabs.vue index 50edb9a9..ab680847 100644 --- a/src/components/pagination/departmentTabs.vue +++ b/src/components/pagination/departmentTabs.vue @@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => { export const getDepartmentsGuest = async (queryParams = {}) => { isLoading.value = true; - const queryString = new URLSearchParams(queryParams).toString(); - const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get"); - departments.value = sortByDepartmentPriorityOrder(request.data.data || []); - isLoading.value = false; - return departments.value; + try { + const queryString = new URLSearchParams(queryParams).toString(); + const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get"); + departments.value = sortByDepartmentPriorityOrder(request.data.data || []); + return departments.value; + } finally { + isLoading.value = false; + } }; diff --git a/src/composables/useWashDepartments.js b/src/composables/useWashDepartments.js index 195a9555..e6c6e5f4 100644 --- a/src/composables/useWashDepartments.js +++ b/src/composables/useWashDepartments.js @@ -2,12 +2,17 @@ import { computed, nextTick, onUnmounted, ref, watch } from "vue"; import { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue"; import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue"; -const isLaneSelfServeEnabled = (lane) => !( - lane?.selfserve_enabled === false - || lane?.selfserve_enabled === 0 - || lane?.selfserve_enabled === "0" - || ["false", "off", "no"].includes(String(lane?.selfserve_enabled ?? "").trim().toLowerCase()) -); +const isLaneSelfServeEnabled = (lane) => + !( + lane?.selfserve_enabled === false || + lane?.selfserve_enabled === 0 || + lane?.selfserve_enabled === "0" || + ["false", "off", "no"].includes( + String(lane?.selfserve_enabled ?? "") + .trim() + .toLowerCase() + ) + ); const toDepartmentViewModel = (department, distance = null) => ({ id: department.id, @@ -18,12 +23,22 @@ const toDepartmentViewModel = (department, distance = null) => ({ self_serve_enabled: department.self_serve_enabled, }); +const extractDepartmentFetchErrorMessage = (error) => { + const candidates = [ + error?.response?.data?.data?.message, + error?.response?.data?.message, + error?.response?.data?.error, + error?.message, + ]; + + return ( + candidates.find((candidate) => typeof candidate === "string" && candidate.trim() !== "") || + "Kunne ikke hente vaskeafdelinger. Prøv igen." + ); +}; + export function useWashDepartments(options = {}) { - const { - includeLanes = false, - refreshIntervalMs = 0, - canAccessSuperUser = () => false, - } = options; + const { includeLanes = false, refreshIntervalMs = 0, canAccessSuperUser = () => false } = options; const guestDepartments = ref([]); const nearestDepartment = ref(null); @@ -31,6 +46,7 @@ export function useWashDepartments(options = {}) { const forceNearestDepartmentEvaluationId = ref(0); const isSearchingDepartments = ref(false); const lastDepartmentFetchTime = ref(null); + const departmentFetchError = ref(null); let refreshInterval = null; @@ -57,13 +73,18 @@ export function useWashDepartments(options = {}) { return nearestDepartment.value.self_serve_enabled === true; }); - const buildGuestDepartmentParams = () => ( - includeLanes ? { include_lanes: true } : {} - ); + const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {}); const fetchDepartments = async () => { - guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams()); - lastDepartmentFetchTime.value = Date.now(); + try { + guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams()); + lastDepartmentFetchTime.value = Date.now(); + departmentFetchError.value = null; + } catch (error) { + departmentFetchError.value = extractDepartmentFetchErrorMessage(error); + console.warn("Failed to fetch wash departments:", error); + } + evaluateLocationDepartments(locations.location.value); return guestDepartments.value; }; @@ -74,19 +95,15 @@ export function useWashDepartments(options = {}) { } const currentTime = Date.now(); - if ( - !lastDepartmentFetchTime.value - || (currentTime - lastDepartmentFetchTime.value) >= refreshIntervalMs - ) { + if (!lastDepartmentFetchTime.value || currentTime - lastDepartmentFetchTime.value >= refreshIntervalMs) { return fetchDepartments(); } return guestDepartments.value; }; - const getForcedDepartment = () => guestDepartments.value.find( - (department) => department.id === forceNearestDepartmentEvaluationId.value - ); + const getForcedDepartment = () => + guestDepartments.value.find((department) => department.id === forceNearestDepartmentEvaluationId.value); const evaluateLocationDepartments = (locationValue = locations.location.value) => { if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) { @@ -191,19 +208,30 @@ export function useWashDepartments(options = {}) { } }; - watch(() => guestDepartments.value, () => { - evaluateLocationDepartments(locations.location.value); - }, { deep: true }); - - watch(() => locations.location.value, (newValue) => { - evaluateLocationDepartments(newValue); - }, { deep: true }); - - watch(() => isSearchingDepartments.value, (isSearching) => { - if (!isSearching) { + watch( + () => guestDepartments.value, + () => { evaluateLocationDepartments(locations.location.value); + }, + { deep: true } + ); + + watch( + () => locations.location.value, + (newValue) => { + evaluateLocationDepartments(newValue); + }, + { deep: true } + ); + + watch( + () => isSearchingDepartments.value, + (isSearching) => { + if (!isSearching) { + evaluateLocationDepartments(locations.location.value); + } } - }); + ); onUnmounted(() => { stopAutoRefresh(); @@ -216,6 +244,7 @@ export function useWashDepartments(options = {}) { forceNearestDepartmentEvaluationId, isSearchingDepartments, lastDepartmentFetchTime, + departmentFetchError, availableProductIds, doesCurrentDepartmentSelectionHaveSelfServeEnabled, fetchDepartments, diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index ae0272d4..5fc93542 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -83,6 +83,7 @@ const { isForcingNearestDepartment, forceNearestDepartmentEvaluationId, isSearchingDepartments, + departmentFetchError, availableProductIds, doesCurrentDepartmentSelectionHaveSelfServeEnabled, fetchDepartments, @@ -436,6 +437,8 @@ const extractErrorMessage = (error: any, fallback: string) => { return fallback; }; +const getRequestStatus = (error: any) => Number(error?.response?.status ?? error?.status ?? 0); + watch(dynamicImageUrl, () => { hideDynamicImage.value = false; }); @@ -779,7 +782,17 @@ const fetchServerActiveWash = async () => { return null; } - const response = await SessionUser.request(SERVER_ACTIVE_WASH_ENDPOINT, "GET"); + let response = null; + 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; @@ -848,7 +861,10 @@ const applyServerActiveWash = async (activeWash: any) => { const summary = await fetchWashSummary(summaryParams, false); if (!summary) { - await fetchSelfServeDataInternal(departmentId || nearestDepartment.value?.id, selectedVehicleTypeId, laneId, reg); + const fallbackDepartmentId = departmentId || nearestDepartment.value?.id; + if (fallbackDepartmentId) { + await fetchSelfServeDataInternal(fallbackDepartmentId, selectedVehicleTypeId, laneId, reg); + } } saveProgress("serverActiveWash"); @@ -1439,6 +1455,28 @@ watch( + + + {{ departmentFetchError }} + + {{ $t("common.try_again") }} + + + + ({ @@ -27,27 +27,30 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment }, })); -import { useWashDepartments } from "@/composables/useWashDepartments.js"; +const { useWashDepartments } = await import("@/composables/useWashDepartments.js"); -const mountComposable = (factory) => { - let result; +const mountedWrappers = []; - mount( - defineComponent({ - setup() { - result = factory(); - return () => h("div"); - }, - }) - ); +const mountDepartments = (options = {}) => { + let departments = null; + const wrapper = mount({ + setup() { + departments = useWashDepartments(options); + return {}; + }, + template: "", + }); + mountedWrappers.push(wrapper); - return result; + return departments; }; describe("useWashDepartments", () => { beforeEach(() => { + mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount()); mocks.getDepartmentsGuest.mockReset(); mocks.getDistance.mockReset(); + vi.restoreAllMocks(); mocks.locationRef.value = { coords: { latitude: 55.5, @@ -88,7 +91,7 @@ describe("useWashDepartments", () => { }, ]); - const departments = mountComposable(() => useWashDepartments({ includeLanes: true })); + const departments = mountDepartments({ includeLanes: true }); await departments.fetchDepartments(); @@ -104,11 +107,9 @@ describe("useWashDepartments", () => { { id: 2, name: "South", address: "B", latitude: 55.7, longitude: 12.6, lanes: [], self_serve_enabled: true }, ]); - const departments = mountComposable(() => - useWashDepartments({ - canAccessSuperUser: () => true, - }) - ); + const departments = mountDepartments({ + canAccessSuperUser: () => true, + }); await departments.fetchDepartments(); departments.startDepartmentSearch(); @@ -125,4 +126,43 @@ describe("useWashDepartments", () => { expect(departments.forceNearestDepartmentEvaluationId.value).toBe(0); expect(departments.nearestDepartment.value).toMatchObject({ id: 2, name: "South" }); }); + + it("keeps the wash start page usable when the guest departments request fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + mocks.getDepartmentsGuest.mockRejectedValueOnce(new Error("Network Error")); + const departments = mountDepartments({ includeLanes: true }); + + await expect(departments.fetchDepartments()).resolves.toEqual([]); + + expect(departments.guestDepartments.value).toEqual([]); + expect(departments.nearestDepartment.value).toBeNull(); + expect(departments.departmentFetchError.value).toBe("Network Error"); + expect(mocks.getDepartmentsGuest).toHaveBeenCalledWith({ include_lanes: true }); + }); + + it("clears the department fetch error after a successful retry", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const department = { + id: 3, + name: "Glostrup", + address: "Fabriksparken 18", + latitude: 55.6, + longitude: 12.5, + self_serve_enabled: true, + lanes: [{ id: 7, status: "AVAILABLE", selfserve_enabled: true }], + }; + mocks.getDepartmentsGuest.mockRejectedValueOnce(new Error("Gateway Timeout")).mockResolvedValueOnce([department]); + const departments = mountDepartments({ includeLanes: true }); + + await departments.fetchDepartments(); + await departments.fetchDepartments(); + + expect(departments.departmentFetchError.value).toBeNull(); + expect(departments.guestDepartments.value).toEqual([department]); + expect(departments.nearestDepartment.value).toMatchObject({ + id: 3, + name: "Glostrup", + lanes: [{ id: 7 }], + }); + }); });