// FloatingTools — a global manager that pops up draggable, closable, liquid-glass // FLOATING WINDOWS (Price Sheet + EMI Calculator) that persist over ANY screen // until the user closes them. Mounted once in App (outside the route transition), // so windows stay open across navigation. // // TRIGGER CONTRACT: listens on `window` for CustomEvent 'uni-open-tool' with // detail.tool === 'price' | 'emi'. Opens (or focuses) that window. Re-firing // for an already-open tool just brings it to front (no-op otherwise). // // BEZEL-SCALE DRAG: the whole app is rendered inside a CSS transform:scale(...) // element (`.tablet-bezel`), so pointer deltas are divided by the live scale. // // Data: TYPOLOGIES, DUMMY_RATE, formatINR(), fmtSqft() (window globals, data.jsx). const TOOLSF_KEYS_ID = 'uni-tools-float-keys'; function ensureToolsFloatKeys() { if (typeof document === 'undefined' || document.getElementById(TOOLSF_KEYS_ID)) return; const s = document.createElement('style'); s.id = TOOLSF_KEYS_ID; s.textContent = ` @keyframes toolFloatIn { 0% { opacity:0; transform:translate(-50%,-50%) scale(0.86); } 100% { opacity:1; transform:translate(-50%,-50%) scale(1); } } .toolf-win::-webkit-scrollbar{ width:9px; height:9px; } .toolf-win::-webkit-scrollbar-thumb{ background:rgba(232,215,168,0.24); border-radius:8px; } .toolf-win::-webkit-scrollbar-track{ background:transparent; } /* gold-fill range slider for the EMI window */ input.toolf-range{ -webkit-appearance:none; appearance:none; width:100%; height:30px; background:transparent; cursor:pointer; outline:none; } input.toolf-range::-webkit-slider-runnable-track{ height:8px; border-radius:6px; background:linear-gradient(90deg, var(--gold) 0%, var(--gold) var(--fill,40%), rgba(255,248,230,0.12) var(--fill,40%), rgba(255,248,230,0.12) 100%); } input.toolf-range::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none; width:26px; height:26px; margin-top:-9px; border-radius:50%; background:radial-gradient(circle at 32% 30%, #fff6e0 0%, var(--gold) 55%, var(--gold-deep) 100%); border:2px solid #fff8e0; box-shadow:0 3px 9px rgba(0,0,0,0.5); transition:transform .14s cubic-bezier(0.34,1.56,0.64,1); } input.toolf-range:active::-webkit-slider-thumb{ transform:scale(1.14); } input.toolf-range::-moz-range-thumb{ width:26px; height:26px; border-radius:50%; background:var(--gold); border:2px solid #fff8e0; box-shadow:0 3px 9px rgba(0,0,0,0.5); } `; document.head.appendChild(s); } // liquid-glass surface for the window shell (matches the app's glass chrome) const toolfGlass = { background: 'linear-gradient(158deg, rgba(30,25,18,0.80) 0%, rgba(18,14,10,0.82) 100%)', backdropFilter: 'blur(22px) saturate(1.4)', WebkitBackdropFilter: 'blur(22px) saturate(1.4)', border: '1px solid rgba(255,248,230,0.30)', boxShadow: '0 36px 96px rgba(3,5,9,0.62), 0 10px 30px rgba(0,0,0,0.4), ' + 'inset 0 1px 0 rgba(255,255,255,0.26), inset 0 -1px 0 rgba(0,0,0,0.34)', }; // ── live bezel scale (CSS transform:scale on .tablet-bezel) ────────────────── function bezelScaleF() { const b = document.querySelector('.tablet-bezel'); if (!b) return 1; const r = b.getBoundingClientRect(); return (r.width / b.offsetWidth) || 1; } const CANVAS_W = 2560, CANVAS_H = 1600; // window registry: default size + staggered default centre position const TOOL_DEFS = { price: { w: 920, h: 620, x: 2560 / 2 - 90, y: 1600 / 2 - 60, title: 'Price Sheet' }, emi: { w: 720, h: 560, x: 2560 / 2 + 150, y: 1600 / 2 + 90, title: 'EMI Calculator' }, }; // ════════════════════════════════════════════════════════════════════════ // FloatingTools (manager — mounted once, globally) // ════════════════════════════════════════════════════════════════════════ function FloatingTools() { ensureToolsFloatKeys(); // open windows: { price?:{x,y,z}, emi?:{x,y,z} } const [wins, setWins] = React.useState({}); const zTop = React.useRef(131); const open = React.useCallback((tool) => { if (tool !== 'price' && tool !== 'emi') return; setWins(prev => { const next = { ...prev }; zTop.current += 1; if (next[tool]) { // already open → just bring to front next[tool] = { ...next[tool], z: zTop.current }; } else { const d = TOOL_DEFS[tool]; next[tool] = { x: d.x, y: d.y, z: zTop.current }; } return next; }); }, []); const close = React.useCallback((tool) => { setWins(prev => { const n = { ...prev }; delete n[tool]; return n; }); }, []); const focus = React.useCallback((tool) => { setWins(prev => { if (!prev[tool]) return prev; zTop.current += 1; return { ...prev, [tool]: { ...prev[tool], z: zTop.current } }; }); }, []); const setPos = React.useCallback((tool, x, y) => { setWins(prev => prev[tool] ? { ...prev, [tool]: { ...prev[tool], x, y } } : prev); }, []); // mount-once event listener with cleanup React.useEffect(() => { const handler = (e) => { const t = e && e.detail && e.detail.tool; if (t) open(t); }; window.addEventListener('uni-open-tool', handler); return () => window.removeEventListener('uni-open-tool', handler); }, [open]); return ( {wins.price && ( close('price')} onFocus={() => focus('price')} onPos={setPos}> )} {wins.emi && ( close('emi')} onFocus={() => focus('emi')} onPos={setPos}> )} ); } // ════════════════════════════════════════════════════════════════════════ // ToolWindow — draggable, closable glass shell (drag by title bar) // ════════════════════════════════════════════════════════════════════════ function ToolWindow({ tool, state, title, w, h, children, onClose, onFocus, onPos }) { const drag = React.useRef({ active: false, sx: 0, sy: 0, ox: 0, oy: 0, scale: 1 }); const stateRef = React.useRef(state); stateRef.current = state; const onMove = React.useCallback((e) => { const d = drag.current; if (!d.active) return; const dx = (e.clientX - d.sx) / d.scale, dy = (e.clientY - d.sy) / d.scale; // clamp the window centre so the whole window stays inside the canvas const nx = clamp(d.ox + dx, w / 2 + 12, CANVAS_W - w / 2 - 12); const ny = clamp(d.oy + dy, h / 2 + 12, CANVAS_H - h / 2 - 12); onPos(tool, nx, ny); }, [tool, w, h, onPos]); const onUp = React.useCallback(() => { drag.current.active = false; window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }, [onMove]); const onDown = (e) => { // ignore drags that start on the close button if (e.target.closest && e.target.closest('[data-toolf-noclose]')) return; e.preventDefault(); e.stopPropagation(); onFocus(); drag.current = { active: true, sx: e.clientX, sy: e.clientY, ox: stateRef.current.x, oy: stateRef.current.y, scale: bezelScaleF(), }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); }; React.useEffect(() => () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }, [onMove, onUp]); return (
{/* title bar (drag handle) */}
{/* grab-dots affordance */}
{[0, 1, 2, 3, 4, 5].map(i => ( ))}
{title}
{/* glass close button */}
{/* body */}
{children}
); } // ════════════════════════════════════════════════════════════════════════ // Price Sheet body // ════════════════════════════════════════════════════════════════════════ function fmtRate(n) { return '₹ ' + Number(n).toLocaleString('en-IN'); } function PriceSheetBody() { const cols = ['Typology', 'Block', 'RERA carpet', 'Rate', 'Indicative price']; const hCell = { padding: '12px 16px', textAlign: 'left', whiteSpace: 'nowrap' }; const rCell = { padding: '16px 16px', borderTop: '1px solid rgba(232,215,168,0.12)', verticalAlign: 'middle' }; return (
{/* header chip row */}
ALL-4BHK · NEHRU NAGAR · INDICATIVE
All-inclusive on request
{/* table */} {cols.map((c, i) => ( ))} {TYPOLOGIES.map((t) => ( ))}
= 2 ? 'right' : 'left' }}>{c}
{t.name}
{t.tag}
{t.pair} {fmtSqft(t.sqft)} {fmtRate(DUMMY_RATE)} /sq.ft {formatINR(t.price)}
{/* footnote */}
Indicative — confirm final pricing with sales. Rate is a placeholder ({fmtRate(DUMMY_RATE)}/sq.ft) pending the Venus price list; carpet areas are RERA-final.
); } // ════════════════════════════════════════════════════════════════════════ // EMI Calculator body // ════════════════════════════════════════════════════════════════════════ function emiMedianPrice() { const priced = TYPOLOGIES.map(t => t.price).filter(p => p != null).sort((a, b) => a - b); if (!priced.length) return 15000000; const m = Math.floor(priced.length / 2); return priced.length % 2 ? priced[m] : Math.round((priced[m - 1] + priced[m]) / 2); } function EmiCalcBody() { const [loan, setLoan] = React.useState(() => emiMedianPrice()); const [rate, setRate] = React.useState(8.5); const [years, setYears] = React.useState(20); // EMI = P*r*(1+r)^n / ((1+r)^n - 1), r = annualRate/12/100, n = years*12 const r = rate / 12 / 100; const n = years * 12; const emi = r > 0 ? loan * r * Math.pow(1 + r, n) / (Math.pow(1 + r, n) - 1) : loan / n; const totalPayable = emi * n; const totalInterest = totalPayable - loan; // slider bounds const LOAN_MIN = 2500000, LOAN_MAX = 60000000; const RATE_MIN = 6, RATE_MAX = 14; const YEAR_MIN = 5, YEAR_MAX = 30; const fillPct = (v, mn, mx) => ((v - mn) / (mx - mn) * 100).toFixed(1) + '%'; return (
{/* big EMI readout */}
MONTHLY EMI
{formatINR(Math.round(emi))}
{/* sliders */}
{/* totals */}
{/* note */}
Indicative, not a loan offer. Actual EMI depends on the lender, profile & final agreement.
); } function EmiSlider({ label, readout, value, min, max, step, onChange, fill }) { return (
{label} {readout}
onChange(parseFloat(e.target.value))}/>
); } function EmiStat({ label, value }) { return (
{label}
{value}
); } window.FloatingTools = FloatingTools;