Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support fiat input in dual currency field #3089

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8c1567b
feat: dual currency input supports fiat input
riccardobl Mar 16, 2024
87a8edc
feat: prefix dual currency input with main symbol, add currency to in…
riccardobl Mar 23, 2024
3bffba9
fix: amount in Satoshis -> amount in sats
riccardobl Apr 1, 2024
29c49b4
fix: attempt at making unit tests happy
riccardobl Apr 1, 2024
90512cf
Merge remote-tracking branch 'upstream/master' into fiatinput
pavanjoshi914 Apr 2, 2024
3f7f107
Merge remote-tracking branch 'upstream/master' into fiatinput
pavanjoshi914 Apr 2, 2024
96635c8
Merge branch 'fiatinput' of https://github.com/getAlby/lightning-brow…
pavanjoshi914 Apr 2, 2024
0394bb7
fix: fix some unit tests
riccardobl Apr 2, 2024
2eb1020
fix: dual currency input event wrapping
riccardobl Apr 3, 2024
5a214c9
fix: more unit tests fixing
riccardobl Apr 3, 2024
2e2e329
fix: currency toggle issue for default values, improve some naming
riccardobl Apr 10, 2024
ea1954f
fix: input value
riccardobl Apr 14, 2024
9cfa078
Merge remote-tracking branch 'upstream/master' into fiatinput
pavanjoshi914 Apr 18, 2024
278acaf
fix: naming, empty input, do not change component to uncontrolled whe…
riccardobl May 5, 2024
49bb31c
fix: fixes and improvements
riccardobl May 24, 2024
fd36df6
fix: light theme
riccardobl May 25, 2024
269f8a3
Merge remote-tracking branch 'upstream/master' into fiatinput
pavanjoshi914 May 30, 2024
5b75f43
fix: dualcurrency component test
pavanjoshi914 May 30, 2024
9ff8337
fix: clone target to avoid side-effects when changing value
riccardobl Jun 19, 2024
564f79e
fix: tests and implementation
riccardobl Jul 1, 2024
ba7cb69
Merge branch 'master' into fiatinput
bumi Jul 3, 2024
0cdba7b
fix: allow 4 decimals in fiat input and add some comments
riccardobl Jul 8, 2024
c49b925
fix: preset workaround
riccardobl Jul 14, 2024
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
9 changes: 3 additions & 6 deletions src/app/components/BudgetControl/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,22 @@ type Props = {
onRememberChange: ChangeEventHandler<HTMLInputElement>;
budget: string;
onBudgetChange: ChangeEventHandler<HTMLInputElement>;
fiatAmount: string;
disabled?: boolean;
showFiat?: boolean;
};

