diff --git a/messages/en.json b/messages/en.json index b10c16cd..97050d81 100644 --- a/messages/en.json +++ b/messages/en.json @@ -117,6 +117,7 @@ "balanceLabel": "Balance", "amountH1": "How much would you like to invest?", "preview": "You'll receive ≈ {shares} HBS · share price {price} · network fee < $0.01", + "projection": "Projected return in 1 year: ≈ ${amount} USDC at {rate}% APY", "liquidLine": "Today the pool is 29% liquid — you can withdraw your liquid share anytime.", "investCta": "Invest {amount} USDC", "investCtaEmpty": "Invest", diff --git a/messages/fr.json b/messages/fr.json index d12ea95c..706147cc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -117,6 +117,7 @@ "balanceLabel": "Solde", "amountH1": "Combien souhaitez-vous investir ?", "preview": "Vous recevrez ≈ {shares} HBS · prix de la part {price} · frais réseau < $0.01", + "projection": "Rendement projeté sur 1 an : ≈ {amount} $ USDC à {rate} % de rendement annuel", "liquidLine": "Aujourd'hui le pool est liquide à 29 % — vous pouvez retirer votre part liquide à tout moment.", "investCta": "Investir {amount} USDC", "investCtaEmpty": "Investir", diff --git a/src/app/page.tsx b/src/app/page.tsx index a47f2ae3..2ad7c9eb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,5 +5,7 @@ import { Landing } from '../screens/Landing' export default function HomePage() { const router = useRouter() - return router.push('/connect')} onNav={() => router.push('/explore')} /> + return ( + router.push('/connect')} onExplore={() => router.push('/explore')} /> + ) } diff --git a/src/app/verify/page.tsx b/src/app/verify/page.tsx index 82095093..1c89cbc2 100644 --- a/src/app/verify/page.tsx +++ b/src/app/verify/page.tsx @@ -7,7 +7,7 @@ export default function VerifyPage() { const t = useTranslations('Footer') return (
- {}} onNav={() => {}} /> + {}} onExplore={() => {}} />

