fix(i18n): localize /katalog filters, /katalog/[id] full rewrite, /portal/subscriptions

Three missed files from the i18n pass:
- /katalog: FILTERS had hardcoded Swedish labels (Alla/Bolag/Beslutsfattare/Fordon/Finansiellt)
- /katalog/[id]: entire page was Swedish — CATALOG data, TABS (with variable-shadowing bug
  where map((t) shadowed useT()), SectionHead, all 5 tab bodies, order rail, PROVENANCE_LEGEND
  rendered as raw Loc object. Converted to stable TabKey enum + all Loc.
- /portal/subscriptions: useT imported but never called; title/labels/options all Swedish.

tsc 0 + build green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:31:07 +02:00
co-authored by Claude Sonnet 4.6
parent f4beca3f76
commit a928ae4e98
3 changed files with 207 additions and 202 deletions
+163 -184
View File
@@ -10,24 +10,24 @@ import { ProvenanceBadge, CellProvenance, PROVENANCE_LEGEND } from "@/components
import { LoadingSkeleton, EmptyState, ErrorState } from "@/components/states";
import { useReveal } from "@/hooks/use-reveal";
import { FIELDS, SAMPLE_ROWS, MARKETS, formatSEK, type ProvLevel } from "@/lib/data";
import type { Loc } from "@/lib/data";
// ─────────────────────────────────────────────────────────────────────────────
// Inline catalog. No detail API yet — keyed by slugified id with a safe fallback.
// Mirrors the slugs that the catalog cards (featured-products-section) imply.
// ─────────────────────────────────────────────────────────────────────────────
interface Dataset {
id: string;
markets: string;
marketCodes: string[];
title: string;
blurb: string;
count: string;
title: Loc;
blurb: Loc;
count: Loc;
rows: number;
freshness: string;
freshness: Loc;
level: ProvLevel;
source: string;
cat: string;
cat: Loc;
price: number;
artifact: "table" | "map" | "schema" | "chart";
}
@@ -37,14 +37,14 @@ const CATALOG: Dataset[] = [
id: "nordisk-bolagsdata",
markets: "SE · NO · DK · FI",
marketCodes: ["SE", "NO", "DK", "FI"],
title: "Nordisk bolagsdata",
blurb: "Officiella register. Per fält.",
count: "4,9M poster",
title: { en: "Nordic company data", sv: "Nordisk bolagsdata" },
blurb: { en: "Official registers. Per field.", sv: "Officiella register. Per fält." },
count: { en: "4.9M records", sv: "4,9M poster" },
rows: 4900000,
freshness: "daglig",
freshness: { en: "daily", sv: "daglig" },
level: 3,
source: "GLEIF · Brreg",
cat: "Bolag",
cat: { en: "Companies", sv: "Bolag" },
price: 18400,
artifact: "table",
},
@@ -52,14 +52,14 @@ const CATALOG: Dataset[] = [
id: "beslutsfattare-tech",
markets: "SE · NO · DK · FI",
marketCodes: ["SE", "NO", "DK", "FI"],
title: "Beslutsfattare i tech",
blurb: "Roll och väg. Källa per fält.",
count: "182k poster",
title: { en: "Decision-makers in tech", sv: "Beslutsfattare i tech" },
blurb: { en: "Role and path. Source per field.", sv: "Roll och väg. Källa per fält." },
count: { en: "182k records", sv: "182k poster" },
rows: 182000,
freshness: "veckovis",
freshness: { en: "weekly", sv: "veckovis" },
level: 1,
source: "publik webb",
cat: "Beslutsfattare",
source: "public web",
cat: { en: "Decision-makers", sv: "Beslutsfattare" },
price: 9600,
artifact: "schema",
},
@@ -67,14 +67,14 @@ const CATALOG: Dataset[] = [
id: "fordon-flotta",
markets: "SE",
marketCodes: ["SE"],
title: "Fordon & flotta",
blurb: "Tekniska data. VehIQ-synergin.",
count: "6,1M poster",
title: { en: "Vehicles & fleet", sv: "Fordon & flotta" },
blurb: { en: "Technical data. VehIQ synergy.", sv: "Tekniska data. VehIQ-synergin." },
count: { en: "6.1M records", sv: "6,1M poster" },
rows: 6100000,
freshness: "daglig",
freshness: { en: "daily", sv: "daglig" },
level: 2,
source: "register",
cat: "Fordon",
cat: { en: "Vehicles", sv: "Fordon" },
price: 12200,
artifact: "map",
},
@@ -82,21 +82,29 @@ const CATALOG: Dataset[] = [
id: "finansiella-serier",
markets: "SE · NO · DK · FI",
marketCodes: ["SE", "NO", "DK", "FI"],
title: "Finansiella serier",
blurb: "Omsättning, resultat, nyckeltal.",
count: "12 år",
title: { en: "Financial series", sv: "Finansiella serier" },
blurb: { en: "Revenue, result, KPIs.", sv: "Omsättning, resultat, nyckeltal." },
count: { en: "12 years", sv: "12 år" },
rows: 240000,
freshness: "årlig",
freshness: { en: "annual", sv: "årlig" },
level: 2,
source: "register",
cat: "Finansiellt",
cat: { en: "Financials", sv: "Finansiellt" },
price: 14800,
artifact: "chart",
},
];
const TABS = ["Översikt", "Exempeldata", "Kvalitet", "Schema", "Täckning", "Pris"] as const;
type Tab = (typeof TABS)[number];
// Stable tab keys — never change, display labels are localized separately
type TabKey = "overview" | "samples" | "quality" | "schema" | "coverage" | "price";
const TABS: { key: TabKey; label: Loc }[] = [
{ key: "overview", label: { en: "Overview", sv: "Översikt" } },
{ key: "samples", label: { en: "Sample data", sv: "Exempeldata" } },
{ key: "quality", label: { en: "Quality", sv: "Kvalitet" } },
{ key: "schema", label: { en: "Schema", sv: "Schema" } },
{ key: "coverage", label: { en: "Coverage", sv: "Täckning" } },
{ key: "price", label: { en: "Price", sv: "Pris" } },
];
function findDataset(raw: string | undefined): Dataset | undefined {
if (!raw) return undefined;
@@ -111,27 +119,25 @@ export default function DatasetDetailPage() {
const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">("loading");
const [dataset, setDataset] = useState<Dataset | null>(null);
const [tab, setTab] = useState<Tab>("Översikt");
const [tab, setTab] = useState<TabKey>("overview");
const attempt = useRef(0);
// Simulated detail fetch. Replace with GET /catalog/:id when the API lands.
const load = useCallback(() => {
setStatus("loading");
attempt.current += 1;
const run = attempt.current;
const t = setTimeout(() => {
const timer = setTimeout(() => {
if (run !== attempt.current) return;
const hit = findDataset(id);
if (hit) {
setDataset(hit);
setStatus("ready");
} else {
// Known id-space, no match → empty (not an error).
setDataset(null);
setStatus("empty");
}
}, 540);
return () => clearTimeout(t);
return () => clearTimeout(timer);
}, [id]);
useEffect(() => {
@@ -157,12 +163,9 @@ export default function DatasetDetailPage() {
{status === "error" ? (
<section className="py-24">
<ErrorState title={t({ en: "Couldn’t load the dataset", sv: "Kunde inte hämta datasetet" })} onRetry={load} />
<ErrorState title={t({ en: "Couldn't load the dataset", sv: "Kunde inte hämta datasetet" })} onRetry={load} />
<div className="mt-10">
<Link
href="/#katalog"
className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
<Link href="/#katalog" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
{t({ en: "← Back to the catalog", sv: "← Till katalogen" })}
</Link>
</div>
@@ -172,19 +175,13 @@ export default function DatasetDetailPage() {
{status === "empty" ? (
<section className="py-24">
<EmptyState
title={t({ en: "This dataset doesn’t exist", sv: "Datasetet finns inte" })}
line={t({
en: "Nothing in the catalog matches that link. Build the job yourself or go back.",
sv: "Inget i katalogen matchar den länken. Bygg jobbet själv eller gå tillbaka.",
})}
title={t({ en: "This dataset doesn't exist", sv: "Datasetet finns inte" })}
line={t({ en: "Nothing in the catalog matches that link. Build the job yourself or go back.", sv: "Inget i katalogen matchar den länken. Bygg jobbet själv eller gå tillbaka." })}
ctaHref="/bestall"
ctaLabel={t({ en: "Compose a job", sv: "Komponera ett jobb" })}
/>
<div className="mt-10">
<Link
href="/#katalog"
className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
<Link href="/#katalog" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
{t({ en: "← Back to the catalog", sv: "← Till katalogen" })}
</Link>
</div>
@@ -199,15 +196,7 @@ export default function DatasetDetailPage() {
// ─────────────────────────────────────────────────────────────────────────────
function DatasetView({
d,
tab,
setTab,
}: {
d: Dataset;
tab: Tab;
setTab: (t: Tab) => void;
}) {
function DatasetView({ d, tab, setTab }: { d: Dataset; tab: TabKey; setTab: (k: TabKey) => void }) {
const t = useT();
const deposit = Math.round(d.price / 2);
const remainder = d.price - deposit;
@@ -220,50 +209,42 @@ function DatasetView({
{t({ en: "Catalog", sv: "Katalog" })}
</Link>
<span className="text-border">/</span>
<span className="text-foreground">{d.title}</span>
<span className="text-foreground">{t(d.title)}</span>
</div>
{/* masthead — oversized faint numeral as texture */}
{/* masthead */}
<header className="relative border-b border-border py-16 md:py-20">
<span
aria-hidden
className="pointer-events-none absolute -top-6 right-0 select-none font-mono text-[clamp(7rem,18vw,15rem)] font-medium leading-none tracking-[-0.04em] text-foreground/[0.04]"
>
<span aria-hidden className="pointer-events-none absolute -top-6 right-0 select-none font-mono text-[clamp(7rem,18vw,15rem)] font-medium leading-none tracking-[-0.04em] text-foreground/[0.04]">
02
</span>
<p className="reveal font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Katalog — {d.cat}
{t({ en: "Catalog", sv: "Katalog" })} — {t(d.cat)}
</p>
<h1 className="reveal delay-1 mt-5 max-w-[16ch] text-[clamp(2.6rem,7vw,5.5rem)] font-medium leading-[0.95] tracking-[-0.035em]">
{d.title}.
{t(d.title)}.
</h1>
<p className="reveal delay-2 mt-5 max-w-md font-mono text-sm text-muted-foreground">{d.blurb}</p>
<p className="reveal delay-2 mt-5 max-w-md font-mono text-sm text-muted-foreground">{t(d.blurb)}</p>
<div className="reveal delay-3 mt-8 flex flex-wrap items-center gap-3">
<ProvenanceBadge level={d.level} source={d.source} freshness={d.freshness} />
<span className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
{d.markets}
</span>
<ProvenanceBadge level={d.level} source={d.source} freshness={t(d.freshness)} />
<span className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">{d.markets}</span>
<span className="text-border">·</span>
<span className="font-mono text-[11px] uppercase tracking-[0.1em] text-foreground">{d.count}</span>
<span className="font-mono text-[11px] uppercase tracking-[0.1em] text-foreground">{t(d.count)}</span>
</div>
</header>
{/* two columns — content + sticky order rail */}
{/* two columns */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px]">
<div className="reveal-left delay-1 lg:border-r lg:border-border">
{/* tabs — shared baseline, single active marker */}
<nav
aria-label="Vyer"
className="sticky top-16 z-30 -mx-px flex flex-wrap gap-px border-b border-border bg-border"
>
{TABS.map((t) => {
const active = t === tab;
{/* tabs */}
<nav aria-label={t({ en: "Views", sv: "Vyer" })} className="sticky top-16 z-30 -mx-px flex flex-wrap gap-px border-b border-border bg-border">
{TABS.map((tb) => {
const active = tb.key === tab;
return (
<button
key={t}
key={tb.key}
type="button"
onClick={() => setTab(t)}
onClick={() => setTab(tb.key)}
aria-current={active ? "page" : undefined}
className="flex-1 bg-background px-4 py-3.5 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{
@@ -271,70 +252,65 @@ function DatasetView({
color: active ? "var(--background)" : "var(--muted-foreground)",
}}
>
{t}
{t(tb.label)}
</button>
);
})}
</nav>
<div className="px-1 py-10 lg:pr-8">
{tab === "Översikt" ? <OverviewTab d={d} /> : null}
{tab === "Exempeldata" ? <SampleTab d={d} /> : null}
{tab === "Kvalitet" ? <QualityTab d={d} /> : null}
{tab === "Schema" ? <SchemaTab /> : null}
{tab === "Täckning" ? <CoverageTab d={d} /> : null}
{tab === "Pris" ? <PriceTab price={d.price} deposit={deposit} remainder={remainder} /> : null}
{tab === "overview" ? <OverviewTab d={d} /> : null}
{tab === "samples" ? <SampleTab d={d} /> : null}
{tab === "quality" ? <QualityTab d={d} /> : null}
{tab === "schema" ? <SchemaTab /> : null}
{tab === "coverage" ? <CoverageTab d={d} /> : null}
{tab === "price" ? <PriceTab price={d.price} deposit={deposit} remainder={remainder} /> : null}
</div>
</div>
{/* sticky right — order panel */}
{/* sticky order rail */}
<aside className="reveal-right delay-2">
<div className="lg:sticky lg:top-20 lg:py-10 lg:pl-8">
<div className="border border-border bg-background">
<div className="border-b border-border px-6 py-5">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">Pris</p>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{t({ en: "Price", sv: "Pris" })}</p>
<p className="mt-3 text-[clamp(2rem,4vw,2.6rem)] font-medium leading-none tracking-[-0.03em] tabular-nums">
{formatSEK(d.price)}
</p>
<p className="mt-3 font-mono text-[11px] text-muted-foreground">Engångsleverans · per fält</p>
<p className="mt-3 font-mono text-[11px] text-muted-foreground">{t({ en: "One-time delivery · per field", sv: "Engångsleverans · per fält" })}</p>
</div>
{/* 50% deposit ledger */}
<div className="border-b border-border px-6 py-5">
<div className="flex items-baseline justify-between font-mono text-[12px]">
<span className="uppercase tracking-[0.1em] text-muted-foreground">Förskott 50 %</span>
<span className="uppercase tracking-[0.1em] text-muted-foreground">{t({ en: "Deposit 50%", sv: "Förskott 50 %" })}</span>
<span className="tabular-nums text-foreground">{formatSEK(deposit)}</span>
</div>
<div className="mt-3 flex items-baseline justify-between font-mono text-[12px]">
<span className="uppercase tracking-[0.1em] text-muted-foreground">Vid godkänt</span>
<span className="uppercase tracking-[0.1em] text-muted-foreground">{t({ en: "On approval", sv: "Vid godkänt" })}</span>
<span className="tabular-nums text-muted-foreground">{formatSEK(remainder)}</span>
</div>
<p className="mt-4 border-t border-border pt-4 font-mono text-[10.5px] leading-relaxed text-muted-foreground">
Resterande 50 % hålls i escrow. Släpps först när du godkänt resultatet.
{t({ en: "The remaining 50% is held in escrow. Released only when you approve.", sv: "Resterande 50 % hålls i escrow. Släpps först när du godkänt resultatet." })}
</p>
</div>
{/* the one red per screen */}
<div className="px-6 py-6">
<Link
href="/bestall"
className="group flex items-center justify-center gap-3 px-5 py-3.5 font-mono text-[12px] uppercase tracking-[0.12em] text-background transition-opacity duration-200 hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{ backgroundColor: "var(--accent-red)" }}
>
Beställ
<span aria-hidden className="transition-transform duration-300 group-hover:translate-x-1">
→
</span>
{t({ en: "Order", sv: "Beställ" })}
<span aria-hidden className="transition-transform duration-300 group-hover:translate-x-1">→</span>
</Link>
<p className="mt-4 text-center font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
Inget binder förrän förskott
{t({ en: "Nothing binding until deposit", sv: "Inget binder förrän förskott" })}
</p>
</div>
</div>
{/* honest disclosure */}
<p className="mt-6 font-mono text-[10.5px] leading-relaxed text-muted-foreground">
Saknade fält levereras som “ej tillgänglig / licens”. Vi gissar aldrig.
{t({ en: "Missing fields delivered as \"not available / licence\". We never guess.", sv: "Saknade fält levereras som \"ej tillgänglig / licens\". Vi gissar aldrig." })}
</p>
</div>
</aside>
@@ -343,43 +319,48 @@ function DatasetView({
);
}
// ── Tab bodies ───────────────────────────────────────────────────────────────
// ── Shared section header ────────────────────────────────────────────────────
function SectionHead({ num, kicker }: { num: string; kicker: string }) {
function SectionHead({ num, kicker }: { num: string; kicker: Loc }) {
const t = useT();
return (
<div className="flex items-baseline justify-between border-b border-border pb-4">
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">{kicker}</p>
<span aria-hidden className="font-mono text-[11px] tabular-nums text-foreground/20">
{num}
</span>
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">{t(kicker)}</p>
<span aria-hidden className="font-mono text-[11px] tabular-nums text-foreground/20">{num}</span>
</div>
);
}
// ── Tab bodies ───────────────────────────────────────────────────────────────
function OverviewTab({ d }: { d: Dataset }) {
const stats: [string, string][] = [
[d.count, "omfattning"],
[`${d.marketCodes.length}`, "marknader"],
[d.freshness, "uppdatering"],
const t = useT();
const stats: [Loc, Loc][] = [
[d.count, { en: "scope", sv: "omfattning" }],
[{ en: String(d.marketCodes.length),
sv: String(d.marketCodes.length) }, { en: "markets", sv: "marknader" }],
[d.freshness, { en: "update", sv: "uppdatering" }],
];
return (
<div>
<SectionHead num="01" kicker="Översikt" />
<SectionHead num="01" kicker={{ en: "Overview", sv: "Översikt" }} />
<p className="mt-8 max-w-xl text-pretty text-[17px] leading-relaxed">
Strukturerat per fält. Varje värde bär källa, färskhet och konfidens — så att du ser var det
kommer ifrån innan du litar på det.
{t({
en: "Structured per field. Every value carries source, freshness, and confidence — so you see where it comes from before you rely on it.",
sv: "Strukturerat per fält. Varje värde bär källa, färskhet och konfidens — så att du ser var det kommer ifrån innan du litar på det.",
})}
</p>
<div className="mt-10 grid grid-cols-1 gap-px border border-border bg-border sm:grid-cols-3">
{stats.map(([n, l], i) => (
<div key={l} className="bg-background p-6">
<div key={l.en} className="bg-background p-6">
<p
className="text-[clamp(1.6rem,3vw,2.2rem)] font-medium leading-none tracking-[-0.02em] tabular-nums"
style={i === 0 ? { color: "var(--accent-red)" } : undefined}
>
{n}
{t(n)}
</p>
<p className="mt-3 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">{l}</p>
<p className="mt-3 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">{t(l)}</p>
</div>
))}
</div>
@@ -392,29 +373,33 @@ function OverviewTab({ d }: { d: Dataset }) {
}
function SampleTab({ d }: { d: Dataset }) {
const t = useT();
const headers: Loc[] = [
{ en: "Org.no", sv: "Org.nr" },
{ en: "Company", sv: "Bolag" },
{ en: "HQ", sv: "Säte" },
{ en: "Employees", sv: "Anställda" },
{ en: "Revenue (tSEK)", sv: "Oms (tkr)" },
{ en: "Email", sv: "E-post" },
];
return (
<div>
<SectionHead num="02" kicker="Exempeldata" />
<SectionHead num="02" kicker={{ en: "Sample data", sv: "Exempeldata" }} />
<p className="mt-8 font-mono text-[12px] text-muted-foreground">
Fem rader. Provenans per cell.
{t({ en: "Five rows. Provenance per cell.", sv: "Fem rader. Provenans per cell." })}
</p>
{/* primary view — generative table artifact */}
<div className="mt-8">
<DataArtifact variant="table" />
</div>
{/* concrete sample rows from lib/data */}
<div className="mt-px overflow-x-auto border border-border">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border">
{["Org.nr", "Bolag", "Säte", "Anställda", "Oms (tkr)", "E-post"].map((h) => (
<th
key={h}
className="whitespace-nowrap px-4 py-3 text-left font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground"
>
{h}
{headers.map((h) => (
<th key={h.en} className="whitespace-nowrap px-4 py-3 text-left font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
{t(h)}
</th>
))}
</tr>
@@ -422,9 +407,7 @@ function SampleTab({ d }: { d: Dataset }) {
<tbody>
{SAMPLE_ROWS.map((r) => (
<tr key={r.orgnr} className="border-b border-border last:border-b-0 transition-colors hover:bg-secondary/40">
<td className="whitespace-nowrap px-4 py-3 font-mono text-[12px] tabular-nums text-muted-foreground">
{r.orgnr}
</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-[12px] tabular-nums text-muted-foreground">{r.orgnr}</td>
<td className="whitespace-nowrap px-4 py-3 text-[14px] tracking-tight">{r.bolag}</td>
<td className="whitespace-nowrap px-4 py-3 text-[13px] text-muted-foreground">{r.sate}</td>
<td className="whitespace-nowrap px-4 py-3 text-right font-mono text-[12px] tabular-nums">
@@ -437,7 +420,7 @@ function SampleTab({ d }: { d: Dataset }) {
{r.epost ? (
<span className="text-foreground">{r.epost}</span>
) : (
<span className="text-muted-foreground">ej tillgänglig</span>
<span className="text-muted-foreground">{t({ en: "not available", sv: "ej tillgänglig" })}</span>
)}
<CellProvenance level={r.epostLevel} />
</td>
@@ -447,32 +430,35 @@ function SampleTab({ d }: { d: Dataset }) {
</table>
</div>
<p className="mt-4 font-mono text-[10.5px] text-muted-foreground">
Tom = licens krävs i marknaden. Inget gissat. Källa: {d.source}.
{t({ en: "Empty = licence required in that market. Nothing guessed. Source:", sv: "Tom = licens krävs i marknaden. Inget gissat. Källa:" })} {d.source}.
</p>
</div>
);
}
function QualityTab({ d }: { d: Dataset }) {
const metrics: [string, number, ProvLevel][] = [
["Identifierad entitet", 99, 3],
["LEI-länkad", 92, 3],
["Dubbletter borttagna", 100, 3],
["E-post verifierad (MX)", 71, 2],
["Källor överens", 88, d.level],
const t = useT();
const metrics: [Loc, number, ProvLevel][] = [
[{ en: "Identified entity", sv: "Identifierad entitet" }, 99, 3],
[{ en: "LEI-linked", sv: "LEI-länkad" }, 92, 3],
[{ en: "Duplicates removed", sv: "Dubbletter borttagna" }, 100, 3],
[{ en: "Email verified (MX)", sv: "E-post verifierad (MX)" }, 71, 2],
[{ en: "Sources agree", sv: "Källor överens" }, 88, d.level],
];
return (
<div>
<SectionHead num="03" kicker="Kvalitet" />
<SectionHead num="03" kicker={{ en: "Quality", sv: "Kvalitet" }} />
<p className="mt-8 max-w-xl text-pretty text-[17px] leading-relaxed">
Mätt, inte påstått. Varje rad passerar expectations, dedup och en konfidenströskel innan den
levereras.
{t({
en: "Measured, not claimed. Every row passes expectations, dedup and a confidence threshold before delivery.",
sv: "Mätt, inte påstått. Varje rad passerar expectations, dedup och en konfidenströskel innan den levereras.",
})}
</p>
<div className="mt-10 flex flex-col gap-5">
{metrics.map(([label, pct, level]) => (
<div key={label} className="flex items-center gap-4">
<span className="w-52 shrink-0 text-[14px] tracking-tight">{label}</span>
<div key={label.en} className="flex items-center gap-4">
<span className="w-52 shrink-0 text-[14px] tracking-tight">{t(label)}</span>
<div className="h-px flex-1 bg-border">
<div
className="h-px transition-all duration-500"
@@ -487,9 +473,8 @@ function QualityTab({ d }: { d: Dataset }) {
))}
</div>
{/* provenance legend */}
<div className="mt-12 border-t border-border pt-6">
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">Härkomst</p>
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{t({ en: "Provenance", sv: "Härkomst" })}</p>
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
{PROVENANCE_LEGEND.map((p) => (
<div key={p.level} className="flex items-center gap-3 font-mono text-[11px] text-muted-foreground">
@@ -501,7 +486,7 @@ function QualityTab({ d }: { d: Dataset }) {
border: p.level === 0 ? "1px solid var(--prov-0)" : "none",
}}
/>
{p.text}
{t(p.text)}
</div>
))}
</div>
@@ -514,7 +499,7 @@ function SchemaTab() {
const t = useT();
return (
<div>
<SectionHead num="04" kicker="Schema" />
<SectionHead num="04" kicker={{ en: "Schema", sv: "Schema" }} />
<p className="mt-8 font-mono text-[12px] text-muted-foreground">{t({ en: "Field, source, weight. Per field.", sv: "Fält, källa, vikt. Per fält." })}</p>
<div className="mt-8">
@@ -523,15 +508,10 @@ function SchemaTab() {
<div className="mt-px border border-border">
{FIELDS.map((f, i) => (
<div
key={f.id}
className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-4 transition-colors last:border-b-0 hover:bg-secondary/40"
>
<div key={f.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-4 transition-colors last:border-b-0 hover:bg-secondary/40">
<div className="min-w-0">
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</span>
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">{String(i + 1).padStart(2, "0")}</span>
<span className="text-[15px] tracking-tight">{t(f.name)}</span>
{f.locked ? (
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-muted-foreground">{t({ en: "locked", sv: "låst" })}</span>
@@ -548,12 +528,16 @@ function SchemaTab() {
}
function CoverageTab({ d }: { d: Dataset }) {
const t = useT();
const inSet = (code: string) => d.marketCodes.includes(code);
return (
<div>
<SectionHead num="05" kicker="Täckning" />
<SectionHead num="05" kicker={{ en: "Coverage", sv: "Täckning" }} />
<p className="mt-8 max-w-xl text-pretty text-[17px] leading-relaxed">
Var datasetet lever idag. Markerade marknader ingår — resten är kommande.
{t({
en: "Where the dataset lives today. Marked markets are included — the rest is coming.",
sv: "Var datasetet lever idag. Markerade marknader ingår — resten är kommande.",
})}
</p>
<div className="mt-8">
@@ -564,23 +548,19 @@ function CoverageTab({ d }: { d: Dataset }) {
{MARKETS.slice(0, 12).map((m) => {
const included = inSet(m.code) && m.live;
return (
<div
key={m.code}
className="relative bg-background p-4 transition-opacity duration-300"
style={{ opacity: included ? 1 : 0.4 }}
>
<div key={m.code} className="relative bg-background p-4 transition-opacity duration-300" style={{ opacity: included ? 1 : 0.4 }}>
<div className="flex items-baseline justify-between">
<p className="font-mono text-sm font-semibold tracking-tight">{m.code}</p>
{included ? (
<span
aria-hidden
className="inline-block h-[7px] w-[7px]"
style={{ backgroundColor: "var(--prov-3)" }}
/>
<span aria-hidden className="inline-block h-[7px] w-[7px]" style={{ backgroundColor: "var(--prov-3)" }} />
) : null}
</div>
<p className="mt-1 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">
{included ? `${m.coverage}%` : m.live ? "ej i set" : "kommande"}
{included
? `${m.coverage}%`
: m.live
? t({ en: "not in set", sv: "ej i set" })
: t({ en: "upcoming", sv: "kommande" })}
</p>
</div>
);
@@ -591,25 +571,26 @@ function CoverageTab({ d }: { d: Dataset }) {
}
function PriceTab({ price, deposit, remainder }: { price: number; deposit: number; remainder: number }) {
const lines: [string, string, boolean][] = [
["Listpris", formatSEK(price), false],
["Förskott 50 % — startar produktion", formatSEK(deposit), true],
["Vid godkänt — släpps ur escrow", formatSEK(remainder), false],
const t = useT();
const lines: [Loc, string, boolean][] = [
[{ en: "List price", sv: "Listpris" }, formatSEK(price), false],
[{ en: "Deposit 50% — starts production", sv: "Förskott 50 % — startar produktion" }, formatSEK(deposit), true],
[{ en: "On approval — released from escrow",sv: "Vid godkänt — släpps ur escrow" }, formatSEK(remainder), false],
];
return (
<div>
<SectionHead num="06" kicker="Pris" />
<SectionHead num="06" kicker={{ en: "Price", sv: "Pris" }} />
<p className="mt-8 max-w-xl text-pretty text-[17px] leading-relaxed">
Halva nu, halva vid godkänt. Resterande hålls i escrow tills du validerat resultatet.
{t({
en: "Half now, half on approval. The remaining half is held in escrow until you have validated the result.",
sv: "Halva nu, halva vid godkänt. Resterande hålls i escrow tills du validerat resultatet.",
})}
</p>
<div className="mt-10 border border-border">
{lines.map(([label, val, hot]) => (
<div
key={label}
className="flex items-baseline justify-between gap-4 border-b border-border px-5 py-5 last:border-b-0"
>
<span className="font-mono text-[12px] uppercase tracking-[0.08em] text-muted-foreground">{label}</span>
<div key={label.en} className="flex items-baseline justify-between gap-4 border-b border-border px-5 py-5 last:border-b-0">
<span className="font-mono text-[12px] uppercase tracking-[0.08em] text-muted-foreground">{t(label)}</span>
<span
className="font-mono text-[15px] tabular-nums"
style={hot ? { color: "var(--accent-red)" } : { color: "var(--foreground)" }}
@@ -625,10 +606,8 @@ function PriceTab({ price, deposit, remainder }: { price: number; deposit: numbe
href="/bestall"
className="group inline-flex items-center gap-3 border border-foreground bg-background px-7 py-4 font-mono text-[12px] uppercase tracking-[0.12em] text-foreground transition-colors duration-200 hover:bg-foreground hover:text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Komponera & beställ
<span aria-hidden className="transition-transform duration-300 group-hover:translate-x-1">
→
</span>
{t({ en: "Compose & order", sv: "Komponera & beställ" })}
<span aria-hidden className="transition-transform duration-300 group-hover:translate-x-1">→</span>
</Link>
</div>
</div>
+7 -7
View File
@@ -22,12 +22,12 @@ interface CatalogItem {
confidence: number;
}
const FILTERS: { key: string; label: string }[] = [
{ key: "all", label: "Alla" },
{ key: "company", label: "Bolag" },
{ key: "contact", label: "Beslutsfattare" },
{ key: "vehicle", label: "Fordon" },
{ key: "financials", label: "Finansiellt" },
const FILTERS: { key: string; label: { en: string; sv: string } }[] = [
{ key: "all", label: { en: "All", sv: "Alla" } },
{ key: "company", label: { en: "Companies", sv: "Bolag" } },
{ key: "contact", label: { en: "Decision-makers", sv: "Beslutsfattare" } },
{ key: "vehicle", label: { en: "Vehicles", sv: "Fordon" } },
{ key: "financials", label: { en: "Financials", sv: "Finansiellt" } },
];
function level(c: number): ProvLevel {
@@ -97,7 +97,7 @@ export default function CatalogPage() {
color: filter === f.key ? "var(--background)" : "var(--foreground)",
}}
>
{f.label}
{t(f.label)}
</button>
))}
</div>
+37 -11
View File
@@ -14,6 +14,7 @@ interface Sub {
}
export default function SubscriptionsPage() {
const t = useT();
const [subs, setSubs] = useState<Sub[] | null>(null);
const [error, setError] = useState(false);
const [orderId, setOrderId] = useState("");
@@ -37,28 +38,53 @@ export default function SubscriptionsPage() {
}
return (
<PortalShell variant="customer" title="Prenumerationer & flöden">
<PortalShell variant="customer" title={t({ en: "Subscriptions & flows", sv: "Prenumerationer & flöden" })}>
<div className="border border-border p-5">
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Gör om en order till ett flöde</p>
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
{t({ en: "Convert an order into a flow", sv: "Gör om en order till ett flöde" })}
</p>
<div className="mt-4 flex flex-wrap items-end gap-3">
<input value={orderId} onChange={(e) => setOrderId(e.target.value)} placeholder="order_id" className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
<select value={cadence} onChange={(e) => setCadence(e.target.value)} className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground">
<option value="weekly">Veckovis</option>
<option value="daily">Dagligt</option>
<input
value={orderId}
onChange={(e) => setOrderId(e.target.value)}
placeholder="order_id"
className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground"
/>
<select
value={cadence}
onChange={(e) => setCadence(e.target.value)}
className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground"
>
<option value="weekly">{t({ en: "Weekly", sv: "Veckovis" })}</option>
<option value="daily">{t({ en: "Daily", sv: "Dagligt" })}</option>
</select>
<button onClick={create} disabled={busy || !orderId} className="bg-foreground px-5 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">
{busy ? "Skapar…" : "Skapa flöde"}
<button
onClick={create}
disabled={busy || !orderId}
className="bg-foreground px-5 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50"
>
{busy ? t({ en: "Creating…", sv: "Skapar…" }) : t({ en: "Create flow", sv: "Skapa flöde" })}
</button>
</div>
</div>
<div className="mt-8">
{error ? <ErrorState onRetry={load} /> : !subs ? <LoadingSkeleton rows={4} /> : subs.length === 0 ? (
<EmptyState title="Inga flöden än." line="Gör om en order till ett återkommande flöde." />
{error ? (
<ErrorState onRetry={load} />
) : !subs ? (
<LoadingSkeleton rows={4} />
) : subs.length === 0 ? (
<EmptyState
title={t({ en: "No flows yet.", sv: "Inga flöden än." })}
line={t({ en: "Convert an order into a recurring flow.", sv: "Gör om en order till ett återkommande flöde." })}
/>
) : (
<div className="border border-border">
<div className="grid grid-cols-[1.6fr_1fr_1fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
<span>Order</span><span>Kadens</span><span>Status</span><span>Webhook</span>
<span>{t({ en: "Order", sv: "Order" })}</span>
<span>{t({ en: "Cadence", sv: "Kadens" })}</span>
<span>{t({ en: "Status", sv: "Status" })}</span>
<span>{t({ en: "Webhook", sv: "Webhook" })}</span>
</div>
{subs.map((s) => (
<div key={s.sub_id} className="grid grid-cols-[1.6fr_1fr_1fr_1fr] items-center border-b border-border px-4 py-3 text-sm last:border-b-0">