Skip to content

feat(frontend): add multi-currency display toggle beyond UsdAmount - #1151

Open
Spagero763 wants to merge 1 commit into
ritik4ever:mainfrom
Spagero763:feat/834-multi-currency-display
Open

feat(frontend): add multi-currency display toggle beyond UsdAmount#1151
Spagero763 wants to merge 1 commit into
ritik4ever:mainfrom
Spagero763:feat/834-multi-currency-display

Conversation

@Spagero763

Copy link
Copy Markdown
Contributor

Description

Extends UsdAmount into a generic CurrencyAmount that renders in the currency the reader picked, with a selector in the header and the choice persisted across reloads.

Fixes #834

Type of Change

  • New feature (non-breaking change which adds functionality)

How the acceptance criteria are met

Criterion How
Switching the selector updates all displayed amounts The selection lives in a CurrencyProvider, not in each component, so one change re-renders every amount at once. Covered by a test that mounts two amounts and asserts both follow a single switch.
The choice persists across page reloads Persisted via the repo's existing useLocalStorage hook, which also syncs other open tabs. Tested both directions: that switching writes the key, and that a fresh mount with the key already set renders in that currency.
A rate-lookup failure falls back to USD without breaking the page Every failure path degrades to USD. Three tests cover it: the fetch rejecting, the response omitting the chosen currency, and a stored currency that is no longer on offer.

Design notes

One lookup per page, not per amount. currency.ts does a single USD-based rate fetch, cached in memory and in localStorage for twelve hours, with concurrent callers sharing one in-flight request. A page showing twenty amounts makes one call; a reload makes none. Amounts resolve to USD once and are then converted for display, so switching currency never re-hits the token price feed. There is a test asserting one fetch for three simultaneous amounts.

Failing to USD, never failing to an error. A rejected fetch, a rate the response omitted, an unusable rate, or a currency Intl cannot format all fall back to the exact USD display the component had before. The selector also hides itself when no rates are available, rather than offering choices that would silently do nothing.

"Local" currency is derived from the browser locale via Intl.Locale.prototype.maximize().currency, with a small region-to-currency table behind it because that property is still missing in some engines. When neither resolves, the selector simply offers USD and EUR.

utils.ts gains xlmToUsdValue. The existing xlmToUsd returns a formatted string, which cannot be converted onward into another currency. The numeric helper is the minimum needed; xlmToUsd now delegates to it and is otherwise unchanged, so its existing callers are unaffected.

Two things worth a reviewer's attention

1. UsdAmount is now a deprecated wrapper. The issue asks to extend it into CurrencyAmount, so the logic moved and UsdAmount stays as a thin delegating wrapper. Existing imports keep working. Its test needed one change: it stubbed xlmToUsd, and the component now takes the numeric path, so the stub moved to xlmToUsdValue. Same three assertions, same expected output.

2. A small behaviour fix, needed to avoid a regression. BountyCard had its own copy of the conversion logic inline, which this PR replaces with the shared component so the card follows the selector too. That copy was more correct than UsdAmount: it rendered nothing for a token with no price feed, whereas UsdAmount valued any non-USDC token at the XLM rate. CurrencyAmount takes the card's behaviour, so unifying them does not regress the card. This does mean a non-XLM, non-USDC token now shows no fiat equivalent anywhere, instead of showing a wrong one.

Validation

  • 12 new tests in CurrencyAmount.test.tsx, one per acceptance criterion plus the caching and fallback edges. All pass.
  • Full frontend suite run on clean upstream/main and on this branch, and compared, because several suites already fail on main:
Failed files Failed tests Passed tests
upstream/main 7 1 174
this branch 7 1 186

Identical failure sets, no newly failing file, passed tests up by exactly the 12 added.

  • tsc --noEmit: 10 pre-existing errors before and after, none in any file this PR touches.
  • eslint --max-warnings 0 passes on every file this PR adds or changes.
  • vite build succeeds and bundles this code (see the note below on what had to be worked around to run it at all).

Frontend CI is currently red on main, for reasons unrelated to this PR

