Add loading screen for session confirmation and enhance vehicle management labels
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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>
|
||||
@@ -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": "/"
|
||||
}
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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'}.",
|
||||
|
||||
@@ -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'}.",
|
||||
|
||||
@@ -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'}.",
|
||||
|
||||
@@ -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'}",
|
||||
|
||||
@@ -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'}.",
|
||||
|
||||
@@ -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'}.",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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")');
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();");
|
||||
|
||||