new compose feature: sub-states

implemented in withdraw page, WIP
This commit is contained in:
Sebastian 2022-09-20 16:04:51 -03:00
parent a5525eab1e
commit 52ec740c82
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
16 changed files with 446 additions and 421 deletions

View File

@ -48,6 +48,7 @@ export namespace State {
} }
export interface Ready extends BaseInfo { export interface Ready extends BaseInfo {
status: "ready"; status: "ready";
doSelectExchange: ButtonHandler;
create: ButtonHandler; create: ButtonHandler;
subject: TextFieldHandler; subject: TextFieldHandler;
toBeReceived: AmountJson; toBeReceived: AmountJson;

View File

@ -84,6 +84,9 @@ export function useComponentState(
value: subject, value: subject,
onInput: async (e) => setSubject(e), onInput: async (e) => setSubject(e),
}, },
doSelectExchange: {
//FIX
},
invalid: !subject || Amounts.isZero(amount), invalid: !subject || Amounts.isZero(amount),
exchangeUrl: selected.exchangeBaseUrl, exchangeUrl: selected.exchangeBaseUrl,
create: { create: {

View File

@ -37,6 +37,9 @@ export const Ready = createExample(ReadyView, {
currency: "ARS", currency: "ARS",
value: 1, value: 1,
fraction: 0, fraction: 0,
},
doSelectExchange: {
}, },
exchangeUrl: "https://exchange.taler.ar", exchangeUrl: "https://exchange.taler.ar",
subject: { subject: {

View File

@ -54,6 +54,7 @@ export function ReadyView({
create, create,
toBeReceived, toBeReceived,
chosenAmount, chosenAmount,
doSelectExchange,
}: State.Ready): VNode { }: State.Ready): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -93,13 +94,13 @@ export function ReadyView({
}} }}
> >
<i18n.Translate>Exchange</i18n.Translate> <i18n.Translate>Exchange</i18n.Translate>
{/* <Link> <Button onClick={doSelectExchange.onClick} variant="text">
<SvgIcon <SvgIcon
title="Edit" title="Edit"
dangerouslySetInnerHTML={{ __html: editIcon }} dangerouslySetInnerHTML={{ __html: editIcon }}
color="black" color="black"
/> />
</Link> */} </Button>
</div> </div>
} }
text={<ExchangeDetails exchange={exchangeUrl} />} text={<ExchangeDetails exchange={exchangeUrl} />}

View File