{ expect(onChangeValue).toBe('0.001') }) + it('strips leading zeros from the whole-number part (#370)', () => { + let onChangeValue = '' + const onChange = (value: string) => { + onChangeValue = value + } + const { container } = render() + const input = container.querySelector('input') as HTMLInputElement + + fireEvent.change(input, { target: { value: '007' } }) + expect(onChangeValue).toBe('7') + + fireEvent.change(input, { target: { value: '00.5' } }) + expect(onChangeValue).toBe('0.5') + + fireEvent.change(input, { target: { value: '0' } }) + expect(onChangeValue).toBe('0') + + fireEvent.change(input, { target: { value: '0.001' } }) + expect(onChangeValue).toBe('0.001') + + fireEvent.change(input, { target: { value: '010.25' } }) + expect(onChangeValue).toBe('10.25') + }) + it('rejects multiple decimal points', () => { let result = '' const onChange = (value: string) => { diff --git a/src/components/AmountInput.tsx b/src/components/AmountInput.tsx index b2d44bb4..329be0ff 100644 --- a/src/components/AmountInput.tsx +++ b/src/components/AmountInput.tsx @@ -109,9 +109,9 @@ export function AmountInput({ value={value} onFocus={(e) => e.target.select()} onChange={(e) => { - const v = sanitizeAmount(e.target.value); + const v = sanitizeAmount(e.target.value) // If editing existing value, typing should replace not append when field was pre-filled - onChange?.(v); + onChange?.(v) }} style={{ flex: 1, @@ -138,7 +138,14 @@ export function AmountInput({

-

+

Min 1 USDC — Max {cap ?? '—'} USDC

@@ -257,5 +264,10 @@ const chipStyle: CSSProperties = { export function sanitizeAmount(val: string): string { const clean = val.replace(/[^0-9.]/g, '') const parts = clean.split('.') - return parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : clean + const joined = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : clean + const [whole, ...rest] = joined.split('.') + // Strip leading zeros from the whole-number part ("007" -> "7", "00.5" -> "0.5"), + // but keep a single zero so "0", "0.5" stay valid while typing. + const trimmedWhole = whole.replace(/^0+(?=\d)/, '') + return rest.length > 0 ? trimmedWhole + '.' + rest.join('.') : trimmedWhole } diff --git a/src/lib/bondUtils.ts b/src/lib/bondUtils.ts index a1ab5d93..42143533 100644 --- a/src/lib/bondUtils.ts +++ b/src/lib/bondUtils.ts @@ -4,84 +4,92 @@ * - #363 case-insensitive search * - #359 stable sort with tie-breaker * - #361 bond comparison view data helper + * - #367 projected return from an investment amount + annual yield */ export interface Bond { - id: string | number; - name: string; - yield: number; - term: number; - rating: string; + id: string | number + name: string + yield: number + term: number + rating: string } -const YIELD_FILTER_KEY = 'bond_yield_filter'; -const YIELD_DEFAULT: [number, number] = [0, 15]; +const YIELD_FILTER_KEY = 'bond_yield_filter' +const YIELD_DEFAULT: [number, number] = [0, 15] export function getPersistedYieldRange(): [number, number] { - if (typeof window === 'undefined') return YIELD_DEFAULT; + if (typeof window === 'undefined') return YIELD_DEFAULT try { - const url = new URL(window.location.href); - const fromUrl = url.searchParams.get('yieldRange'); + const url = new URL(window.location.href) + const fromUrl = url.searchParams.get('yieldRange') if (fromUrl) { - const [min, max] = fromUrl.split('-').map(Number); - if (Number.isFinite(min) && Number.isFinite(max)) return [min, max]; + const [min, max] = fromUrl.split('-').map(Number) + if (Number.isFinite(min) && Number.isFinite(max)) return [min, max] } - const stored = localStorage.getItem(YIELD_FILTER_KEY); + const stored = localStorage.getItem(YIELD_FILTER_KEY) if (stored) { - const parsed = JSON.parse(stored); - if (Array.isArray(parsed) && parsed.length === 2) return parsed as [number, number]; + const parsed = JSON.parse(stored) + if (Array.isArray(parsed) && parsed.length === 2) return parsed as [number, number] } } catch {} - return YIELD_DEFAULT; + return YIELD_DEFAULT } export function persistYieldRange(range: [number, number]): void { - if (typeof window === 'undefined') return; + if (typeof window === 'undefined') return try { - localStorage.setItem(YIELD_FILTER_KEY, JSON.stringify(range)); - const url = new URL(window.location.href); - url.searchParams.set('yieldRange', `${range[0]}-${range[1]}`); - window.history.replaceState(null, '', url.toString()); + localStorage.setItem(YIELD_FILTER_KEY, JSON.stringify(range)) + const url = new URL(window.location.href) + url.searchParams.set('yieldRange', `${range[0]}-${range[1]}`) + window.history.replaceState(null, '', url.toString()) } catch {} } export function filterBondsByYield(bonds: Bond[], range: [number, number]): Bond[] { - const [min, max] = range; - return bonds.filter((b) => b.yield >= min && b.yield <= max); + const [min, max] = range + return bonds.filter((b) => b.yield >= min && b.yield <= max) } // #363 — case-insensitive search export function searchBondsByName(bonds: Bond[], query: string): Bond[] { - const q = query.trim().toLowerCase(); - if (!q) return bonds; - return bonds.filter((b) => b.name.toLowerCase().includes(q)); + const q = query.trim().toLowerCase() + if (!q) return bonds + return bonds.filter((b) => b.name.toLowerCase().includes(q)) } // #359 — stable sort with tie-breaker (name, then id) export function sortBondsByYield(bonds: Bond[], direction: 'asc' | 'desc' = 'asc'): Bond[] { - const dir = direction === 'asc' ? 1 : -1; + const dir = direction === 'asc' ? 1 : -1 return [...bonds].sort((a, b) => { - if (a.yield !== b.yield) return (a.yield - b.yield) * dir; - const nameCmp = a.name.localeCompare(b.name); - if (nameCmp !== 0) return nameCmp; - return String(a.id).localeCompare(String(b.id)); - }); + if (a.yield !== b.yield) return (a.yield - b.yield) * dir + const nameCmp = a.name.localeCompare(b.name) + if (nameCmp !== 0) return nameCmp + return String(a.id).localeCompare(String(b.id)) + }) } // #361 — bond comparison (side-by-side) helper -export function getBondsForComparison(bonds: Bond[], ids: (string|number)[]): Bond[] { - if (ids.length < 2 || ids.length > 3) throw new Error('Select 2-3 bonds to compare'); - const map = new Map(bonds.map((b) => [String(b.id), b])); - const selected = ids.map((id) => map.get(String(id))).filter(Boolean) as Bond[]; - if (selected.length !== ids.length) throw new Error('One or more bonds not found'); - return selected; +export function getBondsForComparison(bonds: Bond[], ids: (string | number)[]): Bond[] { + if (ids.length < 2 || ids.length > 3) throw new Error('Select 2-3 bonds to compare') + const map = new Map(bonds.map((b) => [String(b.id), b])) + const selected = ids.map((id) => map.get(String(id))).filter(Boolean) as Bond[] + if (selected.length !== ids.length) throw new Error('One or more bonds not found') + return selected } -export function compareBondsMetrics(bonds: Bond[]): Record { - const metrics = ['yield', 'term', 'rating', 'name'] as const; - const result: Record = {}; +// #367 — projected return on an investment amount at a given annual yield (%), +// simple (non-compounding) interest over the given number of years. +export function projectedReturn(amount: number, annualYieldPct: number, years = 1): number { + if (!Number.isFinite(amount) || amount <= 0) return 0 + return amount * (annualYieldPct / 100) * years +} + +export function compareBondsMetrics(bonds: Bond[]): Record { + const metrics = ['yield', 'term', 'rating', 'name'] as const + const result: Record = {} for (const m of metrics) { - result[m] = bonds.map((b) => (b as any)[m]); + result[m] = bonds.map((b) => (b as any)[m]) } - return result; + return result } diff --git a/src/lib/format.ts b/src/lib/format.ts index d998466f..850f2b8e 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,3 +1,28 @@ +/** + * Rounds a number to a fixed number of decimals using decimal (not binary + * floating-point) precision, so 1.005 rounds to 1.01 rather than the 1.00 that + * `Math.round(1.005 * 100) / 100` or `(1.005).toFixed(2)` produce because + * 1.005 has no exact binary representation (#369). + */ +export function roundToDecimals(value: number, decimals: number): number { + const factor = Math.pow(10, decimals) + return Math.round((value + Number.EPSILON) * factor) / factor +} + +/** Rounds to whole cents — the shared precision for on-screen USDC amounts (#369). */ +export function roundToCents(value: number): number { + return roundToDecimals(value, 2) +} + +/** + * Formats a number to a fixed number of decimals, rounding once with + * {@link roundToDecimals} first so every caller displays the same rounded + * value instead of re-rounding raw floating-point results independently (#369). + */ +export function formatDecimal(value: number, decimals: number): string { + return roundToDecimals(value, decimals).toFixed(decimals) +} + /** * Formats a number as a localized currency/money string. * Defaults to 'en-US' formatting. diff --git a/src/screens/Deposit.tsx b/src/screens/Deposit.tsx index 06d382f4..bec687d7 100644 --- a/src/screens/Deposit.tsx +++ b/src/screens/Deposit.tsx @@ -10,6 +10,8 @@ import { scrollToFirstError } from '../lib/scrollToError' import { getFriendlyErrorMessage } from '../lib/errorMessages' import { useWallet } from '../wallet/WalletProvider' import { HB_DATA } from '../data' +import { roundToCents, formatDecimal } from '../lib/format' +import { projectedReturn } from '../lib/bondUtils' /** * Deposit — the flow that must be perfect. One column, one decision per step: @@ -33,7 +35,13 @@ export function Deposit({ onDone }: DepositProps) { const t = useTranslations('Deposit') const { toast } = useToast() const { address, sign } = useWallet() - const { sharePrice: livePrice, loading: vaultLoading, error: vaultError, fetchedAt, refresh: refreshVault } = useVault() + const { + sharePrice: livePrice, + loading: vaultLoading, + error: vaultError, + fetchedAt, + refresh: refreshVault, + } = useVault() const [step, setStep] = useState('amount') const [amount, setAmount] = useState('100') const [txHash, setTxHash] = useState(null) @@ -76,7 +84,10 @@ export function Deposit({ onDone }: DepositProps) { } } - const n = parseFloat(amount) || 0 + // Round once to cents and reuse everywhere below so every display of this + // amount agrees, instead of each `.toFixed()` call re-rounding the raw + // float independently (#369). + const n = roundToCents(parseFloat(amount) || 0) const price = livePrice const balance = 240 @@ -149,10 +160,36 @@ export function Deposit({ onDone }: DepositProps) { Using estimated rate )} - {t.rich('preview', { shares: (n / price).toFixed(4), price, num })} - - Fee: < $0.01 · Net proceeds: ≈ {(n - 0.01).toFixed(2)} USDC worth {(n / price).toFixed(4)} HBS (real-time) + {t.rich('preview', { shares: formatDecimal(n / price, 4), price, num })} + + Fee: < $0.01 · Net proceeds: ≈ {formatDecimal(roundToCents(n - 0.01), 2)}{' '} + USDC worth {formatDecimal(n / price, 4)} HBS (real-time) + {n >= 1 && ( + + {t('projection', { + amount: formatDecimal( + roundToCents(projectedReturn(n, HB_DATA.pool.projectedRate)), + 2, + ), + rate: HB_DATA.pool.projectedRate, + })} + + )} ) } @@ -164,7 +201,14 @@ export function Deposit({ onDone }: DepositProps) { style={{ width: '100%', marginTop: 20 }} disabled={n < 1 || n > balance} reason={n > balance ? t('reasonExceeds') : n < 1 ? t('reasonMin') : undefined} - onClick={() => { if (n < 1 || n > balance) { setTxError(n > balance ? 'amount_exceeds_balance' : 'amount_too_low'); setTimeout(()=>scrollToFirstError(document),50); return; } changeStep('review') } } + onClick={() => { + if (n < 1 || n > balance) { + setTxError(n > balance ? 'amount_exceeds_balance' : 'amount_too_low') + setTimeout(() => scrollToFirstError(document), 50) + return + } + changeStep('review') + }} > {n >= 1 && n <= balance ? t('investCta', { amount: n }) : t('investCtaEmpty')} @@ -185,14 +229,30 @@ export function Deposit({ onDone }: DepositProps) { margin: '6px 0 20px', }} > - - + +
-
-

+

+

{isRateStale ? `Rate updated ${rateAgeSeconds}s ago — may be outdated. Refresh before confirming.` : `Live rate — updated ${rateAgeSeconds}s ago at ${priceFetchedAt.toLocaleTimeString()}`} @@ -201,14 +261,37 @@ export function Deposit({ onDone }: DepositProps) { type="button" onClick={() => refreshVault()} aria-label="Refresh exchange rate" - style={{ fontFamily: 'var(--font-body)', fontSize: 'var(--type-caption)', fontWeight: 600, color: 'var(--ink)', background: 'var(--ink-06)', border: '1px solid var(--ink-12)', borderRadius: 'var(--radius-pill)', padding: '4px 10px', cursor: 'pointer' }} + style={{ + fontFamily: 'var(--font-body)', + fontSize: 'var(--type-caption)', + fontWeight: 600, + color: 'var(--ink)', + background: 'var(--ink-06)', + border: '1px solid var(--ink-12)', + borderRadius: 'var(--radius-pill)', + padding: '4px 10px', + cursor: 'pointer', + }} > Refresh rate

{isRateStale && ( -
- Exchange rate is more than 30 seconds old — please refresh to get the latest price before confirming. +
+ Exchange rate is more than 30 seconds old — please refresh to get the latest price + before confirming.
)}

{t.rich('successBody', { - shares: (n / price).toFixed(4), + shares: formatDecimal(n / price, 4), num, b: strong, count: HB_DATA.pool.projectsFunded, diff --git a/src/screens/Landing.tsx b/src/screens/Landing.tsx index d487733b..ac4eedfb 100644 --- a/src/screens/Landing.tsx +++ b/src/screens/Landing.tsx @@ -4,7 +4,6 @@ import { useTranslations } from 'next-intl' import { Button, StatBlock } from '../components' import { LiveHelio } from '../brand/LiveHelio' import { HB_DATA } from '../data' -import type { Screen } from '../types' /** * Landing — public hero. The live Helio dominates; three counters deep-link to @@ -12,10 +11,10 @@ import type { Screen } from '../types' */ export interface LandingProps { onConnect: () => void - onNav: (screen: Screen) => void + onExplore: () => void } -export function Landing({ onConnect, onNav }: LandingProps) { +export function Landing({ onConnect, onExplore }: LandingProps) { const t = useTranslations('Landing') const d = HB_DATA const steps = [1, 2, 3, 4] as const @@ -62,7 +61,7 @@ export function Landing({ onConnect, onNav }: LandingProps) { -

diff --git a/src/screens/Withdraw.tsx b/src/screens/Withdraw.tsx index 45ace9f6..43cfa76e 100644 --- a/src/screens/Withdraw.tsx +++ b/src/screens/Withdraw.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl' import { Button, AmountInput, LiquidityMeter, useToast } from '../components' import { submitWithdraw } from '../wallet/vault' import { useWallet } from '../wallet/WalletProvider' +import { roundToCents, formatDecimal } from '../lib/format' /** * Withdraw — designed with the most care of all. Capped at the live liquid @@ -50,7 +51,8 @@ export function Withdraw({ onDone, onBack }: WithdrawProps) { setStep(newStep) } } - const n = parseFloat(amount) || 0 + // Round once to cents so every display of this amount agrees (#369). + const n = roundToCents(parseFloat(amount) || 0) const renderStep = (currentStep: WithdrawStep) => { switch (currentStep) { @@ -110,7 +112,7 @@ export function Withdraw({ onDone, onBack }: WithdrawProps) { toast({ tone: 'success', title: 'Withdrawal settled', - message: `${n.toFixed(2)} USDC is on its way to your wallet.`, + message: `${formatDecimal(n, 2)} USDC is on its way to your wallet.`, }) } } catch (e) { @@ -219,7 +221,7 @@ export function Withdraw({ onDone, onBack }: WithdrawProps) { }} > {t.rich('successBody', { - amount: n.toFixed(2), + amount: formatDecimal(n, 2), num: (c: ReactNode) => ( {c}