Skip to content
Open
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
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import ContributorProfilePage from "./ContributorProfilePage";
import ContributorDashboard from "./ContributorDashboard";
import ErrorBoundary from "./ErrorBoundary";
import SubmissionChecklistModal, { type SubmissionFormData } from "./SubmissionChecklistModal";
import CurrencySelector from "./CurrencySelector";

const DARK_MODE_KEY = "stellar-bounty-board-theme";

Expand Down Expand Up @@ -652,6 +653,7 @@ function App() {
<h1>Stellar Bounty Board</h1>
</div>
<div className="header-actions">
<CurrencySelector />
<FreighterConnectButton freighter={freighter} compact />
<button className="theme-toggle" onClick={toggleDark}>
{dark ? <Sun size={20} /> : <Moon size={20} />}
Expand Down
43 changes: 3 additions & 40 deletions frontend/src/BountyCard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React, { memo, useEffect, useState, type ReactNode } from "react";
import React, { memo, type ReactNode } from "react";
import { statusCopy, actionCopy } from "./constants";
import { type Bounty } from "./types";
import BountyCountdown from "./BountyCountdown";
import { xlmToUsd } from "./utils";
import CurrencyAmount from "./CurrencyAmount";

/** Props for the BountyAmount sub-component. */
interface BountyAmountProps {
Expand All @@ -14,49 +14,12 @@ interface BountyAmountProps {
* equivalent for USDC and XLM amounts.
*/
const BountyAmount = memo(function BountyAmount({ bounty }: BountyAmountProps) {
const [usdAmount, setUsdAmount] = useState<string | null>(null);

useEffect(() => {
let active = true;

if (bounty.tokenSymbol.toUpperCase() === "USDC") {
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(bounty.amount);
setUsdAmount(formatted);
return () => {
active = false;
};
}

if (bounty.tokenSymbol.toUpperCase() !== "XLM") {
setUsdAmount(null);
return () => {
active = false;
};
}

setUsdAmount(null);
void xlmToUsd(bounty.amount).then((value) => {
if (active) {
setUsdAmount(value);
}
});

return () => {
active = false;
};
}, [bounty.amount, bounty.tokenSymbol]);

return (
<div className="amount-chip">
<strong>
{bounty.amount} {bounty.tokenSymbol}
</strong>
{usdAmount && <span>{usdAmount}</span>}
<CurrencyAmount amount={bounty.amount} tokenSymbol={bounty.tokenSymbol} bare />
</div>
);
});
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/BountyDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ReactNode, useState, useCallback, useEffect, useRef, useMemo } from "re
import { ArrowUpRight, Check, Clock, Copy, Share2, Printer, Star } from "lucide-react";
import { Bounty, BountyEvent, BountyStatus } from "./types";
import BountyCountdown from "./BountyCountdown";
import UsdAmount from "./UsdAmount";
import CurrencyAmount from "./CurrencyAmount";
import { updateSocialMetaTags } from "./metaTags";
import CopyIcon from "./CopyIcons";
import { extendDeadline } from "./api";
Expand Down Expand Up @@ -234,7 +234,7 @@ export default function BountyDetailPage({
<div className="amount-chip">
{bounty.amount} {bounty.tokenSymbol}
{(bounty.tokenSymbol === "XLM" || bounty.tokenSymbol === "USDC") && (
<UsdAmount amount={bounty.amount} tokenSymbol={bounty.tokenSymbol} />
<CurrencyAmount amount={bounty.amount} tokenSymbol={bounty.tokenSymbol} />
)}
</div>
</div>
Expand Down
185 changes: 185 additions & 0 deletions frontend/src/CurrencyAmount.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CurrencyAmount from './CurrencyAmount';
import CurrencySelector from './CurrencySelector';
import { CurrencyProvider } from './CurrencyContext';
import { CURRENCY_STORAGE_KEY, resetCurrencyRatesCache } from './currency';

vi.mock('./utils', async () => {
const actual = await vi.importActual<typeof import('./utils')>('./utils');
return {
...actual,
// 10 XLM at $0.12 is $1.20, which keeps the expected figures readable.
xlmToUsdValue: vi.fn().mockResolvedValue(1.2),
};
});

const RATES = { USD: 1, EUR: 0.5, NGN: 1500 };

function mockRatesOk() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ result: 'success', rates: RATES }),
}),
);
}

