diff --git a/felsokning/app/src/App.tsx b/felsokning/app/src/App.tsx index cc32107..1250573 100644 --- a/felsokning/app/src/App.tsx +++ b/felsokning/app/src/App.tsx @@ -21,6 +21,11 @@ import DelatArende from "./pages/felsokning/DelatArende"; import PublikDelning from "./pages/felsokning/PublikDelning"; import Oversikt from "./pages/felsokning/Oversikt"; import Installningar from "./pages/felsokning/Installningar"; +import AlvaStart from "./pages/alva/Start"; +import AlvaAnsokan from "./pages/alva/Ansokan"; +import AlvaLoggaIn from "./pages/alva/LoggaIn"; +import AlvaPortal from "./pages/alva/Portal"; +import AlvaKunskapskallor from "./pages/alva/Kunskapskallor"; import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner"; const queryClient = new QueryClient(); @@ -51,6 +56,14 @@ function AppContent() { } /> } /> } /> + {/* ALVA — publik webbplats och kundportal. Samma system, samma + komponentbibliotek: besökaren lämnar aldrig marknadswebben + när den loggar in. */} + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/felsokning/app/src/alva/komponenter.tsx b/felsokning/app/src/alva/komponenter.tsx index 968b270..d07edf1 100644 --- a/felsokning/app/src/alva/komponenter.tsx +++ b/felsokning/app/src/alva/komponenter.tsx @@ -123,7 +123,7 @@ export function Falt({ etikett, children }: { etikett: string; children: ReactNo return (
{etikett} -
+
{children}
@@ -135,14 +135,14 @@ export function Block({ children, rubrik, beteckning }: { children: ReactNode; r
{(rubrik || beteckning) && (
{rubrik && {rubrik}} {beteckning && {beteckning}}
)} -
{children}
+
{children}
); } @@ -164,7 +164,7 @@ export function Fasrad({ aktiv, klara = [] }: { aktiv: Fas; klara?: Fas[] }) {
  • {f.bokstav}
  • -
    {f.namn}
    -
    +
    {f.namn}
    +
    {f.syfte}
    @@ -302,7 +302,7 @@ export function Tabell({ kolumner, rader }: { kolumner: string[]; rader: ReactNo {k} @@ -314,7 +314,7 @@ export function Tabell({ kolumner, rader }: { kolumner: string[]; rader: ReactNo {rader.map((rad, i) => ( {rad.map((cell, j) => ( - + {cell} ))} diff --git a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts new file mode 100644 index 0000000..118689f --- /dev/null +++ b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +// Disciplinen är det karakteristiska, inte färgerna. +// +// Ett designsystem som bara finns i ett dokument blir urvattnat på tredje +// sidan någon lägger till. Testet läser den faktiska källkoden för +// webbplatsen och portalen och låser de regler som annars glider: +// inga gradienter, ingen animation, inga emojier, inga marknadsföringsord, +// och ingen färg utanför paletten. +// +// Det testar utseende genom att läsa kod, vilket är trubbigt. Men det +// fångar exakt den sortens tillägg som faktiskt sker — en `animate-pulse` +// här, en `bg-gradient-to-r` där — och som ingen enskilt tycker är fel. +import { readdirSync, readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { FORBJUDNA_ORD } from "@/alva/sprak"; + +const KATALOGER = ["src/alva", "src/pages/alva"]; + +const filer = KATALOGER.flatMap((katalog) => + readdirSync(katalog) + .filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")) + .map((f) => [`${katalog}/${f}`, readFileSync(`${katalog}/${f}`, "utf8")] as const), +); + +/** Kommentarer får diskutera det som är förbjudet i gränssnittet. */ +const utanKommentarer = (kod: string) => + kod.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + +describe("ALVA-ytan följer designsystemet", () => { + it("hittar filerna att granska", () => { + expect(filer.length).toBeGreaterThanOrEqual(7); + }); + + it.each(filer)("%s använder ingen gradient, glaseffekt eller skugga", (_namn, kod) => { + const k = utanKommentarer(kod); + expect(k).not.toMatch(/gradient/i); + expect(k).not.toMatch(/backdrop-blur|backdrop-filter/i); + expect(k).not.toMatch(/\bshadow-(sm|md|lg|xl|2xl)\b/); + }); + + it.each(filer)("%s innehåller ingen animation", (_namn, kod) => { + const k = utanKommentarer(kod); + // Rörelse som inte bär information är brus i ett utrymme där + // teknikern redan har för mycket. + expect(k).not.toMatch(/\banimate-|\btransition-|@keyframes/); + }); + + it.each(filer)("%s använder inga emojier", (_namn, kod) => { + const k = utanKommentarer(kod); + // Fyra tecken är tillåtna: ✓ ○ □ → + const emojier = k.match(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu) ?? []; + expect(emojier.filter((t) => !["✓", "○", "□", "→"].includes(t))).toEqual([]); + }); + + it.each(filer)("%s använder bara färger ur paletten", (_namn, kod) => { + const k = utanKommentarer(kod); + const palett = ["#1B1E22", "#4D5662", "#D7DCE2", "#F6F7F8", "#005CA9", "#FFFFFF"]; + const funna = (k.match(/#[0-9A-Fa-f]{3,8}\b/g) ?? []).map((f) => f.toUpperCase()); + expect(funna.filter((f) => !palett.includes(f))).toEqual([]); + }); + + it.each(filer.filter(([n]) => !n.endsWith("sprak.ts")))( + "%s innehåller inga marknadsföringsord", + (_namn, kod) => { + // sprak.ts undantas: den definierar listan och måste innehålla den. + const text = utanKommentarer(kod).toLowerCase(); + for (const ord of FORBJUDNA_ORD) expect(text, ord).not.toContain(ord); + }, + ); + + it("hela ytan undviker AI-språk — produkten beskrivs som en metod", () => { + for (const [namn, kod] of filer) { + const text = utanKommentarer(kod); + expect(text, namn).not.toMatch(/\bAI[- ]?(assistant|assistent|powered|driven)\b/i); + expect(text, namn).not.toMatch(/\bchatt?bot\b/i); + } + }); + + it("rubriker är versaler — sektionsetiketter, inte meningar", () => { + const komponenter = readFileSync("src/alva/komponenter.tsx", "utf8"); + expect(komponenter).toMatch(/uppercase/); + // Både Etikett och Rubrik måste sätta versaler. + const rubrik = komponenter.slice(komponenter.indexOf("export function Rubrik")); + expect(rubrik.slice(0, 600)).toMatch(/uppercase/); + }); + + it("avståndsmått följer 8 px-rutnätet", () => { + // Rutnätet gäller layout, inte typografi: teckenstorlek och + // radavstånd sätts av läsbarhet, inte av rytmen i sidan. Det som + // mäts här är Tailwinds avståndsklasser, som är 4 px-steg — ett + // jämnt steg är alltså en 8 px-multipel. + const udda: string[] = []; + for (const [namn, kod] of filer) { + for (const m of utanKommentarer(kod).matchAll(/\b(p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g)) { + if (Number(m[2]) % 2 !== 0) udda.push(`${namn}: ${m[0]}`); + } + } + expect(udda).toEqual([]); + }); +}); diff --git a/felsokning/app/src/pages/alva/Ansokan.tsx b/felsokning/app/src/pages/alva/Ansokan.tsx new file mode 100644 index 0000000..135cdfa --- /dev/null +++ b/felsokning/app/src/pages/alva/Ansokan.tsx @@ -0,0 +1,126 @@ +// Kontoansökan. +// +// Ingen självbetjäning, ingen kortbetalning, ingen provperiod. Ansökan +// granskas manuellt och det står på sidan — inte som en ursäkt utan som +// en uppgift. Ett system som avgör vad som får kallas verifierat i en +// garantitvist kan inte dela ut konton automatiskt. + +import { type FormEvent, useState } from "react"; +import { Block, Etikett, FARG, Knapp, Rubrik, Statusmärke } from "@/alva/komponenter"; +import { Ram } from "./Ram"; + +interface Falt { + namn: string; + etikett: 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" }, +]; + +export default function Ansokan() { + const [skickad, setSkickad] = useState(false); + const [referens, setReferens] = useState(""); + + const skicka = (e: FormEvent) => { + e.preventDefault(); + // Referensen bildas lokalt så sökanden har något att hänvisa till + // omedelbart. Den slutliga beteckningen sätts vid granskning. + const data = new FormData(e.currentTarget); + const bas = String(data.get("orgnummer") ?? "").replace(/\D/g, "").slice(-4) || "0000"; + setReferens(`ALVA-ORG-${bas}`); + setSkickad(true); + }; + + if (skickad) { + return ( + +
    + Application +
    + Submitted +
    + +
    +
    +
    Status
    +
    + +
    +
    +
    +
    Reference
    +
    {referens}
    +
    +
    +

    + Applications are reviewed manually. An invoice is issued on approval. The organization is activated + once payment is registered. +

    +
    +
    +
    + ); + } + + return ( + +
    + Registration +
    + Request account +
    +

    + Applications are reviewed manually. No payment is taken at this stage. +

    + +
    + + {FALT.map((f) => ( +
    + + +
    + ))} +
    + +