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
5 changes: 5 additions & 0 deletions messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,11 @@
"scoreGaugeAria": "{label}: {value} من {max}",
"onChainNote": "كل تحديث Oracle على السلسلة ↗",
"verifiedAgo": "تحقق قبل 2 ساعة ↗",
"pricingTitle": "سجل أسعار وعوائد السندات",
"priceLabel": "سجل الأسعار (% من القيمة الاسمية)",
"priceHistory": "سجل الأسعار",
"yieldLabel": "العائد حتى تاريخ الاستحقاق",
"yieldHistory": "سجل العائد",
"fundingTitle": "جدول التمويل",
"fundingGoalLabel": "{raised} م collects من هدف {goal}",
"contributionTitle": "مساهمة هذا المشروع",
Expand Down
5 changes: 5 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@
"scoreGaugeAria": "{label}: {value} out of {max}",
"onChainNote": "every oracle update is on-chain ↗",
"verifiedAgo": "verified 2h ago ↗",
"pricingTitle": "Bond pricing & yield history",
"priceLabel": "Price history (% of par)",
"priceHistory": "Price history",
"yieldLabel": "Yield to maturity",
"yieldHistory": "Yield history",
"fundingTitle": "Funding timeline",
"fundingGoalLabel": "{raised} raised of {goal} goal",
"contributionTitle": "This project's contribution",
Expand Down
5 changes: 5 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,11 @@
"scoreGaugeAria": "{label}: {value} out of {max}",
"onChainNote": "every oracle update is on-chain ↗",
"verifiedAgo": "verified 2h ago ↗",
"pricingTitle": "Historial de precios y rendimiento del bono",
"priceLabel": "Historial de precios (% a la par)",
"priceHistory": "Historial de precios",
"yieldLabel": "Rendimiento al vencimiento",
"yieldHistory": "Historial de rendimiento",
"fundingTitle": "Funding timeline",
"fundingGoalLabel": "{raised} raised of {goal} goal",
"contributionTitle": "This project's contribution",
Expand Down
5 changes: 5 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@
"scoreGaugeAria": "{label} : {value} sur {max}",
"onChainNote": "chaque mise à jour oracle est sur la chaîne ↗",
"verifiedAgo": "vérifié il y a 2h ↗",
"pricingTitle": "Historique des prix et des rendements",
"priceLabel": "Historique des prix (% du pair)",
"priceHistory": "Historique des prix",
"yieldLabel": "Rendement à l'échéance",
"yieldHistory": "Historique des rendements",
"fundingTitle": "Chronologie de financement",
"fundingGoalLabel": "{raised} collectés sur un objectif de {goal}",
"contributionTitle": "Contribution de ce projet",
Expand Down
5 changes: 5 additions & 0 deletions messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,11 @@
"scoreGaugeAria": "{label}: {value} out of {max}",
"onChainNote": "every oracle update is on-chain ↗",
"verifiedAgo": "verified 2h ago ↗",
"pricingTitle": "Histórico de preços e rendimentos do título",
"priceLabel": "Histórico de preços (% do valor nominal)",
"priceHistory": "Histórico de preços",
"yieldLabel": "Rendimento até o vencimento",
"yieldHistory": "Histórico de rendimentos",
"fundingTitle": "Funding timeline",
"fundingGoalLabel": "{raised} raised of {goal} goal",
"contributionTitle": "This project's contribution",
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/watchlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const bond = (over: Partial<Project> = {}): Project => ({
funded: '$420,000',
fundedAmount: 420000,
fundingGoal: 600000,
priceHistory: [],
...over,
})

Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Suspense } from 'react'
import { Providers } from './providers'
import { fontVariables } from '../theme/fonts'
import { LocaleProvider, type Messages } from '../i18n/LocaleProvider'
import { type Locale, RTL_LOCALES } from '../i18n/request'
import { type Locale, RTL_LOCALES } from '../i18n/config'
import { THEME_SCRIPT } from '../theme/themeScript'
import '../styles/index.css'

