Customer order lifecycle on the mono design system: red + provenance tokens, useReveal hook, lib/data pricing model, ProvenanceBadge/CellProvenance, OrderStepper, and 4 routes with live pricing, 50/50 escrow, simulated production, 4-view validation. tsc clean; next build green (5 routes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
6.9 KiB
TypeScript
168 lines
6.9 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.
|
||
|
||
export type ProvLevel = 0 | 1 | 2 | 3; // 0 licens/ej · 1 en källa · 2 delvis · 3 flera källor
|
||
|
||
export interface Market {
|
||
code: string;
|
||
name: string;
|
||
live: boolean;
|
||
coverage: number; // %
|
||
}
|
||
|
||
// 20 European markets (Nordics live first, rest "kommande").
|
||
export const MARKETS: Market[] = [
|
||
{ code: "SE", name: "Sverige", live: true, coverage: 98 },
|
||
{ code: "NO", name: "Norge", live: true, coverage: 96 },
|
||
{ code: "DK", name: "Danmark", live: true, coverage: 94 },
|
||
{ code: "FI", name: "Finland", live: true, coverage: 89 },
|
||
{ code: "IS", name: "Island", live: false, coverage: 0 },
|
||
{ code: "NL", name: "Nederländerna", live: false, coverage: 0 },
|
||
{ code: "DE", name: "Tyskland", live: false, coverage: 0 },
|
||
{ code: "BE", name: "Belgien", live: false, coverage: 0 },
|
||
{ code: "IE", name: "Irland", live: false, coverage: 0 },
|
||
{ code: "FR", name: "Frankrike", live: false, coverage: 0 },
|
||
{ code: "GB", name: "Storbritannien", live: false, coverage: 0 },
|
||
{ code: "AT", name: "Österrike", live: false, coverage: 0 },
|
||
{ code: "CH", name: "Schweiz", live: false, coverage: 0 },
|
||
{ code: "IT", name: "Italien", live: false, coverage: 0 },
|
||
{ code: "ES", name: "Spanien", live: false, coverage: 0 },
|
||
{ code: "PT", name: "Portugal", live: false, coverage: 0 },
|
||
{ code: "PL", name: "Polen", live: false, coverage: 0 },
|
||
{ code: "CZ", name: "Tjeckien", live: false, coverage: 0 },
|
||
{ code: "HU", name: "Ungern", live: false, coverage: 0 },
|
||
{ code: "LU", name: "Luxemburg", live: false, coverage: 0 },
|
||
];
|
||
|
||
export type EntityType = "Bolag" | "Beslutsfattare" | "Fordon";
|
||
export const ENTITY_TYPES: { id: EntityType; mult: number }[] = [
|
||
{ id: "Bolag", mult: 1.0 },
|
||
{ id: "Beslutsfattare", mult: 1.4 },
|
||
{ id: "Fordon", mult: 1.2 },
|
||
];
|
||
|
||
export interface Field {
|
||
id: string;
|
||
name: string;
|
||
sub: string;
|
||
level: ProvLevel;
|
||
source: string;
|
||
freshness: string;
|
||
weight: number;
|
||
locked?: boolean;
|
||
}
|
||
|
||
// Each field carries its honest provenance: source · freshness · confidence level.
|
||
export const FIELDS: Field[] = [
|
||
{ id: "identity", name: "Identitet & säte", sub: "org.nr, namn, adress", level: 3, source: "GLEIF · Brreg", freshness: "daglig", weight: 1.0, locked: true },
|
||
{ id: "lei", name: "LEI / cross-border-nyckel", sub: "GLEIF golden copy", level: 3, source: "GLEIF", freshness: "daglig", weight: 0.8 },
|
||
{ id: "financials", name: "Finansiellt", sub: "omsättning, resultat, nyckeltal", level: 2, source: "register", freshness: "årlig", weight: 1.6 },
|
||
{ id: "ubo", name: "Verklig huvudman (UBO)", sub: "ägarkedja", level: 2, source: "register", freshness: "vid ändring", weight: 1.4 },
|
||
{ id: "contact", name: "Kontakt: e-post & telefon", sub: "licens krävs i DE/FR", level: 1, source: "publik webb", freshness: "veckovis", weight: 1.8 },
|
||
{ id: "intent", name: "Köpsignaler & intent", sub: "jobbannonser, nyheter, tech", level: 1, source: "webb", freshness: "veckovis", weight: 1.5 },
|
||
];
|
||
|
||
export interface Enrichment {
|
||
id: string;
|
||
label: string;
|
||
weight: number;
|
||
}
|
||
export const ENRICHMENTS: Enrichment[] = [
|
||
{ id: "dedup", label: "Dedup mot min fil", weight: 0.1 },
|
||
{ id: "crm", label: "Matcha & berika min CRM-export", weight: 0.2 },
|
||
{ id: "sanctions", label: "Sanktioner / PEP-flagg", weight: 0.15 },
|
||
{ id: "nace", label: "Branschklassning (NACE/SNI)", weight: 0.1 },
|
||
{ id: "tech", label: "Webbplats & teknikstack", weight: 0.15 },
|
||
];
|
||
|
||
export type Frequency = "Engångs" | "Veckovis" | "Dagligt flöde";
|
||
export const FREQUENCIES: Frequency[] = ["Engångs", "Veckovis", "Dagligt flöde"];
|
||
|
||
export const DELIVERY_FORMATS = [
|
||
"CSV",
|
||
"Tabellvy (NocoDB)",
|
||
"Parquet",
|
||
"JSON",
|
||
"Dashboard (Metabase)",
|
||
"API / webhook",
|
||
"Push → FiveCRM",
|
||
];
|
||
|
||
const FIELD_RATE = 240; // kr per field-weight per 1000 rows per market
|
||
|
||
export interface EstimateInput {
|
||
markets: string[];
|
||
type: EntityType;
|
||
fieldIds: string[];
|
||
enrichmentIds: string[];
|
||
volume: number;
|
||
frequency: Frequency;
|
||
confidence: number; // target 80–99
|
||
}
|
||
|
||
export interface Estimate {
|
||
price: number;
|
||
deposit: number;
|
||
remainder: number;
|
||
timelineDays: number;
|
||
estConfidence: number;
|
||
recurring: boolean;
|
||
}
|
||
|
||
export function computeEstimate(input: EstimateInput): Estimate {
|
||
const markets = Math.max(1, input.markets.length);
|
||
const fields = FIELDS.filter((f) => input.fieldIds.includes(f.id) || f.locked);
|
||
const fieldWeight = fields.reduce((s, f) => s + f.weight, 0) || 1;
|
||
const entityMult = ENTITY_TYPES.find((e) => e.id === input.type)?.mult ?? 1;
|
||
const confidenceMult = 1 + Math.max(0, input.confidence - 90) * 0.03;
|
||
const enrichWeight = ENRICHMENTS.filter((e) => input.enrichmentIds.includes(e.id)).reduce((s, e) => s + e.weight, 0);
|
||
|
||
const base = fieldWeight * markets * FIELD_RATE * (input.volume / 1000) * entityMult * confidenceMult;
|
||
const price = Math.round((base * (1 + enrichWeight)) / 5) * 5; // round to 5 kr
|
||
const deposit = Math.round(price / 2);
|
||
|
||
const timelineDays = Math.min(
|
||
14,
|
||
Math.max(1, Math.round(1 + markets * 0.4 + input.volume / 6000 + fields.length * 0.25)),
|
||
);
|
||
|
||
// Honest estimated confidence: bounded by the provenance of the chosen fields.
|
||
const avgLevel = fields.reduce((s, f) => s + f.level, 0) / fields.length; // 0..3
|
||
const fieldCeiling = Math.round(82 + avgLevel * 5); // ~82..97
|
||
const estConfidence = Math.min(input.confidence, fieldCeiling);
|
||
|
||
return {
|
||
price,
|
||
deposit,
|
||
remainder: price - deposit,
|
||
timelineDays,
|
||
estConfidence,
|
||
recurring: input.frequency !== "Engångs",
|
||
};
|
||
}
|
||
|
||
export function formatSEK(n: number): string {
|
||
return new Intl.NumberFormat("sv-SE").format(Math.round(n)) + " kr";
|
||
}
|
||
|
||
// Sample Nordic rows reused by the validation table.
|
||
export interface SampleRow {
|
||
orgnr: string;
|
||
bolag: string;
|
||
sate: string;
|
||
sni: string;
|
||
anstallda: number;
|
||
oms: number; // tkr
|
||
epost: string;
|
||
epostLevel: ProvLevel;
|
||
lei: string;
|
||
}
|
||
export const SAMPLE_ROWS: SampleRow[] = [
|
||
{ orgnr: "556016-0680", bolag: "Volvo Group", sate: "Göteborg", sni: "29100", anstallda: 95000, oms: 553000000, epost: "info@volvo.com", epostLevel: 2, lei: "5493000XYZ..." },
|
||
{ orgnr: "556036-0793", bolag: "Spotify AB", sate: "Stockholm", sni: "63110", anstallda: 9800, oms: 132000000, epost: "press@spotify.com", epostLevel: 2, lei: "549300ABC..." },
|
||
{ orgnr: "915933149", bolag: "Equinor ASA", sate: "Stavanger", sni: "06100", anstallda: 22000, oms: 980000000, epost: "kontakt@equinor.no", epostLevel: 1, lei: "549300NOR..." },
|
||
{ orgnr: "25052943", bolag: "Maersk A/S", sate: "København", sni: "50200", anstallda: 100000, oms: 510000000, epost: "info@maersk.dk", epostLevel: 1, lei: "549300DNK..." },
|
||
{ orgnr: "0112038-9", bolag: "Nokia Oyj", sate: "Espoo", sni: "26300", anstallda: 86000, oms: 221000000, epost: "", epostLevel: 0, lei: "549300FIN..." },
|
||
];
|