new compose feature: sub-states
implemented in withdraw page, WIP
This commit is contained in:
parent
a5525eab1e
commit
52ec740c82
@ -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;
|
||||||
|
@ -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: {
|
||||||
|
@ -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: {
|
||||||
|
@ -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} />}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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",
|
||||||
|
@ -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} />}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -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");
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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 (
|
||||||
|
Loading…
Reference in New Issue
Block a user