@ -128,6 +128,7 @@ export function useComponentState(
}); });
} }
const res = await api.confirmPay(payStatus.proposalId, undefined); const res = await api.confirmPay(payStatus.proposalId, undefined);
// handle confirm pay
if (res.type !== ConfirmPayResultType.Done) { if (res.type !== ConfirmPayResultType.Done) {
throw TalerError.fromUncheckedDetail({ throw TalerError.fromUncheckedDetail({
code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR, code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR,

View File

@ -25,12 +25,17 @@ import {
useComponentStateFromParams, useComponentStateFromParams,
useComponentStateFromURI, useComponentStateFromURI,
} from "./state.js"; } from "./state.js";
import {
State as SelectExchangeState
} from "../../hooks/useSelectedExchange.js";
import { import {
LoadingExchangeView, LoadingExchangeView,
LoadingInfoView, LoadingInfoView,
LoadingUriView, LoadingUriView,
SuccessView, SuccessView,
} from "./views.js"; } from "./views.js";
import { ExchangeSelectionPage } from "../../wallet/ExchangeSelection/index.js";
export interface PropsFromURI { export interface PropsFromURI {
talerWithdrawUri: string | undefined; talerWithdrawUri: string | undefined;
@ -49,6 +54,7 @@ export type State =
| State.LoadingUriError | State.LoadingUriError
| State.LoadingExchangeError | State.LoadingExchangeError
| State.LoadingInfoError | State.LoadingInfoError
| SelectExchangeState.Selecting
| State.Success; | State.Success;
export namespace State { export namespace State {
@ -57,12 +63,12 @@ export namespace State {
error: undefined; error: undefined;
} }
export interface LoadingUriError { export interface LoadingUriError {
status: "loading-uri"; status: "loading-error";
error: HookError; error: HookError;
} }
export interface LoadingExchangeError { export interface LoadingExchangeError {
status: "loading-exchange"; status: "no-exchange";
error: HookError; error: undefined,
} }
export interface LoadingInfoError { export interface LoadingInfoError {
status: "loading-info"; status: "loading-info";
@ -80,6 +86,7 @@ export namespace State {
toBeReceived: AmountJson; toBeReceived: AmountJson;
doWithdrawal: ButtonHandler; doWithdrawal: ButtonHandler;
doSelectExchange: ButtonHandler;
tosProps?: TermsOfServiceSectionProps; tosProps?: TermsOfServiceSectionProps;
mustAcceptFirst: boolean; mustAcceptFirst: boolean;
@ -92,9 +99,10 @@ export namespace State {
const viewMapping: StateViewMap<State> = { const viewMapping: StateViewMap<State> = {
loading: Loading, loading: Loading,
"loading-uri": LoadingUriView, "loading-error": LoadingUriView,
"loading-exchange": LoadingExchangeView, "no-exchange": LoadingExchangeView,
"loading-info": LoadingInfoView, "loading-info": LoadingInfoView,
"selecting-exchange": ExchangeSelectionPage,
success: SuccessView, success: SuccessView,
}; };

View File

@ -14,223 +14,58 @@
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 { Amounts, parsePaytoUri } from "@gnu-taler/taler-util"; /* eslint-disable react-hooks/rules-of-hooks */
import { AmountJson, Amounts, ExchangeListItem, parsePaytoUri } from "@gnu-taler/taler-util";
import { TalerError } from "@gnu-taler/taler-wallet-core"; import { TalerError } from "@gnu-taler/taler-wallet-core";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Amount } from "../../components/Amount.js";
import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
import { useSelectedExchange } from "../../hooks/useSelectedExchange.js";
import { buildTermsOfServiceState } from "../../utils/index.js"; import { buildTermsOfServiceState } from "../../utils/index.js";
import * as wxApi from "../../wxApi.js"; import * as wxApi from "../../wxApi.js";
import { PropsFromURI, PropsFromParams, State } from "./index.js"; import { PropsFromURI, PropsFromParams, State } from "./index.js";
type RecursiveState<S extends object> = S | (() => RecursiveState<S>)
export function useComponentStateFromParams( export function useComponentStateFromParams(
{ amount, cancel, onSuccess }: PropsFromParams, { amount, cancel, onSuccess }: PropsFromParams,
api: typeof wxApi, api: typeof wxApi,
): State { ): RecursiveState<State> {
const [ageRestricted, setAgeRestricted] = useState(0); const uriInfoHook = useAsyncAsHook(async () => {
const exchanges = await api.listExchanges();
return { amount: Amounts.parseOrThrow(amount), exchanges };
});
const exchangeHook = useAsyncAsHook(api.listExchanges); console.log("uri info", uriInfoHook)
const exchangeHookDep = if (!uriInfoHook) return { status: "loading", error: undefined };
!exchangeHook || exchangeHook.hasError || !exchangeHook.response
? undefined
: exchangeHook.response;
const chosenAmount = Amounts.parseOrThrow(amount);
// get the first exchange with the currency as the default one
const exchange = exchangeHookDep
? exchangeHookDep.exchanges.find(
(e) => e.currency === chosenAmount.currency,
)
: undefined;
/**
* For the exchange selected, bring the status of the terms of service
*/
const terms = useAsyncAsHook(async () => {
if (!exchange) return undefined;
const exchangeTos = await api.getExchangeTos(exchange.exchangeBaseUrl, [
"text/xml",
]);
const state = buildTermsOfServiceState(exchangeTos);
return { state };
}, [exchangeHookDep]);
/**
* With the exchange and amount, ask the wallet the information
* about the withdrawal
*/
const amountHook = useAsyncAsHook(async () => {
if (!exchange) return undefined;
const info = await api.getExchangeWithdrawalInfo({
exchangeBaseUrl: exchange.exchangeBaseUrl,
amount: chosenAmount,
tosAcceptedFormat: ["text/xml"],
ageRestricted,
});
const withdrawAmount = {
raw: Amounts.parseOrThrow(info.withdrawalAmountRaw),
effective: Amounts.parseOrThrow(info.withdrawalAmountEffective),
};
if (uriInfoHook.hasError) {
return { return {
amount: withdrawAmount, status: "loading-error",
ageRestrictionOptions: info.ageRestrictionOptions, error: uriInfoHook,
}; };
}, [exchangeHookDep]); }
const [reviewing, setReviewing] = useState<boolean>(false); const chosenAmount = uriInfoHook.response.amount;
const [reviewed, setReviewed] = useState<boolean>(false); const exchangeList = uriInfoHook.response.exchanges.exchanges
const [withdrawError, setWithdrawError] = useState<TalerError | undefined>( async function doManualWithdraw(exchange: string, ageRestricted: number | undefined): Promise<{ transactionId: string, confirmTransferUrl: string | undefined }> {
undefined, const res = await api.acceptManualWithdrawal(exchange, Amounts.stringify(chosenAmount), ageRestricted);
);
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false);
if (!exchangeHook) return { status: "loading", error: undefined };
if (exchangeHook.hasError) {
return { return {
status: "loading-uri", confirmTransferUrl: undefined,
error: exchangeHook, transactionId: res.transactionId
}; };
} }
if (!exchange) { return () => exchangeSelectionState(doManualWithdraw, cancel, onSuccess, undefined, chosenAmount, exchangeList, undefined, api)
return {
status: "loading-exchange",
error: {
hasError: true,
operational: false,
message: "ERROR_NO-DEFAULT-EXCHANGE",
},
};
}
async function doWithdrawAndCheckError(): Promise<void> {
if (!exchange) return;
try {
setDoingWithdraw(true);
const response = await wxApi.acceptManualWithdrawal(
exchange.exchangeBaseUrl,
Amounts.stringify(amount),
);
onSuccess(response.transactionId);
} catch (e) {
if (e instanceof TalerError) {
setWithdrawError(e);
}
}
setDoingWithdraw(false);
}
if (!amountHook) {
return { status: "loading", error: undefined };
}
if (amountHook.hasError) {
return {
status: "loading-info",
error: amountHook,
};
}
if (!amountHook.response) {
return { status: "loading", error: undefined };
}
const withdrawalFee = Amounts.sub(
amountHook.response.amount.raw,
amountHook.response.amount.effective,
).amount;
const toBeReceived = amountHook.response.amount.effective;
const { state: termsState } = (!terms
? undefined
: terms.hasError
? undefined
: terms.response) || { state: undefined };
async function onAccept(accepted: boolean): Promise<void> {
if (!termsState || !exchange) return;
try {
await api.setExchangeTosAccepted(
exchange.exchangeBaseUrl,
accepted ? termsState.version : undefined,
);
setReviewed(accepted);
} catch (e) {
if (e instanceof Error) {
//FIXME: uncomment this and display error
// setErrorAccepting(e.message);
}
}
}
const mustAcceptFirst =
termsState !== undefined &&
(termsState.status === "changed" || termsState.status === "new");
const ageRestrictionOptions =
amountHook.response.ageRestrictionOptions?.reduce(
(p, c) => ({ ...p, [c]: `under ${c}` }),
{} as Record<string, string>,
);
const ageRestrictionEnabled = ageRestrictionOptions !== undefined;
if (ageRestrictionEnabled) {
ageRestrictionOptions["0"] = "Not restricted";
}
//TODO: calculate based on exchange info
const ageRestriction = ageRestrictionEnabled
? {
list: ageRestrictionOptions,
value: String(ageRestricted),
onChange: async (v: string) => setAgeRestricted(parseInt(v, 10)),
}
: undefined;
return {
status: "success",
error: undefined,
exchangeUrl: exchange.exchangeBaseUrl,
toBeReceived,
withdrawalFee,
chosenAmount,
ageRestriction,
doWithdrawal: {
onClick:
doingWithdraw || (mustAcceptFirst && !reviewed)
? undefined
: doWithdrawAndCheckError,
error: withdrawError,
},
tosProps: !termsState
? undefined
: {
onAccept,
onReview: setReviewing,
reviewed: reviewed,
reviewing: reviewing,
terms: termsState,
},
mustAcceptFirst,
cancel,
};
} }
export function useComponentStateFromURI( export function useComponentStateFromURI(
{ talerWithdrawUri, cancel, onSuccess }: PropsFromURI, { talerWithdrawUri, cancel, onSuccess }: PropsFromURI,
api: typeof wxApi, api: typeof wxApi,
): State { ): RecursiveState<State> {
const [ageRestricted, setAgeRestricted] = useState(0);
/** /**
* Ask the wallet about the withdraw URI * Ask the wallet about the withdraw URI
*/ */
@ -240,207 +75,219 @@ export function useComponentStateFromURI(
const uriInfo = await api.getWithdrawalDetailsForUri({ const uriInfo = await api.getWithdrawalDetailsForUri({
talerWithdrawUri, talerWithdrawUri,
}); });
const exchanges = await api.listExchanges();
const { amount, defaultExchangeBaseUrl } = uriInfo; const { amount, defaultExchangeBaseUrl } = uriInfo;
return { amount, thisExchange: defaultExchangeBaseUrl }; return { talerWithdrawUri, amount: Amounts.parseOrThrow(amount), thisExchange: defaultExchangeBaseUrl, exchanges };
}); });
/** console.log("uri info", uriInfoHook)
* Get the amount and select one exchange
*/
const uriHookDep =
!uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response
? undefined
: uriInfoHook.response;
/**
* For the exchange selected, bring the status of the terms of service
*/
const terms = useAsyncAsHook(async () => {
if (!uriHookDep?.thisExchange) return false;
const exchangeTos = await api.getExchangeTos(uriHookDep.thisExchange, [
"text/xml",
]);
const state = buildTermsOfServiceState(exchangeTos);
return { state };
}, [uriHookDep]);
/**
* With the exchange and amount, ask the wallet the information
* about the withdrawal
*/
const amountHook = useAsyncAsHook(async () => {
if (!uriHookDep?.thisExchange) return false;
const info = await api.getExchangeWithdrawalInfo({
exchangeBaseUrl: uriHookDep?.thisExchange,
amount: Amounts.parseOrThrow(uriHookDep.amount),
tosAcceptedFormat: ["text/xml"],
ageRestricted,
});
const withdrawAmount = {
raw: Amounts.parseOrThrow(info.withdrawalAmountRaw),
effective: Amounts.parseOrThrow(info.withdrawalAmountEffective),
};
return {
amount: withdrawAmount,
ageRestrictionOptions: info.ageRestrictionOptions,
};
}, [uriHookDep]);
const [reviewing, setReviewing] = useState<boolean>(false);
const [reviewed, setReviewed] = useState<boolean>(false);
const [withdrawError, setWithdrawError] = useState<TalerError | undefined>(
undefined,
);
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false);
if (!uriInfoHook) return { status: "loading", error: undefined }; if (!uriInfoHook) return { status: "loading", error: undefined };
if (uriInfoHook.hasError) { if (uriInfoHook.hasError) {
return { return {
status: "loading-uri", status: "loading-error",
error: uriInfoHook, error: uriInfoHook,
}; };
} }
const { amount, thisExchange } = uriInfoHook.response; const uri = uriInfoHook.response.talerWithdrawUri;
const chosenAmount = uriInfoHook.response.amount;
const defaultExchange = uriInfoHook.response.thisExchange;
const exchangeList = uriInfoHook.response.exchanges.exchanges
const chosenAmount = Amounts.parseOrThrow(amount); async function doManagedWithdraw(exchange: string, ageRestricted: number | undefined): Promise<{ transactionId: string, confirmTransferUrl: string | undefined }> {
const res = await api.acceptWithdrawal(uri, exchange, ageRestricted,);
if (!thisExchange) {
return { return {
status: "loading-exchange", confirmTransferUrl: res.confirmTransferUrl,
error: { transactionId: res.transactionId
hasError: true,
operational: false,
message: "ERROR_NO-DEFAULT-EXCHANGE",
},
}; };
} }
// const selectedExchange = thisExchange; return () => exchangeSelectionState(doManagedWithdraw, cancel, onSuccess, uri, chosenAmount, exchangeList, defaultExchange, api)
async function doWithdrawAndCheckError(): Promise<void> { }
if (!thisExchange) return;
try { type ManualOrManagedWithdrawFunction = (exchange: string, ageRestricted: number | undefined) => Promise<{ transactionId: string, confirmTransferUrl: string | undefined }>
setDoingWithdraw(true);
if (!talerWithdrawUri) return;
const res = await api.acceptWithdrawal(
talerWithdrawUri,
thisExchange,
!ageRestricted ? undefined : ageRestricted,
);
if (res.confirmTransferUrl) {
document.location.href = res.confirmTransferUrl;
} else {
onSuccess(res.transactionId);
}
} catch (e) {
if (e instanceof TalerError) {
setWithdrawError(e);
}
}
setDoingWithdraw(false);
}
if (!amountHook) { function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, cancel: () => Promise<void>, onSuccess: (txid: string) => Promise<void>, talerWithdrawUri: string | undefined, chosenAmount: AmountJson, exchangeList: ExchangeListItem[], defaultExchange: string | undefined, api: typeof wxApi,): RecursiveState<State> {
return { status: "loading", error: undefined };
} //FIXME: use substates here
if (amountHook.hasError) { const selectedExchange = useSelectedExchange({ currency: chosenAmount.currency, defaultExchange, list: exchangeList })
if (selectedExchange.status === 'no-exchange') {
return { return {
status: "loading-info", status: "no-exchange",
error: amountHook, error: undefined,
};
}
if (!amountHook.response) {
return { status: "loading", error: undefined };
}
const withdrawalFee = Amounts.sub(
amountHook.response.amount.raw,
amountHook.response.amount.effective,
).amount;
const toBeReceived = amountHook.response.amount.effective;
const { state: termsState } = (!terms
? undefined
: terms.hasError
? undefined
: terms.response) || { state: undefined };
async function onAccept(accepted: boolean): Promise<void> {
if (!termsState || !thisExchange) return;
try {
await api.setExchangeTosAccepted(
thisExchange,
accepted ? termsState.version : undefined,
);
setReviewed(accepted);
} catch (e) {
if (e instanceof Error) {
//FIXME: uncomment this and display error
// setErrorAccepting(e.message);
}
} }
} }
const mustAcceptFirst = if (selectedExchange.status === 'selecting-exchange') {
termsState !== undefined && return selectedExchange
(termsState.status === "changed" || termsState.status === "new"); }
console.log("exchange selected", selectedExchange.selected)
const ageRestrictionOptions = return () => {
amountHook.response.ageRestrictionOptions?.reduce(
(p, c) => ({ ...p, [c]: `under ${c}` }), const [ageRestricted, setAgeRestricted] = useState(0);
{} as Record<string, string>, const currentExchange = selectedExchange.selected
/**
* For the exchange selected, bring the status of the terms of service
*/
const terms = useAsyncAsHook(async () => {
const exchangeTos = await api.getExchangeTos(currentExchange.exchangeBaseUrl, [
"text/xml",
]);
const state = buildTermsOfServiceState(exchangeTos);
return { state };
}, []);
console.log("terms", terms)
/**
* With the exchange and amount, ask the wallet the information
* about the withdrawal
*/
const amountHook = useAsyncAsHook(async () => {
const info = await api.getExchangeWithdrawalInfo({
exchangeBaseUrl: currentExchange.exchangeBaseUrl,
amount: chosenAmount,
tosAcceptedFormat: ["text/xml"],
ageRestricted,
});
const withdrawAmount = {
raw: Amounts.parseOrThrow(info.withdrawalAmountRaw),
effective: Amounts.parseOrThrow(info.withdrawalAmountEffective),
};
return {
amount: withdrawAmount,
ageRestrictionOptions: info.ageRestrictionOptions,
};
}, []);
const [reviewing, setReviewing] = useState<boolean>(false);
const [reviewed, setReviewed] = useState<boolean>(false);
const [withdrawError, setWithdrawError] = useState<TalerError | undefined>(
undefined,
); );
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false);
const ageRestrictionEnabled = ageRestrictionOptions !== undefined;
if (ageRestrictionEnabled) {
ageRestrictionOptions["0"] = "Not restricted";
}
//TODO: calculate based on exchange info async function doWithdrawAndCheckError(): Promise<void> {
const ageRestriction = ageRestrictionEnabled
? { try {
setDoingWithdraw(true);
const res = await doWithdraw(currentExchange.exchangeBaseUrl, !ageRestricted ? undefined : ageRestricted)
if (res.confirmTransferUrl) {
document.location.href = res.confirmTransferUrl;
} else {
onSuccess(res.transactionId);
}
} catch (e) {
if (e instanceof TalerError) {
setWithdrawError(e);
}
}
setDoingWithdraw(false);
}
if (!amountHook) {
return { status: "loading", error: undefined };
}
if (amountHook.hasError) {
return {
status: "loading-info",
error: amountHook,
};
}
if (!amountHook.response) {
return { status: "loading", error: undefined };
}
const withdrawalFee = Amounts.sub(
amountHook.response.amount.raw,
amountHook.response.amount.effective,
).amount;
const toBeReceived = amountHook.response.amount.effective;
const { state: termsState } = (!terms
? undefined
: terms.hasError
? undefined
: terms.response) || { state: undefined };
async function onAccept(accepted: boolean): Promise<void> {
if (!termsState) return;
try {
await api.setExchangeTosAccepted(
currentExchange.exchangeBaseUrl,
accepted ? termsState.version : undefined,
);
setReviewed(accepted);
} catch (e) {
if (e instanceof Error) {
//FIXME: uncomment this and display error
// setErrorAccepting(e.message);
}
}
}
const mustAcceptFirst =
termsState !== undefined &&
(termsState.status === "changed" || termsState.status === "new");
const ageRestrictionOptions =
amountHook.response.ageRestrictionOptions?.reduce(
(p, c) => ({ ...p, [c]: `under ${c}` }),
{} as Record<string, string>,
);
const ageRestrictionEnabled = ageRestrictionOptions !== undefined;
if (ageRestrictionEnabled) {
ageRestrictionOptions["0"] = "Not restricted";
}
//TODO: calculate based on exchange info
const ageRestriction = ageRestrictionEnabled
? {
list: ageRestrictionOptions, list: ageRestrictionOptions,
value: String(ageRestricted), value: String(ageRestricted),
onChange: async (v: string) => setAgeRestricted(parseInt(v, 10)), onChange: async (v: string) => setAgeRestricted(parseInt(v, 10)),
} }
: undefined; : undefined;
return { return {
status: "success", status: "success",
error: undefined, error: undefined,
exchangeUrl: thisExchange, doSelectExchange: selectedExchange.doSelect,
toBeReceived, exchangeUrl: currentExchange.exchangeBaseUrl,
withdrawalFee, toBeReceived,
chosenAmount, withdrawalFee,
talerWithdrawUri, chosenAmount,
ageRestriction, talerWithdrawUri,
doWithdrawal: { ageRestriction,
onClick: doWithdrawal: {
doingWithdraw || (mustAcceptFirst && !reviewed) onClick:
? undefined doingWithdraw || (mustAcceptFirst && !reviewed)
: doWithdrawAndCheckError, ? undefined
error: withdrawError, : doWithdrawAndCheckError,
}, error: withdrawError,
tosProps: !termsState },
? undefined tosProps: !termsState
: { ? undefined
: {
onAccept, onAccept,
onReview: setReviewing, onReview: setReviewing,
reviewed: reviewed, reviewed: reviewed,
reviewing: reviewing, reviewing: reviewing,
terms: termsState, terms: termsState,
}, },
mustAcceptFirst, mustAcceptFirst,
cancel, cancel,
}; };
}
} }

View File

@ -76,6 +76,8 @@ export const TermsOfServiceNotYetLoaded = createExample(SuccessView, {
fraction: 10000000, fraction: 10000000,
value: 1, value: 1,
}, },
doSelectExchange: {
},
toBeReceived: { toBeReceived: {
currency: "USD", currency: "USD",
fraction: 0, fraction: 0,
@ -104,6 +106,8 @@ export const WithSomeFee = createExample(SuccessView, {
fraction: 0, fraction: 0,
value: 1, value: 1,
}, },
doSelectExchange: {
},
tosProps: normalTosState, tosProps: normalTosState,
}); });
@ -123,6 +127,8 @@ export const WithoutFee = createExample(SuccessView, {
fraction: 0, fraction: 0,
value: 0, value: 0,
}, },
doSelectExchange: {
},
toBeReceived: { toBeReceived: {
currency: "USD", currency: "USD",
fraction: 0, fraction: 0,
@ -147,6 +153,8 @@ export const EditExchangeUntouched = createExample(SuccessView, {
fraction: 0, fraction: 0,
value: 0, value: 0,
}, },
doSelectExchange: {
},
toBeReceived: { toBeReceived: {
currency: "USD", currency: "USD",
fraction: 0, fraction: 0,
@ -171,6 +179,8 @@ export const EditExchangeModified = createExample(SuccessView, {
fraction: 0, fraction: 0,
value: 0, value: 0,
}, },
doSelectExchange: {
},
toBeReceived: { toBeReceived: {
currency: "USD", currency: "USD",
fraction: 0, fraction: 0,
@ -188,6 +198,8 @@ export const WithAgeRestriction = createExample(SuccessView, {
value: 2, value: 2,
fraction: 10000000, fraction: 10000000,
}, },
doSelectExchange: {
},
doWithdrawal: nullHandler, doWithdrawal: nullHandler,
exchangeUrl: "https://exchange.demo.taler.net", exchangeUrl: "https://exchange.demo.taler.net",
mustAcceptFirst: false, mustAcceptFirst: false,

View File

@ -29,6 +29,7 @@ import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core";
import { expect } from "chai"; import { expect } from "chai";
import { mountHook } from "../../test-utils.js"; import { mountHook } from "../../test-utils.js";
import { useComponentStateFromURI } from "./state.js"; import { useComponentStateFromURI } from "./state.js";
import * as wxApi from "../../wxApi.js";
const exchanges: ExchangeFullDetails[] = [ const exchanges: ExchangeFullDetails[] = [
{ {
@ -92,7 +93,7 @@ describe("Withdraw CTA states", () => {
{ {
const { status, error } = getLastResultOrThrow(); const { status, error } = getLastResultOrThrow();
if (status != "loading-uri") expect.fail(); if (status != "loading-error") expect.fail();
if (!error) expect.fail(); if (!error) expect.fail();
if (!error.hasError) expect.fail(); if (!error.hasError) expect.fail();
if (error.operational) expect.fail(); if (error.operational) expect.fail();
@ -127,7 +128,7 @@ describe("Withdraw CTA states", () => {
{ {
const { status } = getLastResultOrThrow(); const { status } = getLastResultOrThrow();
expect(status).equals("loading"); expect(status).equals("loading", "1");
} }
await waitNextUpdate(); await waitNextUpdate();
@ -135,13 +136,9 @@ describe("Withdraw CTA states", () => {
{ {
const { status, error } = getLastResultOrThrow(); const { status, error } = getLastResultOrThrow();
expect(status).equals("loading-exchange"); expect(status).equals("no-exchange", "3");
expect(error).deep.equals({ expect(error).undefined;
hasError: true,
operational: false,
message: "ERROR_NO-DEFAULT-EXCHANGE",
});
} }
await assertNoPendingUpdate(); await assertNoPendingUpdate();
@ -169,10 +166,10 @@ describe("Withdraw CTA states", () => {
}), }),
getExchangeWithdrawalInfo: getExchangeWithdrawalInfo:
async (): Promise<ExchangeWithdrawDetails> => async (): Promise<ExchangeWithdrawDetails> =>
({ ({
withdrawalAmountRaw: "ARS:2", withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2", withdrawalAmountEffective: "ARS:2",
} as any), } as any),
getExchangeTos: async (): Promise<GetExchangeTosResult> => ({ getExchangeTos: async (): Promise<GetExchangeTosResult> => ({
contentType: "text", contentType: "text",
content: "just accept", content: "just accept",
@ -246,10 +243,10 @@ describe("Withdraw CTA states", () => {
}), }),
getExchangeWithdrawalInfo: getExchangeWithdrawalInfo:
async (): Promise<ExchangeWithdrawDetails> => async (): Promise<ExchangeWithdrawDetails> =>
({ ({
withdrawalAmountRaw: "ARS:2", withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2", withdrawalAmountEffective: "ARS:2",
} as any), } as any),
getExchangeTos: async (): Promise<GetExchangeTosResult> => ({ getExchangeTos: async (): Promise<GetExchangeTosResult> => ({
contentType: "text", contentType: "text",
content: "just accept", content: "just accept",

View File

@ -38,6 +38,7 @@ import editIcon from "../../svg/edit_24px.svg";
import { Amount } from "../../components/Amount.js"; import { Amount } from "../../components/Amount.js";
import { QR } from "../../components/QR.js"; import { QR } from "../../components/QR.js";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { ErrorMessage } from "../../components/ErrorMessage.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode { export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -52,15 +53,12 @@ export function LoadingUriView({ error }: State.LoadingUriError): VNode {
); );
} }
export function LoadingExchangeView({ export function LoadingExchangeView(p: State.LoadingExchangeError): VNode {
error,
}: State.LoadingExchangeError): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
return ( return (
<LoadingError <ErrorMessage
title={<i18n.Translate>Could not get exchange</i18n.Translate>} title={<i18n.Translate>Could not get a default exchange, please check configuration</i18n.Translate>}
error={error}
/> />
); );
} }
@ -106,13 +104,13 @@ export function SuccessView(state: State.Success): VNode {
}} }}
> >
<i18n.Translate>Exchange</i18n.Translate> <i18n.Translate>Exchange</i18n.Translate>
{/* <Link> <Button onClick={state.doSelectExchange.onClick} variant="text">
<SvgIcon <SvgIcon
title="Edit" title="Edit"
dangerouslySetInnerHTML={{ __html: editIcon }} dangerouslySetInnerHTML={{ __html: editIcon }}
color="black" color="black"
/> />
</Link> */} </Button>
</div> </div>
} }
text={<ExchangeDetails exchange={state.exchangeUrl} />} text={<ExchangeDetails exchange={state.exchangeUrl} />}

View File

@ -0,0 +1,125 @@
/*
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 { ExchangeListItem } from "@gnu-taler/taler-util";
import { useState } from "preact/hooks";
import { ButtonHandler } from "../mui/handlers.js";
type State = State.Ready | State.NoExchange | State.Selecting;
export namespace State {
export interface NoExchange {
status: "no-exchange"
error: undefined;
}
export interface Ready {
status: "ready",
doSelect: ButtonHandler,
selected: ExchangeListItem;
}
export interface Selecting {
status: "selecting-exchange",
error: undefined,
onSelection: (url: string) => Promise<void>;
onCancel: () => Promise<void>;
list: ExchangeListItem[],
currency: string;
currentExchange: string;
}
}
interface Props {
currency: string;
//there is a preference for the default at the initial state
defaultExchange?: string,
//list of exchanges
list: ExchangeListItem[],
}
export function useSelectedExchange({ currency, defaultExchange, list }: Props): State {
const [isSelecting, setIsSelecting] = useState(false);
const [selectedExchange, setSelectedExchange] = useState<string | undefined>(undefined);
if (!list.length) {
return {
status: "no-exchange",
error: undefined,
}
}
const firstByCurrency = list.find((e) => e.currency === currency)
if (!firstByCurrency) {
// there should be at least one exchange for this currency
return {
status: "no-exchange",
error: undefined,
}
}
if (isSelecting) {
const currentExchange = selectedExchange ?? defaultExchange ?? firstByCurrency.exchangeBaseUrl;
return {
status: "selecting-exchange",
error: undefined,
list,
currency,
currentExchange: currentExchange,
onSelection: async (exchangeBaseUrl: string) => {
setIsSelecting(false);
setSelectedExchange(exchangeBaseUrl)
},
onCancel: async () => {
setIsSelecting(false);
}
}
}
{
const found = !selectedExchange ? undefined : list.find(
(e) => e.exchangeBaseUrl === selectedExchange,
)
if (found) return {
status: "ready",
doSelect: {
onClick: async () => setIsSelecting(true)
},
selected: found
};
}
{
const found = !defaultExchange ? undefined : list.find(
(e) => e.exchangeBaseUrl === defaultExchange,
)
if (found) return {
status: "ready",
doSelect: {
onClick: async () => setIsSelecting(true)
},
selected: found
};
}
return {
status: "ready",
doSelect: {
onClick: async () => setIsSelecting(true)
},
selected: firstByCurrency
}
}

View File

@ -82,31 +82,38 @@ export function renderNodeOrBrowser(Component: any, args: any): void {
document.body.removeChild(div); document.body.removeChild(div);
} }
} }
type RecursiveState<S> = S | (() => RecursiveState<S>)
interface Mounted<T> { interface Mounted<T> {
unmount: () => void; unmount: () => void;
getLastResultOrThrow: () => T; getLastResultOrThrow: () => Exclude<T, VoidFunction>;
assertNoPendingUpdate: () => void; assertNoPendingUpdate: () => void;
waitNextUpdate: (s?: string) => Promise<void>; waitNextUpdate: (s?: string) => Promise<void>;
} }
const isNode = typeof window === "undefined"; const isNode = typeof window === "undefined";
export function mountHook<T>( export function mountHook<T extends object>(
callback: () => T, callback: () => RecursiveState<T>,
Context?: ({ children }: { children: any }) => VNode, Context?: ({ children }: { children: any }) => VNode,
): Mounted<T> { ): Mounted<T> {
// const result: { current: T | null } = { // const result: { current: T | null } = {
// current: null // current: null
// } // }
let lastResult: T | Error | null = null; let lastResult: Exclude<T, VoidFunction> | Error | null = null;
const listener: Array<() => void> = []; const listener: Array<() => void> = [];
// component that's going to hold the hook // component that's going to hold the hook
function Component(): VNode { function Component(): VNode {
try { try {
lastResult = callback(); let componentOrResult = callback()
while (typeof componentOrResult === "function") {
componentOrResult = componentOrResult();
}
//typecheck fails here
const l: Exclude<T, () => void> = componentOrResult as any
lastResult = l;
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
lastResult = e; lastResult = e;
@ -157,13 +164,13 @@ export function mountHook<T>(
} }
} }
function getLastResult(): T | Error | null { function getLastResult(): Exclude<T | Error | null, VoidFunction> {
const copy = lastResult; const copy: Exclude<T | Error | null, VoidFunction> = lastResult;
lastResult = null; lastResult = null;
return copy; return copy;
} }
function getLastResultOrThrow(): T { function getLastResultOrThrow(): Exclude<T, VoidFunction> {
const r = getLastResult(); const r = getLastResult();
if (r instanceof Error) throw r; if (r instanceof Error) throw r;
if (!r) throw Error("there was no last result"); if (!r) throw Error("there was no last result");

View File

@ -19,7 +19,7 @@ import {
Amounts, Amounts,
GetExchangeTosResult, GetExchangeTosResult,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { VNode } from "preact"; import { VNode, createElement } from "preact";
function getJsonIfOk(r: Response): Promise<any> { function getJsonIfOk(r: Response): Promise<any> {
if (r.ok) { if (r.ok) {
@ -31,8 +31,7 @@ function getJsonIfOk(r: Response): Promise<any> {
} }
throw new Error( throw new Error(
`Try another server: (${r.status}) ${ `Try another server: (${r.status}) ${r.statusText || "internal server error"
r.statusText || "internal server error"
}`, }`,
); );
} }
@ -103,10 +102,10 @@ export function buildTermsOfServiceStatus(
return !content return !content
? "notfound" ? "notfound"
: !acceptedVersion : !acceptedVersion
? "new" ? "new"
: acceptedVersion !== currentVersion : acceptedVersion !== currentVersion
? "changed" ? "changed"
: "accepted"; : "accepted";
} }
function parseTermsOfServiceContent( function parseTermsOfServiceContent(
@ -198,17 +197,35 @@ export type StateViewMap<StateType extends { status: string }> = {
[S in StateType as S["status"]]: StateFunc<S>; [S in StateType as S["status"]]: StateFunc<S>;
}; };
type RecursiveState<S extends object> = S | (() => RecursiveState<S>)
export function compose<SType extends { status: string }, PType>( export function compose<SType extends { status: string }, PType>(
name: string, name: string,
hook: (p: PType) => SType, hook: (p: PType) => RecursiveState<SType>,
vs: StateViewMap<SType>, viewMap: StateViewMap<SType>,
): (p: PType) => VNode { ): (p: PType) => VNode {
const Component = (p: PType): VNode => {
const state = hook(p); function withHook(stateHook: () => RecursiveState<SType>): () => VNode {
const s = state.status as unknown as SType["status"];
const c = vs[s] as unknown as StateFunc<SType>; function TheComponent(): VNode {
return c(state); const state = stateHook();
if (typeof state === "function") {
const subComponent = withHook(state)
return createElement(subComponent, {});
}
const statusName = state.status as unknown as SType["status"];
const viewComponent = viewMap[statusName] as unknown as StateFunc<SType>;
return createElement(viewComponent, state);
}
TheComponent.name = `${name}`;
return TheComponent;
}
return (p: PType) => {
const h = withHook(() => hook(p))
return h()
}; };
Component.name = `${name}`;
return Component;
} }

View File

@ -20,6 +20,7 @@ import {
AbsoluteTime, AbsoluteTime,
ExchangeFullDetails, ExchangeFullDetails,
OperationMap, OperationMap,
ExchangeListItem,
} from "@gnu-taler/taler-util"; } 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";
@ -29,13 +30,14 @@ import * as wxApi from "../../wxApi.js";
import { useComponentState } from "./state.js"; import { useComponentState } from "./state.js";
import { import {
ComparingView, ComparingView,
LoadingUriView, ErrorLoadingView,
NoExchangesView, NoExchangesView,
ReadyView, ReadyView,
} from "./views.js"; } from "./views.js";
export interface Props { export interface Props {
currency?: string; list: ExchangeListItem[],
currentExchange: string,
onCancel: () => Promise<void>; onCancel: () => Promise<void>;
onSelection: (exchange: string) => Promise<void>; onSelection: (exchange: string) => Promise<void>;
} }
@ -54,7 +56,7 @@ export namespace State {
} }
export interface LoadingUriError { export interface LoadingUriError {
status: "loading-uri"; status: "error-loading";
error: HookError; error: HookError;
} }
@ -85,7 +87,7 @@ export namespace State {
const viewMapping: StateViewMap<State> = { const viewMapping: StateViewMap<State> = {
loading: Loading, loading: Loading,
"loading-uri": LoadingUriView, "error-loading": ErrorLoadingView,
comparing: ComparingView, comparing: ComparingView,
"no-exchanges": NoExchangesView, "no-exchanges": NoExchangesView,
ready: ReadyView, ready: ReadyView,

View File

@ -22,14 +22,17 @@ import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js"; import { Props, State } from "./index.js";
export function useComponentState( export function useComponentState(
{ onCancel, onSelection, currency }: Props, { onCancel, onSelection, list: exchanges, currentExchange }: Props,
api: typeof wxApi, api: typeof wxApi,
): State { ): State {
const initialValue = 0; const initialValue = exchanges.findIndex(e => e.exchangeBaseUrl === currentExchange);
if (initialValue === -1) {
throw Error(`wrong usage of ExchangeSelection component, currentExchange '${currentExchange}' is not in the list of exchanges`)
}
const [value, setValue] = useState(String(initialValue)); const [value, setValue] = useState(String(initialValue));
const hook = useAsyncAsHook(async () => { const hook = useAsyncAsHook(async () => {
const { exchanges } = await api.listExchanges(); // const { exchanges } = await api.listExchanges();
const selectedIdx = parseInt(value, 10); const selectedIdx = parseInt(value, 10);
const selectedExchange = const selectedExchange =
@ -54,12 +57,12 @@ export function useComponentState(
} }
if (hook.hasError) { if (hook.hasError) {
return { return {
status: "loading-uri", status: "error-loading",
error: hook, error: hook,
}; };
} }
const { exchanges, selected, original } = hook.response; const { selected, original } = hook.response;
if (!selected) { if (!selected) {
//!selected <=> exchanges.length === 0 //!selected <=> exchanges.length === 0

View File

@ -101,7 +101,7 @@ const Container = styled.div`
} }
`; `;
export function LoadingUriView({ error }: State.LoadingUriError): VNode { export function ErrorLoadingView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
return ( return (