Finance

CurrencyWallet

Balances in several currencies with the total in a base currency, each with its rate, its worth and share, and convert and send actions.

#finance#forex#banking#card#no-js

Demo

Preview

Wallet · total in USD

$19,159.46

6 currencies · rates 14:05 UTC · 1 without a rate, left out

  • USD US dollaraccount ending •••• 4821$12,480.5565.1% of the total
    Base currencyConvertSend
  • EUR Euroaccount ending •••• 2046€3,250.00≈ $3,523.42
    1 EUR = 1.0841 USDConvertSend
  • JPY Japanese yen¥185,000≈ $1,184.08
    1 JPY = 0.006400 USDConvertSend
  • GBP British pound£840.20≈ $1,071.55
    1 GBP = 1.2753 USDConvertSend
  • PHP Philippine pesoaccount ending •••• 7310₱52,300.00≈ $899.86
    1 PHP = 0.017206 USDConvertSend
  • KES Kenyan shillingKES 15,000.00no rate

Travel card · total in EUR

€625.05

2 currencies

  • CHF Swiss francCHF 420.00≈ €439.65
    1 CHF = 1.0468 EURConvert
  • EUR Euro€185.4029.7% of the total
    Base currencyConvert

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/currency_wallet.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/currency_wallet.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/currency_wallet.qwe
view CurrencyWalletDemo() =    <div.demo-stack>        <CurrencyWallet id="currency-wallet-demo" base="USD" updated="14:05 UTC"                        rates={{ USD: 1, EUR: 0.9224m, GBP: 0.7841m, JPY: 156.24m, PHP: 58.12m }}                        convert_href="#convert?from={currency}" send_href="#send?currency={currency}" balances={[            { currency: "PHP", amount: 52300m, account: "•••• 7310" },            { currency: "EUR", amount: 3250m, account: "•••• 2046" },            { currency: "USD", amount: 12480.55m, account: "•••• 4821" },            { currency: "JPY", amount: 185000 },            { currency: "GBP", amount: 840.20m },            { currency: "KES", amount: 15000m },        ]}/>        <div.demo-narrow>            <CurrencyWallet id="currency-wallet-demo-eur" label="Travel card" base="EUR" rates={{ EUR: 1, CHF: 0.9553m }}                            convert_href="#convert?from={currency}" send_href={null} keep_order={true}                            balances={[{ currency: "CHF", amount: 420m }, { currency: "EUR", amount: 185.40m }]}/>        </div>    </div>

Source

ui/currency_wallet.qwe
// CurrencyWallet: money held in several currencies: the total in a base currency at// the top, then each balance with its flag, the amount in its own currency, its rate// to the base, what it is worth in the base and its share of the whole, and links to// convert it or send it. Balances in the base come first, then the largest.////   <CurrencyWallet base="USD" rates={{ USD: 1, EUR: 0.9224m, JPY: 156.24m }} balances={[//       { currency: "USD", amount: 12480.55m, account: "•••• 4821" },//       { currency: "EUR", amount: 3250m }, { currency: "JPY", amount: 185000 }]}/>//// balances: a list of { currency, amount, name, account }: the ISO code, the amount// (decimals best; numbers and numeric text are read as decimals), a name ("Euro"; the// common ones are known) and the account it is held in, masked ("•••• 4821"). base:// the currency of the total ("USD"). rates: a map of code to the units of it one base// buys ({ EUR: 0.9224m }); a balance without one shows "—" and is left out of the// total, which says so. convert_href and send_href: the links of each balance's// actions, with {currency} put in ("/convert?from={currency}", "/send?currency={currency}";// null drops the action). keep_order: show the balances in the order given (false).// locale: how amounts are written ("en-US"). label: the heading ("Wallet"). updated:// when the rates are from. id: the element's id.import ui/theme.qwe use ui_clslet currency_wallet_css = css`.ui-fxwallet { display: grid; gap: 0; min-width: 0; max-width: 620px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius);  background: var(--ui-surface); font: 14px/1.4 var(--ui-font); color: var(--ui-fg); font-variant-numeric: tabular-nums; overflow: hidden; }.ui-fxwallet-head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; padding: 18px 18px 16px;  border-bottom: 1px solid var(--ui-border); }.ui-fxwallet .ui-fxwallet-title { margin: 0; color: var(--ui-muted); font-size: 13px; font-weight: 500; }.ui-fxwallet-total { margin: 2px 0 0; font-size: 30px; font-weight: 700; line-height: 1.15; letter-spacing: -.01em; }.ui-fxwallet-sub { margin: 2px 0 0; color: var(--ui-muted); font-size: 12px; }.ui-fxwallet-actions { display: flex; gap: 8px; }.ui-fxwallet-btn { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px; border: 1px solid var(--ui-border);  border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 13px var(--ui-font); text-decoration: none; }.ui-fxwallet-btn svg { width: 14px; height: 14px; }.ui-fxwallet-btn:hover { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-fxwallet-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fxwallet-primary { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-fxwallet-primary:hover { color: var(--ui-accent-fg); filter: brightness(1.08); }.ui-fxwallet-list { margin: 0; padding: 0; list-style: none; }.ui-fxwallet-item { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; padding: 12px 18px; }.ui-fxwallet-item:not(:last-child) { border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 60%, transparent); }.ui-fxwallet-flag { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--ui-subtle); font-size: 18px; }.ui-fxwallet-flag small { font-size: 10px; font-weight: 700; color: var(--ui-muted); }.ui-fxwallet-name b { font-weight: 650; }.ui-fxwallet-name small { display: block; color: var(--ui-muted); font-size: 12px; }.ui-fxwallet-amount { text-align: right; }.ui-fxwallet-amount b { display: block; font-size: 15px; font-weight: 650; }.ui-fxwallet-amount small { color: var(--ui-muted); font-size: 12px; }.ui-fxwallet-foot { grid-column: 2 / -1; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 14px; }.ui-fxwallet-share { flex: 1; min-width: 80px; max-width: 220px; height: 4px; border-radius: 2px; background: var(--ui-subtle); overflow: hidden; }.ui-fxwallet-share span { display: block; height: 100%; border-radius: 2px; background: var(--ui-accent); }.ui-fxwallet-links { display: flex; gap: 12px; font-size: 13px; }.ui-fxwallet-links a { color: var(--ui-accent); font-weight: 500; text-decoration: none; }.ui-fxwallet-links a:hover { text-decoration: underline; }.ui-fxwallet-links a:focus-visible { outline: none; box-shadow: var(--ui-focus); border-radius: 3px; }.ui-fxwallet-none { color: var(--ui-muted); }.ui-fxwallet-rate { color: var(--ui-muted); font-size: 12px; }`let currency_wallet_flags = { USD: "🇺🇸", EUR: "🇪🇺", GBP: "🇬🇧", JPY: "🇯🇵", CHF: "🇨🇭", CAD: "🇨🇦", AUD: "🇦🇺", NZD: "🇳🇿", CNY: "🇨🇳",                              HKD: "🇭🇰", SGD: "🇸🇬", INR: "🇮🇳", PHP: "🇵🇭", MXN: "🇲🇽", BRL: "🇧🇷", ZAR: "🇿🇦", SEK: "🇸🇪", NOK: "🇳🇴",                              DKK: "🇩🇰", PLN: "🇵🇱", KRW: "🇰🇷", THB: "🇹🇭", IDR: "🇮🇩", MYR: "🇲🇾", AED: "🇦🇪", TRY: "🇹🇷", KES: "🇰🇪",                              NGN: "🇳🇬", VND: "🇻🇳" }let currency_wallet_names = { USD: "US dollar", EUR: "Euro", GBP: "British pound", JPY: "Japanese yen", CHF: "Swiss franc",                              CAD: "Canadian dollar", AUD: "Australian dollar", NZD: "New Zealand dollar", CNY: "Chinese yuan",                              HKD: "Hong Kong dollar", SGD: "Singapore dollar", INR: "Indian rupee", PHP: "Philippine peso",                              MXN: "Mexican peso", KES: "Kenyan shilling", AED: "UAE dirham", ZAR: "South African rand" }// An amount in its currency, to its minor units: "$12,480.55", "¥185,000".fn currency_wallet_money(amount: dec, code: str, locale: str) -> str {    let m = money.of(amount, code, "half_up")    type_of(m) == "error" ? `${format_number(round(amount, 2, "half_up"), locale, 2)} ${code}` : money.format(m, locale)}// An amount rounded to the currency's minor units (2 when it is not known).fn currency_wallet_round(amount: dec, code: str) -> dec {    let m = money.of(amount, code, "half_up")    type_of(m) == "error" ? round(amount, 2, "half_up") : m.amount}// The decimals a rate is written with: fewer for a large one (169.38), more for a small one (0.006400).fn currency_wallet_places(r: dec) -> int = r >= 100 ? 2 : (r >= 0.1m ? 4 : (r >= 0.001m ? 6 : 8))view CurrencyWallet(balances: list = [], base: str = "USD", rates: map = {}, convert_href: str? = "/convert?from={currency}",                    send_href: str? = "/send?currency={currency}", keep_order: bool = false, locale: str = "en-US",                    label: str = "Wallet", updated: str? = null, id: str? = null, class: str? = null) {    style global currency_wallet_css    let home = upper(base)    let rows = balances | map((b) => {        let code = upper(b.currency)        let amount = dec(b.amount)        let r = code == home ? 1m : (rates[code] != null ? dec(rates[code]) : null)        let worth = r != null && r > 0 ? currency_wallet_round(amount / r, home) : null        { ...b, code, amount, r, worth }    })    let known = rows | filter((r) => r.worth != null)    let total = sum(known | map((r) => r.worth))    let ordered = keep_order ? rows        : (rows | filter((r) => r.code == home)) + (rows | filter((r) => r.code != home) | sort_by((r) => r.worth == null ? 1 : -r.worth))    let link = (tpl: str?, code: str) => tpl != null ? replace(tpl, "{currency}", code) : null    let missing = len(rows) - len(known)    <section class={ui_cls(["ui-fxwallet", class])} id={id} aria-label={label}>        <div.ui-fxwallet-head>            <div>                <h3.ui-fxwallet-title>{label} · total in {home}</h3>                <p.ui-fxwallet-total data-total={str(total)}>{currency_wallet_money(dec(total), home, locale)}</p>                <p.ui-fxwallet-sub>{len(rows)} {len(rows) == 1 ? "currency" : "currencies"}{updated != null ? ` · rates ${updated}` : ""}{missing > 0 ? ` · ${missing} without a rate, left out` : ""}</p>            </div>            <div.ui-fxwallet-actions>                {convert_href != null && <a.ui-fxwallet-btn href={link(convert_href, home)}>                    <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.5 4.5h10M9 2l2.5 2.5L9 7M12.5 9.5h-10M5 7L2.5 9.5 5 12"/></svg>Convert</a>}                {send_href != null && <a class="ui-fxwallet-btn ui-fxwallet-primary" href={link(send_href, home)}>                    <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.5 1.5L6 8M12.5 1.5L8.5 12.5 6 8 1.5 5.5z"/></svg>Send</a>}            </div>        </div>        <ul.ui-fxwallet-list>            {for b in ordered {                let flag = currency_wallet_flags[b.code]                let title = get(b, "name") ?? currency_wallet_names[b.code] ?? b.code                let share = b.worth != null && total > 0 ? float(b.worth) * 100 / float(total) : 0                <li key={b.code + (get(b, "account") ?? "")} class="ui-fxwallet-item" data-code={b.code}>                    <span.ui-fxwallet-flag aria-hidden="true">{flag != null ? flag : <small>{b.code}</small>}</span>                    <span.ui-fxwallet-name><b>{b.code}</b> {title}                        {get(b, "account") != null && <small><span.ui-sr>account ending </span>{b.account}</small>}</span>                    <span.ui-fxwallet-amount>                        <b data-col="amount">{currency_wallet_money(b.amount, b.code, locale)}</b>                        <small data-col="worth">{b.code == home ? (len(rows) > 1 ? `${format_number(round(share, 1), locale, 1)}% of the total` : "")                            : (b.worth != null ? `≈ ${currency_wallet_money(b.worth, home, locale)}` : <span.ui-fxwallet-none>no rate</span>)}</small>                    </span>                    <div.ui-fxwallet-foot>                        <small.ui-fxwallet-rate data-col="rate">{b.code == home ? "Base currency"                            : (b.r != null ? `1 ${b.code} = ${format_number(round(1 / b.r, currency_wallet_places(1 / b.r), "half_up"), locale, currency_wallet_places(1 / b.r))} ${home}` : "—")}</small>                        <span.ui-fxwallet-share role="img" aria-label={`${format_number(round(share, 1), locale, 1)}% of the total`}><span style={`width: ${round(share * 100) / 100}%`}></span></span>                        {(convert_href != null || send_href != null) && <span.ui-fxwallet-links>                            {convert_href != null && <a href={link(convert_href, b.code)} aria-label={`Convert ${b.code}`}>Convert</a>}                            {send_href != null && <a href={link(send_href, b.code)} aria-label={`Send ${b.code}`}>Send</a>}                        </span>}                    </div>                </li>            }}        </ul>    </section>}
Thread
96Useful? Like it, or add to its thread below.

Thread 0 posts

On CurrencyWallet: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.