manage account instead of add account

This commit is contained in:
Sebastian 2022-10-28 13:39:06 -03:00
parent 7c33040ae3
commit fe6e9be702
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
15 changed files with 865 additions and 360 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>

After

Width:  |  Height:  |  Size: 188 B

View File

@ -1,29 +0,0 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { createExample } from "../../test-utils.js";
import { ReadyView } from "./views.js";
export default {
title: "example",
};
export const Ready = createExample(ReadyView, {});

View File

@ -1,249 +0,0 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { parsePaytoUri } from "@gnu-taler/taler-util";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { ErrorMessage } from "../../components/ErrorMessage.js";
import { LoadingError } from "../../components/LoadingError.js";
import { SelectList } from "../../components/SelectList.js";
import { Input, LightText, SubTitle } from "../../components/styled/index.js";
import { useTranslationContext } from "../../context/translation.js";
import { Button } from "../../mui/Button.js";
import { TextFieldHandler } from "../../mui/handlers.js";
import { TextField } from "../../mui/TextField.js";
import { State } from "./index.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext();
return (
<LoadingError
title={<i18n.Translate>Could not load</i18n.Translate>}
error={error}
/>
);
}
export function ReadyView({
currency,
error,
accountType,
alias,
onAccountAdded,
onCancel,
uri,
}: State.Ready): VNode {
const { i18n } = useTranslationContext();
return (
<Fragment>
<section>
<SubTitle>
<i18n.Translate>Add bank account for {currency}</i18n.Translate>
</SubTitle>
<LightText>
<i18n.Translate>
Enter the URL of an exchange you trust.
</i18n.Translate>
</LightText>
{error && (
<ErrorMessage
title={<i18n.Translate>Unable add this account</i18n.Translate>}
description={error}
/>
)}
<p>
<Input>
<SelectList
label={<i18n.Translate>Select account type</i18n.Translate>}
list={accountType.list}
name="accountType"
value={accountType.value}
onChange={accountType.onChange}
/>
</Input>
</p>
{accountType.value === "" ? undefined : (
<Fragment>
<p>
<CustomFieldByAccountType type={accountType.value} field={uri} />
</p>
<p>
<TextField
label="Account alias"
variant="standard"
required
fullWidth
disabled={accountType.value === ""}
value={alias.value}
onChange={alias.onInput}
/>
</p>
</Fragment>
)}
</section>
<footer>
<Button
variant="contained"
color="secondary"
onClick={onCancel.onClick}
>
<i18n.Translate>Cancel</i18n.Translate>
</Button>
<Button
variant="contained"
onClick={onAccountAdded.onClick}
disabled={!onAccountAdded.onClick}
>
<i18n.Translate>Add</i18n.Translate>
</Button>
</footer>
</Fragment>
);
}
function BitcoinAddressAccount({ field }: { field: TextFieldHandler }): VNode {
const { i18n } = useTranslationContext();
const [value, setValue] = useState<string | undefined>(undefined);
const errors = undefinedIfEmpty({
value: !value ? i18n.str`Can't be empty` : undefined,
});
return (
<Fragment>
<TextField
label="Bitcoin address"
variant="standard"
fullWidth
value={value}
error={value !== undefined && !!errors?.value}
onChange={(v) => {
setValue(v);
if (!errors) {
field.onInput(`payto://bitcoin/${value}`);
}
}}
/>
{value !== undefined && errors?.value && (
<ErrorMessage title={<span>{errors?.value}</span>} />
)}
</Fragment>
);
}
function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
? obj
: undefined;
}
function TalerBankAddressAccount({
field,
}: {
field: TextFieldHandler;
}): VNode {
const { i18n } = useTranslationContext();
const [host, setHost] = useState<string | undefined>(undefined);
const [account, setAccount] = useState<string | undefined>(undefined);
const errors = undefinedIfEmpty({
host: !host ? i18n.str`Can't be empty` : undefined,
account: !account ? i18n.str`Can't be empty` : undefined,
});
return (
<Fragment>
<TextField
label="Bank host"
variant="standard"
fullWidth
value={host}
error={host !== undefined && !!errors?.host}
onChange={(v) => {
setHost(v);
if (!errors) {
field.onInput(`payto://x-taler-bank/${host}/${account}`);
}
}}
/>{" "}
{host !== undefined && errors?.host && (
<ErrorMessage title={<span>{errors?.host}</span>} />
)}
<TextField
label="Bank account"
variant="standard"
fullWidth
value={account}
error={account !== undefined && !!errors?.account}
onChange={(v) => {
setAccount(v || "");
if (!errors) {
field.onInput(`payto://x-taler-bank/${host}/${account}`);
}
}}
/>{" "}
{account !== undefined && errors?.account && (
<ErrorMessage title={<span>{errors?.account}</span>} />
)}
</Fragment>
);
}
function IbanAddressAccount({ field }: { field: TextFieldHandler }): VNode {
const { i18n } = useTranslationContext();
const [value, setValue] = useState<string | undefined>(undefined);
const errors = undefinedIfEmpty({
value: !value ? i18n.str`Can't be empty` : undefined,
});
return (
<Fragment>
<TextField
label="IBAN number"
variant="standard"
fullWidth
value={value}
error={value !== undefined && !!errors?.value}
onChange={(v) => {
setValue(v);
if (!errors) {
field.onInput(`payto://iba/${value}`);
}
}}
/>
{value !== undefined && errors?.value && (
<ErrorMessage title={<span>{errors?.value}</span>} />
)}
</Fragment>
);
}
function CustomFieldByAccountType({
type,
field,
}: {
type: string;
field: TextFieldHandler;
}): VNode {
if (type === "bitcoin") {
return <BitcoinAddressAccount field={field} />;
}
if (type === "x-taler-bank") {
return <TalerBankAddressAccount field={field} />;
}
if (type === "iban") {
return <IbanAddressAccount field={field} />;
}
return <Fragment />;
}