Expand Down
2 changes: 2 additions & 0 deletions src/app/project/[id]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe('ProjectDetailPage', () => {
funded: '$100,000',
fundedAmount: 100000,
fundingGoal: 200000,
priceHistory: [],
},
detail: {
name: 'Test Project',
Expand All @@ -64,6 +65,7 @@ describe('ProjectDetailPage', () => {
fundingTimeline: [],
fundedAmount: 100000,
fundingGoal: 200000,
priceHistory: [],
},
})
render(<ProjectDetailPage />)
Expand Down
24 changes: 14 additions & 10 deletions src/app/project/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTranslations from 'next-intl'
import { useTranslations } from 'next-intl'
import { Button } from '../../../components'
import { ProjectDetail } from '../../../screens/ProjectDetail'
import { getProject, type ProjectWithDetail } from '../../../lib/api'
import { PriceHistoryChart } from '../../../components/PriceHistoryChart'

export default function ProjectDetailPage() {
const router = useRouter()
Expand All @@ -27,23 +26,28 @@ export default function ProjectDetailPage() {
}, [id])

if (data === 'loading') {
return <div id="main-content" style={{ maxWidth: 860, margin: '0 auto', padding: '40px 24px 96px' }}>Loading...</div>
return <div id="main-content" aria-label="Loading project" style={{ maxWidth: 860, margin: '0 auto', padding: '40px 24px 96px' }}>Loading...</div>
}

if (!data) {
return (
<main id="main-content" style={{ maxWidth: 480, margin: '0 auto', padding: '96px 24px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, textAlign: 'center' }}>
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 'var(--type-h3)', color: 'var(--ink)', margin: 0 }}>{t('notFoundTitle')</h1>
<p style={{ fontFamily: 'var(--font-body)', fontSize: 'var(--type-data)', color: 'var(--ink-60)', margin: 0 }}>{t('notFoundBody')</p>
<Button variant="primary" onClick={() => router.push('/explore')}>{t('notFoundCta')</Button>
<h1 style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 'var(--type-h3)', color: 'var(--ink)', margin: 0 }}>{t('notFoundTitle')}</h1>
<p style={{ fontFamily: 'var(--font-body)', fontSize: 'var(--type-data)', color: 'var(--ink-60)', margin: 0 }}>{t('notFoundBody')}</p>
<Button variant="primary" onClick={() => router.push('/explore')}>{t('notFoundCta')}</Button>
</main>
)
}

return (
<>
<ProjectDetail project={data.project} detail={data.detail} onInvest={() => router.push('/connect')} onBack={() => router.push('/explore')} />
<PriceHistoryChart projectId={id} />
</>
<ProjectDetail
project={data.project}
detail={data.detail}
onInvest={async () => {
router.push('/connect')
return ''
}}
onBack={() => router.push('/explore')}
/>
)
}
19 changes: 12 additions & 7 deletions src/components/AddressVerificationForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('AddressVerificationForm', () => {
fireEvent.change(screen.getByLabelText('Country *'), { target: { value: 'US' } })
fireEvent.click(screen.getByText('Verify address'))
expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText('Street address contains invalid characters')).toBeInDocument()
expect(screen.getByText('Street address contains invalid characters')).toBeInTheDocument()
})