function BudgetControl({
remember,
onRememberChange,
budget,
onBudgetChange,
fiatAmount,
disabled = false,
showFiat = false,
}: Props) {
const { t } = useTranslation("components", {
keyPrefix: "budget_control",
});

const { t: tCommon } = useTranslation("common");

return (
<div className="mb-4">
<div className={`flex items-center`}>
Expand Down Expand Up @@ -60,12 +58,11 @@ function BudgetControl({

<div>
<DualCurrencyField
showFiat={showFiat}
autoFocus
fiatValue={fiatAmount}
id="budget"
min={0}
label={t("budget.label")}
placeholder={tCommon("sats", { count: 0 })}
value={budget}
onChange={onBudgetChange}
/>
Expand Down
3 changes: 3 additions & 0 deletions src/app/components/SitePreferences/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ jest.mock("~/app/context/SettingsContext", () => ({
getFormattedFiat: mockGetFiatValue,
getFormattedNumber: jest.fn(),
getFormattedSats: jest.fn(),
getCurrencyRate: jest.fn(() => 1),
getCurrencySymbol: jest.fn(() => "₿"),
getFormattedInCurrency: mockGetFiatValue,
}),
}));

Expand Down
21 changes: 2 additions & 19 deletions src/app/components/SitePreferences/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,12 @@ export type Props = {
};

function SitePreferences({ launcherType, allowance, onEdit, onDelete }: Props) {
const {
isLoading: isLoadingSettings,
settings,
getFormattedFiat,
} = useSettings();
const { isLoading: isLoadingSettings, settings } = useSettings();
const showFiat = !isLoadingSettings && settings.showFiat;
const { account } = useAccount();
const [modalIsOpen, setIsOpen] = useState(false);
const [budget, setBudget] = useState("");
const [lnurlAuth, setLnurlAuth] = useState(false);
const [fiatAmount, setFiatAmount] = useState("");

const [originalPermissions, setOriginalPermissions] = useState<
Permission[] | null
Expand Down Expand Up @@ -83,17 +78,6 @@ function SitePreferences({ launcherType, allowance, onEdit, onDelete }: Props) {
fetchPermissions();
}, [account?.id, allowance.id]);

useEffect(() => {
if (budget !== "" && showFiat) {
const getFiat = async () => {
const res = await getFormattedFiat(budget);
setFiatAmount(res);
};

getFiat();
}
}, [budget, showFiat, getFormattedFiat]);

function openModal() {
setBudget(allowance.totalBudget.toString());
setLnurlAuth(allowance.lnurlAuth);
Expand Down Expand Up @@ -238,10 +222,9 @@ function SitePreferences({ launcherType, allowance, onEdit, onDelete }: Props) {
label={t("new_budget.label")}
min={0}
autoFocus
placeholder={tCommon("sats", { count: 0 })}
value={budget}
hint={t("hint")}
fiatValue={fiatAmount}
showFiat={showFiat}
onChange={(e) => setBudget(e.target.value)}
/>
</div>
Expand Down
16 changes: 15 additions & 1 deletion src/app/components/form/DualCurrencyField/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { settingsFixture as mockSettings } from "~/../tests/fixtures/settings";

import type { Props } from "./index";
import DualCurrencyField from "./index";

const props: Props = {
fiatValue: "$10.00",
showFiat: true,
label: "Amount",
};
jest.mock("~/app/context/SettingsContext", () => ({
useSettings: () => ({
settings: mockSettings,
isLoading: false,
updateSetting: jest.fn(),
getFormattedFiat: jest.fn(() => "$10.00"),
getFormattedNumber: jest.fn(),
getFormattedSats: jest.fn(),
getCurrencyRate: jest.fn(() => 1),
getCurrencySymbol: jest.fn(() => "₿"),
getFormattedInCurrency: jest.fn(() => "$10.00"),
}),
}));

describe("DualCurrencyField", () => {
test("render", async () => {
Expand Down
193 changes: 178 additions & 15 deletions src/app/components/form/DualCurrencyField/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAccount } from "~/app/context/AccountContext";
import { useSettings } from "~/app/context/SettingsContext";
import { classNames } from "~/app/utils";

import { RangeLabel } from "./rangeLabel";

export type DualCurrencyFieldChangeEvent =
React.ChangeEvent<HTMLInputElement> & {
target: HTMLInputElement & {
valueInFiat: number;
formattedValueInFiat: string;
valueInSats: number;
formattedValueInSats: string;
};
};

export type Props = {
suffix?: string;
endAdornment?: React.ReactNode;
fiatValue: string;
label: string;
hint?: string;
amountExceeded?: boolean;
rangeExceeded?: boolean;
baseToAltRate?: number;
showFiat?: boolean;
onChange?: (e: DualCurrencyFieldChangeEvent) => void;
};

export default function DualCurrencyField({
label,
Copy link
Contributor

Choose a reason for hiding this comment

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

Now its a good enough complexity. maybe add comments wherere necessary why such and such thing is used

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've added some comments to the code and removed baseToAltRate since it was dead code from one of the previous iterations 👍

fiatValue,
showFiat = true,
id,
placeholder,
required = false,
Expand All @@ -38,10 +51,149 @@ export default function DualCurrencyField({
rangeExceeded,
}: React.InputHTMLAttributes<HTMLInputElement> & Props) {
const { t: tCommon } = useTranslation("common");
const {
getFormattedInCurrency,
getCurrencyRate,
getCurrencySymbol,
settings,
} = useSettings();
const { account } = useAccount();

const inputEl = useRef<HTMLInputElement>(null);
const outerStyles =
"rounded-md border border-gray-300 dark:border-gray-800 bg-white dark:bg-black transition duration-300";

const initialized = useRef(false);
const [useFiatAsMain, _setUseFiatAsMain] = useState(false);
const [altFormattedValue, setAltFormattedValue] = useState("");
const [minValue, setMinValue] = useState(min);
const [maxValue, setMaxValue] = useState(max);
const [inputValue, setInputValue] = useState(value || 0);
const [inputPrefix, setInputPrefix] = useState("");
riccardobl marked this conversation as resolved.
Show resolved Hide resolved
const [inputPlaceHolder, setInputPlaceHolder] = useState(placeholder || "");

const convertValues = useCallback(
async (inputValue: number, inputInFiat: boolean) => {
const userCurrency = settings?.currency || "BTC";
let valueInSats = 0;
let valueInFiat = 0;
const rate = await getCurrencyRate();

if (inputInFiat) {
valueInFiat = Number(inputValue);
valueInSats = Math.round(valueInFiat / rate);
} else {
valueInSats = Number(inputValue);
valueInFiat = Math.round(valueInSats * rate * 100) / 100.0;
}

const formattedSats = getFormattedInCurrency(valueInSats, "BTC");
const formattedFiat = getFormattedInCurrency(valueInFiat, userCurrency);

return {
valueInSats,
formattedSats,
valueInFiat,
formattedFiat,
};
},
[getCurrencyRate, getFormattedInCurrency, settings]
);

const setUseFiatAsMain = useCallback(
async (v: boolean) => {
if (!showFiat) v = false;
riccardobl marked this conversation as resolved.
Show resolved Hide resolved
const userCurrency = settings?.currency || "BTC";
const rate = await getCurrencyRate();

if (min) {
setMinValue(
v ? (Math.round(Number(min) * rate * 100) / 100.0).toString() : min
);
}

if (max) {
setMaxValue(
v ? (Math.round(Number(max) * rate * 100) / 100.0).toString() : max
);
}

const newValue = v
? Math.round(Number(inputValue) * rate * 100) / 100.0
: Math.round(Number(inputValue) / rate);

_setUseFiatAsMain(v);
setInputValue(newValue);
setInputPrefix(getCurrencySymbol(v ? userCurrency : "BTC"));
if (!placeholder) {
setInputPlaceHolder(
tCommon("amount_placeholder", {
currency: v ? userCurrency : "sats",
})
);
}
},
[
settings,
showFiat,
getCurrencyRate,
inputValue,
min,
max,
tCommon,
getCurrencySymbol,
placeholder,
]
);

const swapCurrencies = () => {
setUseFiatAsMain(!useFiatAsMain);
};

const onChangeWrapper = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);

if (onChange) {
const value = Number(e.target.value);
const { valueInSats, formattedSats, valueInFiat, formattedFiat } =
await convertValues(value, useFiatAsMain);
const wrappedEvent: DualCurrencyFieldChangeEvent =
e as DualCurrencyFieldChangeEvent;
wrappedEvent.target.value = valueInSats.toString();
wrappedEvent.target.valueInFiat = valueInFiat;
wrappedEvent.target.formattedValueInFiat = formattedFiat;
wrappedEvent.target.valueInSats = valueInSats;
wrappedEvent.target.formattedValueInSats = formattedSats;
onChange(wrappedEvent);
}
},
[onChange, useFiatAsMain, convertValues]
);

// default to fiat when account currency is set to anything other than BTC
useEffect(() => {
if (!initialized.current) {
if (account?.currency && account?.currency !== "BTC") {
setUseFiatAsMain(true);
}
initialized.current = true;
}
}, [account?.currency, setUseFiatAsMain]);

// update alt value
useEffect(() => {
(async () => {
if (showFiat) {
const { formattedSats, formattedFiat } = await convertValues(
Number(inputValue),
useFiatAsMain
);
setAltFormattedValue(useFiatAsMain ? formattedSats : formattedFiat);
}
})();
}, [useFiatAsMain, inputValue, convertValues, showFiat]);

const inputNode = (
<input
ref={inputEl}
Expand All @@ -53,19 +205,20 @@ export default function DualCurrencyField({
"block w-full placeholder-gray-500 dark:placeholder-gray-600 dark:text-white ",
"px-0 border-0 focus:ring-0 bg-transparent"
)}
placeholder={placeholder}
placeholder={inputPlaceHolder}
required={required}
pattern={pattern}
title={title}
onChange={onChange}
onChange={onChangeWrapper}
onFocus={onFocus}
onBlur={onBlur}
value={value}
value={inputValue ? inputValue : undefined}
autoFocus={autoFocus}
autoComplete={autoComplete}
disabled={disabled}
min={min}
max={max}
min={minValue}
max={maxValue}
step={useFiatAsMain ? "0.01" : "1"}
/>
Copy link
Contributor

Choose a reason for hiding this comment

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

what if we keep step value upto 4 decimal places. so that user can enter values for 10 20 sats in USD as well (it has no major use but just to keep it fully flexible)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

);

Expand All @@ -90,14 +243,15 @@ export default function DualCurrencyField({
>
{label}
</label>
{(min || max) && (
{(minValue || maxValue) && (
<span
className={classNames(
"text-xs text-gray-700 dark:text-neutral-400",
!!rangeExceeded && "text-red-500 dark:text-red-500"
)}
>
<RangeLabel min={min} max={max} /> {tCommon("sats_other")}
<RangeLabel min={minValue} max={maxValue} />{" "}
{useFiatAsMain ? "" : tCommon("sats_other")}
</span>
)}
</div>
Expand All @@ -112,17 +266,26 @@ export default function DualCurrencyField({
outerStyles
)}
>
{!!inputPrefix && (
<p className="helper text-gray-500 z-1 pr-2" onClick={swapCurrencies}>
{inputPrefix}
</p>
)}

{inputNode}

{!!fiatValue && (
<p className="helper text-gray-500 z-1 pointer-events-none">
~{fiatValue}
{!!altFormattedValue && (
<p
className="helper whitespace-nowrap text-gray-500 z-1"
onClick={swapCurrencies}
>
~{altFormattedValue}
</p>
)}

{suffix && (
<span
className="flex items-center px-3 font-medium bg-white dark:bg-surface-00dp dark:text-white"
className="flex items-center px-3 font-medium bg-white dark:bg-surface-00dp dark:text-white"
onClick={() => {
inputEl.current?.focus();
}}
Expand Down
Loading
Loading