Handle self-serve wash without geolocation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user