View File

@ -24,7 +24,7 @@ import {
} from "../../mui/handlers.js"; } from "../../mui/handlers.js";
import { compose, StateViewMap } from "../../utils/index.js"; import { compose, StateViewMap } from "../../utils/index.js";
import { wxApi } from "../../wxApi.js"; import { wxApi } from "../../wxApi.js";
import { AddAccountPage } from "../AddAccount/index.js"; import { ManageAccountPage } from "../ManageAccount/index.js";
import { useComponentState } from "./state.js"; import { useComponentState } from "./state.js";
import { import {
AmountOrCurrencyErrorView, AmountOrCurrencyErrorView,
@ -62,7 +62,7 @@ export namespace State {
} }
export interface AddingAccount { export interface AddingAccount {
status: "adding-account"; status: "manage-account";
error: undefined; error: undefined;
currency: string; currency: string;
onAccountAdded: (p: string) => void; onAccountAdded: (p: string) => void;
@ -94,7 +94,7 @@ export namespace State {
error: undefined; error: undefined;
currency: string; currency: string;
selectedAccount: PaytoUri | undefined; currentAccount: PaytoUri;
totalFee: AmountJson; totalFee: AmountJson;
totalToDeposit: AmountJson; totalToDeposit: AmountJson;
@ -112,7 +112,7 @@ const viewMapping: StateViewMap<State> = {
"amount-or-currency-error": AmountOrCurrencyErrorView, "amount-or-currency-error": AmountOrCurrencyErrorView,
"no-enough-balance": NoEnoughBalanceView, "no-enough-balance": NoEnoughBalanceView,
"no-accounts": NoAccountToDepositView, "no-accounts": NoAccountToDepositView,
"adding-account": AddAccountPage, "manage-account": ManageAccountPage,
ready: ReadyView, ready: ReadyView,
}; };

View File

@ -50,9 +50,7 @@ export function useComponentState(
// const [accountIdx, setAccountIdx] = useState<number>(0); // const [accountIdx, setAccountIdx] = useState<number>(0);
const [amount, setAmount] = useState(initialValue); const [amount, setAmount] = useState(initialValue);
const [selectedAccount, setSelectedAccount] = useState< const [selectedAccount, setSelectedAccount] = useState<PaytoUri>();
PaytoUri | undefined
>();
const [fee, setFee] = useState<DepositGroupFees | undefined>(undefined); const [fee, setFee] = useState<DepositGroupFees | undefined>(undefined);
const [addingAccount, setAddingAccount] = useState(false); const [addingAccount, setAddingAccount] = useState(false);
@ -82,7 +80,7 @@ export function useComponentState(
if (addingAccount) { if (addingAccount) {
return { return {
status: "adding-account", status: "manage-account",
error: undefined, error: undefined,
currency, currency,
onAccountAdded: (p: string) => { onAccountAdded: (p: string) => {
@ -92,6 +90,7 @@ export function useComponentState(
}, },
onCancel: () => { onCancel: () => {
setAddingAccount(false); setAddingAccount(false);
hook.retry();
}, },
}; };
} }
@ -122,13 +121,12 @@ export function useComponentState(
}, },
}; };
} }
const firstAccount = accounts[0].uri
const currentAccount = !selectedAccount ? firstAccount : selectedAccount;
const accountMap = createLabelsForBankAccount(accounts); const accountMap = createLabelsForBankAccount(accounts);
accountMap[""] = "Select one account...";
async function updateAccountFromList(accountStr: string): Promise<void> { async function updateAccountFromList(accountStr: string): Promise<void> {
// const newSelected = !accountMap[accountStr] ? undefined : accountMap[accountStr];
// if (!newSelected) return;
const uri = !accountStr ? undefined : parsePaytoUri(accountStr); const uri = !accountStr ? undefined : parsePaytoUri(accountStr);
if (uri && parsedAmount) { if (uri && parsedAmount) {
try { try {
@ -136,7 +134,6 @@ export function useComponentState(
setSelectedAccount(uri); setSelectedAccount(uri);
setFee(result); setFee(result);
} catch (e) { } catch (e) {
console.error(e)
setSelectedAccount(uri); setSelectedAccount(uri);
setFee(undefined); setFee(undefined);
} }
@ -145,13 +142,12 @@ export function useComponentState(
async function updateAmount(numStr: string): Promise<void> { async function updateAmount(numStr: string): Promise<void> {
const parsed = Amounts.parse(`${currency}:${numStr}`); const parsed = Amounts.parse(`${currency}:${numStr}`);
if (parsed && selectedAccount) { if (parsed) {
try { try {
const result = await getFeeForAmount(selectedAccount, parsed, api); const result = await getFeeForAmount(currentAccount, parsed, api);
setAmount(numStr); setAmount(numStr);
setFee(result); setFee(result);
} catch (e) { } catch (e) {
console.error(e)
setAmount(numStr); setAmount(numStr);
setFee(undefined); setFee(undefined);
} }
@ -179,15 +175,14 @@ export function useComponentState(
const unableToDeposit = const unableToDeposit =
!parsedAmount || //no amount specified !parsedAmount || //no amount specified
selectedAccount === undefined || //no account selected
Amounts.isZero(totalToDeposit) || //deposit may be zero because of fee Amounts.isZero(totalToDeposit) || //deposit may be zero because of fee
fee === undefined || //no fee calculated yet fee === undefined || //no fee calculated yet
amountError !== undefined; //amount field may be invalid amountError !== undefined; //amount field may be invalid
async function doSend(): Promise<void> { async function doSend(): Promise<void> {
if (!selectedAccount || !parsedAmount || !currency) return; if (!parsedAmount || !currency) return;
const depositPaytoUri = `payto://${selectedAccount.targetType}/${selectedAccount.targetPath}`; const depositPaytoUri = `payto://${currentAccount.targetType}/${currentAccount.targetPath}`;
const amount = Amounts.stringify(parsedAmount); const amount = Amounts.stringify(parsedAmount);
await api.wallet.call(WalletApiOperation.CreateDepositGroup, { await api.wallet.call(WalletApiOperation.CreateDepositGroup, {
amount, depositPaytoUri amount, depositPaytoUri
@ -211,10 +206,10 @@ export function useComponentState(
}, },
account: { account: {
list: accountMap, list: accountMap,
value: !selectedAccount ? "" : stringifyPaytoUri(selectedAccount), value: stringifyPaytoUri(currentAccount),
onChange: updateAccountFromList, onChange: updateAccountFromList,
}, },
selectedAccount, currentAccount,
cancelHandler: { cancelHandler: {
onClick: async () => { onClick: async () => {
onCancel(currency); onCancel(currency);

View File

@ -55,6 +55,13 @@ export const WithNoAccountForIBAN = createExample(ReadyView, {
null; null;
}, },
}, },
currentAccount: {
isKnown: true,
targetType: "iban",
iban: "ABCD1234",
params: {},
targetPath: "/ABCD1234",
},
currency: "USD", currency: "USD",
amount: { amount: {
onInput: async () => { onInput: async () => {
@ -83,6 +90,13 @@ export const WithIBANAccountTypeSelected = createExample(ReadyView, {
null; null;
}, },
}, },
currentAccount: {
isKnown: true,
targetType: "iban",
iban: "ABCD1234",
params: {},
targetPath: "/ABCD1234",
},
currency: "USD", currency: "USD",
amount: { amount: {
onInput: async () => { onInput: async () => {
@ -111,6 +125,13 @@ export const NewBitcoinAccountTypeSelected = createExample(ReadyView, {
null; null;
}, },
}, },
currentAccount: {
isKnown: true,
targetType: "iban",
iban: "ABCD1234",
params: {},
targetPath: "/ABCD1234",
},
onAddAccount: {}, onAddAccount: {},
currency: "USD", currency: "USD",
amount: { amount: {

View File

@ -172,7 +172,7 @@ describe("DepositPage states", () => {
if (r.status !== "ready") expect.fail(); if (r.status !== "ready") expect.fail();
expect(r.cancelHandler.onClick).not.undefined; expect(r.cancelHandler.onClick).not.undefined;
expect(r.currency).eq(currency); expect(r.currency).eq(currency);
expect(r.account.value).eq(""); expect(r.account.value).eq(stringifyPaytoUri(ibanPayto.uri));
expect(r.amount.value).eq("0"); expect(r.amount.value).eq("0");
expect(r.depositHandler.onClick).undefined; expect(r.depositHandler.onClick).undefined;
} }
@ -195,7 +195,7 @@ describe("DepositPage states", () => {
}], }],
}) })
handler.addWalletCallResponse(WalletApiOperation.ListKnownBankAccounts, undefined, { handler.addWalletCallResponse(WalletApiOperation.ListKnownBankAccounts, undefined, {
accounts: [ibanPayto] accounts: [talerBankPayto, ibanPayto]
}); });
handler.addWalletCallResponse(WalletApiOperation.GetFeeForDeposit, undefined, withoutFee()) handler.addWalletCallResponse(WalletApiOperation.GetFeeForDeposit, undefined, withoutFee())
handler.addWalletCallResponse(WalletApiOperation.GetFeeForDeposit, undefined, withoutFee()) handler.addWalletCallResponse(WalletApiOperation.GetFeeForDeposit, undefined, withoutFee())
@ -221,7 +221,7 @@ describe("DepositPage states", () => {
if (r.status !== "ready") expect.fail(); if (r.status !== "ready") expect.fail();
expect(r.cancelHandler.onClick).not.undefined; expect(r.cancelHandler.onClick).not.undefined;
expect(r.currency).eq(currency); expect(r.currency).eq(currency);
expect(r.account.value).eq(""); expect(r.account.value).eq(stringifyPaytoUri(talerBankPayto.uri));
expect(r.amount.value).eq("0"); expect(r.amount.value).eq("0");
expect(r.depositHandler.onClick).undefined; expect(r.depositHandler.onClick).undefined;
expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
@ -328,7 +328,7 @@ describe("DepositPage states", () => {
}], }],
}) })
handler.addWalletCallResponse(WalletApiOperation.ListKnownBankAccounts, undefined, { handler.addWalletCallResponse(WalletApiOperation.ListKnownBankAccounts, undefined, {
accounts: [ibanPayto] accounts: [talerBankPayto, ibanPayto]
}); });
handler.addWalletCallResponse(WalletApiOperation.GetFeeForDeposit, undefined, withSomeFee()) handler.addWalletCallResponse(WalletApiOperation.GetFeeForDeposit, undefined, withSomeFee())
handler.addWalletCallResponse(WalletApiOperation.GetFeeForDeposit, undefined, withSomeFee()) handler.addWalletCallResponse(WalletApiOperation.GetFeeForDeposit, undefined, withSomeFee())
@ -353,7 +353,7 @@ describe("DepositPage states", () => {
if (r.status !== "ready") expect.fail(); if (r.status !== "ready") expect.fail();
expect(r.cancelHandler.onClick).not.undefined; expect(r.cancelHandler.onClick).not.undefined;
expect(r.currency).eq(currency); expect(r.currency).eq(currency);
expect(r.account.value).eq(""); expect(r.account.value).eq(stringifyPaytoUri(talerBankPayto.uri));
expect(r.amount.value).eq("0"); expect(r.amount.value).eq("0");
expect(r.depositHandler.onClick).undefined; expect(r.depositHandler.onClick).undefined;
expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`)); expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));

View File

@ -160,61 +160,55 @@ export function ReadyView(state: State.Ready): VNode {
variant="text" variant="text"
style={{ marginLeft: "auto" }} style={{ marginLeft: "auto" }}
> >
<i18n.Translate>Add another account</i18n.Translate> <i18n.Translate>Manage accounts</i18n.Translate>
</Button> </Button>
</div> </div>
{state.selectedAccount && ( <p>
<Fragment> <AccountDetails account={state.currentAccount} />
<p> </p>
<AccountDetails account={state.selectedAccount} /> <InputWithLabel invalid={!!state.amount.error}>
</p> <label>
<InputWithLabel invalid={!!state.amount.error}> <i18n.Translate>Amount</i18n.Translate>
<label> </label>
<i18n.Translate>Amount</i18n.Translate> <div>
</label> <span>{state.currency}</span>
<div> <input
<span>{state.currency}</span> type="number"
<input value={state.amount.value}
type="number" onInput={(e) => state.amount.onInput(e.currentTarget.value)}
value={state.amount.value} />
onInput={(e) => state.amount.onInput(e.currentTarget.value)} </div>
/> {state.amount.error && <ErrorText>{state.amount.error}</ErrorText>}
</div> </InputWithLabel>
{state.amount.error && (
<ErrorText>{state.amount.error}</ErrorText>
)}
</InputWithLabel>
<InputWithLabel> <InputWithLabel>
<label> <label>
<i18n.Translate>Deposit fee</i18n.Translate> <i18n.Translate>Deposit fee</i18n.Translate>
</label> </label>
<div> <div>
<span>{state.currency}</span> <span>{state.currency}</span>
<input <input
type="number" type="number"
disabled disabled
value={Amounts.stringifyValue(state.totalFee)} value={Amounts.stringifyValue(state.totalFee)}
/> />
</div> </div>
</InputWithLabel> </InputWithLabel>
<InputWithLabel> <InputWithLabel>
<label> <label>
<i18n.Translate>Total deposit</i18n.Translate> <i18n.Translate>Total deposit</i18n.Translate>
</label> </label>
<div> <div>
<span>{state.currency}</span> <span>{state.currency}</span>
<input <input
type="number" type="number"
disabled disabled
value={Amounts.stringifyValue(state.totalToDeposit)} value={Amounts.stringifyValue(state.totalToDeposit)}
/> />
</div> </div>
</InputWithLabel> </InputWithLabel>
</Fragment>
)}
</section> </section>
<footer> <footer>
<Button <Button

View File

@ -14,6 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { KnownBankAccountsInfo } from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js"; import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js"; import { HookError } from "../../hooks/useAsyncAsHook.js";
import { import {
@ -57,17 +58,23 @@ export namespace State {
alias: TextFieldHandler; alias: TextFieldHandler;
onAccountAdded: ButtonHandler; onAccountAdded: ButtonHandler;
onCancel: ButtonHandler; onCancel: ButtonHandler;
accountByType: AccountByType,
deleteAccount: (a: KnownBankAccountsInfo) => Promise<void>,
} }
} }
export type AccountByType = {
[key: string]: KnownBankAccountsInfo[]
};
const viewMapping: StateViewMap<State> = { const viewMapping: StateViewMap<State> = {
loading: Loading, loading: Loading,
"loading-error": LoadingUriView, "loading-error": LoadingUriView,
ready: ReadyView, ready: ReadyView,
}; };
export const AddAccountPage = compose( export const ManageAccountPage = compose(
"AddAccount", "ManageAccountPage",
(p: Props) => useComponentState(p, wxApi), (p: Props) => useComponentState(p, wxApi),
viewMapping, viewMapping,
); );

View File

@ -14,12 +14,12 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; import { KnownBankAccountsInfo, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
import { wxApi } from "../../wxApi.js"; import { wxApi } from "../../wxApi.js";
import { Props, State } from "./index.js"; import { AccountByType, Props, State } from "./index.js";
export function useComponentState( export function useComponentState(
{ currency, onAccountAdded, onCancel }: Props, { currency, onAccountAdded, onCancel }: Props,
@ -45,10 +45,10 @@ export function useComponentState(
} }
const accountType: Record<string, string> = { const accountType: Record<string, string> = {
"": "Choose one account", "": "Choose one account type",
iban: "IBAN", iban: "IBAN",
bitcoin: "Bitcoin", // bitcoin: "Bitcoin",
"x-taler-bank": "Taler Bank", // "x-taler-bank": "Taler Bank",
}; };
const uri = parsePaytoUri(payto); const uri = parsePaytoUri(payto);
const found = const found =
@ -73,6 +73,24 @@ export function useComponentState(
const unableToAdd = !type || !alias || !!paytoUriError || !uri; const unableToAdd = !type || !alias || !!paytoUriError || !uri;
const accountByType: AccountByType = {
iban: [],
bitcoin: [],
"x-taler-bank": [],
}
hook.response.accounts.forEach(acc => {
accountByType[acc.uri.targetType].push(acc)
});
async function deleteAccount(account: KnownBankAccountsInfo): Promise<void> {
const payto = stringifyPaytoUri(account.uri);
await api.wallet.call(WalletApiOperation.ForgetKnownBankAccounts, {
payto
})
hook?.retry()
}
return { return {
status: "ready", status: "ready",
error: undefined, error: undefined,
@ -97,6 +115,8 @@ export function useComponentState(
setPayto(v); setPayto(v);
}, },
}, },
accountByType,
deleteAccount,
onAccountAdded: { onAccountAdded: {
onClick: unableToAdd ? undefined : addAccount, onClick: unableToAdd ? undefined : addAccount,
}, },

View File

@ -0,0 +1,208 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { createExample } from "../../test-utils.js";
import { ReadyView } from "./views.js";
export default {
title: "wallet/manage account",
};
const nullFunction = async () => {
null;
};
export const JustTwoBitcoinAccounts = createExample(ReadyView, {
status: "ready",
currency: "ARS",
accountType: {
list: {
"": "Choose one account type",
iban: "IBAN",
// bitcoin: "Bitcoin",
// "x-taler-bank": "Taler Bank",
},
value: "",
},
alias: {
value: "",
onInput: nullFunction,
},
uri: {
value: "",
onInput: nullFunction,
},
accountByType: {
iban: [],
"x-taler-bank": [],
bitcoin: [
{
alias: "my bitcoin addr",
currency: "BTC",
kyc_completed: false,
uri: {
targetType: "bitcoin",
segwitAddrs: [],
isKnown: true,
targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
params: {},
},
},
{
alias: "my other addr",
currency: "BTC",
kyc_completed: true,
uri: {
targetType: "bitcoin",
segwitAddrs: [],
isKnown: true,
targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
params: {},
},
},
],
},
onAccountAdded: {},
onCancel: {},
});
export const WithAllTypeOfAccounts = createExample(ReadyView, {
status: "ready",
currency: "ARS",
accountType: {
list: {
"": "Choose one account type",
iban: "IBAN",
// bitcoin: "Bitcoin",
// "x-taler-bank": "Taler Bank",
},
value: "",
},
alias: {
value: "",
onInput: nullFunction,
},
uri: {
value: "",
onInput: nullFunction,
},
accountByType: {
iban: [
{
alias: "my bank",
currency: "ARS",
kyc_completed: true,
uri: {
targetType: "iban",
iban: "ASDQWEQWE",
isKnown: true,
targetPath: "/ASDQWEQWE",
params: {},
},
},
],
"x-taler-bank": [
{
alias: "my xtaler bank",
currency: "ARS",
kyc_completed: true,
uri: {
targetType: "x-taler-bank",
host: "localhost",
account: "123",
isKnown: true,
targetPath: "localhost/123",
params: {},
},
},
],
bitcoin: [
{
alias: "my bitcoin addr",
currency: "BTC",
kyc_completed: false,
uri: {
targetType: "bitcoin",
segwitAddrs: [],
isKnown: true,
targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
params: {},
},
},
{
alias: "my other addr",
currency: "BTC",
kyc_completed: true,
uri: {
targetType: "bitcoin",
segwitAddrs: [],
isKnown: true,
targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
params: {},
},
},
],
},
onAccountAdded: {},
onCancel: {},
});
export const AddingIbanAccount = createExample(ReadyView, {
status: "ready",
currency: "ARS",
accountType: {
list: {
"": "Choose one account type",
iban: "IBAN",
// bitcoin: "Bitcoin",
// "x-taler-bank": "Taler Bank",
},
value: "iban",
},
alias: {
value: "",
onInput: nullFunction,
},
uri: {
value: "",
onInput: nullFunction,
},
accountByType: {
iban: [
{
alias: "my bank",
currency: "ARS",
kyc_completed: true,
uri: {
targetType: "iban",
iban: "ASDQWEQWE",
isKnown: true,
targetPath: "/ASDQWEQWE",
params: {},
},
},
],
"x-taler-bank": [],
bitcoin: [],
},
onAccountAdded: {},
onCancel: {},
});

View File

@ -0,0 +1,534 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
KnownBankAccountsInfo,
PaytoUriBitcoin,
PaytoUriIBAN,
PaytoUriTalerBank,
} from "@gnu-taler/taler-util";
import { styled } from "@linaria/react";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { ErrorMessage } from "../../components/ErrorMessage.js";
import { LoadingError } from "../../components/LoadingError.js";
import { SelectList } from "../../components/SelectList.js";
import {
Input,
LightText,
SubTitle,
SvgIcon,
WarningText,
} from "../../components/styled/index.js";
import { useTranslationContext } from "../../context/translation.js";
import { Button } from "../../mui/Button.js";
import { TextFieldHandler } from "../../mui/handlers.js";
import { TextField } from "../../mui/TextField.js";
import checkIcon from "../../svg/check_24px.svg";
import warningIcon from "../../svg/warning_24px.svg";
import deleteIcon from "../../svg/delete_24px.svg";
import { State } from "./index.js";
type AccountType = "bitcoin" | "x-taler-bank" | "iban";
type ComponentFormByAccountType = {
[type in AccountType]: (props: { field: TextFieldHandler }) => VNode;
};
type ComponentListByAccountType = {
[type in AccountType]: (props: {
list: KnownBankAccountsInfo[];
onDelete: (a: KnownBankAccountsInfo) => Promise<void>;
}) => VNode;
};
const formComponentByAccountType: ComponentFormByAccountType = {
iban: IbanAddressAccount,
bitcoin: BitcoinAddressAccount,
"x-taler-bank": TalerBankAddressAccount,
};
const tableComponentByAccountType: ComponentListByAccountType = {
iban: IbanTable,
bitcoin: BitcoinTable,
"x-taler-bank": TalerBankTable,
};
const AccountTable = styled.table`
width: 100%;
border-collapse: separate;
border-spacing: 0px 10px;
tbody tr:nth-child(odd) > td:not(.actions, .kyc) {
background-color: lightgrey;
}
.actions,
.kyc {
width: 10px;
background-color: inherit;
}
`;
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext();
return (
<LoadingError
title={<i18n.Translate>Could not load</i18n.Translate>}
error={error}
/>
);
}
export function ReadyView({
currency,
error,
accountType,
accountByType,
alias,
onAccountAdded,
deleteAccount,
onCancel,
uri,
}: State.Ready): VNode {
const { i18n } = useTranslationContext();
return (
<Fragment>
<section>
<SubTitle>
<i18n.Translate>Known accounts for {currency}</i18n.Translate>
</SubTitle>
<p>
<i18n.Translate>
To add a new account first select the account type.
</i18n.Translate>
</p>
{error && (
<ErrorMessage
title={<i18n.Translate>Unable add this account</i18n.Translate>}
description={error}
/>
)}
<p>
<Input>
<SelectList
label={<i18n.Translate>Select account type</i18n.Translate>}
list={accountType.list}
name="accountType"
value={accountType.value}
onChange={accountType.onChange}
/>
</Input>
</p>
{accountType.value === "" ? undefined : (
<Fragment>
<p>
<CustomFieldByAccountType
type={accountType.value as AccountType}
field={uri}
/>
</p>
<p>
<TextField
label="Account alias"
variant="standard"
required
fullWidth
disabled={accountType.value === ""}
value={alias.value}
onChange={alias.onInput}
/>
</p>
</Fragment>
)}
</section>
<section>
<Button
variant="contained"
color="secondary"
onClick={onCancel.onClick}
>
<i18n.Translate>Cancel</i18n.Translate>
</Button>
<Button
variant="contained"
onClick={onAccountAdded.onClick}
disabled={!onAccountAdded.onClick}
>
<i18n.Translate>Add</i18n.Translate>
</Button>
</section>
<section>
{Object.entries(accountByType).map(([type, list]) => {
const Table = tableComponentByAccountType[type as AccountType];
return <Table key={type} list={list} onDelete={deleteAccount} />;
})}
</section>
</Fragment>
);
}
function IbanTable({
list,
onDelete,
}: {
list: KnownBankAccountsInfo[];
onDelete: (ac: KnownBankAccountsInfo) => void;
}): VNode {
const { i18n } = useTranslationContext();
if (list.length === 0) return <Fragment />;
return (
<div>
<h1>
<i18n.Translate>IBAN accounts</i18n.Translate>
</h1>
<AccountTable>
<thead>
<tr>
<th>
<i18n.Translate>Alias</i18n.Translate>
</th>
<th>
<i18n.Translate>Int. Account Number</i18n.Translate>
</th>
<th class="kyc">
<i18n.Translate>KYC</i18n.Translate>
</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{list.map((account) => {
const p = account.uri as PaytoUriIBAN;
return (
<tr key={account.alias}>
<td>{account.alias}</td>
<td>{p.targetPath}</td>
<td class="kyc">
{account.kyc_completed ? (
<SvgIcon
title={i18n.str`KYC done`}
dangerouslySetInnerHTML={{ __html: checkIcon }}
color="green"
/>
) : (
<SvgIcon
title={i18n.str`KYC missing`}
dangerouslySetInnerHTML={{ __html: warningIcon }}
color="orange"
/>
)}
</td>
<td class="actions">
<Button
variant="outlined"
startIcon={deleteIcon}
size="small"
onClick={async () => onDelete(account)}
color="error"
>
Forget
</Button>
</td>
</tr>
);
})}
</tbody>
</AccountTable>
</div>
);
}
function TalerBankTable({
list,
onDelete,
}: {
list: KnownBankAccountsInfo[];
onDelete: (ac: KnownBankAccountsInfo) => void;
}): VNode {
const { i18n } = useTranslationContext();
if (list.length === 0) return <Fragment />;
return (
<div>
<h1>
<i18n.Translate>Taler accounts</i18n.Translate>
</h1>
<AccountTable>
<thead>
<tr>
<th>
<i18n.Translate>Alias</i18n.Translate>
</th>
<th>
<i18n.Translate>Host</i18n.Translate>
</th>
<th>
<i18n.Translate>Account</i18n.Translate>
</th>
<th class="kyc">
<i18n.Translate>KYC</i18n.Translate>
</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{list.map((account) => {
const p = account.uri as PaytoUriTalerBank;
return (
<tr key={account.alias}>
<td>{account.alias}</td>
<td>{p.host}</td>
<td>{p.account}</td>
<td class="kyc">
{account.kyc_completed ? (
<SvgIcon
title={i18n.str`KYC done`}
dangerouslySetInnerHTML={{ __html: checkIcon }}
color="green"
/>
) : (
<SvgIcon
title={i18n.str`KYC missing`}
dangerouslySetInnerHTML={{ __html: warningIcon }}
color="orange"
/>
)}
</td>
<td class="actions">
<Button
variant="outlined"
startIcon={deleteIcon}
size="small"
onClick={async () => onDelete(account)}
color="error"
>
Forget
</Button>
</td>
</tr>
);
})}
</tbody>
</AccountTable>
</div>
);
}
function BitcoinTable({
list,
onDelete,
}: {
list: KnownBankAccountsInfo[];
onDelete: (ac: KnownBankAccountsInfo) => void;
}): VNode {
const { i18n } = useTranslationContext();
if (list.length === 0) return <Fragment />;
return (
<div>
<h2>
<i18n.Translate>Bitcoin accounts</i18n.Translate>
</h2>
<AccountTable>
<thead>
<tr>
<th>
<i18n.Translate>Alias</i18n.Translate>
</th>
<th>
<i18n.Translate>Address</i18n.Translate>
</th>
<th class="kyc">
<i18n.Translate>KYC</i18n.Translate>
</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{list.map((account) => {
const p = account.uri as PaytoUriBitcoin;
return (
<tr key={account.alias}>
<td>{account.alias}</td>
<td>{p.targetPath}</td>
<td class="kyc">
{account.kyc_completed ? (
<SvgIcon
title={i18n.str`KYC done`}
dangerouslySetInnerHTML={{ __html: checkIcon }}
color="green"
/>
) : (
<SvgIcon
title={i18n.str`KYC missing`}
dangerouslySetInnerHTML={{ __html: warningIcon }}
color="orange"
/>
)}
</td>
<td class="actions">
<Button
variant="outlined"
startIcon={deleteIcon}
size="small"
onClick={async () => onDelete(account)}
color="error"
>
Forget
</Button>
</td>
</tr>
);
})}
</tbody>
</AccountTable>
</div>
);
}
function BitcoinAddressAccount({ field }: { field: TextFieldHandler }): VNode {
const { i18n } = useTranslationContext();
const [value, setValue] = useState<string | undefined>(undefined);
const errors = undefinedIfEmpty({
value: !value ? i18n.str`Can't be empty` : undefined,
});
return (
<Fragment>
<TextField
label="Bitcoin address"
variant="standard"
fullWidth
value={value}
error={value !== undefined && !!errors?.value}
onChange={(v) => {
setValue(v);
if (!errors) {
field.onInput(`payto://bitcoin/${v}`);
}
}}
/>
{value !== undefined && errors?.value && (
<ErrorMessage title={<span>{errors?.value}</span>} />
)}
</Fragment>
);
}
function undefinedIfEmpty<T extends object>(obj: T): T | undefined {
return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
? obj
: undefined;
}
function TalerBankAddressAccount({
field,
}: {
field: TextFieldHandler;
}): VNode {
const { i18n } = useTranslationContext();
const [host, setHost] = useState<string | undefined>(undefined);
const [account, setAccount] = useState<string | undefined>(undefined);
const errors = undefinedIfEmpty({
host: !host ? i18n.str`Can't be empty` : undefined,
account: !account ? i18n.str`Can't be empty` : undefined,
});
return (
<Fragment>
<TextField
label="Bank host"
variant="standard"
fullWidth
value={host}
error={host !== undefined && !!errors?.host}
onChange={(v) => {
setHost(v);
if (!errors) {
field.onInput(`payto://x-taler-bank/${v}/${account}`);
}
}}
/>{" "}
{host !== undefined && errors?.host && (
<ErrorMessage title={<span>{errors?.host}</span>} />
)}
<TextField
label="Bank account"
variant="standard"
fullWidth
value={account}
error={account !== undefined && !!errors?.account}
onChange={(v) => {
setAccount(v || "");
if (!errors) {
field.onInput(`payto://x-taler-bank/${host}/${v}`);
}
}}
/>{" "}
{account !== undefined && errors?.account && (
<ErrorMessage title={<span>{errors?.account}</span>} />
)}
</Fragment>
);
}
function IbanAddressAccount({ field }: { field: TextFieldHandler }): VNode {
const { i18n } = useTranslationContext();
const [value, setValue] = useState<string | undefined>(undefined);
const errors = undefinedIfEmpty({
value: !value ? i18n.str`Can't be empty` : undefined,
});
return (
<Fragment>
<TextField
label="IBAN number"
variant="standard"
fullWidth
value={value}
error={value !== undefined && !!errors?.value}
onChange={(v) => {
setValue(v);
if (!errors) {
field.onInput(`payto://iban/${v}`);
}
}}
/>
{value !== undefined && errors?.value && (
<ErrorMessage title={<span>{errors?.value}</span>} />
)}
</Fragment>
);
}
function CustomFieldByAccountType({
type,
field,
}: {
type: AccountType;
field: TextFieldHandler;
}): VNode {
const { i18n } = useTranslationContext();
const AccountForm = formComponentByAccountType[type];
return (
<div>
<WarningText>
<i18n.Translate>
We can not validate the account so make sure the value is correct.
</i18n.Translate>
</WarningText>
<AccountForm field={field} />
</div>
);
}

View File

@ -37,6 +37,7 @@ import * as a16 from "./DeveloperPage.stories.js";
import * as a17 from "./QrReader.stories.js"; import * as a17 from "./QrReader.stories.js";
import * as a18 from "./DestinationSelection.stories.js"; import * as a18 from "./DestinationSelection.stories.js";
import * as a19 from "./ExchangeSelection/stories.js"; import * as a19 from "./ExchangeSelection/stories.js";
import * as a20 from "./ManageAccount/stories.js";
export default [ export default [
a1, a1,
@ -57,4 +58,5 @@ export default [
a17, a17,
a18, a18,
a19, a19,
a20,
]; ];