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:
+163
-184
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user