Handle self-serve wash without geolocation

This commit is contained in:
Jeppe B
2026-06-02 13:42:16 +02:00
parent 5170139a32
commit 395cbd87dd
6 changed files with 265 additions and 16 deletions
+24 -8
View File
@@ -14,6 +14,9 @@ const isLaneSelfServeEnabled = (lane) =>
)
);
const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const toDepartmentViewModel = (department, distance = null) => ({
id: department.id,
distance,
@@ -47,6 +50,7 @@ export function useWashDepartments(options = {}) {
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
const departmentSelectionStrategy = ref(null);
let refreshInterval = null;
@@ -73,6 +77,12 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true;
});
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => {
@@ -110,20 +120,24 @@ export function useWashDepartments(options = {}) {
const forcedDepartment = getForcedDepartment();
if (forcedDepartment) {
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
departmentSelectionStrategy.value = "forced";
return nearestDepartment.value;
}
}
if (!hasLocationCoordinates(locationValue)) {
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
return nearestDepartment.value;
}
let currentNearestDepartment = {
id: null,
distance: Infinity,
};
guestDepartments.value.forEach((department) => {
if (!locationValue?.coords) {
return;
}
const from = {
latitude: locationValue.coords.latitude,
longitude: locationValue.coords.longitude,
@@ -139,9 +153,8 @@ export function useWashDepartments(options = {}) {
}
});
if (currentNearestDepartment.id) {
nearestDepartment.value = currentNearestDepartment;
}
nearestDepartment.value = currentNearestDepartment.id ? currentNearestDepartment : null;
departmentSelectionStrategy.value = currentNearestDepartment.id ? "distance" : null;
return nearestDepartment.value;
};
@@ -167,7 +180,7 @@ export function useWashDepartments(options = {}) {
};
const startDepartmentSearch = () => {
if (!canAccessSuperUser()) {
if (!canAccessSuperUser() && hasLocationCoordinates()) {
return;
}
@@ -245,6 +258,9 @@ export function useWashDepartments(options = {}) {
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
departmentSelectionStrategy,
isDepartmentSelectionDistanceBased,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
@@ -10,12 +10,15 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
const {
guestDepartments,
nearestDepartment,
isDepartmentSelectionFallbackBased,
fetchDepartments,
evaluateLocationDepartments,
orderDepartmentsByDistance,
} = useWashDepartments();
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
const getDepartmentDistance = (department) => {
if (!locations.location.value?.coords) {
@@ -46,16 +49,36 @@ onMounted(async () => {
<div class="column is-12">
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
<template #header>
<div class="card-header-title has-text-link has-text-weight-bold" data-testid="self-serve-home-nearest-name">
{{ nearestDepartment ? nearestDepartment.name : $t("common.loading") }}
<div
class="card-header-title has-text-link has-text-weight-bold"
data-testid="self-serve-home-nearest-name"
>
{{ nearestDepartment ? nearestDepartment.name : $t("global.search_departments") }}
</div>
<div class="card-header-icon has-text-link">
<i class="fas fa-map-marker-alt"></i>
</div>
</template>
<template #content>
{{ $t("user_dashboard.wash.nearest_department", { name: nearestDepartment ? nearestDepartment.name : "-", distance: nearestDepartment ? nearestDepartment.distance.toFixed(2) : "-" }) }}
<b-loading :is-full-page="false" :model-value="!nearestDepartment" :can-cancel="true" />
<template v-if="nearestDepartment && isDepartmentSelectionFallbackBased">
Vælg en afdeling for at fortsætte. Vi har valgt den første tilgængelige selvvaskeafdeling, fordi din
placering ikke er tilgængelig.
</template>
<template v-else-if="nearestDepartment">
{{
$t("user_dashboard.wash.nearest_department", {
name: nearestDepartment.name,
distance: nearestDepartment.distance !== null ? nearestDepartment.distance.toFixed(2) : "-",
})
}}
</template>
<template v-else-if="shouldShowChooseDepartmentMessage">
Vælg en afdeling for at starte vask, fordi din placering ikke er tilgængelig.
</template>
<template v-else>
{{ $t("common.loading") }}
<b-loading :is-full-page="false" :model-value="true" :can-cancel="true" />
</template>
</template>
<template #footer>
<div class="card-footer-item">
@@ -74,7 +97,7 @@ onMounted(async () => {
<template v-for="department in orderedDepartments" :key="department.id">
<div
v-if="nearestDepartment && department.id !== nearestDepartment.id"
v-if="!nearestDepartment || department.id !== nearestDepartment.id"
class="column is-12-mobile is-6-tablet is-4-desktop"
:data-testid="`self-serve-home-department-${department.id}`"
>
@@ -94,7 +117,10 @@ onMounted(async () => {
</template>
<template #footer>
<div class="card-footer-item">
<router-link :to="{ name: 'pos', params: { departmentId: department.id } }" class="button is-light is-fullwidth">
<router-link
:to="{ name: 'pos', params: { departmentId: department.id } }"
class="button is-light is-fullwidth"
>
<span class="icon"><i class="fas fa-map-marker-alt"></i></span>
<span>{{ department.address }}</span>
</router-link>
@@ -84,6 +84,7 @@ const {
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
departmentFetchError,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
@@ -463,7 +464,19 @@ const hasAllowedVehicleTypeSelection = computed(() => {
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
const shouldShowLoadingDataMessage = computed(() => !nearestDepartment.value);
const hasLocationCoordinates = computed(() => !!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) {
@@ -1435,7 +1448,7 @@ watch(
:is-searching-departments="isSearchingDepartments"
:is-searching-loading="guestDepartments.length === 0"
:guest-departments="guestDepartments"
:can-access-super-user="SessionUser.canAccessSuperUser() || SessionUser.canAccessDeveloper()"
: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"
@@ -1829,6 +1842,16 @@ watch(
</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>
+47
View File
@@ -264,6 +264,53 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden();
});
test("start route lets regular users choose a department when geolocation permission is denied", async ({
page,
context,
}) => {
await context.clearPermissions();
await page.addInitScript(() => {
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: {
getCurrentPosition: (_success, error) =>
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }),
watchPosition: (_success, error) => {
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 });
return 1;
},
clearWatch: () => {},
},
});
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: true,
});
await primeSession(page, {
token: "self-serve-denied-geolocation-token",
permissions: ["user"],
});
await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("self-serve-department-name").click();
await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-department-search").locator("input").fill("Odense");
await page.getByText("Odense", { exact: true }).click();
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
});
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
const requests = captureSelfServeGatewayRequests(page);
+3
View File
@@ -85,6 +85,7 @@ vi.mock("@/components/session/token/SessionUser.vue", async () => {
customer_number: mocks.sessionCustomerNumber,
},
canAccessSuperUser: () => true,
canAccessDeveloper: () => false,
request: vi.fn(async (...args) => {
const response = await mocks.sessionRequest(...args);
return response ?? { status: 200, data: { data: {} } };
@@ -144,6 +145,8 @@ vi.mock("@/composables/useWashDepartments", () => ({
isForcingNearestDepartment: { value: false },
forceNearestDepartmentEvaluationId: { value: 0 },
isSearchingDepartments: { value: false },
departmentFetchError: { value: null },
isDepartmentSelectionFallbackBased: { value: false },
availableProductIds: { value: [2] },
doesCurrentDepartmentSelectionHaveSelfServeEnabled: mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments: mocks.fetchDepartments,
+134
View File
@@ -165,4 +165,138 @@ describe("useWashDepartments", () => {
lanes: [{ id: 7 }],
});
});
it("uses the first self-serve department with an enabled lane as fallback when coordinates are missing", async () => {
mocks.locationRef.value = null;
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Disabled module",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: false,
},
{
id: 2,
name: "Disabled lane",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: false }],
self_serve_enabled: true,
},
{
id: 3,
name: "Fallback",
address: "C",
latitude: 55.8,
longitude: 12.7,
lanes: [
{ id: 12, selfserve_enabled: "off" },
{ id: 13, selfserve_enabled: true, products: [4] },
],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(mocks.getDistance).not.toHaveBeenCalled();
expect(departments.nearestDepartment.value).toMatchObject({
id: 3,
name: "Fallback",
distance: null,
lanes: [{ id: 13 }],
});
expect(departments.departmentSelectionStrategy.value).toBe("fallback");
expect(departments.isDepartmentSelectionFallbackBased.value).toBe(true);
expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false);
});
it("clears the selected department for empty department responses", async () => {
mocks.getDepartmentsGuest.mockResolvedValueOnce([
{ id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true },
]);
const departments = mountDepartments();
await departments.fetchDepartments();
expect(departments.nearestDepartment.value).toMatchObject({ id: 1 });
mocks.getDepartmentsGuest.mockResolvedValueOnce([]);
await departments.fetchDepartments();
expect(departments.guestDepartments.value).toEqual([]);
expect(departments.nearestDepartment.value).toBeNull();
expect(departments.departmentSelectionStrategy.value).toBeNull();
});
it("does not fallback to disabled departments when coordinates are missing", async () => {
mocks.locationRef.value = { value: null };
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Module disabled",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: false,
},
{
id: 2,
name: "Lane disabled",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: "no" }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(departments.nearestDepartment.value).toBeNull();
expect(departments.departmentSelectionStrategy.value).toBeNull();
});
it("allows regular users to force a department when coordinates are missing", async () => {
mocks.locationRef.value = {};
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Fallback",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10 }],
self_serve_enabled: true,
},
{
id: 2,
name: "Manual choice",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11 }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ canAccessSuperUser: () => false });
await departments.fetchDepartments();
departments.startDepartmentSearch();
departments.selectDepartment(2);
expect(departments.isSearchingDepartments.value).toBe(false);
expect(departments.isForcingNearestDepartment.value).toBe(true);
expect(departments.nearestDepartment.value).toMatchObject({ id: 2, name: "Manual choice" });
expect(departments.departmentSelectionStrategy.value).toBe("forced");
});
});