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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+14
-21
@@ -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
|
||||
|
||||
@@ -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">-></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 }} -> {{ 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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export const errorReportLaunchRequestId = ref(0);
|
||||
|
||||
export const requestOpenErrorReport = () => {
|
||||
errorReportLaunchRequestId.value += 1;
|
||||
};
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
+69
-9
@@ -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,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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
|
||||
@@ -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("#");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user