Add loading screen for session confirmation and enhance vehicle management labels

This commit is contained in:
Jeppe Bundgaard
2026-07-13 22:11:51 +02:00
parent f15d7224e0
commit acec2a694a
83 changed files with 207312 additions and 120 deletions
+434 -3
View File
@@ -1,9 +1,12 @@
<!DOCTYPE html>
<html lang="" class="theme-light" data-theme="light">
<html lang="" class="theme-light tw-bootstrap-loading" data-theme="light">
<head>
<meta charset="UTF-8">
<script>
(function () {
window.__TW_LOADER_STARTED_AT__ = window.performance && typeof window.performance.now === 'function'
? window.performance.now()
: Date.now();
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base');
@@ -21,9 +24,437 @@
<meta name="mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css" integrity="sha512-5Hs3dF2AEPkpNAR7UiOHba+lRSJNeM2ECkwxUIxC1Q/FLycGTbNapWXB4tP889k5T5Ju8fs4b1P5z/iB4nMfSQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<title>Truck Wash Kundeportal</title>
<style>
html,
body,
#app {
min-height: 100%;
}
html.tw-bootstrap-loading {
font-size: 16px !important;
}
body {
margin: 0;
background: #fff;
}
body.tw-bootstrap-loading {
background: #041f32;
}
html.tw-bootstrap-loading,
html.tw-loader-scroll-lock,
body.tw-bootstrap-loading,
body.tw-loader-scroll-lock {
overflow: hidden;
scrollbar-width: none;
}
html.tw-bootstrap-loading::-webkit-scrollbar,
html.tw-loader-scroll-lock::-webkit-scrollbar,
body.tw-bootstrap-loading::-webkit-scrollbar,
body.tw-loader-scroll-lock::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.tw-bootstrap-loader {
--tw-loader-navy: #063651;
--tw-loader-blue: #0787bb;
--tw-loader-cyan: #69d7f5;
--tw-loader-foam: #f8fbff;
--tw-loader-warm: #f4c15d;
position: fixed;
inset: 0;
z-index: 100000;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
padding: 2rem;
box-sizing: border-box;
background:
radial-gradient(circle at 20% 20%, rgba(105, 215, 245, 0.18), transparent 28rem),
radial-gradient(circle at 82% 74%, rgba(244, 193, 93, 0.12), transparent 24rem),
linear-gradient(145deg, #041f32 0%, var(--tw-loader-navy) 52%, #04263d 100%);
color: var(--tw-loader-foam);
font-family: Avenir, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.tw-bootstrap-loader__ambient {
position: absolute;
inset: 0;
pointer-events: none;
}
.tw-bootstrap-loader__ambient::before {
position: absolute;
inset: 14% 9%;
content: "";
border: 1px solid rgba(248, 251, 255, 0.08);
border-radius: 8px;
}
.tw-bootstrap-loader__wash {
position: absolute;
left: 50%;
display: block;
width: 72rem;
height: 8rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(105, 215, 245, 0.2), rgba(248, 251, 255, 0.18), transparent);
filter: blur(8px);
transform: translateX(-50%) rotate(-9deg);
}
.tw-bootstrap-loader__wash--wide {
top: 26%;
animation: tw-bootstrap-loader-wash 5.8s ease-in-out var(--tw-loader-wash-wide-delay, 0ms) infinite;
}
.tw-bootstrap-loader__wash--tight {
bottom: 22%;
width: 48rem;
height: 5rem;
animation: tw-bootstrap-loader-wash 6.8s ease-in-out var(--tw-loader-wash-tight-delay, 0ms) infinite reverse;
}
.tw-bootstrap-loader__panel {
position: relative;
display: grid;
width: min(100%, 25rem);
min-height: 27rem;
align-content: center;
justify-items: center;
padding: 2.5rem 2rem 2.25rem;
box-sizing: border-box;
border: 1px solid rgba(248, 251, 255, 0.16);
border-radius: 8px;
background: rgba(4, 31, 50, 0.74);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
text-align: center;
backdrop-filter: blur(18px);
}
.tw-bootstrap-loader__panel::after {
position: absolute;
inset: 0;
content: "";
border-radius: inherit;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.08), transparent 38%);
pointer-events: none;
}
.tw-bootstrap-loader__brand,
.tw-bootstrap-loader__motion,
.tw-bootstrap-loader__status,
.tw-bootstrap-loader__hint,
.tw-bootstrap-loader__dots {
position: relative;
z-index: 1;
}
.tw-bootstrap-loader__brand {
display: grid;
width: 13rem;
min-height: 4rem;
place-items: center;
margin-bottom: 2.25rem;
}
.tw-bootstrap-loader__logo {
display: block;
width: 100%;
max-height: 4rem;
object-fit: contain;
}
.tw-bootstrap-loader__motion {
display: grid;
width: 8.5rem;
height: 8.5rem;
place-items: center;
margin-bottom: 2rem;
}
.tw-bootstrap-loader__ring {
position: absolute;
inset: 0;
border: 2px solid rgba(248, 251, 255, 0.12);
border-top-color: var(--tw-loader-cyan);
border-right-color: rgba(7, 135, 187, 0.78);
border-radius: 50%;
animation: tw-bootstrap-loader-spin 1.8s linear var(--tw-loader-spin-delay, 0ms) infinite;
}
.tw-bootstrap-loader__ring::before,
.tw-bootstrap-loader__ring::after {
position: absolute;
content: "";
border-radius: 50%;
}
.tw-bootstrap-loader__ring::before {
inset: 1rem;
border: 1px solid rgba(248, 251, 255, 0.18);
}
.tw-bootstrap-loader__ring::after {
inset: 2.3rem;
background: radial-gradient(circle, rgba(105, 215, 245, 0.38), rgba(7, 135, 187, 0.18) 45%, transparent 70%);
animation: tw-bootstrap-loader-pulse 2.4s ease-in-out var(--tw-loader-pulse-delay, 0ms) infinite;
}
.tw-bootstrap-loader__sweep {
width: 6rem;
height: 1.15rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent 0%, rgba(248, 251, 255, 0.92) 45%, var(--tw-loader-cyan) 100%);
box-shadow: 0 0 24px rgba(105, 215, 245, 0.48);
transform: rotate(-12deg);
animation: tw-bootstrap-loader-sweep 1.8s ease-in-out var(--tw-loader-sweep-delay, 0ms) infinite;
}
.tw-bootstrap-loader__spark {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--tw-loader-warm);
box-shadow: 0 0 18px rgba(244, 193, 93, 0.76);
opacity: 0.9;
}
.tw-bootstrap-loader__spark--one {
top: 1rem;
right: 1.3rem;
animation: tw-bootstrap-loader-spark 2.6s ease-in-out var(--tw-loader-spark-one-delay, 0ms) infinite;
}
.tw-bootstrap-loader__spark--two {
bottom: 1.4rem;
left: 1rem;
animation: tw-bootstrap-loader-spark 2.6s ease-in-out var(--tw-loader-spark-two-delay, 0.65s) infinite;
}
.tw-bootstrap-loader__status {
margin: 0;
color: var(--tw-loader-foam);
font-size: 1.2rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.25;
}
.tw-bootstrap-loader__hint {
max-width: 18rem;
margin: 0.55rem 0 0;
color: rgba(248, 251, 255, 0.72);
font-size: 0.9rem;
line-height: 1.45;
}
.tw-bootstrap-loader__dots {
display: inline-flex;
gap: 0.45rem;
height: 0.6rem;
margin-top: 1.35rem;
align-items: center;
}
.tw-bootstrap-loader__dots span {
display: block;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--tw-loader-cyan);
animation: tw-bootstrap-loader-dot 1.35s ease-in-out var(--tw-loader-dot-one-delay, 0ms) infinite;
}
.tw-bootstrap-loader__dots span:nth-child(2) {
animation-delay: var(--tw-loader-dot-two-delay, 0.18s);
}
.tw-bootstrap-loader__dots span:nth-child(3) {
animation-delay: var(--tw-loader-dot-three-delay, 0.36s);
}
@keyframes tw-bootstrap-loader-spin {
to {
transform: rotate(360deg);
}
}
@keyframes tw-bootstrap-loader-sweep {
0%,
100% {
opacity: 0.6;
transform: translateX(-0.65rem) rotate(-12deg);
}
50% {
opacity: 1;
transform: translateX(0.65rem) rotate(-12deg);
}
}
@keyframes tw-bootstrap-loader-pulse {
0%,
100% {
opacity: 0.7;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes tw-bootstrap-loader-spark {
0%,
100% {
opacity: 0.35;
transform: scale(0.7);
}
45% {
opacity: 1;
transform: scale(1);
}
}
@keyframes tw-bootstrap-loader-dot {
0%,
100% {
opacity: 0.35;
transform: translateY(0);
}
45% {
opacity: 1;
transform: translateY(-0.25rem);
}
}
@keyframes tw-bootstrap-loader-wash {
0%,
100% {
opacity: 0.42;
transform: translateX(-54%) rotate(-9deg);
}
50% {
opacity: 0.72;
transform: translateX(-46%) rotate(-9deg);
}
}
@media screen and (max-width: 480px) {
.tw-bootstrap-loader {
padding: 1rem;
}
.tw-bootstrap-loader__panel {
min-height: 25rem;
padding: 2rem 1.4rem;
}
.tw-bootstrap-loader__brand {
width: 11.5rem;
margin-bottom: 1.8rem;
}
.tw-bootstrap-loader__motion {
width: 7.25rem;
height: 7.25rem;
margin-bottom: 1.75rem;
}
.tw-bootstrap-loader__status {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tw-bootstrap-loader *,
.tw-bootstrap-loader *::before,
.tw-bootstrap-loader *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.tw-bootstrap-loader__sweep {
opacity: 0.95;
transform: rotate(-12deg);
}
.tw-bootstrap-loader__dots span {
opacity: 0.75;
transform: none;
}
}
</style>
</head>
<body>
<div id="app"></div>
<body class="tw-bootstrap-loading">
<div id="app">
<div
class="tw-bootstrap-loader"
data-testid="app-bootstrap-loading"
role="status"
aria-live="polite"
aria-label="Indlæser Truck Wash kundeportal"
>
<div class="tw-bootstrap-loader__ambient" aria-hidden="true">
<span class="tw-bootstrap-loader__wash tw-bootstrap-loader__wash--wide"></span>
<span class="tw-bootstrap-loader__wash tw-bootstrap-loader__wash--tight"></span>
</div>
<div class="tw-bootstrap-loader__panel">
<div class="tw-bootstrap-loader__brand">
<img
src="%BASE_URL%assets/branding/truckwash-banner-white-compressed.png"
alt="Truck Wash"
class="tw-bootstrap-loader__logo"
/>
</div>
<div class="tw-bootstrap-loader__motion" aria-hidden="true">
<span class="tw-bootstrap-loader__ring"></span>
<span class="tw-bootstrap-loader__sweep"></span>
<span class="tw-bootstrap-loader__spark tw-bootstrap-loader__spark--one"></span>
<span class="tw-bootstrap-loader__spark tw-bootstrap-loader__spark--two"></span>
</div>
<p class="tw-bootstrap-loader__status" data-testid="app-bootstrap-loading-status">Indlæser...</p>
<p class="tw-bootstrap-loader__hint">Vi gør kundeportalen klar</p>
<div class="tw-bootstrap-loader__dots" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<script>
(function () {
var appPath = window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, '') || '/';
var hasStoredSession = false;
try {
hasStoredSession = Boolean(window.localStorage && window.localStorage.getItem('token'));
} catch (error) {
hasStoredSession = false;
}
var usesProtectedSessionLoader = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/.test(appPath);
var usesGuestSessionLoader = /^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/.test(appPath);
var usesSessionLoader = usesProtectedSessionLoader || (hasStoredSession && usesGuestSessionLoader);
if (!usesSessionLoader) return;
var loader = document.querySelector('[data-testid="app-bootstrap-loading"]');
var status = document.querySelector('[data-testid="app-bootstrap-loading-status"]');
var hint = loader ? loader.querySelector('.tw-bootstrap-loader__hint') : null;
if (loader) loader.setAttribute('aria-label', 'Bekræfter bruger');
if (status) status.textContent = 'Bekræfter bruger...';
if (hint) hint.textContent = 'Vi indlæser din session';
})();
</script>
<script type="module" src="/src/releaseBootstrap.js"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule \.[^/]+$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>
@@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.truckwash.twa",
"sha256_cert_fingerprints": [
"29:56:F7:8D:BD:A0:2E:A9:32:82:97:28:A3:E2:65:16:23:73:DD:2C:16:F6:7A:97:AD:63:27:14:5C:8C:FB:89"
]
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pleno Component Test</title>
<script type="module" crossorigin src="/assets/index-6ljr7rTu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq5akw1E.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Truck Wash Kundeportal",
"short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [
{
"src": "assets/favicons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/favicons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"start_url": "/",
"display": "standalone",
"background_color": "#0787bb",
"theme_color": "#063651",
"orientation": "portrait",
"scope": "/"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+95 -16
View File
@@ -6,6 +6,7 @@ import favicon from "@/assets/favicon.ico";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import {
releaseChannelSwitchedStatus,
releaseChannelUnavailableStatus,
@@ -16,6 +17,20 @@ import {
const route = useRoute();
const { t, te, locale } = useI18n({ useScope: "global" });
const APP_TITLE = "Truck Wash";
const pageRouteLoadingProps = {
title: "Indlæser side...",
subtitle: "Vi gør siden klar",
testId: "app-route-loading",
statusTestId: "app-route-loading-status",
ariaLabel: "Indlæser side",
};
const sessionRouteLoadingProps = {
title: "Bekræfter bruger...",
subtitle: "Vi indlæser din session",
testId: "app-route-loading",
statusTestId: "app-route-loading-status",
ariaLabel: "Bekræfter bruger",
};
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
@@ -81,6 +96,42 @@ const shouldRenderReleaseChannelSwitched = computed(
const shouldHideGlobalOverlays = computed(
() => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value
);
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
const guestSessionLoaderRoutePattern =
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/;
const currentBrowserPath = () => {
if (typeof window === "undefined") {
return "";
}
return window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, "") || "/";
};
const routeMiddlewareList = () =>
route.matched.flatMap((record) => {
const middleware = record.meta?.middleware;
if (!middleware) {
return [];
}
return Array.isArray(middleware) ? middleware : [middleware];
});
const hasRouteMiddleware = (middlewareName) =>
routeMiddlewareList().some((middleware) => middleware?.name === middlewareName);
const shouldUseSessionPathLoader = (path) =>
sessionLoaderRoutePattern.test(path || "") &&
(!guestSessionLoaderRoutePattern.test(path || "") || hasStoredSessionToken());
const shouldUseGuestSessionPathLoader = (path) =>
guestSessionLoaderRoutePattern.test(path || "") && hasStoredSessionToken();
const shouldUseSessionRouteLoader = computed(() =>
route.name !== "default" &&
(shouldUseSessionPathLoader(route.path || "") ||
shouldUseSessionPathLoader(currentBrowserPath()) ||
shouldUseGuestSessionPathLoader(route.path || "") ||
shouldUseGuestSessionPathLoader(currentBrowserPath()) ||
hasRouteMiddleware("authMiddleware") ||
(hasStoredSessionToken() && hasRouteMiddleware("guestMiddleware")))
);
const appRouteLoadingProps = computed(() =>
shouldUseSessionRouteLoader.value ? sessionRouteLoadingProps : pageRouteLoadingProps
);
const initiateStoredSession = async () => {
if (!hasStoredSessionToken()) {
@@ -111,22 +162,50 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</script>
<template>
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<router-view />
</LayoutV2>
<main v-else-if="route.meta?.template === 'clear-main'">
<router-view />
</main>
<template v-else>
<header></header>
<main>
<DefaultPageWrapper>
<router-view />
</DefaultPageWrapper>
</main>
</template>
<Suspense :timeout="0">
<template #default>
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</LayoutV2>
<main v-else-if="route.meta?.template === 'clear-main'">
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</main>
<template v-else>
<header></header>
<main>
<DefaultPageWrapper>
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</DefaultPageWrapper>
</main>
</template>
</template>
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
@@ -149,6 +149,7 @@ const activeAttachmentId = ref(null);
const previewLoadingAttachmentId = ref(null);
const deletingAttachmentId = ref(null);
const previewSourcesById = ref({});
const vehicleLookupByRegistration = ref({});
const previewRequestsInFlight = new Set();
const generatedObjectUrls = new Set();
let previewStateGeneration = 0;
@@ -917,6 +918,7 @@ watch(isDropdownOpen, async (isOpen) => {
}
void loadCustomerRuleAttributes();
void resolveVisibleVehicleRegistrations();
void updateDropdownLayout();
});
@@ -1658,6 +1660,130 @@ const isLabelMenuItem = (item) => item?.type === "label";
const isSubsectionMenuItem = (item) => item?.type === "subsection";
const getSubsectionMenuItems = (section) => (section?.items || []).filter((item) => isSubsectionMenuItem(item));
const hasSubsectionMenuItems = (section) => getSubsectionMenuItems(section).length > 0;
const normalizeVehicleRegistration = (value) => String(value ?? "").trim().toUpperCase().replace(/\s+/g, "");
const vehicleRegistrationKey = (registration) =>
String(registration || "vehicle")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "vehicle";
const canUseSuperuserVehicleActions = () => SessionUser.canAccessSuperUser();
const canDeleteVehicleFromWheel = () =>
canUseSuperuserVehicleActions() && SessionUser.canAccessCustomerFeature("vehicles", "delete");
const visibleVehicleRegistrations = computed(() => {
const seen = new Set();
return [props.reg_1, props.reg_2, props.reg_3]
.map((registration) => normalizeVehicleRegistration(registration))
.filter((registration) => {
if (!registration || seen.has(registration)) {
return false;
}
seen.add(registration);
return true;
})
.map((registration) => ({
key: vehicleRegistrationKey(registration),
registration,
}));
});
const setVehicleLookupState = (registration, state) => {
vehicleLookupByRegistration.value = {
...vehicleLookupByRegistration.value,
[registration]: {
...(vehicleLookupByRegistration.value[registration] || {}),
...state,
},
};
};
const normalizeVehicleSearchRows = (response) => {
const rows = response?.data?.data ?? response?.data ?? response ?? [];
return Array.isArray(rows) ? rows : [];
};
const resolveVehicleRegistration = async (registration) => {
const normalizedRegistration = normalizeVehicleRegistration(registration);
if (!normalizedRegistration) {
return null;
}
const existing = vehicleLookupByRegistration.value[normalizedRegistration];
if (existing?.resolved || existing?.loading) {
return existing?.vehicle || null;
}
setVehicleLookupState(normalizedRegistration, {
error: null,
loading: true,
resolved: false,
});
try {
const response = await SessionUser.request("/vehicles/search", "GET", {
search: normalizedRegistration,
});
const vehicle = normalizeVehicleSearchRows(response).find((row) => {
const rowRegistration = normalizeVehicleRegistration(row?.reg);
const rowId = normalizePositiveInteger(row?.id);
return rowRegistration === normalizedRegistration && rowId !== null;
}) || null;
const resolvedVehicle = vehicle
? {
...vehicle,
id: normalizePositiveInteger(vehicle.id),
reg: normalizedRegistration,
}
: null;
setVehicleLookupState(normalizedRegistration, {
error: null,
loading: false,
resolved: true,
vehicle: resolvedVehicle,
});
return resolvedVehicle;
} catch (error) {
setVehicleLookupState(normalizedRegistration, {
error,
loading: false,
resolved: true,
vehicle: null,
});
return null;
}
};
const resolveVisibleVehicleRegistrations = async () => {
if (!canDeleteVehicleFromWheel()) {
return;
}
await Promise.all(visibleVehicleRegistrations.value.map(({ registration }) => resolveVehicleRegistration(registration)));
};
const getResolvedVehicleForRegistration = (registration) => {
const lookup = vehicleLookupByRegistration.value[normalizeVehicleRegistration(registration)];
return lookup?.vehicle || null;
};
const isVehicleDeleteDisabled = (registration) => {
if (!canDeleteVehicleFromWheel()) {
return true;
}
const lookup = vehicleLookupByRegistration.value[normalizeVehicleRegistration(registration)];
return Boolean(lookup?.loading) || normalizePositiveInteger(lookup?.vehicle?.id) === null;
};
const openSuperuserVehicle = (registration, newTab = false) => {
SessionUser.functions.redirectTo.superUser(`/vehicles/${encodeURIComponent(normalizeVehicleRegistration(registration))}`, newTab);
};
const showDeleteVehicleForm = async (registration) => {
const vehicle = getResolvedVehicleForRegistration(registration) || await resolveVehicleRegistration(registration);
const vehicleId = normalizePositiveInteger(vehicle?.id);
if (vehicleId === null) {
return;
}
await SessionUser.objects.vehicles.functions.showDeleteObjectForm(vehicleId, () => props.refreshFunction());
};
const normalizeExternalMenuItem = (item, itemKey) => {
if (!item || item.visible === false) {
@@ -2390,35 +2516,40 @@ const flatBuiltInMenuSections = computed(() => {
}
}
if (props.reg_1 || props.reg_2 || props.reg_3) {
if (visibleVehicleRegistrations.value.length > 0 && canUseSuperuserVehicleActions()) {
const vehicleSection = buildMenuSection(
"vehicle",
[props.reg_1, props.reg_2, props.reg_3].filter(Boolean).length > 1
visibleVehicleRegistrations.value.length > 1
? SessionUser.objects.vehicles.meta.labels.multiple
: SessionUser.objects.vehicles.meta.labels.single,
[
props.reg_1 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-1", {
visibleVehicleRegistrations.value.map(({ key, registration }) =>
buildMenuSubsection(`vehicle-${key}`, {
label: registration,
testId: `action-settings-wheel-vehicle-${key}`,
items: [
buildMenuAction(`vehicle-${key}-manage`, {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_1 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_1, true),
})
: null,
props.reg_2 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-2", {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_2 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
})
: null,
props.reg_3 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-3", {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_3 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_3, true),
})
: null,
]
label: t("admin.pos.settings_wheel.manage_vehicle"),
clickAction: () => openSuperuserVehicle(registration, false),
testId: `action-settings-wheel-vehicle-manage-${key}`,
}),
buildMenuAction(`vehicle-${key}-open-new-tab`, {
icon: "fas fa-external-link-alt",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab"),
clickAction: () => openSuperuserVehicle(registration, true),
testId: `action-settings-wheel-vehicle-open-${key}`,
}),
buildMenuAction(`vehicle-${key}-delete`, {
disabled: isVehicleDeleteDisabled(registration),
icon: "fas fa-trash",
label: t("admin.pos.settings_wheel.delete_vehicle"),
template: "danger",
clickAction: () => showDeleteVehicleForm(registration),
testId: `action-settings-wheel-vehicle-delete-${key}`,
}),
],
})
)
);
if (vehicleSection) {
@@ -506,6 +506,39 @@ function formatUsage(value) {
return `${Number(value).toFixed(1)}%`;
}
function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "--";
}
return new Intl.NumberFormat(locale.value || "da").format(Number(value));
}
function hasModuleUsage(module) {
return module?.usage && typeof module.usage === "object";
}
function boundedUsagePercent(value) {
const percent = Number(value);
if (!Number.isFinite(percent)) {
return 0;
}
return Math.max(0, Math.min(100, percent));
}
function usageProgressClass(value) {
const percent = Number(value);
if (!Number.isFinite(percent)) {
return "";
}
if (percent >= 100) {
return "is-danger";
}
if (percent >= 90) {
return "is-warning";
}
return "is-success";
}
function formatReplicationLabel(replication) {
if (!replication || typeof replication !== "object") {
return null;
@@ -906,6 +939,41 @@ function modulePath(key) {
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
</div>
<div
v-if="module.key === 'licenseplaterecognizer' && hasModuleUsage(module)"
class="module-card__usage"
:data-testid="`module-usage-${module.key}`"
>
<div class="module-card__usage-top">
<span>{{ $t("system_status.labels.quota_usage") }}</span>
<strong>{{ formatUsage(module.usage.usage_percent) }}</strong>
</div>
<progress
class="progress module-card__usage-progress"
:class="usageProgressClass(module.usage.usage_percent)"
:value="boundedUsagePercent(module.usage.usage_percent)"
max="100"
>
{{ formatUsage(module.usage.usage_percent) }}
</progress>
<div class="module-card__usage-grid">
<span>
<small>{{ $t("system_status.labels.calls_used") }}</small>
<strong>{{ formatNumber(module.usage.calls_used) }}</strong>
</span>
<span>
<small>{{ $t("system_status.labels.quota_calls") }}</small>
<strong>{{ formatNumber(module.usage.quota_calls) }}</strong>
</span>
<span>
<small>{{ $t("system_status.labels.calls_remaining") }}</small>
<strong>{{ formatNumber(module.usage.calls_remaining) }}</strong>
</span>
</div>
<small v-if="module.usage.version" class="module-card__usage-version">
{{ $t("system_status.labels.version") }}: {{ module.usage.version }}
</small>
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
@@ -1177,6 +1245,56 @@ function modulePath(key) {
color: #475569;
}
.module-card__usage {
display: grid;
gap: 0.45rem;
padding: 0.75rem;
border: 1px solid #d7dde7;
border-radius: 8px;
background: #f8fafc;
}
.module-card__usage-top {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
color: #334155;
font-size: 0.85rem;
}
.module-card__usage-progress {
margin: 0;
height: 0.55rem;
}
.module-card__usage-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.module-card__usage-grid span {
min-width: 0;
}
.module-card__usage-grid small {
display: block;
color: #64748b;
font-size: 0.72rem;
line-height: 1.25;
}
.module-card__usage-grid strong {
display: block;
color: #0f172a;
overflow-wrap: anywhere;
}
.module-card__usage-version {
color: #64748b;
}
.gateway-card__message {
margin: 0;
color: #7c2d12;
@@ -58,7 +58,9 @@ const reload = () => {
const canViewVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "list");
const canEditVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "edit");
const canDeleteVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "delete");
const canShowVehicleRowActions = () => canViewVehicle() || canDeleteVehicle();
const usesBuiltInVehicleWheelActions = (vehicle) =>
SessionUser.canAccessSuperUser() && String(vehicle?.reg || "").trim().length > 0;
const canShowVehicleRowActions = (vehicle) => usesBuiltInVehicleWheelActions(vehicle) || canViewVehicle() || canDeleteVehicle();
const isUserScoped = () => Boolean(props.userScopedUserId);
const redirectVehiclePage = (vehicle) => {
@@ -260,12 +262,12 @@ const deleteVehicle = (id) => {
<td v-if="!props.compact">
<!-- Actions stay grouped behind the wheel menu. -->
<ActionSettingsWheelButton
v-if="canShowVehicleRowActions()"
v-if="canShowVehicleRowActions(object)"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
>
<template #actions>
<template v-if="!usesBuiltInVehicleWheelActions(object)" #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
v-if="canViewVehicle()"
@@ -0,0 +1,516 @@
<script setup>
import { computed, onBeforeUnmount, watch } from "vue";
import truckWashLogo from "@/assets/branding/truckwash-banner-white-compressed.png";
const props = defineProps({
title: {
type: String,
default: "Loading...",
},
subtitle: {
type: String,
default: "",
},
testId: {
type: String,
default: "branded-loading-screen",
},
statusTestId: {
type: String,
default: "",
},
ariaLabel: {
type: String,
default: "",
},
lockScroll: {
type: Boolean,
default: true,
},
});
const resolvedAriaLabel = computed(() => props.ariaLabel || props.title || "Loading");
const SCROLL_LOCK_CLASS = "tw-loader-scroll-lock";
const SCROLL_LOCK_COUNT_KEY = "twLoaderScrollLocks";
const LOADER_START_GLOBAL_KEY = "__TW_LOADER_STARTED_AT__";
let hasScrollLock = false;
const currentTime = () => {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
};
const sharedLoaderStart = () => {
if (typeof window === "undefined") {
return currentTime();
}
const startedAt = Number(window[LOADER_START_GLOBAL_KEY]);
if (Number.isFinite(startedAt)) {
return startedAt;
}
const fallbackStartedAt = currentTime();
window[LOADER_START_GLOBAL_KEY] = fallbackStartedAt;
return fallbackStartedAt;
};
const animationDelay = (extraDelayMs = 0) => `${extraDelayMs - Math.max(0, currentTime() - sharedLoaderStart())}ms`;
const animationPhaseStyle = computed(() => ({
"--branded-loader-spin-delay": animationDelay(),
"--branded-loader-sweep-delay": animationDelay(),
"--branded-loader-pulse-delay": animationDelay(),
"--branded-loader-wash-wide-delay": animationDelay(),
"--branded-loader-wash-tight-delay": animationDelay(),
"--branded-loader-spark-one-delay": animationDelay(),
"--branded-loader-spark-two-delay": animationDelay(650),
"--branded-loader-dot-one-delay": animationDelay(),
"--branded-loader-dot-two-delay": animationDelay(180),
"--branded-loader-dot-three-delay": animationDelay(360),
}));
const pageRoot = () => (typeof document === "undefined" ? null : document.documentElement);
const scrollLockElements = () => {
if (typeof document === "undefined") {
return [];
}
return [document.documentElement, document.body].filter(Boolean);
};
const currentLockCount = (root) => {
const count = Number.parseInt(root?.dataset?.[SCROLL_LOCK_COUNT_KEY] || "0", 10);
return Number.isFinite(count) && count > 0 ? count : 0;
};
const lockPageScroll = () => {
const root = pageRoot();
if (!root || hasScrollLock) {
return;
}
root.dataset[SCROLL_LOCK_COUNT_KEY] = String(currentLockCount(root) + 1);
scrollLockElements().forEach((element) => element.classList.add(SCROLL_LOCK_CLASS));
hasScrollLock = true;
};
const unlockPageScroll = () => {
const root = pageRoot();
if (!root || !hasScrollLock) {
return;
}
const nextCount = Math.max(0, currentLockCount(root) - 1);
if (nextCount > 0) {
root.dataset[SCROLL_LOCK_COUNT_KEY] = String(nextCount);
} else {
delete root.dataset[SCROLL_LOCK_COUNT_KEY];
scrollLockElements().forEach((element) => element.classList.remove(SCROLL_LOCK_CLASS));
}
hasScrollLock = false;
};
watch(
() => props.lockScroll,
(shouldLock) => {
if (shouldLock) {
lockPageScroll();
} else {
unlockPageScroll();
}
},
{ immediate: true }
);
onBeforeUnmount(unlockPageScroll);
</script>
<template>
<div
class="branded-loading-screen"
:data-testid="testId"
role="status"
aria-live="polite"
:aria-label="resolvedAriaLabel"
:style="animationPhaseStyle"
>
<div class="branded-loading-screen__ambient" aria-hidden="true">
<span class="branded-loading-screen__wash branded-loading-screen__wash--wide"></span>
<span class="branded-loading-screen__wash branded-loading-screen__wash--tight"></span>
</div>
<div class="branded-loading-screen__panel">
<div class="branded-loading-screen__brand">
<img :src="truckWashLogo" alt="Truck Wash" class="branded-loading-screen__logo" />
</div>
<div class="branded-loading-screen__motion" aria-hidden="true">
<span class="branded-loading-screen__ring"></span>
<span class="branded-loading-screen__sweep"></span>
<span class="branded-loading-screen__spark branded-loading-screen__spark--one"></span>
<span class="branded-loading-screen__spark branded-loading-screen__spark--two"></span>
</div>
<p class="branded-loading-screen__status" :data-testid="statusTestId || undefined">
{{ title }}
</p>
<p v-if="subtitle" class="branded-loading-screen__hint">{{ subtitle }}</p>
<div class="branded-loading-screen__dots" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</template>
<style scoped>
:global(html.tw-loader-scroll-lock),
:global(body.tw-loader-scroll-lock) {
overflow: hidden;
scrollbar-width: none;
}
:global(html.tw-loader-scroll-lock) {
font-size: 16px !important;
}
:global(html.tw-loader-scroll-lock::-webkit-scrollbar),
:global(body.tw-loader-scroll-lock::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.branded-loading-screen {
--branded-loader-navy: #063651;
--branded-loader-blue: #0787bb;
--branded-loader-cyan: #69d7f5;
--branded-loader-foam: #f8fbff;
--branded-loader-warm: #f4c15d;
position: fixed;
inset: 0;
z-index: 100000;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
padding: 2rem;
box-sizing: border-box;
background:
radial-gradient(circle at 20% 20%, rgba(105, 215, 245, 0.18), transparent 28rem),
radial-gradient(circle at 82% 74%, rgba(244, 193, 93, 0.12), transparent 24rem),
linear-gradient(145deg, #041f32 0%, var(--branded-loader-navy) 52%, #04263d 100%);
color: var(--branded-loader-foam);
font-family: Avenir, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.branded-loading-screen__ambient {
position: absolute;
inset: 0;
pointer-events: none;
}
.branded-loading-screen__ambient::before {
position: absolute;
inset: 14% 9%;
content: "";
border: 1px solid rgba(248, 251, 255, 0.08);
border-radius: 8px;
}
.branded-loading-screen__wash {
position: absolute;
left: 50%;
display: block;
width: 72rem;
height: 8rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(105, 215, 245, 0.2), rgba(248, 251, 255, 0.18), transparent);
filter: blur(8px);
transform: translateX(-50%) rotate(-9deg);
}
.branded-loading-screen__wash--wide {
top: 26%;
animation: branded-loader-wash 5.8s ease-in-out var(--branded-loader-wash-wide-delay, 0ms) infinite;
}
.branded-loading-screen__wash--tight {
bottom: 22%;
width: 48rem;
height: 5rem;
animation: branded-loader-wash 6.8s ease-in-out var(--branded-loader-wash-tight-delay, 0ms) infinite reverse;
}
.branded-loading-screen__panel {
position: relative;
display: grid;
width: min(100%, 25rem);
min-height: 27rem;
align-content: center;
justify-items: center;
padding: 2.5rem 2rem 2.25rem;
box-sizing: border-box;
border: 1px solid rgba(248, 251, 255, 0.16);
border-radius: 8px;
background: rgba(4, 31, 50, 0.74);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32) !important;
text-align: center;
backdrop-filter: blur(18px);
}
.branded-loading-screen__panel::after {
position: absolute;
inset: 0;
content: "";
border-radius: inherit;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.08), transparent 38%);
pointer-events: none;
}
.branded-loading-screen__brand,
.branded-loading-screen__motion,
.branded-loading-screen__status,
.branded-loading-screen__hint,
.branded-loading-screen__dots {
position: relative;
z-index: 1;
}
.branded-loading-screen__brand {
display: grid;
width: 13rem;
min-height: 4rem;
place-items: center;
margin-bottom: 2.25rem;
}
.branded-loading-screen__logo {
display: block;
width: 100%;
max-height: 4rem;
object-fit: contain;
}
.branded-loading-screen__motion {
display: grid;
width: 8.5rem;
height: 8.5rem;
place-items: center;
margin-bottom: 2rem;
}
.branded-loading-screen__ring {
position: absolute;
inset: 0;
border: 2px solid rgba(248, 251, 255, 0.12);
border-top-color: var(--branded-loader-cyan);
border-right-color: rgba(7, 135, 187, 0.78);
border-radius: 50%;
animation: branded-loader-spin 1.8s linear var(--branded-loader-spin-delay, 0ms) infinite;
}
.branded-loading-screen__ring::before,
.branded-loading-screen__ring::after {
position: absolute;
content: "";
border-radius: 50%;
}
.branded-loading-screen__ring::before {
inset: 1rem;
border: 1px solid rgba(248, 251, 255, 0.18);
}
.branded-loading-screen__ring::after {
inset: 2.3rem;
background: radial-gradient(circle, rgba(105, 215, 245, 0.38), rgba(7, 135, 187, 0.18) 45%, transparent 70%);
animation: branded-loader-pulse 2.4s ease-in-out var(--branded-loader-pulse-delay, 0ms) infinite;
}
.branded-loading-screen__sweep {
width: 6rem;
height: 1.15rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent 0%, rgba(248, 251, 255, 0.92) 45%, var(--branded-loader-cyan) 100%);
box-shadow: 0 0 24px rgba(105, 215, 245, 0.48);
transform: rotate(-12deg);
animation: branded-loader-sweep 1.8s ease-in-out var(--branded-loader-sweep-delay, 0ms) infinite;
}
.branded-loading-screen__spark {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--branded-loader-warm);
box-shadow: 0 0 18px rgba(244, 193, 93, 0.76);
opacity: 0.9;
}
.branded-loading-screen__spark--one {
top: 1rem;
right: 1.3rem;
animation: branded-loader-spark 2.6s ease-in-out var(--branded-loader-spark-one-delay, 0ms) infinite;
}
.branded-loading-screen__spark--two {
bottom: 1.4rem;
left: 1rem;
animation: branded-loader-spark 2.6s ease-in-out var(--branded-loader-spark-two-delay, 0.65s) infinite;
}
.branded-loading-screen__status {
margin: 0;
color: var(--branded-loader-foam);
font-size: 1.2rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.25;
}
.branded-loading-screen__hint {
max-width: 18rem;
margin: 0.55rem 0 0;
color: rgba(248, 251, 255, 0.72);
font-size: 0.9rem;
line-height: 1.45;
}
.branded-loading-screen__dots {
display: inline-flex;
gap: 0.45rem;
height: 0.6rem;
margin-top: 1.35rem;
align-items: center;
}
.branded-loading-screen__dots span {
display: block;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--branded-loader-cyan);
animation: branded-loader-dot 1.35s ease-in-out var(--branded-loader-dot-one-delay, 0ms) infinite;
}
.branded-loading-screen__dots span:nth-child(2) {
animation-delay: var(--branded-loader-dot-two-delay, 0.18s);
}
.branded-loading-screen__dots span:nth-child(3) {
animation-delay: var(--branded-loader-dot-three-delay, 0.36s);
}
@keyframes branded-loader-spin {
to {
transform: rotate(360deg);
}
}
@keyframes branded-loader-sweep {
0%,
100% {
opacity: 0.6;
transform: translateX(-0.65rem) rotate(-12deg);
}
50% {
opacity: 1;
transform: translateX(0.65rem) rotate(-12deg);
}
}
@keyframes branded-loader-pulse {
0%,
100% {
opacity: 0.7;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes branded-loader-spark {
0%,
100% {
opacity: 0.35;
transform: scale(0.7);
}
45% {
opacity: 1;
transform: scale(1);
}
}
@keyframes branded-loader-dot {
0%,
100% {
opacity: 0.35;
transform: translateY(0);
}
45% {
opacity: 1;
transform: translateY(-0.25rem);
}
}
@keyframes branded-loader-wash {
0%,
100% {
opacity: 0.42;
transform: translateX(-54%) rotate(-9deg);
}
50% {
opacity: 0.72;
transform: translateX(-46%) rotate(-9deg);
}
}
@media screen and (max-width: 480px) {
.branded-loading-screen {
padding: 1rem;
}
.branded-loading-screen__panel {
min-height: 25rem;
padding: 2rem 1.4rem;
}
.branded-loading-screen__brand {
width: 11.5rem;
margin-bottom: 1.8rem;
}
.branded-loading-screen__motion {
width: 7.25rem;
height: 7.25rem;
margin-bottom: 1.75rem;
}
.branded-loading-screen__status {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.branded-loading-screen *,
.branded-loading-screen *::before,
.branded-loading-screen *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.branded-loading-screen__sweep {
opacity: 0.95;
transform: rotate(-12deg);
}
.branded-loading-screen__dots span {
opacity: 0.75;
transform: none;
}
}
</style>
@@ -4,6 +4,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { pingApiServer } from "@/services/apiHealth.js";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
const props = defineProps({
hasPermission: {
@@ -76,11 +77,14 @@ onBeforeUnmount(clearSessionTimeout);
<template>
<div>
<div v-if="!isSessionInitiated">
<div class="pageloader is-active is-dark">
<span class="title">Bekræfter bruger...</span>
</div>
</div>
<BrandedLoadingScreen
v-if="!isSessionInitiated"
title="Bekræfter bruger..."
subtitle="Vi indlæser din session"
test-id="restricted-auth-loading"
status-test-id="restricted-auth-loading-status"
aria-label="Bekræfter bruger"
/>
<slot v-else-if="props.hasPermission" />
<div v-else>
<div class="my-6">
@@ -131,5 +135,3 @@ onBeforeUnmount(clearSessionTimeout);
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,43 @@
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
const resolveSafeRedirectPath = (redirectValue) => {
const normalizedRedirectValue = Array.isArray(redirectValue) ? redirectValue[0] : redirectValue;
if (!normalizedRedirectValue || typeof window === "undefined") {
return null;
}
try {
const redirectUrl = new URL(normalizedRedirectValue, window.location.origin);
if (redirectUrl.origin !== window.location.origin) {
return null;
}
return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
} catch {
return null;
}
};
export const useStoredSessionGuestRedirect = ({ fallbackRoute = { name: "default" } } = {}) => {
const route = useRoute();
const router = useRouter();
const hasPendingStoredSession = computed(() => hasStoredSessionToken());
const redirectStoredSession = () => {
if (!hasStoredSessionToken()) {
return false;
}
const redirectPath = resolveSafeRedirectPath(route.query?.redirect);
void router.replace(redirectPath || fallbackRoute);
return true;
};
onMounted(redirectStoredSession);
return {
hasPendingStoredSession,
redirectStoredSession,
};
};
+13 -1
View File
@@ -1944,7 +1944,9 @@
"view_lane_new_tab": "@:{'words.generated.abn'} @:{'words.generated.bane'} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_lane_setup_new_tab": "@:{'words.generated.abn'} baneopsætning @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_order_new_tab": "@:{'words.generated.abn'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_vehicle_new_tab": "Åbn køretøjet {reg} i ny fane",
"manage_vehicle": "Administrer køretøj",
"view_vehicle_new_tab": "Åbn køretøj i ny fane",
"delete_vehicle": "Slet køretøj",
"accept_self_serve_wash": "Godkend selvbetjent vask",
"accept_self_serve_wash_confirm": "Flyt denne kladde til kunde #{customerNumber} med selvbetjeningsdetaljerne?",
"accept_self_serve_wash_success": "Den selvbetjente vask blev godkendt.",
@@ -5996,14 +5998,19 @@
"labels": {
"activity_window": "@:{'words.generated.aktiv'} @:{'words.generated.inden'} @:{'words.generated.for'} @:{'words.generated.de'} @:{'words.generated.seneste'} {minutes} @:{'words.generated.minutter'}",
"buckets_available": "{available} @:{'words.generated.af'} {total} @:{'words.generated.buckets'} @:{'words.generated.tilgængelige'}",
"calls_remaining": "Kald tilbage",
"calls_used": "Brugte kald",
"checked_at": "@.capitalize:{'words.generated.kontrolleret'}",
"configured": "@.capitalize:{'words.generated.konfigureret'}",
"enabled": "@:{'templates.generated.compat.common.enabled'}",
"latency": "Responstid",
"no_reason": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.yderligere'} @:{'words.generated.detaljer'}",
"quota_calls": "Kvote",
"quota_usage": "Kvoteudnyttelse",
"replication_percent": "{percent}% @:{'words.generated.replikering'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'}-@:{'words.generated.kilde'}",
"server_version": "Serverversion",
"version": "Version",
"warnings": "@:{'templates.generated.compat.global.warnings'}"
},
"modules": {
@@ -6036,6 +6043,11 @@
"http_status": "{label} @:{'words.generated.returnerede'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returnerede'} @:{'words.generated.et_2'} uventet @:{'words.generated.http'}-@:{'words.generated.svar'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.proben'} @:{'words.generated.returnerede'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ugyldig'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten er opbrugt ({used} af {quota} kald brugt).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten er næsten opbrugt: {percent}% ({used} af {quota} kald brugt).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-forbrug og kvote er tilgængelige: {used} af {quota} kald brugt.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerede ikke brugbare kvoteværdier.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerede et ulæseligt forbrugspayload.",
"missing_config": "@:{'words.generated.manglende'} @:{'words.generated.pakrævet'} @:{'words.generated.konfiguration'}: {variables}.",
"module_disabled": "Modulet @:{'words.generated.er'} @:{'words.generated.deaktiveret'}.",
"recaptcha_credentials_rejected": "{label}-@:{'words.generated.legitimationsoplysninger'} @:{'words.generated.blev'} @:{'words.generated.afvist'} @:{'words.generated.af'} @:{'words.generated.google'}.",
+13 -1
View File
@@ -2054,7 +2054,9 @@
"view_lane_new_tab": "@:{'words.generated.spur'} @:{'words.generated.in'} @:{'words.generated.neuem'} @:{'words.generated.tab'} @:{'words.generated.anzeigen'}",
"view_lane_setup_new_tab": "Spurkonfiguration @:{'words.generated.in'} @:{'words.generated.neuem'} @:{'words.generated.tab'} @:{'words.generated.anzeigen'}",
"view_order_new_tab": "@:{'words.generated.auftrag'} @:{'words.generated.in'} @:{'words.generated.neuem'} @:{'words.generated.tab'} @:{'words.generated.anzeigen'}",
"view_vehicle_new_tab": "@:{'words.generated.fahrzeug'} {reg} @:{'words.generated.in'} @:{'words.generated.neuem'} @:{'words.generated.tab'} @:{'words.generated.anzeigen'}",
"manage_vehicle": "Fahrzeug verwalten",
"view_vehicle_new_tab": "Fahrzeug in neuem Tab öffnen",
"delete_vehicle": "Fahrzeug löschen",
"accept_self_serve_wash": "Self-Service-Wäsche akzeptieren",
"accept_self_serve_wash_confirm": "Diesen Entwurf mit den Self-Service-Details zu Kunde #{customerNumber} verschieben?",
"accept_self_serve_wash_success": "Die Self-Service-Wäsche wurde akzeptiert.",
@@ -6106,14 +6108,19 @@
"labels": {
"activity_window": "@.capitalize:{'words.generated.active'} within @:{'words.generated.the'} @:{'words.generated.last'} {minutes} @:{'words.generated.minutes'}",
"buckets_available": "{available} @:{'words.generated.of'} {total} @:{'words.generated.buckets'} @:{'words.generated.available'}",
"calls_remaining": "Verbleibende Aufrufe",
"calls_used": "Verwendete Aufrufe",
"checked_at": "@.capitalize:{'words.generated.checked'} @:{'words.generated.at'}",
"configured": "@.capitalize:{'words.generated.configured'}",
"enabled": "@.capitalize:{'words.generated.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'words.generated.no'} @:{'words.generated.additional'} @:{'words.generated.details'}",
"quota_calls": "Kontingent",
"quota_usage": "Kontingentnutzung",
"replication_percent": "{percent}% @:{'words.generated.replication'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'} @:{'words.generated.source'}",
"server_version": "@.capitalize:{'words.generated.server'} @:{'words.generated.version'}",
"version": "Version",
"warnings": "Warnings"
},
"modules": {
@@ -6149,6 +6156,11 @@
"http_status": "{label} @:{'words.generated.returned'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.unexpected'} @:{'words.generated.http'} @:{'words.generated.response'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.probe'} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.invalid'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "Das License Plate Recognizer-Kontingent ist aufgebraucht ({used} von {quota} Aufrufen verwendet).",
"licenseplaterecognizer_quota_near_limit": "Die License Plate Recognizer-Kontingentnutzung ist nahe am Limit: {percent}% ({used} von {quota} Aufrufen verwendet).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-Nutzung und Kontingent sind verfügbar: {used} von {quota} Aufrufen verwendet.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer hat keine verwendbaren Kontingentwerte zurückgegeben.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer hat ein unlesbares Nutzungspayload zurückgegeben.",
"missing_config": "@.capitalize:{'words.generated.missing'} @:{'words.generated.required'} @:{'words.generated.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'words.generated.module'} @:{'words.generated.is'} @:{'words.generated.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'words.generated.credentials'} @:{'words.generated.were'} rejected @:{'words.generated.by'} @:{'words.generated.google'}.",
+13 -1
View File
@@ -1775,7 +1775,9 @@
"view_lane_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.lane'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_lane_setup_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.lane'} @:{'words.generated.setup'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_order_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_vehicle_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.vehicle'} {reg} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"manage_vehicle": "Manage vehicle",
"view_vehicle_new_tab": "Open vehicle in new tab",
"delete_vehicle": "Delete vehicle",
"accept_self_serve_wash": "Accept self-serve wash",
"accept_self_serve_wash_confirm": "Move this draft to customer #{customerNumber} with the self-serve details?",
"accept_self_serve_wash_success": "The self-serve wash was accepted.",
@@ -5827,14 +5829,19 @@
"labels": {
"activity_window": "@.capitalize:{'words.generated.active'} @:{'words.generated.within'} @:{'words.replication.article.host_mention'} @:{'words.generated.last'} {minutes} @:{'words.generated.minutes'}",
"buckets_available": "{available} @:{'words.generated.of'} {total} @:{'words.generated.buckets'} @:{'words.generated.available'}",
"calls_remaining": "Calls remaining",
"calls_used": "Calls used",
"checked_at": "@.capitalize:{'words.generated.checked'} @:{'words.generated.at'}",
"configured": "@.capitalize:{'words.generated.configured'}",
"enabled": "@:{'templates.generated.compat.common.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'words.generated.no'} @:{'words.generated.additional'} @:{'words.generated.details'}",
"quota_calls": "Quota",
"quota_usage": "Quota usage",
"replication_percent": "{percent}% @:{'words.generated.replication'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'} @:{'words.generated.source'}",
"server_version": "@.capitalize:{'words.generated.server'} @:{'words.generated.version'}",
"version": "Version",
"warnings": "@:{'templates.generated.compat.global.warnings'}"
},
"modules": {
@@ -5870,6 +5877,11 @@
"http_status": "{label} @:{'words.generated.returned'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.unexpected'} @:{'words.generated.http'} @:{'words.generated.response'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.probe'} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.invalid'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer quota is exhausted ({used} of {quota} calls used).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer quota usage is near the limit: {percent}% ({used} of {quota} calls used).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer usage and quota are available: {used} of {quota} calls used.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer did not return usable quota values.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returned an unreadable usage payload.",
"missing_config": "@.capitalize:{'words.generated.missing'} @:{'words.generated.required'} @:{'words.generated.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'words.generated.module'} @:{'words.generated.is'} @:{'words.generated.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'words.generated.credentials'} @:{'words.generated.were'} @:{'words.generated.rejected'} @:{'words.generated.by'} @:{'words.generated.google'}.",
+13
View File
@@ -511,6 +511,7 @@
"delete_booking": "@:{'templates.generated.compat.admin.pos.settings_wheel.delete_booking'}",
"delete_order": "@:pos.order.delete_order",
"delete_user": "@:{'templates.generated.compat.admin.pos.settings_wheel.delete_user'}",
"delete_vehicle": "@:{'templates.generated.compat.admin.pos.settings_wheel.delete_vehicle'}",
"download_invoice": "@:{'templates.generated.compat.admin.pos.settings_wheel.download_invoice'}",
"edit_permissions": "@:{'templates.generated.compat.admin.pos.settings_wheel.edit_permissions'}",
"enter_password": "@:{'templates.generated.compat.admin.pos.settings_wheel.enter_password'}",
@@ -524,6 +525,7 @@
"login_as_customer": "@:{'templates.generated.compat.admin.pos.settings_wheel.login_as_customer'}",
"login_as_user": "@:{'templates.generated.compat.admin.pos.settings_wheel.login_as_user'}",
"login_as_user_qr": "@:{'templates.generated.compat.admin.pos.settings_wheel.login_as_user_qr'}",
"manage_vehicle": "@:{'templates.generated.compat.admin.pos.settings_wheel.manage_vehicle'}",
"mark_as_completed": "@:global.mark_as_completed",
"no_actions_defined": "@:{'templates.generated.compat.admin.pos.settings_wheel.no_actions_defined'}",
"open_attached_file": "@:{'templates.generated.compat.admin.pos.settings_wheel.open_attached_file'}",
@@ -5640,6 +5642,7 @@
},
"employees": {
"create": "@:{'templates.generated.compat.superuser.pages.employees.create'}",
"migrate_to_limited_backoffice": "@:{'templates.generated.compat.superuser.pages.employees.migrate_to_limited_backoffice'}",
"subtitle": "@:{'templates.generated.compat.superuser.pages.employees.subtitle'}",
"title": "@:superuser.nav.employees"
},
@@ -5925,6 +5928,8 @@
"labels": {
"activity_window": "@:{'templates.generated.compat.system_status.labels.activity_window'}",
"buckets_available": "@:{'templates.generated.compat.system_status.labels.buckets_available'}",
"calls_remaining": "@:{'templates.generated.compat.system_status.labels.calls_remaining'}",
"calls_used": "@:{'templates.generated.compat.system_status.labels.calls_used'}",
"checked_at": "@:{'templates.generated.compat.system_status.labels.checked_at'}",
"configured": "@:{'templates.generated.compat.system_status.labels.configured'}",
"buckets": "@.capitalize:{'words.generated.buckets'}",
@@ -5933,9 +5938,12 @@
"endpoint": "@.capitalize:{'words.generated.endpoint'}",
"latency": "@:{'templates.generated.compat.system_status.labels.latency'}",
"no_reason": "@:{'templates.generated.compat.system_status.labels.no_reason'}",
"quota_calls": "@:{'templates.generated.compat.system_status.labels.quota_calls'}",
"quota_usage": "@:{'templates.generated.compat.system_status.labels.quota_usage'}",
"replication_percent": "@:{'templates.generated.compat.system_status.labels.replication_percent'}",
"runtime_source": "@:{'templates.generated.compat.system_status.labels.runtime_source'}",
"server_version": "@:{'templates.generated.compat.system_status.labels.server_version'}",
"version": "@:{'templates.generated.compat.system_status.labels.version'}",
"warnings": "@:{'templates.generated.compat.system_status.labels.warnings'}"
},
"modules": {
@@ -5977,6 +5985,11 @@
"http_status": "@:{'templates.generated.compat.system_status.reasons.http_status'}",
"http_unexpected_response": "@:{'templates.generated.compat.system_status.reasons.http_unexpected_response'}",
"invalid_probe_payload": "@:{'templates.generated.compat.system_status.reasons.invalid_probe_payload'}",
"licenseplaterecognizer_quota_exhausted": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_quota_exhausted'}",
"licenseplaterecognizer_quota_near_limit": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_quota_near_limit'}",
"licenseplaterecognizer_usage_available": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_usage_available'}",
"licenseplaterecognizer_usage_missing": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_usage_missing'}",
"licenseplaterecognizer_usage_unreadable": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_usage_unreadable'}",
"missing_config": "@:{'templates.generated.compat.system_status.reasons.missing_config'}",
"module_disabled": "@:{'templates.generated.compat.system_status.reasons.module_disabled'}",
"recaptcha_credentials_rejected": "@:{'templates.generated.compat.system_status.reasons.recaptcha_credentials_rejected'}",
+13 -1
View File
@@ -2057,7 +2057,9 @@
"view_lane_new_tab": "@.capitalize:{'words.generated.se'} kjørefelt @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_lane_setup_new_tab": "@.capitalize:{'words.generated.se'} kjørefeltoppsett @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_order_new_tab": "@.capitalize:{'words.generated.se'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_vehicle_new_tab": "@.capitalize:{'words.generated.se'} @:{'words.generated.kjøretøy'} {reg} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"manage_vehicle": "Administrer kjøretøy",
"view_vehicle_new_tab": "Åpne kjøretøy i ny fane",
"delete_vehicle": "Slett kjøretøy",
"accept_self_serve_wash": "Godta selvbetjent vask",
"accept_self_serve_wash_confirm": "Flytt denne kladden til kunde #{customerNumber} med selvbetjeningsdetaljene?",
"accept_self_serve_wash_success": "Den selvbetjente vasken ble godtatt.",
@@ -6109,14 +6111,19 @@
"labels": {
"activity_window": "@.capitalize:{'words.generated.active'} within @:{'words.generated.the'} @:{'words.generated.last'} {minutes} @:{'words.generated.minutes'}",
"buckets_available": "{available} @:{'words.generated.of'} {total} @:{'words.generated.buckets'} @:{'words.generated.available'}",
"calls_remaining": "Gjenstående kall",
"calls_used": "Brukte kall",
"checked_at": "@.capitalize:{'words.generated.checked'} @:{'words.generated.at'}",
"configured": "@.capitalize:{'words.generated.configured'}",
"enabled": "@.capitalize:{'words.generated.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'words.generated.no'} @:{'words.generated.additional'} details",
"quota_calls": "Kvote",
"quota_usage": "Kvote bruk",
"replication_percent": "{percent}% @:{'words.generated.replication'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'} @:{'words.generated.source'}",
"server_version": "@.capitalize:{'words.generated.server'} version",
"version": "Versjon",
"warnings": "Warnings"
},
"modules": {
@@ -6149,6 +6156,11 @@
"http_status": "{label} @:{'words.generated.returned'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.unexpected'} @:{'words.generated.http'} @:{'words.generated.response'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.probe'} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.invalid'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten er oppbrukt ({used} av {quota} kall brukt).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten er nær grensen: {percent}% ({used} av {quota} kall brukt).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-bruk og kvote er tilgjengelig: {used} av {quota} kall brukt.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerte ikke brukbare kvoteverdier.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerte et uleselig brukspayload.",
"missing_config": "@.capitalize:{'words.generated.missing'} @:{'words.generated.required'} @:{'words.generated.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'words.generated.module'} @:{'words.generated.is'} @:{'words.generated.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'words.generated.credentials'} @:{'words.generated.were'} rejected @:{'words.generated.by'} @:{'words.generated.google'}.",
+13 -1
View File
@@ -2107,7 +2107,9 @@
"view_lane_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.lane'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_lane_setup_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.lane'} @:{'words.generated.setup'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_order_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_vehicle_new_tab": "@.capitalize:{'words.generated.visa'} @:{'words.generated.fordon'} {reg} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.flik'}",
"manage_vehicle": "Hantera fordon",
"view_vehicle_new_tab": "Öppna fordon i ny flik",
"delete_vehicle": "Ta bort fordon",
"accept_self_serve_wash": "Godkänn självbetjäningstvätt",
"accept_self_serve_wash_confirm": "Flytta detta utkast till kund #{customerNumber} med självbetjäningsdetaljerna?",
"accept_self_serve_wash_success": "Självbetjäningstvätten godkändes.",
@@ -6159,14 +6161,19 @@
"labels": {
"activity_window": "@.capitalize:{'words.generated.active'} @:{'words.generated.within'} @:{'words.generated.the'} @:{'words.generated.last_2'} {minutes} @:{'words.generated.minutes'}",
"buckets_available": "{available} @:{'words.generated.of'} {total} @:{'words.generated.buckets'} @:{'words.generated.available'}",
"calls_remaining": "Återstående anrop",
"calls_used": "Använda anrop",
"checked_at": "@.capitalize:{'words.generated.checked'} @:{'words.generated.at'}",
"configured": "@.capitalize:{'words.generated.configured'}",
"enabled": "@.capitalize:{'words.generated.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'words.generated.no'} @:{'words.generated.additional'} @:{'words.generated.details'}",
"quota_calls": "Kvot",
"quota_usage": "Kvotanvändning",
"replication_percent": "{percent}% @:{'words.generated.replication'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'} @:{'words.generated.source'}",
"server_version": "@.capitalize:{'words.generated.server'} @:{'words.generated.version'}",
"version": "Version",
"warnings": "@:{'templates.generated.compat.global.warnings'}"
},
"modules": {
@@ -6202,6 +6209,11 @@
"http_status": "{label} @:{'words.generated.returned'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.unexpected'} @:{'words.generated.http'} @:{'words.generated.response'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.probe'} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.invalid'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten är slut ({used} av {quota} anrop använda).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten är nära gränsen: {percent}% ({used} av {quota} anrop använda).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-användning och kvot är tillgängliga: {used} av {quota} anrop använda.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerade inga användbara kvotvärden.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerade en oläsbar användningspayload.",
"missing_config": "@.capitalize:{'words.generated.missing'} @:{'words.generated.required'} @:{'words.generated.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'words.generated.module'} @:{'words.generated.is'} @:{'words.generated.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'words.generated.credentials'} @:{'words.generated.were'} rejected @:{'words.generated.by'} @:{'words.generated.google'}.",
@@ -29,7 +29,9 @@
"view_lane_new_tab": "@:{'terms.glossary.abn'} @:{'terms.glossary.bane'} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_lane_setup_new_tab": "@:{'terms.glossary.abn'} baneopsætning @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_order_new_tab": "@:{'terms.glossary.abn'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_vehicle_new_tab": "Åbn køretøjet {reg} i ny fane"
"manage_vehicle": "Administrer køretøj",
"view_vehicle_new_tab": "Åbn køretøj i ny fane",
"delete_vehicle": "Slet køretøj"
},
"show_details": "@:{'terms.glossary.vis'} @:{'terms.glossary.detaljer'}",
"show_order_details": "@:{'terms.glossary.vis'} @:{'terms.glossary.ordredetaljer'}",
@@ -43,14 +43,19 @@
"labels": {
"activity_window": "@:{'terms.glossary.aktiv'} @:{'terms.glossary.inden'} @:{'terms.glossary.for'} @:{'terms.glossary.de'} @:{'terms.glossary.seneste'} {minutes} @:{'terms.glossary.minutter'}",
"buckets_available": "{available} @:{'terms.glossary.af'} {total} @:{'terms.glossary.buckets'} @:{'terms.glossary.tilgængelige'}",
"calls_remaining": "Kald tilbage",
"calls_used": "Brugte kald",
"checked_at": "@.capitalize:{'terms.glossary.kontrolleret'}",
"configured": "@.capitalize:{'terms.glossary.konfigureret'}",
"enabled": "@:{'phrases.compat.common.enabled'}",
"latency": "Responstid",
"no_reason": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.yderligere'} @:{'terms.glossary.detaljer'}",
"quota_calls": "Kvote",
"quota_usage": "Kvoteudnyttelse",
"replication_percent": "{percent}% @:{'terms.glossary.replikering'} ({status})",
"runtime_source": "@.capitalize:{'terms.glossary.runtime'}-@:{'terms.glossary.kilde'}",
"server_version": "Serverversion",
"version": "Version",
"warnings": "@:{'phrases.compat.global.warnings'}"
},
"modules": {
@@ -83,6 +88,11 @@
"http_status": "{label} @:{'terms.glossary.returnerede'} @:{'terms.glossary.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'terms.glossary.returnerede'} @:{'terms.glossary.et_2'} uventet @:{'terms.glossary.http'}-@:{'terms.glossary.svar'}.",
"invalid_probe_payload": "@.capitalize:{'terms.glossary.proben'} @:{'terms.glossary.returnerede'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ugyldig'} @:{'terms.glossary.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten er opbrugt ({used} af {quota} kald brugt).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten er næsten opbrugt: {percent}% ({used} af {quota} kald brugt).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-forbrug og kvote er tilgængelige: {used} af {quota} kald brugt.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerede ikke brugbare kvoteværdier.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerede et ulæseligt forbrugspayload.",
"missing_config": "@:{'terms.glossary.manglende'} @:{'terms.glossary.pakrævet'} @:{'terms.glossary.konfiguration'}: {variables}.",
"module_disabled": "Modulet @:{'terms.glossary.er'} @:{'terms.glossary.deaktiveret'}.",
"recaptcha_credentials_rejected": "{label}-@:{'terms.glossary.legitimationsoplysninger'} @:{'terms.glossary.blev'} @:{'terms.glossary.afvist'} @:{'terms.glossary.af'} @:{'terms.glossary.google'}.",
@@ -29,7 +29,9 @@
"view_lane_new_tab": "@:{'terms.glossary.spur'} @:{'terms.glossary.in'} @:{'terms.glossary.neuem'} @:{'terms.glossary.tab'} @:{'terms.glossary.anzeigen'}",
"view_lane_setup_new_tab": "Spurkonfiguration @:{'terms.glossary.in'} @:{'terms.glossary.neuem'} @:{'terms.glossary.tab'} @:{'terms.glossary.anzeigen'}",
"view_order_new_tab": "@:{'terms.glossary.auftrag'} @:{'terms.glossary.in'} @:{'terms.glossary.neuem'} @:{'terms.glossary.tab'} @:{'terms.glossary.anzeigen'}",
"view_vehicle_new_tab": "@:{'terms.glossary.fahrzeug'} {reg} @:{'terms.glossary.in'} @:{'terms.glossary.neuem'} @:{'terms.glossary.tab'} @:{'terms.glossary.anzeigen'}"
"manage_vehicle": "Fahrzeug verwalten",
"view_vehicle_new_tab": "Fahrzeug in neuem Tab öffnen",
"delete_vehicle": "Fahrzeug löschen"
},
"show_details": "@.capitalize:{'terms.glossary.details'} @:{'terms.glossary.anzeigen'}",
"show_order_details": "@:{'terms.glossary.auftragsdetails'} @:{'terms.glossary.anzeigen'}",
@@ -43,14 +43,19 @@
"labels": {
"activity_window": "@.capitalize:{'terms.glossary.active'} within @:{'terms.glossary.the'} @:{'terms.glossary.last'} {minutes} @:{'terms.glossary.minutes'}",
"buckets_available": "{available} @:{'terms.glossary.of'} {total} @:{'terms.glossary.buckets'} @:{'terms.glossary.available'}",
"calls_remaining": "Verbleibende Aufrufe",
"calls_used": "Verwendete Aufrufe",
"checked_at": "@.capitalize:{'terms.glossary.checked'} @:{'terms.glossary.at'}",
"configured": "@.capitalize:{'terms.glossary.configured'}",
"enabled": "@.capitalize:{'terms.glossary.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.additional'} @:{'terms.glossary.details'}",
"quota_calls": "Kontingent",
"quota_usage": "Kontingentnutzung",
"replication_percent": "{percent}% @:{'terms.glossary.replication'} ({status})",
"runtime_source": "@.capitalize:{'terms.glossary.runtime'} @:{'terms.glossary.source'}",
"server_version": "@.capitalize:{'terms.glossary.server'} @:{'terms.glossary.version'}",
"version": "Version",
"warnings": "Warnings"
},
"modules": {
@@ -86,6 +91,11 @@
"http_status": "{label} @:{'terms.glossary.returned'} @:{'terms.glossary.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'terms.glossary.returned'} @:{'terms.glossary.an'} @:{'terms.glossary.unexpected'} @:{'terms.glossary.http'} @:{'terms.glossary.response'}.",
"invalid_probe_payload": "@.capitalize:{'terms.glossary.probe'} @:{'terms.glossary.returned'} @:{'terms.glossary.an'} @:{'terms.glossary.invalid'} @:{'terms.glossary.payload'}.",
"licenseplaterecognizer_quota_exhausted": "Das License Plate Recognizer-Kontingent ist aufgebraucht ({used} von {quota} Aufrufen verwendet).",
"licenseplaterecognizer_quota_near_limit": "Die License Plate Recognizer-Kontingentnutzung ist nahe am Limit: {percent}% ({used} von {quota} Aufrufen verwendet).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-Nutzung und Kontingent sind verfügbar: {used} von {quota} Aufrufen verwendet.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer hat keine verwendbaren Kontingentwerte zurückgegeben.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer hat ein unlesbares Nutzungspayload zurückgegeben.",
"missing_config": "@.capitalize:{'terms.glossary.missing'} @:{'terms.glossary.required'} @:{'terms.glossary.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'terms.glossary.module'} @:{'terms.glossary.is'} @:{'terms.glossary.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'terms.glossary.credentials'} @:{'terms.glossary.were'} rejected @:{'terms.glossary.by'} @:{'terms.glossary.google'}.",
@@ -29,7 +29,9 @@
"view_lane_new_tab": "@.capitalize:{'terms.glossary.view'} @:{'terms.glossary.lane'} @:{'terms.glossary.in'} @:{'terms.glossary.new'} @:{'terms.glossary.tab'}",
"view_lane_setup_new_tab": "@.capitalize:{'terms.glossary.view'} @:{'terms.glossary.lane'} @:{'terms.glossary.setup'} @:{'terms.glossary.in'} @:{'terms.glossary.new'} @:{'terms.glossary.tab'}",
"view_order_new_tab": "@.capitalize:{'terms.glossary.view'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.new'} @:{'terms.glossary.tab'}",
"view_vehicle_new_tab": "@.capitalize:{'terms.glossary.view'} @:{'terms.glossary.vehicle'} {reg} @:{'terms.glossary.in'} @:{'terms.glossary.new'} @:{'terms.glossary.tab'}"
"manage_vehicle": "Manage vehicle",
"view_vehicle_new_tab": "Open vehicle in new tab",
"delete_vehicle": "Delete vehicle"
},
"show_details": "@.capitalize:{'terms.glossary.show'} @:{'terms.glossary.details'}",
"show_order_details": "@.capitalize:{'terms.glossary.show'} @:{'terms.glossary.order'} @:{'terms.glossary.details'}",
@@ -43,14 +43,19 @@
"labels": {
"activity_window": "@.capitalize:{'terms.glossary.active'} @:{'terms.glossary.within'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.last'} {minutes} @:{'terms.glossary.minutes'}",
"buckets_available": "{available} @:{'terms.glossary.of'} {total} @:{'terms.glossary.buckets'} @:{'terms.glossary.available'}",
"calls_remaining": "Calls remaining",
"calls_used": "Calls used",
"checked_at": "@.capitalize:{'terms.glossary.checked'} @:{'terms.glossary.at'}",
"configured": "@.capitalize:{'terms.glossary.configured'}",
"enabled": "@:{'phrases.compat.common.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.additional'} @:{'terms.glossary.details'}",
"quota_calls": "Quota",
"quota_usage": "Quota usage",
"replication_percent": "{percent}% @:{'terms.glossary.replication'} ({status})",
"runtime_source": "@.capitalize:{'terms.glossary.runtime'} @:{'terms.glossary.source'}",
"server_version": "@.capitalize:{'terms.glossary.server'} @:{'terms.glossary.version'}",
"version": "Version",
"warnings": "@:{'phrases.compat.global.warnings'}"
},
"modules": {
@@ -86,6 +91,11 @@
"http_status": "{label} @:{'terms.glossary.returned'} @:{'terms.glossary.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'terms.glossary.returned'} @:{'terms.glossary.an'} @:{'terms.glossary.unexpected'} @:{'terms.glossary.http'} @:{'terms.glossary.response'}.",
"invalid_probe_payload": "@.capitalize:{'terms.glossary.probe'} @:{'terms.glossary.returned'} @:{'terms.glossary.an'} @:{'terms.glossary.invalid'} @:{'terms.glossary.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer quota is exhausted ({used} of {quota} calls used).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer quota usage is near the limit: {percent}% ({used} of {quota} calls used).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer usage and quota are available: {used} of {quota} calls used.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer did not return usable quota values.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returned an unreadable usage payload.",
"missing_config": "@.capitalize:{'terms.glossary.missing'} @:{'terms.glossary.required'} @:{'terms.glossary.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'terms.glossary.module'} @:{'terms.glossary.is'} @:{'terms.glossary.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'terms.glossary.credentials'} @:{'terms.glossary.were'} @:{'terms.glossary.rejected'} @:{'terms.glossary.by'} @:{'terms.glossary.google'}.",
@@ -51,6 +51,7 @@
"delete_booking": "@:{'phrases.compat.admin.pos.settings_wheel.delete_booking'}",
"delete_order": "@:pos.order.delete_order",
"delete_user": "@:{'phrases.compat.admin.pos.settings_wheel.delete_user'}",
"delete_vehicle": "@:{'phrases.compat.admin.pos.settings_wheel.delete_vehicle'}",
"download_invoice": "@:{'phrases.compat.admin.pos.settings_wheel.download_invoice'}",
"edit_permissions": "@:{'phrases.compat.admin.pos.settings_wheel.edit_permissions'}",
"enter_password": "@:{'phrases.compat.admin.pos.settings_wheel.enter_password'}",
@@ -64,6 +65,7 @@
"login_as_customer": "@:{'phrases.compat.admin.pos.settings_wheel.login_as_customer'}",
"login_as_user": "@:{'phrases.compat.admin.pos.settings_wheel.login_as_user'}",
"login_as_user_qr": "@:{'phrases.compat.admin.pos.settings_wheel.login_as_user_qr'}",
"manage_vehicle": "@:{'phrases.compat.admin.pos.settings_wheel.manage_vehicle'}",
"mark_as_completed": "@:global.mark_as_completed",
"no_actions_defined": "@:{'phrases.compat.admin.pos.settings_wheel.no_actions_defined'}",
"open_attached_file": "@:{'phrases.compat.admin.pos.settings_wheel.open_attached_file'}",
@@ -222,6 +222,7 @@
},
"employees": {
"create": "@:{'phrases.compat.superuser.pages.employees.create'}",
"migrate_to_limited_backoffice": "@:{'phrases.compat.superuser.pages.employees.migrate_to_limited_backoffice'}",
"subtitle": "@:{'phrases.compat.superuser.pages.employees.subtitle'}",
"title": "@:superuser.nav.employees"
},
@@ -58,6 +58,8 @@
"labels": {
"activity_window": "@:{'phrases.compat.system_status.labels.activity_window'}",
"buckets_available": "@:{'phrases.compat.system_status.labels.buckets_available'}",
"calls_remaining": "@:{'phrases.compat.system_status.labels.calls_remaining'}",
"calls_used": "@:{'phrases.compat.system_status.labels.calls_used'}",
"checked_at": "@:{'phrases.compat.system_status.labels.checked_at'}",
"configured": "@:{'phrases.compat.system_status.labels.configured'}",
"buckets": "@.capitalize:{'terms.glossary.buckets'}",
@@ -66,9 +68,12 @@
"endpoint": "@.capitalize:{'terms.glossary.endpoint'}",
"latency": "@:{'phrases.compat.system_status.labels.latency'}",
"no_reason": "@:{'phrases.compat.system_status.labels.no_reason'}",
"quota_calls": "@:{'phrases.compat.system_status.labels.quota_calls'}",
"quota_usage": "@:{'phrases.compat.system_status.labels.quota_usage'}",
"replication_percent": "@:{'phrases.compat.system_status.labels.replication_percent'}",
"runtime_source": "@:{'phrases.compat.system_status.labels.runtime_source'}",
"server_version": "@:{'phrases.compat.system_status.labels.server_version'}",
"version": "@:{'phrases.compat.system_status.labels.version'}",
"warnings": "@:{'phrases.compat.system_status.labels.warnings'}"
},
"modules": {
@@ -110,6 +115,11 @@
"http_status": "@:{'phrases.compat.system_status.reasons.http_status'}",
"http_unexpected_response": "@:{'phrases.compat.system_status.reasons.http_unexpected_response'}",
"invalid_probe_payload": "@:{'phrases.compat.system_status.reasons.invalid_probe_payload'}",
"licenseplaterecognizer_quota_exhausted": "@:{'phrases.compat.system_status.reasons.licenseplaterecognizer_quota_exhausted'}",
"licenseplaterecognizer_quota_near_limit": "@:{'phrases.compat.system_status.reasons.licenseplaterecognizer_quota_near_limit'}",
"licenseplaterecognizer_usage_available": "@:{'phrases.compat.system_status.reasons.licenseplaterecognizer_usage_available'}",
"licenseplaterecognizer_usage_missing": "@:{'phrases.compat.system_status.reasons.licenseplaterecognizer_usage_missing'}",
"licenseplaterecognizer_usage_unreadable": "@:{'phrases.compat.system_status.reasons.licenseplaterecognizer_usage_unreadable'}",
"missing_config": "@:{'phrases.compat.system_status.reasons.missing_config'}",
"module_disabled": "@:{'phrases.compat.system_status.reasons.module_disabled'}",
"recaptcha_credentials_rejected": "@:{'phrases.compat.system_status.reasons.recaptcha_credentials_rejected'}",
@@ -29,7 +29,9 @@
"view_lane_new_tab": "@.capitalize:{'terms.glossary.se'} kjørefelt @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_lane_setup_new_tab": "@.capitalize:{'terms.glossary.se'} kjørefeltoppsett @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_order_new_tab": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_vehicle_new_tab": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.kjøretøy'} {reg} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}"
"manage_vehicle": "Administrer kjøretøy",
"view_vehicle_new_tab": "Åpne kjøretøy i ny fane",
"delete_vehicle": "Slett kjøretøy"
},
"show_details": "@:{'terms.glossary.vis'} @:{'terms.glossary.detaljer'}",
"show_order_details": "@:{'terms.glossary.vis'} @:{'terms.glossary.bestillingsdetaljer'}",
@@ -43,14 +43,19 @@
"labels": {
"activity_window": "@.capitalize:{'terms.glossary.active'} within @:{'terms.glossary.the'} @:{'terms.glossary.last'} {minutes} @:{'terms.glossary.minutes'}",
"buckets_available": "{available} @:{'terms.glossary.of'} {total} @:{'terms.glossary.buckets'} @:{'terms.glossary.available'}",
"calls_remaining": "Gjenstående kall",
"calls_used": "Brukte kall",
"checked_at": "@.capitalize:{'terms.glossary.checked'} @:{'terms.glossary.at'}",
"configured": "@.capitalize:{'terms.glossary.configured'}",
"enabled": "@.capitalize:{'terms.glossary.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.additional'} details",
"quota_calls": "Kvote",
"quota_usage": "Kvote bruk",
"replication_percent": "{percent}% @:{'terms.glossary.replication'} ({status})",
"runtime_source": "@.capitalize:{'terms.glossary.runtime'} @:{'terms.glossary.source'}",
"server_version": "@.capitalize:{'terms.glossary.server'} version",
"version": "Versjon",
"warnings": "Warnings"
},
"modules": {
@@ -83,6 +88,11 @@
"http_status": "{label} @:{'terms.glossary.returned'} @:{'terms.glossary.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'terms.glossary.returned'} @:{'terms.glossary.an'} @:{'terms.glossary.unexpected'} @:{'terms.glossary.http'} @:{'terms.glossary.response'}.",
"invalid_probe_payload": "@.capitalize:{'terms.glossary.probe'} @:{'terms.glossary.returned'} @:{'terms.glossary.an'} @:{'terms.glossary.invalid'} @:{'terms.glossary.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten er oppbrukt ({used} av {quota} kall brukt).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten er nær grensen: {percent}% ({used} av {quota} kall brukt).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-bruk og kvote er tilgjengelig: {used} av {quota} kall brukt.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerte ikke brukbare kvoteverdier.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerte et uleselig brukspayload.",
"missing_config": "@.capitalize:{'terms.glossary.missing'} @:{'terms.glossary.required'} @:{'terms.glossary.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'terms.glossary.module'} @:{'terms.glossary.is'} @:{'terms.glossary.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'terms.glossary.credentials'} @:{'terms.glossary.were'} rejected @:{'terms.glossary.by'} @:{'terms.glossary.google'}.",
@@ -29,7 +29,9 @@
"view_lane_new_tab": "@.capitalize:{'terms.glossary.view'} @:{'terms.glossary.lane'} @:{'terms.glossary.in'} @:{'terms.glossary.new'} @:{'terms.glossary.tab'}",
"view_lane_setup_new_tab": "@.capitalize:{'terms.glossary.view'} @:{'terms.glossary.lane'} @:{'terms.glossary.setup'} @:{'terms.glossary.in'} @:{'terms.glossary.new'} @:{'terms.glossary.tab'}",
"view_order_new_tab": "@.capitalize:{'terms.glossary.view'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.new'} @:{'terms.glossary.tab'}",
"view_vehicle_new_tab": "@.capitalize:{'terms.glossary.visa'} @:{'terms.glossary.fordon'} {reg} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.flik'}"
"manage_vehicle": "Hantera fordon",
"view_vehicle_new_tab": "Öppna fordon i ny flik",
"delete_vehicle": "Ta bort fordon"
},
"show_details": "@:{'terms.glossary.show'} @:{'terms.glossary.details'}",
"show_order_details": "@:{'terms.glossary.show'} @:{'terms.glossary.order'} @:{'terms.glossary.details'}",
@@ -43,14 +43,19 @@
"labels": {
"activity_window": "@.capitalize:{'terms.glossary.active'} @:{'terms.glossary.within'} @:{'terms.glossary.the'} @:{'terms.glossary.last_2'} {minutes} @:{'terms.glossary.minutes'}",
"buckets_available": "{available} @:{'terms.glossary.of'} {total} @:{'terms.glossary.buckets'} @:{'terms.glossary.available'}",
"calls_remaining": "Återstående anrop",
"calls_used": "Använda anrop",
"checked_at": "@.capitalize:{'terms.glossary.checked'} @:{'terms.glossary.at'}",
"configured": "@.capitalize:{'terms.glossary.configured'}",
"enabled": "@.capitalize:{'terms.glossary.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.additional'} @:{'terms.glossary.details'}",
"quota_calls": "Kvot",
"quota_usage": "Kvotanvändning",
"replication_percent": "{percent}% @:{'terms.glossary.replication'} ({status})",
"runtime_source": "@.capitalize:{'terms.glossary.runtime'} @:{'terms.glossary.source'}",
"server_version": "@.capitalize:{'terms.glossary.server'} @:{'terms.glossary.version'}",
"version": "Version",
"warnings": "@:{'phrases.compat.global.warnings'}"
},
"modules": {
@@ -86,6 +91,11 @@
"http_status": "{label} @:{'terms.glossary.returned'} @:{'terms.glossary.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'terms.glossary.returned'} @:{'terms.glossary.an'} @:{'terms.glossary.unexpected'} @:{'terms.glossary.http'} @:{'terms.glossary.response'}.",
"invalid_probe_payload": "@.capitalize:{'terms.glossary.probe'} @:{'terms.glossary.returned'} @:{'terms.glossary.an'} @:{'terms.glossary.invalid'} @:{'terms.glossary.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten är slut ({used} av {quota} anrop använda).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten är nära gränsen: {percent}% ({used} av {quota} anrop använda).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-användning och kvot är tillgängliga: {used} av {quota} anrop använda.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerade inga användbara kvotvärden.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerade en oläsbar användningspayload.",
"missing_config": "@.capitalize:{'terms.glossary.missing'} @:{'terms.glossary.required'} @:{'terms.glossary.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'terms.glossary.module'} @:{'terms.glossary.is'} @:{'terms.glossary.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'terms.glossary.credentials'} @:{'terms.glossary.were'} rejected @:{'terms.glossary.by'} @:{'terms.glossary.google'}.",
+15
View File
@@ -125,6 +125,21 @@ const app = createApp(App)
installReleaseErrorInstrumentation(app, Router);
app.mount("#app");
const releaseBootstrapScrollLock = () => {
document.documentElement?.classList.remove("tw-bootstrap-loading");
document.body?.classList.remove("tw-bootstrap-loading");
};
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(releaseBootstrapScrollLock);
} else {
releaseBootstrapScrollLock();
}
if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
window.setTimeout(releaseBootstrapScrollLock, 0);
}
void Router.isReady().finally(() => {
initializeAutoTableExports();
});
+1 -1
View File
@@ -228,7 +228,7 @@ export const router = createRouter({
name: 'landing',
path: '/',
component: LandingPage,
meta: { template: 'with-header' }
meta: { template: 'with-header', middleware: guestMiddleware }
},
{
name: 'book-a-demo',
+30 -5
View File
@@ -1,6 +1,6 @@
<script setup>
import PageLoader from "@/components/global/PageLoader.vue";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import { useRouter } from "vue-router";
@@ -354,16 +354,41 @@ onUnmounted(() => {
<template>
<div>
<PageLoader title="Omdirigerer..." v-if="!attemptAutoSelectDepartment"/>
<BrandedLoadingScreen
v-if="!attemptAutoSelectDepartment"
title="Omdirigerer..."
subtitle="Vi finder den rigtige side for dig"
test-id="redirect-loading"
status-test-id="redirect-loading-status"
aria-label="Omdirigerer"
/>
<template v-if="attemptAutoSelectDepartment">
<div v-if="!SessionUser.isInitiated()">
<PageLoader title="Indlæser din session..."/>
<BrandedLoadingScreen
title="Indlæser din session..."
subtitle="Vi henter dine adgangsrettigheder"
test-id="redirect-session-loading"
status-test-id="redirect-session-loading-status"
aria-label="Indlæser din session"
/>
</div>
<div v-else-if="!SessionUser.hasToken()">
<PageLoader title="Omdirigerer til login..."/>
<BrandedLoadingScreen
title="Omdirigerer til login..."
subtitle="Du sendes videre til login"
test-id="redirect-login-loading"
status-test-id="redirect-login-loading-status"
aria-label="Omdirigerer til login"
/>
</div>
<div v-else-if="SessionUser.isInitiated()">
<PageLoader :title="`Finder din nærmeste afdeling...`"/>
<BrandedLoadingScreen
title="Finder din nærmeste afdeling..."
subtitle="Vi bruger din placering til at vælge afdeling"
test-id="redirect-nearest-department-loading"
status-test-id="redirect-nearest-department-loading-status"
aria-label="Finder din nærmeste afdeling"
/>
<PosDepartmentStepMobile1Location
:maximum-age="autoSelectGeolocationMaximumAge"
:timeout="autoSelectGeolocationTimeout"
+14 -5
View File
@@ -1,14 +1,23 @@
<script setup>
import LoginEmployeeForm from "@/components/forms/auth/LoginEmployeeForm.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import AuthPageWrapper from "@/components/page/wrappers/AuthPageWrapper.vue";
import GuestDashboardPageWrapper from "@/views/guest/GuestDashboardPageWrapper.vue";
import logo from "@/assets/img/truckwash-banner.png";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import { useStoredSessionGuestRedirect } from "@/composables/useStoredSessionGuestRedirect.js";
const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
</script>
<template>
<section class="login-section">
<BrandedLoadingScreen
v-if="hasPendingStoredSession"
title="Bekræfter bruger..."
subtitle="Vi indlæser din session"
test-id="guest-session-loading"
status-test-id="guest-session-loading-status"
aria-label="Bekræfter bruger"
/>
<section v-else class="login-section">
<div class="wrapper">
<div class="sidebar">
<h1>{{ $t('auth.operator_login_title') }}</h1>
@@ -127,4 +136,4 @@ import logo from "@/assets/img/truckwash-banner.png";
}
}
/*end login-section*/
</style>
</style>
+13 -1
View File
@@ -3,10 +3,22 @@
import LoginForm from "@/components/forms/auth/LoginForm.vue";
import logo from "@/assets/img/truckwash-banner.png";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import { useStoredSessionGuestRedirect } from "@/composables/useStoredSessionGuestRedirect.js";
const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
</script>
<template>
<section class="login-section">
<BrandedLoadingScreen
v-if="hasPendingStoredSession"
title="Bekræfter bruger..."
subtitle="Vi indlæser din session"
test-id="guest-session-loading"
status-test-id="guest-session-loading-status"
aria-label="Bekræfter bruger"
/>
<section v-else class="login-section">
<div class="wrapper">
<div class="sidebar">
<h1>{{ $t('auth.customer_login_title') }}</h1>
+13 -1
View File
@@ -2,10 +2,22 @@
import SubuserLoginForm from "@/components/forms/auth/SubuserLoginForm.vue";
import logo from "@/assets/img/truckwash-banner.png";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import { useStoredSessionGuestRedirect } from "@/composables/useStoredSessionGuestRedirect.js";
const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
</script>
<template>
<section class="login-section">
<BrandedLoadingScreen
v-if="hasPendingStoredSession"
title="Bekræfter bruger..."
subtitle="Vi indlæser din session"
test-id="guest-session-loading"
status-test-id="guest-session-loading-status"
aria-label="Bekræfter bruger"
/>
<section v-else class="login-section">
<div class="wrapper">
<div class="sidebar">
<h1>{{ $t('auth.driver_login_title') }}</h1>
+27 -22
View File
@@ -1,32 +1,37 @@
<script setup>
import { onMounted} from "vue";
import LoginForm from "@/components/forms/auth/LoginForm.vue";
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import { useStoredSessionGuestRedirect } from "@/composables/useStoredSessionGuestRedirect.js";
onMounted(() => {
// If the user is authenticated, redirect to the user or admin dashboard
if (hasStoredSessionToken()) {
window.location.href = '/redirect';
}
});
const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
</script>
<template>
<section class="hero-section">
<div class="container">
<p class="pre-title">VELKOMMEN TIL</p>
<h1 data-testid="landing-title">Truck Wash kundeportal</h1>
</div>
</section>
<section class="login-section">
<div class="wrapper">
<div class="main">
<LoginForm />
<p>Ved at klikke Log in accepterer du truckwash.io <router-link :to="{ name: 'privacy-policy' }">terms of service og privacy policy</router-link></p>
<BrandedLoadingScreen
v-if="hasPendingStoredSession"
title="Bekræfter bruger..."
subtitle="Vi indlæser din session"
test-id="guest-session-loading"
status-test-id="guest-session-loading-status"
aria-label="Bekræfter bruger"
/>
<template v-else>
<section class="hero-section">
<div class="container">
<p class="pre-title">VELKOMMEN TIL</p>
<h1 data-testid="landing-title">Truck Wash kundeportal</h1>
</div>
</div>
</section>
</section>
<section class="login-section">
<div class="wrapper">
<div class="main">
<LoginForm />
<p>Ved at klikke Log in accepterer du truckwash.io <router-link :to="{ name: 'privacy-policy' }">terms of service og privacy policy</router-link></p>
</div>
</div>
</section>
</template>
</template>
+18 -3
View File
@@ -2788,9 +2788,24 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
const vehicleSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-vehicle");
await expect(vehicleSubmenu).toBeVisible();
expect(openedOrderVehicleRegs.length).toBeGreaterThan(0);
for (const reg of openedOrderVehicleRegs) {
await expect(vehicleSubmenu).toContainText(new RegExp(`${reg} i ny fane`, "i"));
const uniqueOpenedOrderVehicleRegs = Array.from(new Set(openedOrderVehicleRegs));
expect(uniqueOpenedOrderVehicleRegs.length).toBeGreaterThan(0);
const vehicleActionKey = (reg: string) =>
reg
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "vehicle";
for (const reg of uniqueOpenedOrderVehicleRegs) {
const vehicleCategory = dropdownContent.getByTestId(`action-settings-wheel-vehicle-${vehicleActionKey(reg)}`);
await expect(vehicleCategory).toBeVisible();
await expect(vehicleCategory).toContainText(reg);
await triggerMenuHover(vehicleCategory);
const vehiclePanel = dropdownContent.getByTestId(`action-settings-wheel-vehicle-${vehicleActionKey(reg)}-panel`);
await expect(vehiclePanel).toBeVisible();
await expect(vehiclePanel).toContainText("Administrer køretøj");
await expect(vehiclePanel).toContainText("Åbn køretøj i ny fane");
await expect(vehiclePanel).toContainText("Slet køretøj");
}
const flyoutBoxAfter = await flyout.boundingBox();
@@ -379,7 +379,7 @@ test.describe("public .htaccess static fallback", () => {
const response = await request.get(`${serverBaseUrl}${appPath}`);
expect(response.status(), appPath).toBe(200);
expect(response.headers()["content-type"], appPath).toContain("text/html");
expect(await response.text(), appPath).toContain('<div id="app"></div>');
expect(await response.text(), appPath).toContain('data-testid="app-bootstrap-loading"');
}
});
@@ -392,7 +392,7 @@ test.describe("public .htaccess static fallback", () => {
expect(response.status(), `${prefix}${staticPath}`).toBe(200);
if (staticPath === "/index.html") {
expect(response.headers()["content-type"], `${prefix}${staticPath}`).toContain("text/html");
expect(await response.text(), `${prefix}${staticPath}`).toContain('<div id="app"></div>');
expect(await response.text(), `${prefix}${staticPath}`).toContain('data-testid="app-bootstrap-loading"');
} else {
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
}
+1 -1
View File
@@ -113,7 +113,7 @@ test("@public-live release manifest, shell, and static assets are available", as
expect(response.status(), `${shellPath} status`).toBe(200);
expect(response.headers()["content-type"] || "", `${shellPath} content-type`).toContain("text/html");
expect(body.replace(/\s+/g, "").length, `${shellPath} body`).toBeGreaterThan(40);
expect(body, `${shellPath} Vue root`).toContain('<div id="app"></div>');
expect(body, `${shellPath} bootstrap loader`).toContain('data-testid="app-bootstrap-loading"');
}
const assetPaths = unique([
@@ -279,11 +279,24 @@ const longTitleSnapshot = {
enabled: true,
configured: true,
probe_supported: true,
status: "ok",
status_reason: "License Plate Recognizer connectivity confirmed.",
status_reason_key: "http_ok",
status_reason_params: { label: "License Plate Recognizer" },
status: "degraded",
status_reason: "License Plate Recognizer quota usage is near the limit.",
status_reason_key: "licenseplaterecognizer_quota_near_limit",
status_reason_params: {
used: "2250",
quota: "2500",
remaining: "250",
percent: "90.0",
},
checked_at: "2026-04-08T08:45:00.000Z",
usage: {
provider: "licenseplaterecognizer",
calls_used: 2250,
quota_calls: 2500,
calls_remaining: 250,
usage_percent: 90,
version: "1.54.0",
},
},
{
key: "virkdata",
@@ -415,6 +428,17 @@ test.describe("Superuser system status smoke", () => {
await expect(card).toBeVisible({ timeout: PAGE_READY_TIMEOUT });
await expect(title).toContainText("License Plate Recognizer");
await expect(page.getByTestId("module-reason-licenseplaterecognizer")).toContainText(
"License Plate Recognizer-kvoten er næsten opbrugt"
);
const usage = page.getByTestId("module-usage-licenseplaterecognizer");
await expect(usage).toContainText("Kvoteudnyttelse");
await expect(usage).toContainText("90.0%");
await expect(usage).toContainText("Brugte kald");
await expect(usage).toContainText("2.250");
await expect(usage).toContainText("Kald tilbage");
await expect(usage).toContainText("250");
await expect(usage).toContainText("Version: 1.54.0");
const [cardBox, titleBox, pillBox] = await Promise.all([
card.boundingBox(),
+6
View File
@@ -835,6 +835,12 @@ test.describe("Superuser employees list", () => {
await expect(page.getByTestId("superuser-users-row-13")).toContainText("Legacy Clerk");
await page.locator('[data-testid="superuser-user-actions-13"] .action-settings-wheel-trigger').click();
await expect(page.getByTestId("superuser-user-migrate-limited-13")).toBeVisible();
await expect(page.getByTestId("superuser-user-migrate-limited-13")).toContainText(
/Migrate to limited backoffice|Migrer til begrænset backoffice/
);
await expect(page.getByTestId("superuser-user-migrate-limited-13")).not.toContainText(
"superuser.pages.employees.migrate_to_limited_backoffice"
);
await page.getByTestId("superuser-user-migrate-limited-13").click();
await expect(page.locator(".swal2-popup")).toContainText("Migrate employee");
@@ -59,6 +59,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
canAccessAdmin: vi.fn(() => false),
canAccessSuperUser: vi.fn(() => true),
canAccessUser: vi.fn(() => false),
canAccessCustomerFeature: vi.fn(() => true),
hasPermission: vi.fn(() => true),
functions: {
ucFirst: (value) => value,
@@ -146,6 +147,9 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
multiple: "Vehicles",
},
},
functions: {
showDeleteObjectForm: vi.fn(() => Promise.resolve()),
},
},
global: {
language: {
@@ -185,6 +189,7 @@ const SETTINGS_WHEEL_TRANSLATION_KEYS = [
"admin.pos.settings_wheel.delete_booking",
"admin.pos.settings_wheel.delete_order",
"admin.pos.settings_wheel.delete_user",
"admin.pos.settings_wheel.delete_vehicle",
"admin.pos.settings_wheel.download_invoice",
"admin.pos.settings_wheel.edit_permissions",
"admin.pos.settings_wheel.email_notifications_section",
@@ -196,6 +201,7 @@ const SETTINGS_WHEEL_TRANSLATION_KEYS = [
"admin.pos.settings_wheel.login_as_customer",
"admin.pos.settings_wheel.login_as_user",
"admin.pos.settings_wheel.login_as_user_qr",
"admin.pos.settings_wheel.manage_vehicle",
"admin.pos.settings_wheel.mark_as_completed",
"admin.pos.settings_wheel.no_actions_defined",
"admin.pos.settings_wheel.customer_section",
@@ -591,6 +597,8 @@ describe("ActionSettingsWheelButton", () => {
swalFireMock.mockResolvedValue({ isConfirmed: true, value: "Needs review" });
SessionUser.request.mockReset();
SessionUser.request.mockResolvedValue({ data: { data: { summary: { primary_gateway: { id: 701 } } } } });
SessionUser.canAccessCustomerFeature.mockReset();
SessionUser.canAccessCustomerFeature.mockReturnValue(true);
SessionUser.hasPermission.mockReset();
SessionUser.hasPermission.mockImplementation((permission) =>
["add_customer_attribute", "delete_customer_attribute", "list_customer_attributes", "superuser"].includes(
@@ -616,6 +624,8 @@ describe("ActionSettingsWheelButton", () => {
SessionUser.objects.department_relays.showCreateObjectForm.mockClear();
SessionUser.objects.collectedOrderInvoices.add.mockReset();
SessionUser.objects.collectedOrderInvoices.add.mockResolvedValue({ data: { data: { id: 901 } } });
SessionUser.objects.vehicles.functions.showDeleteObjectForm.mockReset();
SessionUser.objects.vehicles.functions.showDeleteObjectForm.mockResolvedValue({});
SessionUser.functions.redirectTo.department.mockClear();
SessionUser.functions.redirectTo.superUser.mockClear();
global.fetch = vi.fn(() =>
@@ -1232,6 +1242,97 @@ describe("ActionSettingsWheelButton", () => {
windowOpenSpy.mockRestore();
});
it("groups multiple vehicle registrations into desktop flyout categories with nested actions", async () => {
SessionUser.request.mockImplementation((endpoint, method, params) => {
if (endpoint === "/vehicles/search") {
return Promise.resolve({
data: {
data: [{ id: params.search === "AB12345" ? 501 : 502, reg: params.search }],
},
});
}
return Promise.resolve({ data: { data: { summary: { primary_gateway: { id: 701 } } } } });
});
const geometrySpy = mockMenuGeometry();
const wrapper = mountDesktopFlyoutButton({
reg_1: "AB12345",
reg_2: "CD67890",
reg_3: "AB12345",
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-section-vehicle"]').trigger("mouseenter");
await flushMicrotasks();
const vehicleSubmenu = wrapper.get('[data-testid="action-settings-wheel-submenu-vehicle"]');
expect(vehicleSubmenu.text()).toContain("AB12345");
expect(vehicleSubmenu.text()).toContain("CD67890");
expect(vehicleSubmenu.findAll('[data-testid^="action-settings-wheel-vehicle-"]')).toHaveLength(2);
await wrapper.get('[data-testid="action-settings-wheel-vehicle-ab12345"]').trigger("mouseenter");
await flushMicrotasks();
const nestedPanel = wrapper.get('[data-testid="action-settings-wheel-vehicle-ab12345-panel"]');
expect(nestedPanel.text()).toContain("AB12345");
expect(nestedPanel.text()).toContain("admin.pos.settings_wheel.manage_vehicle");
expect(nestedPanel.text()).toContain("admin.pos.settings_wheel.view_vehicle_new_tab");
expect(nestedPanel.text()).toContain("admin.pos.settings_wheel.delete_vehicle");
wrapper.unmount();
geometrySpy.mockRestore();
});
it("routes vehicle manage/open actions and deletes the resolved vehicle id", async () => {
const refreshFunction = vi.fn(() => Promise.resolve());
SessionUser.request.mockImplementation((endpoint, method, params) => {
if (endpoint === "/vehicles/search") {
return Promise.resolve({
data: {
data: [{ id: 501, reg: params.search, customer_id: 123456 }],
},
});
}
return Promise.resolve({ data: { data: { summary: { primary_gateway: { id: 701 } } } } });
});
const wrapper = mountFlatDropdownButton({
refreshFunction,
reg_1: " AB12345 ",
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-vehicle-manage-ab12345"]').trigger("click");
await flushMicrotasks();
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith("/vehicles/AB12345", false);
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-vehicle-open-ab12345"]').trigger("click");
await flushMicrotasks();
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith("/vehicles/AB12345", true);
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-vehicle-delete-ab12345"]').trigger("click");
await flushMicrotasks();
expect(SessionUser.objects.vehicles.functions.showDeleteObjectForm).toHaveBeenCalledWith(501, expect.any(Function));
const [, onAfterSubmit] = SessionUser.objects.vehicles.functions.showDeleteObjectForm.mock.calls[0];
await onAfterSubmit();
expect(refreshFunction).toHaveBeenCalledTimes(1);
});
it("builds attachment flyout rows, auto-renders previews on hover, and opens the full preview action explicitly", async () => {
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([
{
+88
View File
@@ -0,0 +1,88 @@
// @vitest-environment node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(TEST_DIR, "../..");
const readProjectFile = (relativePath) => fs.readFileSync(path.join(PROJECT_ROOT, relativePath), "utf8");
describe("app loading shell", () => {
it("renders a branded bootstrap loader before Vue mounts", () => {
const indexHtml = readProjectFile("index.html");
expect(indexHtml).toContain("__TW_LOADER_STARTED_AT__");
expect(indexHtml).toContain('<html lang="" class="theme-light tw-bootstrap-loading" data-theme="light">');
expect(indexHtml).toContain('<div id="app">');
expect(indexHtml).not.toContain('<div id="app"></div>');
expect(indexHtml).toContain('<body class="tw-bootstrap-loading">');
expect(indexHtml).toContain("html.tw-bootstrap-loading");
expect(indexHtml).toContain("font-size: 16px !important");
expect(indexHtml).toContain("background: #fff");
expect(indexHtml).toContain("body.tw-bootstrap-loading");
expect(indexHtml).toContain("background: #041f32");
expect(indexHtml).toContain("html.tw-loader-scroll-lock");
expect(indexHtml).toContain("body.tw-loader-scroll-lock");
expect(indexHtml).toContain('data-testid="app-bootstrap-loading"');
expect(indexHtml).toContain('data-testid="app-bootstrap-loading-status"');
expect(indexHtml).toContain('role="status"');
expect(indexHtml).toContain('aria-live="polite"');
expect(indexHtml).toContain("Indlæser...");
expect(indexHtml).toContain("Vi gør kundeportalen klar");
expect(indexHtml).toContain("usesSessionLoader");
expect(indexHtml).toContain("hasStoredSession");
expect(indexHtml).toContain("usesGuestSessionLoader");
expect(indexHtml).toContain("Bekræfter bruger...");
expect(indexHtml).toContain("Vi indlæser din session");
expect(indexHtml).toContain("--tw-loader-spin-delay");
expect(indexHtml).toContain("--tw-loader-sweep-delay");
expect(indexHtml).toContain("--tw-loader-dot-three-delay");
expect(indexHtml).toContain("%BASE_URL%assets/branding/truckwash-banner-white-compressed.png");
expect(indexHtml).toContain("@media (prefers-reduced-motion: reduce)");
expect(indexHtml).not.toContain('src="/assets/');
});
it("keeps the bootstrap logo available as a public asset", () => {
const sourceLogo = fs.readFileSync(
path.join(PROJECT_ROOT, "src/assets/branding/truckwash-banner-white-compressed.png")
);
const publicLogo = fs.readFileSync(
path.join(PROJECT_ROOT, "public/assets/branding/truckwash-banner-white-compressed.png")
);
expect(publicLogo.equals(sourceLogo)).toBe(true);
});
it("uses the branded Vue fallback for async app routes", () => {
const appVue = readProjectFile("src/App.vue");
const mainJs = readProjectFile("src/main.js");
const routerJs = readProjectFile("src/router.js");
const brandedLoaderVue = readProjectFile("src/components/global/BrandedLoadingScreen.vue");
expect(appVue).toContain('import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue"');
expect(appVue).toContain("sessionRouteLoadingProps");
expect(appVue).toContain("sessionLoaderRoutePattern");
expect(appVue).toContain("guestSessionLoaderRoutePattern");
expect(appVue).toContain("currentBrowserPath");
expect(appVue).toContain("hasRouteMiddleware");
expect(appVue).toContain("shouldUseGuestSessionPathLoader");
expect(appVue).toContain("shouldUseSessionRouteLoader");
expect(appVue).toContain('hasRouteMiddleware("guestMiddleware")');
expect(appVue).toContain('testId: "app-route-loading"');
expect(appVue).toContain('statusTestId: "app-route-loading-status"');
expect(appVue.match(/<Suspense/g) || []).toHaveLength(4);
expect(appVue.split('<BrandedLoadingScreen v-bind="appRouteLoadingProps" />')).toHaveLength(5);
expect(brandedLoaderVue).toContain("font-family: Avenir, system-ui");
expect(brandedLoaderVue).toContain("font-size: 16px !important");
expect(brandedLoaderVue).toContain("__TW_LOADER_STARTED_AT__");
expect(brandedLoaderVue).toContain("--branded-loader-spin-delay");
expect(brandedLoaderVue).toContain("--branded-loader-dot-three-delay");
expect(brandedLoaderVue.match(/box-sizing: border-box;/g) || []).toHaveLength(2);
expect(routerJs).toContain("name: 'landing'");
expect(routerJs).toContain("meta: { template: 'with-header', middleware: guestMiddleware }");
expect(mainJs).toContain('document.documentElement?.classList.remove("tw-bootstrap-loading")');
expect(mainJs).toContain('classList.remove("tw-bootstrap-loading")');
expect(mainJs).toContain("window.setTimeout(releaseBootstrapScrollLock, 0)");
});
});
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
let wrappers = [];
const mountLoader = (options) => {
const wrapper = mount(BrandedLoadingScreen, options);
wrappers.push(wrapper);
return wrapper;
};
describe("BrandedLoadingScreen", () => {
afterEach(() => {
wrappers.forEach((wrapper) => wrapper.unmount());
wrappers = [];
document.documentElement.classList.remove("tw-loader-scroll-lock");
document.body.classList.remove("tw-loader-scroll-lock");
delete document.documentElement.dataset.twLoaderScrollLocks;
delete window.__TW_LOADER_STARTED_AT__;
});
it("renders the title and subtitle with custom test ids", () => {
const wrapper = mountLoader({
props: {
title: "Omdirigerer...",
subtitle: "Vi finder den rigtige side for dig",
testId: "redirect-loading",
statusTestId: "redirect-loading-status",
ariaLabel: "Omdirigerer",
},
});
expect(wrapper.get('[data-testid="redirect-loading"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="redirect-loading-status"]').text()).toBe("Omdirigerer...");
expect(wrapper.text()).toContain("Vi finder den rigtige side for dig");
});
it("exposes polite status semantics", () => {
const wrapper = mountLoader({
props: {
title: "Indlæser din session...",
ariaLabel: "Indlæser session",
},
});
const root = wrapper.get('[data-testid="branded-loading-screen"]');
expect(root.attributes("role")).toBe("status");
expect(root.attributes("aria-live")).toBe("polite");
expect(root.attributes("aria-label")).toBe("Indlæser session");
});
it("marks animated decoration as hidden from assistive technology", () => {
const wrapper = mountLoader();
expect(wrapper.get(".branded-loading-screen__ambient").attributes("aria-hidden")).toBe("true");
expect(wrapper.get(".branded-loading-screen__motion").attributes("aria-hidden")).toBe("true");
expect(wrapper.get(".branded-loading-screen__dots").attributes("aria-hidden")).toBe("true");
});
it("continues the shared loader animation phase after remounts", () => {
window.__TW_LOADER_STARTED_AT__ = performance.now() - 1800;
const wrapper = mountLoader();
const style = wrapper.attributes("style") || "";
expect(style).toMatch(/--branded-loader-spin-delay:\s*-\d/);
expect(style).toMatch(/--branded-loader-sweep-delay:\s*-\d/);
expect(style).toMatch(/--branded-loader-dot-three-delay:\s*-\d/);
});
it("locks page scroll only while mounted", () => {
const wrapper = mountLoader();
expect(document.documentElement.classList.contains("tw-loader-scroll-lock")).toBe(true);
expect(document.body.classList.contains("tw-loader-scroll-lock")).toBe(true);
expect(document.documentElement.dataset.twLoaderScrollLocks).toBe("1");
wrapper.unmount();
expect(document.documentElement.classList.contains("tw-loader-scroll-lock")).toBe(false);
expect(document.body.classList.contains("tw-loader-scroll-lock")).toBe(false);
expect(document.documentElement.dataset.twLoaderScrollLocks).toBeUndefined();
});
});
@@ -11,18 +11,45 @@ vi.mock("vue-router", () => {
};
});
vi.mock("@/components/global/PageLoader.vue", () => {
vi.mock("@/components/global/BrandedLoadingScreen.vue", () => {
return {
default: defineComponent({
name: "MockPageLoader",
name: "MockBrandedLoadingScreen",
props: {
title: {
type: String,
default: "",
},
subtitle: {
type: String,
default: "",
},
testId: {
type: String,
default: "branded-loading-screen",
},
statusTestId: {
type: String,
default: "",
},
ariaLabel: {
type: String,
default: "",
},
},
setup(props) {
return () => h("div", { "data-testid": "page-loader" }, props.title);
return () =>
h(
"div",
{
"data-testid": props.testId,
"data-aria-label": props.ariaLabel,
},
[
h("span", { "data-testid": props.statusTestId || undefined }, props.title),
props.subtitle ? h("small", props.subtitle) : null,
]
);
},
}),
};
@@ -183,6 +210,13 @@ const setAdminSession = (permissions = ["admin"]) => {
__sessionState.token.value = "unit-token";
};
const setPendingSession = () => {
__sessionState.initiated.value = false;
__sessionState.permissions.value = [];
__sessionState.isSubuser.value = false;
__sessionState.token.value = "unit-token";
};
const setLocation = ({ latitude, longitude, timestamp = Date.now() }) => {
__locationRef.value = {
coords: {
@@ -215,6 +249,28 @@ describe("DefaultPage mobile department redirect", () => {
vi.useRealTimers();
});
it("shows the branded redirect loader while session access is still unresolved", async () => {
setPendingSession();
const mounted = mountDefaultPage();
await nextTick();
expect(mounted.get('[data-testid="redirect-loading-status"]').text()).toBe("Omdirigerer...");
expect(mounted.text()).toContain("Vi finder den rigtige side for dig");
expect(mounted.find('[data-testid="redirect-nearest-department-loading"]').exists()).toBe(false);
});
it("shows the branded nearest-department loader during mobile auto-selection", async () => {
const mounted = mountDefaultPage();
await nextTick();
expect(mounted.get('[data-testid="redirect-nearest-department-loading-status"]').text()).toBe(
"Finder din nærmeste afdeling..."
);
expect(mounted.text()).toContain("Vi bruger din placering til at vælge afdeling");
expect(mounted.find('[data-testid="redirect-loading"]').exists()).toBe(false);
});
it("routes when geolocation arrives before departments finish loading", async () => {
mountDefaultPage();
await nextTick();
+180
View File
@@ -0,0 +1,180 @@
// @vitest-environment jsdom
import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
hasStoredSessionToken: vi.fn(),
routerReplace: vi.fn(),
routeQuery: {},
}));
vi.mock("@/services/sessionStorage.js", () => ({
hasStoredSessionToken: mocks.hasStoredSessionToken,
}));
vi.mock("vue-router", () => ({
useRoute: () => ({ query: mocks.routeQuery }),
useRouter: () => ({ replace: mocks.routerReplace }),
}));
vi.mock("@/components/global/BrandedLoadingScreen.vue", () => ({
default: defineComponent({
name: "MockBrandedLoadingScreen",
props: {
title: {
type: String,
default: "",
},
testId: {
type: String,
default: "branded-loading-screen",
},
statusTestId: {
type: String,
default: "",
},
},
setup(props) {
return () =>
h("div", { "data-testid": props.testId }, [
h("span", { "data-testid": props.statusTestId || undefined }, props.title),
]);
},
}),
}));
vi.mock("@/components/forms/auth/LoginForm.vue", () => ({
default: defineComponent({
name: "MockLoginForm",
setup() {
return () => h("form", { "data-testid": "login-form" });
},
}),
}));
vi.mock("@/components/forms/auth/SubuserLoginForm.vue", () => ({
default: defineComponent({
name: "MockSubuserLoginForm",
setup() {
return () => h("form", { "data-testid": "subuser-login-form" });
},
}),
}));
vi.mock("@/components/forms/auth/LoginEmployeeForm.vue", () => ({
default: defineComponent({
name: "MockLoginEmployeeForm",
setup() {
return () => h("form", { "data-testid": "employee-login-form" });
},
}),
}));
vi.mock("@/views/errors/ConnectivityIssue.vue", () => ({
default: defineComponent({
name: "MockConnectivityIssue",
setup(_, { slots }) {
return () => h("div", { "data-testid": "connectivity-wrapper" }, slots.default?.());
},
}),
}));
import LandingPage from "@/views/pages/LandingPage.vue";
import Login from "@/views/auth/Login.vue";
import SubuserLogin from "@/views/auth/SubuserLogin.vue";
import Employee from "@/views/auth/Employee.vue";
const routerLinkStub = defineComponent({
name: "RouterLinkStub",
props: {
to: {
type: [String, Object],
default: "",
},
},
setup(_, { slots }) {
return () => h("a", slots.default?.());
},
});
const mountView = (component) =>
mount(component, {
global: {
mocks: {
$t: (key) => key,
},
stubs: {
RouterLink: routerLinkStub,
},
},
});
describe("guest auth entry loaders", () => {
beforeEach(() => {
mocks.hasStoredSessionToken.mockReset();
mocks.hasStoredSessionToken.mockReturnValue(false);
mocks.routerReplace.mockReset();
Object.keys(mocks.routeQuery).forEach((key) => delete mocks.routeQuery[key]);
});
afterEach(() => {
document.body.innerHTML = "";
});
it("shows the landing login form when no stored session exists", async () => {
const wrapper = mountView(LandingPage);
await nextTick();
expect(wrapper.find('[data-testid="login-form"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="guest-session-loading"]').exists()).toBe(false);
expect(mocks.routerReplace).not.toHaveBeenCalled();
});
it("keeps authenticated landing visits on the session loader before redirecting", async () => {
mocks.hasStoredSessionToken.mockReturnValue(true);
const wrapper = mountView(LandingPage);
await nextTick();
expect(wrapper.find('[data-testid="login-form"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="guest-session-loading-status"]').text()).toBe("Bekræfter bruger...");
expect(mocks.routerReplace).toHaveBeenCalledWith({ name: "default" });
});
it("keeps authenticated customer-login visits on the loader and honors safe redirects", async () => {
mocks.hasStoredSessionToken.mockReturnValue(true);
mocks.routeQuery.redirect = "/user/orders?status=open#latest";
const wrapper = mountView(Login);
await nextTick();
expect(wrapper.find('[data-testid="login-form"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="guest-session-loading-status"]').text()).toBe("Bekræfter bruger...");
expect(mocks.routerReplace).toHaveBeenCalledWith("/user/orders?status=open#latest");
});
it("falls back to the session redirect route for unsafe login redirects", async () => {
mocks.hasStoredSessionToken.mockReturnValue(true);
mocks.routeQuery.redirect = "https://example.invalid/user";
mountView(Login);
await nextTick();
expect(mocks.routerReplace).toHaveBeenCalledWith({ name: "default" });
});
it.each([
["driver", SubuserLogin, "subuser-login-form"],
["operator", Employee, "employee-login-form"],
])("keeps authenticated %s login visits on the session loader", async (_label, component, formTestId) => {
mocks.hasStoredSessionToken.mockReturnValue(true);
const wrapper = mountView(component);
await nextTick();
expect(wrapper.find(`[data-testid="${formTestId}"]`).exists()).toBe(false);
expect(wrapper.get('[data-testid="guest-session-loading-status"]').text()).toBe("Bekræfter bruger...");
expect(mocks.routerReplace).toHaveBeenCalledWith({ name: "default" });
});
});
@@ -127,13 +127,17 @@ describe("ActionSettingsWheelButton i18n coverage", () => {
}
});
it("renders the Danish vehicle new-tab action with the expected copy", () => {
it("renders the Danish vehicle action labels with the expected copy", () => {
const messages = readLocale("da");
const value = getKeyPathValue(messages, "admin.pos.settings_wheel.view_vehicle_new_tab");
const manageValue = getKeyPathValue(messages, "admin.pos.settings_wheel.manage_vehicle");
const newTabValue = getKeyPathValue(messages, "admin.pos.settings_wheel.view_vehicle_new_tab");
const deleteValue = getKeyPathValue(messages, "admin.pos.settings_wheel.delete_vehicle");
expect(value).toBe("Åbn køretøjet {reg} i ny fane");
expect(value).toContain("{reg}");
expect(value).not.toMatch(/[?\uFFFD]/u);
expect(manageValue).toBe("Administrer køretøj");
expect(newTabValue).toBe("Åbn køretøj i ny fane");
expect(newTabValue).not.toContain("{reg}");
expect(deleteValue).toBe("Slet køretøj");
expect([manageValue, newTabValue, deleteValue].join(" ")).not.toMatch(/[?\uFFFD]/u);
});
it("contains all shared customer rule translation keys in all active locales", () => {
@@ -60,12 +60,16 @@ describe("NavigationMenuItemsAdmin contract", () => {
});
it("surfaces system security in both superuser navigation menus", () => {
expect(superuserSource).toContain('to: "/superuser"');
expect(superuserSource).not.toContain('to: "/superuser/system/status"');
expect(superuserSource).toContain('to: "/superuser/system/security/overview"');
expect(superuserSource).toContain('t("security.nav")');
expect(superuserSource).toContain('t("system_status.cards.minio")');
expect(superuserSource).toContain('t("system_status.cards.redis")');
expect(superuserSource).not.toContain('to: "/superuser/system/replication"');
expect(superuserLeftMenuSource).toContain("value: null");
expect(superuserLeftMenuSource).not.toContain('value: "/system/status"');
expect(superuserLeftMenuSource).toContain('t("security.nav", "Security")');
expect(superuserLeftMenuSource).toContain('value: "/system/security/overview"');
expect(superuserLeftMenuSource).toContain('t("cron.system_nav", "System")');
+4 -4
View File
@@ -84,8 +84,8 @@ describe("RestrictedPageWrapper", () => {
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.find('[data-testid="restricted-auth-loading"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="restricted-auth-loading-status"]').text()).toBe("Bekræfter bruger...");
expect(wrapper.text()).not.toContain("403 Forbidden");
expect(wrapper.find('[data-testid="allowed-content"]').exists()).toBe(false);
});
@@ -95,7 +95,7 @@ describe("RestrictedPageWrapper", () => {
const wrapper = mountWrapper(false);
expect(wrapper.find(".pageloader").exists()).toBe(false);
expect(wrapper.find('[data-testid="restricted-auth-loading"]').exists()).toBe(false);
expect(wrapper.text()).toContain("403 Forbidden");
expect(wrapper.find('[data-testid="allowed-content"]').exists()).toBe(false);
});
@@ -105,7 +105,7 @@ describe("RestrictedPageWrapper", () => {
const wrapper = mountWrapper(true);
expect(wrapper.find(".pageloader").exists()).toBe(false);
expect(wrapper.find('[data-testid="restricted-auth-loading"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain("403 Forbidden");
expect(wrapper.find('[data-testid="allowed-content"]').exists()).toBe(true);
});
@@ -111,6 +111,30 @@ const createSnapshot = (overrides = {}) => ({
status_reason: "Bird API returned HTTP 503.",
checked_at: "2026-04-08T08:45:00.000Z",
},
{
key: "licenseplaterecognizer",
enabled: true,
configured: true,
probe_supported: true,
status: "degraded",
status_reason: "License Plate Recognizer quota usage is near the limit.",
status_reason_key: "licenseplaterecognizer_quota_near_limit",
status_reason_params: {
used: "2250",
quota: "2500",
remaining: "250",
percent: "90.0",
},
checked_at: "2026-04-08T08:45:00.000Z",
usage: {
provider: "licenseplaterecognizer",
calls_used: 2250,
quota_calls: 2500,
calls_remaining: 250,
usage_percent: 90,
version: "1.54.0",
},
},
],
sessions: {
active_window_minutes: 15,
@@ -246,6 +270,7 @@ const resetSessionUser = () => {
describe("superuser system status route contract", () => {
it("keeps /superuser routed to the main dashboard view and mounts the system dashboard", () => {
expect(routerSource).toContain("path: '/superuser'");
expect(routerSource).not.toContain("path: '/superuser/system/status'");
expect(routerSource).toContain("component: SuperUserDashboard");
expect(superUserDashboardSource).toContain("<SystemStatusDashboard />");
expect(superUserDashboardSource).toContain("$t('system_status.title')");
@@ -302,6 +327,17 @@ describe("superuser system status dashboard", () => {
await openDashboardTab(wrapper, "modules");
expect(wrapper.get('a[href="/superuser/configuration/openai"]').exists()).toBe(true);
const usage = wrapper.get('[data-testid="module-usage-licenseplaterecognizer"]');
expect(usage.text()).toContain("Quota usage");
expect(usage.text()).toContain("90.0%");
expect(usage.text()).toContain("Calls used");
expect(usage.text()).toContain("2,250");
expect(usage.text()).toContain("Quota");
expect(usage.text()).toContain("2,500");
expect(usage.text()).toContain("Calls remaining");
expect(usage.text()).toContain("250");
expect(usage.text()).toContain("Version: 1.54.0");
expect(usage.get("progress").attributes("value")).toBe("90");
await openDashboardTab(wrapper, "sessions");
expect(wrapper.text()).toContain("Acme Logistics");
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { readI18nRuntimeMessages } from "./helpers/readI18nRuntimeMessages";
const activeLocales = ["da", "en", "sv", "de", "no"];
const migrationKey = "superuser.pages.employees.migrate_to_limited_backoffice";
const getKeyPathValue = (messages, keyPath) => {
let current = messages;
for (const segment of keyPath.split(".")) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = current[segment];
}
return current;
};
describe("superuser users i18n", () => {
it("resolves the limited backoffice migration action label in all active locales", () => {
for (const locale of activeLocales) {
const value = getKeyPathValue(readI18nRuntimeMessages(locale), migrationKey);
expect(value, `${locale} ${migrationKey}`).toEqual(expect.any(String));
expect(value.trim()).not.toBe("");
expect(value).not.toBe(migrationKey);
expect(value).not.toMatch(/^@:/);
}
});
});
@@ -14,6 +14,11 @@ describe("user vehicles actions visibility contract", () => {
expect(vehiclesTableSource).toContain(':displayActionsDirectly="false"');
});
it("hides legacy vehicle row actions when the shared superuser vehicle wheel is available", () => {
expect(vehiclesTableSource).toContain("usesBuiltInVehicleWheelActions");
expect(vehiclesTableSource).toContain('v-if="!usesBuiltInVehicleWheelActions(object)" #actions');
});
it("refreshes the customer vehicle list after adding from the summary card", () => {
expect(userVehicleSummarySource).toContain("const reloadVehicleDisplays = async () =>");
expect(userVehicleSummarySource).toContain("await loadList();");