it('does not submit when address contains SQL injection payload', () => {
Expand All @@ -26,7 +26,7 @@ describe('AddressVerificationForm', () => {
fireEvent.change(screen.getByLabelText('Country *'), { target: { value: 'US' } })
fireEvent.click(screen.getByText('Verify address'))
expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText('Street address contains invalid characters')).toBeInDocument()
expect(screen.getByText('Street address contains invalid characters')).toBeInTheDocument()
})

it('submits valid address', () => {
Expand All @@ -41,12 +41,17 @@ describe('AddressVerificationForm', () => {
expect(onSubmit).toHaveBeenCalled()
})

// Additional security-focused edge case tests for all fields
const setField = (label, value) => {
fireEvent.change(screen.getByLabelText(label), { target: { value } })
const setField = (label: string, value: string) => {
const el = screen.getByLabelText(label) as HTMLInputElement | HTMLSelectElement
if (el.tagName === 'SELECT') {
const opt = document.createElement('option')
opt.value = value
el.appendChild(opt)
}
fireEvent.change(el, { target: { value } })
}

const fillForm = (values) => {
const fillForm = (values: Record<string, string>) => {
setField('Street address *', values.street)
setField('City *', values.city)
setField('State / Province *', values.state)
Expand Down Expand Up @@ -82,7 +87,7 @@ describe('AddressVerificationForm', () => {
})
fireEvent.click(screen.getByText('Verify address'))
expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText(error)).toBeInDocument()
expect(screen.getByText(error)).toBeInTheDocument()
})
})
})
Expand Down
3 changes: 3 additions & 0 deletions src/components/AddressVerificationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ export function AddressVerificationForm({ onSubmit, initial }: Props) {
<option value="GB">United Kingdom</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
{!['US', 'CA', 'GB', 'DE', 'FR', ''].includes(values.country) && (
<option value={values.country}>{values.country}</option>
)}
</FormSelect>
</FormField>
{errors.country && <p role="alert" style={errorStyle}>{errors.country}</p>}
Expand Down
12 changes: 12 additions & 0 deletions src/components/AmountInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,16 @@ describe('AmountInput', () => {
expect(queryByText(/Available/)).not.toBeInTheDocument()
})
})

