From 24035dec8b71477dec5dc3616741b50c970089b3 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Wed, 24 Jun 2026 17:28:24 +0200 Subject: [PATCH] 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 --- .npmrc | 1 + app/bestall/forskott/page.tsx | 133 +++++++++++++ app/bestall/page.tsx | 308 +++++++++++++++++++++++++++++++ app/globals.css | 48 +++++ app/order/[id]/page.tsx | 200 ++++++++++++++++++++ app/order/[id]/validera/page.tsx | 226 +++++++++++++++++++++++ components/order-stepper.tsx | 57 ++++++ components/provenance-badge.tsx | 75 ++++++++ hooks/use-reveal.ts | 33 ++++ lib/data.ts | 167 +++++++++++++++++ package.json | 3 + pnpm-workspace.yaml | 2 + 12 files changed, 1253 insertions(+) create mode 100644 .npmrc create mode 100644 app/bestall/forskott/page.tsx create mode 100644 app/bestall/page.tsx create mode 100644 app/order/[id]/page.tsx create mode 100644 app/order/[id]/validera/page.tsx create mode 100644 components/order-stepper.tsx create mode 100644 components/provenance-badge.tsx create mode 100644 hooks/use-reveal.ts create mode 100644 lib/data.ts create mode 100644 pnpm-workspace.yaml diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d099685 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +verify-deps-before-run=false diff --git a/app/bestall/forskott/page.tsx b/app/bestall/forskott/page.tsx new file mode 100644 index 0000000..6c59c4d --- /dev/null +++ b/app/bestall/forskott/page.tsx @@ -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(FALLBACK); + const [recap, setRecap] = useState>({}); + + 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 ( +
+ +
+

Steg 2 — Förskott

+

Betala 50 %. Resten i depå.

+ +
+
+ {/* split */} +
+
+ Betalningsupplägg +
+
+
+

+ Nu — startar jobbet +

+

{formatSEK(est.deposit)}

+

50 % · debiteras idag

+
+
+

Vid godkänt

+

{formatSEK(est.remainder)}

+

50 % · frigörs ur depå

+
+
+
+ Du debiteras aldrig andra halvan förrän du granskat och tryckt Godkänn. +
+
+ + {/* trust */} +
+
+ Så hanterar vi data & åtkomst +
+
+ {TRUST.map(([t, s]) => ( +
+ ✓ + + {t} + {s} + +
+ ))} +
+
+
+ + {/* summary */} + +
+
+
+ ); +} diff --git a/app/bestall/page.tsx b/app/bestall/page.tsx new file mode 100644 index 0000000..0cf50f1 --- /dev/null +++ b/app/bestall/page.tsx @@ -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(["SE", "NO", "DK", "FI"]); + const [type, setType] = useState("Bolag"); + const [fieldIds, setFieldIds] = useState(["lei", "financials", "contact"]); + const [enrichmentIds, setEnrichmentIds] = useState(["nace"]); + const [volume, setVolume] = useState(5000); + const [frequency, setFrequency] = useState("Engångs"); + const [confidence, setConfidence] = useState(92); + const [formats, setFormats] = useState(["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; + }) => ( + + ); + + const Block = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+
+ {label} +
+
{children}
+
+ ); + + return ( +
+ + +
+

+ Steg 1 — Konfigurera +

+

+ Sätt ihop ditt datajobb. +

+ +
+ {/* LEFT — builder */} +
+ +
+ {MARKETS.map((m) => ( + toggle(markets, m.code, setMarkets)} + > + {m.code} + {!m.live ? " ·" : ""} + + ))} +
+

+ Nordiska marknader är live. Övriga markeras "kommande". +

+
+ + +
+ {ENTITY_TYPES.map((e) => ( + setType(e.id)}> + {e.id} + + ))} +
+
+ + +
+ {FIELDS.map((f, i) => { + const on = f.locked || fieldIds.includes(f.id); + return ( + + ); + })} +
+
+ + +
+ {ENRICHMENTS.map((e) => ( + toggle(enrichmentIds, e.id, setEnrichmentIds)} + > + {e.label} + + ))} +
+
+ +
+ + 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" + /> +
+ {FREQUENCIES.map((fr) => ( + setFrequency(fr)}> + {fr} + + ))} +
+
+ + +
+ + {confidence}% + + + 80 – 99 + +
+ setConfidence(Number(e.target.value))} + className="mt-3 w-full" + style={{ accentColor: confidenceColor(confidence) }} + /> +
+
+
+ +
+ + +
+ {DELIVERY_FORMATS.map((f) => ( + toggle(formats, f, setFormats)}> + {f} + + ))} +
+
+
+ + {/* RIGHT — sticky summary */} + +
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index 92d1b03..632f131 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } +} diff --git a/app/order/[id]/page.tsx b/app/order/[id]/page.tsx new file mode 100644 index 0000000..97a35bd --- /dev/null +++ b/app/order/[id]/page.tsx @@ -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([0, 0, 0, 0, 0]); + const [markets, setMarkets] = useState([0, 0, 0, 0]); + const [conf, setConf] = useState(0); + const [feed, setFeed] = useState<{ t: string; msg: string; kind: "ok" | "run" }[]>([]); + const timer = useRef | 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 ( +
+ +
+

Steg 3 — Produktion

+

Följ jobbet — i realtid.

+ + {/* control row */} +
+ + + Order {id} · {done ? "klar" : running ? `${overall}%` : "väntar"} + + + ETA ≈ 4 dgr (simulerad) + +
+ +
+ {/* stages */} +
+ {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 ( +
+
+ {s.title} + {p}% +
+
+
+
+

{s.meta}

+
+ ); + })} +
+ + {/* right column */} +
+
+ +

