refactored terms of service to remove duplicated code

prettfied some sources
This commit is contained in:
Sebastian 2022-10-14 11:40:38 -03:00
parent ca8da4ed38
commit da9ec5eb16
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
34 changed files with 1218 additions and 1113 deletions

View File

@ -18,8 +18,8 @@ import { Outlined, StyledCheckboxLabel } from "./styled/index.js";
import { h, VNode } from "preact";
interface Props {
enabled: boolean;
onToggle: () => Promise<void>;
enabled?: boolean;
onToggle?: () => Promise<void>;
label: VNode;
name: string;
}

View File

@ -17,9 +17,7 @@
import { AmountJson, TalerErrorDetail } from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js";
import {
State as SelectExchangeState
} from "../../hooks/useSelectedExchange.js";
import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
import { ButtonHandler, TextFieldHandler } from "../../mui/handlers.js";
import { compose, StateViewMap } from "../../utils/index.js";
import { ExchangeSelectionPage } from "../../wallet/ExchangeSelection/index.js";
@ -34,12 +32,12 @@ export interface Props {
onSuccess: (tx: string) => Promise<void>;
}
export type State = State.Loading
export type State =
| State.Loading
| State.LoadingUriError
| State.Ready
| SelectExchangeState.Selecting
| SelectExchangeState.NoExchange
;
| SelectExchangeState.NoExchange;
export namespace State {
export interface Loading {

View File

@ -23,7 +23,7 @@ import { useSelectedExchange } from "../../hooks/useSelectedExchange.js";
import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js";
type RecursiveState<S extends object> = S | (() => RecursiveState<S>)
type RecursiveState<S extends object> = S | (() => RecursiveState<S>);
export function useComponentState(
{ amount: amountStr, onClose, onSuccess }: Props,
@ -46,7 +46,7 @@ export function useComponentState(
};
}
const exchangeList = hook.response.exchanges
const exchangeList = hook.response.exchanges;
return () => {
const [subject, setSubject] = useState("");
@ -55,14 +55,17 @@ export function useComponentState(
TalerErrorDetail | undefined
>(undefined);
const selectedExchange = useSelectedExchange({
currency: amount.currency,
defaultExchange: undefined,
list: exchangeList,
});
const selectedExchange = useSelectedExchange({ currency: amount.currency, defaultExchange: undefined, list: exchangeList })
if (selectedExchange.status !== 'ready') {
return selectedExchange
if (selectedExchange.status !== "ready") {
return selectedExchange;
}
const exchange = selectedExchange.selected
const exchange = selectedExchange.selected;
async function accept(): Promise<void> {
try {
@ -105,9 +108,5 @@ export function useComponentState(
error: undefined,
operationError,
};
}
};
}

View File

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

View File

@ -0,0 +1,96 @@
/*
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 { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js";
import { ToggleHandler } from "../../mui/handlers.js";
import { compose, StateViewMap } from "../../utils/index.js";
import * as wxApi from "../../wxApi.js";
import { useComponentState } from "./state.js";
import { TermsState } from "./utils.js";
import {
ErrorAcceptingView,
LoadingUriView,
ShowButtonsAcceptedTosView,
ShowButtonsNonAcceptedTosView,
ShowTosContentView,
} from "./views.js";
export interface Props {
exchangeUrl: string;
onChange: (v: boolean) => void;
readOnly?: boolean;
}
export type State =
| State.Loading
| State.LoadingUriError
| State.ErrorAccepting
| State.ShowContent
| State.ShowButtonsAccepted
| State.ShowButtonsNotAccepted
| State.ShowContent;
export namespace State {
export interface Loading {
status: "loading";
error: undefined;
}
export interface LoadingUriError {
status: "loading-error";
error: HookError;
}
export interface ErrorAccepting {
status: "error-accepting";
error: HookError;
}
export interface BaseInfo {
error: undefined;
termsAccepted: ToggleHandler;
showingTermsOfService: ToggleHandler;
terms: TermsState;
}
export interface ShowContent extends BaseInfo {
status: "show-content";
error: undefined;
}
export interface ShowButtonsAccepted extends BaseInfo {
status: "show-buttons-accepted";
error: undefined;
}
export interface ShowButtonsNotAccepted extends BaseInfo {
status: "show-buttons-not-accepted";
error: undefined;
}
}
const viewMapping: StateViewMap<State> = {
loading: Loading,
"loading-error": LoadingUriView,
"show-content": ShowTosContentView,
"show-buttons-accepted": ShowButtonsAcceptedTosView,
"show-buttons-not-accepted": ShowButtonsNonAcceptedTosView,
"error-accepting": ErrorAcceptingView,
};
export const TermsOfService = compose(
"TermsOfService",
(p: Props) => useComponentState(p, wxApi),
viewMapping,
);

View File

@ -0,0 +1,136 @@
/*
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 { useState } from "preact/hooks";
import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js";
import { buildTermsOfServiceState } from "./utils.js";
export function useComponentState(
{ exchangeUrl, readOnly, onChange }: Props,
api: typeof wxApi,
): State {
const [showContent, setShowContent] = useState<boolean>(false);
// const [accepted, setAccepted] = useState<boolean>(false);
const [errorAccepting, setErrorAccepting] = useState<Error | undefined>(
undefined,
);
/**
* For the exchange selected, bring the status of the terms of service
*/
const terms = useAsyncAsHook(async () => {
const exchangeTos = await api.getExchangeTos(exchangeUrl, ["text/xml"]);
const state = buildTermsOfServiceState(exchangeTos);
return { state };
}, []);
if (!terms) {
return {
status: "loading",
error: undefined,
};
}
if (terms.hasError) {
return {
status: "loading-error",
error: terms,
};
}
if (errorAccepting) {
return {
status: "error-accepting",
error: {
hasError: true,
operational: false,
message: errorAccepting.message,
},
};
}
const { state } = terms.response;
async function onUpdate(accepted: boolean): Promise<void> {
if (!state) return;
try {
if (accepted) {
await api.setExchangeTosAccepted(exchangeUrl, state.version);
} else {
// mark as not accepted
await api.setExchangeTosAccepted(exchangeUrl, undefined);
}
// setAccepted(accepted);
onChange(accepted); //external update
} catch (e) {
if (e instanceof Error) {
//FIXME: uncomment this and display error
// setErrorAccepting(e.message);
setErrorAccepting(e);
}
}
}
const accepted = state.status === "accepted";
const base: State.BaseInfo = {
error: undefined,
showingTermsOfService: {
value: showContent,
button: {
onClick: readOnly
? undefined
: async () => {
setShowContent(!showContent);
},
},
},
terms: state,
termsAccepted: {
value: accepted,
button: {
onClick: async () => {
const newValue = !accepted; //toggle
onUpdate(newValue);
setShowContent(false);
},
},
},
};
if (showContent) {
return {
status: "show-content",
...base,
};
}
//showing buttons
if (accepted) {
return {
status: "show-buttons-accepted",
...base,
};
} else {
return {
status: "show-buttons-not-accepted",
...base,
};
}
}

View File

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

View File

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

View File

@ -0,0 +1,130 @@
/*
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 { GetExchangeTosResult } from "@gnu-taler/taler-util";
export function buildTermsOfServiceState(
tos: GetExchangeTosResult,
): TermsState {
const content: TermsDocument | undefined = parseTermsOfServiceContent(
tos.contentType,
tos.content,
);
const status: TermsStatus = buildTermsOfServiceStatus(
tos.content,
tos.acceptedEtag,
tos.currentEtag,
);
return { content, status, version: tos.currentEtag };
}
export function buildTermsOfServiceStatus(
content: string | undefined,
acceptedVersion: string | undefined,
currentVersion: string | undefined,
): TermsStatus {
return !content
? "notfound"
: !acceptedVersion
? "new"
: acceptedVersion !== currentVersion
? "changed"
: "accepted";
}
function parseTermsOfServiceContent(
type: string,
text: string,
): TermsDocument | undefined {
if (type === "text/xml") {
try {
const document = new DOMParser().parseFromString(text, "text/xml");
return { type: "xml", document };
} catch (e) {
console.log(e);
}
} else if (type === "text/html") {
try {
const href = new URL(text);
return { type: "html", href };
} catch (e) {
console.log(e);
}
} else if (type === "text/json") {
try {
const data = JSON.parse(text);
return { type: "json", data };
} catch (e) {
console.log(e);
}
} else if (type === "text/pdf") {
try {
const location = new URL(text);
return { type: "pdf", location };
} catch (e) {
console.log(e);
}
} else if (type === "text/plain") {
try {
const content = text;
return { type: "plain", content };
} catch (e) {
console.log(e);
}
}
return undefined;
}
export type TermsState = {
content: TermsDocument | undefined;
status: TermsStatus;
version: string;
};
type TermsStatus = "new" | "accepted" | "changed" | "notfound";
type TermsDocument =
| TermsDocumentXml
| TermsDocumentHtml
| TermsDocumentPlain
| TermsDocumentJson
| TermsDocumentPdf;
export interface TermsDocumentXml {
type: "xml";
document: Document;
}
export interface TermsDocumentHtml {
type: "html";
href: URL;
}
export interface TermsDocumentPlain {
type: "plain";
content: string;
}
export interface TermsDocumentJson {
type: "json";
data: any;
}
export interface TermsDocumentPdf {
type: "pdf";
location: URL;
}

View File

@ -0,0 +1,224 @@
/*
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 { Fragment, h, VNode } from "preact";
import { LoadingError } from "../../components/LoadingError.js";
import { useTranslationContext } from "../../context/translation.js";
import { TermsState } from "./utils.js";
import { State } from "./index.js";
import { CheckboxOutlined } from "../../components/CheckboxOutlined.js";
import {
LinkSuccess,
TermsOfService,
WarningBox,
WarningText,
} from "../../components/styled/index.js";
import { ExchangeXmlTos } from "../../components/ExchangeToS.js";
import { ToggleHandler } from "../../mui/handlers.js";
import { Button } from "../../mui/Button.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext();
return (
<LoadingError
title={<i18n.Translate>Could not load</i18n.Translate>}
error={error}
/>
);
}
export function ErrorAcceptingView({ error }: State.ErrorAccepting): VNode {
const { i18n } = useTranslationContext();
return (
<LoadingError
title={<i18n.Translate>Could not load</i18n.Translate>}
error={error}
/>
);
}
export function ShowButtonsAcceptedTosView({
termsAccepted,
showingTermsOfService,
terms,
}: State.ShowButtonsAccepted): VNode {
const { i18n } = useTranslationContext();
const ableToReviewTermsOfService =
showingTermsOfService.button.onClick !== undefined;
return (
<Fragment>
{ableToReviewTermsOfService && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<LinkSuccess
upperCased
onClick={showingTermsOfService.button.onClick}
>
<i18n.Translate>Show terms of service</i18n.Translate>
</LinkSuccess>
</section>
)}
<section style={{ justifyContent: "space-around", display: "flex" }}>
<CheckboxOutlined
name="terms"
enabled={termsAccepted.value}
label={
<i18n.Translate>
I accept the exchange terms of service
</i18n.Translate>
}
onToggle={termsAccepted.button.onClick}
/>
</section>
</Fragment>
);
}
export function ShowButtonsNonAcceptedTosView({
termsAccepted,
showingTermsOfService,
terms,
}: State.ShowButtonsNotAccepted): VNode {
const { i18n } = useTranslationContext();
const ableToReviewTermsOfService =
showingTermsOfService.button.onClick !== undefined;
if (!ableToReviewTermsOfService) {
return (
<Fragment>
{terms.status === "notfound" && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<WarningText>
<i18n.Translate>
Exchange doesn&apos;t have terms of service
</i18n.Translate>
</WarningText>
</section>
)}
</Fragment>
);
}
return (
<Fragment>
{terms.status === "notfound" && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<WarningText>
<i18n.Translate>
Exchange doesn&apos;t have terms of service
</i18n.Translate>
</WarningText>
</section>
)}
{terms.status === "new" && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<Button
variant="contained"
color="success"
onClick={showingTermsOfService.button.onClick}
>
<i18n.Translate>Review exchange terms of service</i18n.Translate>
</Button>
</section>
)}
{terms.status === "changed" && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<Button
variant="contained"
color="success"
onClick={showingTermsOfService.button.onClick}
>
<i18n.Translate>
Review new version of terms of service
</i18n.Translate>
</Button>
</section>
)}
</Fragment>
);
}
export function ShowTosContentView({
termsAccepted,
showingTermsOfService,
terms,
}: State.ShowContent): VNode {
const { i18n } = useTranslationContext();
const ableToReviewTermsOfService =
showingTermsOfService.button.onClick !== undefined;
return (
<Fragment>
{terms.status !== "notfound" && !terms.content && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<WarningBox>
<i18n.Translate>
The exchange reply with a empty terms of service
</i18n.Translate>
</WarningBox>
</section>
)}
{terms.content && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
{terms.content.type === "xml" && (
<TermsOfService>
<ExchangeXmlTos doc={terms.content.document} />
</TermsOfService>
)}
{terms.content.type === "plain" && (
<div style={{ textAlign: "left" }}>
<pre>{terms.content.content}</pre>
</div>
)}
{terms.content.type === "html" && (
<iframe src={terms.content.href.toString()} />
)}
{terms.content.type === "pdf" && (
<a href={terms.content.location.toString()} download="tos.pdf">
<i18n.Translate>Download Terms of Service</i18n.Translate>
</a>
)}
</section>
)}
{termsAccepted && ableToReviewTermsOfService && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<LinkSuccess
upperCased
onClick={showingTermsOfService.button.onClick}
>
<i18n.Translate>Hide terms of service</i18n.Translate>
</LinkSuccess>
</section>
)}
{terms.status !== "notfound" && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<CheckboxOutlined
name="terms"
enabled={termsAccepted.value}
label={
<i18n.Translate>
I accept the exchange terms of service
</i18n.Translate>
}
onToggle={termsAccepted.button.onClick}
/>
</section>
)}
</Fragment>
);
}

View File

@ -1,187 +0,0 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { createExample } from "../test-utils.js";
import { termsHtml, termsPdf, termsPlain, termsXml } from "./termsExample.js";
import { TermsOfServiceSection as TestedComponent } from "./TermsOfServiceSection.js";
function parseFromString(s: string): Document {
if (typeof window === "undefined") {
return {} as Document;
}
return new window.DOMParser().parseFromString(s, "text/xml");
}
export default {
title: "cta/terms of service",
component: TestedComponent,
};
export const ReviewingPLAIN = createExample(TestedComponent, {
terms: {
content: {
type: "plain",
content: termsPlain,
},
status: "new",
version: "",
},
reviewing: true,
});
export const ReviewingHTML = createExample(TestedComponent, {
terms: {
content: {
type: "html",
href: new URL(`data:text/html;base64,${toBase64(termsHtml)}`),
},
version: "",
status: "new",
},
reviewing: true,
});
function toBase64(str: string): string {
const encoded = encodeURIComponent(str).replace(
/%([0-9A-F]{2})/g,
function (match, p1) {
return String.fromCharCode(parseInt(p1, 16));
},
);
if (typeof btoa === "undefined") {
//nodejs
return Buffer.from(encoded).toString("base64");
} else {
//browser
return btoa(encoded);
}
}
export const ReviewingPDF = createExample(TestedComponent, {
terms: {
content: {
type: "pdf",
location: new URL(`data:text/html;base64,${toBase64(termsPdf)}`),
},
status: "new",
version: "",
},
reviewing: true,
});
export const ReviewingXML = createExample(TestedComponent, {
terms: {
content: {
type: "xml",
document: parseFromString(termsXml),
},
status: "new",
version: "",
},
reviewing: true,
});
export const NewAccepted = createExample(TestedComponent, {
terms: {
content: {
type: "xml",
document: parseFromString(termsXml),
},
status: "new",
version: "",
},
reviewed: true,
});
export const ShowAgainXML = createExample(TestedComponent, {
terms: {
content: {
type: "xml",
document: parseFromString(termsXml),
},
version: "",
status: "new",
},
reviewed: true,
reviewing: true,
});
export const ChangedButNotReviewable = createExample(TestedComponent, {
terms: {
content: {
type: "xml",
document: parseFromString(termsXml),
},
version: "",
status: "changed",
},
});
export const ChangedAndAllowReview = createExample(TestedComponent, {
terms: {
content: {
type: "xml",
document: parseFromString(termsXml),
},
version: "",
status: "changed",
},
onReview: () => null,
});
export const NewButNotReviewable = createExample(TestedComponent, {
terms: {
content: {
type: "xml",
document: parseFromString(termsXml),
},
version: "",
status: "new",
},
});
export const NewAndAllowReview = createExample(TestedComponent, {
terms: {
content: {
type: "xml",
document: parseFromString(termsXml),
},
version: "",
status: "new",
},
onReview: () => null,
});
export const NotFound = createExample(TestedComponent, {
terms: {
content: undefined,
status: "notfound",
version: "",
},
});
export const AlreadyAccepted = createExample(TestedComponent, {
terms: {
status: "accepted",
content: undefined,
version: "",
},
});

View File

@ -1,196 +0,0 @@
/*
This file is part of GNU Taler
(C) 2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Fragment, h, VNode } from "preact";
import { CheckboxOutlined } from "../components/CheckboxOutlined.js";
import { ExchangeXmlTos } from "../components/ExchangeToS.js";
import {
LinkSuccess,
TermsOfService,
WarningBox,
WarningText,
} from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.js";
import { Button } from "../mui/Button.js";
import { TermsState } from "../utils/index.js";
export interface Props {
reviewing: boolean;
reviewed: boolean;
terms: TermsState;
onReview?: (b: boolean) => void;
onAccept: (b: boolean) => void;
}
export function TermsOfServiceSection({
reviewed,
reviewing,
terms,
onAccept,
onReview,
}: Props): VNode {
const { i18n } = useTranslationContext();
const ableToReviewTermsOfService = onReview !== undefined;
if (!reviewing) {
if (!reviewed) {
if (!ableToReviewTermsOfService) {
return (
<Fragment>
{terms.status === "notfound" && (
<section
style={{ justifyContent: "space-around", display: "flex" }}
>
<WarningText>
<i18n.Translate>
Exchange doesn&apos;t have terms of service
</i18n.Translate>
</WarningText>
</section>
)}
</Fragment>
);
}
return (
<Fragment>
{terms.status === "notfound" && (
<section
style={{ justifyContent: "space-around", display: "flex" }}
>
<WarningText>
<i18n.Translate>
Exchange doesn&apos;t have terms of service
</i18n.Translate>
</WarningText>
</section>
)}
{terms.status === "new" && (
<section
style={{ justifyContent: "space-around", display: "flex" }}
>
<Button
variant="contained"
color="success"
onClick={async () => onReview(true)}
>
<i18n.Translate>
Review exchange terms of service
</i18n.Translate>
</Button>
</section>
)}
{terms.status === "changed" && (
<section
style={{ justifyContent: "space-around", display: "flex" }}
>
<Button
variant="contained"
color="success"
onClick={async () => onReview(true)}
>
<i18n.Translate>
Review new version of terms of service
</i18n.Translate>
</Button>
</section>
)}
</Fragment>
);
}
return (
<Fragment>
{ableToReviewTermsOfService && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<LinkSuccess upperCased onClick={() => onReview(true)}>
<i18n.Translate>Show terms of service</i18n.Translate>
</LinkSuccess>
</section>
)}
<section style={{ justifyContent: "space-around", display: "flex" }}>
<CheckboxOutlined
name="terms"
enabled={reviewed}
label={
<i18n.Translate>
I accept the exchange terms of service
</i18n.Translate>
}
onToggle={async () => {
onAccept(!reviewed);
if (ableToReviewTermsOfService) onReview(false);
}}
/>
</section>
</Fragment>
);
}
return (
<Fragment>
{terms.status !== "notfound" && !terms.content && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<WarningBox>
<i18n.Translate>
The exchange reply with a empty terms of service
</i18n.Translate>
</WarningBox>
</section>
)}
{terms.status !== "accepted" && terms.content && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
{terms.content.type === "xml" && (
<TermsOfService>
<ExchangeXmlTos doc={terms.content.document} />
</TermsOfService>
)}
{terms.content.type === "plain" && (
<div style={{ textAlign: "left" }}>
<pre>{terms.content.content}</pre>
</div>
)}
{terms.content.type === "html" && (
<iframe src={terms.content.href.toString()} />
)}
{terms.content.type === "pdf" && (
<a href={terms.content.location.toString()} download="tos.pdf">
<i18n.Translate>Download Terms of Service</i18n.Translate>
</a>
)}
</section>
)}
{reviewed && ableToReviewTermsOfService && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<LinkSuccess upperCased onClick={() => onReview(false)}>
<i18n.Translate>Hide terms of service</i18n.Translate>
</LinkSuccess>
</section>
)}
{terms.status !== "notfound" && (
<section style={{ justifyContent: "space-around", display: "flex" }}>
<CheckboxOutlined
name="terms"
enabled={reviewed}
label={
<i18n.Translate>
I accept the exchange terms of service
</i18n.Translate>
}
onToggle={async () => {
onAccept(!reviewed);
if (ableToReviewTermsOfService) onReview(false);
}}
/>
</section>
)}
</Fragment>
);
}

View File

@ -17,10 +17,9 @@
import { AmountJson } from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js";
import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js";
import { ButtonHandler } from "../../mui/handlers.js";
import { compose, StateViewMap } from "../../utils/index.js";
import * as wxApi from "../../wxApi.js";
import { Props as TermsOfServiceSectionProps } from "../TermsOfServiceSection.js";
import { useComponentState } from "./state.js";
import {
AcceptedView,

View File

@ -14,27 +14,20 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { AmountJson } from "@gnu-taler/taler-util";
import { AmountJson, ExchangeListItem } from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js";
import {
State as SelectExchangeState
} from "../../hooks/useSelectedExchange.js";
import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js";
import { compose, StateViewMap } from "../../utils/index.js";
import * as wxApi from "../../wxApi.js";
import { Props as TermsOfServiceSectionProps } from "../TermsOfServiceSection.js";
import {
useComponentStateFromParams,
useComponentStateFromURI
useComponentStateFromURI,
} from "./state.js";
import { ExchangeSelectionPage } from "../../wallet/ExchangeSelection/index.js";
import {
LoadingInfoView,
LoadingUriView,
SuccessView
} from "./views.js";
import { LoadingInfoView, LoadingUriView, SuccessView } from "./views.js";
import { NoExchangesView } from "../../wallet/ExchangeSelection/views.js";
export interface PropsFromURI {
@ -75,7 +68,7 @@ export namespace State {
status: "success";
error: undefined;
exchangeUrl: string;
currentExchange: ExchangeListItem;
chosenAmount: AmountJson;
withdrawalFee: AmountJson;
@ -83,13 +76,12 @@ export namespace State {
doWithdrawal: ButtonHandler;
doSelectExchange: ButtonHandler;
tosProps?: TermsOfServiceSectionProps;
mustAcceptFirst: boolean;
ageRestriction?: SelectFieldHandler;
talerWithdrawUri?: string;
cancel: () => Promise<void>;
onTosUpdate: () => void;
};
}

View File

@ -15,17 +15,15 @@
*/
/* eslint-disable react-hooks/rules-of-hooks */
import { AmountJson, Amounts, ExchangeListItem, parsePaytoUri } from "@gnu-taler/taler-util";
import { AmountJson, Amounts, ExchangeListItem } from "@gnu-taler/taler-util";
import { TalerError } from "@gnu-taler/taler-wallet-core";
import { useState } from "preact/hooks";
import { Amount } from "../../components/Amount.js";
import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
import { useSelectedExchange } from "../../hooks/useSelectedExchange.js";
import { buildTermsOfServiceState } from "../../utils/index.js";
import * as wxApi from "../../wxApi.js";
import { PropsFromURI, PropsFromParams, State } from "./index.js";
import { PropsFromParams, PropsFromURI, State } from "./index.js";
type RecursiveState<S extends object> = S | (() => RecursiveState<S>)
type RecursiveState<S extends object> = S | (() => RecursiveState<S>);
export function useComponentStateFromParams(
{ amount, cancel, onSuccess }: PropsFromParams,
@ -46,18 +44,38 @@ export function useComponentStateFromParams(
}
const chosenAmount = uriInfoHook.response.amount;
const exchangeList = uriInfoHook.response.exchanges.exchanges
const exchangeList = uriInfoHook.response.exchanges.exchanges;
async function doManualWithdraw(exchange: string, ageRestricted: number | undefined): Promise<{ transactionId: string, confirmTransferUrl: string | undefined }> {
const res = await api.acceptManualWithdrawal(exchange, Amounts.stringify(chosenAmount), ageRestricted);
async function doManualWithdraw(
exchange: string,
ageRestricted: number | undefined,
): Promise<{
transactionId: string;
confirmTransferUrl: string | undefined;
}> {
const res = await api.acceptManualWithdrawal(
exchange,
Amounts.stringify(chosenAmount),
ageRestricted,
);
return {
confirmTransferUrl: undefined,
transactionId: res.transactionId
transactionId: res.transactionId,
};
}
return () => exchangeSelectionState(doManualWithdraw, cancel, onSuccess, undefined, chosenAmount, exchangeList, undefined, api)
return () =>
exchangeSelectionState(
uriInfoHook.retry,
doManualWithdraw,
cancel,
onSuccess,
undefined,
chosenAmount,
exchangeList,
undefined,
api,
);
}
export function useComponentStateFromURI(
@ -75,7 +93,12 @@ export function useComponentStateFromURI(
});
const exchanges = await api.listExchanges();
const { amount, defaultExchangeBaseUrl } = uriInfo;
return { talerWithdrawUri, amount: Amounts.parseOrThrow(amount), thisExchange: defaultExchangeBaseUrl, exchanges };
return {
talerWithdrawUri,
amount: Amounts.parseOrThrow(amount),
thisExchange: defaultExchangeBaseUrl,
exchanges,
};
});
if (!uriInfoHook) return { status: "loading", error: undefined };
@ -90,53 +113,75 @@ export function useComponentStateFromURI(
const uri = uriInfoHook.response.talerWithdrawUri;
const chosenAmount = uriInfoHook.response.amount;
const defaultExchange = uriInfoHook.response.thisExchange;
const exchangeList = uriInfoHook.response.exchanges.exchanges
const exchangeList = uriInfoHook.response.exchanges.exchanges;
async function doManagedWithdraw(exchange: string, ageRestricted: number | undefined): Promise<{ transactionId: string, confirmTransferUrl: string | undefined }> {
const res = await api.acceptWithdrawal(uri, exchange, ageRestricted,);
async function doManagedWithdraw(
exchange: string,
ageRestricted: number | undefined,
): Promise<{
transactionId: string;
confirmTransferUrl: string | undefined;
}> {
const res = await api.acceptWithdrawal(uri, exchange, ageRestricted);
return {
confirmTransferUrl: res.confirmTransferUrl,
transactionId: res.transactionId
transactionId: res.transactionId,
};
}
return () => exchangeSelectionState(doManagedWithdraw, cancel, onSuccess, uri, chosenAmount, exchangeList, defaultExchange, api)
return () =>
exchangeSelectionState(
uriInfoHook.retry,
doManagedWithdraw,
cancel,
onSuccess,
uri,
chosenAmount,
exchangeList,
defaultExchange,
api,
);
}
type ManualOrManagedWithdrawFunction = (exchange: string, ageRestricted: number | undefined) => Promise<{ transactionId: string, confirmTransferUrl: string | undefined }>
type ManualOrManagedWithdrawFunction = (
exchange: string,
ageRestricted: number | undefined,
) => Promise<{ transactionId: string; confirmTransferUrl: string | undefined }>;
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> {
function exchangeSelectionState(
onTosUpdate: () => void,
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> {
const selectedExchange = useSelectedExchange({
currency: chosenAmount.currency,
defaultExchange,
list: exchangeList,
});
const selectedExchange = useSelectedExchange({ currency: chosenAmount.currency, defaultExchange, list: exchangeList })
if (selectedExchange.status !== 'ready') {
return selectedExchange
if (selectedExchange.status !== "ready") {
return selectedExchange;
}
return () => {
const [ageRestricted, setAgeRestricted] = useState(0);
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 };
}, []);
const currentExchange = selectedExchange.selected;
const tosNeedToBeAccepted =
!currentExchange.tos.acceptedVersion ||
currentExchange.tos.currentVersion !==
currentExchange.tos.acceptedVersion;
/**
* 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,
@ -155,20 +200,18 @@ function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, can
};
}, []);
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);
async function doWithdrawAndCheckError(): Promise<void> {
try {
setDoingWithdraw(true);
const res = await doWithdraw(currentExchange.exchangeBaseUrl, !ageRestricted ? undefined : ageRestricted)
const res = await doWithdraw(
currentExchange.exchangeBaseUrl,
!ageRestricted ? undefined : ageRestricted,
);
if (res.confirmTransferUrl) {
document.location.href = res.confirmTransferUrl;
} else {
@ -201,33 +244,6 @@ function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, can
).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}` }),
@ -242,17 +258,17 @@ function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, can
//TODO: calculate based on exchange info
const ageRestriction = ageRestrictionEnabled
? {
list: ageRestrictionOptions,
value: String(ageRestricted),
onChange: async (v: string) => setAgeRestricted(parseInt(v, 10)),
}
list: ageRestrictionOptions,
value: String(ageRestricted),
onChange: async (v: string) => setAgeRestricted(parseInt(v, 10)),
}
: undefined;
return {
status: "success",
error: undefined,
doSelectExchange: selectedExchange.doSelect,
exchangeUrl: currentExchange.exchangeBaseUrl,
currentExchange,
toBeReceived,
withdrawalFee,
chosenAmount,
@ -260,22 +276,13 @@ function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, can
ageRestriction,
doWithdrawal: {
onClick:
doingWithdraw || (mustAcceptFirst && !reviewed)
doingWithdraw || tosNeedToBeAccepted
? undefined
: doWithdrawAndCheckError,
error: withdrawError,
},
tosProps: !termsState
? undefined
: {
onAccept,
onReview: setReviewing,
reviewed: reviewed,
reviewing: reviewing,
terms: termsState,
},
mustAcceptFirst,
onTosUpdate,
cancel,
};
}
};
}

View File

@ -19,8 +19,9 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { ExchangeListItem } from "@gnu-taler/taler-util";
import { createExample } from "../../test-utils.js";
import { TermsState } from "../../utils/index.js";
// import { TermsState } from "../../utils/index.js";
import { SuccessView } from "./views.js";
export default {
@ -38,16 +39,16 @@ const nullHandler = {
},
};
const normalTosState = {
terms: {
status: "accepted",
version: "",
} as TermsState,
onAccept: () => null,
onReview: () => null,
reviewed: false,
reviewing: false,
};
// const normalTosState = {
// terms: {
// status: "accepted",
// version: "",
// } as TermsState,
// onAccept: () => null,
// onReview: () => null,
// reviewed: false,
// reviewing: false,
// };
const ageRestrictionOptions: Record<string, string> = "6:12:18"
.split(":")
@ -69,15 +70,16 @@ export const TermsOfServiceNotYetLoaded = createExample(SuccessView, {
fraction: 10000000,
},
doWithdrawal: nullHandler,
exchangeUrl: "https://exchange.demo.taler.net",
mustAcceptFirst: false,
currentExchange: {
exchangeBaseUrl: "https://exchange.demo.taler.net",
tos: {},
} as Partial<ExchangeListItem> as any,
withdrawalFee: {
currency: "USD",
fraction: 10000000,
value: 1,
},
doSelectExchange: {
},
doSelectExchange: {},
toBeReceived: {
currency: "USD",
fraction: 0,
@ -94,8 +96,10 @@ export const WithSomeFee = createExample(SuccessView, {
fraction: 10000000,
},
doWithdrawal: nullHandler,
exchangeUrl: "https://exchange.demo.taler.net",
mustAcceptFirst: false,
currentExchange: {
exchangeBaseUrl: "https://exchange.demo.taler.net",
tos: {},
} as Partial<ExchangeListItem> as any,
withdrawalFee: {
currency: "USD",
fraction: 10000000,
@ -106,9 +110,7 @@ export const WithSomeFee = createExample(SuccessView, {
fraction: 0,
value: 1,
},
doSelectExchange: {
},
tosProps: normalTosState,
doSelectExchange: {},
});
export const WithoutFee = createExample(SuccessView, {
@ -120,21 +122,21 @@ export const WithoutFee = createExample(SuccessView, {
fraction: 0,
},
doWithdrawal: nullHandler,
exchangeUrl: "https://exchange.demo.taler.net",
mustAcceptFirst: false,
currentExchange: {
exchangeBaseUrl: "https://exchange.demo.taler.net",
tos: {},
} as Partial<ExchangeListItem> as any,
withdrawalFee: {
currency: "USD",
fraction: 0,
value: 0,
},
doSelectExchange: {
},
doSelectExchange: {},
toBeReceived: {
currency: "USD",
fraction: 0,
value: 2,
},
tosProps: normalTosState,
});
export const EditExchangeUntouched = createExample(SuccessView, {
@ -146,21 +148,21 @@ export const EditExchangeUntouched = createExample(SuccessView, {
fraction: 10000000,
},
doWithdrawal: nullHandler,
exchangeUrl: "https://exchange.demo.taler.net",
mustAcceptFirst: false,
currentExchange: {
exchangeBaseUrl: "https://exchange.demo.taler.net",
tos: {},
} as Partial<ExchangeListItem> as any,
withdrawalFee: {
currency: "USD",
fraction: 0,
value: 0,
},
doSelectExchange: {
},
doSelectExchange: {},
toBeReceived: {
currency: "USD",
fraction: 0,
value: 2,
},
tosProps: normalTosState,
});
export const EditExchangeModified = createExample(SuccessView, {
@ -172,21 +174,21 @@ export const EditExchangeModified = createExample(SuccessView, {
fraction: 10000000,
},
doWithdrawal: nullHandler,
exchangeUrl: "https://exchange.demo.taler.net",
mustAcceptFirst: false,
currentExchange: {
exchangeBaseUrl: "https://exchange.demo.taler.net",
tos: {},
} as Partial<ExchangeListItem> as any,
withdrawalFee: {
currency: "USD",
fraction: 0,
value: 0,
},
doSelectExchange: {
},
doSelectExchange: {},
toBeReceived: {
currency: "USD",
fraction: 0,
value: 2,
},
tosProps: normalTosState,
});
export const WithAgeRestriction = createExample(SuccessView, {
@ -198,11 +200,12 @@ export const WithAgeRestriction = createExample(SuccessView, {
value: 2,
fraction: 10000000,
},
doSelectExchange: {
},
doSelectExchange: {},
doWithdrawal: nullHandler,
exchangeUrl: "https://exchange.demo.taler.net",
mustAcceptFirst: false,
currentExchange: {
exchangeBaseUrl: "https://exchange.demo.taler.net",
tos: {},
} as Partial<ExchangeListItem> as any,
withdrawalFee: {
currency: "USD",
fraction: 0,
@ -213,5 +216,4 @@ export const WithAgeRestriction = createExample(SuccessView, {
fraction: 0,
value: 2,
},
tosProps: normalTosState,
});

View File

@ -37,7 +37,8 @@ const exchanges: ExchangeFullDetails[] = [
exchangeBaseUrl: "http://exchange.demo.taler.net",
paytoUris: [],
tos: {
acceptedVersion: "",
acceptedVersion: "v1",
currentVersion: "v1",
},
auditors: [
{
@ -58,7 +59,7 @@ const exchanges: ExchangeFullDetails[] = [
accounts: [],
feesForType: {},
},
},
} as Partial<ExchangeFullDetails> as ExchangeFullDetails,
];
describe("Withdraw CTA states", () => {
@ -161,17 +162,20 @@ describe("Withdraw CTA states", () => {
},
{
listExchanges: async () => ({ exchanges }),
getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({
amount: "ARS:2",
possibleExchanges: exchanges,
defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl,
}),
getWithdrawalDetailsForUri: async ({
talerWithdrawUri,
}: any): Promise<ExchangeWithdrawDetails> =>
({
amount: "ARS:2",
possibleExchanges: exchanges,
defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl,
} as Partial<ExchangeWithdrawDetails> as ExchangeWithdrawDetails),
getExchangeWithdrawalInfo:
async (): Promise<ExchangeWithdrawDetails> =>
({
withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2",
} as any),
({
withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2",
} as any),
getExchangeTos: async (): Promise<GetExchangeTosResult> => ({
contentType: "text",
content: "just accept",
@ -205,25 +209,39 @@ describe("Withdraw CTA states", () => {
expect(state.status).equals("success");
if (state.status !== "success") return;
// expect(state.exchange.isDirty).false;
// expect(state.exchange.value).equal("http://exchange.demo.taler.net");
// expect(state.exchange.list).deep.equal({
// "http://exchange.demo.taler.net": "http://exchange.demo.taler.net",
// });
// expect(state.showExchangeSelection).false;
expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"));
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.doWithdrawal.onClick).not.undefined;
expect(state.mustAcceptFirst).false;
}
await assertNoPendingUpdate();
});
it("should be accept the tos before withdraw", async () => {
const listExchangesResponse = {
exchanges: exchanges.map((e) => ({
...e,
tos: {
...e.tos,
acceptedVersion: undefined,
},
})) as ExchangeFullDetails[],
};
function updateAcceptedVersionToCurrentVersion(): void {
listExchangesResponse.exchanges = listExchangesResponse.exchanges.map(
(e) => ({
...e,
tos: {
...e.tos,
acceptedVersion: e.tos.currentVersion,
},
}),
);
}
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() =>
useComponentStateFromURI(
@ -237,18 +255,19 @@ describe("Withdraw CTA states", () => {
},
},
{
listExchanges: async () => ({ exchanges }),
getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({
amount: "ARS:2",
possibleExchanges: exchanges,
defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl,
}),
listExchanges: async () => listExchangesResponse,
getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) =>
({
amount: "ARS:2",
possibleExchanges: exchanges,
defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl,
} as Partial<ExchangeWithdrawDetails> as ExchangeWithdrawDetails),
getExchangeWithdrawalInfo:
async (): Promise<ExchangeWithdrawDetails> =>
({
withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2",
} as any),
({
withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2",
} as any),
getExchangeTos: async (): Promise<GetExchangeTosResult> => ({
contentType: "text",
content: "just accept",
@ -283,22 +302,14 @@ describe("Withdraw CTA states", () => {
expect(state.status).equals("success");
if (state.status !== "success") return;
// expect(state.exchange.isDirty).false;
// expect(state.exchange.value).equal("http://exchange.demo.taler.net");
// expect(state.exchange.list).deep.equal({
// "http://exchange.demo.taler.net": "http://exchange.demo.taler.net",
// });
// expect(state.showExchangeSelection).false;
expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"));
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.doWithdrawal.onClick).undefined;
expect(state.mustAcceptFirst).true;
// accept TOS
state.tosProps?.onAccept(true);
updateAcceptedVersionToCurrentVersion();
state.onTosUpdate();
}
await waitNextUpdate();
@ -308,19 +319,11 @@ describe("Withdraw CTA states", () => {
expect(state.status).equals("success");
if (state.status !== "success") return;
// expect(state.exchange.isDirty).false;
// expect(state.exchange.value).equal("http://exchange.demo.taler.net");
// expect(state.exchange.list).deep.equal({
// "http://exchange.demo.taler.net": "http://exchange.demo.taler.net",
// });
// expect(state.showExchangeSelection).false;
expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0"));
expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2"));
expect(state.doWithdrawal.onClick).not.undefined;
expect(state.mustAcceptFirst).true;
}
await assertNoPendingUpdate();

View File

@ -15,30 +15,28 @@
*/
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { Amount } from "../../components/Amount.js";
import { ErrorTalerOperation } from "../../components/ErrorTalerOperation.js";
import { LoadingError } from "../../components/LoadingError.js";
import { LogoHeader } from "../../components/LogoHeader.js";
import { Part } from "../../components/Part.js";
import { QR } from "../../components/QR.js";
import { SelectList } from "../../components/SelectList.js";
import {
Input,
Link,
LinkSuccess,
SubTitle,
SuccessBox,
SvgIcon,
WalletAction,
} from "../../components/styled/index.js";
import { useTranslationContext } from "../../context/translation.js";
import { Button } from "../../mui/Button.js";
import { ExchangeDetails, WithdrawDetails } from "../../wallet/Transaction.js";
import { TermsOfServiceSection } from "../TermsOfServiceSection.js";
import { State } from "./index.js";
import editIcon from "../../svg/edit_24px.svg";
import { Amount } from "../../components/Amount.js";
import { QR } from "../../components/QR.js";
import { useState } from "preact/hooks";
import { ErrorMessage } from "../../components/ErrorMessage.js";
import { ExchangeDetails, WithdrawDetails } from "../../wallet/Transaction.js";
import { TermsOfService } from "../TermsOfService/index.js";
import { State } from "./index.js";
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
const { i18n } = useTranslationContext();
@ -66,6 +64,9 @@ export function LoadingInfoView({ error }: State.LoadingInfoError): VNode {
export function SuccessView(state: State.Success): VNode {
const { i18n } = useTranslationContext();
const currentTosVersionIsAccepted =
state.currentExchange.tos.acceptedVersion ===
state.currentExchange.tos.currentVersion;
return (
<WalletAction>
<LogoHeader />
@ -103,7 +104,9 @@ export function SuccessView(state: State.Success): VNode {
</Button>
</div>
}
text={<ExchangeDetails exchange={state.exchangeUrl} />}
text={
<ExchangeDetails exchange={state.currentExchange.exchangeBaseUrl} />
}
kind="neutral"
big
/>
@ -130,43 +133,29 @@ export function SuccessView(state: State.Success): VNode {
</Input>
)}
</section>
{state.tosProps && <TermsOfServiceSection {...state.tosProps} />}
{state.tosProps ? (
<Fragment>
<section>
{(state.tosProps.terms.status === "accepted" ||
(state.mustAcceptFirst && state.tosProps.reviewed)) && (
<Button
variant="contained"
color="success"
disabled={!state.doWithdrawal.onClick}
onClick={state.doWithdrawal.onClick}
>
<i18n.Translate>
Withdraw &nbsp; <Amount value={state.toBeReceived} />
</i18n.Translate>
</Button>
)}
{state.tosProps.terms.status === "notfound" && (
<Button
variant="contained"
color="warning"
disabled={!state.doWithdrawal.onClick}
onClick={state.doWithdrawal.onClick}
>
<i18n.Translate>Withdraw anyway</i18n.Translate>
</Button>
)}
</section>
{state.talerWithdrawUri ? (
<WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} />
) : undefined}
</Fragment>
) : (
<section>
<i18n.Translate>Loading terms of service...</i18n.Translate>
</section>
)}
<section>
{currentTosVersionIsAccepted ? (
<Button
variant="contained"
color="success"
disabled={!state.doWithdrawal.onClick}
onClick={state.doWithdrawal.onClick}
>
<i18n.Translate>
Withdraw &nbsp; <Amount value={state.toBeReceived} />
</i18n.Translate>
</Button>
) : (
<TermsOfService
exchangeUrl={state.currentExchange.exchangeBaseUrl}
onChange={state.onTosUpdate}
/>
)}
</section>
{state.talerWithdrawUri ? (
<WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} />
) : undefined}
<section>
<Link upperCased onClick={state.cancel}>
<i18n.Translate>Cancel</i18n.Translate>

View File

@ -24,7 +24,7 @@ import * as a3 from "./Payment/stories.jsx";
import * as a4 from "./Refund/stories.jsx";
import * as a5 from "./Tip/stories.jsx";
import * as a6 from "./Withdraw/stories.jsx";
import * as a7 from "./TermsOfServiceSection.stories.js";
import * as a7 from "./TermsOfService/stories.js";
import * as a8 from "./InvoiceCreate/stories.js";
import * as a9 from "./InvoicePay/stories.js";
import * as a10 from "./TransferCreate/stories.js";

View File

@ -22,21 +22,21 @@ type State = State.Ready | State.NoExchange | State.Selecting;
export namespace State {
export interface NoExchange {
status: "no-exchange"
status: "no-exchange";
error: undefined;
currency: string | undefined;
}
export interface Ready {
status: "ready",
doSelect: ButtonHandler,
status: "ready";
doSelect: ButtonHandler;
selected: ExchangeListItem;
}
export interface Selecting {
status: "selecting-exchange",
error: undefined,
status: "selecting-exchange";
error: undefined;
onSelection: (url: string) => Promise<void>;
onCancel: () => Promise<void>;
list: ExchangeListItem[],
list: ExchangeListItem[];
currency: string;
currentExchange: string;
}
@ -45,38 +45,42 @@ export namespace State {
interface Props {
currency: string;
//there is a preference for the default at the initial state
defaultExchange?: string,
defaultExchange?: string;
//list of exchanges
list: ExchangeListItem[],
list: ExchangeListItem[];
}
export function useSelectedExchange({ currency, defaultExchange, list }: Props): State {
export function useSelectedExchange({
currency,
defaultExchange,
list,
}: Props): State {
const [isSelecting, setIsSelecting] = useState(false);
const [selectedExchange, setSelectedExchange] = useState<string | undefined>(undefined);
const [selectedExchange, setSelectedExchange] = useState<string | undefined>(
undefined,
);
if (!list.length) {
return {
status: "no-exchange",
error: undefined,
currency: undefined,
}
};
}
const listCurrency = list.filter((e) => e.currency === currency)
const listCurrency = list.filter((e) => e.currency === currency);
if (!listCurrency.length) {
// there should be at least one exchange for this currency
return {
status: "no-exchange",
error: undefined,
currency,
}
};
}
if (isSelecting) {
const currentExchange = selectedExchange ?? defaultExchange ?? listCurrency[0].exchangeBaseUrl;
const currentExchange =
selectedExchange ?? defaultExchange ?? listCurrency[0].exchangeBaseUrl;
return {
status: "selecting-exchange",
error: undefined,
@ -85,44 +89,46 @@ export function useSelectedExchange({ currency, defaultExchange, list }: Props):
currentExchange: currentExchange,
onSelection: async (exchangeBaseUrl: string) => {
setIsSelecting(false);
setSelectedExchange(exchangeBaseUrl)
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 = !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
};
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)
onClick: async () => setIsSelecting(true),
},
selected: listCurrency[0]
}
selected: listCurrency[0],
};
}

View File

@ -82,7 +82,7 @@ export function renderNodeOrBrowser(Component: any, args: any): void {
document.body.removeChild(div);
}
}
type RecursiveState<S> = S | (() => RecursiveState<S>)
type RecursiveState<S> = S | (() => RecursiveState<S>);
interface Mounted<T> {
unmount: () => void;
@ -107,12 +107,12 @@ export function mountHook<T extends object>(
// component that's going to hold the hook
function Component(): VNode {
try {
let componentOrResult = callback()
let componentOrResult = callback();
while (typeof componentOrResult === "function") {
componentOrResult = componentOrResult();
}
//typecheck fails here
const l: Exclude<T, () => void> = componentOrResult as any
const l: Exclude<T, () => void> = componentOrResult as any;
lastResult = l;
} catch (e) {
if (e instanceof Error) {

View File

@ -14,12 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
AmountJson,
Amounts,
GetExchangeTosResult,
} from "@gnu-taler/taler-util";
import { VNode, createElement } from "preact";
import { createElement, VNode } from "preact";
function getJsonIfOk(r: Response): Promise<any> {
if (r.ok) {
@ -31,7 +26,8 @@ function getJsonIfOk(r: Response): Promise<any> {
}
throw new Error(
`Try another server: (${r.status}) ${r.statusText || "internal server error"
`Try another server: (${r.status}) ${
r.statusText || "internal server error"
}`,
);
}
@ -78,140 +74,25 @@ export async function queryToSlashKeys<T>(url: string): Promise<T> {
return timeout(3000, query);
}
export function buildTermsOfServiceState(
tos: GetExchangeTosResult,
): TermsState {
const content: TermsDocument | undefined = parseTermsOfServiceContent(
tos.contentType,
tos.content,
);
const status: TermsStatus = buildTermsOfServiceStatus(
tos.content,
tos.acceptedEtag,
tos.currentEtag,
);
return { content, status, version: tos.currentEtag };
}
export function buildTermsOfServiceStatus(
content: string | undefined,
acceptedVersion: string | undefined,
currentVersion: string | undefined,
): TermsStatus {
return !content
? "notfound"
: !acceptedVersion
? "new"
: acceptedVersion !== currentVersion
? "changed"
: "accepted";
}
function parseTermsOfServiceContent(
type: string,
text: string,
): TermsDocument | undefined {
if (type === "text/xml") {
try {
const document = new DOMParser().parseFromString(text, "text/xml");
return { type: "xml", document };
} catch (e) {
console.log(e);
}
} else if (type === "text/html") {
try {
const href = new URL(text);
return { type: "html", href };
} catch (e) {
console.log(e);
}
} else if (type === "text/json") {
try {
const data = JSON.parse(text);
return { type: "json", data };
} catch (e) {
console.log(e);
}
} else if (type === "text/pdf") {
try {
const location = new URL(text);
return { type: "pdf", location };
} catch (e) {
console.log(e);
}
} else if (type === "text/plain") {
try {
const content = text;
return { type: "plain", content };
} catch (e) {
console.log(e);
}
}
return undefined;
}
export type TermsState = {
content: TermsDocument | undefined;
status: TermsStatus;
version: string;
};
type TermsStatus = "new" | "accepted" | "changed" | "notfound";
type TermsDocument =
| TermsDocumentXml
| TermsDocumentHtml
| TermsDocumentPlain
| TermsDocumentJson
| TermsDocumentPdf;
export interface TermsDocumentXml {
type: "xml";
document: Document;
}
export interface TermsDocumentHtml {
type: "html";
href: URL;
}
export interface TermsDocumentPlain {
type: "plain";
content: string;
}
export interface TermsDocumentJson {
type: "json";
data: any;
}
export interface TermsDocumentPdf {
type: "pdf";
location: URL;
}
export type StateFunc<S> = (p: S) => VNode;
export type StateViewMap<StateType extends { status: string }> = {
[S in StateType as S["status"]]: StateFunc<S>;
};
type RecursiveState<S extends object> = S | (() => RecursiveState<S>)
type RecursiveState<S extends object> = S | (() => RecursiveState<S>);
export function compose<SType extends { status: string }, PType>(
name: string,
hook: (p: PType) => RecursiveState<SType>,
viewMap: StateViewMap<SType>,
): (p: PType) => VNode {
function withHook(stateHook: () => RecursiveState<SType>): () => VNode {
function TheComponent(): VNode {
const state = stateHook();
if (typeof state === "function") {
const subComponent = withHook(state)
const subComponent = withHook(state);
return createElement(subComponent, {});
}
@ -225,7 +106,7 @@ export function compose<SType extends { status: string }, PType>(
}
return (p: PType) => {
const h = withHook(() => hook(p))
return h()
const h = withHook(() => hook(p));
return h();
};
}

View File

@ -20,7 +20,11 @@ import { compose, StateViewMap } from "../../utils/index.js";
import { LoadingUriView, ReadyView } from "./views.js";
import * as wxApi from "../../wxApi.js";
import { useComponentState } from "./state.js";
import { ButtonHandler, SelectFieldHandler, TextFieldHandler } from "../../mui/handlers.js";
import {
ButtonHandler,
SelectFieldHandler,
TextFieldHandler,
} from "../../mui/handlers.js";
export interface Props {
currency: string;

View File

@ -20,16 +20,18 @@ import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js";
export function useComponentState({ currency, onAccountAdded, onCancel }: Props, api: typeof wxApi): State {
export function useComponentState(
{ currency, onAccountAdded, onCancel }: Props,
api: typeof wxApi,
): State {
const hook = useAsyncAsHook(async () => {
const { accounts } = await api.listKnownBankAccounts(currency);
return { accounts };
});
const [payto, setPayto] = useState("")
const [alias, setAlias] = useState("")
const [type, setType] = useState("")
const [payto, setPayto] = useState("");
const [alias, setAlias] = useState("");
const [type, setType] = useState("");
if (!hook) {
return {
@ -41,31 +43,38 @@ export function useComponentState({ currency, onAccountAdded, onCancel }: Props,
return {
status: "loading-error",
error: hook,
}
};
}
const accountType: Record<string, string> = {
"": "Choose one account",
"iban": "IBAN",
"bitcoin": "Bitcoin",
"x-taler-bank": "Taler Bank"
}
const uri = parsePaytoUri(payto)
const found = hook.response.accounts.findIndex(a => stringifyPaytoUri(a.uri) === payto) !== -1
iban: "IBAN",
bitcoin: "Bitcoin",
"x-taler-bank": "Taler Bank",
};
const uri = parsePaytoUri(payto);
const found =
hook.response.accounts.findIndex(
(a) => stringifyPaytoUri(a.uri) === payto,
) !== -1;
async function addAccount(): Promise<void> {
if (!uri || found) return;
await api.addKnownBankAccounts(uri, currency, alias)
onAccountAdded(payto)
await api.addKnownBankAccounts(uri, currency, alias);
onAccountAdded(payto);
}
const paytoUriError = payto === "" ? undefined
: !uri ? "the uri is not ok"
: found ? "that account is already present"
: undefined
const paytoUriError =
payto === ""
? undefined
: !uri
? "the uri is not ok"
: found
? "that account is already present"
: undefined;
const unableToAdd = !type || !alias || paytoUriError
const unableToAdd = !type || !alias || paytoUriError;
return {
status: "ready",
@ -75,27 +84,27 @@ export function useComponentState({ currency, onAccountAdded, onCancel }: Props,
list: accountType,
value: type,
onChange: async (v) => {
setType(v)
}
setType(v);
},
},
alias: {
value: alias,
onInput: async (v) => {
setAlias(v)
setAlias(v);
},
},
uri: {
value: payto,
error: paytoUriError,
onInput: async (v) => {
setPayto(v)
}
setPayto(v);
},
},
onAccountAdded: {
onClick: unableToAdd ? undefined : addAccount
onClick: unableToAdd ? undefined : addAccount,
},
onCancel: {
onClick: async () => onCancel()
}
onClick: async () => onCancel(),
},
};
}

View File

@ -17,11 +17,22 @@
import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js";
import { compose, StateViewMap } from "../../utils/index.js";
import { AmountOrCurrencyErrorView, LoadingErrorView, NoAccountToDepositView, NoEnoughBalanceView, ReadyView } from "./views.js";
import {
AmountOrCurrencyErrorView,
LoadingErrorView,
NoAccountToDepositView,
NoEnoughBalanceView,
ReadyView,
} from "./views.js";
import * as wxApi from "../../wxApi.js";
import { useComponentState } from "./state.js";
import { AmountJson, PaytoUri } from "@gnu-taler/taler-util";
import { ButtonHandler, SelectFieldHandler, TextFieldHandler, ToggleHandler } from "../../mui/handlers.js";
import {
ButtonHandler,
SelectFieldHandler,
TextFieldHandler,
ToggleHandler,
} from "../../mui/handlers.js";
import { AddAccountPage } from "../AddAccount/index.js";
export interface Props {
@ -31,7 +42,8 @@ export interface Props {
onSuccess: (currency: string) => void;
}
export type State = State.Loading
export type State =
| State.Loading
| State.LoadingUriError
| State.AmountOrCurrencyError
| State.NoEnoughBalance

View File

@ -14,13 +14,24 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { AmountJson, Amounts, DepositGroupFees, KnownBankAccountsInfo, parsePaytoUri, PaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util";
import {
AmountJson,
Amounts,
DepositGroupFees,
KnownBankAccountsInfo,
parsePaytoUri,
PaytoUri,
stringifyPaytoUri,
} from "@gnu-taler/taler-util";
import { useState } from "preact/hooks";
import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js";
import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.js";
export function useComponentState({ amount: amountStr, currency: currencyStr, onCancel, onSuccess }: Props, api: typeof wxApi): State {
export function useComponentState(
{ amount: amountStr, currency: currencyStr, onCancel, onSuccess }: Props,
api: typeof wxApi,
): State {
const parsed = amountStr === undefined ? undefined : Amounts.parse(amountStr);
const currency = parsed !== undefined ? parsed.currency : currencyStr;
@ -46,8 +57,8 @@ export function useComponentState({ amount: amountStr, currency: currencyStr, on
if (!currency) {
return {
status: "amount-or-currency-error",
error: undefined
}
error: undefined,
};
}
if (!hook) {
@ -60,7 +71,7 @@ export function useComponentState({ amount: amountStr, currency: currencyStr, on
return {
status: "loading-error",
error: hook,
}
};
}
const { accounts, balances } = hook.response;
@ -74,13 +85,12 @@ export function useComponentState({ amount: amountStr, currency: currencyStr, on
onAccountAdded: (p: string) => {
updateAccountFromList(p);
setAddingAccount(false);
hook.retry()
hook.retry();
},
onCancel: () => {
setAddingAccount(false);
}
,
}
},
};
}
const bs = balances.filter((b) => b.available.startsWith(currency));
@ -103,13 +113,15 @@ export function useComponentState({ amount: amountStr, currency: currencyStr, on
error: undefined,
currency,
onAddAccount: {
onClick: async () => { setAddingAccount(true) }
onClick: async () => {
setAddingAccount(true);
},
},
}
};
}
const accountMap = createLabelsForBankAccount(accounts);
accountMap[""] = "Select one account..."
accountMap[""] = "Select one account...";
async function updateAccountFromList(accountStr: string): Promise<void> {
// const newSelected = !accountMap[accountStr] ? undefined : accountMap[accountStr];
@ -144,18 +156,19 @@ export function useComponentState({ amount: amountStr, currency: currencyStr, on
? Amounts.sum([fee.wire, fee.coin, fee.refresh]).amount
: Amounts.getZero(currency);
const totalToDeposit = parsedAmount && fee !== undefined
? Amounts.sub(parsedAmount, totalFee).amount
: Amounts.getZero(currency);
const totalToDeposit =
parsedAmount && fee !== undefined
? Amounts.sub(parsedAmount, totalFee).amount
: Amounts.getZero(currency);
const isDirty = amount !== initialValue;
const amountError = !isDirty
? undefined
: !parsedAmount
? "Invalid amount"
: Amounts.cmp(balance, parsedAmount) === -1
? `Too much, your current balance is ${Amounts.stringifyValue(balance)}`
: undefined;
? "Invalid amount"
: Amounts.cmp(balance, parsedAmount) === -1
? `Too much, your current balance is ${Amounts.stringifyValue(balance)}`
: undefined;
const unableToDeposit =
!parsedAmount ||
@ -181,10 +194,11 @@ export function useComponentState({ amount: amountStr, currency: currencyStr, on
value: String(amount),
onInput: updateAmount,
error: amountError,
},
onAddAccount: {
onClick: async () => { setAddingAccount(true) }
onClick: async () => {
setAddingAccount(true);
},
},
account: {
list: accountMap,
@ -219,22 +233,26 @@ async function getFeeForAmount(
export function labelForAccountType(id: string) {
switch (id) {
case "": return "Choose one";
case "x-taler-bank": return "Taler Bank";
case "bitcoin": return "Bitcoin";
case "iban": return "IBAN";
default: return id;
case "":
return "Choose one";
case "x-taler-bank":
return "Taler Bank";
case "bitcoin":
return "Bitcoin";
case "iban":
return "IBAN";
default:
return id;
}
}
export function createLabelsForBankAccount(
knownBankAccounts: Array<KnownBankAccountsInfo>,
): { [value: string]: string } {
const initialList: Record<string, string> = {
}
const initialList: Record<string, string> = {};
if (!knownBankAccounts.length) return initialList;
return knownBankAccounts.reduce((prev, cur, i) => {
prev[stringifyPaytoUri(cur.uri)] = cur.alias
prev[stringifyPaytoUri(cur.uri)] = cur.alias;
return prev;
}, initialList);
}

View File

@ -19,7 +19,14 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { Amounts, Balance, BalancesResponse, DepositGroupFees, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util";
import {
Amounts,
Balance,
BalancesResponse,
DepositGroupFees,
parsePaytoUri,
stringifyPaytoUri,
} from "@gnu-taler/taler-util";
import { expect } from "chai";
import { mountHook } from "../../test-utils.js";
@ -52,17 +59,19 @@ const nullFunction: any = () => null;
type VoidFunction = () => void;
describe("DepositPage states", () => {
it("should have status 'no-enough-balance' when balance is empty", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() =>
useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, {
getBalance: async () =>
({
balances: [{ available: `${currency}:0` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: {} }),
} as Partial<typeof wxApi> as any),
useComponentState(
{ currency, onCancel: nullFunction, onSuccess: nullFunction },
{
getBalance: async () =>
({
balances: [{ available: `${currency}:0` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: {} }),
} as Partial<typeof wxApi> as any,
),
);
{
@ -111,25 +120,28 @@ describe("DepositPage states", () => {
uri: parsePaytoUri("payto://iban/ES8877998399652238")!,
kyc_completed: false,
currency: "EUR",
alias: "my iban account"
alias: "my iban account",
};
const talerBankPayto = {
uri: parsePaytoUri("payto://x-taler-bank/ES8877998399652238")!,
kyc_completed: false,
currency: "EUR",
alias: "my taler account"
alias: "my taler account",
};
it("should have status 'ready' but unable to deposit ", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() =>
useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, {
getBalance: async () =>
({
balances: [{ available: `${currency}:1` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }),
} as Partial<typeof wxApi> as any),
useComponentState(
{ currency, onCancel: nullFunction, onSuccess: nullFunction },
{
getBalance: async () =>
({
balances: [{ available: `${currency}:1` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }),
} as Partial<typeof wxApi> as any,
),
);
{
@ -155,14 +167,17 @@ describe("DepositPage states", () => {
it.skip("should not be able to deposit more than the balance ", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() =>
useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, {
getBalance: async () =>
({
balances: [{ available: `${currency}:1` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }),
getFeeForDeposit: withoutFee,
} as Partial<typeof wxApi> as any),
useComponentState(
{ currency, onCancel: nullFunction, onSuccess: nullFunction },
{
getBalance: async () =>
({
balances: [{ available: `${currency}:1` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }),
getFeeForDeposit: withoutFee,
} as Partial<typeof wxApi> as any,
),
);
{
@ -217,14 +232,17 @@ describe("DepositPage states", () => {
it.skip("should calculate the fee upon entering amount ", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() =>
useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, {
getBalance: async () =>
({
balances: [{ available: `${currency}:1` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }),
getFeeForDeposit: withSomeFee,
} as Partial<typeof wxApi> as any),
useComponentState(
{ currency, onCancel: nullFunction, onSuccess: nullFunction },
{
getBalance: async () =>
({
balances: [{ available: `${currency}:1` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }),
getFeeForDeposit: withSomeFee,
} as Partial<typeof wxApi> as any,
),
);
{
@ -281,16 +299,19 @@ describe("DepositPage states", () => {
it("should calculate the fee upon selecting account ", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() =>
useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, {
getBalance: async () =>
({
balances: [{ available: `${currency}:1` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({
accounts: [ibanPayto, talerBankPayto],
}),
getFeeForDeposit: freeJustForIBAN,
} as Partial<typeof wxApi> as any),
useComponentState(
{ currency, onCancel: nullFunction, onSuccess: nullFunction },
{
getBalance: async () =>
({
balances: [{ available: `${currency}:1` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({
accounts: [ibanPayto, talerBankPayto],
}),
getFeeForDeposit: freeJustForIBAN,
} as Partial<typeof wxApi> as any,
),
);
{
@ -327,7 +348,6 @@ describe("DepositPage states", () => {
expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
expect(r.depositHandler.onClick).undefined;
}
await waitNextUpdate("");
@ -358,7 +378,6 @@ describe("DepositPage states", () => {
expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`));
expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`));
expect(r.depositHandler.onClick).undefined;
}
await waitNextUpdate("");
@ -374,7 +393,6 @@ describe("DepositPage states", () => {
expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`));
expect(r.depositHandler.onClick).undefined;
if (r.account.onChange === undefined) expect.fail();
r.account.onChange(stringifyPaytoUri(talerBankPayto.uri));
}
@ -391,7 +409,6 @@ describe("DepositPage states", () => {
expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`));
expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:7`));
expect(r.depositHandler.onClick).undefined;
}
await waitNextUpdate("");
@ -414,14 +431,17 @@ describe("DepositPage states", () => {
it.skip("should be able to deposit if has the enough balance ", async () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } =
mountHook(() =>
useComponentState({ currency, onCancel: nullFunction, onSuccess: nullFunction }, {
getBalance: async () =>
({
balances: [{ available: `${currency}:15` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }),
getFeeForDeposit: withSomeFee,
} as Partial<typeof wxApi> as any),
useComponentState(
{ currency, onCancel: nullFunction, onSuccess: nullFunction },
{
getBalance: async () =>
({
balances: [{ available: `${currency}:15` }],
} as Partial<BalancesResponse>),
listKnownBankAccounts: async () => ({ accounts: [ibanPayto] }),
getFeeForDeposit: withSomeFee,
} as Partial<typeof wxApi> as any,
),
);
{
@ -456,7 +476,6 @@ describe("DepositPage states", () => {
expect(r.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:0`));
expect(r.totalToDeposit).deep.eq(Amounts.parseOrThrow(`${currency}:10`));
expect(r.depositHandler.onClick).undefined;
}
await waitNextUpdate();

View File

@ -19,20 +19,8 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { termsXml } from "../cta/termsExample.js";
import { createExample } from "../test-utils.js";
import { View as TestedComponent } from "./ExchangeAddConfirm.js";
function parseFromString(s: string): Document {
if (typeof window === "undefined") {
return {
querySelector: () => ({
children: [],
}),
} as any;
}
return new window.DOMParser().parseFromString(s, "text/xml");
}
import { ExchangeAddConfirmPage as TestedComponent } from "./ExchangeAddConfirm.js";
export default {
title: "wallet/exchange add/confirm",
@ -46,33 +34,12 @@ export default {
export const TermsNotFound = createExample(TestedComponent, {
url: "https://exchange.demo.taler.net/",
terms: {
status: "notfound",
version: "1",
content: undefined,
},
onAccept: async () => undefined,
});
export const NewTerms = createExample(TestedComponent, {
url: "https://exchange.demo.taler.net/",
terms: {
status: "new",
version: "1",
content: undefined,
},
onAccept: async () => undefined,
});
export const TermsChanged = createExample(TestedComponent, {
url: "https://exchange.demo.taler.net/",
terms: {
status: "changed",
version: "1",
content: {
type: "xml",
document: parseFromString(termsXml),
},
},
onAccept: async () => undefined,
});

View File

@ -17,10 +17,9 @@ import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { Title } from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.js";
import { TermsOfServiceSection } from "../cta/TermsOfServiceSection.js";
import { TermsOfService } from "../cta/TermsOfService/index.js";
import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { Button } from "../mui/Button.js";
import { buildTermsOfServiceState, TermsState } from "../utils/index.js";
import * as wxApi from "../wxApi.js";
export interface Props {
@ -34,69 +33,9 @@ export function ExchangeAddConfirmPage({
onCancel,
onConfirm,
}: Props): VNode {
const detailsHook = useAsyncAsHook(async () => {
const tos = await wxApi.getExchangeTos(url, ["text/xml"]);
const tosState = buildTermsOfServiceState(tos);
return { tos: tosState };
});
const termsNotFound: TermsState = {
status: "notfound",
version: "",
content: undefined,
};
const terms = !detailsHook
? undefined
: detailsHook.hasError
? termsNotFound
: detailsHook.response.tos;
// const [errorAccepting, setErrorAccepting] = useState<string | undefined>(
// undefined,
// );
const onAccept = async (): Promise<void> => {
if (!terms) return;
try {
await wxApi.setExchangeTosAccepted(url, terms.version);
} catch (e) {
if (e instanceof Error) {
// setErrorAccepting(e.message);
}
}
};
return (
<View
url={url}
onAccept={onAccept}
onCancel={onCancel}
onConfirm={onConfirm}
terms={terms}
/>
);
}
export interface ViewProps {
url: string;
terms: TermsState | undefined;
onAccept: (b: boolean) => Promise<void>;
onCancel: () => Promise<void>;
onConfirm: () => Promise<void>;
}
export function View({
url,
terms,
onAccept: doAccept,
onConfirm,
onCancel,
}: ViewProps): VNode {
const { i18n } = useTranslationContext();
const needsReview =
!terms || terms.status === "changed" || terms.status === "new";
const [reviewed, setReviewed] = useState<boolean>(false);
const [accepted, setAccepted] = useState(false);
return (
<Fragment>
@ -111,52 +50,27 @@ export function View({
</a>
</div>
</section>
{terms && (
<TermsOfServiceSection
reviewed={reviewed}
reviewing={true}
terms={terms}
onAccept={(value) =>
doAccept(value).then(() => {
setReviewed(value);
})
}
/>
)}
<TermsOfService key="terms" exchangeUrl={url} onChange={setAccepted} />
<footer>
<Button variant="contained" color="secondary" onClick={onCancel}>
<Button
key="cancel"
variant="contained"
color="secondary"
onClick={onCancel}
>
<i18n.Translate>Cancel</i18n.Translate>
</Button>
{!terms && (
<Button variant="contained" disabled>
<i18n.Translate>Loading terms..</i18n.Translate>
</Button>
)}
{terms && (
<Fragment>
{needsReview && !reviewed && (
<Button
variant="contained"
color="success"
disabled
onClick={onConfirm}
>
<i18n.Translate>Add exchange</i18n.Translate>
</Button>
)}
{(terms.status === "accepted" || (needsReview && reviewed)) && (
<Button variant="contained" color="success" onClick={onConfirm}>
<i18n.Translate>Add exchange</i18n.Translate>
</Button>
)}
{terms.status === "notfound" && (
<Button variant="contained" color="warning" onClick={onConfirm}>
<i18n.Translate>Add exchange anyway</i18n.Translate>
</Button>
)}
</Fragment>
)}
<Button
key="add"
variant="contained"
color="success"
disabled={!accepted}
onClick={onConfirm}
>
<i18n.Translate>Add exchange</i18n.Translate>
</Button>
</footer>
</Fragment>
);

View File

@ -17,13 +17,12 @@
import {
DenomOperationMap,
ExchangeFullDetails,
ExchangeListItem, FeeDescriptionPair
ExchangeListItem,
FeeDescriptionPair,
} from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js";
import {
State as SelectExchangeState
} from "../../hooks/useSelectedExchange.js";
import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js";
import { compose, StateViewMap } from "../../utils/index.js";
import * as wxApi from "../../wxApi.js";
@ -32,12 +31,12 @@ import {
ComparingView,
ErrorLoadingView,
NoExchangesView,
ReadyView
ReadyView,
} from "./views.js";
export interface Props {
list: ExchangeListItem[],
currentExchange: string,
list: ExchangeListItem[];
currentExchange: string;
onCancel: () => Promise<void>;
onSelection: (exchange: string) => Promise<void>;
}

View File

@ -25,9 +25,13 @@ export function useComponentState(
{ onCancel, onSelection, list: exchanges, currentExchange }: Props,
api: typeof wxApi,
): State {
const initialValue = exchanges.findIndex(e => e.exchangeBaseUrl === currentExchange);
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`)
throw Error(
`wrong usage of ExchangeSelection component, currentExchange '${currentExchange}' is not in the list of exchanges`,
);
}
const [value, setValue] = useState(String(initialValue));
@ -113,7 +117,7 @@ export function useComponentState(
withdraw: createPairTimeline(
selected.denomFees.withdraw,
original.denomFees.withdraw,
)
),
};
return {

View File

@ -14,24 +14,20 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
Amounts,
FeeDescription,
FeeDescriptionPair,
} from "@gnu-taler/taler-util";
import { FeeDescription, FeeDescriptionPair } from "@gnu-taler/taler-util";
import { styled } from "@linaria/react";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { Amount } from "../../components/Amount.js";
import { LoadingError } from "../../components/LoadingError.js";
import { SelectList } from "../../components/SelectList.js";
import { Input, LinkPrimary, SvgIcon } from "../../components/styled/index.js";
import { Input, SvgIcon } from "../../components/styled/index.js";
import { Time } from "../../components/Time.js";
import { useTranslationContext } from "../../context/translation.js";
import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
import { Button } from "../../mui/Button.js";
import arrowDown from "../../svg/chevron-down.svg";
import { State } from "./index.js";
import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js";
const ButtonGroup = styled.div`
& > button {
@ -39,6 +35,16 @@ const ButtonGroup = styled.div`
margin-right: 8px;
}
`;
const ButtonGroupFooter = styled.div`
& {
display: flex;
justify-content: space-between;
}
& > button {
margin-left: 8px;
margin-right: 8px;
}
`;
const FeeDescriptionTable = styled.table`
& {
@ -343,10 +349,10 @@ export function ComparingView({
</table>
</section>
<section>
<ButtonGroup>
<LinkPrimary>Privacy policy</LinkPrimary>
<LinkPrimary>Terms of service</LinkPrimary>
</ButtonGroup>
<ButtonGroupFooter>
<Button variant="outlined">Privacy policy</Button>
<Button variant="outlined">Terms of service</Button>
</ButtonGroupFooter>
</section>
</Container>
);
@ -609,10 +615,10 @@ export function ReadyView({
</FeeDescriptionTable>
</section>
<section>
<ButtonGroup>
<LinkPrimary>Privacy policy</LinkPrimary>
<LinkPrimary>Terms of service</LinkPrimary>
</ButtonGroup>
<ButtonGroupFooter>
<Button variant="outlined">Privacy policy</Button>
<Button variant="outlined">Terms of service</Button>
</ButtonGroupFooter>
</section>
</Container>
);

View File

@ -36,7 +36,7 @@ import { useBackupDeviceName } from "../hooks/useBackupDeviceName.js";
import { useAutoOpenPermissions } from "../hooks/useAutoOpenPermissions.js";
import { ToggleHandler } from "../mui/handlers.js";
import { Pages } from "../NavigationBar.js";
import { buildTermsOfServiceStatus } from "../utils/index.js";
import { buildTermsOfServiceStatus } from "../cta/TermsOfService/utils.js";
import * as wxApi from "../wxApi.js";
import { platform } from "../platform/api.js";
import { useClipboardPermissions } from "../hooks/useClipboardPermissions.js";

View File

@ -24,8 +24,16 @@
import {
AcceptExchangeTosRequest,
AcceptManualWithdrawalResult,
AcceptPeerPullPaymentRequest, AcceptPeerPullPaymentResponse, AcceptPeerPushPaymentRequest, AcceptPeerPushPaymentResponse, AcceptTipRequest, AcceptTipResponse, AcceptWithdrawalResponse,
AddExchangeRequest, AddKnownBankAccountsRequest, AmountString,
AcceptPeerPullPaymentRequest,
AcceptPeerPullPaymentResponse,
AcceptPeerPushPaymentRequest,
AcceptPeerPushPaymentResponse,
AcceptTipRequest,
AcceptTipResponse,
AcceptWithdrawalResponse,
AddExchangeRequest,
AddKnownBankAccountsRequest,
AmountString,
ApplyRefundResponse,
BalancesResponse,
CheckPeerPullPaymentRequest,
@ -37,7 +45,12 @@ import {
CoreApiResponse,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
DeleteTransactionRequest, DepositGroupFees, ExchangeFullDetails, ExchangesListResponse, ForgetKnownBankAccountsRequest, GetExchangeTosResult,
DeleteTransactionRequest,
DepositGroupFees,
ExchangeFullDetails,
ExchangesListResponse,
ForgetKnownBankAccountsRequest,
GetExchangeTosResult,
GetExchangeWithdrawalInfo,
GetFeeForDepositRequest,
GetWithdrawalDetailsForUriRequest,
@ -47,7 +60,9 @@ import {
InitiatePeerPushPaymentResponse,
KnownBankAccounts,
Logger,
NotificationType, PaytoUri, PrepareDepositRequest,
NotificationType,
PaytoUri,
PrepareDepositRequest,
PrepareDepositResponse,
PreparePayResult,
PrepareRefundRequest,
@ -55,9 +70,13 @@ import {
PrepareTipRequest,
PrepareTipResult,
RetryTransactionRequest,
SetWalletDeviceIdRequest, stringifyPaytoUri, Transaction,
TransactionsResponse, WalletCoreVersion,
WalletDiagnostics, WithdrawUriInfoResponse
SetWalletDeviceIdRequest,
stringifyPaytoUri,
Transaction,
TransactionsResponse,
WalletCoreVersion,
WalletDiagnostics,
WithdrawUriInfoResponse,
} from "@gnu-taler/taler-util";
import {
AddBackupProviderRequest,
@ -66,7 +85,7 @@ import {
PendingOperationsResponse,
RemoveBackupProviderRequest,
TalerError,
WalletContractData
WalletContractData,
} from "@gnu-taler/taler-wallet-core";
import { MessageFromBackend, platform } from "./platform/api.js";
@ -268,13 +287,13 @@ export function addKnownBankAccounts(
return callBackend("addKnownBankAccounts", {
payto: stringifyPaytoUri(payto),
currency,
alias
alias,
} as AddKnownBankAccountsRequest);
}
export function forgetKnownBankAccounts(
payto: string,
): Promise<void> {
return callBackend("forgetKnownBankAccounts", { payto } as ForgetKnownBankAccountsRequest);
export function forgetKnownBankAccounts(payto: string): Promise<void> {
return callBackend("forgetKnownBankAccounts", {
payto,
} as ForgetKnownBankAccountsRequest);
}
/**