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"; import { h, VNode } from "preact";
interface Props { interface Props {
enabled: boolean; enabled?: boolean;
onToggle: () => Promise<void>; onToggle?: () => Promise<void>;
label: VNode; label: VNode;
name: string; name: string;
} }

View File

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

View File

@ -23,7 +23,7 @@ import { useSelectedExchange } from "../../hooks/useSelectedExchange.js";
import * as wxApi from "../../wxApi.js"; import * as wxApi from "../../wxApi.js";
import { Props, State } from "./index.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( export function useComponentState(
{ amount: amountStr, onClose, onSuccess }: Props, { amount: amountStr, onClose, onSuccess }: Props,
@ -46,7 +46,7 @@ export function useComponentState(
}; };
} }
const exchangeList = hook.response.exchanges const exchangeList = hook.response.exchanges;
return () => { return () => {
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
@ -55,14 +55,17 @@ export function useComponentState(
TalerErrorDetail | undefined TalerErrorDetail | undefined
>(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> { async function accept(): Promise<void> {
try { try {
@ -105,9 +108,5 @@ export function useComponentState(
error: undefined, error: undefined,
operationError, operationError,
}; };
} };
} }

View File

@ -38,9 +38,7 @@ export const Ready = createExample(ReadyView, {
value: 1, value: 1,
fraction: 0, fraction: 0,
}, },
doSelectExchange: { doSelectExchange: {},
},
exchangeUrl: "https://exchange.taler.ar", exchangeUrl: "https://exchange.taler.ar",
subject: { subject: {
value: "some 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 { AmountJson } from "@gnu-taler/taler-util";
import { Loading } from "../../components/Loading.js"; import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js"; import { HookError } from "../../hooks/useAsyncAsHook.js";
import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js"; import { ButtonHandler } from "../../mui/handlers.js";
import { compose, StateViewMap } from "../../utils/index.js"; import { compose, StateViewMap } from "../../utils/index.js";
import * as wxApi from "../../wxApi.js"; import * as wxApi from "../../wxApi.js";
import { Props as TermsOfServiceSectionProps } from "../TermsOfServiceSection.js";
import { useComponentState } from "./state.js"; import { useComponentState } from "./state.js";
import { import {
AcceptedView, AcceptedView,

View File

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

View File

@ -15,17 +15,15 @@
*/ */
/* eslint-disable react-hooks/rules-of-hooks */ /* 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 { 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 { useSelectedExchange } from "../../hooks/useSelectedExchange.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 { 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( export function useComponentStateFromParams(
{ amount, cancel, onSuccess }: PropsFromParams, { amount, cancel, onSuccess }: PropsFromParams,
@ -46,18 +44,38 @@ export function useComponentStateFromParams(
} }
const chosenAmount = uriInfoHook.response.amount; 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 }> { async function doManualWithdraw(
const res = await api.acceptManualWithdrawal(exchange, Amounts.stringify(chosenAmount), ageRestricted); exchange: string,
ageRestricted: number | undefined,
): Promise<{
transactionId: string;
confirmTransferUrl: string | undefined;
}> {
const res = await api.acceptManualWithdrawal(
exchange,
Amounts.stringify(chosenAmount),
ageRestricted,
);
return { return {
confirmTransferUrl: undefined, 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( export function useComponentStateFromURI(
@ -75,7 +93,12 @@ export function useComponentStateFromURI(
}); });
const exchanges = await api.listExchanges(); const exchanges = await api.listExchanges();
const { amount, defaultExchangeBaseUrl } = uriInfo; 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 }; if (!uriInfoHook) return { status: "loading", error: undefined };
@ -90,53 +113,75 @@ export function useComponentStateFromURI(
const uri = uriInfoHook.response.talerWithdrawUri; const uri = uriInfoHook.response.talerWithdrawUri;
const chosenAmount = uriInfoHook.response.amount; const chosenAmount = uriInfoHook.response.amount;
const defaultExchange = uriInfoHook.response.thisExchange; 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 }> { async function doManagedWithdraw(
const res = await api.acceptWithdrawal(uri, exchange, ageRestricted,); exchange: string,
ageRestricted: number | undefined,
): Promise<{
transactionId: string;
confirmTransferUrl: string | undefined;
}> {
const res = await api.acceptWithdrawal(uri, exchange, ageRestricted);
return { return {
confirmTransferUrl: res.confirmTransferUrl, 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 () => { return () => {
const [ageRestricted, setAgeRestricted] = useState(0); const [ageRestricted, setAgeRestricted] = useState(0);
const currentExchange = selectedExchange.selected const currentExchange = selectedExchange.selected;
/** const tosNeedToBeAccepted =
* For the exchange selected, bring the status of the terms of service !currentExchange.tos.acceptedVersion ||
*/ currentExchange.tos.currentVersion !==
const terms = useAsyncAsHook(async () => { currentExchange.tos.acceptedVersion;
const exchangeTos = await api.getExchangeTos(currentExchange.exchangeBaseUrl, [
"text/xml",
]);
const state = buildTermsOfServiceState(exchangeTos);
return { state };
}, []);
/** /**
* With the exchange and amount, ask the wallet the information * With the exchange and amount, ask the wallet the information
* about the withdrawal * about the withdrawal
*/ */
const amountHook = useAsyncAsHook(async () => { const amountHook = useAsyncAsHook(async () => {
const info = await api.getExchangeWithdrawalInfo({ const info = await api.getExchangeWithdrawalInfo({
exchangeBaseUrl: currentExchange.exchangeBaseUrl, exchangeBaseUrl: currentExchange.exchangeBaseUrl,
amount: chosenAmount, 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>( const [withdrawError, setWithdrawError] = useState<TalerError | undefined>(
undefined, undefined,
); );
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false);
async function doWithdrawAndCheckError(): Promise<void> { async function doWithdrawAndCheckError(): Promise<void> {
try { try {
setDoingWithdraw(true); setDoingWithdraw(true);
const res = await doWithdraw(currentExchange.exchangeBaseUrl, !ageRestricted ? undefined : ageRestricted) const res = await doWithdraw(
currentExchange.exchangeBaseUrl,
!ageRestricted ? undefined : ageRestricted,
);
if (res.confirmTransferUrl) { if (res.confirmTransferUrl) {
document.location.href = res.confirmTransferUrl; document.location.href = res.confirmTransferUrl;
} else { } else {
@ -201,33 +244,6 @@ function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, can
).amount; ).amount;
const toBeReceived = amountHook.response.amount.effective; 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 = const ageRestrictionOptions =
amountHook.response.ageRestrictionOptions?.reduce( amountHook.response.ageRestrictionOptions?.reduce(
(p, c) => ({ ...p, [c]: `under ${c}` }), (p, c) => ({ ...p, [c]: `under ${c}` }),
@ -242,17 +258,17 @@ function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, can
//TODO: calculate based on exchange info //TODO: calculate based on exchange info
const ageRestriction = ageRestrictionEnabled 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,
doSelectExchange: selectedExchange.doSelect, doSelectExchange: selectedExchange.doSelect,
exchangeUrl: currentExchange.exchangeBaseUrl, currentExchange,
toBeReceived, toBeReceived,
withdrawalFee, withdrawalFee,
chosenAmount, chosenAmount,
@ -260,22 +276,13 @@ function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, can
ageRestriction, ageRestriction,
doWithdrawal: { doWithdrawal: {
onClick: onClick:
doingWithdraw || (mustAcceptFirst && !reviewed) doingWithdraw || tosNeedToBeAccepted
? undefined ? undefined
: doWithdrawAndCheckError, : doWithdrawAndCheckError,
error: withdrawError, error: withdrawError,
}, },
tosProps: !termsState onTosUpdate,
? undefined
: {
onAccept,
onReview: setReviewing,
reviewed: reviewed,
reviewing: reviewing,
terms: termsState,
},
mustAcceptFirst,
cancel, cancel,
}; };
} };
} }

View File

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

View File

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

View File

@ -15,30 +15,28 @@
*/ */
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { Amount } from "../../components/Amount.js";
import { ErrorTalerOperation } from "../../components/ErrorTalerOperation.js"; import { ErrorTalerOperation } from "../../components/ErrorTalerOperation.js";
import { LoadingError } from "../../components/LoadingError.js"; import { LoadingError } from "../../components/LoadingError.js";
import { LogoHeader } from "../../components/LogoHeader.js"; import { LogoHeader } from "../../components/LogoHeader.js";
import { Part } from "../../components/Part.js"; import { Part } from "../../components/Part.js";
import { QR } from "../../components/QR.js";
import { SelectList } from "../../components/SelectList.js"; import { SelectList } from "../../components/SelectList.js";
import { import {
Input, Input,
Link, Link,
LinkSuccess, LinkSuccess,
SubTitle, SubTitle,
SuccessBox,
SvgIcon, SvgIcon,
WalletAction, WalletAction,
} from "../../components/styled/index.js"; } from "../../components/styled/index.js";
import { useTranslationContext } from "../../context/translation.js"; import { useTranslationContext } from "../../context/translation.js";
import { Button } from "../../mui/Button.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 editIcon from "../../svg/edit_24px.svg";
import { Amount } from "../../components/Amount.js"; import { ExchangeDetails, WithdrawDetails } from "../../wallet/Transaction.js";
import { QR } from "../../components/QR.js"; import { TermsOfService } from "../TermsOfService/index.js";
import { useState } from "preact/hooks"; import { State } from "./index.js";
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();
@ -66,6 +64,9 @@ export function LoadingInfoView({ error }: State.LoadingInfoError): VNode {
export function SuccessView(state: State.Success): VNode { export function SuccessView(state: State.Success): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const currentTosVersionIsAccepted =
state.currentExchange.tos.acceptedVersion ===
state.currentExchange.tos.currentVersion;
return ( return (
<WalletAction> <WalletAction>
<LogoHeader /> <LogoHeader />
@ -103,7 +104,9 @@ export function SuccessView(state: State.Success): VNode {
</Button> </Button>
</div> </div>
} }
text={<ExchangeDetails exchange={state.exchangeUrl} />} text={
<ExchangeDetails exchange={state.currentExchange.exchangeBaseUrl} />
}
kind="neutral" kind="neutral"
big big
/> />
@ -130,43 +133,29 @@ export function SuccessView(state: State.Success): VNode {
</Input> </Input>
)} )}
</section> </section>
{state.tosProps && <TermsOfServiceSection {...state.tosProps} />}
{state.tosProps ? ( <section>
<Fragment> {currentTosVersionIsAccepted ? (
<section> <Button
{(state.tosProps.terms.status === "accepted" || variant="contained"
(state.mustAcceptFirst && state.tosProps.reviewed)) && ( color="success"
<Button disabled={!state.doWithdrawal.onClick}
variant="contained" onClick={state.doWithdrawal.onClick}
color="success" >
disabled={!state.doWithdrawal.onClick} <i18n.Translate>
onClick={state.doWithdrawal.onClick} Withdraw &nbsp; <Amount value={state.toBeReceived} />
> </i18n.Translate>
<i18n.Translate> </Button>
Withdraw &nbsp; <Amount value={state.toBeReceived} /> ) : (
</i18n.Translate> <TermsOfService
</Button> exchangeUrl={state.currentExchange.exchangeBaseUrl}
)} onChange={state.onTosUpdate}
{state.tosProps.terms.status === "notfound" && ( />
<Button )}
variant="contained" </section>
color="warning" {state.talerWithdrawUri ? (
disabled={!state.doWithdrawal.onClick} <WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} />
onClick={state.doWithdrawal.onClick} ) : undefined}
>
<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> <section>
<Link upperCased onClick={state.cancel}> <Link upperCased onClick={state.cancel}>
<i18n.Translate>Cancel</i18n.Translate> <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 a4 from "./Refund/stories.jsx";
import * as a5 from "./Tip/stories.jsx"; import * as a5 from "./Tip/stories.jsx";
import * as a6 from "./Withdraw/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 a8 from "./InvoiceCreate/stories.js";
import * as a9 from "./InvoicePay/stories.js"; import * as a9 from "./InvoicePay/stories.js";
import * as a10 from "./TransferCreate/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 namespace State {
export interface NoExchange { export interface NoExchange {
status: "no-exchange" status: "no-exchange";
error: undefined; error: undefined;
currency: string | undefined; currency: string | undefined;
} }
export interface Ready { export interface Ready {
status: "ready", status: "ready";
doSelect: ButtonHandler, doSelect: ButtonHandler;
selected: ExchangeListItem; selected: ExchangeListItem;
} }
export interface Selecting { export interface Selecting {
status: "selecting-exchange", status: "selecting-exchange";
error: undefined, error: undefined;
onSelection: (url: string) => Promise<void>; onSelection: (url: string) => Promise<void>;
onCancel: () => Promise<void>; onCancel: () => Promise<void>;
list: ExchangeListItem[], list: ExchangeListItem[];
currency: string; currency: string;
currentExchange: string; currentExchange: string;
} }
@ -45,38 +45,42 @@ export namespace State {
interface Props { interface Props {
currency: string; currency: string;
//there is a preference for the default at the initial state //there is a preference for the default at the initial state
defaultExchange?: string, defaultExchange?: string;
//list of exchanges //list of exchanges
list: ExchangeListItem[], list: ExchangeListItem[];
} }
export function useSelectedExchange({
currency,
export function useSelectedExchange({ currency, defaultExchange, list }: Props): State { defaultExchange,
list,
}: Props): State {
const [isSelecting, setIsSelecting] = useState(false); const [isSelecting, setIsSelecting] = useState(false);
const [selectedExchange, setSelectedExchange] = useState<string | undefined>(undefined); const [selectedExchange, setSelectedExchange] = useState<string | undefined>(
undefined,
);
if (!list.length) { if (!list.length) {
return { return {
status: "no-exchange", status: "no-exchange",
error: undefined, error: undefined,
currency: undefined, currency: undefined,
} };
} }
const listCurrency = list.filter((e) => e.currency === currency) const listCurrency = list.filter((e) => e.currency === currency);
if (!listCurrency.length) { if (!listCurrency.length) {
// there should be at least one exchange for this currency // there should be at least one exchange for this currency
return { return {
status: "no-exchange", status: "no-exchange",
error: undefined, error: undefined,
currency, currency,
} };
} }
if (isSelecting) { if (isSelecting) {
const currentExchange = selectedExchange ?? defaultExchange ?? listCurrency[0].exchangeBaseUrl; const currentExchange =
selectedExchange ?? defaultExchange ?? listCurrency[0].exchangeBaseUrl;
return { return {
status: "selecting-exchange", status: "selecting-exchange",
error: undefined, error: undefined,
@ -85,44 +89,46 @@ export function useSelectedExchange({ currency, defaultExchange, list }: Props):
currentExchange: currentExchange, currentExchange: currentExchange,
onSelection: async (exchangeBaseUrl: string) => { onSelection: async (exchangeBaseUrl: string) => {
setIsSelecting(false); setIsSelecting(false);
setSelectedExchange(exchangeBaseUrl) setSelectedExchange(exchangeBaseUrl);
}, },
onCancel: async () => { onCancel: async () => {
setIsSelecting(false); setIsSelecting(false);
} },
} };
} }
{ {
const found = !selectedExchange ? undefined : list.find( const found = !selectedExchange
(e) => e.exchangeBaseUrl === selectedExchange, ? undefined
) : list.find((e) => e.exchangeBaseUrl === selectedExchange);
if (found) return { if (found)
status: "ready", return {
doSelect: { status: "ready",
onClick: async () => setIsSelecting(true) doSelect: {
}, onClick: async () => setIsSelecting(true),
selected: found },
}; selected: found,
};
} }
{ {
const found = !defaultExchange ? undefined : list.find( const found = !defaultExchange
(e) => e.exchangeBaseUrl === defaultExchange, ? undefined
) : list.find((e) => e.exchangeBaseUrl === defaultExchange);
if (found) return { if (found)
status: "ready", return {
doSelect: { status: "ready",
onClick: async () => setIsSelecting(true) doSelect: {
}, onClick: async () => setIsSelecting(true),
selected: found },
}; selected: found,
};
} }
return { return {
status: "ready", status: "ready",
doSelect: { 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); document.body.removeChild(div);
} }
} }
type RecursiveState<S> = S | (() => RecursiveState<S>) type RecursiveState<S> = S | (() => RecursiveState<S>);
interface Mounted<T> { interface Mounted<T> {
unmount: () => void; unmount: () => void;
@ -107,12 +107,12 @@ export function mountHook<T extends object>(
// component that's going to hold the hook // component that's going to hold the hook
function Component(): VNode { function Component(): VNode {
try { try {
let componentOrResult = callback() let componentOrResult = callback();
while (typeof componentOrResult === "function") { while (typeof componentOrResult === "function") {
componentOrResult = componentOrResult(); componentOrResult = componentOrResult();
} }
//typecheck fails here //typecheck fails here
const l: Exclude<T, () => void> = componentOrResult as any const l: Exclude<T, () => void> = componentOrResult as any;
lastResult = l; lastResult = l;
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {

View File

@ -14,12 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { import { createElement, VNode } from "preact";
AmountJson,
Amounts,
GetExchangeTosResult,
} from "@gnu-taler/taler-util";
import { VNode, createElement } from "preact";
function getJsonIfOk(r: Response): Promise<any> { function getJsonIfOk(r: Response): Promise<any> {
if (r.ok) { if (r.ok) {
@ -31,7 +26,8 @@ function getJsonIfOk(r: Response): Promise<any> {
} }
throw new Error( 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); 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 StateFunc<S> = (p: S) => VNode;
export type StateViewMap<StateType extends { status: string }> = { 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>) 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) => RecursiveState<SType>, hook: (p: PType) => RecursiveState<SType>,
viewMap: StateViewMap<SType>, viewMap: StateViewMap<SType>,
): (p: PType) => VNode { ): (p: PType) => VNode {
function withHook(stateHook: () => RecursiveState<SType>): () => VNode { function withHook(stateHook: () => RecursiveState<SType>): () => VNode {
function TheComponent(): VNode { function TheComponent(): VNode {
const state = stateHook(); const state = stateHook();
if (typeof state === "function") { if (typeof state === "function") {
const subComponent = withHook(state) const subComponent = withHook(state);
return createElement(subComponent, {}); return createElement(subComponent, {});
} }
@ -225,7 +106,7 @@ export function compose<SType extends { status: string }, PType>(
} }
return (p: PType) => { return (p: PType) => {
const h = withHook(() => hook(p)) const h = withHook(() => hook(p));
return h() return h();
}; };
} }

View File

@ -20,7 +20,11 @@ import { compose, StateViewMap } from "../../utils/index.js";
import { LoadingUriView, ReadyView } from "./views.js"; import { LoadingUriView, ReadyView } from "./views.js";
import * as wxApi from "../../wxApi.js"; import * as wxApi from "../../wxApi.js";
import { useComponentState } from "./state.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 { export interface Props {
currency: string; currency: string;

View File

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

View File

@ -17,11 +17,22 @@
import { Loading } from "../../components/Loading.js"; import { Loading } from "../../components/Loading.js";
import { HookError } from "../../hooks/useAsyncAsHook.js"; import { HookError } from "../../hooks/useAsyncAsHook.js";
import { compose, StateViewMap } from "../../utils/index.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 * as wxApi from "../../wxApi.js";
import { useComponentState } from "./state.js"; import { useComponentState } from "./state.js";
import { AmountJson, PaytoUri } from "@gnu-taler/taler-util"; 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"; import { AddAccountPage } from "../AddAccount/index.js";
export interface Props { export interface Props {
@ -31,7 +42,8 @@ export interface Props {
onSuccess: (currency: string) => void; onSuccess: (currency: string) => void;
} }
export type State = State.Loading export type State =
| State.Loading
| State.LoadingUriError | State.LoadingUriError
| State.AmountOrCurrencyError | State.AmountOrCurrencyError
| State.NoEnoughBalance | State.NoEnoughBalance

View File

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

View File

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

View File

@ -19,20 +19,8 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { termsXml } from "../cta/termsExample.js";
import { createExample } from "../test-utils.js"; import { createExample } from "../test-utils.js";
import { View as TestedComponent } from "./ExchangeAddConfirm.js"; import { ExchangeAddConfirmPage 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");
}
export default { export default {
title: "wallet/exchange add/confirm", title: "wallet/exchange add/confirm",
@ -46,33 +34,12 @@ export default {
export const TermsNotFound = createExample(TestedComponent, { export const TermsNotFound = createExample(TestedComponent, {
url: "https://exchange.demo.taler.net/", url: "https://exchange.demo.taler.net/",
terms: {
status: "notfound",
version: "1",
content: undefined,
},
onAccept: async () => undefined,
}); });
export const NewTerms = createExample(TestedComponent, { export const NewTerms = createExample(TestedComponent, {
url: "https://exchange.demo.taler.net/", url: "https://exchange.demo.taler.net/",
terms: {
status: "new",
version: "1",
content: undefined,
},
onAccept: async () => undefined,
}); });
export const TermsChanged = createExample(TestedComponent, { export const TermsChanged = createExample(TestedComponent, {
url: "https://exchange.demo.taler.net/", 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 { useState } from "preact/hooks";
import { Title } from "../components/styled/index.js"; import { Title } from "../components/styled/index.js";
import { useTranslationContext } from "../context/translation.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 { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { Button } from "../mui/Button.js"; import { Button } from "../mui/Button.js";
import { buildTermsOfServiceState, TermsState } from "../utils/index.js";
import * as wxApi from "../wxApi.js"; import * as wxApi from "../wxApi.js";
export interface Props { export interface Props {
@ -34,69 +33,9 @@ export function ExchangeAddConfirmPage({
onCancel, onCancel,
onConfirm, onConfirm,
}: Props): VNode { }: 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 { i18n } = useTranslationContext();
const needsReview =
!terms || terms.status === "changed" || terms.status === "new"; const [accepted, setAccepted] = useState(false);
const [reviewed, setReviewed] = useState<boolean>(false);
return ( return (
<Fragment> <Fragment>
@ -111,52 +50,27 @@ export function View({
</a> </a>
</div> </div>
</section> </section>
{terms && (
<TermsOfServiceSection <TermsOfService key="terms" exchangeUrl={url} onChange={setAccepted} />
reviewed={reviewed}
reviewing={true}
terms={terms}
onAccept={(value) =>
doAccept(value).then(() => {
setReviewed(value);
})
}
/>
)}
<footer> <footer>
<Button variant="contained" color="secondary" onClick={onCancel}> <Button
key="cancel"
variant="contained"
color="secondary"
onClick={onCancel}
>
<i18n.Translate>Cancel</i18n.Translate> <i18n.Translate>Cancel</i18n.Translate>
</Button> </Button>
{!terms && ( <Button
<Button variant="contained" disabled> key="add"
<i18n.Translate>Loading terms..</i18n.Translate> variant="contained"
</Button> color="success"
)} disabled={!accepted}
{terms && ( onClick={onConfirm}
<Fragment> >
{needsReview && !reviewed && ( <i18n.Translate>Add exchange</i18n.Translate>
<Button </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>
)}
</footer> </footer>
</Fragment> </Fragment>
); );

View File

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

View File

@ -25,9 +25,13 @@ export function useComponentState(
{ onCancel, onSelection, list: exchanges, currentExchange }: Props, { onCancel, onSelection, list: exchanges, currentExchange }: Props,
api: typeof wxApi, api: typeof wxApi,
): State { ): State {
const initialValue = exchanges.findIndex(e => e.exchangeBaseUrl === currentExchange); const initialValue = exchanges.findIndex(
(e) => e.exchangeBaseUrl === currentExchange,
);
if (initialValue === -1) { 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)); const [value, setValue] = useState(String(initialValue));
@ -113,7 +117,7 @@ export function useComponentState(
withdraw: createPairTimeline( withdraw: createPairTimeline(
selected.denomFees.withdraw, selected.denomFees.withdraw,
original.denomFees.withdraw, original.denomFees.withdraw,
) ),
}; };
return { return {

View File

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

View File

@ -36,7 +36,7 @@ import { useBackupDeviceName } from "../hooks/useBackupDeviceName.js";
import { useAutoOpenPermissions } from "../hooks/useAutoOpenPermissions.js"; import { useAutoOpenPermissions } from "../hooks/useAutoOpenPermissions.js";
import { ToggleHandler } from "../mui/handlers.js"; import { ToggleHandler } from "../mui/handlers.js";
import { Pages } from "../NavigationBar.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 * as wxApi from "../wxApi.js";
import { platform } from "../platform/api.js"; import { platform } from "../platform/api.js";
import { useClipboardPermissions } from "../hooks/useClipboardPermissions.js"; import { useClipboardPermissions } from "../hooks/useClipboardPermissions.js";

View File

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