/* 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 (
{[0.45, 0.7].map(m => { const [mx, my] = gPt(cx, cy, r, m); const [ix, iy] = gPt(cx, cy, r - 11, m); return ; })}
{score}/100
); }; /* ---- 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)}
)}
{listing != null && (
)}
Lower
{rmK(lower, cur)}
Central
{rmK(central, cur)}
Upper
{rmK(upper, 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) => ( ))} {rows.map((r, i) => ( ))}
= 3 ? 'right' : 'left', }}>{h}
{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 */}
Sentiment +18%
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 });