Compare commits

...
32 changed files with 822 additions and 92 deletions
@@ -1,5 +1,5 @@
<script setup>
import {computed, onMounted, ref, watch} from 'vue';
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import { useRouter } from 'vue-router';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {BSwitch} from "buefy";
@@ -18,6 +18,39 @@ const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false);
const bookingsCount = ref(0);
const selfServeLoadingMinimumMs = import.meta.env.MODE === "test" ? 0 : import.meta.env.VITE_IS_PLAYWRIGHT ? 5000 : 250;
const selfServeLoadingStartedAt = ref(0);
let selfServeLoadingTimer = null;
const clearSelfServeLoadingTimer = () => {
if (selfServeLoadingTimer !== null) {
clearTimeout(selfServeLoadingTimer);
selfServeLoadingTimer = null;
}
};
const setSelfServeLoading = (isLoading) => {
if (isLoading) {
clearSelfServeLoadingTimer();
selfServeLoadingStartedAt.value = Date.now();
isLoadingSelfServeEnabled.value = true;
return;
}
const elapsed = Date.now() - selfServeLoadingStartedAt.value;
const remaining = Math.max(0, selfServeLoadingMinimumMs - elapsed);
clearSelfServeLoadingTimer();
if (remaining === 0) {
isLoadingSelfServeEnabled.value = false;
return;
}
selfServeLoadingTimer = setTimeout(() => {
isLoadingSelfServeEnabled.value = false;
selfServeLoadingTimer = null;
}, remaining);
};
const getDepartmentId = () => {
return parseInt(router.currentRoute.value.params.departmentId);
@@ -190,13 +223,13 @@ const getSelfServeStatus = async () => {
const departmentId = getDepartmentId();
if (!departmentId) return;
isLoadingSelfServeEnabled.value = true;
setSelfServeLoading(true);
try {
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
} catch (error) {
console.error("Failed to fetch self-serve status", error);
} finally {
isLoadingSelfServeEnabled.value = false;
setSelfServeLoading(false);
}
};
@@ -204,6 +237,10 @@ onMounted(() => {
getSelfServeStatus();
});
onUnmounted(() => {
clearSelfServeLoadingTimer();
});
// Watch the departmentId
watch(() => router.currentRoute.value.params.departmentId, () => {
getTodaysBookings();
@@ -106,8 +106,7 @@ const onDynamicImageError = () => {
/>
</div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
<h1 class="title has-text-centered mb-2">{{ $t("self_wash.start_machine") }}</h1>
<SelfServeTaskList
:tasks="activeTasks"
:completedTasks="completedTasks"
@@ -1,6 +1,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
@@ -10,6 +11,7 @@ const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false);
const isBusy = ref(false);
const isClearConfirmationOpen = ref(false);
const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
@@ -18,6 +20,7 @@ const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.lates
const closeMenu = () => {
if (!isBusy.value) {
isOpen.value = false;
isClearConfirmationOpen.value = false;
}
};
@@ -34,8 +37,18 @@ const handleKeydown = (event) => {
}
};
const openClearConfirmation = () => {
if (!isBusy.value) {
isClearConfirmationOpen.value = true;
}
};
const closeClearConfirmation = () => {
isClearConfirmationOpen.value = false;
};
const forceUpdateAndClearLocal = async () => {
if (isBusy.value || !window.confirm(t("maintenance_menu.confirm_clear_local"))) {
if (isBusy.value) {
return;
}
@@ -84,7 +97,7 @@ onBeforeUnmount(() => {
class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear"
:disabled="isBusy"
@click="forceUpdateAndClearLocal"
@click="openClearConfirmation"
>
<i class="fas fa-sync-alt" aria-hidden="true"></i>
<span>
@@ -93,6 +106,12 @@ onBeforeUnmount(() => {
</span>
</button>
</section>
<LocalDataResetDialog
v-model="isClearConfirmationOpen"
:busy="isBusy"
@confirm="forceUpdateAndClearLocal"
@dismiss="closeClearConfirmation"
/>
</div>
</Teleport>
</template>
@@ -0,0 +1,156 @@
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "confirm", "dismiss"]);
const dismissDialog = () => {
emit("dismiss");
emit("update:modelValue", false);
};
const confirmDialog = () => {
emit("confirm");
};
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="local-data-reset-dialog" data-testid="local-data-reset-dialog">
<button
type="button"
class="local-data-reset-dialog__backdrop"
aria-label="Luk"
:disabled="busy"
@click="dismissDialog"
></button>
<section
class="local-data-reset-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="local-data-reset-dialog-title"
>
<h2 id="local-data-reset-dialog-title">Ryd lokale data?</h2>
<p>
Dette sletter login, localStorage, sessionStorage, browsercache og lokale appdata denne enhed. Du bliver
logget ud.
</p>
<footer class="local-data-reset-dialog__actions">
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--secondary"
data-testid="local-data-reset-cancel"
:disabled="busy"
@click="dismissDialog"
>
Nej
</button>
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--danger"
data-testid="local-data-reset-confirm"
:disabled="busy"
@click="confirmDialog"
>
Ja, ryd alt
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.local-data-reset-dialog {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: 18px;
pointer-events: none;
}
.local-data-reset-dialog__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(9, 20, 33, 0.48);
cursor: pointer;
pointer-events: auto;
}
.local-data-reset-dialog__backdrop:disabled {
cursor: wait;
}
.local-data-reset-dialog__panel {
position: relative;
width: min(430px, calc(100vw - 36px));
border: 1px solid #d5dde8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
color: #172033;
pointer-events: auto;
}
.local-data-reset-dialog__panel h2 {
margin: 0;
padding: 18px 18px 8px;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0;
}
.local-data-reset-dialog__panel p {
margin: 0;
padding: 0 18px 16px;
color: #475467;
font-size: 0.92rem;
font-weight: 550;
line-height: 1.42;
}
.local-data-reset-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px 18px;
border-top: 1px solid #edf1f5;
}
.local-data-reset-dialog__button {
min-height: 38px;
padding: 0 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.86rem;
font-weight: 800;
}
.local-data-reset-dialog__button:disabled {
cursor: wait;
opacity: 0.68;
}
.local-data-reset-dialog__button--secondary {
border: 1px solid #cfd8e3;
background: #ffffff;
color: #334155;
}
.local-data-reset-dialog__button--danger {
border: 1px solid #b42318;
background: #b42318;
color: #ffffff;
}
</style>
@@ -4,7 +4,6 @@ import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/Obje
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { editOrderItem, getOrderItems } from "@/components/shop/OrdersItems.vue";
import {createApp} from "vue";
import i18n from '@/i18n';
import { dispatchNavigationCountRefresh } from "@/components/models/navigation/items/navigationCountEvents.js";
@@ -265,6 +264,19 @@ const refreshDraftNavigationCount = () => {
dispatchNavigationCountRefresh();
};
const getOrderItemsForRepricing = (orderId) => authenticatedRequest('/order/items', 'GET', {
order_id: orderId,
});
const editOrderItemForRepricing = ({ id, price, notes, reference, quantity }) => authenticatedRequest('/order/items', 'PUT', {
id,
price,
notes,
reference,
quantity,
});
const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => {
const normalizedProductId = normalizePositiveInteger(productId);
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
@@ -298,7 +310,7 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
throw new Error("Invalid order repricing context");
}
const response = await getOrderItems(normalizedOrderId);
const response = await getOrderItemsForRepricing(normalizedOrderId);
const orderItems = Array.isArray(response?.data?.data) ? response.data.data : [];
const uniqueProductIds = [...new Set(
orderItems
@@ -329,13 +341,13 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
return null;
}
return editOrderItem(
normalizedItemId,
finalPriceMap.get(normalizedProductId),
item?.notes ?? "",
item?.reference ?? "",
normalizePositiveInteger(item?.quantity) ?? 1
);
return editOrderItemForRepricing({
id: normalizedItemId,
price: finalPriceMap.get(normalizedProductId),
notes: item?.notes ?? "",
reference: item?.reference ?? "",
quantity: normalizePositiveInteger(item?.quantity) ?? 1,
});
})
.filter(Boolean);
@@ -370,11 +382,6 @@ const assignDraftOrderCustomer = async ({
normalizedCustomerId
);
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
normalizedOrderId,
normalizedInvoiceCollectionId
);
let repricingResponse = null;
if (recalculate_prices) {
repricingResponse = await recalculateOrderItemPricesForCustomer({
@@ -384,6 +391,11 @@ const assignDraftOrderCustomer = async ({
});
}
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
normalizedOrderId,
normalizedInvoiceCollectionId
);
return {
customerResponse,
invoiceCollectionResponse,
+3
View File
@@ -11,6 +11,8 @@ import {useRouter} from "vue-router";
import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue";
import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
import { inject } from "vue";
const router = useRouter();
const headerHeight = ref(60); // Default header height in pixels
@@ -87,6 +89,7 @@ const isFooterInContent = computed(() => {
style="max-height: 28px;"
>
<p class="is-size-7 has-text-grey-light mb-2 mt-0">© Truckwash ApS. All rights reserved.</p>
<p class="is-size-7 has-text-grey-light mb-2 mt-0" v-show="SessionUser.functions.device.isMobile()">{{inject("VERSION")}}</p>
</a>
</div>
</ViewportContent>
@@ -1,10 +1,20 @@
<script setup lang="ts">
import {BButton, BField, BIcon} from "buefy";
import {computed, ref} from "vue";
import {computed, onBeforeUnmount, ref, watch} from "vue";
import { useRouter } from "vue-router";
import { IS_DEV } from '@/config.js';
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const router = useRouter();
const HOME_LONG_PRESS_MS = 5000;
const HOME_CLICK_SUPPRESS_MS = 1200;
type FooterPage = {
to: string;
label: string;
icon: string;
disabled: boolean;
};
const show = computed(() => {
const path = router.currentRoute.value.path;
@@ -12,10 +22,16 @@ const show = computed(() => {
return path.startsWith("/user");
});
const currentPath = computed(() => router.currentRoute.value.path);
const isLocalDataResetOpen = ref(false);
const isClearingLocalData = ref(false);
const suppressHomeClickUntil = ref(0);
let homeLongPressTimer: ReturnType<typeof window.setTimeout> | null = null;
/**
* Page navigation footer for mobile devices
*/
const pages = {
const pages: Record<string, FooterPage> = {
home: {
to: "/user",
label: "Hjem",
@@ -35,6 +51,78 @@ const pages = {
disabled: false
},
}
const cancelHomeLongPress = () => {
if (homeLongPressTimer !== null) {
window.clearTimeout(homeLongPressTimer);
homeLongPressTimer = null;
}
};
const startHomeLongPress = (event: PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
if (isLocalDataResetOpen.value || isClearingLocalData.value) {
return;
}
cancelHomeLongPress();
homeLongPressTimer = window.setTimeout(() => {
homeLongPressTimer = null;
suppressHomeClickUntil.value = Date.now() + HOME_CLICK_SUPPRESS_MS;
isLocalDataResetOpen.value = true;
}, HOME_LONG_PRESS_MS);
};
const footerButtonListeners = (key: string) =>
key === "home"
? {
pointerdown: startHomeLongPress,
pointerup: cancelHomeLongPress,
pointercancel: cancelHomeLongPress,
pointerleave: cancelHomeLongPress,
}
: {};
const isActiveFooterRoute = (path: string) => currentPath.value.endsWith(path);
const handlePageClick = (event: MouseEvent, key: string, page: FooterPage) => {
if (page.disabled) {
return;
}
if (key === "home" && Date.now() <= suppressHomeClickUntil.value) {
event.preventDefault();
event.stopPropagation();
suppressHomeClickUntil.value = 0;
return;
}
router.push(page.to);
};
const closeLocalDataReset = () => {
isLocalDataResetOpen.value = false;
};
const confirmLocalDataReset = async () => {
if (isClearingLocalData.value) {
return;
}
isClearingLocalData.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isClearingLocalData.value = false;
console.error("Failed to clear local app data:", error);
}
};
watch(() => router.currentRoute.value.fullPath || router.currentRoute.value.path, cancelHomeLongPress);
onBeforeUnmount(cancelHomeLongPress);
</script>
<template>
@@ -45,13 +133,15 @@ const pages = {
<template v-for="(page, key) in pages" :key="key">
<div class="column has-text-centered">
<b-button
class="no-border-radius"
:type="$route.path.endsWith(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
class="no-border-radius"
:data-testid="`mobile-footer-${key}`"
:type="isActiveFooterRoute(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
size="is-normal"
@click="$router.push(page.to)"
v-on="footerButtonListeners(key)"
@click="handlePageClick($event, key, page)"
iconPack="fas"
expanded
:disabled="page.disabled"
:disabled="page.disabled"
>
<span>
<span><b-icon pack="fas" :icon="page.icon"></b-icon></span>
@@ -64,6 +154,12 @@ const pages = {
</div>
</b-field>
</section>
<LocalDataResetDialog
v-model="isLocalDataResetOpen"
:busy="isClearingLocalData"
@confirm="confirmLocalDataReset"
@dismiss="closeLocalDataReset"
/>
</div>
</template>
@@ -84,4 +180,4 @@ const pages = {
border-top-right-radius: 4px;
background-color: #2c3e50;
}
</style>
</style>
+4 -4
View File
@@ -1152,10 +1152,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Vælg afdeling manuelt",
"manual_title": "Vælg din afdeling",
"manual_loading": "Indlæser afdelinger...",
"use_department": "Vælg {name}"
"manual_button": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.afdeling'} manuelt",
"manual_title": "@.capitalize:{'words.generated.vælg'} din @:{'words.generated.afdeling'}",
"manual_loading": "@.capitalize:{'words.generated.indlæser'} afdelinger...",
"use_department": "@.capitalize:{'words.generated.vælg'} {name}"
}
},
"replication": {
+4 -4
View File
@@ -1262,10 +1262,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Abteilung manuell auswählen",
"manual_title": "Wählen Sie Ihre Abteilung",
"manual_loading": "Abteilungen werden geladen...",
"use_department": "{name} auswählen"
"manual_button": "@:{'words.generated.abteilung'} @:{'words.generated.manuell'} @:{'words.generated.auswahlen'}",
"manual_title": "@.capitalize:{'words.generated.wahlen'} @.capitalize:{'words.generated.sie'} @.capitalize:{'words.generated.ihre'} @:{'words.generated.abteilung'}",
"manual_loading": "@:{'words.generated.abteilungen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
"use_department": "{name} @:{'words.generated.auswahlen'}"
}
},
"replication": {
+4 -4
View File
@@ -986,10 +986,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Select department manually",
"manual_title": "Select your department",
"manual_loading": "Loading departments...",
"use_department": "Select {name}"
"manual_button": "@.capitalize:{'words.generated.select'} @:{'words.generated.department'} @:{'words.generated.manually'}",
"manual_title": "@.capitalize:{'words.generated.select'} @:{'words.generated.your'} @:{'words.generated.department'}",
"manual_loading": "@.capitalize:{'words.generated.loading'} @:{'words.generated.departments'}...",
"use_department": "@.capitalize:{'words.generated.select'} {name}"
}
},
"replication": {
+13
View File
@@ -1367,7 +1367,12 @@
"customer_registration_webhook_url_desc": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'templates.generated.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'templates.generated.compat.configuration.slack.notification_settings_desc'}",
"send_test_webhook": "@:{'templates.generated.compat.configuration.slack.send_test_webhook'}",
"subtitle": "@:{'templates.generated.compat.configuration.slack.subtitle'}",
"test_webhook_error": "@:{'templates.generated.compat.configuration.slack.test_webhook_error'}",
"test_webhook_not_configured": "@:{'templates.generated.compat.configuration.slack.test_webhook_not_configured'}",
"test_webhook_sent": "@:{'templates.generated.compat.configuration.slack.test_webhook_sent'}",
"test_webhook_sent_success": "@:{'templates.generated.compat.configuration.slack.test_webhook_sent_success'}",
"title": "@:{'templates.generated.compat.configuration.slack.title'}",
"unavailable": "@:{'templates.generated.compat.configuration.slack.unavailable'}"
},
@@ -4059,6 +4064,14 @@
},
"title": "@:common.profile"
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'templates.redirect.mobile_department_auto_select.manual_button'}",
"manual_title": "@:{'templates.redirect.mobile_department_auto_select.manual_title'}",
"manual_loading": "@:{'templates.redirect.mobile_department_auto_select.manual_loading'}",
"use_department": "@:{'templates.redirect.mobile_department_auto_select.use_department'}"
}
},
"replication": {
"actions": {
"add": "@:{'templates.generated.compat.replication.actions.add'}",
+9 -4
View File
@@ -1263,10 +1263,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Velg avdeling manuelt",
"manual_title": "Velg din avdeling",
"manual_loading": "Laster avdelinger...",
"use_department": "Velg {name}"
"manual_button": "@.capitalize:{'words.generated.velg'} @:{'words.generated.avdeling'} @:{'words.generated.manuelt'}",
"manual_title": "@.capitalize:{'words.generated.velg'} @:{'words.generated.din'} @:{'words.generated.avdeling'}",
"manual_loading": "@.capitalize:{'words.generated.laster'} @:{'words.generated.avdelinger'}...",
"use_department": "@.capitalize:{'words.generated.velg'} {name}"
}
},
"replication": {
@@ -2454,7 +2454,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
},
+4 -4
View File
@@ -1313,10 +1313,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Välj avdelning manuellt",
"manual_title": "Välj din avdelning",
"manual_loading": "Laddar avdelningar...",
"use_department": "Välj {name}"
"manual_button": "@.capitalize:{'words.generated.valj'} @:{'words.generated.avdelning'} manuellt",
"manual_title": "@.capitalize:{'words.generated.valj'} @:{'words.generated.din'} @:{'words.generated.avdelning'}",
"manual_loading": "@:{'words.generated.laddar'} @:{'words.generated.avdelningar'}...",
"use_department": "@.capitalize:{'words.generated.valj'} {name}"
}
},
"replication": {
+5
View File
@@ -1376,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook der modtager en besked, når en ny kunderegistrering lykkes. Lad feltet være tomt for at deaktivere.",
"notification_settings": "Notifikationer",
"notification_settings_desc": "Slack-webhooks til systemhændelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfiguration af Slack-notifikationer",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Gem en webhook-URL til kunderegistreringer, før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testbeskeden for kunderegistrering blev sendt.",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
},
+5
View File
@@ -1376,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-Webhook, der eine Nachricht erhaelt, wenn eine neue Kundenregistrierung erfolgreich ist. Leer lassen, um dies zu deaktivieren.",
"notification_settings": "Benachrichtigungseinstellungen",
"notification_settings_desc": "Slack-Webhooks fuer Systemereignisse.",
"send_test_webhook": "Test-Webhook senden",
"subtitle": "Konfiguration von Slack-Benachrichtigungen",
"test_webhook_error": "Der Slack-Test-Webhook konnte nicht gesendet werden.",
"test_webhook_not_configured": "Speichern Sie zuerst eine Webhook-URL fuer Kundenregistrierungen.",
"test_webhook_sent": "Slack-Test gesendet",
"test_webhook_sent_success": "Die Slack-Testnachricht fuer Kundenregistrierungen wurde gesendet.",
"title": "Slack-Konfiguration",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
},
+5
View File
@@ -1376,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack webhook that receives a message when a new customer registration succeeds. Leave empty to disable.",
"notification_settings": "Notification settings",
"notification_settings_desc": "Slack webhooks for system events.",
"send_test_webhook": "Send test webhook",
"subtitle": "Configuration of Slack notifications",
"test_webhook_error": "Could not send the Slack test webhook.",
"test_webhook_not_configured": "Save a customer registration webhook URL before sending a test.",
"test_webhook_sent": "Slack test sent",
"test_webhook_sent_success": "The Slack customer registration test message was sent.",
"title": "Slack configuration",
"unavailable": "Slack configuration is not available on this API release."
},
+5
View File
@@ -1376,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
},
+5
View File
@@ -1376,7 +1376,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som får ett meddelande när en ny kundregistrering lyckas. Lämna tomt för att inaktivera.",
"notification_settings": "Aviseringsinställningar",
"notification_settings_desc": "Slack-webhooks för systemhändelser.",
"send_test_webhook": "Skicka test-webhook",
"subtitle": "Konfiguration av Slack-aviseringar",
"test_webhook_error": "Det gick inte att skicka Slack-testwebhooken.",
"test_webhook_not_configured": "Spara en webhook-URL för kundregistreringar innan du skickar ett test.",
"test_webhook_sent": "Slack-test skickat",
"test_webhook_sent_success": "Slack-testmeddelandet för kundregistrering skickades.",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
},
+4 -4
View File
@@ -14,10 +14,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Vælg afdeling manuelt",
"manual_title": "Vælg din afdeling",
"manual_loading": "Indlæser afdelinger...",
"use_department": "Vælg {name}"
"manual_button": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.afdeling'} manuelt",
"manual_title": "@.capitalize:{'terms.glossary.vælg'} din @:{'terms.glossary.afdeling'}",
"manual_loading": "@.capitalize:{'terms.glossary.indlæser'} afdelinger...",
"use_department": "@.capitalize:{'terms.glossary.vælg'} {name}"
}
},
"replication": {
+4 -4
View File
@@ -14,10 +14,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Abteilung manuell auswählen",
"manual_title": "Wählen Sie Ihre Abteilung",
"manual_loading": "Abteilungen werden geladen...",
"use_department": "{name} auswählen"
"manual_button": "@:{'terms.glossary.abteilung'} @:{'terms.glossary.manuell'} @:{'terms.glossary.auswahlen'}",
"manual_title": "@.capitalize:{'terms.glossary.wahlen'} @.capitalize:{'terms.glossary.sie'} @.capitalize:{'terms.glossary.ihre'} @:{'terms.glossary.abteilung'}",
"manual_loading": "@:{'terms.glossary.abteilungen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"use_department": "{name} @:{'terms.glossary.auswahlen'}"
}
},
"replication": {
+4 -4
View File
@@ -14,10 +14,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Select department manually",
"manual_title": "Select your department",
"manual_loading": "Loading departments...",
"use_department": "Select {name}"
"manual_button": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.department'} @:{'terms.glossary.manually'}",
"manual_title": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.your'} @:{'terms.glossary.department'}",
"manual_loading": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.departments'}...",
"use_department": "@.capitalize:{'terms.glossary.select'} {name}"
}
},
"replication": {
@@ -189,7 +189,12 @@
"customer_registration_webhook_url_desc": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'phrases.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'phrases.compat.configuration.slack.notification_settings_desc'}",
"send_test_webhook": "@:{'phrases.compat.configuration.slack.send_test_webhook'}",
"subtitle": "@:{'phrases.compat.configuration.slack.subtitle'}",
"test_webhook_error": "@:{'phrases.compat.configuration.slack.test_webhook_error'}",
"test_webhook_not_configured": "@:{'phrases.compat.configuration.slack.test_webhook_not_configured'}",
"test_webhook_sent": "@:{'phrases.compat.configuration.slack.test_webhook_sent'}",
"test_webhook_sent_success": "@:{'phrases.compat.configuration.slack.test_webhook_sent_success'}",
"title": "@:{'phrases.compat.configuration.slack.title'}",
"unavailable": "@:{'phrases.compat.configuration.slack.unavailable'}"
},
@@ -0,0 +1,10 @@
{
"redirect": {
"mobile_department_auto_select": {
"manual_button": "@:{'phrases.redirect.mobile_department_auto_select.manual_button'}",
"manual_title": "@:{'phrases.redirect.mobile_department_auto_select.manual_title'}",
"manual_loading": "@:{'phrases.redirect.mobile_department_auto_select.manual_loading'}",
"use_department": "@:{'phrases.redirect.mobile_department_auto_select.use_department'}"
}
}
}
@@ -163,7 +163,12 @@
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
"send_test_webhook": "Send test-webhook",
"subtitle": "Konfigurasjon av Slack-varsler",
"test_webhook_error": "Kunne ikke sende Slack-testwebhooken.",
"test_webhook_not_configured": "Lagre en webhook-URL for kunderegistreringer før du sender en test.",
"test_webhook_sent": "Slack-test sendt",
"test_webhook_sent_success": "Slack-testmeldingen for kunderegistrering ble sendt.",
"title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
},
+4 -4
View File
@@ -14,10 +14,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Velg avdeling manuelt",
"manual_title": "Velg din avdeling",
"manual_loading": "Laster avdelinger...",
"use_department": "Velg {name}"
"manual_button": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.avdeling'} @:{'terms.glossary.manuelt'}",
"manual_title": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.din'} @:{'terms.glossary.avdeling'}",
"manual_loading": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.avdelinger'}...",
"use_department": "@.capitalize:{'terms.glossary.velg'} {name}"
}
},
"replication": {
+4 -4
View File
@@ -14,10 +14,10 @@
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Välj avdelning manuellt",
"manual_title": "Välj din avdelning",
"manual_loading": "Laddar avdelningar...",
"use_department": "Välj {name}"
"manual_button": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.avdelning'} manuellt",
"manual_title": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.din'} @:{'terms.glossary.avdelning'}",
"manual_loading": "@:{'terms.glossary.laddar'} @:{'terms.glossary.avdelningar'}...",
"use_department": "@.capitalize:{'terms.glossary.valj'} {name}"
}
},
"replication": {
+1
View File
@@ -116,6 +116,7 @@ const app = createApp(App)
.provide('Colors', Colors)
.provide('IS_DEV', IS_DEV)
.provide('API_URL', getReleaseRuntimeApiBaseUrl())
.provide('VERSION', `${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)}`);
installReleaseErrorInstrumentation(app, Router);
app.mount('#app');
+66 -15
View File
@@ -1,24 +1,15 @@
const CACHE_INVALIDATION_THROTTLE_MS = 30 * 1000;
const CACHE_BUST_PARAM = "force_update";
const PRESERVED_LOCAL_STORAGE_KEYS = ["token"];
const KNOWN_INDEXED_DB_NAMES = ["workbox-expiration", "workbox-background-sync", "pleno", "truckwash"];
let lastErrorInvalidationAt = 0;
const browserWindow = () => (typeof window !== "undefined" ? window : null);
const browserNavigator = () => (typeof navigator !== "undefined" ? navigator : null);
const clearStorage = (storage, preservedKeys = []) => {
const clearStorage = (storage) => {
try {
const preservedEntries = preservedKeys
.map((key) => [key, storage?.getItem(key)])
.filter(([, value]) => value !== null && value !== undefined);
storage?.clear();
preservedEntries.forEach(([key, value]) => {
storage?.setItem(key, value);
});
return true;
} catch {
return false;
@@ -60,6 +51,62 @@ const unregisterServiceWorkers = async () => {
return registrations.length;
};
const browserIndexedDb = () => {
const win = browserWindow();
if (win?.indexedDB) {
return win.indexedDB;
}
return typeof indexedDB !== "undefined" ? indexedDB : null;
};
const deleteIndexedDatabase = (indexedDb, databaseName) =>
new Promise((resolve) => {
if (!indexedDb || !databaseName) {
resolve(false);
return;
}
try {
const request = indexedDb.deleteDatabase(databaseName);
request.onsuccess = () => resolve(true);
request.onerror = () => resolve(false);
request.onblocked = () => resolve(false);
} catch {
resolve(false);
}
});
const getIndexedDatabaseNames = async (indexedDb) => {
if (typeof indexedDb?.databases !== "function") {
return KNOWN_INDEXED_DB_NAMES;
}
try {
const databases = await indexedDb.databases();
return [
...new Set(
databases
.map((database) => database?.name)
.filter((databaseName) => typeof databaseName === "string" && databaseName.length > 0)
),
];
} catch {
return KNOWN_INDEXED_DB_NAMES;
}
};
const deleteIndexedDatabases = async () => {
const indexedDb = browserIndexedDb();
if (!indexedDb) {
return [];
}
const databaseNames = await getIndexedDatabaseNames(indexedDb);
await Promise.all(databaseNames.map((databaseName) => deleteIndexedDatabase(indexedDb, databaseName)));
return databaseNames;
};
const reloadWithCacheBust = () => {
const win = browserWindow();
if (!win?.location) {
@@ -83,13 +130,17 @@ export const invalidateFrontendCachesAfterError = async ({ now = Date.now() } =
export const forceFrontendUpdateAndClearLocal = async ({ reload = reloadWithCacheBust } = {}) => {
const win = browserWindow();
const [cacheNames, serviceWorkerRegistrations] = await Promise.all([clearCacheStorage(), unregisterServiceWorkers()]);
clearStorage(win?.localStorage, PRESERVED_LOCAL_STORAGE_KEYS);
clearStorage(win?.localStorage);
clearStorage(win?.sessionStorage);
const [cacheNames, serviceWorkerRegistrations, indexedDatabaseNames] = await Promise.all([
clearCacheStorage(),
unregisterServiceWorkers(),
deleteIndexedDatabases(),
]);
reload();
return { cacheNames, serviceWorkerRegistrations };
return { cacheNames, serviceWorkerRegistrations, indexedDatabaseNames };
};
export const __resetFrontendMaintenanceForTests = () => {
+8 -3
View File
@@ -19,8 +19,8 @@ const pressShift = () => {
};
describe("FrontendMaintenanceMenu", () => {
it("opens after three Shift presses and runs the force update cleanup after confirmation", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
it("opens after three Shift presses and runs the force update cleanup after in-app confirmation", async () => {
const confirmSpy = vi.spyOn(window, "confirm");
mount(FrontendMaintenanceMenu, {
global: {
plugins: [i18n],
@@ -39,7 +39,12 @@ describe("FrontendMaintenanceMenu", () => {
document.body.querySelector("[data-testid='frontend-maintenance-force-clear']").click();
await flushMicrotasks();
expect(window.confirm).toHaveBeenCalledTimes(1);
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).not.toBeNull();
document.body.querySelector("[data-testid='local-data-reset-confirm']").click();
await flushMicrotasks();
expect(confirmSpy).not.toHaveBeenCalled();
expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1);
});
});
+47 -5
View File
@@ -6,8 +6,9 @@ import {
invalidateFrontendCachesAfterError,
} from "@/services/frontendMaintenance.js";
const installBrowserMaintenanceMocks = () => {
const installBrowserMaintenanceMocks = ({ indexedDatabaseNames = [] } = {}) => {
const deletedCaches = [];
const deletedIndexedDatabases = [];
const update = vi.fn(async () => true);
const unregister = vi.fn(async () => true);
const getRegistrations = vi.fn(async () => [{ update, unregister }]);
@@ -18,6 +19,15 @@ const installBrowserMaintenanceMocks = () => {
return true;
}),
};
const indexedDB = {
databases: vi.fn(async () => indexedDatabaseNames.map((name) => ({ name }))),
deleteDatabase: vi.fn((databaseName) => {
deletedIndexedDatabases.push(databaseName);
const request = {};
queueMicrotask(() => request.onsuccess?.());
return request;
}),
};
Object.defineProperty(window, "caches", {
configurable: true,
@@ -27,8 +37,12 @@ const installBrowserMaintenanceMocks = () => {
configurable: true,
value: { getRegistrations },
});
Object.defineProperty(window, "indexedDB", {
configurable: true,
value: indexedDB,
});
return { caches, deletedCaches, getRegistrations, unregister, update };
return { caches, deletedCaches, deletedIndexedDatabases, getRegistrations, indexedDB, unregister, update };
};
describe("frontend maintenance", () => {
@@ -39,6 +53,9 @@ describe("frontend maintenance", () => {
afterEach(() => {
__resetFrontendMaintenanceForTests();
vi.restoreAllMocks();
Reflect.deleteProperty(window, "caches");
Reflect.deleteProperty(window, "indexedDB");
Reflect.deleteProperty(navigator, "serviceWorker");
});
it("invalidates browser caches and asks service workers to update after frontend errors", async () => {
@@ -66,8 +83,10 @@ describe("frontend maintenance", () => {
expect(mocks.update).toHaveBeenCalledTimes(1);
});
it("clears local state except the session token, unregisters service workers, clears caches, and reloads", async () => {
const mocks = installBrowserMaintenanceMocks();
it("clears all local state, unregisters service workers, clears caches, deletes IndexedDB, and reloads", async () => {
const mocks = installBrowserMaintenanceMocks({
indexedDatabaseNames: ["workbox-expiration", "truckwash-offline"],
});
const reload = vi.fn();
localStorage.setItem("token", "secret-token");
localStorage.setItem("draft", "local-value");
@@ -76,10 +95,33 @@ describe("frontend maintenance", () => {
const result = await forceFrontendUpdateAndClearLocal({ reload });
expect(result.cacheNames).toEqual(["pleno-api-cache", "pleno-website-cache"]);
expect(result.indexedDatabaseNames).toEqual(["workbox-expiration", "truckwash-offline"]);
expect(mocks.unregister).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("token")).toBe("secret-token");
expect(mocks.deletedCaches).toEqual(["pleno-api-cache", "pleno-website-cache"]);
expect(mocks.deletedIndexedDatabases).toEqual(["workbox-expiration", "truckwash-offline"]);
expect(localStorage.getItem("token")).toBeNull();
expect(localStorage.getItem("draft")).toBeNull();
expect(sessionStorage.getItem("draft")).toBeNull();
expect(reload).toHaveBeenCalledTimes(1);
});
it("falls back to known IndexedDB names when database enumeration is unavailable", async () => {
const mocks = installBrowserMaintenanceMocks();
delete mocks.indexedDB.databases;
const result = await forceFrontendUpdateAndClearLocal({ reload: vi.fn() });
expect(result.indexedDatabaseNames).toEqual([
"workbox-expiration",
"workbox-background-sync",
"pleno",
"truckwash",
]);
expect(mocks.deletedIndexedDatabases).toEqual([
"workbox-expiration",
"workbox-background-sync",
"pleno",
"truckwash",
]);
});
});
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { enableAutoUnmount, mount } from "@vue/test-utils";
import { nextTick } from "vue";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
enableAutoUnmount(afterEach);
const flush = async () => {
await Promise.resolve();
await nextTick();
};
const clickBodyButton = async (testId) => {
await flush();
document.body.querySelector(`[data-testid='${testId}']`).click();
await flush();
};
describe("LocalDataResetDialog", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
});
it("emits dismiss and model update when Nej is pressed", async () => {
const wrapper = mount(LocalDataResetDialog, {
attachTo: document.body,
props: {
modelValue: true,
},
});
await clickBodyButton("local-data-reset-cancel");
expect(wrapper.emitted("dismiss")).toHaveLength(1);
expect(wrapper.emitted("update:modelValue")).toEqual([[false]]);
});
it("emits confirm when Ja, ryd alt is pressed", async () => {
const wrapper = mount(LocalDataResetDialog, {
attachTo: document.body,
props: {
modelValue: true,
},
});
await clickBodyButton("local-data-reset-confirm");
expect(wrapper.emitted("confirm")).toHaveLength(1);
});
});
+191
View File
@@ -0,0 +1,191 @@
// @vitest-environment jsdom
import { enableAutoUnmount, mount } from "@vue/test-utils";
import { nextTick } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import MobileFooter from "@/components/viewport/page/footers/MobileFooter.vue";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
enableAutoUnmount(afterEach);
const mocks = vi.hoisted(() => {
const route = {
path: "/user/wash/start",
fullPath: "/user/wash/start",
};
return {
route,
push: vi.fn(),
};
});
vi.mock("vue-router", () => ({
useRouter: () => ({
currentRoute: {
value: mocks.route,
},
push: mocks.push,
}),
}));
vi.mock("@/services/frontendMaintenance.js", () => ({
forceFrontendUpdateAndClearLocal: vi.fn(async () => true),
}));
vi.mock("buefy", async () => {
const { defineComponent, h } = await import("vue");
const BButton = defineComponent({
name: "MockBButton",
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => {
const { type: _type, iconPack: _iconPack, expanded: _expanded, size: _size, ...buttonAttrs } = attrs;
return h(
"button",
{
...buttonAttrs,
type: "button",
disabled: Boolean(attrs.disabled),
},
slots.default ? slots.default() : []
);
};
},
});
const Passthrough = defineComponent({
name: "MockPassthrough",
setup(_props, { slots }) {
return () => h("div", slots.default ? slots.default() : []);
},
});
const BIcon = defineComponent({
name: "MockBIcon",
props: {
icon: {
type: String,
default: "",
},
},
setup(props) {
return () => h("span", { "data-testid": `mock-icon-${props.icon}` });
},
});
return {
BButton,
BField: Passthrough,
BIcon,
};
});
const flushMicrotasks = async () => {
await Promise.resolve();
await Promise.resolve();
await nextTick();
};
const mountFooter = () =>
mount(MobileFooter, {
attachTo: document.body,
});
const getHomeButton = (wrapper) => wrapper.get("[data-testid='mobile-footer-home']");
const dispatchHomePointerEvent = async (wrapper, eventName) => {
const event = new Event(eventName, {
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, "button", {
value: 0,
});
Object.defineProperty(event, "pointerType", {
value: "touch",
});
getHomeButton(wrapper).element.dispatchEvent(event);
await nextTick();
};
const clickDialogButton = async (testId) => {
await flushMicrotasks();
document.body.querySelector(`[data-testid='${testId}']`).click();
await flushMicrotasks();
};
const holdHomeFor = async (wrapper, durationMs) => {
await dispatchHomePointerEvent(wrapper, "pointerdown");
await vi.advanceTimersByTimeAsync(durationMs);
await nextTick();
};
describe("MobileFooter", () => {
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = "";
mocks.route.path = "/user/wash/start";
mocks.route.fullPath = "/user/wash/start";
mocks.push.mockReset();
vi.mocked(forceFrontendUpdateAndClearLocal).mockClear();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
});
it("navigates to /user on a short Hjem click", async () => {
const wrapper = mountFooter();
await dispatchHomePointerEvent(wrapper, "pointerdown");
await dispatchHomePointerEvent(wrapper, "pointerup");
await getHomeButton(wrapper).trigger("click");
expect(mocks.push).toHaveBeenCalledTimes(1);
expect(mocks.push).toHaveBeenCalledWith("/user");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
});
it("does not open the reset dialog when Hjem is held for less than 5 seconds", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 4999);
await dispatchHomePointerEvent(wrapper, "pointerup");
await vi.advanceTimersByTimeAsync(1);
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
});
it("opens the reset dialog after a 5 second Hjem hold and suppresses navigation", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await dispatchHomePointerEvent(wrapper, "pointerup");
await getHomeButton(wrapper).trigger("click");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).not.toBeNull();
expect(mocks.push).not.toHaveBeenCalled();
});
it("closes the reset dialog without cleanup when Nej is pressed", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await flushMicrotasks();
await clickDialogButton("local-data-reset-cancel");
expect(document.body.querySelector("[data-testid='local-data-reset-dialog']")).toBeNull();
expect(forceFrontendUpdateAndClearLocal).not.toHaveBeenCalled();
});
it("runs the full local data cleanup when Ja, ryd alt is pressed", async () => {
const wrapper = mountFooter();
await holdHomeFor(wrapper, 5000);
await flushMicrotasks();
await clickDialogButton("local-data-reset-confirm");
expect(forceFrontendUpdateAndClearLocal).toHaveBeenCalledTimes(1);
});
});