function mockRatesFailure() {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
}

/** The app shell: a selector and an amount sharing one provider. */
function Board({ amount = 10, tokenSymbol = 'XLM' }: { amount?: number; tokenSymbol?: string }) {
return (
<CurrencyProvider>
<CurrencySelector />
<CurrencyAmount amount={amount} tokenSymbol={tokenSymbol} />
</CurrencyProvider>
);
}

beforeEach(() => {
window.localStorage.clear();
resetCurrencyRatesCache();
mockRatesOk();
});

afterEach(() => {
vi.unstubAllGlobals();
window.localStorage.clear();
resetCurrencyRatesCache();
});

describe('CurrencyAmount', () => {
it('defaults to USD', async () => {
render(<Board />);
await waitFor(() => expect(screen.getByText('($1.20)')).toBeInTheDocument());
});

it('converts USDC without a token price lookup', async () => {
render(<Board amount={100} tokenSymbol="USDC" />);
await waitFor(() => expect(screen.getByText('($100.00)')).toBeInTheDocument());
});

it('renders nothing for a token with no price feed', async () => {
const { container } = render(<Board amount={5} tokenSymbol="FOO" />);
await waitFor(() => expect(container.querySelector('.usd-amount')).toBeNull());
});
});

describe('switching currency', () => {
it('updates the displayed amount', async () => {
const user = userEvent.setup();
render(<Board />);

await waitFor(() => expect(screen.getByText('($1.20)')).toBeInTheDocument());

await user.selectOptions(screen.getByLabelText('Display currency'), 'EUR');

await waitFor(() => expect(screen.getByText('(€0.60)')).toBeInTheDocument());
expect(screen.queryByText('($1.20)')).not.toBeInTheDocument();
});

it('updates every amount on the page at once', async () => {
const user = userEvent.setup();
render(
<CurrencyProvider>
<CurrencySelector />
<CurrencyAmount amount={10} tokenSymbol="XLM" />
<CurrencyAmount amount={100} tokenSymbol="USDC" />
</CurrencyProvider>,
);

await waitFor(() => expect(screen.getByText('($1.20)')).toBeInTheDocument());
expect(screen.getByText('($100.00)')).toBeInTheDocument();

await user.selectOptions(screen.getByLabelText('Display currency'), 'EUR');

await waitFor(() => expect(screen.getByText('(€0.60)')).toBeInTheDocument());
expect(screen.getByText('(€50.00)')).toBeInTheDocument();
});
});

describe('persistence', () => {
it('writes the choice to localStorage', async () => {
const user = userEvent.setup();
render(<Board />);

await waitFor(() => expect(screen.getByText('($1.20)')).toBeInTheDocument());
await user.selectOptions(screen.getByLabelText('Display currency'), 'EUR');

await waitFor(() => expect(JSON.parse(window.localStorage.getItem(CURRENCY_STORAGE_KEY)!)).toBe('EUR'));
});

it('restores the choice on a fresh mount, as a reload would', async () => {
window.localStorage.setItem(CURRENCY_STORAGE_KEY, JSON.stringify('EUR'));

render(<Board />);

await waitFor(() => expect(screen.getByText('(€0.60)')).toBeInTheDocument());
expect((screen.getByLabelText('Display currency') as HTMLSelectElement).value).toBe('EUR');
});

it('ignores a stored currency that is no longer offered', async () => {
window.localStorage.setItem(CURRENCY_STORAGE_KEY, JSON.stringify('XYZ'));

render(<Board />);

await waitFor(() => expect(screen.getByText('($1.20)')).toBeInTheDocument());
});
});

