From 125b2e1bbe9166959fa8caf17818ce2a759b599d Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Thu, 25 Jun 2026 11:47:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20localize=20data-layer=20labels=20?= =?UTF-8?q?(lib/data=20+=20lib/services)=20=E2=80=94=20English=20base=20co?= =?UTF-8?q?mplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All display labels in MARKETS/ENTITY_TYPES/FIELDS/ENRICHMENTS/FREQUENCIES/DELIVERY_FORMATS + SERVICES/FLOW_LABEL are now {en,sv} with stable IDs/values (pricing logic, EntityType/Frequency unions, config keys unchanged). Added entityLabel()/FREQUENCY_LABEL/FORMAT_LABEL. Updated all render sites (configurator, tjanster, beskriv, katalog/[id]) to resolve via useT(). Also fixed 2 pre-existing tsc nits (auth BufferSource cast, orders-store sql.json param type). tsc 0, build green. 0pt is now fully English-by-default with SV toggle. Co-Authored-By: Claude Opus 4.8 --- app/bestall/beskriv/page.tsx | 4 +- app/bestall/page.tsx | 27 +++++---- app/katalog/[id]/page.tsx | 11 ++-- app/tjanster/page.tsx | 10 ++-- lib/auth.ts | 2 +- lib/data.ts | 110 +++++++++++++++++++++-------------- lib/orders-store.ts | 2 +- lib/services.ts | 43 ++++++++------ 8 files changed, 121 insertions(+), 88 deletions(-) diff --git a/app/bestall/beskriv/page.tsx b/app/bestall/beskriv/page.tsx index 7275b41..66a7096 100644 --- a/app/bestall/beskriv/page.tsx +++ b/app/bestall/beskriv/page.tsx @@ -74,11 +74,11 @@ export default function DescribePage() { color: service === s.id ? "var(--background)" : "var(--foreground)", }} > - {s.name} + {t(s.name)} ))} - {svc ?

{svc.covers} → {svc.deliverable}

: null} + {svc ?

{t(svc.covers)} → {t(svc.deliverable)}

: null}
diff --git a/app/bestall/page.tsx b/app/bestall/page.tsx index 7e0b035..4b4c034 100644 --- a/app/bestall/page.tsx +++ b/app/bestall/page.tsx @@ -14,6 +14,9 @@ import { ENRICHMENTS, FREQUENCIES, DELIVERY_FORMATS, + FREQUENCY_LABEL, + FORMAT_LABEL, + entityLabel, computeEstimate, formatSEK, type EntityType, @@ -137,12 +140,12 @@ export default function ConfiguratorPage() {
{SERVICES.map((s) => ( setService(s.id)}> - {s.name} + {t(s.name)} ))}
{serviceById(service) ? ( -

{serviceById(service)!.covers}

+

{t(serviceById(service)!.covers)}

) : null} @@ -169,7 +172,7 @@ export default function ConfiguratorPage() {
{ENTITY_TYPES.map((e) => ( setType(e.id)}> - {e.id} + {t(e.label)} ))}
@@ -203,13 +206,13 @@ export default function ConfiguratorPage() { onChange={() => toggle(fieldIds, f.id, setFieldIds)} /> - {f.name} - {f.sub} + {t(f.name)} + {t(f.sub)} {f.locked ? ( {t({ en: "locked", sv: "låst" })} ) : null} - + ); })} @@ -224,7 +227,7 @@ export default function ConfiguratorPage() { active={enrichmentIds.includes(e.id)} onClick={() => toggle(enrichmentIds, e.id, setEnrichmentIds)} > - {e.label} + {t(e.label)} ))}
@@ -243,7 +246,7 @@ export default function ConfiguratorPage() {
{FREQUENCIES.map((fr) => ( setFrequency(fr)}> - {fr} + {t(FREQUENCY_LABEL[fr])} ))}
@@ -280,7 +283,7 @@ export default function ConfiguratorPage() {
{DELIVERY_FORMATS.map((f) => ( toggle(formats, f, setFormats)}> - {f} + {t(FORMAT_LABEL[f] ?? { en: f, sv: f })} ))}
@@ -297,13 +300,13 @@ export default function ConfiguratorPage() {
{[ - [t({ en: "Service", sv: "Tjänst" }), serviceById(service)?.name ?? service], + [t({ en: "Service", sv: "Tjänst" }), serviceById(service) ? t(serviceById(service)!.name) : service], [t({ en: "Markets", sv: "Marknader" }), markets.join(" · ") || "—"], - [t({ en: "Type", sv: "Typ" }), type], + [t({ en: "Type", sv: "Typ" }), t(entityLabel(type))], [t({ en: "Fields", sv: "Fält" }), String(FIELDS.filter((f) => f.locked || fieldIds.includes(f.id)).length)], [t({ en: "Enrichment", sv: "Berikning" }), String(enrichmentIds.length)], [t({ en: "Volume", sv: "Volym" }), new Intl.NumberFormat("sv-SE").format(volume)], - [t({ en: "Frequency", sv: "Frekvens" }), frequency], + [t({ en: "Frequency", sv: "Frekvens" }), t(FREQUENCY_LABEL[frequency])], [t({ en: "Delivery", sv: "Leverans" }), `≈ ${est.timelineDays} ${t({ en: "days", sv: "dgr" })}`], [t({ en: "Confidence", sv: "Konfidens" }), `${est.estConfidence}%`], ].map(([k, v]) => ( diff --git a/app/katalog/[id]/page.tsx b/app/katalog/[id]/page.tsx index 2c0cf5c..075171e 100644 --- a/app/katalog/[id]/page.tsx +++ b/app/katalog/[id]/page.tsx @@ -511,10 +511,11 @@ function QualityTab({ d }: { d: Dataset }) { } function SchemaTab() { + const t = useT(); return (
-

Fält, källa, vikt. Per fält.

+

{t({ en: "Field, source, weight. Per field.", sv: "Fält, källa, vikt. Per fält." })}

@@ -531,14 +532,14 @@ function SchemaTab() { {String(i + 1).padStart(2, "0")} - {f.name} + {t(f.name)} {f.locked ? ( - låst + {t({ en: "locked", sv: "låst" })} ) : null}
-

{f.sub}

+

{t(f.sub)}

- + ))} diff --git a/app/tjanster/page.tsx b/app/tjanster/page.tsx index 245d6c0..0910ef8 100644 --- a/app/tjanster/page.tsx +++ b/app/tjanster/page.tsx @@ -39,7 +39,7 @@ export default function ServicesPage() { return (
-

{FLOW_LABEL[flow]}

+

{t(FLOW_LABEL[flow])}

{String(items.length).padStart(2, "0")}
@@ -47,10 +47,10 @@ export default function ServicesPage() { const cta = ctaFor(flow, s.id); return (
-

{s.name}

-

{s.tagline}

-

{s.covers}

-

→ {s.deliverable}

+

{t(s.name)}

+

{t(s.tagline)}

+

{t(s.covers)}

+

→ {t(s.deliverable)}

{t(cta.label)} → diff --git a/lib/auth.ts b/lib/auth.ts index 4831315..67385c3 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -32,7 +32,7 @@ export async function verifySession(token: string | undefined | null): Promise e.id === id)?.label ?? { en: id, sv: id }; +} export interface Field { id: string; - name: string; - sub: string; + name: Loc; + sub: Loc; level: ProvLevel; - source: string; - freshness: string; + source: Loc; + freshness: Loc; 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 }, + { id: "identity", name: { en: "Identity & registered office", sv: "Identitet & säte" }, sub: { en: "reg. no., name, address", sv: "org.nr, namn, adress" }, level: 3, source: { en: "GLEIF · Brreg", sv: "GLEIF · Brreg" }, freshness: { en: "daily", sv: "daglig" }, weight: 1.0, locked: true }, + { id: "lei", name: { en: "LEI / cross-border key", sv: "LEI / cross-border-nyckel" }, sub: { en: "GLEIF golden copy", sv: "GLEIF golden copy" }, level: 3, source: { en: "GLEIF", sv: "GLEIF" }, freshness: { en: "daily", sv: "daglig" }, weight: 0.8 }, + { id: "financials", name: { en: "Financials", sv: "Finansiellt" }, sub: { en: "revenue, profit, key ratios", sv: "omsättning, resultat, nyckeltal" }, level: 2, source: { en: "register", sv: "register" }, freshness: { en: "annual", sv: "årlig" }, weight: 1.6 }, + { id: "ubo", name: { en: "Beneficial owner (UBO)", sv: "Verklig huvudman (UBO)" }, sub: { en: "ownership chain", sv: "ägarkedja" }, level: 2, source: { en: "register", sv: "register" }, freshness: { en: "on change", sv: "vid ändring" }, weight: 1.4 }, + { id: "contact", name: { en: "Contact: email & phone", sv: "Kontakt: e-post & telefon" }, sub: { en: "licence required in DE/FR", sv: "licens krävs i DE/FR" }, level: 1, source: { en: "public web", sv: "publik webb" }, freshness: { en: "weekly", sv: "veckovis" }, weight: 1.8 }, + { id: "intent", name: { en: "Buying signals & intent", sv: "Köpsignaler & intent" }, sub: { en: "job ads, news, tech", sv: "jobbannonser, nyheter, tech" }, level: 1, source: { en: "web", sv: "webb" }, freshness: { en: "weekly", sv: "veckovis" }, weight: 1.5 }, ]; export interface Enrichment { id: string; - label: string; + label: Loc; 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 }, + { id: "dedup", label: { en: "Dedup against my file", sv: "Dedup mot min fil" }, weight: 0.1 }, + { id: "crm", label: { en: "Match & enrich my CRM export", sv: "Matcha & berika min CRM-export" }, weight: 0.2 }, + { id: "sanctions", label: { en: "Sanctions / PEP flag", sv: "Sanktioner / PEP-flagg" }, weight: 0.15 }, + { id: "nace", label: { en: "Industry class (NACE/SNI)", sv: "Branschklassning (NACE/SNI)" }, weight: 0.1 }, + { id: "tech", label: { en: "Website & tech stack", sv: "Webbplats & teknikstack" }, weight: 0.15 }, ]; +// Stable values (stored + used by computeEstimate); display via FREQUENCY_LABEL. export type Frequency = "Engångs" | "Veckovis" | "Dagligt flöde"; export const FREQUENCIES: Frequency[] = ["Engångs", "Veckovis", "Dagligt flöde"]; +export const FREQUENCY_LABEL: Record = { + "Engångs": { en: "One-time", sv: "Engångs" }, + "Veckovis": { en: "Weekly", sv: "Veckovis" }, + "Dagligt flöde": { en: "Daily feed", sv: "Dagligt flöde" }, +}; +// Stable values (stored in config.formats); display via FORMAT_LABEL. export const DELIVERY_FORMATS = [ "CSV", "Tabellvy (NocoDB)", @@ -88,6 +103,15 @@ export const DELIVERY_FORMATS = [ "API / webhook", "Push → FiveCRM", ]; +export const FORMAT_LABEL: Record = { + "CSV": { en: "CSV", sv: "CSV" }, + "Tabellvy (NocoDB)": { en: "Table view (NocoDB)", sv: "Tabellvy (NocoDB)" }, + "Parquet": { en: "Parquet", sv: "Parquet" }, + "JSON": { en: "JSON", sv: "JSON" }, + "Dashboard (Metabase)": { en: "Dashboard (Metabase)", sv: "Dashboard (Metabase)" }, + "API / webhook": { en: "API / webhook", sv: "API / webhook" }, + "Push → FiveCRM": { en: "Push → FiveCRM", sv: "Push → FiveCRM" }, +}; const FIELD_RATE = 240; // kr per field-weight per 1000 rows per market diff --git a/lib/orders-store.ts b/lib/orders-store.ts index 1a4026f..c54ca61 100644 --- a/lib/orders-store.ts +++ b/lib/orders-store.ts @@ -49,7 +49,7 @@ export async function createOrder(flow: string, config: OrderConfig): Promise` INSERT INTO orders (flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence) - VALUES (${flow}, 'draft', ${sql.json(config as object)}, ${est.price * 100}, ${est.deposit * 100}, + VALUES (${flow}, 'draft', ${sql.json(config as Parameters[0])}, ${est.price * 100}, ${est.deposit * 100}, ${est.remainder * 100}, ${est.timelineDays}, ${est.estConfidence / 100}) RETURNING order_id, created_at`; return { diff --git a/lib/services.ts b/lib/services.ts index 9ef608b..1dfd4d4 100644 --- a/lib/services.ts +++ b/lib/services.ts @@ -1,37 +1,42 @@ // The 0pt service catalog. Every sellable service maps to one of the three flows // (docs/01): catalog product, custom job, subscription. Categories are tags within // those flows — not separate machinery. +// +// i18n: id/flow stay stable (keys); display fields are localized { en, sv } and +// resolved with useT() at the render site. + +import type { Loc } from "@/lib/data"; export type Flow = "catalog" | "custom" | "subscription"; export interface Service { id: string; - name: string; - tagline: string; // terse, confident - covers: string; // one line of scope + name: Loc; + tagline: Loc; // terse, confident + covers: Loc; // one line of scope flow: Flow; - deliverable: string; + deliverable: Loc; mult: number; // price multiplier vs a base dataset job } export const SERVICES: Service[] = [ - { id: "data", name: "Dataleverans", tagline: "Färdig eller skräddarsydd.", covers: "Bolag, beslutsfattare, fordon, finansiellt — per fält med härkomst.", flow: "custom", deliverable: "CSV · Parquet · JSON · API", mult: 1.0 }, - { id: "scraping", name: "Webbskrapning", tagline: "Lagligt insamlat.", covers: "Officiella API:er och tydligt publika sidor. Aldrig bakom inloggning.", flow: "custom", deliverable: "Strukturerat dataset + källa per fält", mult: 1.1 }, - { id: "mapping", name: "Mappning & matchning", tagline: "Era data mot världen.", covers: "Koppla era poster mot orgnr/LEI och externa nycklar. Cross-border.", flow: "custom", deliverable: "Matchad fil + matchningskonfidens", mult: 1.2 }, - { id: "normalization", name: "Normalisering", tagline: "Städat. Standardiserat.", covers: "Format, dubbletter, validering (e-post/telefon), enhetlig taxonomi.", flow: "custom", deliverable: "Ren datamodell + kvalitetsrapport", mult: 1.1 }, - { id: "consolidation", name: "Konsolidering → CRM/ERP", tagline: "En sanning. Rätt system.", covers: "Slå ihop admindata och pusha till HubSpot, Salesforce, Dynamics, Pipedrive, FiveCRM.", flow: "custom", deliverable: "Konsoliderad data + push/sync", mult: 1.4 }, - { id: "documents", name: "Dokument & OCR", tagline: "Pixlar till poster.", covers: "PDF, bilder, scannade myndighetsdokument → strukturerade fält.", flow: "custom", deliverable: "Strukturerat dataset från dokument", mult: 1.3 }, - { id: "enrichment", name: "Berikning", tagline: "Fyll luckorna.", covers: "Kontakter, firmografik, finansiellt, köpsignaler — med laglig grund.", flow: "subscription", deliverable: "Berikad fil eller löpande flöde", mult: 1.2 }, - { id: "icp", name: "ICP & leads", tagline: "Rätt målgrupp.", covers: "Målgruppslistor med beslutsfattare, härkomst per kontakt.", flow: "catalog", deliverable: "Lead-lista + export till CRM", mult: 1.2 }, - { id: "ml", name: "ML-jobb", tagline: "Modeller på er data.", covers: "Scoring, matchning, prognos, klustring — tränat på era + våra data.", flow: "custom", deliverable: "Modell + prediktioner + API", mult: 1.8 }, - { id: "bi", name: "BI & marknadsanalys", tagline: "Beslut, inte bara siffror.", covers: "Dashboards, marknadsöversikter, konkurrent- och täckningsanalys.", flow: "custom", deliverable: "Metabase-dashboard + rapport", mult: 1.5 }, - { id: "feeds", name: "Flöden & övervakning", tagline: "Alltid färskt.", covers: "Återkommande leverans, freshness-bevakning, webhook vid förändring.", flow: "subscription", deliverable: "Webhook/feed + larm", mult: 1.3 }, + { id: "data", name: { en: "Data delivery", sv: "Dataleverans" }, tagline: { en: "Ready-made or bespoke.", sv: "Färdig eller skräddarsydd." }, covers: { en: "Companies, decision-makers, vehicles, financials — per field, with provenance.", sv: "Bolag, beslutsfattare, fordon, finansiellt — per fält med härkomst." }, flow: "custom", deliverable: { en: "CSV · Parquet · JSON · API", sv: "CSV · Parquet · JSON · API" }, mult: 1.0 }, + { id: "scraping", name: { en: "Web scraping", sv: "Webbskrapning" }, tagline: { en: "Legally collected.", sv: "Lagligt insamlat." }, covers: { en: "Official APIs and clearly public pages. Never behind a login.", sv: "Officiella API:er och tydligt publika sidor. Aldrig bakom inloggning." }, flow: "custom", deliverable: { en: "Structured dataset + source per field", sv: "Strukturerat dataset + källa per fält" }, mult: 1.1 }, + { id: "mapping", name: { en: "Mapping & matching", sv: "Mappning & matchning" }, tagline: { en: "Your data against the world.", sv: "Era data mot världen." }, covers: { en: "Link your records to org. no./LEI and external keys. Cross-border.", sv: "Koppla era poster mot orgnr/LEI och externa nycklar. Cross-border." }, flow: "custom", deliverable: { en: "Matched file + match confidence", sv: "Matchad fil + matchningskonfidens" }, mult: 1.2 }, + { id: "normalization", name: { en: "Normalization", sv: "Normalisering" }, tagline: { en: "Cleaned. Standardized.", sv: "Städat. Standardiserat." }, covers: { en: "Format, duplicates, validation (email/phone), unified taxonomy.", sv: "Format, dubbletter, validering (e-post/telefon), enhetlig taxonomi." }, flow: "custom", deliverable: { en: "Clean data model + quality report", sv: "Ren datamodell + kvalitetsrapport" }, mult: 1.1 }, + { id: "consolidation", name: { en: "Consolidation → CRM/ERP", sv: "Konsolidering → CRM/ERP" }, tagline: { en: "One truth. The right system.", sv: "En sanning. Rätt system." }, covers: { en: "Merge admin data and push to HubSpot, Salesforce, Dynamics, Pipedrive, FiveCRM.", sv: "Slå ihop admindata och pusha till HubSpot, Salesforce, Dynamics, Pipedrive, FiveCRM." }, flow: "custom", deliverable: { en: "Consolidated data + push/sync", sv: "Konsoliderad data + push/sync" }, mult: 1.4 }, + { id: "documents", name: { en: "Documents & OCR", sv: "Dokument & OCR" }, tagline: { en: "Pixels to records.", sv: "Pixlar till poster." }, covers: { en: "PDFs, images, scanned official documents → structured fields.", sv: "PDF, bilder, scannade myndighetsdokument → strukturerade fält." }, flow: "custom", deliverable: { en: "Structured dataset from documents", sv: "Strukturerat dataset från dokument" }, mult: 1.3 }, + { id: "enrichment", name: { en: "Enrichment", sv: "Berikning" }, tagline: { en: "Fill the gaps.", sv: "Fyll luckorna." }, covers: { en: "Contacts, firmographics, financials, buying signals — with a lawful basis.", sv: "Kontakter, firmografik, finansiellt, köpsignaler — med laglig grund." }, flow: "subscription", deliverable: { en: "Enriched file or ongoing feed", sv: "Berikad fil eller löpande flöde" }, mult: 1.2 }, + { id: "icp", name: { en: "ICP & leads", sv: "ICP & leads" }, tagline: { en: "The right audience.", sv: "Rätt målgrupp." }, covers: { en: "Target lists with decision-makers, provenance per contact.", sv: "Målgruppslistor med beslutsfattare, härkomst per kontakt." }, flow: "catalog", deliverable: { en: "Lead list + export to CRM", sv: "Lead-lista + export till CRM" }, mult: 1.2 }, + { id: "ml", name: { en: "ML jobs", sv: "ML-jobb" }, tagline: { en: "Models on your data.", sv: "Modeller på er data." }, covers: { en: "Scoring, matching, forecasting, clustering — trained on your + our data.", sv: "Scoring, matchning, prognos, klustring — tränat på era + våra data." }, flow: "custom", deliverable: { en: "Model + predictions + API", sv: "Modell + prediktioner + API" }, mult: 1.8 }, + { id: "bi", name: { en: "BI & market analysis", sv: "BI & marknadsanalys" }, tagline: { en: "Decisions, not just numbers.", sv: "Beslut, inte bara siffror." }, covers: { en: "Dashboards, market overviews, competitor and coverage analysis.", sv: "Dashboards, marknadsöversikter, konkurrent- och täckningsanalys." }, flow: "custom", deliverable: { en: "Metabase dashboard + report", sv: "Metabase-dashboard + rapport" }, mult: 1.5 }, + { id: "feeds", name: { en: "Feeds & monitoring", sv: "Flöden & övervakning" }, tagline: { en: "Always fresh.", sv: "Alltid färskt." }, covers: { en: "Recurring delivery, freshness monitoring, webhook on change.", sv: "Återkommande leverans, freshness-bevakning, webhook vid förändring." }, flow: "subscription", deliverable: { en: "Webhook/feed + alerts", sv: "Webhook/feed + larm" }, mult: 1.3 }, ]; -export const FLOW_LABEL: Record = { - catalog: "Katalog", - custom: "Skräddarsytt", - subscription: "Flöde", +export const FLOW_LABEL: Record = { + catalog: { en: "Catalog", sv: "Katalog" }, + custom: { en: "Bespoke", sv: "Skräddarsytt" }, + subscription: { en: "Feed", sv: "Flöde" }, }; // CRM/ERP destinations for the consolidation/push services.