I could not run the CI steps as written, because all four fail on a clean checkout of main. Flagging them since they will make this PR look red:

  1. npm ci failsfrontend/package-lock.json is committed as a 0-byte file, so npm reports "can only install with an existing package-lock.json". I installed with npm install locally and restored the empty file so it stays out of this diff.
  2. npx tsc --noEmit failstsconfig.json sets "moduleResolution": "Node", and frontend pins no TypeScript, so npx fetches TS 6, which removed node10. Reproduces on main with no changes.
  3. npx eslint . --max-warnings 0 fails — 21 pre-existing problems (12 errors) in files this PR does not touch, including unused variables and a stale disable directive.
  4. npx vite build failsfrontend/vite.config.js is a committed 72-byte fragment (/// <reference types="vitest" /> followed by four dangling closing braces) which shadows the real vite.config.ts. With it moved aside the build gets further and then fails on src/hooks/useWallet.ts importing @stellar/freighter-api, which is not in frontend/package.json.

To verify my own work I pinned typescript@5.9.3 and installed @stellar/freighter-api, both with --no-save, and temporarily moved the broken vite.config.js aside. With only those pre-existing blockers removed, the build completes cleanly:

dist/assets/index-BI6hYKBS.css   35.22 kB │ gzip:  7.44 kB
dist/assets/index-Ca1TBpST.js   269.23 kB │ gzip: 81.42 kB
✓ built in 7.23s

None of those four are touched by this PR. Happy to open a separate PR fixing them (regenerate the lockfile, delete the stray vite.config.js, pin TypeScript, add the missing dependency) since they block every frontend contribution right now — just say the word, as it is a big and entirely unrelated diff.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added tests that prove my feature works
  • New and existing unit tests pass locally with my changes

Extends UsdAmount into a generic CurrencyAmount that renders in the currency
the reader picked, with a selector in the header and the choice persisted
across reloads.

The selected currency lives in a CurrencyProvider rather than in each
component, so one change updates every amount on the page at once. It is
persisted through the existing useLocalStorage hook, which also keeps other
open tabs in step.

Rates come from a new currency.ts: one USD-based lookup, cached in memory and
in localStorage for twelve hours, with concurrent callers sharing a single
request. A page showing twenty amounts makes one call, and a reload makes none.
Amounts resolve to USD once and are converted for display, so switching
currency never re-hits the token price feed.

Every step degrades to USD rather than failing. A failed lookup, a rate the
response omitted, or a currency the runtime cannot format all fall back to the
USD display the component had before, and the selector hides itself when no
rates are available rather than offering choices that would do nothing.

utils.ts gains xlmToUsdValue, returning the USD value as a number. xlmToUsd
formats for display and so cannot be converted onward; it now delegates to the
numeric helper and is otherwise unchanged.

UsdAmount stays as a deprecated wrapper so existing imports keep working.
BountyCard had its own copy of the conversion logic inline, which is now the
shared component. That copy correctly rendered nothing for tokens with no price
feed, unlike UsdAmount, which valued any non-USDC token at the XLM rate;
CurrencyAmount takes the correct behaviour so unifying the two does not
regress the card.
Copilot AI lite review requested due to automatic review settings August 31, 2026 09:14
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@Spagero763 is attempting to deploy a commit to the ritik4ever's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@Spagero763 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are user-visible fallback inconsistencies (e.g., selector offering non-convertible currencies and XLM price lookup failures rendering nothing instead of the prior “USD unavailable” marker).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR introduces app-wide display-currency support by adding a CurrencyProvider + header selector and replacing UsdAmount usage with a new CurrencyAmount component that can render amounts in the reader’s selected currency (persisted via localStorage) while caching USD exchange rates client-side.

Changes:

  • Add currency.ts, CurrencyContext, CurrencySelector, and CurrencyAmount to support a persisted, global display-currency choice with cached USD-based rates.
  • Deprecate UsdAmount into a thin wrapper around CurrencyAmount, and update existing pages/components to use CurrencyAmount where appropriate.
  • Add comprehensive tests for switching, persistence, caching, and failure fallbacks; add xlmToUsdValue for numeric USD computation.
File summaries
File Description
frontend/src/utils.ts Adds numeric xlmToUsdValue helper and updates xlmToUsd to delegate to it.
frontend/src/UsdAmount.tsx Deprecates UsdAmount into a wrapper around CurrencyAmount.
frontend/src/UsdAmount.test.tsx Updates mocking and async handling to reflect numeric conversion path.
frontend/src/RecommendedBounties.tsx Switches bounty fiat display from UsdAmount to CurrencyAmount.
frontend/src/main.tsx Wraps the app in CurrencyProvider to make currency selection global.
frontend/src/index.css Adds .currency-selector styling for the header select control.
frontend/src/CurrencySelector.tsx New header selector component driven by CurrencyContext.
frontend/src/CurrencyContext.tsx New provider/hook managing selected currency, persistence, and rate loading.
frontend/src/CurrencyAmount.tsx New component that resolves token→USD once, then converts USD→display currency.
frontend/src/CurrencyAmount.test.tsx Adds tests for selector switching, persistence, caching, and failure fallbacks.
frontend/src/currency.ts New rate fetching/caching + locale currency detection + formatting/conversion helpers.
frontend/src/BountyDetailPage.tsx Replaces UsdAmount usage with CurrencyAmount for displayed bounties.
frontend/src/BountyCard.tsx Removes inline conversion logic and uses shared CurrencyAmount instead.
frontend/src/App.tsx Adds CurrencySelector to the header actions area.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +57 to +80
.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 +41 to +45
const options = useMemo(() => currencyOptions(detectLocalCurrency()), []);

// A stored currency that is no longer offered (a different device, or a
// locale change) would otherwise leave the selector showing nothing.
const currency = options.includes(stored) ? stored : USD;
@Spagero763

Copy link
Copy Markdown
Contributor Author

CI is showing 13 failures on this PR. I went through each one, and none of them are reached by this change. Posting the detail so it is not a guess.

This PR touches 14 files, all under frontend/src/. No Rust, no backend, no Docker, no config, no generated artifacts.

Check Why it fails Related to this PR
Frontend CI npm ci exits EUSAGE, because frontend/package-lock.json is committed as a 0-byte file No, it dies at install before compiling anything
CI / Frontend lint, type-check and tests Same npm ci failure No
Publish Docker Images to GHCR npm ci inside the Dockerfile, same lockfile No
Lighthouse CI Same npm ci failure No
Playwright E2E Same install chain No
Soroban Contract CI Rust syntax error in contracts/src/lib.rs:175 No, this PR contains no Rust
Contract Bindings Drift cargo exits 101 on the same broken contract source No
CI / Contract audit and build cargo 101, plus a parse error in the RUSTSEC advisory database entry for astral-tokio-tar No
CI / Backend lint, type-check, test and coverage Backend ESLint no-console errors No, backend is untouched
Gitleaks Secret Scan Config fails to load: 'Allowlist' expected a map, got 'slice' No
OpenAPI Spec docs/openapi.generated.json is out of date No
PR Check A backend test asserting on a submission URL, plus ECONNREFUSED No
Vercel "Authorization required to deploy", an account authorization prompt No

The three that look like committed-file corruption rather than ordinary breakage:

frontend/package-lock.json is 0 bytes. Every job that runs npm ci against the frontend fails immediately:

npm error code EUSAGE
npm error The `npm ci` command can only install with an existing package-lock.json or
npm error npm-shrinkwrap.json with lockfileVersion >= 1.

contracts/src/lib.rs has a dangling attribute and a stray brace, which is why the contract jobs fail to compile:

pub struct ContractUnpaused {
    pub admin: Address,
}

#[contracttype]

}
error: unexpected closing delimiter: `}`
   --> src/lib.rs:175:1

