Add release management enhancements, runtime-aware frontend updates, and extensive test coverage

- Implement release update detection, asset preloading, and frontend version management.
- Add unit and E2E tests for release update workflows, widget behavior, and failure scenarios.
- Introduce new services for handling release ping, error reporting, and update installation workflows.
- Extend i18n for release-related components and error report localization.
- Add `ReleaseFrontendVersionBadge.vue` and related styles to display frontend update statuses.
This commit is contained in:
Jeppe Bundgaard
2026-05-27 13:23:22 +02:00
parent 184ef5670c
commit c437c89cfa
85 changed files with 3888 additions and 407 deletions
+22
View File
@@ -22,6 +22,28 @@ npm install
npm run dev
```
By default, the Vite dev server proxies `/api/*` to the remote stable API at
`https://api-v2.truckwash.io/master/api`. This lets the Vue app run locally
without a local PHP API container.
To develop against a local PHP API instead:
```powershell
$env:VITE_API_PROXY_TARGET="http://localhost"; npm run dev
```
To use another remote API route:
```powershell
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
```
For compatible local gateways that expect the `/api` prefix to be preserved:
```powershell
$env:VITE_API_PROXY_TARGET="http://localhost"; $env:VITE_API_PROXY_STRIP_PREFIX="false"; npm run dev
```
### Compile and Minify for Production
```sh
-2
View File
@@ -18,7 +18,6 @@ const { t, te, locale } = useI18n({ useScope: "global" });
const APP_TITLE = "Truck Wash";
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const VersionCheck = defineAsyncComponent(() => import("@/components/global/VersionCheck.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
@@ -123,7 +122,6 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
<header></header>
<main>
<DefaultPageWrapper>
<VersionCheck />
<router-view />
</DefaultPageWrapper>
</main>
@@ -1,7 +1,16 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from "vue-router";
import { getCurrentStep, setDepartment, getOrderId, setStep, setOrderId, searchAndSelectCustomer, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
import {
clearActivePosOrderContext,
getCurrentStep,
setDepartment,
getOrderId,
setStep,
setOrderId,
searchAndSelectCustomer,
loadOrderItems,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue";
@@ -41,20 +50,33 @@ const debugVehicles = () => {
}
);
};
setDepartment();
watch(() => router.currentRoute.value.params.departmentId, (nextDepartmentId) => {
if (nextDepartmentId) {
setDepartment(nextDepartmentId);
}
});
applyPosRouteSearch(window.location.search, {
const routeStateHandlers = {
setOrderId,
loadOrderItems,
setStep,
searchAndSelectCustomer,
});
clearActivePosOrderContext,
resetMobilePos: () => pos.reset.pos(),
};
const getRouteSearch = (route) => {
const fullPath = route?.fullPath || "";
const queryIndex = fullPath.indexOf("?");
return queryIndex >= 0 ? fullPath.slice(queryIndex) : "";
};
const applyCurrentPosRoute = () => {
const currentRoute = router.currentRoute.value;
if (currentRoute.params.departmentId) {
setDepartment(currentRoute.params.departmentId);
} else {
setDepartment();
}
applyPosRouteSearch(getRouteSearch(currentRoute), routeStateHandlers);
};
watch(() => router.currentRoute.value.fullPath, applyCurrentPosRoute, { immediate: true });
/** Define the createOrder function */
</script>
@@ -1,34 +1,27 @@
<script setup lang="ts">
import {
reset_all_values,
customer_name,
nextStep,
searchAndSelectCustomer,
isCustomerSelected,
order_id,
customer_id,
step,
reg_1,
reg_2,
reg_3,
reference,
order_notes,
setDepartment,
getDepartment,
getStoredPosOrderId,
} from "@/components/shop/POSDepartmentProcess.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
const toPositiveInteger = (value: unknown) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const onClickClearAll = async () => {
// 1. Delete the order (If any)
// Check localstorage for pos_order_id
if (localStorage.getItem("pos_order_id")) {
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
}
if (order_id.value) {
const storedOrderId = getStoredPosOrderId();
const currentOrderId = toPositiveInteger(order_id.value);
const activeDraftOrderId = storedOrderId && currentOrderId === storedOrderId ? storedOrderId : null;
// 1. Delete only the active mobile draft order, never a historical route-loaded order.
if (activeDraftOrderId) {
try {
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
order_id.value = activeDraftOrderId;
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
if (!deleted) {
return;
}
@@ -27,6 +27,9 @@ const emit = defineEmits<{
:model-value="selectedLaneId"
:native-value="lane.id"
type="is-link"
:disabled="!isLaneAvailable(lane)"
:title="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
:aria-label="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
:data-testid="`self-serve-lane-option-${lane.id}`"
@update:model-value="emit('update:selectedLaneId', lane.id)"
@input="emit('update:selectedLaneId', lane.id)"
@@ -36,7 +39,7 @@ const emit = defineEmits<{
<span v-if="!isLaneAvailable(lane)">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.occupied") }}
{{ $t("self_wash.lane_unavailable") }}
</small>
</span>
<span v-else>
@@ -83,6 +86,8 @@ const emit = defineEmits<{
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable(selectedLaneId)"
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
@@ -105,6 +110,15 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
<b-message
v-if="selectedLaneId && !isMachineAvailable(selectedLaneId)"
type="is-warning"
has-icon
:closable="false"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</b-field>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { BCheckbox, BField, BIcon } from "buefy";
import { getSelfServeTaskDynamicImagePresentation } from "@/services/selfServeDynamicImage.js";
const props = withDefaults(defineProps<{
tasks: Array<any>;
@@ -74,13 +75,8 @@ const getProgramWheelSelection = (task: any) => {
if (!taskUsesProgramPicker(task)) {
return null;
}
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
if (rawValue === null || rawValue === undefined || rawValue === "") {
return null;
}
const parsed = Number.parseInt(String(rawValue), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
return getSelfServeTaskDynamicImagePresentation(task).thumbPosition;
};
const formatTaskTitle = (task: any) => {
@@ -16,6 +16,7 @@ const props = defineProps<{
availableProductIds: number[];
vehicleTypes: Array<any>;
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
}>();
const emit = defineEmits<{
@@ -109,6 +110,15 @@ const emitRegistration = (value: unknown) => {
>
{{ props.vehicleStepError }}
</b-message>
<b-message
v-else-if="props.vehicleStepGuidance"
type="is-info"
has-icon
:closable="false"
data-testid="self-serve-vehicle-step-guidance"
>
{{ props.vehicleStepGuidance }}
</b-message>
<b-field :label="$t('self_wash.select_your_vehicle')">
<template v-if="availableProductIds.length > 0">
<SelfServeVehicleTypeSelector
+40 -5
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, nextTick, reactive, ref } from "vue";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -9,6 +10,7 @@ import {
releaseRuntimeState,
} from "@/services/releaseTimeline.js";
import { submitErrorReport } from "@/services/errorReports.js";
import { errorReportLaunchRequestId } from "@/services/errorReportLauncher.js";
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
@@ -21,6 +23,9 @@ const FALLBACK_LABELS = {
"error_report.before_error": "What were you doing before the error occurred?",
"error_report.expected": "What did you expect would happen?",
"error_report.actual": "What actually happened?",
"error_report.before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"error_report.expected_placeholder": "Describe the result you expected to see.",
"error_report.actual_placeholder": "Describe what you saw instead, including any error text.",
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"error_report.submit": "Submit report",
"error_report.submitted": "Error report submitted.",
@@ -46,7 +51,12 @@ const form = reactive({
data_collection_accepted: false,
});
const isMobileReportPlacement = useMediaQuery("(max-width: 768px)");
const isDesktopNavigationReportPlacement = useMediaQuery("(min-width: 1024px)");
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const shouldShowFloatingButton = computed(
() => !isMobileReportPlacement.value && !isDesktopNavigationReportPlacement.value
);
const isValid = computed(() => (
form.before_error.trim().length > 0
&& form.expected.trim().length > 0
@@ -96,6 +106,12 @@ const open = () => {
void loadHtml2Canvas().catch(() => {});
};
watch(errorReportLaunchRequestId, () => {
if (isAuthenticated.value) {
open();
}
});
const close = () => {
if (isSubmitting.value) {
return;
@@ -300,6 +316,7 @@ const submit = async () => {
<template>
<div v-if="isAuthenticated" data-error-report-exclude>
<button
v-if="shouldShowFloatingButton"
class="button is-danger error-report-button"
type="button"
data-testid="error-report-button"
@@ -317,7 +334,7 @@ const submit = async () => {
<h2 class="title is-4">{{ tr("error_report.title") }}</h2>
<p class="subtitle is-6">{{ tr("error_report.subtitle") }}</p>
</div>
<button class="delete" type="button" aria-label="close" :disabled="isSubmitting" @click="close"></button>
<button class="delete" type="button" :aria-label="t('common.close')" :disabled="isSubmitting" @click="close"></button>
</header>
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
@@ -332,17 +349,35 @@ const submit = async () => {
<label class="field">
<span class="label">{{ tr("error_report.before_error") }}</span>
<textarea v-model="form.before_error" class="textarea" maxlength="4000" required></textarea>
<textarea
v-model="form.before_error"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.before_error_placeholder')"
required
></textarea>
</label>
<label class="field">
<span class="label">{{ tr("error_report.expected") }}</span>
<textarea v-model="form.expected" class="textarea" maxlength="4000" required></textarea>
<textarea
v-model="form.expected"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.expected_placeholder')"
required
></textarea>
</label>
<label class="field">
<span class="label">{{ tr("error_report.actual") }}</span>
<textarea v-model="form.actual" class="textarea" maxlength="4000" required></textarea>
<textarea
v-model="form.actual"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.actual_placeholder')"
required
></textarea>
</label>
<label class="checkbox error-report-consent">
@@ -3,38 +3,80 @@
import { useRouter } from 'vue-router';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { ref } from 'vue';
defineProps(['hasPermission']);
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { pingApiServer } from "@/services/apiHealth.js";
const props = defineProps({
hasPermission: {
type: Boolean,
default: false,
},
});
const router = useRouter();
const showDebugInfo = ref(false);
const isSessionInitiated = computed(() => SessionUser.isInitiated());
let sessionTimeout = null;
// If the user is not initiated, and it takes more than 5 seconds, show an error message
setTimeout(() => {
const clearSessionTimeout = () => {
if (sessionTimeout) {
clearTimeout(sessionTimeout);
sessionTimeout = null;
}
};
const redirectToConnectivityIssue = () => {
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
return;
}
window.location.href = "/connectivity-issue";
};
const handleSlowSessionBootstrap = async () => {
if (!SessionUser.isInitiated()) {
Swal.fire({
const pingResult = await pingApiServer();
if (!pingResult.ok) {
redirectToConnectivityIssue();
return;
}
const result = await Swal.fire({
title: 'Error',
text: 'The user session could not be initiated.',
icon: 'error',
confirmButtonText: 'Clear session, and try again',
showCancelButton: true,
cancelButtonText: 'Retry',
}).then(() => {
SessionUser.auth.forceClearSession();
window.location.reload();
});
if (result.isConfirmed) {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
return;
}
await SessionUser.initiateOnAppStart({ force: true });
}
};
onMounted(() => {
sessionTimeout = setTimeout(() => {
void handleSlowSessionBootstrap();
}, 5000);
});
onBeforeUnmount(clearSessionTimeout);
</script>
<template>
<div>
<div v-if="!SessionUser.isInitiated()">
<div v-if="!isSessionInitiated">
<div class="pageloader is-active is-dark">
<span class="title">Bekræfter bruger...</span>
</div>
</div>
<slot v-if="hasPermission" />
<slot v-else-if="props.hasPermission" />
<div v-else>
<div class="my-6">
<h2 class="title is-2">403 Forbidden</h2>
@@ -5,6 +5,7 @@ import {
releaseChannelKey,
releaseChannelOptions,
} from "@/services/releaseChannelAvailability.js";
import ReleaseFrontendVersionBadge from "@/components/release/ReleaseFrontendVersionBadge.vue";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const props = defineProps({
@@ -157,7 +158,10 @@ const selectOption = (option) => {
data-testid="release-channel-selector"
>
<div class="release-channel-selector__header">
<div class="release-channel-selector__header-main">
<p>{{ selectorTitle }}</p>
<ReleaseFrontendVersionBadge v-if="isSidebar" />
</div>
<span v-if="selectorSubtitle">{{ selectorSubtitle }}</span>
</div>
@@ -239,6 +243,14 @@ const selectOption = (option) => {
margin-bottom: 10px;
}
.release-channel-selector__header-main {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.release-channel-selector__header p {
margin: 0;
color: #10243f;
@@ -1,21 +1,42 @@
<script setup>
import { ref } from "vue";
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
import {
clearSelectedReleaseChannel,
releaseChannelSelectorVisible,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
import {
isReleaseSourceOverrideAvailable,
RELEASE_SOURCE_MODES,
setReleaseSourceOverride,
} from "@/services/releaseBootstrap.js";
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const switchingSlug = ref("");
const switchError = ref("");
const { t, te } = useI18n({ useScope: "global" });
const reloadWindow = inject("releaseSourceReload", () => {
if (typeof window !== "undefined") {
window.location.reload();
}
});
const tr = (key, fallback) => {
const path = `configuration.release_manager.channel_selector.${key}`;
return te(path) ? t(path) : fallback;
};
const showUseLocalFrontend = computed(
() =>
releaseChannelSelectorVisible.value &&
isReleaseSourceOverrideAvailable() &&
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
);
const switchChannel = async (option) => {
if (switchingSlug.value) {
return;
@@ -25,29 +46,84 @@ const switchChannel = async (option) => {
switchError.value = "";
try {
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
} catch (error) {
switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
} finally {
switchingSlug.value = "";
}
};
const useLocalFrontend = () => {
if (switchingSlug.value) {
return;
}
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
clearSelectedReleaseChannel();
reloadWindow();
};
</script>
<template>
<div v-if="releaseChannelSelectorVisible" class="release-channel-sidebar-selector">
<div class="release-channel-sidebar-selector">
<ReleaseChannelSelector
v-if="releaseChannelSelectorVisible"
variant="sidebar"
:switching-slug="switchingSlug"
:disabled="Boolean(switchingSlug)"
@select="switchChannel"
/>
<button
v-if="showUseLocalFrontend"
type="button"
class="release-channel-sidebar-selector__local-button"
data-testid="release-channel-use-local-frontend"
:disabled="Boolean(switchingSlug)"
@click="useLocalFrontend"
>
<i class="fas fa-code" aria-hidden="true"></i>
<span>{{ tr("use_local_frontend", "Use local frontend") }}</span>
</button>
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
{{ switchError }}
</p>
<ReleaseUpdateWidget />
</div>
</template>
<style scoped>
.release-channel-sidebar-selector__local-button {
width: calc(100% - 32px);
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: -2px 16px 14px;
padding: 9px 10px;
border: 1px solid #b8c7d9;
border-radius: 5px;
background: #f8fbff;
color: #153554;
cursor: pointer;
font-size: 0.82rem;
font-weight: 800;
line-height: 1.2;
text-align: center;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.release-channel-sidebar-selector__local-button:hover:not(:disabled) {
border-color: #6f8fb4;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.release-channel-sidebar-selector__local-button:disabled {
cursor: default;
opacity: 0.7;
}
.release-channel-sidebar-selector__error {
margin: 0 16px 14px;
color: #b42318;
@@ -0,0 +1,115 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import {
isSameReleaseCommit,
releaseUpdateState,
shortReleaseCommit,
} from "@/services/releaseUpdate.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const { locale } = useI18n({ useScope: "global" });
const textValue = (value) => String(value ?? "").trim();
const githubAccess = (version) =>
version?.metadata?.github_access && typeof version.metadata.github_access === "object"
? version.metadata.github_access
: {};
const githubCommit = (version) => {
const access = githubAccess(version);
return version?.commit && typeof version.commit === "object"
? version.commit
: access.commit || access.latest_commit || null;
};
const versionTimestamp = (version) => {
const commit = githubCommit(version);
return (
textValue(version?.deployed_at) ||
textValue(version?.released_at) ||
textValue(version?.promoted_at) ||
textValue(version?.created_at) ||
textValue(version?.commit_authored_at) ||
textValue(commit?.authored_at) ||
textValue(githubAccess(version)?.commit_authored_at)
);
};
const formatReleaseTime = (value) => {
const timestamp = textValue(value);
if (!timestamp) {
return "";
}
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) {
return timestamp;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
};
const currentText = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const latestText = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit));
const currentReleaseTime = computed(() =>
formatReleaseTime(versionTimestamp(releaseRuntimeState.versions?.frontend || null))
);
const hasLatestUpdate = computed(
() =>
Boolean(releaseUpdateState.latestCommit) &&
!isSameReleaseCommit(releaseUpdateState.currentCommit, releaseUpdateState.latestCommit)
);
const title = computed(() => {
const releaseTime = currentReleaseTime.value;
if (!hasLatestUpdate.value) {
return releaseTime ? `Frontend ${currentText.value} released ${releaseTime}` : `Frontend ${currentText.value}`;
}
return releaseTime
? `Frontend ${currentText.value} released ${releaseTime} to ${latestText.value}`
: `Frontend ${currentText.value} to ${latestText.value}`;
});
</script>
<template>
<span
class="release-frontend-version-badge"
:class="{ 'release-frontend-version-badge--update': hasLatestUpdate }"
:title="title"
data-testid="release-frontend-version-badge"
>
<span class="release-frontend-version-badge__commit">{{ currentText }}</span>
<template v-if="hasLatestUpdate">
<span class="release-frontend-version-badge__arrow">-&gt;</span>
<span class="release-frontend-version-badge__commit">{{ latestText }}</span>
</template>
</span>
</template>
<style scoped>
.release-frontend-version-badge {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
border: 0;
background: transparent;
color: #64748b;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.68rem;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.release-frontend-version-badge__commit {
padding: 0;
color: #64748b;
}
.release-frontend-version-badge--update {
color: #64748b;
}
.release-frontend-version-badge__arrow {
color: #64748b;
}
</style>
@@ -0,0 +1,232 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, watch } from "vue";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
import {
checkReleaseUpdateNow,
downloadReleaseCandidate,
inspectReleaseRuntimeForUpdate,
installReadyReleaseUpdate,
isSameReleaseCommit,
releaseUpdateState,
shortReleaseCommit,
startReleaseUpdateAutoCheck,
} from "@/services/releaseUpdate.js";
let stopAutoCheck = null;
const updateAvailable = computed(
() =>
Boolean(releaseUpdateState.latestCommit) &&
!isSameReleaseCommit(releaseUpdateState.currentCommit, releaseUpdateState.latestCommit)
);
const isVisible = computed(
() => updateAvailable.value && ["downloading", "ready", "failed"].includes(releaseUpdateState.status)
);
const progress = computed(() => Math.max(0, Math.min(100, Number(releaseUpdateState.progress || 0))));
const progressStyle = computed(() => ({ width: `${progress.value}%` }));
const latestCommitText = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit));
const currentCommitText = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const statusLabel = computed(() => {
if (releaseUpdateState.status === "ready") {
return "Ready";
}
if (releaseUpdateState.status === "failed") {
return "Failed";
}
return "Downloading";
});
const runtimeSignature = computed(() =>
JSON.stringify({
channel: releaseRuntimeState.channel?.slug || "",
commit: releaseRuntimeState.versions?.frontend?.commit_sha || releaseRuntimeState.versions?.frontend?.commit || "",
frontendBaseUrl: releaseRuntimeState.frontendBaseUrl || "",
configured: releaseRuntimeState.availability?.configured !== false,
})
);
const hasRuntimeCandidate = () =>
Boolean(releaseRuntimeState.channel || releaseRuntimeState.frontendBaseUrl || releaseRuntimeState.versions?.frontend);
const inspectCurrentRuntime = () => {
if (!hasRuntimeCandidate()) {
return;
}
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
};
const retryDownload = () => {
if (releaseUpdateState.candidate) {
void downloadReleaseCandidate(releaseUpdateState.candidate);
return;
}
void checkReleaseUpdateNow();
};
const installUpdate = () => {
void installReadyReleaseUpdate();
};
onMounted(() => {
stopAutoCheck = startReleaseUpdateAutoCheck();
inspectCurrentRuntime();
});
onBeforeUnmount(() => {
stopAutoCheck?.();
stopAutoCheck = null;
});
watch(runtimeSignature, inspectCurrentRuntime, { immediate: true });
</script>
<template>
<section v-if="isVisible" class="release-update-widget" data-testid="release-update-widget">
<div class="release-update-widget__topline">
<span class="release-update-widget__label">{{ statusLabel }}</span>
<span class="release-update-widget__commits">{{ currentCommitText }} -&gt; {{ latestCommitText }}</span>
</div>
<template v-if="releaseUpdateState.status === 'ready'">
<button
type="button"
class="release-update-widget__install"
data-testid="release-update-install"
@click="installUpdate"
>
Install
</button>
</template>
<template v-else-if="releaseUpdateState.status === 'failed'">
<div class="release-update-widget__failed">
<span>{{ releaseUpdateState.error || "Download failed." }}</span>
<button type="button" data-testid="release-update-retry" @click="retryDownload">Retry</button>
</div>
</template>
<template v-else>
<div
class="release-update-widget__progress"
role="progressbar"
:aria-valuenow="progress"
aria-valuemin="0"
aria-valuemax="100"
data-testid="release-update-progress"
>
<span :style="progressStyle"></span>
</div>
<div class="release-update-widget__progress-meta">
<span>{{ progress }}%</span>
<span v-if="releaseUpdateState.totalAssets">
{{ releaseUpdateState.downloadedAssets }}/{{ releaseUpdateState.totalAssets }}
</span>
</div>
</template>
</section>
</template>
<style scoped>
.release-update-widget {
margin: -2px 16px 14px;
padding: 9px 10px;
border: 1px solid #d7e2ef;
border-radius: 5px;
background: #f8fbff;
color: #1f2937;
}
.release-update-widget__topline,
.release-update-widget__progress-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.release-update-widget__label {
color: #10243f;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.release-update-widget__commits {
min-width: 0;
overflow: hidden;
color: #475569;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.68rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-update-widget__progress {
height: 7px;
margin-top: 8px;
border-radius: 999px;
background: #dbe5f0;
overflow: hidden;
}
.release-update-widget__progress span {
display: block;
height: 100%;
border-radius: inherit;
background: #153554;
transition: width 0.18s ease;
}
.release-update-widget__progress-meta {
margin-top: 5px;
color: #64748b;
font-size: 0.68rem;
font-weight: 700;
}
.release-update-widget__install {
width: 100%;
min-height: 30px;
margin-top: 8px;
border: 0;
border-radius: 4px;
background: #153554;
color: #ffffff;
cursor: pointer;
font-size: 0.78rem;
font-weight: 800;
}
.release-update-widget__install:hover {
background: #10243f;
}
.release-update-widget__failed {
display: grid;
gap: 7px;
margin-top: 7px;
}
.release-update-widget__failed span {
color: #b42318;
font-size: 0.7rem;
font-weight: 700;
line-height: 1.25;
}
.release-update-widget__failed button {
min-height: 28px;
border: 1px solid #f4a3a3;
border-radius: 4px;
background: #fff5f5;
color: #9f1c1c;
cursor: pointer;
font-size: 0.74rem;
font-weight: 800;
}
@media (prefers-reduced-motion: reduce) {
.release-update-widget__progress span {
transition: none;
}
}
</style>
+114 -23
View File
@@ -49,6 +49,7 @@ import { SubuserGrants } from "@/components/session/token/SessionUser/Objects/Su
import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue";
import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js";
import { pingApiServer } from "@/services/apiHealth.js";
import { normalizeSessionPayload } from "@/services/sessionPayload.js";
import {
isReleaseChannelApiAvailabilityError,
@@ -233,6 +234,92 @@ const resetSessionState = () => {
configureReleaseRuntime({});
};
let sessionBootstrapPromise = null;
const extractErrorMessage = (error) => {
if (typeof error === "string") {
return error;
}
if (error?.response?.data?.data?.message) {
return error.response.data.data.message;
}
if (error?.response?.data?.message) {
return error.response.data.message;
}
if (typeof error?.response?.data === "string") {
return error.response.data;
}
if (error?.message) {
return error.message;
}
return "";
};
export const isInvalidSessionError = (error) => {
const status = Number.parseInt(String(error?.response?.status ?? ""), 10);
const message = extractErrorMessage(error).toLowerCase();
if (status === 401 || status === 419) {
return true;
}
if (![400, 403].includes(status)) {
return false;
}
return (
message.includes("invalid session") ||
message.includes("invalid token") ||
message.includes("unauthenticated") ||
message.includes("unauthorized")
);
};
const redirectToConnectivityIssue = () => {
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
return false;
}
window.location.href = "/connectivity-issue";
return true;
};
export const handleSessionBootstrapFailure = async (error, { title, text } = {}) => {
if (isInvalidSessionError(error)) {
const pingResult = await pingApiServer();
if (!pingResult.ok) {
console.warn("Preserving stored session because API ping failed during invalid-session handling.", pingResult);
const redirected = redirectToConnectivityIssue();
return {
cleared: false,
apiReachable: false,
ping: pingResult,
redirectedTo: redirected ? "/connectivity-issue" : null,
};
}
forceClearSession();
SessionUser.functions.redirectTo.pages.login();
return { cleared: true, apiReachable: true, ping: pingResult, redirectedTo: "/login" };
}
parseError(error, "auth");
console.error(error);
await Swal.fire({
title,
text,
icon: "error",
confirmButtonText: "Ok",
});
return { cleared: false, apiReachable: null, ping: null, redirectedTo: null };
};
export const refreshSessionData = async () => {
if (!hydrateSessionFromStorage()) {
resetSessionState();
@@ -250,12 +337,30 @@ export const refreshSessionData = async () => {
* Initiate the user session on app start
* @returns {Promise<void>}
*/
export const initiateOnAppStart = async () => {
if (hydrateSessionFromStorage()) {
export const initiateOnAppStart = async ({ force = false } = {}) => {
if (sessionBootstrapPromise && !force) {
return await sessionBootstrapPromise;
}
const bootstrapPromise = (async () => {
if (!hydrateSessionFromStorage()) {
return null;
}
if (SessionUser.isSubuser.value) {
await getSubuserSessionData();
} else {
await getSessionData();
return await getSubuserSessionData();
}
return await getSessionData();
})();
sessionBootstrapPromise = bootstrapPromise;
try {
return await bootstrapPromise;
} finally {
if (sessionBootstrapPromise === bootstrapPromise) {
sessionBootstrapPromise = null;
}
}
};
@@ -372,23 +477,16 @@ export const getSubuserSessionData = async () => {
await refreshReleaseRuntime();
SessionUser.initiated.value = true;
})
.catch((error) => {
.catch(async (error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
parseError(error, "auth");
console.error(error);
Swal.fire({
return await handleSessionBootstrapFailure(error, {
title: "Fejl ved hentning af subbrugerdata",
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
icon: "error",
confirmButtonText: "Ok",
}).then(() => {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
});
});
};
@@ -448,23 +546,16 @@ export const getSessionData = async () => {
}
SessionUser.initiated.value = true;
})
.catch((error) => {
.catch(async (error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
parseError(error, "auth");
console.error(error);
Swal.fire({
return await handleSessionBootstrapFailure(error, {
title: "Fejl ved hentning af brugerdata",
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
icon: "error",
confirmButtonText: "Ok",
}).then(() => {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
});
});
};
+58 -20
View File
@@ -507,6 +507,63 @@ export const clearStoredPosOrderId = () => {
localStorage.removeItem("pos_order_id");
};
const hasActivePosOrderContext = () => {
return Boolean(
toPositiveInteger(order_id.value) ||
getSelectedCustomerNumber() ||
String(customer_name.value ?? "").trim() ||
(Array.isArray(order_items.value) && order_items.value.length > 0) ||
String(reference.value ?? "").trim() ||
String(order_notes.value ?? "").trim() ||
String(order_po.value ?? "").trim() ||
String(order_safety_seal.value ?? "").trim() ||
String(reg_1.value ?? "").trim() ||
String(reg_2.value ?? "").trim() ||
String(reg_3.value ?? "").trim() ||
invoiceCollectionId.value ||
completed_at.value ||
getStoredPosOrderId()
);
};
export const clearActivePosOrderContext = (options = {}) => {
const normalizedOptions = {
clearStep: false,
clearStoredOrderId: true,
...options,
};
const hadActiveContext = hasActivePosOrderContext();
clearErrors();
if (normalizedOptions.clearStep) {
step.value = 1;
}
order_id.value = null;
clearSelectedCustomerState({ clearOrderNotes: true });
order_items.value = [];
user_discounts.value = [];
has_user_discounts_loaded.value = false;
scans.value = [];
scan_data.value = [];
invoiceCollectionId.value = null;
completed_at.value = null;
reference.value = "";
order_notes.value = "";
order_po.value = "";
order_safety_seal.value = "";
reg_1.value = "";
reg_2.value = "";
reg_3.value = "";
isCreatingOrder.value = false;
createOrderRequest = null;
clearSelectedOrderBookingSelection();
if (normalizedOptions.clearStoredOrderId) {
clearStoredPosOrderId();
}
return hadActiveContext;
};
const SELECTED_ORDER_BOOKING_ID_STORAGE_KEY = "pos_selected_order_booking_id";
const SELECTED_ORDER_BOOKING_PLATE_STORAGE_KEY = "pos_selected_order_booking_plate";
const SELECTED_ORDER_BOOKING_SKIPPED_PLATE_STORAGE_KEY = "pos_selected_order_booking_skipped_plate";
@@ -692,26 +749,7 @@ export const restoreStoredPosOrderId = async (
export const reset_all_values = () => {
// Reset all values to their initial state
clearErrors();
step.value = 1;
order_id.value = null;
clearCustomerSelection();
clearCache();
user_discounts.value = [];
has_user_discounts_loaded.value = false;
scans.value = [];
scan_data.value = [];
invoiceCollectionId.value = null;
completed_at.value = null;
reference.value = "";
order_notes.value = "";
order_po.value = "";
order_safety_seal.value = "";
isCreatingOrder.value = false;
createOrderRequest = null;
clearSelectedOrderBookingSelection();
// Clear the order id from the local storage (if present)
clearStoredPosOrderId();
clearActivePosOrderContext({ clearStep: true });
// Clear the query parameters
window.history.pushState({}, "", `?step=1`);
};
@@ -7,7 +7,9 @@ import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelec
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
<template>
@@ -62,13 +64,13 @@ const route = useRoute();
<span>{{ $t('header.return_to_account') }}</span>
</router-link>
</template>
<router-link class="button is-white" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon">
<i class="fas fa-user-circle" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ SessionUser.authenticated.value ? SessionUser.getName() : ''}}</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ isAuthenticatedSessionReady ? SessionUser.getName() : ''}}</span>
</router-link>
<template v-else>
<template v-if="!SessionUser.authenticated.value">
<router-link class="button is-white" to="/register">
<span class="icon">
<i class="fas fa-user-plus" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { BIcon, BMenu, BMenuItem, BMenuList, BTooltip } from "buefy";
import type { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
import { useNavigationItems } from "@/components/models/navigation/items/NavigationMenuItems.vue";
@@ -11,6 +12,7 @@ import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue";
import { requestOpenErrorReport } from "@/services/errorReportLauncher.js";
type NavigationMenuItem = NavigationItemProps & {
priority_order?: number | null;
@@ -19,9 +21,17 @@ type NavigationMenuItem = NavigationItemProps & {
type NavigationBadge = NonNullable<NavigationMenuItem["badge"]>;
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const { parsedItems, parsedItemsGlobal } = useNavigationItems();
const ERROR_REPORT_BUTTON_FALLBACK = "Report error";
const isMatchingRoute = (item: NavigationMenuItem) => item.to === route.path;
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const shouldShowErrorReportAction = computed(() => !isSmall.value && isAuthenticated.value);
const errorReportButtonLabel = computed(() => {
const label = t("error_report.button");
return label === "error_report.button" ? ERROR_REPORT_BUTTON_FALLBACK : label;
});
const navigationItems = computed<NavigationMenuItem[]>(() => {
const items = [...parsedItems(), ...parsedItemsGlobal()] as NavigationMenuItem[];
@@ -454,10 +464,39 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
</BMenu>
<LanguageSelector />
<ReleaseChannelSidebarSelector />
<div v-if="shouldShowErrorReportAction" class="desktop-buefy-error-report-action">
<button
class="button is-text desktop-buefy-error-report-button"
type="button"
data-testid="error-report-button"
@click="requestOpenErrorReport"
>
<span class="icon is-small"><i class="fas fa-bug" aria-hidden="true"></i></span>
<span>{{ errorReportButtonLabel }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.desktop-buefy-error-report-action {
padding: 0 16px 14px;
}
.desktop-buefy-error-report-button {
width: 100%;
justify-content: flex-start;
color: #172033;
text-decoration: none;
}
.button.desktop-buefy-error-report-button:hover,
.button.desktop-buefy-error-report-button:focus-visible {
background: rgba(180, 35, 24, 0.08);
color: #b42318;
text-decoration: none;
}
.desktop-buefy-item-label,
.desktop-buefy-child-label {
display: flex;
@@ -13,6 +13,7 @@ const showProfile = computed(() => {
const path = route.path;
return path.startsWith("/user");
});
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -23,7 +24,7 @@ const showProfile = computed(() => {
<nav class="navbar is-link is-mobile is-fixed-top mobile-header-section" role="navigation" aria-label="main navigation" :class="{'is-blurred': isTransparent}">
<div class="navbar-item" v-if="showProfile">
<!-- Profile -->
<router-link class="button is-white py-0" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white py-0" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon is-large">
<i class="fas fa-user-circle fa-2x"
:class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
@@ -45,7 +46,7 @@ const showProfile = computed(() => {
<NavigationMenuDepartment/>
</div>
<!-- Subuser Grant Selection -->
<div class="navbar-item" v-if="SessionUser.isSubuser.value && showProfile">
<div class="navbar-item" v-if="isAuthenticatedSessionReady && SessionUser.isSubuser.value && showProfile">
<SubuserGrantSelector :showLabel="false" :compact="true" />
</div>
<div class="navbar-end" style="margin-left: auto;" v-show="!SessionUser.canAccessAdmin()">
@@ -1,12 +1,31 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { toggleExpanded, isOpen } from '@/components/viewport/page/headers/ViewportHeaderSettings.vue';
import NavigationMenuItems from "@/components/viewport/page/headers/menu/NavigationMenuItems.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import DesktopNavigationBuefy from "@/components/viewport/page/headers/DesktopNavigationBuefy.vue";
import { requestOpenErrorReport } from "@/services/errorReportLauncher.js";
const { t } = useI18n({ useScope: "global" });
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || 'No build date';
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || 'No commit hash';
const VITE_APP_VERSION = import.meta.env.VITE_APP_VERSION || 'No app version';
const VITE_IS_DEV = import.meta.env.DEV || false;
const ERROR_REPORT_BUTTON_FALLBACK = "Report error";
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const errorReportButtonLabel = computed(() => {
const label = t("error_report.button");
return label === "error_report.button" ? ERROR_REPORT_BUTTON_FALLBACK : label;
});
const openErrorReport = () => {
if (isOpen.value) {
toggleExpanded();
}
requestOpenErrorReport();
};
/**
* Scrollable menu for mobile viewports.
@@ -65,12 +84,23 @@ const VITE_IS_DEV = import.meta.env.DEV || false;
<NavigationMenuItems />
</div>
<!-- Bottom version info -->
<div class="has-text-centered" style="padding: 16px; font-size: 12px; color: gray;">
<div class="has-text-centered" style="padding: 16px; font-size: 12px; color: gray;" data-testid="mobile-build-info">
<!--<div>Version: {{ VITE_APP_VERSION }}</div>-->
<div>Commit: {{ VITE_COMMIT_HASH }}</div>
<div>Build Date: {{ SessionUser.functions.date.toLocal(VITE_BUILD_DATE) }}</div>
<div v-if="VITE_IS_DEV" style="color: red;">Development Mode</div>
</div>
<div v-if="isAuthenticated" class="mobile-error-report-action">
<button
class="button is-danger mobile-error-report-button"
type="button"
data-testid="error-report-button"
@click="openErrorReport"
>
<span class="icon is-small"><i class="fas fa-bug" aria-hidden="true"></i></span>
<span>{{ errorReportButtonLabel }}</span>
</button>
</div>
</div>
</div>
@@ -111,6 +141,12 @@ const VITE_IS_DEV = import.meta.env.DEV || false;
.mobile-menu-open .mobile-menu-spacer {
flex: 1;
}
.mobile-error-report-action {
padding: 0 16px 16px;
}
.mobile-error-report-button {
width: 100%;
}
.mobile-menu-open .text {
/* Menu */
margin: 0 auto;
@@ -6,7 +6,9 @@ import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/men
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
<template>
@@ -62,13 +64,13 @@ const route = useRoute();
<span>{{ $t('header.return_to_account') }}</span>
</router-link>
</template>
<router-link class="button is-white" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon">
<i class="fas fa-user-circle" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ SessionUser.authenticated.value ? SessionUser.getName() : ''}}</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ isAuthenticatedSessionReady ? SessionUser.getName() : ''}}</span>
</router-link>
<template v-else>
<template v-if="!SessionUser.authenticated.value">
<router-link class="button is-white" to="/register">
<span class="icon">
<i class="fas fa-user-plus" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
+1
View File
@@ -220,6 +220,7 @@ export function useWashSessionActions(options) {
await updateLaneAllowedServices(laneId);
} catch (error) {
console.error("Error updating allowed services before start:", error);
alertFn(extractCommandErrorMessage(error, "Der opstod en fejl ved klargoring af vasken. Prov igen senere."));
return false;
}
+28 -24
View File
@@ -2649,6 +2649,7 @@
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"use_local_frontend": "Brug lokal frontend",
"switch_error": "Release-kanalen kunne ikke skiftes. Den forrige kanal er stadig aktiv."
},
"common": {
@@ -3193,31 +3194,34 @@
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.medarbejdere'}"
},
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
"screenshot": "Skærmbillede",
"request_errors": "Request-fejl",
"vue_errors": "Vue-fejl",
"runtime_context": "Runtime-kontekst",
"empty": "Ingen fejlrapporter fundet.",
"resolve": "Marker som løst",
"reopen": "Genåbn",
"resolution_note": "Løsningsnote",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
"open": "Åben",
"resolved": "Løst",
"all": "Alle"
}
},
"errors": {
+28 -24
View File
@@ -2743,7 +2743,8 @@
"release_runtime": "Release-Laufzeit",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Lokales Frontend verwenden"
},
"common": {
"yes": "ja",
@@ -3286,31 +3287,34 @@
"subtitle": "@:{'words.generated.mitarbeiter'} @:{'words.generated.verwalten'}"
},
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"request_errors": "Request-Fehler",
"vue_errors": "Vue-Fehler",
"runtime_context": "Runtime-Kontext",
"empty": "Keine Fehlerberichte gefunden.",
"resolve": "Als gelöst markieren",
"reopen": "Wieder öffnen",
"resolution_note": "Lösungsnotiz",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
"open": "Offen",
"resolved": "Gelöst",
"all": "Alle"
}
},
"errors": {
+4
View File
@@ -2483,6 +2483,7 @@
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"use_local_frontend": "Use local frontend",
"switch_error": "Release channel could not be switched. The previous channel is still active."
},
"common": {
@@ -3033,6 +3034,9 @@
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
+33 -1
View File
@@ -1675,7 +1675,8 @@
"release_runtime": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.release_runtime'}",
"bundle": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.bundle'}",
"frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.frontend'}",
"api": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api'}"
"api": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.api'}",
"use_local_frontend": "@:{'templates.generated.compat.configuration.release_manager.channel_selector.use_local_frontend'}"
},
"common": {
"yes": "@:{'templates.generated.compat.configuration.release_manager.common.yes'}",
@@ -2389,6 +2390,37 @@
"subtitle": "@:{'templates.generated.compat.employees.subtitle'}",
"title": "@:superuser.nav.employees"
},
"error_report": {
"button": "@:{'templates.generated.compat.error_report.button'}",
"title": "@:{'templates.generated.compat.error_report.title'}",
"subtitle": "@:{'templates.generated.compat.error_report.subtitle'}",
"before_error": "@:{'templates.generated.compat.error_report.before_error'}",
"expected": "@:{'templates.generated.compat.error_report.expected'}",
"actual": "@:{'templates.generated.compat.error_report.actual'}",
"before_error_placeholder": "@:{'templates.generated.compat.error_report.before_error_placeholder'}",
"expected_placeholder": "@:{'templates.generated.compat.error_report.expected_placeholder'}",
"actual_placeholder": "@:{'templates.generated.compat.error_report.actual_placeholder'}",
"consent": "@:{'templates.generated.compat.error_report.consent'}",
"submit": "@:{'templates.generated.compat.error_report.submit'}",
"submitted": "@:{'templates.generated.compat.error_report.submitted'}",
"capture_failed": "@:{'templates.generated.compat.error_report.capture_failed'}",
"submit_failed": "@:{'templates.generated.compat.error_report.submit_failed'}",
"required": "@:{'templates.generated.compat.error_report.required'}",
"page_subtitle": "@:{'templates.generated.compat.error_report.page_subtitle'}",
"screenshot": "@:{'templates.generated.compat.error_report.screenshot'}",
"request_errors": "@:{'templates.generated.compat.error_report.request_errors'}",
"vue_errors": "@:{'templates.generated.compat.error_report.vue_errors'}",
"runtime_context": "@:{'templates.generated.compat.error_report.runtime_context'}",
"empty": "@:{'templates.generated.compat.error_report.empty'}",
"resolve": "@:{'templates.generated.compat.error_report.resolve'}",
"reopen": "@:{'templates.generated.compat.error_report.reopen'}",
"resolution_note": "@:{'templates.generated.compat.error_report.resolution_note'}",
"filters": {
"open": "@:{'templates.generated.compat.error_report.filters.open'}",
"resolved": "@:{'templates.generated.compat.error_report.filters.resolved'}",
"all": "@:{'templates.generated.compat.error_report.filters.all'}"
}
},
"errors": {
"forbidden": "@:{'templates.generated.compat.errors.forbidden'}",
"general": "@:{'templates.generated.compat.errors.general'}",
+29 -25
View File
@@ -2744,7 +2744,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Bruk lokal frontend"
},
"common": {
"yes": "ja",
@@ -3287,31 +3288,34 @@
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.ansatte'}"
},
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
"screenshot": "Skjermbilde",
"request_errors": "Request-feil",
"vue_errors": "Vue-feil",
"runtime_context": "Runtime-kontekst",
"empty": "Ingen feilrapporter funnet.",
"resolve": "Marker som løst",
"reopen": "Åpne igjen",
"resolution_note": "Løsningsnotat",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
"open": "Åpen",
"resolved": "Løst",
"all": "Alle"
}
},
"errors": {
+29 -25
View File
@@ -2794,7 +2794,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Använd lokal frontend"
},
"common": {
"yes": "ja",
@@ -3337,31 +3338,34 @@
"subtitle": "@:{'words.generated.administrer'} @:{'words.generated.ansatte'}"
},
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"button": "Rapportera fel",
"title": "Rapportera fel",
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
"before_error": "Vad gjorde du innan felet uppstod?",
"expected": "Vad förväntade du dig skulle hända?",
"actual": "Vad hände faktiskt?",
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
"submit": "Skicka rapport",
"submitted": "Felrapport skickad.",
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
"submit_failed": "Felrapporten kunde inte skickas.",
"required": "Alla fält och godkännande av datainsamling krävs.",
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
"screenshot": "Skärmbild",
"request_errors": "Request-fel",
"vue_errors": "Vue-fel",
"runtime_context": "Runtime-kontext",
"empty": "Inga felrapporter hittades.",
"resolve": "Markera som löst",
"reopen": "Öppna igen",
"resolution_note": "Lösningsnotering",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
"open": "Öppen",
"resolved": "Löst",
"all": "Alla"
}
},
"errors": {
+33 -1
View File
@@ -1,4 +1,35 @@
{
"error_report": {
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
"screenshot": "Skærmbillede",
"request_errors": "Request-fejl",
"vue_errors": "Vue-fejl",
"runtime_context": "Runtime-kontekst",
"empty": "Ingen fejlrapporter fundet.",
"resolve": "Marker som løst",
"reopen": "Genåbn",
"resolution_note": "Løsningsnote",
"filters": {
"open": "Åben",
"resolved": "Løst",
"all": "Alle"
}
},
"about_us": {
"solutions": {
"customer": {
@@ -1691,7 +1722,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Brug lokal frontend"
},
"common": {
"yes": "ja",
+33 -1
View File
@@ -1,4 +1,35 @@
{
"error_report": {
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
"screenshot": "Screenshot",
"request_errors": "Request-Fehler",
"vue_errors": "Vue-Fehler",
"runtime_context": "Runtime-Kontext",
"empty": "Keine Fehlerberichte gefunden.",
"resolve": "Als gelöst markieren",
"reopen": "Wieder öffnen",
"resolution_note": "Lösungsnotiz",
"filters": {
"open": "Offen",
"resolved": "Gelöst",
"all": "Alle"
}
},
"about_us": {
"solutions": {
"customer": {
@@ -1691,7 +1722,8 @@
"release_runtime": "Release-Laufzeit",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Lokales Frontend verwenden"
},
"common": {
"yes": "ja",
+33 -1
View File
@@ -1,4 +1,35 @@
{
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
}
},
"about_us": {
"solutions": {
"customer": {
@@ -1691,7 +1722,8 @@
"release_runtime": "Release runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Use local frontend"
},
"common": {
"yes": "yes",
+33 -1
View File
@@ -1,4 +1,35 @@
{
"error_report": {
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
"screenshot": "Skjermbilde",
"request_errors": "Request-feil",
"vue_errors": "Vue-feil",
"runtime_context": "Runtime-kontekst",
"empty": "Ingen feilrapporter funnet.",
"resolve": "Marker som løst",
"reopen": "Åpne igjen",
"resolution_note": "Løsningsnotat",
"filters": {
"open": "Åpen",
"resolved": "Løst",
"all": "Alle"
}
},
"about_us": {
"solutions": {
"customer": {
@@ -1691,7 +1722,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Bruk lokal frontend"
},
"common": {
"yes": "ja",
+33 -1
View File
@@ -1,4 +1,35 @@
{
"error_report": {
"button": "Rapportera fel",
"title": "Rapportera fel",
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
"before_error": "Vad gjorde du innan felet uppstod?",
"expected": "Vad förväntade du dig skulle hända?",
"actual": "Vad hände faktiskt?",
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
"submit": "Skicka rapport",
"submitted": "Felrapport skickad.",
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
"submit_failed": "Felrapporten kunde inte skickas.",
"required": "Alla fält och godkännande av datainsamling krävs.",
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
"screenshot": "Skärmbild",
"request_errors": "Request-fel",
"vue_errors": "Vue-fel",
"runtime_context": "Runtime-kontext",
"empty": "Inga felrapporter hittades.",
"resolve": "Markera som löst",
"reopen": "Öppna igen",
"resolution_note": "Lösningsnotering",
"filters": {
"open": "Öppen",
"resolved": "Löst",
"all": "Alla"
}
},
"about_us": {
"solutions": {
"customer": {
@@ -1691,7 +1722,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Använd lokal frontend"
},
"common": {
"yes": "ja",
@@ -510,6 +510,7 @@
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"use_local_frontend": "Brug lokal frontend",
"switch_error": "Release-kanalen kunne ikke skiftes. Den forrige kanal er stadig aktiv."
},
"common": {
@@ -1,31 +1,34 @@
{
"compat": {
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
"screenshot": "Skærmbillede",
"request_errors": "Request-fejl",
"vue_errors": "Vue-fejl",
"runtime_context": "Runtime-kontekst",
"empty": "Ingen fejlrapporter fundet.",
"resolve": "Marker som løst",
"reopen": "Genåbn",
"resolution_note": "Løsningsnote",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
"open": "Åben",
"resolved": "Løst",
"all": "Alle"
}
}
}
@@ -16,7 +16,9 @@
"loading_department": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.afdeling'}...",
"loading_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.typer'}...",
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgaengelig",
"machine": "@.capitalize:{'terms.glossary.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgaengelig for den valgte bane. Vaelg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
@@ -32,6 +34,10 @@
"start_wash": "@:{'phrases.compat.guest.start_wash'}",
"total_time": "@.capitalize:{'terms.glossary.samlet'} @:{'terms.glossary.tid'}: {minutes} @:{'terms.glossary.minutter'} @:{'terms.glossary.og'} {seconds} @:{'terms.glossary.sekunder'}",
"unavailable": "@.capitalize:{'terms.glossary.utilgængelig'}",
"vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsaette.",
"vehicle_step_missing_department": "Vent paa, at naermeste afdeling er indlaest, foer du fortsaetter.",
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsaette.",
"vehicle_step_missing_vehicle_type": "Vaelg en tilgaengelig koeretoejstype for at fortsaette.",
"wash_completed_contact": "@.capitalize:{'terms.glossary.vi'} håber, @:{'terms.glossary.at'} @:{'terms.glossary.du'} @:{'terms.glossary.er'} tilfreds @:{'terms.glossary.med'} resultatet. @.capitalize:{'terms.glossary.hvis'} @:{'terms.glossary.du'} @:{'terms.glossary.har'} @:{'terms.glossary.brug'} @:{'terms.glossary.for'} @:{'terms.glossary.yderligere'} assistance, @:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.velkommen'} @:{'terms.glossary.til'} @:{'terms.glossary.at'} kontakte @:{'terms.glossary.vores'} personale.",
"wash_completed_message": "@.capitalize:{'terms.glossary.din'} @:{'terms.glossary.vask'} @:{'terms.glossary.er'} @:{'terms.glossary.nu'} @:{'terms.glossary.færdig'}! @:{'terms.glossary.tak'} @:{'terms.glossary.fordi'} @:{'terms.glossary.du'} brugte @:{'terms.glossary.vores'} selvbetjeningsvask.",
"wash_completed": "@.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.færdig'}",
@@ -494,7 +494,8 @@
"release_runtime": "Release-Laufzeit",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Lokales Frontend verwenden"
},
"common": {
"yes": "ja",
@@ -1,31 +1,34 @@
{
"compat": {
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"request_errors": "Request-Fehler",
"vue_errors": "Vue-Fehler",
"runtime_context": "Runtime-Kontext",
"empty": "Keine Fehlerberichte gefunden.",
"resolve": "Als gelöst markieren",
"reopen": "Wieder öffnen",
"resolution_note": "Lösungsnotiz",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
"open": "Offen",
"resolved": "Gelöst",
"all": "Alle"
}
}
}
@@ -16,7 +16,9 @@
"loading_department": "@:{'terms.glossary.abteilung'} @:{'terms.glossary.wird'} @:{'terms.glossary.geladen'}...",
"loading_types": "@.capitalize:{'terms.glossary.typen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'terms.glossary.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
@@ -32,6 +34,10 @@
"start_wash": "@:{'phrases.compat.guest.start_wash'}",
"total_time": "Gesamtzeit: {minutes} Minuten @:{'terms.glossary.und'} {seconds} @:{'terms.glossary.sekunden'}",
"unavailable": "@.capitalize:{'terms.glossary.nicht'} @:{'terms.glossary.verfugbar'}",
"vehicle_step_missing_customer_number": "Geben Sie Ihre Kundennummer ein, um fortzufahren.",
"vehicle_step_missing_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
"vehicle_step_missing_vehicle_type": "Wahlen Sie einen verfugbaren Fahrzeugtyp, um fortzufahren.",
"wash_completed_contact": "@.capitalize:{'terms.glossary.wir'} hoffen, @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.sind'} @:{'terms.glossary.mit'} @:{'terms.glossary.dem'} @:{'terms.glossary.ergebnis'} zufrieden. @.capitalize:{'terms.glossary.wenn'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.weitere'} @:{'terms.glossary.hilfe'} @:{'terms.glossary.ben'}?@:{'terms.glossary.tigen'}, @:{'terms.glossary.kontaktieren'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.bitte'} @:{'terms.glossary.unser'} Personal.",
"wash_completed_message": "@.capitalize:{'terms.glossary.ihre'} @.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} @:{'terms.glossary.ist'} @:{'terms.glossary.jetzt'} @:{'terms.glossary.abgeschlossen'}! @:{'terms.glossary.danke'}, @:{'terms.glossary.dass'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.unsere'} Selbstbedienungsw?@:{'terms.glossary.sche'} genutzt @:{'terms.glossary.haben'}.",
"wash_completed": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} @:{'terms.glossary.abgeschlossen'}",
@@ -510,6 +510,7 @@
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API",
"use_local_frontend": "Use local frontend",
"switch_error": "Release channel could not be switched. The previous channel is still active."
},
"common": {
@@ -7,6 +7,9 @@
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
@@ -16,7 +16,9 @@
"loading_department": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.department'}...",
"loading_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.types'}...",
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
@@ -32,6 +34,10 @@
"start_wash": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.wash'}",
"total_time": "@.capitalize:{'terms.glossary.total'} @:{'terms.glossary.time'}: {minutes} @:{'terms.glossary.minutes'} @:{'terms.glossary.and'} {seconds} @:{'terms.glossary.seconds'}",
"unavailable": "@.capitalize:{'terms.glossary.unavailable'}",
"vehicle_step_missing_customer_number": "Enter your customer number to continue.",
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
"vehicle_step_missing_registration": "Enter your registration number to continue.",
"vehicle_step_missing_vehicle_type": "Choose an available vehicle type to continue.",
"wash_completed_contact": "@.capitalize:{'terms.glossary.we'} hope @:{'terms.glossary.you'} @:{'terms.glossary.are'} satisfied @:{'terms.glossary.with'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.result'}. @.capitalize:{'terms.glossary.if'} @:{'terms.glossary.you'} @:{'terms.glossary.need'} @:{'terms.glossary.further'} @:{'terms.glossary.assistance'}, @:{'terms.glossary.please'} @:{'terms.glossary.contact'} @:{'terms.glossary.our'} staff.",
"wash_completed_message": "@.capitalize:{'terms.glossary.your'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} @:{'terms.glossary.now'} @:{'terms.glossary.complete'}! @:{'terms.glossary.thank'} @:{'terms.glossary.you'} @:{'terms.glossary.for'} @:{'terms.glossary.using'} @:{'terms.glossary.our'} @:{'terms.glossary.self'}-@:{'terms.glossary.service'} @:{'terms.glossary.wash'}.",
"wash_completed": "@.capitalize:{'terms.glossary.wash'} @:{'terms.glossary.completed'}",
@@ -530,7 +530,8 @@
"release_runtime": "@:{'phrases.compat.configuration.release_manager.channel_selector.release_runtime'}",
"bundle": "@:{'phrases.compat.configuration.release_manager.channel_selector.bundle'}",
"frontend": "@:{'phrases.compat.configuration.release_manager.channel_selector.frontend'}",
"api": "@:{'phrases.compat.configuration.release_manager.channel_selector.api'}"
"api": "@:{'phrases.compat.configuration.release_manager.channel_selector.api'}",
"use_local_frontend": "@:{'phrases.compat.configuration.release_manager.channel_selector.use_local_frontend'}"
},
"common": {
"yes": "@:{'phrases.compat.configuration.release_manager.common.yes'}",
@@ -0,0 +1,33 @@
{
"error_report": {
"button": "@:{'phrases.compat.error_report.button'}",
"title": "@:{'phrases.compat.error_report.title'}",
"subtitle": "@:{'phrases.compat.error_report.subtitle'}",
"before_error": "@:{'phrases.compat.error_report.before_error'}",
"expected": "@:{'phrases.compat.error_report.expected'}",
"actual": "@:{'phrases.compat.error_report.actual'}",
"before_error_placeholder": "@:{'phrases.compat.error_report.before_error_placeholder'}",
"expected_placeholder": "@:{'phrases.compat.error_report.expected_placeholder'}",
"actual_placeholder": "@:{'phrases.compat.error_report.actual_placeholder'}",
"consent": "@:{'phrases.compat.error_report.consent'}",
"submit": "@:{'phrases.compat.error_report.submit'}",
"submitted": "@:{'phrases.compat.error_report.submitted'}",
"capture_failed": "@:{'phrases.compat.error_report.capture_failed'}",
"submit_failed": "@:{'phrases.compat.error_report.submit_failed'}",
"required": "@:{'phrases.compat.error_report.required'}",
"page_subtitle": "@:{'phrases.compat.error_report.page_subtitle'}",
"screenshot": "@:{'phrases.compat.error_report.screenshot'}",
"request_errors": "@:{'phrases.compat.error_report.request_errors'}",
"vue_errors": "@:{'phrases.compat.error_report.vue_errors'}",
"runtime_context": "@:{'phrases.compat.error_report.runtime_context'}",
"empty": "@:{'phrases.compat.error_report.empty'}",
"resolve": "@:{'phrases.compat.error_report.resolve'}",
"reopen": "@:{'phrases.compat.error_report.reopen'}",
"resolution_note": "@:{'phrases.compat.error_report.resolution_note'}",
"filters": {
"open": "@:{'phrases.compat.error_report.filters.open'}",
"resolved": "@:{'phrases.compat.error_report.filters.resolved'}",
"all": "@:{'phrases.compat.error_report.filters.all'}"
}
}
}
@@ -19,7 +19,9 @@
"loading_department": "@:{'phrases.compat.self_wash.loading_department'}",
"loading_types": "@:{'phrases.compat.self_wash.loading_types'}",
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
"machine": "@:{'phrases.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'phrases.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
@@ -36,6 +38,10 @@
"start_wash": "@:{'phrases.compat.self_wash.start_wash'}",
"total_time": "@:{'phrases.compat.self_wash.total_time'}",
"unavailable": "@:{'phrases.compat.self_wash.unavailable'}",
"vehicle_step_missing_customer_number": "@:{'phrases.compat.self_wash.vehicle_step_missing_customer_number'}",
"vehicle_step_missing_department": "@:{'phrases.compat.self_wash.vehicle_step_missing_department'}",
"vehicle_step_missing_registration": "@:{'phrases.compat.self_wash.vehicle_step_missing_registration'}",
"vehicle_step_missing_vehicle_type": "@:{'phrases.compat.self_wash.vehicle_step_missing_vehicle_type'}",
"vehicle_type": "@:{'phrases.compat.products.form.vehicle_type'}",
"vehicle": "@:common.vehicle",
"wash_completed_contact": "@:{'phrases.compat.self_wash.wash_completed_contact'}",
@@ -494,7 +494,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Bruk lokal frontend"
},
"common": {
"yes": "ja",
@@ -1,31 +1,34 @@
{
"compat": {
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
"screenshot": "Skjermbilde",
"request_errors": "Request-feil",
"vue_errors": "Vue-feil",
"runtime_context": "Runtime-kontekst",
"empty": "Ingen feilrapporter funnet.",
"resolve": "Marker som løst",
"reopen": "Åpne igjen",
"resolution_note": "Løsningsnotat",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
"open": "Åpen",
"resolved": "Løst",
"all": "Alle"
}
}
}
@@ -16,7 +16,9 @@
"loading_department": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.avdelingen'}...",
"loading_types": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.inn'} @:{'terms.glossary.typer'}...",
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.inn'} @:{'terms.glossary.tilgjengelige'} @:{'terms.glossary.kjøretøytyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "@.capitalize:{'terms.glossary.maskin'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Okkupert",
"open_property_access_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} inngangsport",
@@ -32,6 +34,10 @@
"start_wash": "@:{'phrases.compat.guest.start_wash'}",
"total_time": "@:{'terms.glossary.total'} @:{'terms.glossary.tid'}: {minutes} minutter @:{'terms.glossary.og'} {seconds} @:{'terms.glossary.sekunder'}",
"unavailable": "@.capitalize:{'terms.glossary.utilgjengelig'}",
"vehicle_step_missing_customer_number": "Skriv inn kundenummer for a fortsette.",
"vehicle_step_missing_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
"vehicle_step_missing_vehicle_type": "Velg en tilgjengelig kjoretoytype for a fortsette.",
"wash_completed_contact": "@.capitalize:{'terms.glossary.vi'} håper @:{'terms.glossary.du'} @:{'terms.glossary.er'} fornøyd @:{'terms.glossary.med'} resultatet. @.capitalize:{'terms.glossary.hvis'} @:{'terms.glossary.du'} @:{'terms.glossary.trenger'} @:{'terms.glossary.ytterligere'} @:{'terms.glossary.hjelp'}, @:{'terms.glossary.vennligst'} @:{'terms.glossary.kontakt'} @:{'terms.glossary.vare'} @:{'terms.glossary.ansatte'}.",
"wash_completed_message": "@.capitalize:{'terms.glossary.vasken'} @:{'terms.glossary.din'} @:{'terms.glossary.er'} @:{'terms.glossary.na'} @:{'terms.glossary.fullført'}! @:{'terms.glossary.takk'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} @:{'terms.glossary.du'} @:{'terms.glossary.bruker'} @:{'terms.glossary.var_2'} @:{'terms.glossary.selvbetjente'} @:{'terms.glossary.vask'}.",
"wash_completed": "@:{'terms.glossary.vasking'} @:{'terms.glossary.fullført'}",
@@ -494,7 +494,8 @@
"release_runtime": "Release-runtime",
"bundle": "Bundle",
"frontend": "Frontend",
"api": "API"
"api": "API",
"use_local_frontend": "Använd lokal frontend"
},
"common": {
"yes": "ja",
@@ -1,31 +1,34 @@
{
"compat": {
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
"screenshot": "Screenshot",
"request_errors": "Request errors",
"vue_errors": "Vue errors",
"runtime_context": "Runtime context",
"empty": "No error reports found.",
"resolve": "Mark resolved",
"reopen": "Reopen",
"resolution_note": "Resolution note",
"button": "Rapportera fel",
"title": "Rapportera fel",
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
"before_error": "Vad gjorde du innan felet uppstod?",
"expected": "Vad förväntade du dig skulle hända?",
"actual": "Vad hände faktiskt?",
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
"submit": "Skicka rapport",
"submitted": "Felrapport skickad.",
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
"submit_failed": "Felrapporten kunde inte skickas.",
"required": "Alla fält och godkännande av datainsamling krävs.",
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
"screenshot": "Skärmbild",
"request_errors": "Request-fel",
"vue_errors": "Vue-fel",
"runtime_context": "Runtime-kontext",
"empty": "Inga felrapporter hittades.",
"resolve": "Markera som löst",
"reopen": "Öppna igen",
"resolution_note": "Lösningsnotering",
"filters": {
"open": "Open",
"resolved": "Resolved",
"all": "All"
"open": "Öppen",
"resolved": "Löst",
"all": "Alla"
}
}
}
@@ -16,7 +16,9 @@
"loading_department": "@.capitalize:{'terms.glossary.laser'} @:{'terms.glossary.in'} @:{'terms.glossary.avdelning'}...",
"loading_types": "@:{'terms.glossary.laddar'} @:{'terms.glossary.typer'}...",
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laser'} @:{'terms.glossary.in'} @:{'terms.glossary.tillgangliga'} @:{'terms.glossary.fordonstyper'}...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"no_departments_found": "@.capitalize:{'terms.glossary.inga'} @:{'terms.glossary.avdelningar'} @:{'terms.glossary.hittades'}",
"occupied": "Upptagen",
"open_property_access_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} ingangsgrind",
@@ -32,6 +34,10 @@
"start_wash": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.wash'}",
"total_time": "@.capitalize:{'terms.glossary.total'} @:{'terms.glossary.tid'}: {minutes} minuter @:{'terms.glossary.och'} {seconds} @:{'terms.glossary.sekunder'}",
"unavailable": "@:{'phrases.compat.common.not_available'}",
"vehicle_step_missing_customer_number": "Ange kundnummer for att fortsatta.",
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
"vehicle_step_missing_vehicle_type": "Valj en tillganglig fordonstyp for att fortsatta.",
"wash_completed_contact": "@.capitalize:{'terms.glossary.vi'} hoppas @:{'terms.glossary.att'} @:{'terms.glossary.du'} @:{'terms.glossary.ar'} nöjd @:{'terms.glossary.med'} resultatet. @.capitalize:{'terms.glossary.om'} @:{'terms.glossary.du'} @:{'terms.glossary.behover'} @:{'terms.glossary.ytterligare'} @:{'terms.glossary.hjalp'}, @:{'terms.glossary.kontakta'} @:{'terms.glossary.var_2'} personal.",
"wash_completed_message": "@.capitalize:{'terms.glossary.din'} @:{'terms.glossary.tvatt'} @:{'terms.glossary.ar'} @:{'terms.glossary.nu'} @:{'terms.glossary.klar'}! @:{'terms.glossary.tack'} @:{'terms.glossary.for'} @:{'terms.glossary.att'} @:{'terms.glossary.du'} @:{'terms.glossary.anvander'} @:{'terms.glossary.var_2'} självbetjäningstvätt.",
"wash_completed": "@.capitalize:{'terms.glossary.tvatt'} @:{'terms.glossary.slutford'}",
-16
View File
@@ -27,7 +27,6 @@ import { RELEASE_RUNTIME_GLOBAL_KEY } from '@/services/releaseBootstrap.js';
import { IS_DEV } from './config';
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || '';
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || '';
const LAST_VERSION_CHECK_STORAGE_KEY = 'lastVersionCheck';
const bootstrapRuntime = typeof window !== 'undefined' ? window[RELEASE_RUNTIME_GLOBAL_KEY] : null;
if (bootstrapRuntime && typeof bootstrapRuntime === 'object') {
@@ -61,31 +60,16 @@ const formatDateTime = (value) => {
return `${day}-${month}-${year} ${hours}:${minutes}`;
};
const formatTimeAgo = (timestamp) => {
const elapsed = Date.now() - timestamp;
if (elapsed < 60_000) return 'just now';
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`;
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`;
return `${Math.floor(elapsed / 86_400_000)}d ago`;
};
const formatCommit = (hash) => {
if (!hash) return '#unknown';
return `#${hash.substring(0, 7)}`;
};
const logBuildBanner = () => {
const lastVersionCheckRaw = Number.parseInt(localStorage.getItem(LAST_VERSION_CHECK_STORAGE_KEY) || '', 10);
const hasLastVersionCheck = Number.isFinite(lastVersionCheckRaw) && lastVersionCheckRaw > 0;
const lastVersionCheckDisplay = hasLastVersionCheck
? `${formatTimeAgo(lastVersionCheckRaw)} @ ${formatDateTime(lastVersionCheckRaw)}`
: 'never @ Unknown';
console.log([
TRUCK_WASH_ASCII,
'',
`Build: ${formatCommit(VITE_COMMIT_HASH)} @ ${formatDateTime(VITE_BUILD_DATE)} (${IS_DEV ? 'development' : 'production'})`,
`Last version check: ${lastVersionCheckDisplay}`,
`Remote API: ${getReleaseRuntimeApiBaseUrl()}`,
`Debug: Is running beta FE.`,
].join('\n'));
+57
View File
@@ -0,0 +1,57 @@
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
export const resolvePingUrl = (endpoint = REQUEST_QUEUE_CONFIG.ping.endpoint) => {
const value = String(endpoint || "/ping");
return resolveReleaseApiUrl(value);
};
export const pingApiServer = async ({
endpoint = REQUEST_QUEUE_CONFIG.ping.endpoint,
timeoutMs = REQUEST_QUEUE_CONFIG.ping.timeoutMs,
fetchImpl = globalThis.fetch,
} = {}) => {
const url = resolvePingUrl(endpoint);
if (typeof fetchImpl !== "function") {
return {
ok: false,
status: null,
url,
error: new Error("Fetch API is unavailable"),
};
}
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeout =
controller && Number.isFinite(timeoutMs) && timeoutMs > 0
? setTimeout(() => controller.abort(), timeoutMs)
: null;
try {
const response = await fetchImpl(url, {
method: "GET",
cache: "no-store",
mode: "cors",
signal: controller?.signal,
});
return {
ok: Boolean(response?.ok),
status: response?.status ?? null,
url,
error: null,
};
} catch (error) {
return {
ok: false,
status: null,
url,
error,
};
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
};
+7
View File
@@ -0,0 +1,7 @@
import { ref } from "vue";
export const errorReportLaunchRequestId = ref(0);
export const requestOpenErrorReport = () => {
errorReportLaunchRequestId.value += 1;
};
+20 -1
View File
@@ -69,6 +69,25 @@ const readReleaseSourceOverride = () => {
return override === RELEASE_SOURCE_MODES.AUTO ? "" : override;
};
export const isReleaseSourceOverrideAvailable = ({
isDev = IS_DEV,
releaseSourceEnv = RELEASE_SOURCE_ENV,
} = {}) => Boolean(isDev) && !normalizeReleaseSourceValue(releaseSourceEnv);
export const setReleaseSourceOverride = (value) => {
const source = normalizeReleaseSourceValue(value);
if (!source) {
return "";
}
try {
browserStorage()?.setItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY, source);
} catch {
// Storage is optional; callers can still fall back to the current runtime.
}
return source;
};
export const resolveReleaseSourceMode = (
configuredSource = RELEASE_SOURCE,
{ allowStorageOverride = false } = {}
@@ -306,7 +325,7 @@ export const bootstrapReleaseApp = async ({
const hasExplicitSourceMode = sourceMode !== undefined && sourceMode !== null && String(sourceMode).trim() !== "";
const resolvedSourceMode = resolveReleaseSourceMode(sourceMode || RELEASE_SOURCE, {
allowStorageOverride: IS_DEV && !hasExplicitSourceMode && !normalizeReleaseSourceValue(RELEASE_SOURCE_ENV),
allowStorageOverride: isReleaseSourceOverrideAvailable() && !hasExplicitSourceMode,
});
if (resolvedSourceMode === RELEASE_SOURCE_MODES.LOCAL) {
setReleaseRuntimeGlobal(localReleaseRuntime({ requestedSource: resolvedSourceMode }));
+515
View File
@@ -0,0 +1,515 @@
import { reactive, readonly } from "vue";
import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js";
export const RELEASE_UPDATE_CACHE_NAME = "truckwash-release-update-v1";
export const RELEASE_MANIFEST_FILENAME = "release-manifest.json";
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
export const RELEASE_UPDATE_STATUSES = Object.freeze({
IDLE: "idle",
CHECKING: "checking",
UP_TO_DATE: "up_to_date",
DOWNLOADING: "downloading",
READY: "ready",
FAILED: "failed",
});
const AUTO_CHECK_INTERVAL_MS = 5 * 60 * 1000;
const MIN_COMMIT_COMPARE_LENGTH = 7;
const textValue = (value) => String(value ?? "").trim();
const normalizeBaseUrl = (value) => textValue(value).replace(/\/+$/, "");
const browserOrigin = () =>
typeof window !== "undefined" && window.location?.origin ? window.location.origin : "http://localhost";
export const normalizeReleaseCommit = (value) => {
const commit = textValue(value).toLowerCase();
if (!commit || commit === "unknown" || commit === "no commit hash") {
return "";
}
return commit;
};
export const shortReleaseCommit = (value, length = 8) => {
const commit = normalizeReleaseCommit(value);
return commit ? commit.slice(0, length) : "unknown";
};
export const isSameReleaseCommit = (left, right) => {
const leftCommit = normalizeReleaseCommit(left);
const rightCommit = normalizeReleaseCommit(right);
if (!leftCommit || !rightCommit) {
return false;
}
if (leftCommit === rightCommit) {
return true;
}
const shortest = Math.min(leftCommit.length, rightCommit.length);
return (
shortest >= MIN_COMMIT_COMPARE_LENGTH &&
(leftCommit.startsWith(rightCommit) || rightCommit.startsWith(leftCommit))
);
};
const initialFrontendCommit = () =>
normalizeReleaseCommit(import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "");
const initialState = () => ({
status: RELEASE_UPDATE_STATUSES.IDLE,
currentCommit: initialFrontendCommit(),
latestCommit: "",
channelSlug: "",
channelName: "",
frontendBaseUrl: "",
buildId: "",
progress: 0,
downloadedAssets: 0,
totalAssets: 0,
downloadedBytes: 0,
totalBytes: 0,
error: "",
checkedAt: null,
candidate: null,
readyCandidate: null,
candidateKey: "",
});
const state = reactive(initialState());
let downloadRunId = 0;
let autoCheckConsumers = 0;
let autoCheckTimer = null;
let autoCheckRun = null;
let autoCheckVisibilityHandler = null;
let autoCheckFocusHandler = null;
export const releaseUpdateState = readonly(state);
const resetTransientProgress = () => {
state.progress = 0;
state.downloadedAssets = 0;
state.totalAssets = 0;
state.downloadedBytes = 0;
state.totalBytes = 0;
state.error = "";
};
const assignCandidateState = (candidate) => {
state.latestCommit = candidate?.latestCommit || "";
state.channelSlug = candidate?.channelSlug || "";
state.channelName = candidate?.channelName || "";
state.frontendBaseUrl = candidate?.frontendBaseUrl || "";
state.buildId = candidate?.buildId || "";
state.candidate = candidate || null;
state.candidateKey = candidate ? releaseUpdateCandidateKey(candidate) : "";
};
const versionCommit = (version = null) => {
if (!version || typeof version !== "object") {
return "";
}
if (typeof version.commit === "string") {
return version.commit;
}
const commit = version.commit && typeof version.commit === "object" ? version.commit : {};
const githubAccess =
version.metadata?.github_access && typeof version.metadata.github_access === "object"
? version.metadata.github_access
: {};
return (
textValue(version.commit_sha) ||
textValue(commit.sha) ||
textValue(commit.commit_sha) ||
textValue(githubAccess.commit?.sha) ||
textValue(githubAccess.latest_commit?.sha)
);
};
const versionBuildId = (version = null, runtime = {}) =>
textValue(version?.build_id) || textValue(version?.buildId) || textValue(runtime?.versions?.bundle_id);
const normalizeChannelSlug = (channel = {}) =>
textValue(channel?.slug)
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 64);
const normalizeRuntimeFrontendBaseUrl = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
const raw = normalizeBaseUrl(runtime?.frontendBaseUrl || runtime?.frontend_base_url || urls.frontend_base_url);
if (!raw) {
return "";
}
if (/^https?:\/\//i.test(raw)) {
return raw;
}
return new URL(`${raw.replace(/^\/+/, "")}/`, `${browserOrigin()}/`).href.replace(/\/+$/, "");
};
const releaseUpdateCandidateKey = (candidate = {}) =>
[candidate.channelSlug || "", candidate.frontendBaseUrl || "", candidate.latestCommit || ""].join("|");
export const releaseUpdateCandidateFromRuntime = (runtime = {}) => {
const channel = runtime?.channel && typeof runtime.channel === "object" ? runtime.channel : {};
const frontendVersion = runtime?.versions?.frontend || null;
const latestCommit = normalizeReleaseCommit(versionCommit(frontendVersion));
const frontendBaseUrl = normalizeRuntimeFrontendBaseUrl(runtime);
const availability = runtime?.availability && typeof runtime.availability === "object" ? runtime.availability : {};
if (availability.configured === false || !latestCommit || !frontendBaseUrl) {
return null;
}
return {
channelSlug: normalizeChannelSlug(channel),
channelName: textValue(channel.name) || textValue(channel.slug) || "Release",
latestCommit,
frontendBaseUrl,
buildId: versionBuildId(frontendVersion, runtime),
};
};
const ensureCurrentCommit = (runtime = null) => {
if (normalizeReleaseCommit(state.currentCommit)) {
return state.currentCommit;
}
const runtimeCommit = normalizeReleaseCommit(versionCommit(runtime?.versions?.frontend || null));
state.currentCommit = runtimeCommit || initialFrontendCommit();
return state.currentCommit;
};
const releaseAssetUrl = (frontendBaseUrl, value) => {
const asset = textValue(value);
if (!asset) {
return null;
}
const url = /^https?:\/\//i.test(asset)
? new URL(asset).href
: new URL(asset.replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
return {
raw: asset,
key: asset.replace(/^\/+/, ""),
url,
};
};
const uniqueReleaseAssets = (frontendBaseUrl, manifest = {}) => {
const values = [
RELEASE_MANIFEST_FILENAME,
RELEASE_ENTRY_FILENAME,
manifest.entry,
...(Array.isArray(manifest.css) ? manifest.css : []),
...(Array.isArray(manifest.index_asset_urls) ? manifest.index_asset_urls : []),
...(Array.isArray(manifest.pwa_asset_urls) ? manifest.pwa_asset_urls : []),
...(Array.isArray(manifest.asset_urls) ? manifest.asset_urls : []),
];
const byUrl = new Map();
values.forEach((value) => {
const asset = releaseAssetUrl(frontendBaseUrl, value);
if (asset?.url && !byUrl.has(asset.url) && asset.raw !== "/index.html") {
byUrl.set(asset.url, asset);
}
});
return [...byUrl.values()];
};
const manifestAssetMeta = (manifest = {}, asset = {}) => {
const hashes = manifest.asset_hashes && typeof manifest.asset_hashes === "object" ? manifest.asset_hashes : {};
return hashes[asset.raw] || hashes[`/${asset.key}`] || hashes[asset.key] || null;
};
const fetchJson = async (fetchFn, url) => {
const response = await fetchFn(url, {
method: "GET",
cache: "no-store",
mode: "cors",
credentials: "omit",
});
if (!response?.ok) {
throw new Error(`Release manifest request failed with HTTP ${response?.status || 0}.`);
}
return response.json();
};
const consumeResponseBody = async (response) => {
if (typeof response?.arrayBuffer === "function") {
await response.arrayBuffer();
return;
}
if (typeof response?.blob === "function") {
await response.blob();
return;
}
if (typeof response?.text === "function") {
await response.text();
}
};
const releaseCache = async () => {
if (typeof caches === "undefined" || typeof caches.open !== "function") {
return null;
}
try {
return await caches.open(RELEASE_UPDATE_CACHE_NAME);
} catch {
return null;
}
};
const cacheReleaseResponse = async (cacheRef, url, response) => {
if (!cacheRef || typeof cacheRef.put !== "function" || typeof response?.clone !== "function") {
return;
}
try {
await cacheRef.put(url, response.clone());
} catch {
// Cache API is opportunistic. Network fetch success is enough to mark the update ready.
}
};
const fetchReleaseAsset = async (fetchFn, cacheRef, asset) => {
const response = await fetchFn(asset.url, {
method: "GET",
cache: "reload",
mode: "cors",
credentials: "omit",
});
if (!response?.ok) {
throw new Error(`Release asset request failed with HTTP ${response?.status || 0}.`);
}
await cacheReleaseResponse(cacheRef, asset.url, response);
await consumeResponseBody(response);
};
const markUpToDate = (candidate = null) => {
state.status = RELEASE_UPDATE_STATUSES.UP_TO_DATE;
resetTransientProgress();
assignCandidateState(candidate);
state.latestCommit = candidate?.latestCommit || normalizeReleaseCommit(state.currentCommit);
state.readyCandidate = null;
state.checkedAt = Date.now();
return releaseUpdateState;
};
export const downloadReleaseCandidate = async (candidate, { fetchFn = globalThis.fetch } = {}) => {
if (!candidate || typeof fetchFn !== "function") {
return releaseUpdateState;
}
const candidateKey = releaseUpdateCandidateKey(candidate);
if (state.status === RELEASE_UPDATE_STATUSES.DOWNLOADING && state.candidateKey === candidateKey) {
return releaseUpdateState;
}
if (state.status === RELEASE_UPDATE_STATUSES.READY && state.readyCandidate?.key === candidateKey) {
return releaseUpdateState;
}
const runId = ++downloadRunId;
state.status = RELEASE_UPDATE_STATUSES.DOWNLOADING;
resetTransientProgress();
assignCandidateState(candidate);
try {
const manifestUrl = new URL(RELEASE_MANIFEST_FILENAME, `${normalizeBaseUrl(candidate.frontendBaseUrl)}/`).href;
const manifest = await fetchJson(fetchFn, manifestUrl);
const assets = uniqueReleaseAssets(candidate.frontendBaseUrl, manifest);
const cacheRef = await releaseCache();
const byteTotal = assets.reduce((total, asset) => {
const bytes = Number(manifestAssetMeta(manifest, asset)?.bytes || 0);
return total + (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
}, 0);
if (runId !== downloadRunId) {
return releaseUpdateState;
}
state.totalAssets = assets.length;
state.totalBytes = byteTotal;
for (const asset of assets) {
await fetchReleaseAsset(fetchFn, cacheRef, asset);
if (runId !== downloadRunId) {
return releaseUpdateState;
}
const bytes = Number(manifestAssetMeta(manifest, asset)?.bytes || 0);
state.downloadedAssets += 1;
state.downloadedBytes += Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
state.progress = state.totalAssets > 0 ? Math.round((state.downloadedAssets / state.totalAssets) * 100) : 100;
}
state.status = RELEASE_UPDATE_STATUSES.READY;
state.progress = 100;
state.error = "";
state.buildId = textValue(manifest.build_id) || candidate.buildId || "";
state.readyCandidate = {
...candidate,
key: candidateKey,
buildId: state.buildId,
manifestCommit: normalizeReleaseCommit(manifest.commit_sha),
assetCount: assets.length,
};
state.checkedAt = Date.now();
} catch (error) {
if (runId === downloadRunId) {
state.status = RELEASE_UPDATE_STATUSES.FAILED;
state.error = error?.message || "Release update download failed.";
state.checkedAt = Date.now();
}
}
return releaseUpdateState;
};
export const inspectReleaseRuntimeForUpdate = async (
runtime = {},
{ fetchFn = globalThis.fetch, autoDownload = true } = {}
) => {
ensureCurrentCommit(runtime);
const candidate = releaseUpdateCandidateFromRuntime(runtime);
if (!candidate) {
return markUpToDate(null);
}
assignCandidateState(candidate);
state.checkedAt = Date.now();
if (!normalizeReleaseCommit(state.currentCommit) || isSameReleaseCommit(state.currentCommit, candidate.latestCommit)) {
return markUpToDate(candidate);
}
if (state.status === RELEASE_UPDATE_STATUSES.READY && state.readyCandidate?.key === releaseUpdateCandidateKey(candidate)) {
return releaseUpdateState;
}
if (!autoDownload) {
state.status = RELEASE_UPDATE_STATUSES.IDLE;
return releaseUpdateState;
}
return downloadReleaseCandidate(candidate, { fetchFn });
};
export const checkReleaseUpdateNow = async ({
runtime = null,
fetchFn = globalThis.fetch,
autoDownload = true,
} = {}) => {
if (state.status === RELEASE_UPDATE_STATUSES.DOWNLOADING) {
return releaseUpdateState;
}
state.status = RELEASE_UPDATE_STATUSES.CHECKING;
state.error = "";
try {
const nextRuntime = runtime || (await fetchReleaseRuntime({ fetchFn }));
return inspectReleaseRuntimeForUpdate(nextRuntime || {}, { fetchFn, autoDownload });
} catch (error) {
state.status = RELEASE_UPDATE_STATUSES.FAILED;
state.error = error?.message || "Release update check failed.";
state.checkedAt = Date.now();
return releaseUpdateState;
}
};
export const installReadyReleaseUpdate = async ({
reload = () => window.location.reload(),
serviceWorker = typeof navigator !== "undefined" ? navigator.serviceWorker : null,
} = {}) => {
if (state.status !== RELEASE_UPDATE_STATUSES.READY || !state.readyCandidate) {
return false;
}
try {
const registration =
serviceWorker && typeof serviceWorker.getRegistration === "function"
? await serviceWorker.getRegistration()
: null;
if (registration?.waiting && typeof registration.waiting.postMessage === "function") {
registration.waiting.postMessage({ type: "SKIP_WAITING" });
}
} catch {
// Reload still lets the normal bootstrap select the latest runtime.
}
if (typeof reload === "function") {
reload();
}
return true;
};
const clearAutoCheck = () => {
if (typeof window !== "undefined" && autoCheckTimer !== null) {
window.clearInterval(autoCheckTimer);
}
autoCheckTimer = null;
if (typeof window !== "undefined" && autoCheckFocusHandler) {
window.removeEventListener("focus", autoCheckFocusHandler);
}
if (typeof document !== "undefined" && autoCheckVisibilityHandler) {
document.removeEventListener("visibilitychange", autoCheckVisibilityHandler);
}
autoCheckFocusHandler = null;
autoCheckVisibilityHandler = null;
autoCheckRun = null;
};
export const startReleaseUpdateAutoCheck = ({
intervalMs = AUTO_CHECK_INTERVAL_MS,
fetchFn = globalThis.fetch,
immediate = true,
} = {}) => {
autoCheckConsumers += 1;
if (autoCheckConsumers > 1) {
return () => {
autoCheckConsumers = Math.max(0, autoCheckConsumers - 1);
if (autoCheckConsumers === 0) {
clearAutoCheck();
}
};
}
autoCheckRun = () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
return;
}
void checkReleaseUpdateNow({ fetchFn }).catch(() => {});
};
if (immediate) {
autoCheckRun();
}
if (typeof window !== "undefined") {
autoCheckTimer = window.setInterval(autoCheckRun, intervalMs);
autoCheckFocusHandler = () => autoCheckRun?.();
window.addEventListener("focus", autoCheckFocusHandler);
}
if (typeof document !== "undefined") {
autoCheckVisibilityHandler = () => {
if (document.visibilityState === "visible") {
autoCheckRun?.();
}
};
document.addEventListener("visibilitychange", autoCheckVisibilityHandler);
}
return () => {
autoCheckConsumers = Math.max(0, autoCheckConsumers - 1);
if (autoCheckConsumers === 0) {
clearAutoCheck();
}
};
};
export const __resetReleaseUpdateForTests = ({ currentCommit = "" } = {}) => {
clearAutoCheck();
autoCheckConsumers = 0;
downloadRunId += 1;
Object.assign(state, initialState(), {
currentCommit: normalizeReleaseCommit(currentCommit) || initialFrontendCommit(),
});
};
+81 -10
View File
@@ -10,22 +10,95 @@ const normalizeTaskServices = (task) => (
: []
);
export const selfServeTaskUsesProgramPicker = (task) => (
normalizeTaskServices(task).includes("PROGRAM_PICKER")
export const isSelfServeProgramNumberButton = (button) => (
Number.isInteger(button) && button >= 0 && button <= 11
);
export const getSelfServeTaskDynamicImageButtons = (task) => {
const buttons = normalizeSelfServeTaskButtons(
const getRawSelfServeTaskButtons = (task) => (
task?.buttons ?? task?.button_ids ?? task?.machine_buttons ?? task?.dynamic_image_buttons
);
if (!selfServeTaskUsesProgramPicker(task)) {
return buttons;
export const selfServeTaskUsesProgramPicker = (task) => (
normalizeTaskServices(task).includes("PROGRAM_PICKER")
|| normalizeSelfServeTaskButtons(getRawSelfServeTaskButtons(task)).includes(SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER)
);
export const normalizeSelfServeProgramPickerTaskButtons = (
buttons,
services = [],
preferredProgramNumber = null
) => {
const normalizedButtons = normalizeSelfServeTaskButtons(buttons);
const usesProgramPicker = selfServeTaskUsesProgramPicker({
services,
buttons: normalizedButtons,
});
if (!usesProgramPicker) {
return normalizedButtons;
}
return normalizeSelfServeTaskButtons([SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER, ...buttons]);
const preferredButton = isSelfServeProgramNumberButton(preferredProgramNumber)
&& normalizedButtons.includes(preferredProgramNumber)
? preferredProgramNumber
: null;
const selectedProgramNumber = preferredButton ?? normalizedButtons.find(isSelfServeProgramNumberButton);
let hasSelectedProgramNumber = false;
return normalizedButtons.filter((button) => {
if (!isSelfServeProgramNumberButton(button)) {
return true;
}
if (button !== selectedProgramNumber || hasSelectedProgramNumber) {
return false;
}
hasSelectedProgramNumber = true;
return true;
});
};
const getTaskFallbackThumbPosition = (task) => parseSelfServeDynamicImageThumbPosition(
task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType
);
export const getSelfServeTaskDynamicImagePresentation = (task) => {
const normalizedButtons = normalizeSelfServeProgramPickerTaskButtons(
getRawSelfServeTaskButtons(task),
task?.services
);
if (!selfServeTaskUsesProgramPicker({ ...(task || {}), buttons: normalizedButtons })) {
return {
buttons: normalizedButtons,
normalizedButtons,
thumbPosition: getTaskFallbackThumbPosition(task),
usesProgramPicker: false,
};
}
const selectedProgramNumber = normalizedButtons.find(isSelfServeProgramNumberButton);
const highlightedButtons = normalizeSelfServeTaskButtons([
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
...normalizedButtons.filter((button) => (
button !== SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER
&& !isSelfServeProgramNumberButton(button)
)),
]);
return {
buttons: highlightedButtons,
normalizedButtons,
thumbPosition: selectedProgramNumber === undefined
? getTaskFallbackThumbPosition(task)
: selectedProgramNumber + 1,
usesProgramPicker: true,
};
};
export const getSelfServeTaskDynamicImageButtons = (task) => (
getSelfServeTaskDynamicImagePresentation(task).buttons
);
export const getSelfServeDynamicImageButtonsToPress = (tasks = []) => (
Array.isArray(tasks) ? tasks : []
).flatMap((task) => getSelfServeTaskDynamicImageButtons(task));
@@ -55,9 +128,7 @@ export const parseSelfServeDynamicImageThumbPosition = (value) => {
export const getSelfServeDynamicImageThumbPosition = (tasks = []) => {
for (const task of Array.isArray(tasks) ? tasks : []) {
const thumbPosition = parseSelfServeDynamicImageThumbPosition(
task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType
);
const thumbPosition = getSelfServeTaskDynamicImagePresentation(task).thumbPosition;
if (thumbPosition !== null) {
return thumbPosition;
}
@@ -35,12 +35,42 @@ export const applyPosRouteSearch = (
loadOrderItems,
setStep,
searchAndSelectCustomer,
clearActivePosOrderContext,
resetMobilePos,
}
) => {
const urlParams = new URLSearchParams(search || '');
const rawStep = parsePositiveInteger(urlParams.get('step'));
const parsedState = parsePosRouteSearch(search);
const effectiveStep = parsedState.step !== null && parsedState.step > 1 && parsedState.orderId === null
? 1
: parsedState.step;
const hasInvalidStep = urlParams.has('step') && normalizeStep(rawStep) === null;
const isExplicitFreshStep = parsedState.step === 1;
const isLaterStepWithoutOrder = parsedState.orderId === null && parsedState.step !== null && parsedState.step > 1;
const hasRouteContext = parsedState.orderId !== null || parsedState.customerId !== null;
const isFreshRoute = isExplicitFreshStep || parsedState.orderId === null;
if (isFreshRoute) {
const hadActiveContext =
typeof clearActivePosOrderContext === 'function'
? clearActivePosOrderContext({ clearStep: true })
: false;
if (
typeof resetMobilePos === 'function' &&
(hadActiveContext ||
parsedState.customerId !== null ||
isLaterStepWithoutOrder ||
hasInvalidStep ||
(isExplicitFreshStep && hasRouteContext))
) {
resetMobilePos();
}
if (typeof setStep === 'function') {
setStep(1);
}
return parsedState;
}
if (parsedState.orderId !== null) {
setOrderId(parsedState.orderId);
@@ -49,8 +79,8 @@ export const applyPosRouteSearch = (
}
}
if (effectiveStep !== null) {
setStep(effectiveStep);
if (parsedState.step !== null) {
setStep(parsedState.step);
}
if (parsedState.customerId !== null) {
@@ -20,6 +20,8 @@ import {
getSelfServeDynamicImageButtonsToPress,
getSelfServeTaskDynamicImageButtons,
getSelfServeDynamicImageThumbPosition,
isSelfServeProgramNumberButton,
normalizeSelfServeProgramPickerTaskButtons,
} from "@/services/selfServeDynamicImage.js";
import {
SELF_SERVE_TASK_BUTTON_OPTIONS,
@@ -1715,6 +1717,54 @@ const normalizeServiceList = (value) => {
const normalizeButtonList = (value) => normalizeSelfServeTaskButtons(value);
const normalizeTaskButtonList = (dataOrButtons, services = [], preferredProgramNumber = null) => {
const raw = dataOrButtons && typeof dataOrButtons === "object" && !Array.isArray(dataOrButtons)
? (dataOrButtons.raw ? rawFor(dataOrButtons) : dataOrButtons)
: { buttons: dataOrButtons, services };
return normalizeSelfServeProgramPickerTaskButtons(
raw?.buttons,
raw?.services ?? services,
preferredProgramNumber
);
};
const buttonListsEqual = (left, right) => (
JSON.stringify(normalizeButtonList(left)) === JSON.stringify(normalizeButtonList(right))
);
const normalizeInspectorTaskButtonSelection = (preferredProgramNumber = null) => {
if (selectedNodeKind.value !== "task") {
return;
}
const normalizedButtons = normalizeTaskButtonList(inspectorForm.value, [], preferredProgramNumber);
if (!buttonListsEqual(inspectorForm.value.buttons, normalizedButtons)) {
inspectorForm.value.buttons = normalizedButtons;
}
};
const onTaskButtonSelectionChange = (buttonId, checked) => {
nextTick(() => {
normalizeInspectorTaskButtonSelection(
checked && isSelfServeProgramNumberButton(buttonId) ? buttonId : null
);
});
};
const onTaskServicesChange = () => {
nextTick(() => {
normalizeInspectorTaskButtonSelection();
});
};
const onTaskCustomServicesInput = (value) => {
inspectorForm.value.services = normalizeServiceList(value);
nextTick(() => {
normalizeInspectorTaskButtonSelection();
});
};
const taskServiceList = (dataOrRaw) => {
const raw = dataOrRaw?.raw ? rawFor(dataOrRaw) : (dataOrRaw || {});
return normalizeServiceList(raw.services);
@@ -1765,7 +1815,7 @@ const inspectorTaskDynamicImageTask = computed(() => {
...selectedRaw.value,
...inspectorForm.value,
services: normalizeServiceList(inspectorForm.value?.services),
buttons: normalizeButtonList(inspectorForm.value?.buttons),
buttons: normalizeTaskButtonList(inspectorForm.value),
};
});
@@ -2296,8 +2346,8 @@ const taskButtonSummary = (buttons, emptyLabel = "No dynamic image buttons") =>
const taskButtonCount = (buttons) => normalizeButtonList(buttons).length;
const taskButtonIsSelected = (buttons, buttonId) => (
normalizeButtonList(buttons).includes(buttonId)
const taskButtonIsSelected = (dataOrButtons, buttonId) => (
normalizeTaskButtonList(dataOrButtons).includes(buttonId)
);
const simulatorPreviewTasks = computed(() => {
@@ -3199,7 +3249,7 @@ const refreshInspectorForm = () => {
if (selectedNode.value?.data?.kind === "task") {
inspectorForm.value.gate_type ||= "ALWAYS";
inspectorForm.value.services = normalizeServiceList(inspectorForm.value.services);
inspectorForm.value.buttons = normalizeButtonList(inspectorForm.value.buttons);
inspectorForm.value.buttons = normalizeTaskButtonList(inspectorForm.value);
inspectorTaskDynamicImageHidden.value = false;
inspectorTaskDynamicImageError.value = "";
}
@@ -3238,7 +3288,7 @@ const saveInspector = async () => {
}
if (entity === "task") {
data.services = normalizeServiceList(data.services);
data.buttons = normalizeButtonList(data.buttons);
data.buttons = normalizeTaskButtonList(data);
delete data.attachments;
}
if (entity === "action") {
@@ -5637,7 +5687,12 @@ onBeforeUnmount(() => {
class="studio-service-option"
:class="{ 'is-active': taskServiceList(inspectorForm).includes(option.service), 'is-unbound': option.bindings.length === 0 }"
>
<input v-model="inspectorForm.services" type="checkbox" :value="option.service">
<input
v-model="inspectorForm.services"
type="checkbox"
:value="option.service"
@change="onTaskServicesChange"
>
<span>
<strong>{{ option.service }}</strong>
<small>{{ serviceOptionSummary(option) }}</small>
@@ -5651,7 +5706,7 @@ onBeforeUnmount(() => {
class="input"
:value="taskServiceList(inspectorForm).join(', ')"
placeholder="MACHINE, SOAP, DRYER"
@input="inspectorForm.services = normalizeServiceList($event.target.value)"
@input="onTaskCustomServicesInput($event.target.value)"
>
</label>
<div class="studio-field-group">
@@ -5661,9 +5716,14 @@ onBeforeUnmount(() => {
v-for="option in taskButtonOptions"
:key="String(option.id)"
class="studio-button-option"
:class="{ 'is-active': taskButtonIsSelected(inspectorForm.buttons, option.id) }"
:class="{ 'is-active': taskButtonIsSelected(inspectorForm, option.id) }"
>
<input
v-model="inspectorForm.buttons"
type="checkbox"
:value="option.id"
@change="onTaskButtonSelectionChange(option.id, $event.target.checked)"
>
<input v-model="inspectorForm.buttons" type="checkbox" :value="option.id">
<span>
<strong>{{ option.name }}</strong>
<small>{{ option.description }}</small>
@@ -51,6 +51,7 @@ const vehicleStepError = ref<string | null>(null);
const washActionError = ref<string | null>(null);
const isSelfServeRetrying = ref(false);
const isCompletingWash = ref(false);
const hasStartFormUserInput = ref(false);
const MIN_FINISHING_SCREEN_MS = 250;
const normalizePositiveInteger = (value: any) => {
@@ -417,12 +418,52 @@ const allVisibleQuestionsAnswered = computed(() => (
|| visibleQuestions.value.every((question) => answers.value[question.id] === true || answers.value[question.id] === 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 showCustomerNumberInput = computed(() => (
!getAuthenticatedCustomerNumber() && !customerNumberInput.value
));
const shouldShowLoadingDataMessage = computed(() => !nearestDepartment.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 getRuleTypeLabel = (type: string) => {
const labels: Record<string, string> = {
IS_TRUE: "Er sand",
@@ -703,6 +744,10 @@ const applyServerActiveWash = async (activeWash: any) => {
};
const restoreServerActiveWash = async () => {
if (hasStartFormUserInput.value && !washInProgress.value) {
return;
}
try {
const activeWash = await fetchServerActiveWash();
if (activeWash) {
@@ -741,12 +786,14 @@ const onSelectVehicleType = (selection: VehicleTypeTemplate) => {
return;
}
markStartFormUserInput();
vehicleStepError.value = null;
washActionError.value = null;
vehicleTypeSelect.value = selection.id;
};
const onUpdateCustomerNumber = (value: string) => {
markStartFormUserInput();
vehicleStepError.value = null;
washActionError.value = null;
const authenticatedCustomerNumber = getAuthenticatedCustomerNumber();
@@ -754,11 +801,24 @@ const onUpdateCustomerNumber = (value: string) => {
};
const onUpdateRegistrationNumber = (value: string) => {
markStartFormUserInput();
vehicleStepError.value = null;
washActionError.value = null;
licensePlateInput.value = normalizeLicensePlate(value);
};
const onUpdateSelectedLaneId = (laneId: number) => {
markStartFormUserInput();
washActionError.value = null;
radioLaneOption.value = laneId;
};
const onUpdateWashType = (washType: string) => {
markStartFormUserInput();
washActionError.value = null;
radioWashType.value = washType;
};
const onVehicleStepNext = async () => {
vehicleStepError.value = null;
licensePlateInput.value = normalizeLicensePlate(licensePlateInput.value);
@@ -770,7 +830,8 @@ const onVehicleStepNext = async () => {
}
await fetchSelfServeData();
currentStep.value = steps.QUESTIONS;
await nextTick();
currentStep.value = visibleQuestions.value.length > 0 ? steps.QUESTIONS : steps.SELECT_LANE;
};
const onToggleTask = (taskId: number, value: boolean) => {
@@ -818,6 +879,7 @@ const onCloseCompleted = () => {
editAnswers.value = false;
vehicleStepError.value = null;
washActionError.value = null;
hasStartFormUserInput.value = false;
currentStep.value = steps.VEHICLE;
clearProgress();
saveProgress("onCloseCompleted");
@@ -1082,6 +1144,7 @@ watch(() => forceNearestDepartmentEvaluationId.value, () => {
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
@update:customer-number="onUpdateCustomerNumber"
@update:registration-number="onUpdateRegistrationNumber"
@select-vehicle-type="onSelectVehicleType"
@@ -1130,8 +1193,8 @@ watch(() => forceNearestDepartmentEvaluationId.value, () => {
:department-name="nearestDepartment?.name || null"
:is-lane-available="isLaneAvailable"
:is-machine-available="isMachineAvailable"
@update:selected-lane-id="washActionError = null; radioLaneOption = $event"
@update:wash-type="washActionError = null; radioWashType = $event"
@update:selected-lane-id="onUpdateSelectedLaneId"
@update:wash-type="onUpdateWashType"
/>
</b-step-item>
@@ -1238,7 +1301,7 @@ watch(() => forceNearestDepartmentEvaluationId.value, () => {
:disabled="isStartingWash || isNextButtonDisabled()"
@click="handleConfirmNext"
>
{{ $t("common.confirm") }}
{{ $t(confirmActionLabelKey) }}
</b-button>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
const PING_URL =
/(?:https:\/\/api\.truckwash\.io:4433|https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/api)\/ping(?:\?.*)?$/;
/(?:https:\/\/api\.truckwash\.io:4433|https:\/\/api-v2\.truckwash\.io\/master\/api|https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/api)\/ping(?:\?.*)?$/;
const CONNECTIVITY_PAGE_TIMEOUT_MS = 30_000;
function installPingFailure(page, onPing) {
+61 -7
View File
@@ -41,10 +41,52 @@ async function mockHtml2Canvas(page) {
});
}
async function openErrorReportLauncher(page) {
const reportButton = page.getByTestId("error-report-button");
const viewportWidth = page.viewportSize()?.width ?? 0;
if (viewportWidth <= 768) {
const burgerButton = page.locator(".mobile-navbar-burger[aria-expanded='false']").first();
await expect(burgerButton).toBeVisible();
await expect(reportButton).toHaveCount(0);
await burgerButton.click();
await expect(page.locator(".mobile-menu-open")).toBeVisible();
await expect(reportButton).toHaveCount(1);
await expect(reportButton).toBeVisible();
await reportButton.click();
await expect(page.locator(".mobile-menu-open")).toHaveCount(0);
return;
}
if (viewportWidth >= 1024) {
const desktopNavigation = page.getByTestId("desktop-buefy-navigation").first();
const desktopReportButton = desktopNavigation.getByTestId("error-report-button");
await expect(desktopNavigation).toBeVisible();
await expect(reportButton).toHaveCount(1);
await expect(desktopReportButton).toBeVisible();
await expect(desktopReportButton).toHaveClass(/desktop-buefy-error-report-button/);
await expect(desktopReportButton).toHaveClass(/is-text/);
await expect(desktopReportButton).not.toHaveClass(/is-danger/);
await desktopReportButton.hover();
await expect(desktopReportButton).toHaveCSS("color", "rgb(180, 35, 24)");
await desktopReportButton.click();
return;
}
await expect(reportButton).toHaveCount(1);
await expect(reportButton).toBeVisible();
await reportButton.click();
}
test.describe("Authenticated error reports", () => {
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await mockHtml2Canvas(page);
await mockApi(page, {
authenticated: true,
@@ -83,16 +125,28 @@ test.describe("Authenticated error reports", () => {
);
});
await primeMockSession(page, { token: "error-report-user-token", bootPath: "/user" });
await expect(page.getByTestId("error-report-button")).toBeVisible();
await page.getByTestId("error-report-button").click();
await primeMockSession(page, { token: "error-report-user-token", bootPath: "/admin" });
await openErrorReportLauncher(page);
await expect(page.getByTestId("error-report-modal")).toBeVisible();
await expect(page.getByTestId("error-report-submit")).toBeDisabled();
await page.getByLabel("What were you doing before the error occurred?").fill("Opening the orders page");
await page.getByLabel("What did you expect would happen?").fill("The orders should load");
await page.getByLabel("What actually happened?").fill("The page showed an error");
const beforeErrorField = page.getByLabel("What were you doing before the error occurred?");
const expectedField = page.getByLabel("What did you expect would happen?");
const actualField = page.getByLabel("What actually happened?");
await expect(beforeErrorField).toHaveAttribute(
"placeholder",
"Describe the action you were taking, for example opening orders or selecting a customer."
);
await expect(expectedField).toHaveAttribute("placeholder", "Describe the result you expected to see.");
await expect(actualField).toHaveAttribute(
"placeholder",
"Describe what you saw instead, including any error text."
);
await beforeErrorField.fill("Opening the orders page");
await expectedField.fill("The orders should load");
await actualField.fill("The page showed an error");
await expect(page.getByTestId("error-report-submit")).toBeDisabled();
await page.getByTestId("error-report-consent").check();
+72 -7
View File
@@ -2153,8 +2153,10 @@ test.describe("POS mobile order flow", () => {
);
await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-next-step")).toContainText("Pleno Logistics", { timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0);
const posSnapshot = await getStoredPosSnapshot(page);
expect(posSnapshot?.metadata?.customerId ?? null).toBeNull();
expect(posSnapshot?.vehicles?.vehicle_1 ?? null).toBeNull();
});
test("step 1 attachments upload after order creation completes", async ({ page }) => {
@@ -2176,7 +2178,7 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page);
});
test("stored pos_order_id restores step 2 without creating a duplicate order", async ({ page }) => {
test("fresh step 1 clears stored pos_order_id without deleting a historical order", async ({ page }) => {
const orderId = 9305;
const fixture = createMobilePosFixture({
ordersById: {
@@ -2191,7 +2193,7 @@ test.describe("POS mobile order flow", () => {
});
await setupMobilePosPage(page, fixture, {
token: "mobile-restore-token",
token: "mobile-stored-order-fresh-token",
storedOrderId: orderId,
seedState: {
customerId: REGULAR_CUSTOMER_ID,
@@ -2202,15 +2204,76 @@ test.describe("POS mobile order flow", () => {
},
route: {
step: 1,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-next-step")).toContainText("Pleno Logistics", { timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0);
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("pos_order_id"))).toBeNull();
expect(fixture.requestCounters.orderCreate).toBe(0);
expect(fixture.requestCounters.orderGet).toBe(0);
expect(fixture.requestCounters.orderDelete).toBe(0);
expect(fixture.deletedOrderIds.includes(orderId)).toBe(false);
});
test("returning from a route-loaded order to mobile step 1 clears stale order before clear all", async ({ page }) => {
const orderId = 9405;
const fixture = createMobilePosFixture({
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STALE-ROUTE-REF",
reg_1: "AB12345",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-route-loaded-clear-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "STALE-ROUTE-REF",
includePrimaryItem: true,
primaryItemId: 53,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
expect(fixture.requestCounters.orderCreate).toBe(0);
expect(fixture.requestCounters.orderGet).toBeGreaterThan(0);
await page.evaluate((departmentId) => {
window.history.pushState({}, "", `/admin/${departmentId}/modules/pos?step=1`);
window.dispatchEvent(new PopStateEvent("popstate", { state: {} }));
}, fixture.departmentId ?? DEFAULT_DEPARTMENT_ID);
await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-step-2")).toHaveCount(0);
const posSnapshot = await getStoredPosSnapshot(page);
expect(posSnapshot?.metadata?.customerId ?? null).toBeNull();
expect(posSnapshot?.vehicles?.vehicle_1 ?? null).toBeNull();
expect(posSnapshot?.transactionItems?.primaryItem ?? null).toBeNull();
const clearAllAction = page
.getByTestId("pos-mobile-clear-all-button")
.or(page.locator('[data-action-key="pos-mobile-clear-all"]'))
.or(page.getByText("Slet alt"))
.first();
await expect(clearAllAction).toBeVisible({ timeout: 10_000 });
await clearAllAction.click({ force: true });
await page.waitForTimeout(500);
expect(fixture.requestCounters.orderDelete).toBe(0);
expect(fixture.deletedOrderIds.includes(orderId)).toBe(false);
});
test("step 2 query bootstrap seeds the vehicle reference and auto-loads the primary product", async ({ page }) => {
@@ -3218,6 +3281,7 @@ test.describe("POS mobile order flow", () => {
await setupMobilePosPage(page, fixture, {
token: "mobile-clear-all-token",
storedOrderId: orderId,
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
@@ -3280,6 +3344,7 @@ test.describe("POS mobile order flow", () => {
await setupMobilePosPage(page, fixture, {
token: "mobile-clear-all-protected-token",
storedOrderId: orderId,
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
+55 -1
View File
@@ -135,7 +135,11 @@ const runtimeWithFailingBetaSwitch = () => ({
default_channel: true,
},
versions: {
frontend: { version_label: "frontend-stable", commit_sha: "abc123" },
frontend: {
version_label: "frontend-stable",
commit_sha: "abc123",
deployed_at: "2026-05-19T09:10:00.000Z",
},
api: { version_label: "api-stable", commit_sha: "def456" },
bundle_id: 31,
},
@@ -297,7 +301,9 @@ async function boot(page, runtime = unavailableRuntime, locale = "en") {
await page.addInitScript((selectedLocale) => {
window.localStorage.setItem("locale", selectedLocale);
window.localStorage.removeItem("release_channel_unavailable_ignore_until");
if (!window.localStorage.getItem("release_source_override")) {
window.localStorage.setItem("release_source_override", "deployment");
}
}, locale);
await mockApi(page, {
@@ -558,6 +564,24 @@ test("ready sidebar release channel switches are confirmed through the control r
await expect(stable).toHaveAttribute("aria-pressed", "true");
await expect(beta).toHaveAttribute("aria-pressed", "false");
const frontendBadge = page.getByTestId("release-frontend-version-badge");
await expect(frontendBadge).toBeVisible();
await expect(frontendBadge).toHaveText("184ef567");
await expect(frontendBadge).toHaveAttribute("title", /released (May 19, 2026|19 May 2026)/);
const frontendBadgeStyles = await frontendBadge.evaluate((element) => {
const commit = element.querySelector(".release-frontend-version-badge__commit");
const badgeStyles = window.getComputedStyle(element);
const commitStyles = commit ? window.getComputedStyle(commit) : null;
return {
badgeBackground: badgeStyles.backgroundColor,
badgeBorderTopWidth: badgeStyles.borderTopWidth,
commitColor: commitStyles?.color || "",
};
});
expect(frontendBadgeStyles.badgeBackground).toBe("rgba(0, 0, 0, 0)");
expect(frontendBadgeStyles.badgeBorderTopWidth).toBe("0px");
expect(frontendBadgeStyles.commitColor).toBe("rgb(100, 116, 139)");
await beta.click();
await expect(page.getByTestId("release-channel-switched-page")).toContainText("You are now on Beta");
await expect(page.locator(".release-channel-sidebar-selector__error")).toHaveCount(0);
@@ -576,6 +600,36 @@ test("ready sidebar release channel switches are confirmed through the control r
expect(betaRuntimeRequestUrl.pathname).not.toBe("/beta/api/release/runtime");
});
test("sidebar can switch back to the local frontend runtime", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
const runtime = runtimeWithFailingBetaSwitch();
await boot(page, runtime);
await page.route("**/release/runtime**", async (route) => {
await route.fulfill(json({ data: runtime }));
});
await page.goto("/user", { waitUntil: "domcontentloaded" });
const beta = page.getByTestId("release-channel-option-beta");
const useLocal = page.getByTestId("release-channel-use-local-frontend");
await expect(beta).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
await expect(useLocal).toBeVisible();
await expect(useLocal).toHaveText(/Use local frontend/i);
const positions = await Promise.all([beta.boundingBox(), useLocal.boundingBox()]);
expect(positions[0]).toBeTruthy();
expect(positions[1]).toBeTruthy();
expect(positions[1].y).toBeGreaterThan(positions[0].y);
await page.evaluate(() => window.localStorage.setItem("release_channel_selected_slug", "beta"));
await useLocal.click();
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_source_override"))).toBe("local");
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"))).toBeNull();
});
test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "The sidebar release selector is hidden in the mobile layout.");
+225
View File
@@ -0,0 +1,225 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
const FRONTEND_BASE_URL = "https://static.example.test/releases/stable/frontend";
const LATEST_FRONTEND_COMMIT = "ffffffffffffffffffffffffffffffffffffffff";
const json = (body, status = 200) => ({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
});
const text = (body, contentType = "text/plain") => ({
status: 200,
contentType,
headers: {
"access-control-allow-origin": "*",
},
body,
});
const releaseRuntime = () => ({
generated_at: "2026-05-27T09:30:00.000Z",
trace_id: "trace-release-update-widget",
channel: {
id: 1,
slug: "stable",
name: "Stable",
default_channel: true,
},
available_channels: [
{
channel: {
id: 1,
slug: "stable",
name: "Stable",
default_channel: true,
},
versions: {
frontend: {
version_label: "stable-frontend",
commit_sha: LATEST_FRONTEND_COMMIT,
},
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
{
channel: {
id: 2,
slug: "beta",
name: "Beta",
default_channel: false,
},
versions: {
frontend: {
version_label: "beta-frontend",
commit_sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
},
},
availability: {
configured: false,
missing: ["api_base_url"],
status: "unconfigured",
},
},
],
versions: {
frontend: {
version_label: "stable-frontend",
commit_sha: LATEST_FRONTEND_COMMIT,
},
api: {
version_label: "stable-api",
commit_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
bundle_id: 42,
},
frontend_base_url: FRONTEND_BASE_URL,
api_base_url: "https://api-v2.truckwash.io/master/api",
urls: {
frontend_base_url: FRONTEND_BASE_URL,
api_base_url: "https://api-v2.truckwash.io/master/api",
},
availability: {
configured: true,
missing: [],
status: "ready",
},
capture_policy: {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
},
});
const releaseManifest = () => ({
schema_version: 1,
build_id: "build-frontend-latest",
commit_sha: LATEST_FRONTEND_COMMIT,
entry: "assets/app.js",
css: ["assets/app.css"],
asset_urls: ["assets/chunk.js"],
asset_hashes: {
"/release-manifest.json": { bytes: 100 },
"/release-entry.json": { bytes: 80 },
"/assets/app.js": { bytes: 120 },
"/assets/app.css": { bytes: 60 },
"/assets/chunk.js": { bytes: 90 },
},
});
async function bootReleaseUpdateScenario(page) {
const runtimeRequests = [];
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
window.localStorage.removeItem("release_channel_unavailable_ignore_until");
window.localStorage.removeItem("release_channel_switch_notice_seen");
});
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
sessionData: {
id: 77,
customer_number: 990077,
display_name: "Superuser demo",
runtime_config: {
economic: {
transaction_draft_customer_number: null,
default_distribution_department_id: null,
},
release: releaseRuntime(),
},
},
});
await page.route("**/release/runtime**", async (route) => {
runtimeRequests.push(route.request().url());
await route.fulfill(json({ data: releaseRuntime() }));
});
await page.route(`${FRONTEND_BASE_URL}/release-manifest.json`, async (route) => {
await route.fulfill(json(releaseManifest()));
});
await page.route(`${FRONTEND_BASE_URL}/release-entry.json`, async (route) => {
await route.fulfill(json({ entry: "assets/app.js", css: ["assets/app.css"] }));
});
await page.route(`${FRONTEND_BASE_URL}/assets/app.css`, async (route) => {
await route.fulfill(text("body {}", "text/css"));
});
await page.route(`${FRONTEND_BASE_URL}/assets/app.js`, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 800));
await route.fulfill(text("console.log('latest release');", "application/javascript"));
});
await page.route(`${FRONTEND_BASE_URL}/assets/chunk.js`, async (route) => {
await route.fulfill(text("console.log('chunk');", "application/javascript"));
});
await seedAuthenticatedState(page, "release-update-widget-token");
return { runtimeRequests };
}
test("desktop release navigation predownloads an update and shows Install without a popup", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "desktop release navigation coverage");
await page.setViewportSize({ width: 1280, height: 820 });
const { runtimeRequests } = await bootReleaseUpdateScenario(page);
await page.goto("/user", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("release-frontend-version-badge").first()).toContainText("FE", { timeout: 30_000 });
await expect(page.getByTestId("release-frontend-version-badge").first()).toContainText("->", { timeout: 30_000 });
await expect(page.getByTestId("release-update-widget").first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("release-update-progress").first()).toBeVisible({ timeout: 30_000 });
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await expect(page.getByTestId("release-update-install").first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("release-update-progress")).toHaveCount(0);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator(".swal2-popup")).toHaveCount(0);
await expect.poll(() => runtimeRequests.length).toBeGreaterThanOrEqual(2);
});
test("mobile navigation shows the release update widget once above build info", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("mobile"), "mobile release navigation coverage");
await page.setViewportSize({ width: 390, height: 820 });
await bootReleaseUpdateScenario(page);
await page.goto("/user", { waitUntil: "domcontentloaded" });
await page.locator(".mobile-navbar-burger[aria-label='menu']").click();
const menu = page.locator(".mobile-menu-open");
await expect(menu).toBeVisible();
await expect(menu.getByTestId("release-update-widget")).toHaveCount(1, { timeout: 30_000 });
await expect(menu.getByTestId("release-update-install")).toBeVisible({ timeout: 30_000 });
await expect(menu.getByTestId("mobile-build-info")).toBeVisible();
const order = await menu.evaluate((node) => {
const widget = node.querySelector('[data-testid="release-update-widget"]');
const buildInfo = node.querySelector('[data-testid="mobile-build-info"]');
return {
widgetBeforeBuildInfo:
Boolean(widget && buildInfo) &&
(widget.compareDocumentPosition(buildInfo) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
};
});
expect(order.widgetBeforeBuildInfo).toBe(true);
await expect(page.locator(".swal2-popup")).toHaveCount(0);
});
+4 -2
View File
@@ -790,7 +790,7 @@ function buildSimulationResponse(request = {}) {
id: 41,
task: "Fold mirrors",
services: ["PROGRAM_PICKER"],
buttons: [],
buttons: [3],
dynamic_images_vehicle_type: 4,
attachments: mirrorTaskAttachments,
};
@@ -828,7 +828,9 @@ function buildSimulationResponse(request = {}) {
causes: debugCausesForTask(entry),
}));
const dynamicImageButtonsForTask = (entry) =>
entry.services.includes("PROGRAM_PICKER") ? ["program_picker", ...entry.buttons] : entry.buttons;
entry.services.includes("PROGRAM_PICKER")
? ["program_picker", ...entry.buttons.filter((button) => typeof button !== "number")]
: entry.buttons;
const dynamicImageButtonLabel = (button) => (button === "program_picker" ? "Program picker" : String(button));
const dynamicButtonDecisions = tasks.flatMap((entry) =>
dynamicImageButtonsForTask(entry).map((button, index) => ({
+1 -1
View File
@@ -906,7 +906,7 @@ test.describe("Self-serve wash", () => {
gate_type: "ALWAYS",
gate_ref_id: null,
services: ["PROGRAM_PICKER"],
buttons: [],
buttons: [3],
dynamic_images_vehicle_type: 4,
},
{
+85
View File
@@ -0,0 +1,85 @@
import { test, expect } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
const AUTH_SESSION_URL = /\/auth\/session(?:\?.*)?$/;
function json(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
},
body: JSON.stringify(body),
};
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve };
}
test.describe("session bootstrap", () => {
test("does not flash forbidden or unnamed UI while auth session is delayed", async ({ page }) => {
const releaseSession = deferred();
let sessionRequested = false;
const permissions = ["user", "admin", "superuser"];
const sessionData = {
id: 1,
customer_number: 12345,
group_id: 1,
email: "bootstrap@example.com",
phone: {
number: "12345678",
country_code: 45,
},
notifications: {
wash_certificate_email: null,
email_notifications_enabled: true,
sms_notifications_enabled: false,
},
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
display_name: "Bootstrap User",
permissions,
economic_customer: [],
runtime_config: {
economic: {
transaction_draft_customer_number: null,
default_distribution_department_id: null,
},
release: {},
},
};
await mockApi(page, {
authenticated: true,
permissions,
sessionData,
});
await page.route(AUTH_SESSION_URL, async (route) => {
sessionRequested = true;
await releaseSession.promise;
await route.fulfill(json({ data: sessionData }));
});
await seedAuthenticatedState(page, "delayed-session-token");
await page.goto("/superuser", { waitUntil: "domcontentloaded" });
await expect.poll(() => sessionRequested).toBe(true);
await page.waitForTimeout(500);
await expect(page.getByText("403 Forbidden")).toHaveCount(0);
await expect(page.getByText("Unnamed")).toHaveCount(0);
releaseSession.resolve();
await expect(page.getByText("Bootstrap User")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/System Status|Systemstatus/)).toBeVisible();
await expect(page.getByText("403 Forbidden")).toHaveCount(0);
await expect(page.getByText("Unnamed")).toHaveCount(0);
});
});
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { pingApiServer } from "@/services/apiHealth.js";
describe("apiHealth", () => {
afterEach(() => {
vi.useRealTimers();
});
it("treats a 2xx ping response as healthy", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
});
const result = await pingApiServer({ fetchImpl });
expect(result).toMatchObject({
ok: true,
status: 200,
error: null,
});
expect(result.url).toContain("/ping");
expect(fetchImpl).toHaveBeenCalledWith(
expect.stringContaining("/ping"),
expect.objectContaining({
method: "GET",
cache: "no-store",
mode: "cors",
})
);
});
it("treats a non-2xx ping response as unhealthy", async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
const result = await pingApiServer({ fetchImpl });
expect(result).toMatchObject({
ok: false,
status: 503,
error: null,
});
});
it("returns an unhealthy result when ping fetch rejects", async () => {
const error = new Error("Network unavailable");
const fetchImpl = vi.fn().mockRejectedValue(error);
const result = await pingApiServer({ fetchImpl });
expect(result).toMatchObject({
ok: false,
status: null,
error,
});
});
it("returns an unhealthy result when ping times out", async () => {
vi.useFakeTimers();
const fetchImpl = vi.fn((_url, options) => {
return new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
});
});
const ping = pingApiServer({ fetchImpl, timeoutMs: 25 });
await vi.advanceTimersByTimeAsync(25);
const result = await ping;
expect(result.ok).toBe(false);
expect(result.status).toBeNull();
expect(result.error?.name).toBe("AbortError");
});
});
+68 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const asMockRef = (value) => ({ value, __v_isRef: true });
@@ -282,6 +282,7 @@ const stubComponents = {
<div data-testid="vehicle-step-stub">
<button data-testid="emit-registration" @click="$emit('update:registration-number', 'AB12345')">registration</button>
<button data-testid="emit-registration-unknown" @click="$emit('update:registration-number', 'ZZ99999')">registration-unknown</button>
<button data-testid="emit-customer-number" @click="$emit('update:customer-number', '12345679')">customer</button>
<button data-testid="emit-vehicle-type" @click="$emit('select-vehicle-type', { id: 2, name: 'Truck' })">vehicle</button>
</div>
`,
@@ -334,6 +335,10 @@ const stubComponents = {
};
describe("MyWashStart", () => {
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => {
mocks.nearestDepartment.value = {
id: 6,
@@ -661,12 +666,74 @@ describe("MyWashStart", () => {
await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click");
await flushPromises();
expect(wrapper.get('[data-testid="self-serve-nav-confirm"]').text()).toContain("common.next");
await wrapper.get('[data-testid="emit-answer"]').trigger("click");
await flushPromises();
expect(mocks.onStartWash).not.toHaveBeenCalled();
});
it("skips directly to lane selection when no questions are visible after vehicle setup", async () => {
mocks.visibleQuestions.value = [];
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click");
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
expect(wrapper.get('[data-testid="self-serve-nav-confirm"]').text()).toContain("self_wash.confirm_and_start");
expect(mocks.onStartWash).not.toHaveBeenCalled();
});
it("does not restore a delayed server wash after the user edits start form input", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: false,
washLaneId: null,
washStartTime: null,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 0,
};
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await wrapper.get('[data-testid="emit-registration-unknown"]').trigger("click");
await vi.advanceTimersByTimeAsync(1600);
await flushPromises();
expect(mocks.sessionRequest).not.toHaveBeenCalledWith(
"/modules/self-serve/lane/wash/in-progress",
"GET",
expect.anything()
);
wrapper.unmount();
});
it("wires property gate buttons in the in-progress step", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
+57
View File
@@ -118,13 +118,28 @@ vi.mock("sweetalert2", () => ({
import axios from "axios";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
clearActivePosOrderContext,
clearStoredPosOrderId,
createOrder,
completed_at,
customer_id,
customer_name,
department_id,
invoiceCollectionId,
loadOrderItems,
order_id,
order_items,
order_notes,
order_po,
order_safety_seal,
reference,
reg_1,
reg_2,
reg_3,
restoreStoredPosOrderId,
scan_data,
scans,
step,
} from "@/components/shop/POSDepartmentProcess.vue";
describe("POSDepartmentProcess.loadOrderItems", () => {
@@ -177,6 +192,48 @@ describe("POSDepartmentProcess.loadOrderItems", () => {
});
});
describe("POSDepartmentProcess.clearActivePosOrderContext", () => {
it("clears active order, customer, metadata and stored mobile order id", () => {
localStorage.setItem("pos_order_id", "51211");
step.value = 2;
order_id.value = 51211;
customer_id.value = 12345679;
customer_name.value = "Existing Customer";
order_items.value = [{ id: 1 }];
reference.value = "STALE-REF";
order_notes.value = "STALE-NOTES";
order_po.value = "STALE-PO";
order_safety_seal.value = "STALE-SEAL";
reg_1.value = "AB12345";
reg_2.value = "CD12345";
reg_3.value = "EF12345";
invoiceCollectionId.value = 7001;
completed_at.value = "2026-01-01T10:00:00.000Z";
scans.value = [{ id: 1 }];
scan_data.value = [{ id: 2 }];
expect(clearActivePosOrderContext({ clearStep: true })).toBe(true);
expect(step.value).toBe(1);
expect(order_id.value).toBeNull();
expect(customer_id.value).toBe("");
expect(customer_name.value).toBe("");
expect(order_items.value).toEqual([]);
expect(reference.value).toBe("");
expect(order_notes.value).toBe("");
expect(order_po.value).toBe("");
expect(order_safety_seal.value).toBe("");
expect(reg_1.value).toBe("");
expect(reg_2.value).toBe("");
expect(reg_3.value).toBe("");
expect(invoiceCollectionId.value).toBeNull();
expect(completed_at.value).toBeNull();
expect(scans.value).toEqual([]);
expect(scan_data.value).toEqual([]);
expect(localStorage.getItem("pos_order_id")).toBeNull();
});
});
describe("POSDepartmentProcess.restoreStoredPosOrderId", () => {
beforeEach(() => {
clearStoredPosOrderId();
+84 -1
View File
@@ -43,6 +43,8 @@ describe("applyPosRouteSearch", () => {
loadOrderItems: vi.fn(),
setStep: vi.fn(),
searchAndSelectCustomer: vi.fn(),
clearActivePosOrderContext: vi.fn(),
resetMobilePos: vi.fn(),
};
const result = applyPosRouteSearch("?id=9201&customer_id=12345&step=2", handlers);
@@ -56,14 +58,18 @@ describe("applyPosRouteSearch", () => {
expect(handlers.loadOrderItems).toHaveBeenCalledTimes(1);
expect(handlers.setStep).toHaveBeenCalledWith(2);
expect(handlers.searchAndSelectCustomer).toHaveBeenCalledWith(12345, { forceRefresh: true });
expect(handlers.clearActivePosOrderContext).not.toHaveBeenCalled();
expect(handlers.resetMobilePos).not.toHaveBeenCalled();
});
it("does not call order handlers when only customer_id exists", () => {
it("resets to a fresh step 1 route when only customer_id exists", () => {
const handlers = {
setOrderId: vi.fn(),
loadOrderItems: vi.fn(),
setStep: vi.fn(),
searchAndSelectCustomer: vi.fn(),
clearActivePosOrderContext: vi.fn(() => false),
resetMobilePos: vi.fn(),
};
const result = applyPosRouteSearch("?customer_id=12345&step=1", handlers);
@@ -76,6 +82,83 @@ describe("applyPosRouteSearch", () => {
expect(handlers.setOrderId).not.toHaveBeenCalled();
expect(handlers.loadOrderItems).not.toHaveBeenCalled();
expect(handlers.setStep).toHaveBeenCalledWith(1);
expect(handlers.searchAndSelectCustomer).not.toHaveBeenCalled();
expect(handlers.clearActivePosOrderContext).toHaveBeenCalledWith({ clearStep: true });
expect(handlers.resetMobilePos).toHaveBeenCalledTimes(1);
});
it("treats explicit step 1 as fresh even when stale order params exist", () => {
const handlers = {
setOrderId: vi.fn(),
loadOrderItems: vi.fn(),
setStep: vi.fn(),
searchAndSelectCustomer: vi.fn(),
clearActivePosOrderContext: vi.fn(() => false),
resetMobilePos: vi.fn(),
};
const result = applyPosRouteSearch("?id=9201&customer_id=12345&step=1", handlers);
expect(result).toEqual({
orderId: 9201,
customerId: 12345,
step: 1,
});
expect(handlers.setOrderId).not.toHaveBeenCalled();
expect(handlers.loadOrderItems).not.toHaveBeenCalled();
expect(handlers.searchAndSelectCustomer).not.toHaveBeenCalled();
expect(handlers.setStep).toHaveBeenCalledWith(1);
expect(handlers.clearActivePosOrderContext).toHaveBeenCalledWith({ clearStep: true });
expect(handlers.resetMobilePos).toHaveBeenCalledTimes(1);
});
it("resets invalid later-step routes that do not include an order id", () => {
const handlers = {
setOrderId: vi.fn(),
loadOrderItems: vi.fn(),
setStep: vi.fn(),
searchAndSelectCustomer: vi.fn(),
clearActivePosOrderContext: vi.fn(() => false),
resetMobilePos: vi.fn(),
};
const result = applyPosRouteSearch("?step=2&customer_id=12345", handlers);
expect(result).toEqual({
orderId: null,
customerId: 12345,
step: 2,
});
expect(handlers.setOrderId).not.toHaveBeenCalled();
expect(handlers.loadOrderItems).not.toHaveBeenCalled();
expect(handlers.searchAndSelectCustomer).not.toHaveBeenCalled();
expect(handlers.setStep).toHaveBeenCalledWith(1);
expect(handlers.clearActivePosOrderContext).toHaveBeenCalledWith({ clearStep: true });
expect(handlers.resetMobilePos).toHaveBeenCalledTimes(1);
});
it("keeps step 3 route hydration when a valid order id is present", () => {
const handlers = {
setOrderId: vi.fn(),
loadOrderItems: vi.fn(),
setStep: vi.fn(),
searchAndSelectCustomer: vi.fn(),
clearActivePosOrderContext: vi.fn(),
resetMobilePos: vi.fn(),
};
const result = applyPosRouteSearch("?id=9201&customer_id=12345&step=3", handlers);
expect(result).toEqual({
orderId: 9201,
customerId: 12345,
step: 3,
});
expect(handlers.setOrderId).toHaveBeenCalledWith(9201);
expect(handlers.loadOrderItems).toHaveBeenCalledTimes(1);
expect(handlers.setStep).toHaveBeenCalledWith(3);
expect(handlers.searchAndSelectCustomer).toHaveBeenCalledWith(12345, { forceRefresh: true });
expect(handlers.clearActivePosOrderContext).not.toHaveBeenCalled();
expect(handlers.resetMobilePos).not.toHaveBeenCalled();
});
});
+24
View File
@@ -5,9 +5,12 @@ import {
RELEASE_SOURCE_OVERRIDE_STORAGE_KEY,
bootstrapReleaseApp,
fetchReleaseRuntime,
isReleaseSourceOverrideAvailable,
loadRemoteReleaseEntry,
RELEASE_SOURCE_MODES,
resolveReleaseSourceMode,
runtimeApiUrl,
setReleaseSourceOverride,
shouldLoadRemoteRelease,
} from "@/services/releaseBootstrap.js";
import { DEFAULT_STABLE_API_URL } from "@/config.js";
@@ -114,6 +117,27 @@ describe("release bootstrap", () => {
expect(resolveReleaseSourceMode("local", { allowStorageOverride: true })).toBe("deployment");
});
it("sets only supported release source overrides", () => {
expect(setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL)).toBe("local");
expect(localStorage.getItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY)).toBe("local");
expect(setReleaseSourceOverride("deployment")).toBe("deployment");
expect(localStorage.getItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY)).toBe("deployment");
expect(setReleaseSourceOverride("auto")).toBe("auto");
expect(localStorage.getItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY)).toBe("auto");
expect(setReleaseSourceOverride("invalid-source")).toBe("");
expect(localStorage.getItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY)).toBe("auto");
});
it("reports storage source overrides only for local dev builds without an explicit source", () => {
expect(isReleaseSourceOverrideAvailable({ isDev: true, releaseSourceEnv: "" })).toBe(true);
expect(isReleaseSourceOverrideAvailable({ isDev: false, releaseSourceEnv: "" })).toBe(false);
expect(isReleaseSourceOverrideAvailable({ isDev: true, releaseSourceEnv: "deployment" })).toBe(false);
expect(isReleaseSourceOverrideAvailable({ isDev: true, releaseSourceEnv: "local" })).toBe(false);
});
it("loads the local unavailable app state when selected release runtime returns invalid JSON", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
localStorage.setItem("release_channel_selected_slug", "internal");
+125 -3
View File
@@ -1,18 +1,32 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/i18n";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import { __resetReleaseChannelAvailabilityForTests } from "@/services/releaseChannelAvailability.js";
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
import {
RELEASE_CHANNEL_SELECTION_STORAGE_KEY,
selectReleaseChannel,
__resetReleaseChannelAvailabilityForTests,
} from "@/services/releaseChannelAvailability.js";
import { RELEASE_SOURCE_OVERRIDE_STORAGE_KEY } from "@/services/releaseBootstrap.js";
import { configureReleaseRuntime, __resetReleaseTimelineForTests } from "@/services/releaseTimeline.js";
import { __resetReleaseUpdateForTests } from "@/services/releaseUpdate.js";
const configureSelectableChannels = () => {
const configureSelectableChannels = ({ source = "deployment" } = {}) => {
configureReleaseRuntime({
source,
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
},
versions: {
frontend: {
commit_sha: "184ef567",
deployed_at: "2026-05-27T11:07:00.000Z",
},
},
available_channels: [
{
channel: {
@@ -20,6 +34,24 @@ const configureSelectableChannels = () => {
name: "Stable",
default_channel: true,
},
versions: {
frontend: {
commit_sha: "184ef567",
deployed_at: "2026-05-27T11:07:00.000Z",
},
},
availability: {
configured: true,
missing: [],
status: "ready",
},
},
{
channel: {
slug: "internal",
name: "Internal",
default_channel: false,
},
availability: {
configured: true,
missing: [],
@@ -38,6 +70,18 @@ const configureSelectableChannels = () => {
status: "unconfigured",
},
},
{
channel: {
slug: "beta",
name: "Beta",
default_channel: false,
},
availability: {
configured: false,
missing: ["frontend_version"],
status: "unconfigured",
},
},
],
});
};
@@ -46,6 +90,8 @@ describe("ReleaseChannelSelector", () => {
afterEach(() => {
__resetReleaseChannelAvailabilityForTests();
__resetReleaseTimelineForTests();
__resetReleaseUpdateForTests({ currentCommit: "184ef567" });
window.localStorage.removeItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY);
});
it("renders available release channels and emits the selected channel", async () => {
@@ -68,4 +114,80 @@ describe("ReleaseChannelSelector", () => {
expect(wrapper.emitted("select")).toHaveLength(1);
expect(wrapper.emitted("select")[0][0].channelSlug).toBe("canary");
});
it("shows the compact frontend commit text in sidebar mode", () => {
__resetReleaseUpdateForTests({ currentCommit: "184ef567" });
configureSelectableChannels();
i18n.global.locale.value = "en";
const wrapper = mount(ReleaseChannelSelector, {
props: {
variant: "sidebar",
},
global: {
plugins: [i18n],
},
});
const badge = wrapper.find('[data-testid="release-frontend-version-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe("184ef567");
expect(badge.attributes("title")).toContain("released");
expect(badge.attributes("title")).toMatch(/May 27, 2026|27 May 2026/);
expect(badge.find(".release-frontend-version-badge__commit").text()).toBe("184ef567");
});
it("shows a local frontend action below sidebar release channels and clears channel selection", async () => {
const reload = vi.fn();
configureSelectableChannels({ source: "deployment" });
selectReleaseChannel("beta");
i18n.global.locale.value = "en";
const wrapper = mount(ReleaseChannelSidebarSelector, {
global: {
plugins: [i18n],
provide: {
releaseSourceReload: reload,
},
stubs: {
ReleaseUpdateWidget: true,
},
},
});
expect(wrapper.text()).toContain("Stable");
expect(wrapper.text()).toContain("Internal");
expect(wrapper.text()).toContain("Canary");
expect(wrapper.text()).toContain("Beta");
const beta = wrapper.find('[data-testid="release-channel-option-beta"]');
const localButton = wrapper.find('[data-testid="release-channel-use-local-frontend"]');
expect(localButton.exists()).toBe(true);
expect(beta.element.compareDocumentPosition(localButton.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await localButton.trigger("click");
expect(window.localStorage.getItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY)).toBe("local");
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBeNull();
expect(reload).toHaveBeenCalledTimes(1);
});
it("hides the local frontend action when already using the local runtime", () => {
configureSelectableChannels({ source: "local" });
i18n.global.locale.value = "en";
const wrapper = mount(ReleaseChannelSidebarSelector, {
global: {
plugins: [i18n],
provide: {
releaseSourceReload: vi.fn(),
},
stubs: {
ReleaseUpdateWidget: true,
},
},
});
expect(wrapper.find('[data-testid="release-channel-use-local-frontend"]').exists()).toBe(false);
});
});
+6
View File
@@ -26,6 +26,7 @@ const requiredReleaseAvailabilityLabels = {
"bundle",
"frontend",
"api",
"use_local_frontend",
],
};
const expectedNewReleaseLabels = {
@@ -34,30 +35,35 @@ const expectedNewReleaseLabels = {
api_base_url: "API-URL",
frontend_entry: "Frontend-entry",
release_runtime: "Release-runtime",
use_local_frontend: "Brug lokal frontend",
},
en: {
frontend_base_url: "Frontend URL",
api_base_url: "API URL",
frontend_entry: "Frontend entry",
release_runtime: "Release runtime",
use_local_frontend: "Use local frontend",
},
sv: {
frontend_base_url: "Frontend-URL",
api_base_url: "API-URL",
frontend_entry: "Frontend-entry",
release_runtime: "Release-runtime",
use_local_frontend: "Använd lokal frontend",
},
de: {
frontend_base_url: "Frontend-URL",
api_base_url: "API-URL",
frontend_entry: "Frontend-Einstiegspunkt",
release_runtime: "Release-Laufzeit",
use_local_frontend: "Lokales Frontend verwenden",
},
no: {
frontend_base_url: "Frontend-URL",
api_base_url: "API-URL",
frontend_entry: "Frontend-entry",
release_runtime: "Release-runtime",
use_local_frontend: "Bruk lokal frontend",
},
};
const sharedReleaseDetailLabels = {
+197
View File
@@ -0,0 +1,197 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetReleaseUpdateForTests,
checkReleaseUpdateNow,
downloadReleaseCandidate,
inspectReleaseRuntimeForUpdate,
installReadyReleaseUpdate,
releaseUpdateState,
} from "@/services/releaseUpdate.js";
const jsonResponse = (body, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json",
},
});
const textResponse = (body = "", status = 200, contentType = "text/plain") =>
new Response(body, {
status,
headers: {
"content-type": contentType,
},
});
const releaseRuntime = (overrides = {}) => ({
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
},
versions: {
frontend: {
commit_sha: "bbbbbbb2222222",
build_id: "build-2",
},
},
frontend_base_url: "https://static.example.test/stable/frontend",
availability: {
configured: true,
missing: [],
status: "ready",
},
...overrides,
});
const releaseManifest = () => ({
build_id: "build-2",
commit_sha: "bbbbbbb2222222",
entry: "assets/app.js",
css: ["assets/app.css"],
asset_urls: ["assets/chunk.js"],
asset_hashes: {
"release-manifest.json": { bytes: 10 },
"release-entry.json": { bytes: 11 },
"assets/app.js": { bytes: 12 },
"assets/app.css": { bytes: 13 },
"assets/chunk.js": { bytes: 14 },
},
});
const mockReleaseFetch = (manifest = releaseManifest()) =>
vi.fn(async (url) => {
const value = String(url);
if (value.endsWith("/release-manifest.json")) {
return jsonResponse(manifest);
}
if (value.endsWith("/release-entry.json")) {
return jsonResponse({ entry: manifest.entry, css: manifest.css || [] });
}
if (value.endsWith(".css")) {
return textResponse("body {}", 200, "text/css");
}
return textResponse("console.log('release');", 200, "application/javascript");
});
describe("release update service", () => {
beforeEach(() => {
__resetReleaseUpdateForTests({ currentCommit: "aaaaaaa1111111" });
vi.stubGlobal("caches", {
open: vi.fn(async () => ({
put: vi.fn(async () => {}),
})),
});
});
afterEach(() => {
__resetReleaseUpdateForTests({ currentCommit: "aaaaaaa1111111" });
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("detects a newer frontend commit and predownloads manifest assets", async () => {
const fetchFn = mockReleaseFetch();
await inspectReleaseRuntimeForUpdate(releaseRuntime(), { fetchFn });
expect(releaseUpdateState.status).toBe("ready");
expect(releaseUpdateState.currentCommit).toBe("aaaaaaa1111111");
expect(releaseUpdateState.latestCommit).toBe("bbbbbbb2222222");
expect(releaseUpdateState.progress).toBe(100);
expect(releaseUpdateState.downloadedAssets).toBe(5);
expect(releaseUpdateState.totalBytes).toBe(60);
expect(fetchFn).toHaveBeenCalledWith(
"https://static.example.test/stable/frontend/assets/app.js",
expect.objectContaining({ cache: "reload" })
);
expect(caches.open).toHaveBeenCalledWith("truckwash-release-update-v1");
});
it("ignores the same frontend commit", async () => {
__resetReleaseUpdateForTests({ currentCommit: "bbbbbbb2222222" });
const fetchFn = mockReleaseFetch();
await inspectReleaseRuntimeForUpdate(releaseRuntime(), { fetchFn });
expect(releaseUpdateState.status).toBe("up_to_date");
expect(releaseUpdateState.latestCommit).toBe("bbbbbbb2222222");
expect(fetchFn).not.toHaveBeenCalled();
});
it("ignores missing frontend commit or base URL candidates", async () => {
const fetchFn = mockReleaseFetch();
await inspectReleaseRuntimeForUpdate(
releaseRuntime({
versions: { frontend: null },
frontend_base_url: "",
}),
{ fetchFn }
);
expect(releaseUpdateState.status).toBe("up_to_date");
expect(fetchFn).not.toHaveBeenCalled();
});
it("reports failed predownloads without reloading", async () => {
const fetchFn = vi.fn(async () => jsonResponse({ message: "missing" }, 404));
const reload = vi.fn();
await inspectReleaseRuntimeForUpdate(releaseRuntime(), { fetchFn });
await installReadyReleaseUpdate({ reload });
expect(releaseUpdateState.status).toBe("failed");
expect(releaseUpdateState.error).toContain("HTTP 404");
expect(reload).not.toHaveBeenCalled();
});
it("reloads only when installing a ready update", async () => {
const fetchFn = mockReleaseFetch();
const reload = vi.fn();
const postMessage = vi.fn();
expect(await installReadyReleaseUpdate({ reload })).toBe(false);
expect(reload).not.toHaveBeenCalled();
await downloadReleaseCandidate(
{
channelSlug: "stable",
channelName: "Stable",
latestCommit: "bbbbbbb2222222",
frontendBaseUrl: "https://static.example.test/stable/frontend",
buildId: "build-2",
},
{ fetchFn }
);
expect(releaseUpdateState.status).toBe("ready");
expect(reload).not.toHaveBeenCalled();
expect(
await installReadyReleaseUpdate({
reload,
serviceWorker: {
getRegistration: vi.fn(async () => ({
waiting: { postMessage },
})),
},
})
).toBe(true);
expect(postMessage).toHaveBeenCalledWith({ type: "SKIP_WAITING" });
expect(reload).toHaveBeenCalledTimes(1);
});
it("checks runtime with the release endpoint without mutating the active app runtime", async () => {
const fetchFn = mockReleaseFetch();
fetchFn.mockResolvedValueOnce(jsonResponse({ data: releaseRuntime() }));
await checkReleaseUpdateNow({ fetchFn });
expect(fetchFn.mock.calls[0][0]).toContain("/release/runtime");
expect(releaseUpdateState.status).toBe("ready");
expect(releaseUpdateState.latestCommit).toBe("bbbbbbb2222222");
});
});
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
const mocks = vi.hoisted(() => {
const sessionState = {
initiated: false,
};
return {
sessionState,
router: {
currentRoute: {
value: {
path: "/superuser",
},
},
go: vi.fn(),
},
sessionUser: {
isInitiated: () => sessionState.initiated,
hasPermission: vi.fn(() => false),
auth: {
forceClearSession: vi.fn(),
},
functions: {
redirectTo: {
pages: {
login: vi.fn(),
},
},
},
initiateOnAppStart: vi.fn(),
},
pingApiServer: vi.fn(),
swalFire: vi.fn(),
};
});
vi.mock("vue-router", () => ({
useRouter: () => mocks.router,
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
default: mocks.sessionUser,
SessionUser: mocks.sessionUser,
}));
vi.mock("@/services/apiHealth.js", () => ({
pingApiServer: mocks.pingApiServer,
}));
vi.mock("sweetalert2", () => ({
default: {
fire: mocks.swalFire,
},
}));
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
const mountWrapper = (hasPermission) =>
mount(RestrictedPageWrapper, {
props: {
hasPermission,
},
slots: {
default: '<div data-testid="allowed-content">Allowed content</div>',
},
});
describe("RestrictedPageWrapper", () => {
beforeEach(() => {
mocks.sessionState.initiated = false;
mocks.router.currentRoute.value.path = "/superuser";
mocks.sessionUser.hasPermission.mockReturnValue(false);
mocks.sessionUser.auth.forceClearSession.mockReset();
mocks.sessionUser.functions.redirectTo.pages.login.mockReset();
mocks.sessionUser.initiateOnAppStart.mockReset();
mocks.pingApiServer.mockReset();
mocks.swalFire.mockReset();
});
it("shows only the bootstrap loader before the session is initiated", () => {
const wrapper = mountWrapper(false);
expect(wrapper.find(".pageloader").exists()).toBe(true);
expect(wrapper.text()).toContain("Bekr");
expect(wrapper.text()).not.toContain("403 Forbidden");
expect(wrapper.find('[data-testid="allowed-content"]').exists()).toBe(false);
});
it("shows the 403 branch after initiation when permission is missing", () => {
mocks.sessionState.initiated = true;
const wrapper = mountWrapper(false);
expect(wrapper.find(".pageloader").exists()).toBe(false);
expect(wrapper.text()).toContain("403 Forbidden");
expect(wrapper.find('[data-testid="allowed-content"]').exists()).toBe(false);
});
it("shows protected content after initiation when permission is present", () => {
mocks.sessionState.initiated = true;
const wrapper = mountWrapper(true);
expect(wrapper.find(".pageloader").exists()).toBe(false);
expect(wrapper.text()).not.toContain("403 Forbidden");
expect(wrapper.find('[data-testid="allowed-content"]').exists()).toBe(true);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
getSelfServeCompletedDynamicImageStep,
getSelfServeTaskDynamicImagePresentation,
normalizeSelfServeProgramPickerTaskButtons,
} from "@/services/selfServeDynamicImage.js";
describe("selfServeDynamicImage", () => {
it("uses a selected program picker button number as thumb position", () => {
const presentation = getSelfServeTaskDynamicImagePresentation({
buttons: ["program_picker", 0],
});
expect(presentation.buttons).toEqual(["program_picker"]);
expect(presentation.normalizedButtons).toEqual(["program_picker", 0]);
expect(presentation.thumbPosition).toBe(1);
});
it("uses PROGRAM_PICKER service button numbers as thumb position and keeps start highlighted", () => {
const presentation = getSelfServeTaskDynamicImagePresentation({
services: ["PROGRAM_PICKER"],
buttons: [2, "start"],
dynamic_images_vehicle_type: 8,
});
expect(presentation.buttons).toEqual(["program_picker", "start"]);
expect(presentation.normalizedButtons).toEqual([2, "start"]);
expect(presentation.thumbPosition).toBe(3);
});
it("keeps numeric buttons highlighted outside program-picker mode", () => {
const presentation = getSelfServeTaskDynamicImagePresentation({
services: ["MACHINE"],
buttons: [2, "start"],
dynamic_images_vehicle_type: 8,
});
expect(presentation.buttons).toEqual([2, "start"]);
expect(presentation.thumbPosition).toBe(8);
});
it("excludes program-picker thumb selectors from completed step count", () => {
const tasks = [
{
id: 10,
services: ["PROGRAM_PICKER"],
buttons: [2, "start"],
},
];
expect(getSelfServeCompletedDynamicImageStep(tasks, { 10: true })).toBe(2);
});
it("keeps only one numbered program button in program-picker mode", () => {
expect(normalizeSelfServeProgramPickerTaskButtons(["program_picker", 1, 2, "start"], [], 2)).toEqual([
"program_picker",
2,
"start",
]);
});
});
+81
View File
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
const RadioButtonStub = {
props: ["modelValue", "nativeValue", "disabled"],
emits: ["update:modelValue", "input"],
template: `
<button
v-bind="$attrs"
type="button"
:disabled="disabled"
@click="!disabled && ($emit('update:modelValue', nativeValue), $emit('input', nativeValue))"
>
<slot />
</button>
`,
};
const mountLaneStep = (props = {}) =>
mount(SelfServeLaneStep, {
props: {
lanes: [
{ id: 7, name: "7", status: "OCCUPIED" },
{ id: 8, name: "8", status: "AVAILABLE" },
],
selectedLaneId: 8,
washType: "Manual",
departmentName: "Roskilde",
isLaneAvailable: (lane) => lane.status === "AVAILABLE",
isMachineAvailable: () => true,
...props,
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BField: {
template: "<div><slot /></div>",
},
BIcon: {
template: "<i />",
},
BMessage: {
template: "<div v-bind='$attrs'><slot /></div>",
},
BRadioButton: RadioButtonStub,
},
},
});
describe("SelfServeLaneStep", () => {
it("keeps unavailable lanes visible but disabled", async () => {
const wrapper = mountLaneStep();
const unavailableLane = wrapper.get('[data-testid="self-serve-lane-option-7"]');
const availableLane = wrapper.get('[data-testid="self-serve-lane-option-8"]');
expect(unavailableLane.attributes("disabled")).toBeDefined();
expect(unavailableLane.text()).toContain("self_wash.lane_unavailable");
expect(availableLane.attributes("disabled")).toBeUndefined();
await unavailableLane.trigger("click");
expect(wrapper.emitted("update:selectedLaneId")).toBeUndefined();
});
it("shows machine unavailable guidance for the selected lane", () => {
const wrapper = mountLaneStep({
isMachineAvailable: () => false,
});
const machineButton = wrapper.get('[data-testid="self-serve-wash-type-machine"]');
expect(machineButton.attributes("disabled")).toBeDefined();
expect(machineButton.attributes("title")).toBe("self_wash.machine_unavailable_for_lane");
expect(wrapper.get('[data-testid="self-serve-machine-unavailable-guidance"]').text()).toContain(
"self_wash.machine_unavailable_for_lane"
);
});
});
@@ -26,9 +26,9 @@ describe("self-serve studio task editing", () => {
expect(source).toContain('inspectorForm.value.gate_type ||= "ALWAYS"');
expect(source).toContain("inspectorForm.value.services = normalizeServiceList(inspectorForm.value.services)");
expect(source).toContain("inspectorForm.value.buttons = normalizeButtonList(inspectorForm.value.buttons)");
expect(source).toContain("inspectorForm.value.buttons = normalizeTaskButtonList(inspectorForm.value)");
expect(source).toContain("data.services = normalizeServiceList(data.services)");
expect(source).toContain("data.buttons = normalizeButtonList(data.buttons)");
expect(source).toContain("data.buttons = normalizeTaskButtonList(data)");
});
it("renders mapped dynamic image task buttons in the inspector", () => {
@@ -37,7 +37,8 @@ describe("self-serve studio task editing", () => {
expect(source).toContain("SELF_SERVE_TASK_BUTTON_OPTIONS");
expect(source).toContain('data-testid="studio-task-button-picker"');
expect(source).toContain('v-model="inspectorForm.buttons"');
expect(source).toContain("taskButtonIsSelected(inspectorForm.buttons, option.id)");
expect(source).toContain("taskButtonIsSelected(inspectorForm, option.id)");
expect(source).toContain('@change="onTaskButtonSelectionChange(option.id, $event.target.checked)"');
expect(source).toContain("taskButtonSummary(rawFor(data).buttons)");
});
+9
View File
@@ -84,6 +84,14 @@ describe("SelfServeTaskList", () => {
buttons: ["reset"],
dynamic_images_vehicle_type: 7,
},
{
id: 9,
task: "Choose by button",
description: "-",
services: ["PROGRAM_PICKER"],
buttons: [2],
dynamic_images_vehicle_type: null,
},
{
id: 8,
task: "Manual inspection",
@@ -107,6 +115,7 @@ describe("SelfServeTaskList", () => {
expect(wrapper.get('[data-testid="self-serve-task-6"]').text()).toContain("#4 Choose program");
expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).toContain("Tryk reset");
expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).not.toContain("#");
expect(wrapper.get('[data-testid="self-serve-task-9"]').text()).toContain("#3 Choose by button");
expect(wrapper.get('[data-testid="self-serve-task-8"]').text()).toContain("Manual inspection");
expect(wrapper.get('[data-testid="self-serve-task-8"]').text()).not.toContain("#");
});
+41 -2
View File
@@ -51,7 +51,7 @@ describe("SelfServeVehicleStep", () => {
BAutocomplete: AutocompleteStub,
BInput: true,
BMessage: {
template: "<div><slot /></div>",
template: "<div v-bind='$attrs'><slot /></div>",
},
},
},
@@ -92,7 +92,7 @@ describe("SelfServeVehicleStep", () => {
BAutocomplete: AutocompleteStub,
BInput: true,
BMessage: {
template: "<div><slot /></div>",
template: "<div v-bind='$attrs'><slot /></div>",
},
},
},
@@ -105,4 +105,43 @@ describe("SelfServeVehicleStep", () => {
expect(emitted[0]?.[0]).toBe("EC21235");
expect(emitted[1]?.[0]).toBe("AB12345");
});
it("renders guidance without hiding vehicle controls", () => {
const wrapper = mount(SelfServeVehicleStep, {
props: {
customerNumber: "12345",
showCustomerNumberInput: false,
registrationNumber: "",
registrationOptions: ["AB12345"],
isCustomerVehiclesLoading: false,
hasMatchingVehicle: false,
selectedVehicleTypeId: null,
selectedVehicleName: null,
selectedVehicleDescription: null,
availableProductIds: [2],
vehicleTypes: [{ id: 2 }],
vehicleStepGuidance: "Enter your registration number to continue.",
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BField: {
template: "<div><slot /></div>",
},
BAutocomplete: AutocompleteStub,
BInput: true,
BMessage: {
template: "<div v-bind='$attrs'><slot /></div>",
},
},
},
});
expect(wrapper.get('[data-testid="self-serve-vehicle-step-guidance"]').text()).toContain(
"Enter your registration number to continue."
);
expect(wrapper.find('[data-testid="selector-stub"]').exists()).toBe(true);
});
});
+134
View File
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticatedRequest: vi.fn(),
parseError: vi.fn(),
pingApiServer: vi.fn(),
swalFire: vi.fn(() => Promise.resolve({ isConfirmed: true })),
loginRedirect: vi.fn(),
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: mocks.authenticatedRequest,
}));
vi.mock("@/components/request/HandleGlobalError.vue", () => ({
parseError: mocks.parseError,
}));
vi.mock("@/services/apiHealth.js", () => ({
pingApiServer: mocks.pingApiServer,
}));
vi.mock("sweetalert2", () => ({
default: {
fire: mocks.swalFire,
},
}));
import { getSessionData, SessionUser } from "@/components/session/token/SessionUser.vue";
const seedStoredSession = (token = "stored-token") => {
localStorage.setItem("token", token);
SessionUser.token.value = token;
SessionUser.authenticated.value = true;
SessionUser.isSubuser.value = false;
SessionUser.initiated.value = false;
};
const invalidSessionError = () => ({
response: {
status: 401,
data: {
message: "Unauthenticated",
},
},
});
describe("session bootstrap failure handling", () => {
beforeEach(() => {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login = mocks.loginRedirect;
mocks.authenticatedRequest.mockReset();
mocks.parseError.mockReset();
mocks.pingApiServer.mockReset();
mocks.swalFire.mockClear();
mocks.loginRedirect.mockReset();
window.history.replaceState({}, "", "/user");
});
it("clears an invalid stored session after ping confirms the API is reachable", async () => {
seedStoredSession("invalid-token");
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
mocks.pingApiServer.mockResolvedValueOnce({
ok: true,
status: 200,
url: "/api/ping",
error: null,
});
const result = await getSessionData();
expect(mocks.pingApiServer).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("token")).toBeNull();
expect(SessionUser.authenticated.value).toBe(false);
expect(mocks.loginRedirect).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
cleared: true,
apiReachable: true,
redirectedTo: "/login",
});
});
it("preserves an invalid stored session and routes to connectivity when ping fails", async () => {
seedStoredSession("invalid-token");
mocks.authenticatedRequest.mockRejectedValueOnce(invalidSessionError());
mocks.pingApiServer.mockResolvedValueOnce({
ok: false,
status: null,
url: "/api/ping",
error: new Error("Network unavailable"),
});
const result = await getSessionData();
expect(mocks.pingApiServer).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("token")).toBe("invalid-token");
expect(SessionUser.authenticated.value).toBe(true);
expect(mocks.loginRedirect).not.toHaveBeenCalled();
expect(result).toMatchObject({
cleared: false,
apiReachable: false,
redirectedTo: "/connectivity-issue",
});
});
it("does not clear the stored session for generic server bootstrap errors", async () => {
seedStoredSession("still-valid-token");
const serverError = {
response: {
status: 500,
data: {
message: "Internal server error",
},
},
};
mocks.authenticatedRequest.mockRejectedValueOnce(serverError);
const result = await getSessionData();
expect(mocks.pingApiServer).not.toHaveBeenCalled();
expect(localStorage.getItem("token")).toBe("still-valid-token");
expect(SessionUser.authenticated.value).toBe(true);
expect(mocks.loginRedirect).not.toHaveBeenCalled();
expect(mocks.parseError).toHaveBeenCalledWith(serverError, "auth");
expect(mocks.swalFire).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
cleared: false,
apiReachable: null,
redirectedTo: null,
});
});
});
+6 -6
View File
@@ -158,10 +158,10 @@ describe("useWashSessionActions property gate commands", () => {
const url = new URL(actions.dynamicImageUrl.value, "https://app.example.test");
expect(JSON.parse(url.searchParams.get("buttons"))).toEqual(["program_picker", "reset", 0, 2, "start", 5]);
expect(url.searchParams.get("current_step")).toBe("3");
expect(url.searchParams.get("thumb_position")).toBe("2");
expect(actions.machineStartCurrentStep.value).toBe(3);
expect(JSON.parse(url.searchParams.get("buttons"))).toEqual(["program_picker", "reset", 2, "start", 5]);
expect(url.searchParams.get("current_step")).toBe("2");
expect(url.searchParams.get("thumb_position")).toBe("1");
expect(actions.machineStartCurrentStep.value).toBe(2);
});
it("does not enter wash-in-progress state when the start command fails", async () => {
@@ -213,7 +213,7 @@ describe("useWashSessionActions property gate commands", () => {
consoleErrorSpy.mockRestore();
});
it("does not duplicate action alerts when allowed-service setup fails before start", async () => {
it("surfaces action alerts when allowed-service setup fails before start", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const washInProgress = ref(false);
const washLaneId = ref(null);
@@ -236,7 +236,7 @@ describe("useWashSessionActions property gate commands", () => {
expect(result).toBe(false);
expect(updateLaneAllowedServices).toHaveBeenCalledWith(7);
expect(request).not.toHaveBeenCalled();
expect(alertFn).not.toHaveBeenCalled();
expect(alertFn).toHaveBeenCalledWith("Edge gateway command timed out");
expect(washInProgress.value).toBe(false);
expect(currentStep.value).toBe(2);
+16 -6
View File
@@ -2,18 +2,18 @@ import { describe, expect, it } from "vitest";
import { createApiProxyOptions } from "../../vite.config.js";
describe("Vite API proxy", () => {
it("forwards local /api requests to the working dev API by default", () => {
it("forwards local /api requests to the remote stable API by default", () => {
const options = createApiProxyOptions({});
expect(options.target).toBe("http://localhost");
expect(options.target).toBe("https://api-v2.truckwash.io");
expect(options.changeOrigin).toBe(true);
expect(options.secure).toBe(false);
expect(options.rewrite("/api/ping")).toBe("/ping");
expect(options.rewrite("/api/release/runtime")).toBe("/release/runtime");
expect(options.rewrite("/api")).toBe("/");
expect(options.rewrite("/api/ping")).toBe("/master/api/ping");
expect(options.rewrite("/api/release/runtime")).toBe("/master/api/release/runtime");
expect(options.rewrite("/api")).toBe("/master/api");
});
it("allows a local gateway override", () => {
it("allows a local PHP API override", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_TARGET: "http://localhost",
});
@@ -22,6 +22,16 @@ describe("Vite API proxy", () => {
expect(options.rewrite("/api/ping")).toBe("/ping");
});
it("allows overriding the remote gateway base path", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_BASE_PATH: "/canary/api",
});
expect(options.target).toBe("https://api-v2.truckwash.io");
expect(options.rewrite("/api/ping")).toBe("/canary/api/ping");
expect(options.rewrite("/api")).toBe("/canary/api");
});
it("allows disabling prefix stripping for compatible local gateways", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_TARGET: "http://localhost",
+28 -2
View File
@@ -344,16 +344,42 @@ function releaseMetadataManifest() {
}
}
const DEFAULT_API_PROXY_TARGET = 'https://api-v2.truckwash.io'
const DEFAULT_API_PROXY_BASE_PATH = '/master/api'
function normalizeProxyBasePath(value) {
const normalized = String(value || '').trim().replace(/^\/+|\/+$/g, '')
return normalized ? `/${normalized}` : ''
}
function rewriteApiProxyPath(requestPath, basePath = '') {
const pathWithoutApiPrefix = String(requestPath || '/').replace(/^\/api(?=\/|\?|$)/, '') || '/'
if (!basePath) {
return pathWithoutApiPrefix.startsWith('?') ? `/${pathWithoutApiPrefix}` : pathWithoutApiPrefix
}
if (pathWithoutApiPrefix === '/') {
return basePath
}
return `${basePath}${pathWithoutApiPrefix}`
}
export function createApiProxyOptions(env = process.env) {
const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false'
const configuredTarget = String(env.VITE_API_PROXY_TARGET || '').trim()
const target = configuredTarget || DEFAULT_API_PROXY_TARGET
const basePath = env.VITE_API_PROXY_BASE_PATH !== undefined
? normalizeProxyBasePath(env.VITE_API_PROXY_BASE_PATH)
: configuredTarget
? ''
: DEFAULT_API_PROXY_BASE_PATH
return {
target: env.VITE_API_PROXY_TARGET || 'http://localhost',
target,
changeOrigin: true,
secure: false,
...(stripPrefix
? {
rewrite: (requestPath) => requestPath.replace(/^\/api(?=\/|$)/, '') || '/'
rewrite: (requestPath) => rewriteApiProxyPath(requestPath, basePath)
}
: {})
}