/* eslint-disable no-undef */
/* TrustLensUI.jsx — product mockup widgets for the Mytanah TrustLens pitch.
Built on the Mytanah tokens (C, Display, Eyebrow from Primitives.jsx).
Everything here is mock data shaped like the real AVM / sentiment / risk
outputs. Widgets: RiskBadge, TrustDial, ValueRange, Sparkline, CompsTable,
and the composed TrustDashboardPreview (the hero "money shot"). */
const rm = (n, cur = 'RM') => cur + ' ' + Math.round(n).toLocaleString('en-US');
const rmK = (n, cur = 'RM') => cur + ' ' + (Math.round(n / 1000)).toLocaleString('en-US') + 'k';
/* ---- trust label system (the five verdicts) -------------------------- */
const TRUST_LABELS = {
'Fair Price': { fg: C.up, fgDark: '#74C49A', soft: 'rgba(45,122,79,.14)' },
'Underpriced': { fg: C.earth, fgDark: '#C49A7A', soft: 'rgba(162,123,92,.16)' },
'Overpriced': { fg: C.down, fgDark: '#E0938A', soft: 'rgba(166,50,40,.14)' },
'High Risk': { fg: '#7E1E16', fgDark: '#E58A80', soft: 'rgba(126,30,22,.16)' },
'Insufficient Data': { fg: C.muted, fgDark: '#C8C3B8', soft: 'rgba(176,170,158,.20)' },
};
const RiskBadge = ({ label, size = 'md', onDark }) => {
const t = TRUST_LABELS[label] || TRUST_LABELS['Insufficient Data'];
const col = onDark ? (t.fgDark || t.fg) : t.fg;
const pad = size === 'lg' ? '8px 16px' : size === 'sm' ? '4px 10px' : '6px 13px';
const fs = size === 'lg' ? 14 : size === 'sm' ? 11 : 12.5;
return (
{label}
);
};
/* ---- semicircle trust gauge ------------------------------------------ */
const gPt = (cx, cy, r, t) => { const a = Math.PI - t * Math.PI; return [cx + r * Math.cos(a), cy - r * Math.sin(a)]; };
const gArc = (cx, cy, r, t0, t1) => {
const [x0, y0] = gPt(cx, cy, r, t0), [x1, y1] = gPt(cx, cy, r, t1);
const large = (t1 - t0) > 0.5 ? 1 : 0;
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
};
const scoreColor = (s) => (s >= 70 ? C.up : s >= 45 ? C.stable : C.down);
const TrustDial = ({ score = 76, label = 'Fair Price', onDark }) => {
const W = 188, H = 116, cx = W / 2, cy = 104, r = 84;
const t = Math.max(0, Math.min(1, score / 100));
const col = scoreColor(score);
const trackCol = onDark ? 'rgba(220,215,201,.16)' : C.border;
return (
);
};
/* ---- AVM value range bar --------------------------------------------- */
const ValueRange = ({ lower, central, upper, listing, onDark, cur = 'RM' }) => {
const pad = (upper - lower) * 0.28 || 1;
const lo = lower - pad, hi = upper + pad;
const pos = v => `${(((v - lo) / (hi - lo)) * 100).toFixed(2)}%`;
const lpos = `${Math.max(2, Math.min(98, ((listing - lo) / (hi - lo)) * 100)).toFixed(2)}%`;
const listColor = listing > upper ? C.down : listing < lower ? C.earth : C.up;
const trackCol = onDark ? 'rgba(220,215,201,.14)' : C.border;
const txt = onDark ? C.cream : C.deep;
const sub = onDark ? 'rgba(220,215,201,.55)' : C.mid;
return (
{listing != null && (
Listing {rmK(listing, cur)}
)}
Central
{rmK(central, cur)}
);
};
/* ---- sentiment sparkline --------------------------------------------- */
const Sparkline = ({ data, color = C.earth, w = 160, h = 44 }) => {
const min = Math.min(...data), max = Math.max(...data), span = max - min || 1;
const pts = data.map((v, i) => [(i / (data.length - 1)) * w, h - ((v - min) / span) * (h - 6) - 3]);
const d = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
const area = `${d} L${w} ${h} L0 ${h} Z`;
return (
);
};
/* ---- comparable transactions table ----------------------------------- */
const CompsTable = ({ rows, onDark, compact }) => {
const txt = onDark ? C.cream : C.deep;
const sub = onDark ? 'rgba(220,215,201,.55)' : C.mid;
const line = onDark ? 'rgba(220,215,201,.12)' : C.border;
return (
{['Scheme / Road', 'Type', 'Built-up', 'Price', 'Quarter'].map((h, i) => (
| = 3 ? 'right' : 'left',
}}>{h} |
))}
{rows.map((r, i) => (
| {r.scheme} |
{r.type} |
{r.size} |
{rm(r.price)} |
{r.q} |
))}
);
};
/* ---- mock subject + comps -------------------------------------------- */
const DEMO = {
subject: { addr: 'No. 12, Jalan Setia Prima U13, Setia Alam', area: 'Shah Alam, Selangor', type: '2-storey Terrace · 22×75 · 2,200 sqft', listing: 720000 },
avm: { lower: 648000, central: 685000, upper: 722000 },
trust: { score: 76, label: 'Fair Price' },
sentiment: [38, 41, 40, 46, 52, 49, 58, 63, 61, 67],
comps: [
{ scheme: 'Jalan Setia Prima U13', type: '2-sty Terrace', size: '2,180 sqft', price: 690000, q: "Q3'24" },
{ scheme: 'Jalan Setia Murni U13', type: '2-sty Terrace', size: '2,240 sqft', price: 705000, q: "Q2'24" },
{ scheme: 'Setia Eco Park', type: '2-sty Terrace', size: '2,400 sqft', price: 760000, q: "Q1'24" },
{ scheme: 'Jalan Setia Impian', type: '2-sty Terrace', size: '2,150 sqft', price: 672000, q: "Q4'23" },
],
};
/* ---- the composed hero dashboard card -------------------------------- */
const TrustDashboardPreview = ({ data = DEMO }) => {
const { subject, avm, trust, sentiment, comps } = data;
return (
{/* header */}
Trust Report
{subject.addr}
{subject.area}
Q1 2025
{/* dial + listing */}
Listing price
{rm(subject.listing)}
{subject.type}
{/* value range */}
Fair value range · live
Exa-researched
{/* rental yield / ROI */}
Est. gross rental yield
3.6% · positive ROI
{/* sentiment + risk row */}
Market risk
Upward
price pressure · low overhang
{/* comps */}
Comparable transactions · NAPIC
);
};
Object.assign(window, { rm, rmK, TRUST_LABELS, RiskBadge, TrustDial, ValueRange, Sparkline, CompsTable, TrustDashboardPreview, DEMO });