frontend/vite.config.js is a 72-byte fragment consisting of a reference comment followed by four dangling closing braces. It shadows the real vite.config.ts, so vite build fails to parse it. Behind that, the build then fails on src/hooks/useWallet.ts importing @stellar/freighter-api, which is not listed in frontend/package.json.

How I verified this change itself

Since the CI steps cannot run on a clean checkout, I ran them locally, pinning typescript@5.9.3 and installing @stellar/freighter-api with --no-save and moving the broken vite.config.js aside. None of those workarounds are in the diff.

  • 12 new tests in CurrencyAmount.test.tsx, one per acceptance criterion plus the caching and fallback edges. All pass.
  • Full frontend suite run on a clean upstream/main checkout and on this branch, then compared, because several suites already fail on main:
Failed files Failed tests Passed tests
upstream/main 7 1 174
this branch 7 1 186

Identical failure sets, no newly failing file, passed tests up by exactly the 12 added.

  • tsc --noEmit: 10 pre-existing errors before and after, none in a file this PR touches.
  • eslint --max-warnings 0: clean on every file this PR adds or changes.
  • vite build: completes, with only the pre-existing blockers above lifted.
dist/assets/index-BI6hYKBS.css   35.22 kB │ gzip:  7.44 kB
dist/assets/index-Ca1TBpST.js   269.23 kB │ gzip: 81.42 kB
✓ built in 7.23s

Happy to open a separate PR repairing the root causes, since they currently block every contribution: regenerate the frontend lockfile, delete the stray vite.config.js, fix the contracts/src/lib.rs syntax error, add the missing @stellar/freighter-api dependency, repair the gitleaks config, and regenerate the OpenAPI spec. It would be a large diff unrelated to this feature, so I did not fold it in here. Let me know if that would help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Add multi-currency display toggle beyond UsdAmount

2 participants