Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"balanceLabel": "Balance",
"amountH1": "How much would you like to invest?",
"preview": "You'll receive ≈ <num>{shares} HBS</num> · share price <num>{price}</num> · network fee <num>< $0.01</num>",
"projection": "Projected return in 1 year: ≈ ${amount} USDC at {rate}% APY",
"liquidLine": "Today the pool is <b>29% liquid</b> — you can withdraw your liquid share anytime.",
"investCta": "Invest {amount} USDC",
"investCtaEmpty": "Invest",
Expand Down
1 change: 1 addition & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"balanceLabel": "Solde",
"amountH1": "Combien souhaitez-vous investir ?",
"preview": "Vous recevrez ≈ <num>{shares} HBS</num> · prix de la part <num>{price}</num> · frais réseau <num>< $0.01</num>",
"projection": "Rendement projeté sur 1 an : ≈ {amount} $ USDC à {rate} % de rendement annuel",
"liquidLine": "Aujourd'hui le pool est <b>liquide à 29 %</b> — vous pouvez retirer votre part liquide à tout moment.",
"investCta": "Investir {amount} USDC",
"investCtaEmpty": "Investir",
Expand Down
4 changes: 3 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ import { Landing } from '../screens/Landing'

export default function HomePage() {
const router = useRouter()
return <Landing onConnect={() => router.push('/connect')} onNav={() => router.push('/explore')} />
return (
<Landing onConnect={() => router.push('/connect')} onExplore={() => router.push('/explore')} />
)
}
2 changes: 1 addition & 1 deletion src/app/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default function VerifyPage() {
const t = useTranslations('Footer')
return (
<main id="main-content">
<Landing onConnect={() => {}} onNav={() => {}} />
<Landing onConnect={() => {}} onExplore={() => {}} />
<div style={{ maxWidth: 1320, margin: '0 auto', padding: '64px 32px', scrollMarginTop: 68 }}>
<h2
style={{
Expand Down
24 changes: 24 additions & 0 deletions src/components/AmountInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ describe('AmountInput', () => {
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(<AmountInput onChange={onChange} />)
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) => {
Expand Down
20 changes: 16 additions & 4 deletions src/components/AmountInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -138,7 +138,14 @@ export function AmountInput({
</span>
</div>

<p style={{ fontFamily: 'var(--font-body)', fontSize: 'var(--type-caption)', color: 'var(--ink-60)', margin: '8px 0 0' }}>
<p
style={{
fontFamily: 'var(--font-body)',
fontSize: 'var(--type-caption)',
color: 'var(--ink-60)',
margin: '8px 0 0',
}}
>
Min 1 USDC — Max {cap ?? '—'} USDC
</p>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
Expand Down Expand Up @@ -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
}
94 changes: 51 additions & 43 deletions src/lib/bondUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (string|number)[]> {
const metrics = ['yield', 'term', 'rating', 'name'] as const;
const result: Record<string, (string|number)[]> = {};
// #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<string, (string | number)[]> {
const metrics = ['yield', 'term', 'rating', 'name'] as const
const result: Record<string, (string | number)[]> = {}
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
}
25 changes: 25 additions & 0 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading