feat: Add option to select desired fiat currency

This commit is contained in:
benalleng
2023-08-30 23:27:28 -04:00
committed by Paul Miller
parent 294547b1ee
commit d410a9cf63
14 changed files with 775 additions and 125 deletions

View File

@@ -17,6 +17,7 @@ export const ActivityAmount: ParentComponent<{
positive?: boolean; positive?: boolean;
center?: boolean; center?: boolean;
}> = (props) => { }> = (props) => {
const [state, _actions] = useMegaStore();
return ( return (
<div <div
class="flex flex-col gap-1" class="flex flex-col gap-1"
@@ -38,6 +39,7 @@ export const ActivityAmount: ParentComponent<{
<AmountFiat <AmountFiat
amountSats={Number(props.amount)} amountSats={Number(props.amount)}
denominationSize="sm" denominationSize="sm"
loading={state.price === 0}
/> />
</div> </div>
</div> </div>

View File

@@ -4,13 +4,13 @@ import bolt from "~/assets/icons/bolt.svg";
import chain from "~/assets/icons/chain.svg"; import chain from "~/assets/icons/chain.svg";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils"; import { satsToFormattedFiat } from "~/utils";
function prettyPrintAmount(n?: number | bigint): string { function prettyPrintAmount(n?: number | bigint): string {
if (!n || n.valueOf() === 0) { if (!n || n.valueOf() === 0) {
return "0"; return "0";
} }
return n.toLocaleString(); return n.toLocaleString(navigator.languages[0]);
} }
export function AmountSats(props: { export function AmountSats(props: {
@@ -35,7 +35,7 @@ export function AmountSats(props: {
<Show when={props.icon === "minus"}> <Show when={props.icon === "minus"}>
<span>-</span> <span>-</span>
</Show> </Show>
{props.loading ? "..." : prettyPrintAmount(props.amountSats)} {props.loading ? "" : prettyPrintAmount(props.amountSats)}
&nbsp; &nbsp;
<span <span
class="text-base font-light" class="text-base font-light"
@@ -72,15 +72,19 @@ export function AmountFiat(props: {
loading?: boolean; loading?: boolean;
denominationSize?: "sm" | "lg" | "xl"; denominationSize?: "sm" | "lg" | "xl";
}) { }) {
const i18n = useI18n();
const [state, _] = useMegaStore(); const [state, _] = useMegaStore();
const amountInUsd = () => const amountInFiat = () =>
satsToUsd(state.price, Number(props.amountSats) || 0, true); (state.fiat.value === "BTC" ? "" : "~") +
satsToFormattedFiat(
state.price,
Number(props.amountSats) || 0,
state.fiat
);
return ( return (
<h2 class="font-light"> <h2 class="font-light">
~{props.loading ? "..." : amountInUsd()} {props.loading ? "" : amountInFiat()}
<span <span
classList={{ classList={{
"text-sm": props.denominationSize === "sm", "text-sm": props.denominationSize === "sm",
@@ -88,7 +92,8 @@ export function AmountFiat(props: {
"text-xl": props.denominationSize === "xl" "text-xl": props.denominationSize === "xl"
}} }}
> >
&nbsp;{i18n.t("common.usd")} &nbsp;
{props.loading ? "" : state.fiat.value}
</span> </span>
</h2> </h2>
); );

View File

@@ -3,7 +3,7 @@ import { createMemo, Match, ParentComponent, Show, Switch } from "solid-js";
import { AmountEditable, Card, VStack } from "~/components"; import { AmountEditable, Card, VStack } from "~/components";
import { useI18n } from "~/i18n/context"; import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { satsToUsd } from "~/utils"; import { satsToFormattedFiat } from "~/utils";
const noop = () => { const noop = () => {
// do nothing // do nothing
@@ -24,7 +24,6 @@ const KeyValue: ParentComponent<{ key: string; gray?: boolean }> = (props) => {
export const InlineAmount: ParentComponent<{ export const InlineAmount: ParentComponent<{
amount: string; amount: string;
sign?: string; sign?: string;
fiat?: boolean;
}> = (props) => { }> = (props) => {
const i18n = useI18n(); const i18n = useI18n();
const prettyPrint = createMemo(() => { const prettyPrint = createMemo(() => {
@@ -32,34 +31,34 @@ export const InlineAmount: ParentComponent<{
if (isNaN(parsed)) { if (isNaN(parsed)) {
return props.amount; return props.amount;
} else { } else {
return parsed.toLocaleString(); return parsed.toLocaleString(navigator.languages[0]);
} }
}); });
return ( return (
<div class="inline-block text-lg"> <div class="inline-block text-lg">
{props.sign ? `${props.sign} ` : ""} {props.sign ? `${props.sign} ` : ""}
{props.fiat ? "$" : ""} {prettyPrint()} <span class="text-sm">{i18n.t("common.sats")}</span>
{prettyPrint()}{" "}
<span class="text-sm">
{props.fiat ? i18n.t("common.usd") : i18n.t("common.sats")}
</span>
</div> </div>
); );
}; };
function USDShower(props: { amountSats: string; fee?: string }) { function USDShower(props: { amountSats: string; fee?: string }) {
const i18n = useI18n();
const [state, _] = useMegaStore(); const [state, _] = useMegaStore();
const amountInUsd = () => const amountInFiat = () =>
satsToUsd(state.price, add(props.amountSats, props.fee), true); (state.fiat.value === "BTC" ? "" : "~") +
satsToFormattedFiat(
state.price,
add(props.amountSats, props.fee),
state.fiat
);
return ( return (
<Show when={!(props.amountSats === "0")}> <Show when={!(props.amountSats === "0")}>
<KeyValue gray key=""> <KeyValue gray key="">
<div class="self-end"> <div class="self-end">
~{amountInUsd()}&nbsp; {amountInFiat()}&nbsp;
<span class="text-sm">{i18n.t("common.usd")}</span> <span class="text-sm">{state.fiat.value}</span>
</div> </div>
</KeyValue> </KeyValue>
</Show> </Show>

View File

@@ -1,5 +1,6 @@
import { Dialog } from "@kobalte/core"; import { Dialog } from "@kobalte/core";
import { import {
createEffect,
createResource, createResource,
createSignal, createSignal,
For, For,
@@ -20,24 +21,52 @@ import { useI18n } from "~/i18n/context";
import { Network } from "~/logic/mutinyWalletSetup"; import { Network } from "~/logic/mutinyWalletSetup";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs"; import { DIALOG_CONTENT, DIALOG_POSITIONER } from "~/styles/dialogs";
import { satsToUsd, usdToSats } from "~/utils"; import { fiatToSats, satsToFiat } from "~/utils";
function fiatInputSanitizer(input: string): string { import { Currency } from "./ChooseCurrency";
// Make sure only numbers and a single decimal point are allowed
const numeric = input.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1"); // Checks the users locale to determine if decimals should be a "." or a ","
const decimalDigitDivider = Number(1.0)
.toLocaleString(navigator.languages[0], { minimumFractionDigits: 1 })
.substring(1, 2);
function btcFloatRounding(localValue: string): string {
return (
(parseFloat(localValue) -
parseFloat(localValue.charAt(localValue.length - 1)) / 100000000) /
10
).toFixed(8);
}
function fiatInputSanitizer(input: string, maxDecimals: number): string {
// Make sure only numbers and a single decimal point are allowed if decimals are allowed
let allowDecimalRegex;
if (maxDecimals !== 0) {
allowDecimalRegex = new RegExp("[^0-9.]", "g");
} else {
allowDecimalRegex = new RegExp("[^0-9]", "g");
}
const numeric = input
.replace(allowDecimalRegex, "")
.replace(/(\..*)\./g, "$1");
// Remove leading zeros if not a decimal, add 0 if starts with a decimal // Remove leading zeros if not a decimal, add 0 if starts with a decimal
const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0."); const cleaned = numeric.replace(/^0([^.]|$)/g, "$1").replace(/^\./g, "0.");
// If there are three characters after the decimal, shift the decimal // If there are more characters after the decimal than allowed, shift the decimal
const shifted = cleaned.match(/(\.[0-9]{3}).*/g) const shiftRegex = new RegExp(
? (parseFloat(cleaned) * 10).toFixed(2) "(\\.[0-9]{" + (maxDecimals + 1) + "}).*",
"g"
);
const shifted = cleaned.match(shiftRegex)
? (parseFloat(cleaned) * 10).toFixed(maxDecimals)
: cleaned; : cleaned;
// Truncate any numbers two past the decimal // Truncate any numbers past the maxDecimal for the currency
const twoDecimals = shifted.replace(/(\.[0-9]{2}).*/g, "$1"); const decimalRegex = new RegExp("(\\.[0-9]{" + maxDecimals + "}).*", "g");
const decimals = shifted.replace(decimalRegex, "$1");
return twoDecimals; return decimals;
} }
function satsInputSanitizer(input: string): string { function satsInputSanitizer(input: string): string {
@@ -53,9 +82,10 @@ function SingleDigitButton(props: {
character: string; character: string;
onClick: (c: string) => void; onClick: (c: string) => void;
onClear: () => void; onClear: () => void;
fiat: boolean; fiat?: Currency;
}) { }) {
const i18n = useI18n(); const i18n = useI18n();
let holdTimer: ReturnType<typeof setTimeout> | undefined; let holdTimer: ReturnType<typeof setTimeout> | undefined;
const holdThreshold = 500; const holdThreshold = 500;
@@ -85,9 +115,14 @@ function SingleDigitButton(props: {
}); });
return ( return (
// Skip the "." if it's fiat // Skip the "." if it's sats or a fiat with no decimal option
<Show <Show
when={props.fiat || !(props.character === ".")} when={
(props.fiat &&
props.fiat?.maxFractionalDigits !== 0 &&
props.fiat?.value !== "BTC") ||
!(props.character === "." || props.character === ",")
}
fallback={<div />} fallback={<div />}
> >
<button <button
@@ -102,7 +137,12 @@ function SingleDigitButton(props: {
); );
} }
function BigScalingText(props: { text: string; fiat: boolean }) { function BigScalingText(props: {
text: string;
fiat?: Currency;
mode: "fiat" | "sats";
loading: boolean;
}) {
const chars = () => props.text.length; const chars = () => props.text.length;
const i18n = useI18n(); const i18n = useI18n();
@@ -119,53 +159,102 @@ function BigScalingText(props: { text: string; fiat: boolean }) {
"scale-150": chars() <= 4 "scale-150": chars() <= 4
}} }}
> >
{props.text}&nbsp; <Show when={!props.loading || props.mode === "sats"} fallback="…">
<span class="text-xl"> {!props.loading && props.mode === "sats"}
{props.fiat ? i18n.t("common.usd") : i18n.t("common.sats")} {props.mode === "fiat" &&
</span> //adds only the symbol
props.fiat?.hasSymbol}
{props.text}&nbsp;
<span class="text-xl">
{props.fiat ? props.fiat.value : i18n.t("common.sats")}
</span>
</Show>
</h1> </h1>
); );
} }
function SmallSubtleAmount(props: { text: string; fiat: boolean }) { function SmallSubtleAmount(props: {
text: string;
fiat?: Currency;
mode: "fiat" | "sats";
loading: boolean;
}) {
const i18n = useI18n(); const i18n = useI18n();
return ( return (
<h2 class="flex flex-row items-end text-xl font-light text-neutral-400"> <h2 class="flex flex-row items-end text-xl font-light text-neutral-400">
~{props.text}&nbsp; <Show when={!props.loading || props.mode === "fiat"} fallback="…">
<span class="text-base"> {props.fiat?.value !== "BTC" && props.mode === "sats" && "~"}
{props.fiat ? i18n.t("common.usd") : i18n.t("common.sats")} {props.mode === "sats" &&
</span> //adds only the symbol
<img props.fiat?.hasSymbol}
class={"pb-[4px] pl-[4px] hover:cursor-pointer"} {props.text}&nbsp;
src={currencySwap} <span class="text-base">
height={24} {props.fiat ? props.fiat.value : i18n.t("common.sats")}
width={24} </span>
alt="Swap currencies" <img
/> class={"pb-[4px] pl-[4px] hover:cursor-pointer"}
src={currencySwap}
height={24}
width={24}
alt="Swap currencies"
/>
</Show>
</h2> </h2>
); );
} }
function toDisplayHandleNaN(input: string, _fiat: boolean): string { function toDisplayHandleNaN(input: string, fiat?: Currency): string {
const parsed = Number(input); const parsed = Number(input);
//handle decimals so the user can always see the accurate amount //handle decimals so the user can always see the accurate amount
if (isNaN(parsed)) { if (isNaN(parsed)) {
return "0"; return "0";
} else if (parsed === Math.trunc(parsed) && input.endsWith(".")) { } else if (parsed === Math.trunc(parsed) && input.endsWith(".")) {
return parsed.toLocaleString() + "."; return (
parsed.toLocaleString(navigator.languages[0]) + decimalDigitDivider
);
/* To avoid having logic to handle every number up to 8 decimals
any custom currency pair that has more than 3 decimals will always show all decimals*/
} else if (fiat?.maxFractionalDigits && fiat.maxFractionalDigits > 3) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: parsed === 0 ? 0 : fiat.maxFractionalDigits,
maximumFractionDigits: fiat.maxFractionalDigits
});
} else if (parsed === Math.trunc(parsed) && input.endsWith(".0")) { } else if (parsed === Math.trunc(parsed) && input.endsWith(".0")) {
return parsed.toFixed(1); return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 1
});
} else if (parsed === Math.trunc(parsed) && input.endsWith(".00")) { } else if (parsed === Math.trunc(parsed) && input.endsWith(".00")) {
return parsed.toFixed(2); return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 2
});
} else if (parsed === Math.trunc(parsed) && input.endsWith(".000")) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 3
});
} else if ( } else if (
parsed !== Math.trunc(parsed) && parsed !== Math.trunc(parsed) &&
input.endsWith("0") && // matches strings that have 3 total digits after the decimal and ends with 0
input.match(/\.\d{2}0$/) &&
input.includes(".", input.length - 4)
) {
return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 3
});
} else if (
parsed !== Math.trunc(parsed) &&
// matches strings that have 2 total digits after the decimal and ends with 0
input.match(/\.\d{1}0$/) &&
input.includes(".", input.length - 3) input.includes(".", input.length - 3)
) { ) {
return parsed.toFixed(2); return parsed.toLocaleString(navigator.languages[0], {
minimumFractionDigits: 2
});
} else { } else {
return parsed.toLocaleString(); return parsed.toLocaleString(navigator.languages[0], {
maximumFractionDigits: 3
});
} }
} }
@@ -187,13 +276,41 @@ export const AmountEditable: ParentComponent<{
props.initialAmountSats || "0" props.initialAmountSats || "0"
); );
const [localFiat, setLocalFiat] = createSignal( const [localFiat, setLocalFiat] = createSignal(
satsToUsd( satsToFiat(
state.price, state.price,
parseInt(props.initialAmountSats || "0") || 0, parseInt(props.initialAmountSats || "0") || 0,
false state.fiat
) )
); );
/** FixedAmounts allows for the user to choose 3 amount options approximately equal to ~$1, ~$10, ~$100
* This is done by fetching the price and reducing it such that the amounts all end up around the same value
*
* price = ~261,508.89
* roundedPrice = "261508"
* priceLength = 6
*
* input - {@link multipler}: 1, 10, 100
* fixedAmount - (10 ** (6 - 5)) * {@link multiplier}
* result - 10, 100, 1000
*/
const fixedAmount = (multiplier: number, label: boolean) => {
const roundedPrice = Math.round(state.price);
const priceLength = roundedPrice.toString().length;
//This returns a stringified number based on the price range of the chosen currency as compared to BTC
if (!label) {
return Number(10 ** (priceLength - 5) * multiplier).toString();
// Handle labels with a currency identifier inserted in front/back
} else {
return `${state.fiat?.hasSymbol ?? ""}${Number(
10 ** (priceLength - 5) * multiplier
).toLocaleString(navigator.languages[0], {
maximumFractionDigits: state.fiat.maxFractionalDigits
})} ${!state.fiat?.hasSymbol ? state.fiat?.value : ""}`;
}
};
const FIXED_AMOUNTS_SATS = [ const FIXED_AMOUNTS_SATS = [
{ {
label: i18n.t("receive.amount_editable.fix_amounts.ten_k"), label: i18n.t("receive.amount_editable.fix_amounts.ten_k"),
@@ -209,11 +326,28 @@ export const AmountEditable: ParentComponent<{
} }
]; ];
const FIXED_AMOUNTS_USD = [ // Wait to set fiat amounts until we have a price when loading the page
{ label: "$1", amount: "1" }, let FIXED_AMOUNTS_FIAT;
{ label: "$10", amount: "10" },
{ label: "$100", amount: "100" } createEffect(() => {
]; // set FIXED_AMOUNTS_FIAT once we have a price
if (state.price !== 0) {
FIXED_AMOUNTS_FIAT = [
{
label: fixedAmount(1, true),
amount: fixedAmount(1, false)
},
{
label: fixedAmount(10, true),
amount: fixedAmount(10, false)
},
{
label: fixedAmount(100, true),
amount: fixedAmount(100, false)
}
];
}
});
const CHARACTERS = [ const CHARACTERS = [
"1", "1",
@@ -225,13 +359,14 @@ export const AmountEditable: ParentComponent<{
"7", "7",
"8", "8",
"9", "9",
".", decimalDigitDivider,
"0", "0",
i18n.t("receive.amount_editable.del") i18n.t("receive.amount_editable.del")
]; ];
const displaySats = () => toDisplayHandleNaN(localSats(), false); const displaySats = () => toDisplayHandleNaN(localSats());
const displayFiat = () => `$${toDisplayHandleNaN(localFiat(), true)}`; const displayFiat = () =>
state.price !== 0 ? toDisplayHandleNaN(localFiat(), state.fiat) : "…";
let satsInputRef!: HTMLInputElement; let satsInputRef!: HTMLInputElement;
let fiatInputRef!: HTMLInputElement; let fiatInputRef!: HTMLInputElement;
@@ -293,11 +428,33 @@ export const AmountEditable: ParentComponent<{
} }
}; };
function handleCharacterInput(character: string) { /** Handling character inputs gives our virtual keyboard full functionality to add and remove digits in a UX friendly way
* When the input is dealing with sats there is no allowed decimals
*
* Special logic is required for BTC as we want to start from the 8th decimal
* if state.fiat.value === "BTC"
* input - 000123
* result - 0.00000123
*
* input - 11"DEL"11
* result - 0.00000111
*
* for other currencies the inputSanitizer seeks to limit the maximum decimal digits
*
* if state.fiat.value === "KWD"
* input - 123.456666
* result - 123456.666
*/
function handleCharacterInput(characterInput: string) {
const isFiatMode = mode() === "fiat"; const isFiatMode = mode() === "fiat";
const inputSanitizer = isFiatMode const character = characterInput === "," ? "." : characterInput;
? fiatInputSanitizer let inputSanitizer;
: satsInputSanitizer; if (isFiatMode) {
inputSanitizer = fiatInputSanitizer;
} else {
inputSanitizer = satsInputSanitizer;
}
const localValue = isFiatMode ? localFiat : localSats; const localValue = isFiatMode ? localFiat : localSats;
let sane; let sane;
@@ -306,27 +463,57 @@ export const AmountEditable: ParentComponent<{
character === "DEL" || character === "DEL" ||
character === i18n.t("receive.amount_editable.del") character === i18n.t("receive.amount_editable.del")
) { ) {
if (localValue().length <= 1) { if (
localValue().length === 1 ||
(state.fiat.maxFractionalDigits === 0 &&
localValue().startsWith("0"))
) {
sane = "0"; sane = "0";
} else if (
state.fiat.value === "BTC" &&
isFiatMode &&
localValue() !== "0"
) {
// This allows us to handle the backspace key and fight float rounding
sane = inputSanitizer(
btcFloatRounding(localValue()),
state.fiat.maxFractionalDigits
);
} else { } else {
sane = inputSanitizer(localValue().slice(0, -1)); sane = inputSanitizer(
localValue().slice(0, -1),
state.fiat.maxFractionalDigits
);
} }
} else { } else {
if (localValue() === "0") { if (localValue() === "0" && state.fiat.value !== "BTC") {
sane = inputSanitizer(character); sane = inputSanitizer(
character,
state.fiat.maxFractionalDigits
);
} else if (state.fiat.value === "BTC" && isFiatMode) {
sane = inputSanitizer(
Number(localValue()).toFixed(8) + character,
state.fiat.maxFractionalDigits
);
} else { } else {
sane = inputSanitizer(localValue() + character); sane = inputSanitizer(
localValue() + character,
state.fiat.maxFractionalDigits
);
} }
} }
if (isFiatMode) { if (isFiatMode) {
setLocalFiat(sane); setLocalFiat(sane);
setLocalSats( setLocalSats(
usdToSats(state.price, parseFloat(sane || "0") || 0, false) fiatToSats(state.price, parseFloat(sane || "0") || 0, false)
); );
} else { } else {
setLocalSats(sane); setLocalSats(sane);
setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false)); setLocalFiat(
satsToFiat(state.price, Number(sane) || 0, state.fiat)
);
} }
// After a button press make sure we re-focus the input // After a button press make sure we re-focus the input
@@ -338,10 +525,10 @@ export const AmountEditable: ParentComponent<{
if (isFiatMode) { if (isFiatMode) {
setLocalFiat("0"); setLocalFiat("0");
setLocalSats(usdToSats(state.price, parseFloat("0") || 0, false)); setLocalSats(fiatToSats(state.price, parseFloat("0") || 0, false));
} else { } else {
setLocalSats("0"); setLocalSats("0");
setLocalFiat(satsToUsd(state.price, Number("0") || 0, false)); setLocalFiat(satsToFiat(state.price, Number("0") || 0, state.fiat));
} }
// After a button press make sure we re-focus the input // After a button press make sure we re-focus the input
@@ -352,11 +539,13 @@ export const AmountEditable: ParentComponent<{
if (mode() === "fiat") { if (mode() === "fiat") {
setLocalFiat(amount); setLocalFiat(amount);
setLocalSats( setLocalSats(
usdToSats(state.price, parseFloat(amount || "0") || 0, false) fiatToSats(state.price, parseFloat(amount || "0") || 0, false)
); );
} else { } else {
setLocalSats(amount); setLocalSats(amount);
setLocalFiat(satsToUsd(state.price, Number(amount) || 0, false)); setLocalFiat(
satsToFiat(state.price, Number(amount) || 0, state.fiat)
);
} }
} }
@@ -365,10 +554,10 @@ export const AmountEditable: ParentComponent<{
setIsOpen(false); setIsOpen(false);
setLocalSats(props.initialAmountSats); setLocalSats(props.initialAmountSats);
setLocalFiat( setLocalFiat(
satsToUsd( satsToFiat(
state.price, state.price,
parseInt(props.initialAmountSats || "0") || 0, parseInt(props.initialAmountSats || "0") || 0,
false state.fiat
) )
); );
props.exitRoute && navigate(props.exitRoute); props.exitRoute && navigate(props.exitRoute);
@@ -378,7 +567,9 @@ export const AmountEditable: ParentComponent<{
function handleSubmit(e: SubmitEvent | MouseEvent) { function handleSubmit(e: SubmitEvent | MouseEvent) {
e.preventDefault(); e.preventDefault();
props.setAmountSats(BigInt(localSats())); props.setAmountSats(BigInt(localSats()));
setLocalFiat(satsToUsd(state.price, Number(localSats()) || 0, false)); setLocalFiat(
satsToFiat(state.price, Number(localSats()) || 0, state.fiat)
);
setIsOpen(false); setIsOpen(false);
} }
@@ -386,20 +577,55 @@ export const AmountEditable: ParentComponent<{
const { value } = e.target as HTMLInputElement; const { value } = e.target as HTMLInputElement;
const sane = satsInputSanitizer(value); const sane = satsInputSanitizer(value);
setLocalSats(sane); setLocalSats(sane);
setLocalFiat(satsToUsd(state.price, Number(sane) || 0, false)); setLocalFiat(satsToFiat(state.price, Number(sane) || 0, state.fiat));
} }
function handleFiatInput(e: InputEvent) { function handleFiatInput(e: InputEvent) {
const { value } = e.target as HTMLInputElement; const { value } = e.currentTarget as HTMLInputElement;
const sane = fiatInputSanitizer(value); let sane;
/** This behaves the same as handleCharacterInput but allows for the keyboard to be used instead of the virtual keypad
*
* if state.fiat.value === "BTC"
* tracking e.data is required as the string is not created from just normal sequencing numbers
* input - 12345
* result - 0.00012345
*
* if state.fiat.value !== "BTC"
* Otherwise we need to account for the user inputting decimals
* input - 123,45
* result - 123.45
*/
if (state.fiat.value === "BTC") {
if (e.data !== null) {
sane = fiatInputSanitizer(
Number(localFiat()).toFixed(8) + e.data,
state.fiat.maxFractionalDigits
);
} else {
sane = fiatInputSanitizer(
// This allows us to handle the backspace key and fight float rounding
btcFloatRounding(localFiat()),
state.fiat.maxFractionalDigits
);
}
} else {
sane = fiatInputSanitizer(
value.replace(",", "."),
state.fiat.maxFractionalDigits
);
}
setLocalFiat(sane); setLocalFiat(sane);
setLocalSats( setLocalSats(
usdToSats(state.price, parseFloat(sane || "0") || 0, false) fiatToSats(state.price, parseFloat(sane || "0") || 0, false)
); );
} }
function toggle() { function toggle(disabled: boolean) {
setMode((m) => (m === "sats" ? "fiat" : "sats")); if (!disabled) {
setMode((m) => (m === "sats" ? "fiat" : "sats"));
}
focus(); focus();
} }
@@ -428,7 +654,7 @@ export const AmountEditable: ParentComponent<{
) { ) {
return ( return (
Number(props.maxAmountSats) - Number(props.fee) Number(props.maxAmountSats) - Number(props.fee)
).toLocaleString(); ).toLocaleString(navigator.languages[0]);
} else { } else {
return localSats(); return localSats();
} }
@@ -496,7 +722,7 @@ export const AmountEditable: ParentComponent<{
<div class="flex justify-center"> <div class="flex justify-center">
<div <div
class="flex w-max flex-col items-center justify-center gap-4 p-4" class="flex w-max flex-col items-center justify-center gap-4 p-4"
onClick={toggle} onClick={() => toggle(state.price === 0)}
> >
<BigScalingText <BigScalingText
text={ text={
@@ -504,7 +730,13 @@ export const AmountEditable: ParentComponent<{
? displayFiat() ? displayFiat()
: displaySats() : displaySats()
} }
fiat={mode() === "fiat"} fiat={
mode() === "fiat"
? state.fiat
: undefined
}
mode={mode()}
loading={state.price === 0}
/> />
<SmallSubtleAmount <SmallSubtleAmount
text={ text={
@@ -512,7 +744,13 @@ export const AmountEditable: ParentComponent<{
? displayFiat() ? displayFiat()
: displaySats() : displaySats()
} }
fiat={mode() !== "fiat"} fiat={
mode() !== "fiat"
? state.fiat
: undefined
}
mode={mode()}
loading={state.price === 0}
/> />
</div> </div>
</div> </div>
@@ -534,7 +772,7 @@ export const AmountEditable: ParentComponent<{
<For <For
each={ each={
mode() === "fiat" mode() === "fiat"
? FIXED_AMOUNTS_USD ? FIXED_AMOUNTS_FIAT
: FIXED_AMOUNTS_SATS : FIXED_AMOUNTS_SATS
} }
> >
@@ -550,7 +788,11 @@ export const AmountEditable: ParentComponent<{
</button> </button>
)} )}
</For> </For>
<Show when={props.maxAmountSats}> <Show
when={
mode() === "sats" && props.maxAmountSats
}
>
<button <button
onClick={() => { onClick={() => {
setFixedAmount( setFixedAmount(
@@ -568,7 +810,11 @@ export const AmountEditable: ParentComponent<{
<For each={CHARACTERS}> <For each={CHARACTERS}>
{(character) => ( {(character) => (
<SingleDigitButton <SingleDigitButton
fiat={mode() === "fiat"} fiat={
mode() === "fiat"
? state.fiat
: undefined
}
character={character} character={character}
onClick={handleCharacterInput} onClick={handleCharacterInput}
onClear={handleClear} onClear={handleClear}

View File

@@ -75,6 +75,7 @@ export function BalanceBox(props: { loading?: boolean }) {
state.balance?.lightning || 0 state.balance?.lightning || 0
} }
denominationSize="sm" denominationSize="sm"
loading={state.price === 0}
/> />
</div> </div>
</div> </div>
@@ -97,6 +98,7 @@ export function BalanceBox(props: { loading?: boolean }) {
<AmountFiat <AmountFiat
amountSats={totalOnchain()} amountSats={totalOnchain()}
denominationSize="sm" denominationSize="sm"
loading={state.price === 0}
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,173 @@
import { createForm } from "@modular-forms/solid";
import { createSignal, Show } from "solid-js";
import {
Button,
ExternalLink,
InfoBox,
NiceP,
SelectField,
VStack
} from "~/components";
import { useI18n } from "~/i18n/context";
import { useMegaStore } from "~/state/megaStore";
import { eify, timeout } from "~/utils";
export interface Currency {
value: string;
label: string;
hasSymbol?: string;
maxFractionalDigits: number;
}
type ChooseCurrencyForm = {
fiatCurrency: string;
};
/**
* FIAT_OPTIONS is an array of possible currencies
* All available currencies can be found here https://api.coingecko.com/api/v3/simple/supported_vs_currencies
* @Currency
* @param {string} label - should be in the format {Name} {ISO code}
* @param {string} values - are uppercase ISO 4217 currency code
* @param {string?} hasSymbol - if the currency has a symbol it should be represented as a string
* @param {number} maxFractionalDigits - the standard fractional units used by the currency should be set with maxFractionalDigits
*
* Bitcoin is represented as:
* {
* label: "bitcoin BTC",
* value: "BTC",
* hasSymbol: "₿",
* maxFractionalDigits: 8
* }
*/
//Bitcoin and USD need to be FIAT_OPTIONS[0] and FIAT_OPTIONS[1] respectively, they are called in megaStore.tsx
export const FIAT_OPTIONS: Currency[] = [
{
label: "Bitcoin BTC",
value: "BTC",
hasSymbol: "₿",
maxFractionalDigits: 8
},
{
label: "United States Dollar USD",
value: "USD",
hasSymbol: "$",
maxFractionalDigits: 2
},
{ label: "Swiss Franc CHF", value: "CHF", maxFractionalDigits: 2 },
{
label: "Chinese Yuan CNY",
value: "CNY",
hasSymbol: "¥",
maxFractionalDigits: 2
},
{
label: "Euro EUR",
value: "EUR",
hasSymbol: "€",
maxFractionalDigits: 2
},
{
label: "British Pound GBP",
value: "GBP",
hasSymbol: "₤",
maxFractionalDigits: 2
},
{
label: "Japanese Yen JPY",
value: "JPY",
hasSymbol: "¥",
maxFractionalDigits: 0
},
{
label: "Korean Won KRW",
value: "KRW",
hasSymbol: "₩",
maxFractionalDigits: 0
},
{ label: "Kuwaiti Dinar KWD", value: "KWD", maxFractionalDigits: 3 }
];
export function ChooseCurrency() {
const i18n = useI18n();
const [error, setError] = createSignal<Error>();
const [state, _actions] = useMegaStore();
const [loading, setLoading] = createSignal(false);
function findCurrencyByValue(value: string) {
return FIAT_OPTIONS.find((currency) => currency.value === value);
}
const [_chooseCurrencyForm, { Form, Field }] =
createForm<ChooseCurrencyForm>({
initialValues: {
fiatCurrency: ""
},
validate: (values) => {
const errors: Record<string, string> = {};
if (values.fiatCurrency === undefined) {
errors.fiatCurrency = i18n.t(
"settings.currency.error_unsupported_currency"
);
}
return errors;
}
});
const handleFormSubmit = async (f: ChooseCurrencyForm) => {
setLoading(true);
try {
localStorage.setItem(
"fiat_currency",
JSON.stringify(findCurrencyByValue(f.fiatCurrency))
);
await timeout(1000);
window.location.href = "/";
} catch (e) {
console.error(e);
setError(eify(e));
setLoading(false);
}
};
return (
<VStack>
<Form onSubmit={handleFormSubmit} class="flex flex-col gap-4">
<NiceP>{i18n.t("settings.currency.caption")}</NiceP>
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/issues/new">
{i18n.t("settings.currency.request_currency_support_link")}
</ExternalLink>
<div />
<VStack>
<Field name="fiatCurrency">
{(field, props) => (
<SelectField
{...props}
value={field.value}
error={field.error}
placeholder={state.fiat.label}
options={FIAT_OPTIONS}
label={i18n.t(
"settings.currency.select_currency_label"
)}
caption={i18n.t(
"settings.currency.select_currency_caption"
)}
/>
)}
</Field>
<Show when={error()}>
<InfoBox accent="red">{error()?.message}</InfoBox>
</Show>
<div />
<Button intent="blue" loading={loading()}>
{i18n.t("settings.currency.select_currency")}
</Button>
</VStack>
</Form>
</VStack>
);
}

View File

@@ -9,6 +9,7 @@ export * from "./AmountEditable";
export * from "./App"; export * from "./App";
export * from "./BalanceBox"; export * from "./BalanceBox";
export * from "./BetaWarningModal"; export * from "./BetaWarningModal";
export * from "./ChooseCurrency";
export * from "./ContactEditor"; export * from "./ContactEditor";
export * from "./ContactForm"; export * from "./ContactForm";
export * from "./ContactViewer"; export * from "./ContactViewer";

View File

@@ -0,0 +1,95 @@
import { Select as KSelect } from "@kobalte/core";
import { JSX, Show, splitProps } from "solid-js";
import check from "~/assets/icons/check.svg";
import { Currency } from "../ChooseCurrency";
type SelectProps = {
options: Currency[];
multiple?: boolean;
size?: string | number;
caption?: string;
name: string;
label?: string | undefined;
placeholder?: string | undefined;
value: string | undefined;
error: string;
required?: boolean | undefined;
disabled?: boolean | undefined;
ref: (element: HTMLSelectElement) => void;
onInput: JSX.EventHandler<HTMLSelectElement, InputEvent>;
onChange: JSX.EventHandler<HTMLSelectElement, Event>;
onBlur: JSX.EventHandler<HTMLSelectElement, FocusEvent>;
};
export function SelectField(props: SelectProps) {
// Split select element props
const [rootProps, selectProps] = splitProps(
props,
["name", "placeholder", "options", "required", "disabled"],
["placeholder", "ref", "onInput", "onChange", "onBlur"]
);
return (
<div class="relative flex items-center">
<KSelect.Root
{...rootProps}
class="flex w-full flex-col gap-2 text-sm font-semibold"
optionValue="value"
optionTextValue="label"
sameWidth
validationState={props.error ? "invalid" : "valid"}
itemComponent={(props) => (
<KSelect.Item
item={props.item}
class="flex w-full justify-between rounded-lg p-1 hover:bg-m-grey-800"
>
<Show when={props.item.rawValue.label}>
<KSelect.ItemLabel>
{props.item.rawValue.label}
</KSelect.ItemLabel>
</Show>
<KSelect.ItemIndicator>
<img
src={check}
alt="check"
height={20}
width={20}
/>
</KSelect.ItemIndicator>
</KSelect.Item>
)}
>
<Show when={props.label}>
<KSelect.Label class="uppercase">
{props.label}
</KSelect.Label>
</Show>
<KSelect.HiddenSelect {...selectProps} />
<KSelect.Trigger
class="flex w-full justify-between rounded-lg bg-white/10 p-2 text-base font-normal text-neutral-400"
aria-label="selectField"
>
<KSelect.Value<Currency>>
{(state) => state.selectedOption().label}
</KSelect.Value>
<KSelect.Icon class="text-white" />
</KSelect.Trigger>
<Show when={props.caption}>
<KSelect.Description class="text-sm font-normal text-neutral-400">
{props.caption}
</KSelect.Description>
</Show>
<KSelect.Portal>
<KSelect.Content>
<KSelect.Listbox class="w-full cursor-default rounded-lg bg-[#0a0a0a] p-2" />
</KSelect.Content>
</KSelect.Portal>
<KSelect.ErrorMessage class="text-m-red">
{props.error}
</KSelect.ErrorMessage>
</KSelect.Root>
</div>
);
}

View File

@@ -6,6 +6,7 @@ export * from "./Linkify";
export * from "./Misc"; export * from "./Misc";
export * from "./ProgressBar"; export * from "./ProgressBar";
export * from "./Radio"; export * from "./Radio";
export * from "./SelectField";
export * from "./TextField"; export * from "./TextField";
export * from "./ExternalLink"; export * from "./ExternalLink";
export * from "./LoadingSpinner"; export * from "./LoadingSpinner";

View File

@@ -5,7 +5,6 @@ export default {
home: "Home", home: "Home",
sats: "SATS", sats: "SATS",
sat: "SAT", sat: "SAT",
usd: "USD",
fee: "Fee", fee: "Fee",
send: "Send", send: "Send",
receive: "Receive", receive: "Receive",
@@ -185,10 +184,10 @@ export default {
settings: { settings: {
header: "Settings", header: "Settings",
support: "Learn how to support Mutiny", support: "Learn how to support Mutiny",
general: "GENERAL",
beta_features: "BETA FEATURES", beta_features: "BETA FEATURES",
debug_tools: "DEBUG TOOLS", debug_tools: "DEBUG TOOLS",
danger_zone: "Danger zone", danger_zone: "Danger zone",
general: "General",
admin: { admin: {
title: "Admin Page", title: "Admin Page",
caption: "Our internal debug tools. Use wisely!", caption: "Our internal debug tools. Use wisely!",
@@ -345,6 +344,17 @@ export default {
forgot_password_link: "Forgot Password?", forgot_password_link: "Forgot Password?",
error_wrong_password: "Invalid Password" error_wrong_password: "Invalid Password"
}, },
currency: {
title: "Currency",
caption: "Choose your preferred currency pair",
select_currency: "Select Currency",
select_currency_label: "Currency Pair",
select_currency_caption:
"Choosing a new currency will resync the wallet to fetch a price update",
request_currency_support_link:
"Request support for more currencies",
error_unsupported_currency: "Please Select a supported currency."
},
lnurl_auth: { lnurl_auth: {
title: "LNURL Auth", title: "LNURL Auth",
auth: "Auth", auth: "Auth",

View File

@@ -0,0 +1,35 @@
import {
BackLink,
Card,
ChooseCurrency,
DefaultMain,
LargeHeader,
MutinyWalletGuard,
NavBar,
SafeArea
} from "~/components";
import { useI18n } from "~/i18n/context";
export default function Currency() {
const i18n = useI18n();
return (
<MutinyWalletGuard>
<SafeArea>
<DefaultMain>
<BackLink
href="/settings"
title={i18n.t("settings.header")}
/>
<LargeHeader>
{i18n.t("settings.currency.title")}
</LargeHeader>
<Card title={i18n.t("settings.currency.select_currency")}>
<ChooseCurrency />
</Card>
<div class="h-full" />
</DefaultMain>
<NavBar activeTab="settings" />
</SafeArea>
</MutinyWalletGuard>
);
}

View File

@@ -81,6 +81,11 @@ export default function Settings() {
<SettingsLinkList <SettingsLinkList
header={i18n.t("settings.general")} header={i18n.t("settings.general")}
links={[ links={[
{
href: "/settings/currency",
text: i18n.t("settings.currency.title"),
caption: i18n.t("settings.currency.caption")
},
{ {
href: "/settings/channels", href: "/settings/channels",
text: i18n.t("settings.channels.title") text: i18n.t("settings.channels.title")

View File

@@ -12,6 +12,7 @@ import {
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import { useSearchParams } from "solid-start"; import { useSearchParams } from "solid-start";
import { Currency, FIAT_OPTIONS } from "~/components/ChooseCurrency";
import { checkBrowserCompatibility } from "~/logic/browserCompatibility"; import { checkBrowserCompatibility } from "~/logic/browserCompatibility";
import { import {
doubleInitDefense, doubleInitDefense,
@@ -41,6 +42,7 @@ export type MegaStore = [
is_syncing?: boolean; is_syncing?: boolean;
last_sync?: number; last_sync?: number;
price: number; price: number;
fiat: Currency;
has_backed_up: boolean; has_backed_up: boolean;
wallet_loading: boolean; wallet_loading: boolean;
setup_error?: Error; setup_error?: Error;
@@ -74,6 +76,9 @@ export const Provider: ParentComponent = (props) => {
deleting: false, deleting: false,
scan_result: undefined as ParsedParams | undefined, scan_result: undefined as ParsedParams | undefined,
price: 0, price: 0,
fiat: localStorage.getItem("fiat_currency")
? (JSON.parse(localStorage.getItem("fiat_currency")!) as Currency)
: FIAT_OPTIONS[1],
has_backed_up: localStorage.getItem("has_backed_up") === "true", has_backed_up: localStorage.getItem("has_backed_up") === "true",
balance: undefined as MutinyBalance | undefined, balance: undefined as MutinyBalance | undefined,
last_sync: undefined as number | undefined, last_sync: undefined as number | undefined,
@@ -209,14 +214,37 @@ export const Provider: ParentComponent = (props) => {
try { try {
if (state.mutiny_wallet && !state.is_syncing) { if (state.mutiny_wallet && !state.is_syncing) {
setState({ is_syncing: true }); setState({ is_syncing: true });
let price: number;
const newBalance = await state.mutiny_wallet?.get_balance(); const newBalance = await state.mutiny_wallet?.get_balance();
const price = if (state.fiat.value === "BTC") {
await state.mutiny_wallet?.get_bitcoin_price(); setState({
setState({ balance: newBalance,
balance: newBalance, last_sync: Date.now(),
last_sync: Date.now(), price: 1,
price: price || 0 fiat: state.fiat
}); });
} else {
try {
price =
await state.mutiny_wallet?.get_bitcoin_price(
state.fiat.value.toLowerCase() || "usd"
);
setState({
balance: newBalance,
last_sync: Date.now(),
price: price || 0,
fiat: state.fiat
});
} catch (e) {
price = 1;
setState({
balance: newBalance,
last_sync: Date.now(),
price: price,
fiat: FIAT_OPTIONS[0]
});
}
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@@ -1,9 +1,18 @@
import { MutinyWallet } from "@mutinywallet/mutiny-wasm"; import { MutinyWallet } from "@mutinywallet/mutiny-wasm";
export function satsToUsd( import { Currency } from "~/components/ChooseCurrency";
/** satsToFiat
* returns a toLocaleString() based on the bitcoin price in the chosen currency
* @param {number} amount - Takes a number as a string to parse the formatted value
* @param {number} price - Finds the Price from the megaStore state
* @param {Currency} fiat - Takes {@link Currency} object options to determine how to format the amount input
*/
export function satsToFiat(
amount: number | undefined, amount: number | undefined,
price: number, price: number,
formatted: boolean fiat: Currency
): string { ): string {
if (typeof amount !== "number" || isNaN(amount)) { if (typeof amount !== "number" || isNaN(amount)) {
return ""; return "";
@@ -12,21 +21,16 @@ export function satsToUsd(
const btc = MutinyWallet.convert_sats_to_btc( const btc = MutinyWallet.convert_sats_to_btc(
BigInt(Math.floor(amount)) BigInt(Math.floor(amount))
); );
const usd = btc * price; const fiatPrice = btc * price;
const roundedFiat = Math.round(fiatPrice);
if (formatted) { if (
return usd.toLocaleString("en-US", { (fiat.value !== "BTC" &&
style: "currency", roundedFiat * 100 === Math.round(fiatPrice * 100)) ||
currency: "USD" fiatPrice === 0
}); ) {
return fiatPrice.toFixed(0);
} else { } else {
// Some float fighting shenaningans return fiatPrice.toFixed(fiat.maxFractionalDigits);
const roundedUsd = Math.round(usd);
if (roundedUsd * 100 === Math.round(usd * 100)) {
return usd.toFixed(0);
} else {
return usd.toFixed(2);
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -34,7 +38,51 @@ export function satsToUsd(
} }
} }
export function usdToSats( /** satsToFormattedFiat
* returns a toLocaleString() based on the bitcoin price in the chosen currency with its appropriate currency prefix
* @param {number} amount - Takes a number as a string to parse the formatted value
* @param {number} price - Finds the Price from the megaStore state
* @param {Currency} fiat - Takes {@link Currency} object options to determine how to format the amount input
*/
export function satsToFormattedFiat(
amount: number | undefined,
price: number,
fiat: Currency
): string {
if (typeof amount !== "number" || isNaN(amount)) {
return "";
}
try {
const btc = MutinyWallet.convert_sats_to_btc(
BigInt(Math.floor(amount))
);
const fiatPrice = btc * price;
//Handles currencies not supported by .toLocaleString() like BTC
//Returns a string with a currency symbol and a number with decimals equal to the maxFractionalDigits
if (fiat.hasSymbol) {
return (
fiat.hasSymbol +
fiatPrice.toLocaleString(navigator.languages[0] || "en-US", {
minimumFractionDigits:
fiatPrice === 0 ? 0 : fiat.maxFractionalDigits,
maximumFractionDigits: fiat.maxFractionalDigits
})
);
//Handles currencies with no symbol only an ISO code
} else {
return fiatPrice.toLocaleString(navigator.languages[0], {
minimumFractionDigits:
fiatPrice === 0 ? 0 : fiat.maxFractionalDigits
});
}
} catch (e) {
console.error(e);
return "";
}
}
export function fiatToSats(
amount: number | undefined, amount: number | undefined,
price: number, price: number,
formatted: boolean formatted: boolean