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 <noreply@anthropic.com>
194 lines
9.8 KiB
TypeScript
194 lines
9.8 KiB
TypeScript
// Central mock data + the live-estimate pricing model shared by the order flow.
|
||
// No real backend yet — the configurator's price/timeline/confidence come from
|
||
// computeEstimate(); replace with a call to the FastAPI /estimates later.
|
||
//
|
||
// i18n: IDs/values (used as keys + in pricing logic) stay stable; only DISPLAY
|
||
// labels are localized as { en, sv } and resolved with useT() at the render site.
|
||
|
||
export type ProvLevel = 0 | 1 | 2 | 3; // 0 licence/none · 1 one source · 2 partial · 3 multi-source
|
||
export type Loc = { en: string; sv: string };
|
||
|
||
export interface Market {
|
||
code: string;
|
||
name: Loc;
|
||
live: boolean;
|
||
coverage: number; // %
|
||
}
|
||
|
||
// 20 European markets (Nordics live first, rest coming).
|
||
export const MARKETS: Market[] = [
|
||
{ code: "SE", name: { en: "Sweden", sv: "Sverige" }, live: true, coverage: 98 },
|
||
{ code: "NO", name: { en: "Norway", sv: "Norge" }, live: true, coverage: 96 },
|
||
{ code: "DK", name: { en: "Denmark", sv: "Danmark" }, live: true, coverage: 94 },
|
||
{ code: "FI", name: { en: "Finland", sv: "Finland" }, live: true, coverage: 89 },
|
||
{ code: "IS", name: { en: "Iceland", sv: "Island" }, live: false, coverage: 0 },
|
||
{ code: "NL", name: { en: "Netherlands", sv: "Nederländerna" }, live: false, coverage: 0 },
|
||
{ code: "DE", name: { en: "Germany", sv: "Tyskland" }, live: false, coverage: 0 },
|
||
{ code: "BE", name: { en: "Belgium", sv: "Belgien" }, live: false, coverage: 0 },
|
||
{ code: "IE", name: { en: "Ireland", sv: "Irland" }, live: false, coverage: 0 },
|
||
{ code: "FR", name: { en: "France", sv: "Frankrike" }, live: false, coverage: 0 },
|
||
{ code: "GB", name: { en: "United Kingdom", sv: "Storbritannien" }, live: false, coverage: 0 },
|
||
{ code: "AT", name: { en: "Austria", sv: "Österrike" }, live: false, coverage: 0 },
|
||
{ code: "CH", name: { en: "Switzerland", sv: "Schweiz" }, live: false, coverage: 0 },
|
||
{ code: "IT", name: { en: "Italy", sv: "Italien" }, live: false, coverage: 0 },
|
||
{ code: "ES", name: { en: "Spain", sv: "Spanien" }, live: false, coverage: 0 },
|
||
{ code: "PT", name: { en: "Portugal", sv: "Portugal" }, live: false, coverage: 0 },
|
||
{ code: "PL", name: { en: "Poland", sv: "Polen" }, live: false, coverage: 0 },
|
||
{ code: "CZ", name: { en: "Czechia", sv: "Tjeckien" }, live: false, coverage: 0 },
|
||
{ code: "HU", name: { en: "Hungary", sv: "Ungern" }, live: false, coverage: 0 },
|
||
{ code: "LU", name: { en: "Luxembourg", sv: "Luxemburg" }, live: false, coverage: 0 },
|
||
];
|
||
|
||
// Stable IDs (also the stored value + pricing key). Display via entityLabel().
|
||
export type EntityType = "Bolag" | "Beslutsfattare" | "Fordon";
|
||
export const ENTITY_TYPES: { id: EntityType; label: Loc; mult: number }[] = [
|
||
{ id: "Bolag", label: { en: "Companies", sv: "Bolag" }, mult: 1.0 },
|
||
{ id: "Beslutsfattare", label: { en: "Decision-makers", sv: "Beslutsfattare" }, mult: 1.4 },
|
||
{ id: "Fordon", label: { en: "Vehicles", sv: "Fordon" }, mult: 1.2 },
|
||
];
|
||
export function entityLabel(id: EntityType): Loc {
|
||
return ENTITY_TYPES.find((e) => e.id === id)?.label ?? { en: id, sv: id };
|
||
}
|
||
|
||
export interface Field {
|
||
id: string;
|
||
name: Loc;
|
||
sub: Loc;
|
||
level: ProvLevel;
|
||
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: { 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: Loc;
|
||
weight: number;
|
||
}
|
||
export const ENRICHMENTS: Enrichment[] = [
|
||
{ 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<Frequency, Loc> = {
|
||
"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)",
|
||
"Parquet",
|
||
"JSON",
|
||
"Dashboard (Metabase)",
|
||
"API / webhook",
|
||
"Push → FiveCRM",
|
||
];
|
||
export const FORMAT_LABEL: Record<string, Loc> = {
|
||
"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
|
||
|
||
export interface EstimateInput {
|
||
markets: string[];
|
||
type: EntityType;
|
||
fieldIds: string[];
|
||
enrichmentIds: string[];
|
||
volume: number;
|
||
frequency: Frequency;
|
||
confidence: number; // target 80–99
|
||
serviceMult?: number; // service-type price multiplier (see lib/services)
|
||
}
|
||
|
||
export interface Estimate {
|
||
price: number;
|
||
deposit: number;
|
||
remainder: number;
|
||
timelineDays: number;
|
||
estConfidence: number;
|
||
recurring: boolean;
|
||
}
|
||
|
||
export function computeEstimate(input: EstimateInput): Estimate {
|
||
const markets = Math.max(1, input.markets.length);
|
||
const fields = FIELDS.filter((f) => input.fieldIds.includes(f.id) || f.locked);
|
||
const fieldWeight = fields.reduce((s, f) => s + f.weight, 0) || 1;
|
||
const entityMult = ENTITY_TYPES.find((e) => e.id === input.type)?.mult ?? 1;
|
||
const confidenceMult = 1 + Math.max(0, input.confidence - 90) * 0.03;
|
||
const enrichWeight = ENRICHMENTS.filter((e) => input.enrichmentIds.includes(e.id)).reduce((s, e) => s + e.weight, 0);
|
||
|
||
const serviceMult = input.serviceMult ?? 1;
|
||
const base = fieldWeight * markets * FIELD_RATE * (input.volume / 1000) * entityMult * confidenceMult * serviceMult;
|
||
const price = Math.round((base * (1 + enrichWeight)) / 5) * 5; // round to 5 kr
|
||
const deposit = Math.round(price / 2);
|
||
|
||
const timelineDays = Math.min(
|
||
14,
|
||
Math.max(1, Math.round(1 + markets * 0.4 + input.volume / 6000 + fields.length * 0.25)),
|
||
);
|
||
|
||
// Honest estimated confidence: bounded by the provenance of the chosen fields.
|
||
const avgLevel = fields.reduce((s, f) => s + f.level, 0) / fields.length; // 0..3
|
||
const fieldCeiling = Math.round(82 + avgLevel * 5); // ~82..97
|
||
const estConfidence = Math.min(input.confidence, fieldCeiling);
|
||
|
||
return {
|
||
price,
|
||
deposit,
|
||
remainder: price - deposit,
|
||
timelineDays,
|
||
estConfidence,
|
||
recurring: input.frequency !== "Engångs",
|
||
};
|
||
}
|
||
|
||
export function formatSEK(n: number): string {
|
||
return new Intl.NumberFormat("sv-SE").format(Math.round(n)) + " kr";
|
||
}
|
||
|
||
// Sample Nordic rows reused by the validation table.
|
||
export interface SampleRow {
|
||
orgnr: string;
|
||
bolag: string;
|
||
sate: string;
|
||
sni: string;
|
||
anstallda: number;
|
||
oms: number; // tkr
|
||
epost: string;
|
||
epostLevel: ProvLevel;
|
||
lei: string;
|
||
}
|
||
export const SAMPLE_ROWS: SampleRow[] = [
|
||
{ orgnr: "556016-0680", bolag: "Volvo Group", sate: "Göteborg", sni: "29100", anstallda: 95000, oms: 553000000, epost: "info@volvo.com", epostLevel: 2, lei: "5493000XYZ..." },
|
||
{ orgnr: "556036-0793", bolag: "Spotify AB", sate: "Stockholm", sni: "63110", anstallda: 9800, oms: 132000000, epost: "press@spotify.com", epostLevel: 2, lei: "549300ABC..." },
|
||
{ orgnr: "915933149", bolag: "Equinor ASA", sate: "Stavanger", sni: "06100", anstallda: 22000, oms: 980000000, epost: "kontakt@equinor.no", epostLevel: 1, lei: "549300NOR..." },
|
||
{ orgnr: "25052943", bolag: "Maersk A/S", sate: "København", sni: "50200", anstallda: 100000, oms: 510000000, epost: "info@maersk.dk", epostLevel: 1, lei: "549300DNK..." },
|
||
{ orgnr: "0112038-9", bolag: "Nokia Oyj", sate: "Espoo", sni: "26300", anstallda: 86000, oms: 221000000, epost: "", epostLevel: 0, lei: "549300FIN..." },
|
||
];
|