describe('Accessibility (#467)', () => {
it('provides a default aria-label for screen reader users when label is omitted', () => {
const { getByRole } = render(<AmountInput />)
expect(getByRole('textbox', { name: 'Investment amount' })).toBeInTheDocument()
})

it('uses the provided label as aria-label for screen reader users', () => {
const { getByRole } = render(<AmountInput label="Deposit amount" />)
expect(getByRole('textbox', { name: 'Deposit amount' })).toBeInTheDocument()
})
})
})
1 change: 1 addition & 0 deletions src/components/AmountInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export function AmountInput({
pattern="[0-9.]*"
placeholder="0.00"
value={value}
aria-label={label || 'Investment amount'}
onFocus={(e) => e.target.select()}
onPaste={(e) => {
e.preventDefault()
Expand Down
43 changes: 5 additions & 38 deletions src/data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Heliobond — fake data for the click-through. Not production: these stand in
// for live reads from the InvestmentVault + ProjectRegistry Soroban contracts.
// for live reads from the InvestmentVault + ProjectRegistry Soroban contracts.

import { formatPoolCounters } from './lib/format'

export type ProjectType = 'Solar' | 'Wind' | 'Hydro'

Expand All @@ -9,7 +11,7 @@ export type ProjectType = 'Solar' | 'Wind' | 'Hydro'
* saved bonds they can act on now. `upcoming` = not yet available;
* `funded` = fully funded, no further capacity.
*/
type BondStatus = 'open' | 'upcoming' | 'funded'
export type BondStatus = 'open' | 'upcoming' | 'funded'

export interface PricePoint {
date: string
Expand Down Expand Up @@ -221,7 +223,7 @@ const INITIAL_PROJECTS: Project[] = [
// plus 8 historical or off-screen projects funded in the past.
export const OFF_SCREEN_PROJECTS_COUNT = 8

const INITIAL_FUNDED_COUNT = INITIAL_PROJECTS.filter((p) => {
export const INITIAL_FUNDED_COUNT = INITIAL_PROJECTS.filter((p) => {
const n = Number(p.funded.replace(/[^0-9.]/g, ''))
return Number.isFinite(n) && n > 0
}).length
Expand Down Expand Up @@ -264,41 +266,6 @@ function getRiskIndicator(projects: Project[]): { riskScore: number; riskLevel:

const PROJECTS_FUNDED = INITIAL_PROJECTS.length + OFF_SCREEN_PROJECTS_COUNT

// Helper to derive the portfolio risk indicator from the bond mix.
// Credit scores are 0–100; higher credit = lower risk.
// The risk score is inverted so a higher number means higher risk, and the
// risk level is determined by the share of holdings in each credit band.
function getRiskIndicator(projects: Project[]): { riskScore: number; riskLevel: 'conservative' | 'moderate' | 'aggressive' } {
const totalFunded = projects.reduce((sum, p) => sum + p.fundedAmount, 0)
if (totalFunded === 0) {
return { riskScore: 0, riskLevel: 'conservative' }
}

const weightedCredit = projects.reduce((sum, p) => sum + p.credit * p.fundedAmount, 0) / totalFunded
const riskScore = Math.round((100 - weightedCredit) * 10) / 10

// Determine the mix of holdings by rating class.
let highGradeShare = 0 // credit >= 80
let lowGradeShare = 0 // credit < 70

for (const p of projects) {
if (p.fundedAmount <= 0) continue
const share = p.fundedAmount / totalFunded
if (p.credit >= 80) highGradeShare += share
else if (p.credit < 70) lowGradeShare += share
}

let riskLevel: 'conservative' | 'moderate' | 'aggressive'
if (lowGradeShare > 0.2 || highGradeShare < 0.5) {
riskLevel = 'aggressive'
} else if (highGradeShare >= 0.7 && lowGradeShare <= 0.1) {
riskLevel = 'conservative'
} else {
riskLevel = 'moderate'
}

return { riskScore, riskLevel }
}

const { riskScore, riskLevel } = getRiskIndicator(INITIAL_PROJECTS)

Expand Down
8 changes: 0 additions & 8 deletions src/data/projectDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,14 +291,6 @@ export const PROJECT_DETAILS: Record<number, ProjectDetail> = {
],
fundedAmount: 310000,
fundingGoal: 400000,
priceHistory: [
{ date: 'Jan 2026', price: 94.0, yield: 8.1, hash: '4d0a…91b7' },
{ date: 'Feb 2026', price: 94.8, yield: 8.0, hash: 'e913…7c4f' },
{ date: 'Mar 2026', price: 95.6, yield: 7.9, hash: 'aa70…2d6e' },
{ date: 'Apr 2026', price: 96.3, yield: 7.8, hash: '1c8b…43a1' },
{ date: 'May 2026', price: 97.0, yield: 7.7, hash: 'f2e8…02d9' },
{ date: 'Jun 2026', price: 97.8, yield: 7.6, hash: '6d1c…b0a4' },
],
heroGradient:
'radial-gradient(130% 120% at 74% 10%, var(--solar-12), transparent 60%), linear-gradient(155deg, var(--solar-12), var(--ink-06))',
},
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/LocaleProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import fr from '../../messages/fr.json'
import es from '../../messages/es.json'
import ar from '../../messages/ar.json'
import pt from '../../messages/pt.json'
import { type Locale, RTL_LOCALES } from './request'
import { type Locale, RTL_LOCALES } from './config'

export type Messages = typeof en

Expand Down
13 changes: 13 additions & 0 deletions src/i18n/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const LOCALES = ['en', 'fr', 'es', 'ar', 'pt'] as const
export type Locale = (typeof LOCALES)[number]
export const DEFAULT_LOCALE: Locale = 'en'

export const LOCALE_LABELS: Record<Locale, string> = {
en: 'EN',
fr: 'FR',
es: 'ES',
ar: 'AR',
pt: 'PT',
}

export const RTL_LOCALES: ReadonlySet<Locale> = new Set(['ar'] as const)
2 changes: 1 addition & 1 deletion src/i18n/next-intl.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Locale } from './request'
import { type Locale } from './config'
import en from '../../messages/en.json'

declare module 'next-intl' {
Expand Down
11 changes: 2 additions & 9 deletions src/i18n/request.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import { getRequestConfig } from 'next-intl/server'
import { cookies } from 'next/headers'
import { LOCALES, DEFAULT_LOCALE, type Locale } from './config'

// Locale is chosen by a cookie (set by the in-app language switcher); no
// [locale] URL segment is used, so existing routes are untouched.
export const LOCALES = ['en', 'fr', 'es', 'ar', 'pt'] as const
export type Locale = (typeof LOCALES)[number]
export const DEFAULT_LOCALE: Locale = 'en'

export const LOCALE_LABELS: Record<Locale, string> = { en: 'EN', fr: 'FR', es: 'ES', ar: 'AR', pt: 'PT' }

export const RTL_LOCALES: ReadonlySet<Locale> = new Set(['ar'] as const)
export * from './config'

export default getRequestConfig(async () => {
const store = await cookies()
Expand Down
Loading
Loading