Build 0pt ordering flow: configurator, deposit/escrow, production tracker, validation

Customer order lifecycle on the mono design system: red + provenance tokens, useReveal hook, lib/data pricing model, ProvenanceBadge/CellProvenance, OrderStepper, and 4 routes with live pricing, 50/50 escrow, simulated production, 4-view validation. tsc clean; next build green (5 routes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:28:24 +02:00
co-authored by Claude Opus 4.8
parent 5e53a6d71c
commit 24035dec8b
12 changed files with 1253 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
verify-deps-before-run=false
+133
View File
@@ -0,0 +1,133 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { OrderStepper } from "@/components/order-stepper";
import { formatSEK, computeEstimate, type Estimate } from "@/lib/data";
const FALLBACK: Estimate = computeEstimate({
markets: ["SE", "NO", "DK", "FI"],
type: "Bolag",
fieldIds: ["lei", "financials", "contact"],
enrichmentIds: ["nace"],
volume: 5000,
frequency: "Engångs",
confidence: 92,
});
const TRUST = [
["Härkomst per fält", "Varje värde bär källa, färskhet och konfidens."],
["DPA & GDPR-native", "Databehandlaravtal och dokumenterad laglig grund per fält."],
["Din egen data: du pushar", "Read-only roll du själv skapar, tidsbegränsad, raderad efteråt."],
["Revisionslogg", "Vad vi rörde, när och varför — fullständigt loggat."],
];
export default function DepositPage() {
const [est, setEst] = useState<Estimate>(FALLBACK);
const [recap, setRecap] = useState<Record<string, string>>({});
useEffect(() => {
try {
const raw = sessionStorage.getItem("0pt-order");
if (raw) {
const o = JSON.parse(raw);
if (o.est) setEst(o.est);
setRecap({
Marknader: (o.markets || []).join(" · "),
Typ: o.type,
Volym: new Intl.NumberFormat("sv-SE").format(o.volume || 0),
Frekvens: o.frequency,
});
}
} catch {}
}, []);
return (
<main className="min-h-screen bg-background text-foreground">
<OrderStepper active={2} />
<div className="mx-auto max-w-[80rem] px-6 py-10">
<p className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Steg 2 — Förskott</p>
<h1 className="mt-3 text-4xl font-medium tracking-[-0.025em] md:text-5xl">Betala 50 %. Resten i depå.</h1>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-4">
{/* split */}
<div className="border border-border">
<div className="border-b border-border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
Betalningsupplägg
</div>
<div className="grid grid-cols-1 sm:grid-cols-2">
<div className="border-b border-border p-6 sm:border-b-0 sm:border-r" style={{ backgroundColor: "rgba(228,0,20,0.06)" }}>
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em]" style={{ color: "var(--accent-red)" }}>
Nu — startar jobbet
</p>
<p className="mt-2 text-3xl font-medium" style={{ color: "var(--accent-red)" }}>{formatSEK(est.deposit)}</p>
<p className="mt-1 font-mono text-[11px] text-muted-foreground">50 % · debiteras idag</p>
</div>
<div className="p-6">
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground">Vid godkänt</p>
<p className="mt-2 text-3xl font-medium">{formatSEK(est.remainder)}</p>
<p className="mt-1 font-mono text-[11px] text-muted-foreground">50 % · frigörs ur depå</p>
</div>
</div>
<div className="border-t border-border px-6 py-3 text-sm text-muted-foreground">
Du debiteras aldrig andra halvan förrän du granskat och tryckt Godkänn.
</div>
</div>
{/* trust */}
<div className="border border-border">
<div className="border-b border-border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
Så hanterar vi data & åtkomst
</div>
<div className="divide-y divide-border">
{TRUST.map(([t, s]) => (
<div key={t} className="flex items-start gap-3 px-4 py-3">
<span className="mt-0.5 inline-block h-4 w-4 shrink-0 text-center text-xs leading-4" style={{ color: "var(--prov-3)" }}>✓</span>
<span>
<span className="text-sm font-medium">{t}</span>
<span className="ml-2 text-xs text-muted-foreground">{s}</span>
</span>
</div>
))}
</div>
</div>
</div>
{/* summary */}
<aside className="lg:sticky lg:top-6 lg:self-start">
<div className="border border-border">
<div className="border-b border-border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
Din beställning
</div>
<dl className="divide-y divide-border">
{Object.entries(recap).map(([k, v]) => (
<div key={k} className="flex items-center justify-between px-4 py-2.5">
<dt className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">{k}</dt>
<dd className="max-w-[60%] truncate text-right font-mono text-xs">{v || "—"}</dd>
</div>
))}
</dl>
<div className="m-4 border p-4" style={{ borderColor: "var(--accent-red)" }}>
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em]" style={{ color: "var(--accent-red)" }}>Att betala nu</p>
<p className="mt-1 font-mono text-2xl font-medium">{formatSEK(est.deposit)}</p>
<p className="mt-1 text-xs text-muted-foreground">exkl. moms · Stripe</p>
</div>
<div className="p-4 pt-0">
<Link
href="/order/SX-4471"
className="block w-full bg-foreground py-3.5 text-center font-mono text-[12px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)]"
>
Betala & starta produktion →
</Link>
<Link href="/bestall" className="mt-3 block text-center font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground underline-offset-4 hover:underline">
← Tillbaka till konfiguration
</Link>
</div>
</div>
</aside>
</div>
</div>
</main>
);
}
+308
View File
@@ -0,0 +1,308 @@
"use client";
import Link from "next/link";
import { useMemo, useState, useEffect } from "react";
import { OrderStepper } from "@/components/order-stepper";
import { ProvenanceBadge } from "@/components/provenance-badge";
import {
MARKETS,
ENTITY_TYPES,
FIELDS,
ENRICHMENTS,
FREQUENCIES,
DELIVERY_FORMATS,
computeEstimate,
formatSEK,
type EntityType,
type Frequency,
type ProvLevel,
} from "@/lib/data";
function confidenceColor(v: number) {
if (v >= 92) return "var(--prov-3)";
if (v >= 87) return "var(--prov-2)";
return "var(--prov-1)";
}
export default function ConfiguratorPage() {
const [markets, setMarkets] = useState<string[]>(["SE", "NO", "DK", "FI"]);
const [type, setType] = useState<EntityType>("Bolag");
const [fieldIds, setFieldIds] = useState<string[]>(["lei", "financials", "contact"]);
const [enrichmentIds, setEnrichmentIds] = useState<string[]>(["nace"]);
const [volume, setVolume] = useState(5000);
const [frequency, setFrequency] = useState<Frequency>("Engångs");
const [confidence, setConfidence] = useState(92);
const [formats, setFormats] = useState<string[]>(["CSV", "Tabellvy (NocoDB)"]);
const est = useMemo(
() => computeEstimate({ markets, type, fieldIds, enrichmentIds, volume, frequency, confidence }),
[markets, type, fieldIds, enrichmentIds, volume, frequency, confidence],
);
// Persist so the deposit screen shows the same numbers.
useEffect(() => {
try {
sessionStorage.setItem(
"0pt-order",
JSON.stringify({ markets, type, fieldIds, enrichmentIds, volume, frequency, confidence, formats, est }),
);
} catch {}
}, [markets, type, fieldIds, enrichmentIds, volume, frequency, confidence, formats, est]);
const toggle = (arr: string[], v: string, set: (x: string[]) => void) =>
set(arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]);
const Chip = ({
active,
disabled,
onClick,
children,
}: {
active?: boolean;
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}) => (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="border px-3 py-2 font-mono text-[11px] uppercase tracking-[0.08em] transition-colors disabled:cursor-not-allowed disabled:opacity-35"
style={{
borderColor: active ? "var(--foreground)" : "var(--border)",
backgroundColor: active ? "var(--foreground)" : "transparent",
color: active ? "var(--background)" : "var(--foreground)",
}}
>
{children}
</button>
);
const Block = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="border border-border">
<div className="border-b border-border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
{label}
</div>
<div className="p-4">{children}</div>
</div>
);
return (
<main className="min-h-screen bg-background text-foreground">
<OrderStepper active={1} />
<div className="mx-auto max-w-[80rem] px-6 py-10">
<p className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
Steg 1 — Konfigurera
</p>
<h1 className="mt-3 text-4xl font-medium tracking-[-0.025em] md:text-5xl">
Sätt ihop ditt datajobb.
</h1>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_360px]">
{/* LEFT — builder */}
<div className="flex flex-col gap-4">
<Block label="Marknader">
<div className="flex flex-wrap gap-2">
{MARKETS.map((m) => (
<Chip
key={m.code}
active={markets.includes(m.code)}
disabled={!m.live}
onClick={() => toggle(markets, m.code, setMarkets)}
>
{m.code}
{!m.live ? " ·" : ""}
</Chip>
))}
</div>
<p className="mt-3 font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">
Nordiska marknader är live. Övriga markeras "kommande".
</p>
</Block>
<Block label="Typ av poster">
<div className="flex gap-2">
{ENTITY_TYPES.map((e) => (
<Chip key={e.id} active={type === e.id} onClick={() => setType(e.id)}>
{e.id}
</Chip>
))}
</div>
</Block>
<Block label="Fält att inkludera">
<div className="flex flex-col">
{FIELDS.map((f, i) => {
const on = f.locked || fieldIds.includes(f.id);
return (
<label
key={f.id}
className="flex items-center gap-3 border-border py-3 [&:not(:last-child)]:border-b"
style={{ cursor: f.locked ? "default" : "pointer" }}
>
<span
className="flex h-4 w-4 shrink-0 items-center justify-center border"
style={{
borderColor: on ? "var(--foreground)" : "var(--border)",
backgroundColor: on ? "var(--foreground)" : "transparent",
color: "var(--background)",
}}
>
{on ? "✓" : ""}
</span>
<input
type="checkbox"
className="sr-only"
checked={on}
disabled={f.locked}
onChange={() => toggle(fieldIds, f.id, setFieldIds)}
/>
<span className="flex-1">
<span className="text-sm">{f.name}</span>
<span className="ml-2 text-xs text-muted-foreground">{f.sub}</span>
{f.locked ? (
<span className="ml-2 font-mono text-[10px] uppercase text-muted-foreground">låst</span>
) : null}
</span>
<ProvenanceBadge level={f.level as ProvLevel} source={f.source} freshness={f.freshness} />
</label>
);
})}
</div>
</Block>
<Block label="Berikning & matchning">
<div className="flex flex-wrap gap-2">
{ENRICHMENTS.map((e) => (
<Chip
key={e.id}
active={enrichmentIds.includes(e.id)}
onClick={() => toggle(enrichmentIds, e.id, setEnrichmentIds)}
>
{e.label}
</Chip>
))}
</div>
</Block>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Block label="Volym (antal poster)">
<input
type="number"
min={100}
step={100}
value={volume}
onChange={(e) => setVolume(Math.max(100, Number(e.target.value) || 0))}
className="w-full border border-border bg-input px-3 py-2 font-mono text-sm outline-none focus:border-foreground"
/>
<div className="mt-3 flex gap-2">
{FREQUENCIES.map((fr) => (
<Chip key={fr} active={frequency === fr} onClick={() => setFrequency(fr)}>
{fr}
</Chip>
))}
</div>
</Block>
<Block label="Konfidensmål">
<div className="flex items-baseline justify-between">
<span className="font-mono text-2xl" style={{ color: confidenceColor(confidence) }}>
{confidence}%
</span>
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">
80 – 99
</span>
</div>
<input
type="range"
min={80}
max={99}
value={confidence}
onChange={(e) => setConfidence(Number(e.target.value))}
className="mt-3 w-full"
style={{ accentColor: confidenceColor(confidence) }}
/>
<div className="mt-2 h-1.5 w-full bg-secondary">
<div
className="h-full transition-all"
style={{ width: `${((confidence - 80) / 19) * 100}%`, backgroundColor: confidenceColor(confidence) }}
/>
</div>
</Block>
</div>
<Block label="Leveransformat">
<div className="flex flex-wrap gap-2">
{DELIVERY_FORMATS.map((f) => (
<Chip key={f} active={formats.includes(f)} onClick={() => toggle(formats, f, setFormats)}>
{f}
</Chip>
))}
</div>
</Block>
</div>
{/* RIGHT — sticky summary */}
<aside className="lg:sticky lg:top-6 lg:self-start">
<div className="border border-border">
<div className="border-b border-border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
Sammanfattning
</div>
<dl className="divide-y divide-border">
{[
["Marknader", markets.join(" · ") || "—"],
["Typ", type],
["Fält", String(FIELDS.filter((f) => f.locked || fieldIds.includes(f.id)).length)],
["Berikning", String(enrichmentIds.length)],
["Volym", new Intl.NumberFormat("sv-SE").format(volume)],
["Frekvens", frequency],
["Leveranstid", `≈ ${est.timelineDays} dgr`],
["Est. konfidens", `${est.estConfidence}%`],
].map(([k, v]) => (
<div key={k} className="flex items-center justify-between px-4 py-2.5">
<dt className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">{k}</dt>
<dd className="max-w-[60%] truncate text-right font-mono text-xs">{v}</dd>
</div>
))}
</dl>
{/* price */}
<div className="border-t border-border p-4">
<div className="flex items-baseline justify-between">
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">
Totalt arvode{est.recurring ? " /mån" : ""}
</span>
<span className="text-[34px] font-medium tracking-[-0.02em]">{formatSEK(est.price)}</span>
</div>
</div>
{/* deposit — the only red on this screen */}
<div className="m-4 mt-0 border p-4" style={{ borderColor: "var(--accent-red)" }}>
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em]" style={{ color: "var(--accent-red)" }}>
Förskott 50 % för att starta
</p>
<p className="mt-1 font-mono text-2xl font-medium">{formatSEK(est.deposit)}</p>
<p className="mt-1 text-xs text-muted-foreground">
Resterande {formatSEK(est.remainder)} hålls i depå och frigörs vid godkänd leverans.
</p>
</div>
<div className="p-4 pt-0">
<Link
href="/bestall/forskott"
className="block w-full bg-foreground py-3.5 text-center font-mono text-[12px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)]"
>
Gå till förskott →
</Link>
</div>
</div>
<p className="mt-3 text-center font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
Härkomst per fält · GDPR-native · ingen uppgift gissad
</p>
</aside>
</div>
</div>
</main>
);
}
+48
View File
@@ -267,3 +267,51 @@
.animate-fade-in {
animation: fadeIn 0.7s ease-out forwards;
}
/* ─────────────────────────────────────────────────────────────
0pt additions: brand red (single accent) + provenance status
colors + scroll-reveal API (IntersectionObserver via useReveal)
───────────────────────────────────────────────────────────── */
:root {
--accent-red: #E40014; /* the single accent — one CTA / key number per screen */
--prov-0: #737373; /* ej tillgänglig / licens krävs */
--prov-1: #143AFF; /* en källa */
--prov-2: #FCBB00; /* delvis bekräftad */
--prov-3: #00D294; /* flera källor överens */
}
@theme inline {
--color-accent-red: var(--accent-red);
--color-prov-0: var(--prov-0);
--color-prov-1: var(--prov-1);
--color-prov-2: var(--prov-2);
--color-prov-3: var(--prov-3);
}
/* scroll-reveal contract: add class "reveal" (+ optional -left/-right/-scale/-fade
and "delay-N") to children of an element wired with useReveal(). */
.reveal,
.reveal-left,
.reveal-right,
.reveal-scale,
.reveal-fade { opacity: 0; }
.reveal.in-view { animation: reveal-up 0.8s ease-out forwards; }
.reveal-left.in-view { animation: reveal-left 0.8s ease-out forwards; }
.reveal-right.in-view { animation: reveal-right 0.8s ease-out forwards; }
.reveal-scale.in-view { animation: scale-in 0.6s ease-out forwards; }
.reveal-fade.in-view { animation: fadeIn 0.7s ease-out forwards; }
.delay-1 { animation-delay: 0.1s; }
.delay-2 { animation-delay: 0.2s; }
.delay-3 { animation-delay: 0.3s; }
.delay-4 { animation-delay: 0.4s; }
.delay-5 { animation-delay: 0.5s; }
.delay-6 { animation-delay: 0.6s; }
@media (prefers-reduced-motion: reduce) {
.reveal, .reveal-left, .reveal-right, .reveal-scale, .reveal-fade {
opacity: 1;
animation: none;
}
.animate-float,
.grain-overlay::before { animation: none; }
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { OrderStepper } from "@/components/order-stepper";
const STAGES = [
{ key: "source", title: "Källracing", meta: "GLEIF · Brreg · CVR · PRH" },
{ key: "extract", title: "Extraktion & OCR", meta: "register-API · dokument" },
{ key: "structure", title: "Strukturering & matchning", meta: "NER · entity resolution · LEI-länk" },
{ key: "enrich", title: "Berikning & fusion", meta: "multi-source agreement" },
{ key: "quality", title: "Kvalitetskontroll", meta: "expectations · dedup · tröskel" },
];
const MARKET_KEYS = [
{ code: "SE", speed: 1.0 },
{ code: "NO", speed: 0.85 },
{ code: "DK", speed: 0.7 },
{ code: "FI", speed: 0.5 },
];
function prefersReduced() {
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
}
export default function ProductionPage() {
const { id } = useParams<{ id: string }>();
const [running, setRunning] = useState(false);
const [done, setDone] = useState(false);
const [progress, setProgress] = useState<number[]>([0, 0, 0, 0, 0]);
const [markets, setMarkets] = useState<number[]>([0, 0, 0, 0]);
const [conf, setConf] = useState(0);
const [feed, setFeed] = useState<{ t: string; msg: string; kind: "ok" | "run" }[]>([]);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const tick = useRef(0);
const finish = () => {
setProgress([100, 100, 100, 100, 100]);
setMarkets([100, 100, 100, 100]);
setConf(94);
setDone(true);
setRunning(false);
setFeed((f) => [{ t: stamp(), msg: "Leverans klar · konfidens 94 % · 4 980 poster", kind: "ok" }, ...f]);
};
const stamp = () => {
const s = 10 + Math.floor(tick.current / 2);
return `00:${String(s).padStart(2, "0")}`;
};
const start = () => {
if (running || done) return;
if (prefersReduced()) {
finish();
return;
}
setRunning(true);
setFeed([{ t: "00:00", msg: `Order ${id} startad · depå mottagen`, kind: "run" }]);
timer.current = setInterval(() => {
tick.current += 1;
setProgress((prev) => {
const next = [...prev];
const i = next.findIndex((p) => p < 100);
if (i === -1) return prev;
next[i] = Math.min(100, next[i] + 8 + Math.random() * 10);
if (next[i] >= 100 && i < STAGES.length) {
setFeed((f) => [{ t: stamp(), msg: `${STAGES[i].title} klar`, kind: "ok" }, ...f].slice(0, 12));
}
return next;
});
setMarkets((prev) => prev.map((m, idx) => Math.min(100, m + (6 + Math.random() * 6) * MARKET_KEYS[idx].speed)));
setConf((c) => Math.min(94, c + 2.5 + Math.random() * 2));
}, 320);
};
useEffect(() => {
if (progress.every((p) => p >= 100) && running) finish();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress, running]);
useEffect(() => () => { if (timer.current) clearInterval(timer.current); }, []);
const overall = Math.round(progress.reduce((a, b) => a + b, 0) / STAGES.length);
return (
<main className="min-h-screen bg-background text-foreground">
<OrderStepper active={3} />
<div className="mx-auto max-w-[80rem] px-6 py-10">
<p className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Steg 3 — Produktion</p>
<h1 className="mt-3 text-4xl font-medium tracking-[-0.025em] md:text-5xl">Följ jobbet — i realtid.</h1>
{/* control row */}
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={start}
disabled={running || done}
className="bg-foreground px-5 py-3 font-mono text-[12px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-40"
>
{done ? "✓ Klart" : running ? "Kör…" : "▸ Starta simulering"}
</button>
<span className="border border-border px-3 py-2 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
Order {id} · {done ? "klar" : running ? `${overall}%` : "väntar"}
</span>
<span className="border border-border px-3 py-2 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
ETA ≈ 4 dgr (simulerad)
</span>
</div>
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
{/* stages */}
<div className="flex flex-col gap-3">
{STAGES.map((s, i) => {
const p = Math.round(progress[i]);
const isDone = p >= 100;
const isRun = !isDone && (i === 0 || progress[i - 1] >= 100) && running;
const color = isDone ? "var(--prov-3)" : isRun ? "var(--accent-red)" : "var(--border)";
return (
<div key={s.key} className="border p-4" style={{ borderColor: color }}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{s.title}</span>
<span className="font-mono text-xs text-muted-foreground">{p}%</span>
</div>
<div className="mt-2 h-1.5 w-full bg-secondary">
<div className="h-full transition-all" style={{ width: `${p}%`, backgroundColor: isDone ? "var(--prov-3)" : "var(--foreground)" }} />
</div>
<p className="mt-2 font-mono text-[10.5px] uppercase tracking-[0.06em] text-muted-foreground">{s.meta}</p>
</div>
);
})}
</div>
{/* right column */}
<div className="flex flex-col gap-4">
<div className="border border-border p-5 text-center">
<ConfidenceRing value={conf} />
<p className="mt-2 font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground">Levererbart</p>
</div>
<div className="border border-border p-4">
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground">Per marknad</p>
<div className="mt-3 flex flex-col gap-2">
{MARKET_KEYS.map((m, i) => (
<div key={m.code} className="flex items-center gap-2">
<span className="w-7 font-mono text-xs">{m.code}</span>
<div className="h-1.5 flex-1 bg-secondary">
<div className="h-full bg-foreground transition-all" style={{ width: `${Math.round(markets[i])}%` }} />
</div>
<span className="w-9 text-right font-mono text-[10.5px] text-muted-foreground">{Math.round(markets[i])}%</span>
</div>
))}
</div>
</div>
<div className="border border-border p-4">
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground">Aktivitet</p>
<div className="mt-3 flex flex-col gap-1.5 font-mono text-[11px]">
{feed.length === 0 ? <span className="text-muted-foreground">— väntar på start —</span> : null}
{feed.map((f, i) => (
<div key={i} className="flex gap-2">
<span className="text-muted-foreground">{f.t}</span>
<span style={{ color: f.kind === "ok" ? "var(--prov-3)" : "var(--accent-red)" }}>{f.msg}</span>
</div>
))}
</div>
</div>
</div>
</div>
{done ? (
<div className="mt-8">
<Link
href={`/order/${id}/validera`}
className="inline-block bg-foreground px-6 py-3.5 font-mono text-[12px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)]"
>
Granska & validera resultatet →
</Link>
</div>
) : null}
</div>
</main>
);
}
function ConfidenceRing({ value }: { value: number }) {
const r = 52;
const c = 2 * Math.PI * r;
const off = c - (value / 100) * c;
return (
<svg viewBox="0 0 130 130" className="mx-auto h-36 w-36">
<circle cx="65" cy="65" r={r} fill="none" stroke="var(--secondary)" strokeWidth="8" />
<circle
cx="65" cy="65" r={r} fill="none" stroke="var(--prov-3)" strokeWidth="8"
strokeDasharray={c} strokeDashoffset={off} strokeLinecap="butt"
transform="rotate(-90 65 65)" style={{ transition: "stroke-dashoffset .3s" }}
/>
<text x="65" y="72" textAnchor="middle" className="fill-foreground font-mono" style={{ fontSize: 24 }}>
{Math.round(value)}%
</text>
</svg>
);
}
+226
View File
@@ -0,0 +1,226 @@
"use client";
import { useParams } from "next/navigation";
import { useState } from "react";
import { OrderStepper } from "@/components/order-stepper";
import { CellProvenance, ProvenanceBadge, PROVENANCE_LEGEND } from "@/components/provenance-badge";
import { MARKETS, SAMPLE_ROWS, formatSEK } from "@/lib/data";
const TABS = ["Tabell", "Täckning", "Schema & arkitektur", "Kvalitetsrapport"] as const;
type Tab = (typeof TABS)[number];
const DESTINATIONS = ["CSV", "Tabellvy (NocoDB)", "Parquet", "JSON", "Dashboard (Metabase)", "API", "Webhook", "FiveCRM", "Google Sheets"];
const DIMENSIONS: [string, number, boolean][] = [
["Komplett", 97, false], ["Färskhet", 93, false], ["Korroborering", 88, true], ["Unikhet", 99, false], ["Giltighet", 96, false],
];
const CHECKS = [
"Org.nr mot register-checksumma", "LEI mot GLEIF", "E-post MX-verifierad",
"Dubbletter sammanslagna", "20 poster under tröskel utelämnade", "Sanktions/PEP-skärm utan träff",
];
const ENTITIES = [
{ name: "dim_company", key: true, cols: ["company_id PK", "org_nr", "lei FK", "name"] },
{ name: "fact_financials", key: false, cols: ["company_id FK", "year", "revenue", "result"] },
{ name: "dim_contact", key: false, cols: ["contact_id PK", "company_id FK", "email"] },
{ name: "dim_ubo", key: false, cols: ["ubo_id PK", "company_id FK", "share_pct"] },
{ name: "bridge_provenance", key: true, cols: ["field_id PK", "source_id FK", "confidence", "fetched_at"] },
{ name: "dim_source", key: false, cols: ["source_id PK", "name", "license", "freshness"] },
];
export default function ValidationPage() {
const { id } = useParams<{ id: string }>();
const [tab, setTab] = useState<Tab>("Tabell");
const [dests, setDests] = useState<string[]>(["CSV", "Tabellvy (NocoDB)"]);
const [approved, setApproved] = useState(false);
const toggleDest = (d: string) => setDests((p) => (p.includes(d) ? p.filter((x) => x !== d) : [...p, d]));
return (
<main className="min-h-screen bg-background text-foreground">
<OrderStepper active={4} />
<div className="mx-auto max-w-[80rem] px-6 py-10">
<p className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Steg 4 — Validera & leverera</p>
<h1 className="mt-3 text-4xl font-medium tracking-[-0.025em] md:text-5xl">Granska allt. Godkänn fritt.</h1>
{/* tabs */}
<div className="mt-8 flex flex-wrap gap-0 border-b border-border">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className="px-4 py-3 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors"
style={{
color: tab === t ? "var(--foreground)" : "var(--muted-foreground)",
borderBottom: tab === t ? "2px solid var(--accent-red)" : "2px solid transparent",
}}
>
{t}
</button>
))}
</div>
<div className="mt-6">
{tab === "Tabell" ? (
<div>
<div className="mb-4 flex flex-wrap gap-2">
{PROVENANCE_LEGEND.map((l) => (
<ProvenanceBadge key={l.level} level={l.level} source={l.text} />
))}
</div>
<div className="overflow-x-auto border border-border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-secondary">
{["Org.nr", "Bolag", "Säte", "SNI", "Anställda", "Oms. (tkr)", "E-post", "LEI"].map((h) => (
<th key={h} className="px-3 py-2 text-left font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">{h}</th>
))}
</tr>
</thead>
<tbody>
{SAMPLE_ROWS.map((r) => (
<tr key={r.orgnr} className="border-b border-border last:border-b-0 hover:bg-secondary/50">
<td className="px-3 py-2 font-mono text-xs">{r.orgnr}<CellProvenance level={3} /></td>
<td className="px-3 py-2">{r.bolag}<CellProvenance level={3} /></td>
<td className="px-3 py-2">{r.sate}<CellProvenance level={3} /></td>
<td className="px-3 py-2 font-mono text-xs">{r.sni}<CellProvenance level={2} /></td>
<td className="px-3 py-2 font-mono text-xs">{new Intl.NumberFormat("sv-SE").format(r.anstallda)}<CellProvenance level={2} /></td>
<td className="px-3 py-2 font-mono text-xs">{new Intl.NumberFormat("sv-SE").format(r.oms)}<CellProvenance level={2} /></td>
<td className="px-3 py-2 text-xs">
{r.epost ? (<>{r.epost}<CellProvenance level={r.epostLevel} /></>) : (<span className="text-muted-foreground">— licens (FI)<CellProvenance level={0} /></span>)}
</td>
<td className="px-3 py-2 font-mono text-xs">{r.lei}<CellProvenance level={3} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
{tab === "Täckning" ? (
<div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-px bg-border">
{MARKETS.filter((m) => m.live).map((m) => (
<div key={m.code} className="bg-background p-3">
<p className="font-mono text-sm font-semibold">{m.code}</p>
<p className="font-mono text-[10.5px] text-muted-foreground">{m.coverage}%</p>
<div className="mt-2 h-1 w-full bg-secondary">
<div className="h-full" style={{ width: `${m.coverage}%`, backgroundColor: m.coverage >= 95 ? "var(--prov-3)" : "var(--prov-2)" }} />
</div>
</div>
))}
</div>
<div className="mt-4 grid grid-cols-1 gap-px bg-border sm:grid-cols-3">
{[["4 980", "poster levererade"], ["20", "utelämnade — ej gissade"], ["3,2", "källor per bekräftat fält"]].map(([n, l]) => (
<div key={l} className="bg-background p-5">
<p className="text-3xl font-medium">{n}</p>
<p className="mt-1 font-mono text-[10.5px] uppercase tracking-[0.08em] text-muted-foreground">{l}</p>
</div>
))}
</div>
</div>
) : null}
{tab === "Schema & arkitektur" ? (
<div className="border border-border p-6" style={{ backgroundImage: "radial-gradient(var(--border) 1px, transparent 1px)", backgroundSize: "16px 16px" }}>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{ENTITIES.map((e) => (
<div key={e.name} className="bg-background" style={{ border: e.key ? "1px solid var(--accent-red)" : "1px solid var(--border)" }}>
<div className="border-b border-border px-3 py-2 font-mono text-[11px]">{e.name}</div>
<ul className="px-3 py-2 font-mono text-[10.5px] text-muted-foreground">
{e.cols.map((c) => (<li key={c}>{c}</li>))}
</ul>
</div>
))}
</div>
<p className="mt-4 font-mono text-[10.5px] uppercase tracking-[0.06em] text-muted-foreground">
→ varje fält länkar till bridge_provenance · ingen uppgift utan spårbar källa
</p>
</div>
) : null}
{tab === "Kvalitetsrapport" ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="border border-border p-5">
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground">Per dimension</p>
<div className="mt-4 flex flex-col gap-3">
{DIMENSIONS.map(([name, v, amber]) => (
<div key={name}>
<div className="flex justify-between font-mono text-xs"><span>{name}</span><span className="text-muted-foreground">{v}%</span></div>
<div className="mt-1 h-1.5 w-full bg-secondary">
<div className="h-full" style={{ width: `${v}%`, backgroundColor: amber ? "var(--prov-2)" : "var(--prov-3)" }} />
</div>
</div>
))}
</div>
</div>
<div className="border border-border p-5">
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground">Kontroller som passerade</p>
<ul className="mt-4 flex flex-col gap-2 text-sm">
{CHECKS.map((c) => (
<li key={c} className="flex items-start gap-2">
<span style={{ color: "var(--prov-3)" }}>✓</span>
<span>{c}</span>
</li>
))}
</ul>
</div>
</div>
) : null}
</div>
{/* destinations */}
<div className="mt-8">
<p className="font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground">Leverera till</p>
<div className="mt-3 flex flex-wrap gap-2">
{DESTINATIONS.map((d) => {
const on = dests.includes(d);
return (
<button
key={d}
onClick={() => toggleDest(d)}
className="border px-3 py-2 font-mono text-[11px] uppercase tracking-[0.08em] transition-colors"
style={{
borderColor: on ? "var(--accent-red)" : "var(--border)",
backgroundColor: on ? "rgba(228,0,20,0.06)" : "transparent",
color: "var(--foreground)",
}}
>
{d}
</button>
);
})}
</div>
</div>
{/* action bar */}
<div className="mt-8 border border-border p-5">
{approved ? (
<div className="border p-4" style={{ borderColor: "var(--prov-3)" }}>
<p className="font-mono text-sm" style={{ color: "var(--prov-3)" }}>
✓ Leverans godkänd. Slutbetalning frigjord. Export upplåst.
</p>
<p className="mt-1 text-xs text-muted-foreground">Order {id} · levererad till {dests.join(" · ") || "—"}.</p>
</div>
) : (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="max-w-xl text-sm text-muted-foreground">
Godkänn leveransen — frigör resterande belopp ur depån och lås upp full export. Inte nöjd? Begär revidering utan kostnad.
</p>
<div className="flex gap-3">
<button className="border border-foreground px-5 py-3 font-mono text-[12px] uppercase tracking-[0.1em] transition-colors hover:bg-foreground hover:text-background">
Begär revidering
</button>
<button
onClick={() => setApproved(true)}
className="bg-foreground px-5 py-3 font-mono text-[12px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)]"
>
Godkänn & frigör 50 %
</button>
</div>
</div>
)}
</div>
</div>
</main>
);
}
+57
View File
@@ -0,0 +1,57 @@
import Link from "next/link";
const STEPS = [
{ n: 1, label: "Konfigurera", href: "/bestall" },
{ n: 2, label: "Förskott", href: "/bestall/forskott" },
{ n: 3, label: "Produktion", href: null },
{ n: 4, label: "Validera", href: null },
] as const;
/** Mono 4-step indicator. Active step carries the single red marker. */
export function OrderStepper({ active }: { active: 1 | 2 | 3 | 4 }) {
return (
<nav
aria-label="Beställningssteg"
className="flex w-full items-center gap-0 border-b border-border bg-background"
>
{STEPS.map((s, i) => {
const isActive = s.n === active;
const isDone = s.n < active;
const inner = (
<span className="flex items-center gap-3">
<span
className="flex h-6 w-6 items-center justify-center font-mono text-[11px]"
style={{
backgroundColor: isActive ? "var(--accent-red)" : isDone ? "var(--foreground)" : "transparent",
color: isActive || isDone ? "#fff" : "var(--muted-foreground)",
border: isActive || isDone ? "none" : "1px solid var(--border)",
}}
>
{s.n}
</span>
<span
className="font-mono text-[11px] uppercase tracking-[0.12em]"
style={{ color: isActive ? "var(--foreground)" : "var(--muted-foreground)" }}
>
{s.label}
</span>
</span>
);
return (
<div
key={s.n}
className="flex flex-1 items-center justify-center border-r border-border px-4 py-3 last:border-r-0"
>
{s.href && (isDone || isActive) ? (
<Link href={s.href} className="transition-opacity hover:opacity-70">
{inner}
</Link>
) : (
inner
)}
</div>
);
})}
</nav>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { ProvLevel } from "@/lib/data";
const PROV_COLOR: Record<ProvLevel, string> = {
0: "var(--prov-0)", // ej tillgänglig / licens
1: "var(--prov-1)", // en källa
2: "var(--prov-2)", // delvis bekräftad
3: "var(--prov-3)", // flera källor överens
};
const PROV_LABEL: Record<ProvLevel, string> = {
0: "Ej tillgänglig / licens",
1: "En källa",
2: "Delvis bekräftad",
3: "Flera källor överens",
};
/**
* The product's whole promise in one chip: where a value came from and how
* confident we are. The ONLY place non-mono color (besides the single red CTA)
* is allowed to appear.
*/
export function ProvenanceBadge({
level,
source,
freshness,
}: {
level: ProvLevel;
source: string;
freshness?: string;
}) {
return (
<span
className="inline-flex items-center gap-1.5 border border-border px-1.5 py-0.5 font-mono text-[10.5px] tracking-[0.02em] text-muted-foreground"
title={PROV_LABEL[level]}
>
<span
aria-hidden
className="inline-block h-[7px] w-[7px]"
style={{
backgroundColor: level === 0 ? "transparent" : PROV_COLOR[level],
border: level === 0 ? `1px solid ${PROV_COLOR[0]}` : "none",
}}
/>
<span className="uppercase">{source}</span>
{freshness ? (
<>
<span className="text-border">·</span>
<span>{freshness}</span>
</>
) : null}
</span>
);
}
/** Just the 9px status square — for the end of table cells. */
export function CellProvenance({ level }: { level: ProvLevel }) {
return (
<span
aria-hidden
title={PROV_LABEL[level]}
className="ml-1.5 inline-block h-[9px] w-[9px] align-middle"
style={{
backgroundColor: level === 0 ? "transparent" : PROV_COLOR[level],
border: level === 0 ? `1px solid ${PROV_COLOR[0]}` : "none",
}}
/>
);
}
export const PROVENANCE_LEGEND: { level: ProvLevel; text: string }[] = [
{ level: 3, text: "Flera källor överens" },
{ level: 2, text: "Delvis bekräftad" },
{ level: 1, text: "En källa" },
{ level: 0, text: "Licens krävs / ej tillgänglig" },
];
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Wire a section so its descendants carrying `.reveal` (+ optional
* `.reveal-left|-right|-scale|-fade` and `.delay-N`) animate in when the
* section scrolls into view. Matches the keyframes in globals.css.
*/
export function useReveal<T extends HTMLElement = HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) =>
entries.forEach((e) => {
if (e.isIntersecting) {
el
.querySelectorAll(
".reveal, .reveal-left, .reveal-right, .reveal-scale, .reveal-fade",
)
.forEach((n) => n.classList.add("in-view"));
io.unobserve(el);
}
}),
{ threshold: 0.15, rootMargin: "0px 0px -10% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return ref;
}
+167
View File
@@ -0,0 +1,167 @@
// Central mock data + the live-estimate pricing model shared by the order flow.
// No real backend yet — the configurator's price/timeline/confidence come from
// computeEstimate(); replace with a call to the FastAPI /estimates later.
export type ProvLevel = 0 | 1 | 2 | 3; // 0 licens/ej · 1 en källa · 2 delvis · 3 flera källor
export interface Market {
code: string;
name: string;
live: boolean;
coverage: number; // %
}
// 20 European markets (Nordics live first, rest "kommande").
export const MARKETS: Market[] = [
{ code: "SE", name: "Sverige", live: true, coverage: 98 },
{ code: "NO", name: "Norge", live: true, coverage: 96 },
{ code: "DK", name: "Danmark", live: true, coverage: 94 },
{ code: "FI", name: "Finland", live: true, coverage: 89 },
{ code: "IS", name: "Island", live: false, coverage: 0 },
{ code: "NL", name: "Nederländerna", live: false, coverage: 0 },
{ code: "DE", name: "Tyskland", live: false, coverage: 0 },
{ code: "BE", name: "Belgien", live: false, coverage: 0 },
{ code: "IE", name: "Irland", live: false, coverage: 0 },
{ code: "FR", name: "Frankrike", live: false, coverage: 0 },
{ code: "GB", name: "Storbritannien", live: false, coverage: 0 },
{ code: "AT", name: "Österrike", live: false, coverage: 0 },
{ code: "CH", name: "Schweiz", live: false, coverage: 0 },
{ code: "IT", name: "Italien", live: false, coverage: 0 },
{ code: "ES", name: "Spanien", live: false, coverage: 0 },
{ code: "PT", name: "Portugal", live: false, coverage: 0 },
{ code: "PL", name: "Polen", live: false, coverage: 0 },
{ code: "CZ", name: "Tjeckien", live: false, coverage: 0 },
{ code: "HU", name: "Ungern", live: false, coverage: 0 },
{ code: "LU", name: "Luxemburg", live: false, coverage: 0 },
];
export type EntityType = "Bolag" | "Beslutsfattare" | "Fordon";
export const ENTITY_TYPES: { id: EntityType; mult: number }[] = [
{ id: "Bolag", mult: 1.0 },
{ id: "Beslutsfattare", mult: 1.4 },
{ id: "Fordon", mult: 1.2 },
];
export interface Field {
id: string;
name: string;
sub: string;
level: ProvLevel;
source: string;
freshness: string;
weight: number;
locked?: boolean;
}
// Each field carries its honest provenance: source · freshness · confidence level.
export const FIELDS: Field[] = [
{ id: "identity", name: "Identitet & säte", sub: "org.nr, namn, adress", level: 3, source: "GLEIF · Brreg", freshness: "daglig", weight: 1.0, locked: true },
{ id: "lei", name: "LEI / cross-border-nyckel", sub: "GLEIF golden copy", level: 3, source: "GLEIF", freshness: "daglig", weight: 0.8 },
{ id: "financials", name: "Finansiellt", sub: "omsättning, resultat, nyckeltal", level: 2, source: "register", freshness: "årlig", weight: 1.6 },
{ id: "ubo", name: "Verklig huvudman (UBO)", sub: "ägarkedja", level: 2, source: "register", freshness: "vid ändring", weight: 1.4 },
{ id: "contact", name: "Kontakt: e-post & telefon", sub: "licens krävs i DE/FR", level: 1, source: "publik webb", freshness: "veckovis", weight: 1.8 },
{ id: "intent", name: "Köpsignaler & intent", sub: "jobbannonser, nyheter, tech", level: 1, source: "webb", freshness: "veckovis", weight: 1.5 },
];
export interface Enrichment {
id: string;
label: string;
weight: number;
}
export const ENRICHMENTS: Enrichment[] = [
{ id: "dedup", label: "Dedup mot min fil", weight: 0.1 },
{ id: "crm", label: "Matcha & berika min CRM-export", weight: 0.2 },
{ id: "sanctions", label: "Sanktioner / PEP-flagg", weight: 0.15 },
{ id: "nace", label: "Branschklassning (NACE/SNI)", weight: 0.1 },
{ id: "tech", label: "Webbplats & teknikstack", weight: 0.15 },
];
export type Frequency = "Engångs" | "Veckovis" | "Dagligt flöde";
export const FREQUENCIES: Frequency[] = ["Engångs", "Veckovis", "Dagligt flöde"];
export const DELIVERY_FORMATS = [
"CSV",
"Tabellvy (NocoDB)",
"Parquet",
"JSON",
"Dashboard (Metabase)",
"API / webhook",
"Push → FiveCRM",
];
const FIELD_RATE = 240; // kr per field-weight per 1000 rows per market
export interface EstimateInput {
markets: string[];
type: EntityType;
fieldIds: string[];
enrichmentIds: string[];
volume: number;
frequency: Frequency;
confidence: number; // target 80–99
}
export interface Estimate {
price: number;
deposit: number;
remainder: number;
timelineDays: number;
estConfidence: number;
recurring: boolean;
}
export function computeEstimate(input: EstimateInput): Estimate {
const markets = Math.max(1, input.markets.length);
const fields = FIELDS.filter((f) => input.fieldIds.includes(f.id) || f.locked);
const fieldWeight = fields.reduce((s, f) => s + f.weight, 0) || 1;
const entityMult = ENTITY_TYPES.find((e) => e.id === input.type)?.mult ?? 1;
const confidenceMult = 1 + Math.max(0, input.confidence - 90) * 0.03;
const enrichWeight = ENRICHMENTS.filter((e) => input.enrichmentIds.includes(e.id)).reduce((s, e) => s + e.weight, 0);
const base = fieldWeight * markets * FIELD_RATE * (input.volume / 1000) * entityMult * confidenceMult;
const price = Math.round((base * (1 + enrichWeight)) / 5) * 5; // round to 5 kr
const deposit = Math.round(price / 2);
const timelineDays = Math.min(
14,
Math.max(1, Math.round(1 + markets * 0.4 + input.volume / 6000 + fields.length * 0.25)),
);
// Honest estimated confidence: bounded by the provenance of the chosen fields.
const avgLevel = fields.reduce((s, f) => s + f.level, 0) / fields.length; // 0..3
const fieldCeiling = Math.round(82 + avgLevel * 5); // ~82..97
const estConfidence = Math.min(input.confidence, fieldCeiling);
return {
price,
deposit,
remainder: price - deposit,
timelineDays,
estConfidence,
recurring: input.frequency !== "Engångs",
};
}
export function formatSEK(n: number): string {
return new Intl.NumberFormat("sv-SE").format(Math.round(n)) + " kr";
}
// Sample Nordic rows reused by the validation table.
export interface SampleRow {
orgnr: string;
bolag: string;
sate: string;
sni: string;
anstallda: number;
oms: number; // tkr
epost: string;
epostLevel: ProvLevel;
lei: string;
}
export const SAMPLE_ROWS: SampleRow[] = [
{ orgnr: "556016-0680", bolag: "Volvo Group", sate: "Göteborg", sni: "29100", anstallda: 95000, oms: 553000000, epost: "info@volvo.com", epostLevel: 2, lei: "5493000XYZ..." },
{ orgnr: "556036-0793", bolag: "Spotify AB", sate: "Stockholm", sni: "63110", anstallda: 9800, oms: 132000000, epost: "press@spotify.com", epostLevel: 2, lei: "549300ABC..." },
{ orgnr: "915933149", bolag: "Equinor ASA", sate: "Stavanger", sni: "06100", anstallda: 22000, oms: 980000000, epost: "kontakt@equinor.no", epostLevel: 1, lei: "549300NOR..." },
{ orgnr: "25052943", bolag: "Maersk A/S", sate: "København", sni: "50200", anstallda: 100000, oms: 510000000, epost: "info@maersk.dk", epostLevel: 1, lei: "549300DNK..." },
{ orgnr: "0112038-9", bolag: "Nokia Oyj", sate: "Espoo", sni: "26300", anstallda: 86000, oms: 221000000, epost: "", epostLevel: 0, lei: "549300FIN..." },
];
+3
View File
@@ -69,5 +69,8 @@
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
},
"pnpm": {
"onlyBuiltDependencies": ["sharp"]
}
}
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
sharp: set this to true or false