diff --git a/felsokning/app/src/alva/webbsprak.ts b/felsokning/app/src/alva/webbsprak.ts new file mode 100644 index 0000000..f045c4a --- /dev/null +++ b/felsokning/app/src/alva/webbsprak.ts @@ -0,0 +1,56 @@ +// ALVA-SPEC-060 · Webbplatsens språk. +// +// Plattformen väljer språk ur konto → organisation → webbläsare. Den +// publika webbplatsen har inget konto och ingen organisation — där är +// kedjan besökarens eget val (sparat lokalt) → webbläsaren → engelska. +// Rangordningen ligger kvar i `valjSprak`, så att "hur språk väljs" bara +// finns implementerat på ett ställe. +// +// Valet lagras i localStorage men hålls också i minnet: en webbläsare i +// privat läge kan neka lagring, och då ska väljaren ändå fungera för +// sidvisningen i stället för att tyst göra ingenting. + +import { useSyncExternalStore } from "react"; +import { STANDARD, valjSprak } from "../../../services/gemensam/sprak/index.mjs"; + +const NYCKEL = "alva.webbsprak"; +const lyssnare = new Set<() => void>(); +let minne: string | undefined; + +function lagratVal(): string | undefined { + try { + return localStorage.getItem(NYCKEL) ?? minne; + } catch { + return minne; + } +} + +/** Webbplatsens språk just nu: eget val → webbläsare → engelska. */ +export function webbSprak(): string { + const webblasare = + typeof navigator === "undefined" ? [] : (navigator.languages ?? [navigator.language]); + return valjSprak({ anvandare: lagratVal(), webblasare }) as string; +} + +/** Sätter besökarens val och meddelar alla vyer som lyssnar. */ +export function sattWebbSprak(kod: string): void { + minne = kod; + try { + localStorage.setItem(NYCKEL, kod); + } catch { + // Se filhuvudet: minnesvärdet bär valet när lagringen nekar. + } + for (const meddela of lyssnare) meddela(); +} + +/** React-krok: webbplatsens språk, med omritning vid byte. */ +export function useWebbSprak(): string { + return useSyncExternalStore( + (meddela) => { + lyssnare.add(meddela); + return () => lyssnare.delete(meddela); + }, + webbSprak, + () => STANDARD as string, + ); +} diff --git a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts index 8347da8..ac827a5 100644 --- a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts +++ b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts @@ -339,22 +339,30 @@ describe("ytor utan verklig funktion är märkta som demonstration", () => { // En märkning som inte går att missförstå säger två saker: att ingenting // sker, och vad läsaren ska göra i stället. describe("demonstrationsmärkningen är entydig", () => { + // Texterna bor sedan flerspråkigheten i katalogen. Garantin är därför + // tvådelad: källtexten säger det som ska sägas, OCH sidan hämtar just + // den nyckeln — annars kan en riktig text stå oanvänd i katalogen. const sida = (namn: string) => filer.find(([n]) => n.endsWith(`pages/alva/${namn}`))![1]; - it("inloggningen säger vad besökaren ska göra", () => { - const kod = sida("LoggaIn.tsx"); - expect(kod).toContain("Type anything"); - expect(kod).toContain("no account is needed"); + it("inloggningen säger vad besökaren ska göra", async () => { + const { EN } = await import("../../../../services/gemensam/sprak/en.mjs"); + expect(EN["webb.login.demo"]).toContain("Type anything"); + expect(EN["webb.login.demo"]).toContain("no account is needed"); + expect(sida("LoggaIn.tsx")).toContain('t("webb.login.demo")'); }); - it("ansökan säger att det inte finns någon mottagning", () => { - expect(sida("Ansokan.tsx")).toContain("there is no intake behind it yet"); + it("ansökan säger att det inte finns någon mottagning", async () => { + const { EN } = await import("../../../../services/gemensam/sprak/en.mjs"); + expect(EN["webb.ansokan.demo"]).toContain("there is no intake behind it yet"); + expect(sida("Ansokan.tsx")).toContain('t("webb.ansokan.demo")'); }); - it("beskrivningen av avsedd drift är märkt som avsedd, inte som gällande", () => { + it("beskrivningen av avsedd drift är märkt som avsedd, inte som gällande", async () => { // Två meningar bredvid varandra, där den ena säger att inget händer // och den andra beskriver en handläggningsprocess, upphäver varandra. - expect(sida("Ansokan.tsx")).toContain("Intended operation:"); + const { EN } = await import("../../../../services/gemensam/sprak/en.mjs"); + expect(EN["webb.ansokan.avsikt"]).toContain("Intended operation:"); + expect(sida("Ansokan.tsx")).toContain('t("webb.ansokan.avsikt")'); }); it("ingen märkning hänvisar till något som läsaren inte kan nå", () => { @@ -432,7 +440,7 @@ describe("portalen är stängd, eller uttryckligen en demonstration", () => { expect(loggaIn).toContain("loggaInPlattform"); // Och skylten står kvar exakt när den ska: bara i det läge där // inloggningen inte prövar något. - expect(loggaIn).toMatch(/!skarpt && \(\s*/); + expect(loggaIn).toMatch(/!skarpt && (\(\s*)?/); }); it("fakturavyn visar organisationens egna fakturor mot en plattform", () => { @@ -465,18 +473,22 @@ describe("inloggningen heter samma sak överallt", () => { const start = readFileSync("src/pages/alva/Start.tsx", "utf8"); it("navigationens namn på inloggningen är det som används", () => { - const namn = ram.match(/\{ till: "\/alva\/logga-in", text: "([^"]+)" \}/)?.[1]; - expect(namn).toBeTruthy(); - // Varje länk till inloggningen på startsidan bär samma text. + // Sedan flerspråkigheten jämförs KATALOGNYCKLAR, inte strängar — det + // är starkare: samma nyckel är samma ord på vartenda språk, medan två + // nycklar med samma engelska text hade kunnat glida isär i en enda + // översättning. + const nyckel = ram.match(/\{ till: "\/alva\/logga-in", nyckel: "([^"]+)" \}/)?.[1]; + expect(nyckel).toBeTruthy(); + // Varje länk till inloggningen på startsidan bär samma nyckel. // // Mönstret tål attribut på länken. Första versionen gjorde inte det, // och när fick en className matchade den noll knappar — testet // slutade mäta i stället för att falla. Det enda som avslöjade det var // kravet nedan på att den ska hitta NÅGOT, vilket är därför det står // där och inte som en självklarhet. - const knappar = [...start.matchAll(/to="\/alva\/logga-in"[^>]*>\s*]*>([^<]+)<\/Knapp>/g)]; + const knappar = [...start.matchAll(/to="\/alva\/logga-in"[^>]*>\s*]*>\{t\("([^"]+)"\)\}<\/Knapp>/g)]; expect(knappar.length).toBeGreaterThan(0); - for (const [, text] of knappar) expect(text.trim()).toBe(namn); + for (const [, n] of knappar) expect(n).toBe(nyckel); }); it("ingen ingång kallar verkstaden för kund", () => { diff --git a/felsokning/app/src/felsokning/__tests__/sprak.test.ts b/felsokning/app/src/felsokning/__tests__/sprak.test.ts index 81051be..4974a26 100644 --- a/felsokning/app/src/felsokning/__tests__/sprak.test.ts +++ b/felsokning/app/src/felsokning/__tests__/sprak.test.ts @@ -194,6 +194,31 @@ describe("metodikvarningen", () => { }); }); +describe("webbplatsens källtext", () => { + // Hjälteraden och metodkorten på startsidan hämtas ur katalogen, men + // systemdefinitionen i @/alva/system är normen. Två källor för samma + // mening driver isär tyst — testet gör skillnaden till ett byggfel. + it("hjälteraden och faskorten säger samma sak som systemdefinitionen", async () => { + const { ALVA, FASER } = await import("../../alva/system"); + expect(EN["webb.hero.position"]).toBe(ALVA.position); + expect(EN["webb.hero.definition"]).toBe(ALVA.definition); + for (const f of FASER) { + expect(EN[`webb.fas.${f.id}.syfte`], f.id).toBe(f.syfte); + expect(EN[`webb.fas.${f.id}.avgransning`], f.id).toBe(f.avgransning); + } + }); + + it("webbnycklarna finns i varje språk — sajten är inte engelska med undantag", () => { + const webb = NYCKLAR.filter((n) => n.startsWith("webb.")); + expect(webb.length).toBeGreaterThan(140); + for (const kod of OVRIGA) { + for (const n of webb) { + expect(KATALOGER[kod][n], `${kod}: ${n}`).toBeTruthy(); + } + } + }); +}); + function egetNamn(kod: string): string { return SPRAK.find((s) => s.kod === kod)!.egetNamn; } diff --git a/felsokning/app/src/pages/alva/Ansokan.tsx b/felsokning/app/src/pages/alva/Ansokan.tsx index a9463be..21f14de 100644 --- a/felsokning/app/src/pages/alva/Ansokan.tsx +++ b/felsokning/app/src/pages/alva/Ansokan.tsx @@ -7,29 +7,32 @@ import { type FormEvent, useState } from "react"; import { Demonstration, Block, Etikett, FARG, Knapp, Rubrik, Statusmärke } from "@/alva/komponenter"; +import { useWebbSprak } from "@/alva/webbsprak"; +import { oversattare } from "../../../../services/gemensam/sprak/index.mjs"; import { Ram } from "./Ram"; interface Falt { namn: string; - etikett: string; + nyckel: string; typ?: string; kravs?: boolean; - hjalp?: string; } const FALT: Falt[] = [ - { namn: "foretag", etikett: "Company", kravs: true }, - { namn: "orgnummer", etikett: "Organization number", kravs: true }, - { namn: "kontakt", etikett: "Contact person", kravs: true }, - { namn: "epost", etikett: "Email", typ: "email", kravs: true }, - { namn: "telefon", etikett: "Phone", typ: "tel", kravs: true }, - { namn: "tekniker", etikett: "Number of technicians", typ: "number", kravs: true }, - { namn: "bransch", etikett: "Industry", kravs: true }, - { namn: "land", etikett: "Country", kravs: true }, - { namn: "anvandare", etikett: "Expected number of users", typ: "number" }, + { namn: "foretag", nyckel: "webb.falt.foretag", kravs: true }, + { namn: "orgnummer", nyckel: "webb.falt.orgnummer", kravs: true }, + { namn: "kontakt", nyckel: "webb.falt.kontakt", kravs: true }, + { namn: "epost", nyckel: "webb.falt.epost", typ: "email", kravs: true }, + { namn: "telefon", nyckel: "webb.falt.telefon", typ: "tel", kravs: true }, + { namn: "tekniker", nyckel: "webb.falt.tekniker", typ: "number", kravs: true }, + { namn: "bransch", nyckel: "webb.falt.bransch", kravs: true }, + { namn: "land", nyckel: "webb.falt.land", kravs: true }, + { namn: "anvandare", nyckel: "webb.falt.anvandare", typ: "number" }, ]; export default function Ansokan() { + const sprak = useWebbSprak(); + const t = oversattare(sprak) as (nyckel: string) => string; const [skickad, setSkickad] = useState(false); const [referens, setReferens] = useState(""); @@ -47,31 +50,26 @@ export default function Ansokan() { return (
- Application + {t("webb.ansokan.mottagen.etikett")}
- Submitted + {t("webb.ansokan.mottagen")}
- - Nothing was submitted and no one was notified. The reference below was computed in your - browser from what you typed, and exists nowhere else — it will be gone when you close - this page. - - + {t("webb.ansokan.mottagen.demo")} +
-
Status
+
{t("webb.ansokan.status")}
-
Reference
+
{t("webb.ansokan.referens")}
{referens}

- Applications are reviewed manually. An invoice is issued on approval. The organization is activated - once payment is registered. + {t("webb.ansokan.granskning")}

@@ -82,18 +80,13 @@ export default function Ansokan() { return (
- Registration + {t("webb.ansokan.etikett")}
- Request account + {t("webb.ansok")}
- - Nothing is sent. Submitting this form stores nothing, notifies no one, and creates no - application — there is no intake behind it yet. The description below states how - registration is intended to work, not what happens today. - + {t("webb.ansokan.demo")}

- Intended operation: applications are reviewed manually and no payment is taken at this - stage. + {t("webb.ansokan.avsikt")}

@@ -101,9 +94,9 @@ export default function Ansokan() { {FALT.map((f) => (