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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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
@@ -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..." },
|
||||
];
|
||||
@@ -69,5 +69,8 @@
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["sharp"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
sharp: set this to true or false
|
||||
Reference in New Issue
Block a user