Levererbart

+
+
+

Per marknad

+
+ {MARKET_KEYS.map((m, i) => ( +
+ {m.code} +
+
+
+ {Math.round(markets[i])}% +
+ ))} +
+
+
+

Aktivitet

+
+ {feed.length === 0 ? — väntar på start — : null} + {feed.map((f, i) => ( +
+ {f.t} + {f.msg} +
+ ))} +
+
+
+
+ + {done ? ( +
+ + Granska & validera resultatet → + +
+ ) : null} +
+
+ ); +} + +function ConfidenceRing({ value }: { value: number }) { + const r = 52; + const c = 2 * Math.PI * r; + const off = c - (value / 100) * c; + return ( + + + + + {Math.round(value)}% + + + ); +} diff --git a/app/order/[id]/validera/page.tsx b/app/order/[id]/validera/page.tsx new file mode 100644 index 0000000..e48801c --- /dev/null +++ b/app/order/[id]/validera/page.tsx @@ -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("Tabell"); + const [dests, setDests] = useState(["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 ( +
+ +
+

Steg 4 — Validera & leverera

+

Granska allt. Godkänn fritt.

+ + {/* tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === "Tabell" ? ( +
+
+ {PROVENANCE_LEGEND.map((l) => ( + + ))} +
+
+ + + + {["Org.nr", "Bolag", "Säte", "SNI", "Anställda", "Oms. (tkr)", "E-post", "LEI"].map((h) => ( + + ))} + + + + {SAMPLE_ROWS.map((r) => ( + + + + + + + + + + + ))} + +
{h}
{r.orgnr}{r.bolag}{r.sate}{r.sni}{new Intl.NumberFormat("sv-SE").format(r.anstallda)}{new Intl.NumberFormat("sv-SE").format(r.oms)} + {r.epost ? (<>{r.epost}) : (— licens (FI))} + {r.lei}
+
+
+ ) : null} + + {tab === "Täckning" ? ( +
+
+ {MARKETS.filter((m) => m.live).map((m) => ( +
+

{m.code}

+

{m.coverage}%

+
+
= 95 ? "var(--prov-3)" : "var(--prov-2)" }} /> +
+
+ ))} +
+
+ {[["4 980", "poster levererade"], ["20", "utelämnade — ej gissade"], ["3,2", "källor per bekräftat fält"]].map(([n, l]) => ( +
+

{n}

+

{l}

+
+ ))} +
+
+ ) : null} + + {tab === "Schema & arkitektur" ? ( +
+
+ {ENTITIES.map((e) => ( +
+
{e.name}
+
    + {e.cols.map((c) => (
  • {c}
  • ))} +
+
+ ))} +
+

+ → varje fält länkar till bridge_provenance · ingen uppgift utan spårbar källa +

+
+ ) : null} + + {tab === "Kvalitetsrapport" ? ( +
+
+

Per dimension

+
+ {DIMENSIONS.map(([name, v, amber]) => ( +
+
{name}{v}%
+
+
+
+
+ ))} +
+
+
+

Kontroller som passerade

+
    + {CHECKS.map((c) => ( +
  • + ✓ + {c} +
  • + ))} +
+
+
+ ) : null} +
+ + {/* destinations */} +
+

Leverera till

+
+ {DESTINATIONS.map((d) => { + const on = dests.includes(d); + return ( + + ); + })} +
+
+ + {/* action bar */} +
+ {approved ? ( +
+

+ ✓ Leverans godkänd. Slutbetalning frigjord. Export upplåst. +

+

Order {id} · levererad till {dests.join(" · ") || "—"}.

+
+ ) : ( +
+

+ Godkänn leveransen — frigör resterande belopp ur depån och lås upp full export. Inte nöjd? Begär revidering utan kostnad. +

+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/components/order-stepper.tsx b/components/order-stepper.tsx new file mode 100644 index 0000000..a913b9b --- /dev/null +++ b/components/order-stepper.tsx @@ -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 ( + + ); +} diff --git a/components/provenance-badge.tsx b/components/provenance-badge.tsx new file mode 100644 index 0000000..74e10d6 --- /dev/null +++ b/components/provenance-badge.tsx @@ -0,0 +1,75 @@ +import type { ProvLevel } from "@/lib/data"; + +const PROV_COLOR: Record = { + 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 = { + 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 ( + + + {source} + {freshness ? ( + <> + · + {freshness} + + ) : null} + + ); +} + +/** Just the 9px status square — for the end of table cells. */ +export function CellProvenance({ level }: { level: ProvLevel }) { + return ( + + ); +} + +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" }, +]; diff --git a/hooks/use-reveal.ts b/hooks/use-reveal.ts new file mode 100644 index 0000000..f0c86bc --- /dev/null +++ b/hooks/use-reveal.ts @@ -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() { + const ref = useRef(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; +} diff --git a/lib/data.ts b/lib/data.ts new file mode 100644 index 0000000..1ba9195 --- /dev/null +++ b/lib/data.ts @@ -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..." }, +]; diff --git a/package.json b/package.json index 60cce49..77aa307 100644 --- a/package.json +++ b/package.json @@ -69,5 +69,8 @@ "tailwindcss": "^4.1.9", "tw-animate-css": "1.3.3", "typescript": "^5" + }, + "pnpm": { + "onlyBuiltDependencies": ["sharp"] } } \ No newline at end of file diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ee81e4e --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + sharp: set this to true or false