Add Connectivity Issue Page and Improve Table Semantics

- Introduced `ConnectivityIssue.vue` for handling and displaying connectivity problems.
- Added new route for the connectivity issue error page.
- Enhanced accessibility by wrapping table headers in `OrderBookingsTable.vue` with `<thead>` and `<tbody>` tags.
- Included connectivity-related internationalization keys across various locale files.
- Updated components to integrate `ConnectivityIssue` as a fallback display for connection errors.
This commit is contained in:
Jeppe Bundgaard
2026-02-25 12:59:10 +01:00
parent 61a3736ad1
commit ee04e213f1
17 changed files with 423 additions and 37 deletions
+12
View File
@@ -8386,6 +8386,18 @@ components:
For backward-compatibility the API also accepts camelCase `progressAlertTimeOfDay` on input.
pattern: '^([01]\d|2[0-3]):[0-5]\d(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$'
example: "14:30+02:00"
department_daily_targets:
type: object
description: |
Optional per-department custom daily targets. Keys are department IDs and values are non-negative integers representing the target per operating day for that department.
If omitted, the daily target is split evenly across selected departments. The canonical field name is snake_case `department_daily_targets`.
For backward-compatibility the API also accepts camelCase `departmentDailyTargets` on input.
additionalProperties:
type: integer
minimum: 0
example:
"12": 3
"15": 5
DepartmentGoal:
type: object
@@ -1,5 +1,5 @@
<script setup>
import {defineProps, defineSlots, onMounted, ref, watch, defineEmits} from 'vue';
import { onMounted, ref, watch } from 'vue';
const props = defineProps({
/**
@@ -2,6 +2,7 @@
import logo from "@/assets/img/truckwash.png";
import LanguageSwitcher from "@/components/LanguageSwitcher.vue";
import {onBeforeUnmount, onMounted} from "vue";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
onMounted(() => {
const headerWrapper = document.querySelector('.header-wrapper');
const toggle = document.querySelector('.menu-toggle__icon');
+4 -1
View File
@@ -10,6 +10,7 @@ import ViewportFooter from "@/components/viewport/page/footers/ViewportFooter.vu
import {useRouter} from "vue-router";
import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue";
import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
const router = useRouter();
console.log("Viewport component loaded");
@@ -77,7 +78,9 @@ const isFooterInContent = computed(() => {
}">
<div class="independent-scroll slick-scroll-layout" :style="{ maxHeight: independentScrollMaxHeight }">
<ViewportContent>
<slot />
<ConnectivityIssue>
<slot />
</ConnectivityIssue>
<div class="has-text-centered" v-show="isFooterInContent">
<a class="is-align-content-center" href="/">
<!-- All rights reserved footer logo -->
+26 -18
View File
@@ -843,22 +843,22 @@
"updated": "Opdateret",
"updated_at": "Opdateret",
"upload": "Upload",
"user": "Bruger",
"users": "Brugere",
"vehicle": "Køretøj",
"vehicles": "Køretøjer",
"via": "Via",
"view": "Vis",
"warning": "Advarsel",
"was": "var",
"week": "Uge",
"were": "var",
"will": "vil",
"would": "ville",
"year": "År",
"yes": "Ja",
"yesterday": "I går",
"zip": "Postnummer"
"user": "Bruger",
"users": "Brugere",
"vehicle": "Køretøj",
"vehicles": "Køretøjer",
"via": "Via",
"view": "Vis",
"warning": "Advarsel",
"was": "var",
"week": "Uge",
"were": "var",
"will": "vil",
"would": "ville",
"year": "År",
"yes": "Ja",
"yesterday": "I går",
"zip": "Postnummer"
},
"configuration": {
"backups": {
@@ -3881,7 +3881,15 @@
"enter_code": "Indtast din bekræftelseskode",
"back_to_login": "Tilbage til login"
},
"common": {
"cancel": "Annuller"
"connectivity": {
"title": "Forbindelsesproblem",
"subtitle": "Det ser ud til, at der er et problem med din internetforbindelse eller vores server.",
"no_internet": "Ingen internetforbindelse.",
"server_down": "Ingen forbindelse til serveren.",
"description": "Dette løser sig som regel selv. Vent venligst, mens vi tjekker serveren.",
"retry_in": "Prøver igen om {seconds} sekunder...",
"retry_now": "Prøver at genoprette forbindelsen...",
"retry": "Prøv igen",
"footer": "Hvis problemet fortsætter, kontakt venligst support."
}
}
+11
View File
@@ -3807,5 +3807,16 @@
"subtitle": "Her kan du håndtere XLVask integrationen",
"title": "XLVask administration"
}
},
"connectivity": {
"title": "Verbindungsproblem",
"subtitle": "Wird alle paar Sekunden automatisch versucht, die Verbindung wiederherzustellen.",
"no_internet": "Keine Internetverbindung.",
"server_down": "Keine Verbindung zum Server.",
"description": "Dies klärt sich in der Regel von selbst. Bitte warten Sie, während wir den Server überprüfen.",
"retry_in": "Wird erneut versucht in {seconds} Sekunden...",
"retry_now": "Versuche Verbindung wiederherzustellen...",
"retry": "Wiederholen",
"footer": "Wenn das Problem weiterhin besteht, kontaktieren Sie bitte den Support."
}
}
+10 -2
View File
@@ -3876,7 +3876,15 @@
"enter_code": "Enter your verification code",
"back_to_login": "Back to Login"
},
"common": {
"cancel": "Cancel"
"connectivity": {
"title": "Connectivity Issue",
"subtitle": "Retrying every 5 seconds...",
"no_internet": "No internet connection.",
"server_down": "No connection to the server.",
"description": "This usually resolves itself. Please wait while we check the server.",
"retry_in": "Retrying in {seconds} seconds...",
"retry_now": "Trying to reconnect...",
"retry": "Retry",
"footer": "If the issue persists, please contact support."
}
}
+11
View File
@@ -3837,5 +3837,16 @@
"not_supported_browser": "Nettleseren din støtter ikke passnøkler. Vennligst prøv en moderne nettleser som Chrome, Safari, Firefox eller Edge.",
"login_with_passkey": "Logg inn med passnøkkel",
"login_failed": "Kunne ikke logge inn med passnøkkel. Vennligst prøv igjen."
},
"connectivity": {
"title": "Forbindelsesproblem",
"subtitle": "Det ser ut til at det er et problem med din internettforbindelse eller vår server.",
"no_internet": "Ingen internettforbindelse.",
"server_down": "Ingen forbindelse til serveren.",
"description": "Dette løser seg vanligvis av seg selv. Vennligst vent mens vi sjekker serveren.",
"retry_in": "Prøver på nytt om {seconds} sekunder...",
"retry_now": "Prøver å gjenopprette forbindelsen...",
"retry": "Prøv på nytt",
"footer": "Hvis problemet vedvarer, kontakt support."
}
}
+11
View File
@@ -3844,5 +3844,16 @@
"not_supported_browser": "Din webbläsare stöder inte lösennycklar. Prova en modern webbläsare som Chrome, Safari, Firefox eller Edge.",
"login_with_passkey": "Logga in med lösennyckel",
"login_failed": "Kunde inte logga in med lösennyckel. Försök igen."
},
"connectivity": {
"title": "Anslutningsproblem",
"subtitle": "Försöker igen var 5:e sekund...",
"no_internet": "Ingen internetanslutning.",
"server_down": "Ingen anslutning till servern.",
"description": "Det här brukar lösa sig själv. Vänta medan vi kontrollerar servern.",
"retry_in": "Försöker igen om {seconds} sekunder...",
"retry_now": "Försöker att återställa anslutningen...",
"retry": "Försök igen",
"footer": "Om problemet kvarstår, kontakta supporten."
}
}
+6
View File
@@ -46,6 +46,7 @@ import MyMaterials from "@/views/dashboards/userDashboard/materials/MyMaterials.
/** Errors */
import NotFound from './views/errors/NotFound.vue';
import ConnectivityIssue from './views/errors/ConnectivityIssue.vue';
import MyOrders from "@/views/dashboards/userDashboard/orders/MyOrders.vue";
import NewVehicle from "@/views/dashboards/userDashboard/vehicles/NewVehicle.vue";
import MyBookings from "@/views/dashboards/userDashboard/bookings/MyBookings.vue";
@@ -886,6 +887,11 @@ export const router = createRouter({
path: '/guest/book/wash',
component: GuestBookExteriorWash,
},
{
name: 'connectivity-issue',
path: '/connectivity-issue',
component: ConnectivityIssue
},
{
name: 'notfound',
path: '/:pathMatch(.*)*',
+1
View File
@@ -3,6 +3,7 @@
* This is the user store.
*/
import { createStore } from 'vuex';
import { API_URL } from '../config.js';
export default createStore({
state: {
+4 -1
View File
@@ -6,6 +6,7 @@ import AuthPageWrapper from "@/components/page/wrappers/AuthPageWrapper.vue";
import GuestDashboardPageWrapper from "@/views/guest/GuestDashboardPageWrapper.vue";
import Viewport from "@/components/viewport/Viewport.vue";
import logo from "@/assets/img/truckwash-banner.png";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
</script>
<template>
@@ -18,7 +19,9 @@ import logo from "@/assets/img/truckwash-banner.png";
<router-link :to="{ name: 'landing' }" class="logo">
<img :src="logo" alt="Truck Wash">
</router-link>
<LoginForm />
<ConnectivityIssue>
<LoginForm />
</ConnectivityIssue>
<p>{{ $t('auth.terms_acceptance') }}</p>
</div>
</div>
+4 -1
View File
@@ -1,6 +1,7 @@
<script setup>
import SubuserLoginForm from "@/components/forms/auth/SubuserLoginForm.vue";
import logo from "@/assets/img/truckwash-banner.png";
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
</script>
<template>
@@ -13,7 +14,9 @@ import logo from "@/assets/img/truckwash-banner.png";
<router-link :to="{ name: 'landing' }" class="logo">
<img :src="logo" alt="Truck Wash">
</router-link>
<SubuserLoginForm />
<ConnectivityIssue>
<SubuserLoginForm />
</ConnectivityIssue>
<p>{{ $t('auth.terms_acceptance') }}</p>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup>
import {defineProps, onMounted, watch} from 'vue'
import { onMounted, watch} from 'vue'
import { ref } from 'vue'
import {authenticatedRequest} from "@/components/session/authenticatedRequest.vue";
const props = defineProps({
@@ -200,17 +200,20 @@ const getColspan = () => {
<div>
<table class="table is-fullwidth is-hoverable is-striped"
style="table-layout: fixed;">
<tr>
<th>{{ t('tables.bookings.date') }}</th>
<th v-show="fieldVisibility.customer_number">{{ t('tables.common.customer') }}</th>
<th>{{ t('tables.bookings.vehicles') }}</th>
<th>{{ t('tables.bookings.department') }}</th>
<th>{{ t('tables.bookings.po') }}</th>
<th>{{ t('tables.bookings.reference') }}</th>
<th>{{ t('tables.bookings.pickup') }}</th>
<th>{{ t('tables.bookings.services') }}</th>
<th><!-- Actions --></th>
</tr>
<thead>
<tr>
<th>{{ t('tables.bookings.date') }}</th>
<th v-show="fieldVisibility.customer_number">{{ t('tables.common.customer') }}</th>
<th>{{ t('tables.bookings.vehicles') }}</th>
<th>{{ t('tables.bookings.department') }}</th>
<th>{{ t('tables.bookings.po') }}</th>
<th>{{ t('tables.bookings.reference') }}</th>
<th>{{ t('tables.bookings.pickup') }}</th>
<th>{{ t('tables.bookings.services') }}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<tbody>
<template v-if="(objects || []).length === 0">
<tr>
<td :colspan="getColspan()" class="has-text-centered">{{ t('tables.bookings.no_bookings_found') }}</td>
@@ -394,7 +397,8 @@ const getColspan = () => {
</tr>
</template>
</template>
</table>
</tbody>
</table>
</div>
</template>
<template #mobile>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import {useI18n} from "vue-i18n";
import {BButton, BIcon} from "buefy";
const { t } = useI18n();
import { SessionUser } from "@/components/session/token/SessionUser.vue";
// font awesome icon name
const iconName = 'wifi';
const connectionStatus = ref<'loading' | 'ok' | 'error'>('loading');
const check_connection = () => {
reset_timer();
try {
SessionUser.request('/ping')
.then((response: any) => {
connectionStatus.value = 'ok';
console.warn('Connection OK', response);
})
.catch((error: any) => {
// Keep the user on the error page if the connection check fails
connectionStatus.value = 'error';
console.error('Connection failed, staying on the error page', error);
});
} catch (error) {
connectionStatus.value = 'error';
console.error('Unexpected error during connection check', error);
}
};
onMounted(() => {
check_connection();
// Check every 5 seconds
setInterval(() => {
check_connection();
}, 5000);
})
const timer = ref<NodeJS.Timeout | null>(null);
const clear_timer_interval = () => {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
}
const reset_timer = () => {
clear_timer_interval();
start_timer_interval();
}
const start_timer_interval = () => {
time_until_retry.value = 5;
timer.value = setInterval(() => {
time_until_retry.value--;
if (time_until_retry.value === 0) {
clearInterval(timer.value);
}
}, 1000)
}
const time_until_retry = ref(5);
</script>
<template>
<template v-if="connectionStatus === 'error'">
<section class="hero is-light" v-if="connectionStatus === 'error'">
<div class="hero-body">
<div class="container has-text-centered">
<div class="icon is-large mb-5">
<b-icon :icon="iconName" type="is-danger" size="is-large" style="animation: pulse 2s infinite;" pack="fas"></b-icon>
</div>
<h1 class="title mb-4">{{ t('connectivity.title') }}</h1>
<h2 class="subtitle mb-5">{{ t('connectivity.subtitle') }}</h2>
<p class="content mt-6">{{ t('connectivity.description') }}</p>
<!-- Retry timer -->
<p class="content mt-2">
<span v-if="time_until_retry > 0">{{ t('connectivity.retry_in', { seconds: time_until_retry }) }}</span>
<span v-else>{{ t('connectivity.retry_now') }}</span>
</p>
<b-button
class="mt-6"
type="is-primary"
size="is-large"
icon-left="refresh"
iconPack="fas"
@click="check_connection()"
>
{{ t('connectivity.retry') }}
</b-button>
</div>
</div>
<div class="hero-foot">
<div class="container has-text-centered">
<p class="is-size-7 has-text-grey">{{ t('connectivity.footer') }}</p>
</div>
</div>
</section>
</template>
<template v-if="connectionStatus === 'ok' || connectionStatus === 'loading'">
<slot></slot>
</template>
</template>
+201
View File
@@ -0,0 +1,201 @@
/**
* Test to verify i18n translations for MyBookingsBook component
* are properly defined in both English and Danish locale files.
*/
describe('i18n Bookings Translations', () => {
const bookingKeys = [
'guest_mode_warning',
'guest_mode_login_prompt',
'login',
'select_wash_hall',
'select_vehicle',
'is_this_your_vehicle',
'next_step_addons',
'product_selection_unavailable',
'login_for_products',
'interior_wash',
'exterior_wash',
'no_items_in_basket',
'total',
'required',
'expected_date_time',
'reference',
'po_number',
'note',
'customer_number',
'enter_customer_number',
'customer_not_found',
'pickup',
'incl_pickup',
'basket',
'step_x_of_y',
'select_a_wash_hall',
'enter_vehicle',
'select_products',
'select_date_time_customer',
'back',
'next',
'go_to_confirmation',
'missing_wash_hall',
'missing_customer_number',
'missing_valid_customer_number',
'missing_vehicle',
'missing_date',
'missing_products',
'error_copied',
'guest_info_html',
'confirm_booking'
];
const commonKeys = [
'select',
'during_the_day',
'approx',
'thank_you',
'booking_created',
'unknown_error',
'error_creating_booking',
'try_again',
'copy_error',
'cancel',
'close',
'confirm'
];
it('should have all booking translations defined in English', async () => {
await browser.url('/');
const result = await browser.executeAsync(async (keys, done) => {
try {
const response = await fetch('/src/i18n/locales/en.json');
const data = await response.json();
if (!data.user_dashboard || !data.user_dashboard.bookings || !data.user_dashboard.bookings.book) {
done({ success: false, error: 'user_dashboard.bookings.book namespace not found' });
return;
}
const missingKeys = [];
for (const key of keys) {
if (!data.user_dashboard.bookings.book[key]) {
missingKeys.push(key);
}
}
if (missingKeys.length > 0) {
done({ success: false, error: 'Missing keys: ' + missingKeys.join(', ') });
} else {
done({ success: true, keyCount: Object.keys(data.user_dashboard.bookings.book).length });
}
} catch (e) {
done({ success: false, error: e.message });
}
}, bookingKeys);
console.log('English booking result:', JSON.stringify(result));
expect(result.success).toBe(true);
});
it('should have all booking translations defined in Danish', async () => {
await browser.url('/');
const result = await browser.executeAsync(async (keys, done) => {
try {
const response = await fetch('/src/i18n/locales/da.json');
const data = await response.json();
if (!data.user_dashboard || !data.user_dashboard.bookings || !data.user_dashboard.bookings.book) {
done({ success: false, error: 'user_dashboard.bookings.book namespace not found' });
return;
}
const missingKeys = [];
for (const key of keys) {
if (!data.user_dashboard.bookings.book[key]) {
missingKeys.push(key);
}
}
if (missingKeys.length > 0) {
done({ success: false, error: 'Missing keys: ' + missingKeys.join(', ') });
} else {
done({ success: true, keyCount: Object.keys(data.user_dashboard.bookings.book).length });
}
} catch (e) {
done({ success: false, error: e.message });
}
}, bookingKeys);
console.log('Danish booking result:', JSON.stringify(result));
expect(result.success).toBe(true);
});
it('should have all common translations defined in English', async () => {
await browser.url('/');
const result = await browser.executeAsync(async (keys, done) => {
try {
const response = await fetch('/src/i18n/locales/en.json');
const data = await response.json();
if (!data.common) {
done({ success: false, error: 'common namespace not found' });
return;
}
const missingKeys = [];
for (const key of keys) {
if (!data.common[key]) {
missingKeys.push(key);
}
}
if (missingKeys.length > 0) {
done({ success: false, error: 'Missing keys: ' + missingKeys.join(', ') });
} else {
done({ success: true, keyCount: Object.keys(data.common).length });
}
} catch (e) {
done({ success: false, error: e.message });
}
}, commonKeys);
console.log('English common result:', JSON.stringify(result));
expect(result.success).toBe(true);
});
it('should have all common translations defined in Danish', async () => {
await browser.url('/');
const result = await browser.executeAsync(async (keys, done) => {
try {
const response = await fetch('/src/i18n/locales/da.json');
const data = await response.json();
if (!data.common) {
done({ success: false, error: 'common namespace not found' });
return;
}
const missingKeys = [];
for (const key of keys) {
if (!data.common[key]) {
missingKeys.push(key);
}
}
if (missingKeys.length > 0) {
done({ success: false, error: 'Missing keys: ' + missingKeys.join(', ') });
} else {
done({ success: true, keyCount: Object.keys(data.common).length });
}
} catch (e) {
done({ success: false, error: e.message });
}
}, commonKeys);
console.log('Danish common result:', JSON.stringify(result));
expect(result.success).toBe(true);
});
});