describe('rate lookup failure', () => {
it('falls back to USD without breaking the page', async () => {
mockRatesFailure();
window.localStorage.setItem(CURRENCY_STORAGE_KEY, JSON.stringify('EUR'));

render(<Board />);

// The stored preference is EUR, but with no rates the amount still renders,
// in USD, rather than erroring or hanging on the loading state.
await waitFor(() => expect(screen.getByText('($1.20)')).toBeInTheDocument());
});

it('hides the selector when no rates are available', async () => {
mockRatesFailure();

render(<Board />);

await waitFor(() => expect(screen.getByText('($1.20)')).toBeInTheDocument());
expect(screen.queryByLabelText('Display currency')).not.toBeInTheDocument();
});

it('falls back to USD when the response is missing the chosen currency', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ result: 'success', rates: { USD: 1 } }),
}),
);
window.localStorage.setItem(CURRENCY_STORAGE_KEY, JSON.stringify('EUR'));

render(<Board />);

await waitFor(() => expect(screen.getByText('($1.20)')).toBeInTheDocument());
});
});

describe('rate caching', () => {
it('fetches rates once for many amounts', async () => {
render(
<CurrencyProvider>
<CurrencyAmount amount={10} tokenSymbol="XLM" />
<CurrencyAmount amount={20} tokenSymbol="XLM" />
<CurrencyAmount amount={30} tokenSymbol="XLM" />
</CurrencyProvider>,
);

await waitFor(() => expect(screen.getAllByText('($1.20)').length).toBe(3));
expect(fetch).toHaveBeenCalledTimes(1);
});
});
85 changes: 85 additions & 0 deletions frontend/src/CurrencyAmount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import { xlmToUsdValue } from './utils';
import { useCurrency } from './CurrencyContext';
import { USD, convertFromUsd, formatCurrency } from './currency';

interface CurrencyAmountProps {
amount: number;
tokenSymbol?: string;
/**
* Render without the surrounding parentheses. The parenthesised form is what
* `UsdAmount` has always shown next to a token amount.
*/
bare?: boolean;
}

/**
* A token amount shown in the reader's chosen display currency.
*
* Resolves the amount to USD once, then converts for display, so switching
* currency never re-hits the token price feed. When the rate lookup has failed
* the USD value is shown as-is, which keeps this component working exactly as
* `UsdAmount` did before currencies were selectable.
*/
export default function CurrencyAmount({ amount, tokenSymbol = 'XLM', bare = false }: CurrencyAmountProps) {
const { currency, rates, ratesResolved } = useCurrency();
const [usdValue, setUsdValue] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);

useEffect(() => {
let active = true;
setIsLoading(true);

const symbol = tokenSymbol.toUpperCase();

// USDC is dollar-denominated, so it needs no price lookup.
if (symbol === 'USDC') {
setUsdValue(amount);
setIsLoading(false);
return;
}

// Only XLM has a price feed. Anything else would otherwise be valued at the
// XLM rate, which would be wrong rather than merely unavailable.
if (symbol !== 'XLM') {
setUsdValue(null);
setIsLoading(false);
return;
}

xlmToUsdValue(amount)
.then((value) => {
if (active) {
setUsdValue(value);
setIsLoading(false);
}
})
.catch(() => {
if (active) {
setUsdValue(null);
setIsLoading(false);
}
});

return () => {
active = false;
};
}, [amount, tokenSymbol]);

// Wait for the rates before painting a converted figure, so a non-USD reader
// never sees the amount jump from dollars to their currency after a beat.
if (isLoading || (currency !== USD && !ratesResolved)) {
return <span className="usd-amount">{bare ? 'Loading...' : '(Loading...)'}</span>;
}

if (usdValue === null) return null;

// A missing or unusable rate falls back to USD rather than hiding the amount.
const converted = convertFromUsd(usdValue, currency, rates);
const displayCurrency = converted === null ? USD : currency;
const displayValue = converted === null ? usdValue : converted;
Comment on lines +57 to +80

const formatted = formatCurrency(displayValue, displayCurrency);

return <span className="usd-amount">{bare ? formatted : `(${formatted})`}</span>;
}
Loading
Loading