From cafe671e85f92eac3475b7c9eea1363314324ffe Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Sun, 16 Aug 2026 22:38:03 +0200 Subject: [PATCH] feat(pleno-vue): add driver/subuser login button (mobile-visible) (TRU-61) (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Drivers (sub-users) could not reliably reach the driver login from the customer login page (/login) on mobile devices. The form contained an internal driver button, but it sat below the QR code and passkey buttons, making it easy to miss on small screens. The desktop sidebar layout further reduced discoverability. ## What changed - **src/views/auth/Login.vue** — Add a page-level .driver-entry block below the customer login form, with a router-link to { name: 'subuserlogin' }. Distinct color (#1584BC) and a truck icon match the existing LandingPage driver entry pattern. Full-width on mobile (<768px) so it's impossible to miss. - **i18n** — Add auth.driver_login_help to en, da, de, no, sv locale files + global shared auth index. auth.driver_login_button already existed in all locales. - **tests/unit/login-driver-entry.spec.js** — 5 Vitest tests verifying the driver entry block, router-link, button label, help text, and placement (not in sidebar). ## CI - Vitest unit tests pass (5/5 new tests, existing tests unaffected) - ESLint clean - Text encoding check clean - i18n v2 source check + compile clean Co-authored-by: Pleno Bugfix Bot --- src/i18n/generated/da-v2.json | 1 + src/i18n/generated/de-v2.json | 1 + src/i18n/generated/en-v2.json | 1 + src/i18n/generated/global-v2.json | 1 + src/i18n/generated/no-v2.json | 1 + src/i18n/generated/sv-v2.json | 1 + .../source/da/phrases/compat/auth/index.json | 1 + .../source/de/phrases/compat/auth/index.json | 1 + .../source/en/phrases/compat/auth/index.json | 1 + src/i18n/source/global/shared/auth/index.json | 1 + .../source/no/phrases/compat/auth/index.json | 1 + .../source/sv/phrases/compat/auth/index.json | 1 + src/views/auth/Login.vue | 81 +++++++++++ tests/unit/login-driver-entry.spec.js | 134 ++++++++++++++++++ 14 files changed, 227 insertions(+) create mode 100644 tests/unit/login-driver-entry.spec.js diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index a0977c39..64e67006 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -2143,6 +2143,7 @@ "customer_login_title": "@:{'templates.generated.compat.global.login'}", "driver_entry_intro": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.chauffør'}? @.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.her'} @:{'words.generated.for'} @:{'words.generated.at'} @:{'words.generated.fa'} @:{'words.generated.adgang'} @:{'words.generated.til'} @:{'words.generated.dine'} @:{'words.generated.vaske'}.", "driver_login_button": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @.lower:{'words.generated.som'} @:{'words.generated.chauffør'}", + "driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.", "driver_login_title": "@.capitalize:{'words.generated.chauffør'} @.capitalize:{'words.generated.login'}", "forgot_password": "Glemt @:{'words.generated.adgangskode'}", "operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} – @:{'words.generated.digitale'} @:{'words.generated.løsninger'} @:{'words.generated.til'} @:{'words.generated.lastbilvask'}", diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index 38b3b872..474d85e0 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -2260,6 +2260,7 @@ "username": "@:{'words.generated.benutzername'}", "your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}", "driver_login_button": "Fahrer-Login", + "driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.", "driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren." }, "book_wash": { diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index 857c1dce..1c71289d 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -1974,6 +1974,7 @@ "customer_login_title": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.login'}", "driver_entry_intro": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.a'} @:{'words.generated.driver'}? @.capitalize:{'words.generated.log'} @:{'words.generated.in'} @:{'words.generated.here'} @:{'words.generated.to'} @:{'words.generated.register'} @:{'words.generated.a'} @:{'words.generated.wash'}.", "driver_login_button": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}", + "driver_login_help": "Drivers (sub-users) log in here to register a wash.", "driver_login_title": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}", "forgot_password": "Forgot @.capitalize:{'words.generated.password'}", "operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} - @:{'words.generated.digital'} @:{'words.generated.solutions'} @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.washing'}", diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index 35d40d52..48fc9091 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -786,6 +786,7 @@ "customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}", "driver_entry_intro": "@:{'templates.generated.compat.auth.driver_entry_intro'}", "driver_login_button": "@:{'templates.generated.compat.auth.driver_login_button'}", + "driver_login_help": "@:{'templates.generated.compat.auth.driver_login_help'}", "driver_login_title": "@:{'templates.generated.compat.auth.driver_login_title'}", "forgot_password": "@:{'templates.generated.compat.auth.forgot_password'}", "login_as_customer": "@:{'templates.generated.compat.global.login_as_customer'}", diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index d35245ff..0fc554dc 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -2263,6 +2263,7 @@ "username": "@.capitalize:{'words.generated.brukernavn'}", "your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}", "driver_login_button": "Sjåfør-innlogging", + "driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.", "driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask." }, "book_wash": { diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index 8fd8ae72..9f6e61a6 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -2313,6 +2313,7 @@ "username": "@.capitalize:{'words.generated.anvandarnamn'}", "your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}", "driver_login_button": "Förarinloggning", + "driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.", "driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt." }, "book_wash": { diff --git a/src/i18n/source/da/phrases/compat/auth/index.json b/src/i18n/source/da/phrases/compat/auth/index.json index 7a146785..2d97e706 100644 --- a/src/i18n/source/da/phrases/compat/auth/index.json +++ b/src/i18n/source/da/phrases/compat/auth/index.json @@ -5,6 +5,7 @@ "customer_login_title": "@:{'phrases.compat.global.login'}", "driver_entry_intro": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.chauffør'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @:{'terms.glossary.her'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} @:{'terms.glossary.fa'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}.", "driver_login_button": "@.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @.lower:{'terms.glossary.som'} @:{'terms.glossary.chauffør'}", + "driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.", "driver_login_title": "@.capitalize:{'terms.glossary.chauffør'} @.capitalize:{'terms.glossary.login'}", "forgot_password": "Glemt @:{'terms.glossary.adgangskode'}", "operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} – @:{'terms.glossary.digitale'} @:{'terms.glossary.løsninger'} @:{'terms.glossary.til'} @:{'terms.glossary.lastbilvask'}", diff --git a/src/i18n/source/de/phrases/compat/auth/index.json b/src/i18n/source/de/phrases/compat/auth/index.json index 15d3360b..9fd4ce10 100644 --- a/src/i18n/source/de/phrases/compat/auth/index.json +++ b/src/i18n/source/de/phrases/compat/auth/index.json @@ -12,6 +12,7 @@ "username": "@:{'terms.glossary.benutzername'}", "your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}", "driver_login_button": "Fahrer-Login", + "driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.", "driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren." } } diff --git a/src/i18n/source/en/phrases/compat/auth/index.json b/src/i18n/source/en/phrases/compat/auth/index.json index f58ec9f8..a5e5ccd6 100644 --- a/src/i18n/source/en/phrases/compat/auth/index.json +++ b/src/i18n/source/en/phrases/compat/auth/index.json @@ -5,6 +5,7 @@ "customer_login_title": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.login'}", "driver_entry_intro": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.a'} @:{'terms.glossary.driver'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.in'} @:{'terms.glossary.here'} @:{'terms.glossary.to'} @:{'terms.glossary.register'} @:{'terms.glossary.a'} @:{'terms.glossary.wash'}.", "driver_login_button": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}", + "driver_login_help": "Drivers (sub-users) log in here to register a wash.", "driver_login_title": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}", "forgot_password": "Forgot @.capitalize:{'terms.glossary.password'}", "operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} - @:{'terms.glossary.digital'} @:{'terms.glossary.solutions'} @:{'terms.glossary.for'} @:{'terms.glossary.truck'} @:{'terms.glossary.washing'}", diff --git a/src/i18n/source/global/shared/auth/index.json b/src/i18n/source/global/shared/auth/index.json index 9e6d24f7..acba67d4 100644 --- a/src/i18n/source/global/shared/auth/index.json +++ b/src/i18n/source/global/shared/auth/index.json @@ -5,6 +5,7 @@ "customer_number": "@:{'phrases.compat.objects.columns.customer_number'}", "driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}", "driver_login_button": "@:{'phrases.compat.auth.driver_login_button'}", + "driver_login_help": "@:{'phrases.compat.auth.driver_login_help'}", "driver_login_title": "@:{'phrases.compat.auth.driver_login_title'}", "forgot_password": "@:{'phrases.compat.auth.forgot_password'}", "login_as_customer": "@:{'phrases.compat.global.login_as_customer'}", diff --git a/src/i18n/source/no/phrases/compat/auth/index.json b/src/i18n/source/no/phrases/compat/auth/index.json index 03b32a2c..92d89c24 100644 --- a/src/i18n/source/no/phrases/compat/auth/index.json +++ b/src/i18n/source/no/phrases/compat/auth/index.json @@ -12,6 +12,7 @@ "username": "@.capitalize:{'terms.glossary.brukernavn'}", "your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}", "driver_login_button": "Sjåfør-innlogging", + "driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.", "driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask." } } diff --git a/src/i18n/source/sv/phrases/compat/auth/index.json b/src/i18n/source/sv/phrases/compat/auth/index.json index e0dc9cda..b378a811 100644 --- a/src/i18n/source/sv/phrases/compat/auth/index.json +++ b/src/i18n/source/sv/phrases/compat/auth/index.json @@ -12,6 +12,7 @@ "username": "@.capitalize:{'terms.glossary.anvandarnamn'}", "your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.anvandarnamn'}", "driver_login_button": "Förarinloggning", + "driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.", "driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt." } } diff --git a/src/views/auth/Login.vue b/src/views/auth/Login.vue index fa79cc5b..7b71ae51 100644 --- a/src/views/auth/Login.vue +++ b/src/views/auth/Login.vue @@ -31,6 +31,23 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();

{{ $t('auth.terms_acceptance') }}

+ + +
+

{{ $t('auth.driver_login_help') }}

+ +
@@ -158,6 +175,55 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect(); text-decoration: none; } +/* Driver entry block — page-level, always visible on every viewport. + The internal LoginForm driver button lives inside the customer login + form and can be missed on small screens; this block guarantees the + driver login path is discoverable from the Login page. */ +.login-section .wrapper .main .driver-entry { + max-width: 400px; + width: 100%; + margin-top: 24px; + padding: 16px 20px; + background-color: #F2F8FC; + border: 1px solid #BFE0EF; + border-radius: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.login-section .wrapper .main .driver-entry__help { + max-width: none; + margin: 0; + font-size: 14px; + color: #13324C; + text-align: center; + line-height: 1.4; +} + +.login-section .wrapper .main .driver-entry .driver-login-link { + background-color: #1584BC; + color: #fff; + border: 0; + border-radius: 30px; + min-height: 43px; + font-size: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + text-decoration: none; + margin: 0; + width: auto; + padding: 0 24px; +} + +.login-section .wrapper .main .driver-entry .driver-login-link:hover { + background-color: #1a99d6; + color: #fff; +} + @media (max-width: 1250px) { .login-section .wrapper .sidebar h1{ font-size: 2.3em; @@ -187,5 +253,20 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect(); padding: 70px 10px; } } + +/* Mobile (<768px): ensure the driver entry block is always visible and + takes full width so it's impossible to miss on phones. */ +@media (max-width: 767px) { + .login-section .wrapper .main .driver-entry { + margin-top: 20px; + padding: 14px 16px; + width: 100%; + } + + .login-section .wrapper .main .driver-entry .driver-login-link { + width: 100%; + padding: 0 16px; + } +} /*end login-section*/ diff --git a/tests/unit/login-driver-entry.spec.js b/tests/unit/login-driver-entry.spec.js new file mode 100644 index 00000000..5329fb99 --- /dev/null +++ b/tests/unit/login-driver-entry.spec.js @@ -0,0 +1,134 @@ +// @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(), +})); + +vi.mock("@/services/sessionStorage.js", () => ({ + hasStoredSessionToken: mocks.hasStoredSessionToken, +})); + +vi.mock("@/composables/useStoredSessionGuestRedirect.js", () => ({ + useStoredSessionGuestRedirect: () => ({ + hasPendingStoredSession: mocks.hasStoredSessionToken(), + }), +})); + +vi.mock("@/components/forms/auth/LoginForm.vue", () => ({ + default: defineComponent({ + name: "MockLoginForm", + setup() { + return () => h("form", { "data-testid": "login-form" }); + }, + }), +})); + +vi.mock("@/views/errors/ConnectivityIssue.vue", () => ({ + default: defineComponent({ + name: "MockConnectivityIssue", + setup(_, { slots }) { + return () => h("div", { "data-testid": "connectivity-wrapper" }, slots.default?.()); + }, + }), +})); + +vi.mock("@/components/global/BrandedLoadingScreen.vue", () => ({ + default: defineComponent({ + name: "MockBrandedLoadingScreen", + props: { + testId: { type: String, default: "branded-loading-screen" }, + }, + setup(props) { + return () => h("div", { "data-testid": props.testId }); + }, + }), +})); + +import Login from "@/views/auth/Login.vue"; + +const routerLinkStub = defineComponent({ + name: "RouterLinkStub", + props: { + to: { + type: [String, Object], + default: "", + }, + }, + setup(_, { slots, attrs }) { + return () => h("a", attrs, slots.default?.()); + }, +}); + +const mountLogin = () => + mount(Login, { + global: { + mocks: { + $t: (key) => key, + }, + stubs: { + RouterLink: routerLinkStub, + }, + }, + }); + +describe("Login.vue — driver entry block (TRU-61)", () => { + beforeEach(() => { + mocks.hasStoredSessionToken.mockReset(); + mocks.hasStoredSessionToken.mockReturnValue(false); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders the page-level driver entry block on the customer login page", async () => { + const wrapper = mountLogin(); + await nextTick(); + + const entry = wrapper.find('[data-testid="login-driver-entry"]'); + expect(entry.exists()).toBe(true); + }); + + it("renders a router-link to the subuser login route inside the driver entry block", async () => { + const wrapper = mountLogin(); + await nextTick(); + + const link = wrapper.find('[data-testid="login-driver-login-link"]'); + expect(link.exists()).toBe(true); + }); + + it("renders the driver login button with the localized driver_login_button label", async () => { + const wrapper = mountLogin(); + await nextTick(); + + const link = wrapper.find('[data-testid="login-driver-login-link"]'); + expect(link.text()).toContain("auth.driver_login_button"); + }); + + it("renders the driver login help text inside the entry block", async () => { + const wrapper = mountLogin(); + await nextTick(); + + const entry = wrapper.find('[data-testid="login-driver-entry"]'); + expect(entry.text()).toContain("auth.driver_login_help"); + }); + + it("places the driver entry block in the main content area (not the sidebar)", async () => { + const wrapper = mountLogin(); + await nextTick(); + + // The sidebar should NOT contain the driver entry — it must be in main + const html = wrapper.html(); + const entryHtml = wrapper.find('[data-testid="login-driver-entry"]').html(); + // Find the sidebar's outer container and ensure the entry is not inside it + const sidebarMatch = html.match(/