diff options
author | Sebastian <sebasjm@gmail.com> | 2024-02-04 12:04:27 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2024-02-05 09:18:22 -0300 |
commit | b71d6f2b11342bd22197289ad3872d8a341686b5 (patch) | |
tree | d263482af0121d87000476fd0306530a77580817 /packages/demobank-ui | |
parent | 83ff7de59b8a00b313ecb00f4c6150a37c38902f (diff) | |
download | wallet-core-b71d6f2b11342bd22197289ad3872d8a341686b5.tar.xz |
wip DD39: removed webRequest permission and changes made into demobank
Diffstat (limited to 'packages/demobank-ui')
-rw-r--r-- | packages/demobank-ui/src/Routing.tsx | 153 | ||||
-rw-r--r-- | packages/demobank-ui/src/components/app.tsx | 8 | ||||
-rw-r--r-- | packages/demobank-ui/src/context/navigation.ts | 80 | ||||
-rw-r--r-- | packages/demobank-ui/src/context/wallet-integration.ts | 90 | ||||
-rw-r--r-- | packages/demobank-ui/src/index.html | 42 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/OperationState/views.tsx | 16 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/PaymentOptions.tsx | 49 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/ProfileNavigation.tsx | 10 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/QrCodeSection.tsx | 19 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/WalletWithdrawForm.tsx | 31 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/WithdrawalQRCode.tsx | 46 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/business/CreateCashout.tsx | 2 | ||||
-rw-r--r-- | packages/demobank-ui/src/route.ts | 124 |
13 files changed, 396 insertions, 274 deletions
diff --git a/packages/demobank-ui/src/Routing.tsx b/packages/demobank-ui/src/Routing.tsx index e73493d60..442a276a0 100644 --- a/packages/demobank-ui/src/Routing.tsx +++ b/packages/demobank-ui/src/Routing.tsx @@ -48,6 +48,8 @@ import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; import { CreateCashout } from "./pages/business/CreateCashout.js"; import { ShowCashoutDetails } from "./pages/business/ShowCashoutDetails.js"; import { RouteParamsType, urlPattern, useCurrentLocation } from "./route.js"; +import { useNavigationContext } from "./context/navigation.js"; +import { useEffect } from "preact/hooks"; export function Routing(): VNode { const backend = useBackendState(); @@ -89,12 +91,18 @@ function PublicRounting({ }): VNode { const settings = useSettingsContext(); const { i18n } = useTranslationContext(); - const [loc, routeTo] = useCurrentLocation(publicPages); + const location = useCurrentLocation(publicPages); + const { navigateTo } = useNavigationContext() const { api } = useBankCoreApiContext(); const [notification, notify, handleError] = useLocalNotification(); - if (loc === undefined) { - routeTo("login", {}); + useEffect(() => { + if (location === undefined) { + navigateTo(privatePages.home.url({})) + } + }, [location]) + + if (location === undefined) { return <Fragment />; } @@ -132,7 +140,7 @@ function PublicRounting({ }); } - switch (loc.name) { + switch (location.name) { case "login": { return ( <Fragment> @@ -148,17 +156,17 @@ function PublicRounting({ return <PublicHistoriesPage />; } case "operationDetails": { - const { wopid } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { wopid } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <WithdrawalOperationPage operationId={wopid} - onOperationAborted={() => routeTo("login", {})} + onOperationAborted={() => navigateTo(publicPages.login.url({}))} routeClose={publicPages.login} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(publicPages.solveSecondFactor.url({}))} /> ); } @@ -176,18 +184,17 @@ function PublicRounting({ case "solveSecondFactor": { return ( <SolveChallengePage - onChallengeCompleted={() => routeTo("login", {})} + onChallengeCompleted={() => navigateTo(publicPages.login.url({}))} routeClose={publicPages.login} /> ); } default: - assertUnreachable(loc.name); + assertUnreachable(location.name); } } export const privatePages = { - home: urlPattern(/\/account/, () => "#/account"), homeChargeWallet: urlPattern( /\/account\/charge-wallet/, () => "#/account/charge-wallet", @@ -196,6 +203,7 @@ export const privatePages = { /\/account\/wire-transfer/, () => "#/account/wire-transfer", ), + home: urlPattern(/\/account/, () => "#/account"), solveSecondFactor: urlPattern(/\/2fa/, () => "#/2fa"), cashoutCreate: urlPattern(/\/new-cashout/, () => "#/new-cashout"), cashoutDetails: urlPattern<{ cid: string }>( @@ -233,7 +241,7 @@ export const privatePages = { ({ account }) => `#/profile/${account}/cashouts`, ), operationDetails: urlPattern<{ wopid: string }>( - /\/operation\/(?<wopid>[a-zA-Z0-9]+)/, + /\/operation\/(?<wopid>[a-zA-Z0-9-]+)/, ({ wopid }) => `#/operation/${wopid}`, ), }; @@ -245,33 +253,38 @@ function PrivateRouting({ username: string; isAdmin: boolean; }): VNode { - const [loc, routeTo] = useCurrentLocation(privatePages); + const { navigateTo } = useNavigationContext() + const location = useCurrentLocation(privatePages); + useEffect(() => { + if (location === undefined) { + navigateTo(privatePages.home.url({})) + } + }, [location]) - if (loc === undefined) { - routeTo("home", {}); + if (location === undefined) { return <Fragment />; } - switch (loc.name) { + switch (location.name) { case "operationDetails": { - const { wopid } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { wopid } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <WithdrawalOperationPage operationId={wopid} - onOperationAborted={() => routeTo("home", {})} + onOperationAborted={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} /> ); } case "solveSecondFactor": { return ( <SolveChallengePage - onChallengeCompleted={() => routeTo("home", {})} + onChallengeCompleted={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} /> ); @@ -286,64 +299,64 @@ function PrivateRouting({ return ( <CreateNewAccount routeCancel={privatePages.home} - onCreateSuccess={() => routeTo("home", {})} + onCreateSuccess={() => navigateTo(privatePages.home.url({}))} /> ); } case "accountDetails": { - const { account } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { account } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <ShowAccountDetails account={account} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); } case "accountChangePassword": { - const { account } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { account } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <UpdateAccountPassword focus account={account} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); } case "accountDelete": { - const { account } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { account } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <RemoveAccount account={account} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeCancel={privatePages.home} /> ); } case "accountCashouts": { - const { account } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { account } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <CashoutListForAccount account={account} routeCashoutDetails={privatePages.cashoutDetails} routeClose={privatePages.home} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} /> ); } @@ -351,8 +364,8 @@ function PrivateRouting({ return ( <RemoveAccount account={username} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeCancel={privatePages.home} /> ); @@ -361,8 +374,8 @@ function PrivateRouting({ return ( <ShowAccountDetails account={username} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); @@ -372,8 +385,8 @@ function PrivateRouting({ <UpdateAccountPassword focus account={username} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); @@ -383,7 +396,7 @@ function PrivateRouting({ <CashoutListForAccount account={username} routeCashoutDetails={privatePages.cashoutDetails} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); @@ -392,7 +405,7 @@ function PrivateRouting({ if (isAdmin) { return ( <AdminHome - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeCreate={privatePages.accountCreate} routeRemoveAccount={privatePages.accountDelete} routeShowAccount={privatePages.accountDetails} @@ -408,9 +421,9 @@ function PrivateRouting({ routeChargeWallet={privatePages.homeChargeWallet} routeWireTransfer={privatePages.homeWireTransfer} routeClose={privatePages.home} - onClose={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} - onOperationCreated={(wopid) => routeTo("operationDetails", { wopid })} + onClose={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} + onOperationCreated={(wopid) => navigateTo(privatePages.operationDetails.url({ wopid }))} /> ); } @@ -418,15 +431,15 @@ function PrivateRouting({ return ( <CreateCashout account={username} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); } case "cashoutDetails": { - const { cid } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { cid } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <ShowCashoutDetails @@ -436,16 +449,16 @@ function PrivateRouting({ ); } case "wireTranserCreate": { - const { destination } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { destination } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <WireTransfer toAccount={destination} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeCancel={privatePages.home} - onSuccess={() => routeTo("home", {})} + onSuccess={() => navigateTo(privatePages.home.url({}))} /> ); } @@ -457,9 +470,9 @@ function PrivateRouting({ routeChargeWallet={privatePages.homeChargeWallet} routeWireTransfer={privatePages.homeWireTransfer} routeClose={privatePages.home} - onClose={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} - onOperationCreated={(wopid) => routeTo("operationDetails", { wopid })} + onClose={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} + onOperationCreated={(wopid) => navigateTo(privatePages.operationDetails.url({ wopid }))} /> ); } @@ -471,13 +484,13 @@ function PrivateRouting({ routeChargeWallet={privatePages.homeChargeWallet} routeWireTransfer={privatePages.homeWireTransfer} routeClose={privatePages.home} - onClose={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} - onOperationCreated={(wopid) => routeTo("operationDetails", { wopid })} + onClose={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} + onOperationCreated={(wopid) => navigateTo(privatePages.operationDetails.url({ wopid }))} /> ); } default: - assertUnreachable(loc.name); + assertUnreachable(location.name); } } diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx index 31013388b..97778e6d7 100644 --- a/packages/demobank-ui/src/components/app.tsx +++ b/packages/demobank-ui/src/components/app.tsx @@ -30,6 +30,8 @@ import { SettingsProvider } from "../context/settings.js"; import { strings } from "../i18n/strings.js"; import { BankFrame } from "../pages/BankFrame.js"; import { BankUiSettings, fetchSettings } from "../settings.js"; +import { TalerWalletIntegrationBrowserProvider } from "../context/wallet-integration.js"; +import { BrowserHashNavigationProvider } from "../context/navigation.js"; const WITH_LOCAL_STORAGE_CACHE = false; const App: FunctionalComponent = () => { @@ -78,7 +80,11 @@ const App: FunctionalComponent = () => { keepPreviousData: true, }} > - <Routing /> + <TalerWalletIntegrationBrowserProvider> + <BrowserHashNavigationProvider> + <Routing /> + </BrowserHashNavigationProvider> + </TalerWalletIntegrationBrowserProvider> </SWRConfig> </BankCoreApiProvider> </BackendStateProvider> diff --git a/packages/demobank-ui/src/context/navigation.ts b/packages/demobank-ui/src/context/navigation.ts new file mode 100644 index 000000000..fc1460c02 --- /dev/null +++ b/packages/demobank-ui/src/context/navigation.ts @@ -0,0 +1,80 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type Type = { + path: string; + params: Record<string, string>; + navigateTo: (path: string) => void; + // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void); +}; + +// @ts-expect-error shold not be used without provider +const Context = createContext<Type>(undefined); + +export const useNavigationContext = (): Type => useContext(Context); + +function getPathAndParamsFromWindow() { + const path = typeof window !== "undefined" ? window.location.hash.substring(1) : "/"; + const params: Record<string, string> = {} + if (typeof window !== "undefined") { + for (const [key, value] of new URLSearchParams(window.location.search)) { + params[key] = value; + } + } + return { path, params } +} + +const { path: initialPath, params: initialParams } = getPathAndParamsFromWindow() + +// there is a posibility that if the browser does a redirection +// (which doesn't go through navigatTo function) and that exectued +// too early (before addEventListener runs) it won't be taking +// into account +const PopStateEventType = "popstate"; + +export const BrowserHashNavigationProvider = ({ children }: { children: ComponentChildren }): VNode => { + const [{ path, params }, setState] = useState({ path: initialPath, params: initialParams }) + if (typeof window === "undefined") { + throw Error("Can't use BrowserHashNavigationProvider if there is no window object") + } + function navigateTo(path: string) { + const { params } = getPathAndParamsFromWindow() + setState({ path, params }) + window.location.href = path + } + + useEffect(() => { + function eventListener() { + setState(getPathAndParamsFromWindow()) + } + window.addEventListener(PopStateEventType, eventListener); + return () => { + window.removeEventListener(PopStateEventType, eventListener) + } + }, []) + return h(Context.Provider, { + value: { path, params, navigateTo }, + children, + }); +}; diff --git a/packages/demobank-ui/src/context/wallet-integration.ts b/packages/demobank-ui/src/context/wallet-integration.ts new file mode 100644 index 000000000..47bdc90ec --- /dev/null +++ b/packages/demobank-ui/src/context/wallet-integration.ts @@ -0,0 +1,90 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { + stringifyTalerUri, + TalerUri +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + createContext, + h, + VNode +} from "preact"; +import { useContext } from "preact/hooks"; + +/** + * https://docs.taler.net/design-documents/039-taler-browser-integration.html + * + * @param uri + */ +function createHeadMetaTag(uri: TalerUri, onNotFound?: () => void) { + + const meta = document.createElement("meta"); + meta.setAttribute("name", "taler-uri"); + meta.setAttribute("content", stringifyTalerUri(uri)); + + document.head.appendChild(meta); + + let walletFound = false + window.addEventListener("beforeunload", () => { + walletFound = true + }) + setTimeout(() => { + if (!walletFound && onNotFound) { + onNotFound() + } + }, 10)//very short timeout +} +interface Type { + /** + * Tell the active wallet that an action is found + * + * @param uri + * @returns + */ + publishTalerAction: (uri: TalerUri, onNotFound?: () => void) => void; +} + +// @ts-expect-error default value to undefined, should it be another thing? +const Context = createContext<Type>(undefined); + +export const useTalerWalletIntegrationAPI = (): Type => useContext(Context); + +export const TalerWalletIntegrationBrowserProvider = ({ children }: { children: ComponentChildren }): VNode => { + const value: Type = { + publishTalerAction: createHeadMetaTag + }; + return h(Context.Provider, { + value, + children, + }); +}; + + +export const TalerWalletIntegrationTestingProvider = ({ + children, + value, +}: { + children: ComponentChildren; + value: Type; +}): VNode => { + + return h(Context.Provider, { + value, + children, + }); +}; diff --git a/packages/demobank-ui/src/index.html b/packages/demobank-ui/src/index.html index 720b678a3..6e0638e3f 100644 --- a/packages/demobank-ui/src/index.html +++ b/packages/demobank-ui/src/index.html @@ -17,25 +17,25 @@ --> <!doctype html> <html lang="en" class="h-full bg-gray-100"> - <head> - <meta http-equiv="content-type" content="text/html; charset=utf-8" /> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width,initial-scale=1" /> - <meta name="taler-support" content="uri" /> - <meta name="mobile-web-app-capable" content="yes" /> - <meta name="apple-mobile-web-app-capable" content="yes" /> - <link - rel="icon" - href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" - /> - <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> - <title>Bank</title> - <!-- Entry point for the bank SPA. --> - <script type="module" src="index.js"></script> - <link rel="stylesheet" href="index.css" /> - </head> - <body class="h-full"> - <div id="app"></div> - </body> -</html> +<head> + <meta http-equiv="content-type" content="text/html; charset=utf-8" /> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <meta name="taler-support" content="uri,api" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <link rel="icon" + href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" /> + <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> + <title>Bank</title> + <!-- Entry point for the bank SPA. --> + <script type="module" src="index.js"></script> + <link rel="stylesheet" href="index.css" /> +</head> + +<body class="h-full"> + <div id="app"></div> +</body> + +</html>
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/OperationState/views.tsx b/packages/demobank-ui/src/pages/OperationState/views.tsx index ac3724eb8..4d193505e 100644 --- a/packages/demobank-ui/src/pages/OperationState/views.tsx +++ b/packages/demobank-ui/src/pages/OperationState/views.tsx @@ -36,6 +36,7 @@ import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js"; import { State } from "./index.js"; +import { useTalerWalletIntegrationAPI } from "../../context/wallet-integration.js"; export function InvalidPaytoView({ payto }: State.InvalidPayto) { return <div>Payto from server is not valid "{payto}"</div>; @@ -328,23 +329,12 @@ export function ReadyView({ onAbort: doAbort, }: State.Ready): VNode<Record<string, never>> { const { i18n } = useTranslationContext(); + const walletInegrationApi = useTalerWalletIntegrationAPI() const [notification, notify, errorHandler] = useLocalNotification(); const talerWithdrawUri = stringifyWithdrawUri(uri); useEffect(() => { - // Taler Wallet WebExtension is listening to headers response and tab updates. - // In the SPA there is no header response with the Taler URI so - // this hack manually triggers the tab update after the QR is in the DOM. - // WebExtension will be using - // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated - document.title = `${document.title} ${uri.withdrawalOperationId}`; - const meta = document.createElement("meta"); - meta.setAttribute("name", "taler-uri"); - meta.setAttribute("content", talerWithdrawUri); - document.head.insertBefore( - meta, - document.head.children.length ? document.head.children[0] : null, - ); + walletInegrationApi.publishTalerAction(uri) }, []); async function onAbort() { diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index 53086d4cc..51a6a17a9 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -14,13 +14,43 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson } from "@gnu-taler/taler-util"; -import { VNode, h } from "preact"; +import { AmountJson, TalerError } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; import { useBankState } from "../hooks/bank-state.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; import { RouteDefinition } from "../route.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { useWithdrawalDetails } from "../hooks/access.js"; +import { useEffect } from "preact/hooks"; + +function ShowOperationPendingTag({ woid, onOperationAlreadyCompleted }: { woid: string, onOperationAlreadyCompleted?: () => void }): VNode { + const { i18n } = useTranslationContext(); + const result = useWithdrawalDetails(woid); + const error = !result || result instanceof TalerError || result.type === "fail" + const completed = !error && (result.body.status === "aborted" || result.body.status === "confirmed") + useEffect(() => { + if (completed && onOperationAlreadyCompleted) { + onOperationAlreadyCompleted() + } + }, [completed]) + + if (error || completed) { + return <Fragment />; + } + + return <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre"> + <svg + class="h-1.5 w-1.5 fill-green-500" + viewBox="0 0 6 6" + aria-hidden="true" + > + <circle cx="3" cy="3" r="3" /> + </svg> + <i18n.Translate>operation ready</i18n.Translate> + </span> + +} /** * Let the user choose a payment option, @@ -46,7 +76,7 @@ export function PaymentOptions({ routeWireTransfer: RouteDefinition<Record<string, never>>; }): VNode { const { i18n } = useTranslationContext(); - const [bankState] = useBankState(); + const [bankState, updateBankState] = useBankState(); return ( <div class="mt-4"> @@ -98,16 +128,9 @@ export function PaymentOptions({ </i18n.Translate> </div> {!!bankState.currentWithdrawalOperationId && ( - <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre"> - <svg - class="h-1.5 w-1.5 fill-green-500" - viewBox="0 0 6 6" - aria-hidden="true" - > - <circle cx="3" cy="3" r="3" /> - </svg> - <i18n.Translate>operation ready</i18n.Translate> - </span> + <ShowOperationPendingTag woid={bankState.currentWithdrawalOperationId} onOperationAlreadyCompleted={() => { + updateBankState("currentWithdrawalOperationId", undefined) + }} /> )} </div> </label> diff --git a/packages/demobank-ui/src/pages/ProfileNavigation.tsx b/packages/demobank-ui/src/pages/ProfileNavigation.tsx index a6615d578..02f30d8e8 100644 --- a/packages/demobank-ui/src/pages/ProfileNavigation.tsx +++ b/packages/demobank-ui/src/pages/ProfileNavigation.tsx @@ -19,6 +19,7 @@ import { privatePages } from "../Routing.js"; import { useBankCoreApiContext } from "../context/config.js"; import { useBackendState } from "../hooks/backend.js"; import { assertUnreachable } from "@gnu-taler/taler-util"; +import { useNavigationContext } from "../context/navigation.js"; export function ProfileNavigation({ current, @@ -32,6 +33,7 @@ export function ProfileNavigation({ credentials.status !== "loggedIn" ? false : !credentials.isUserAdministrator; + const { navigateTo } = useNavigationContext() return ( <div> <div class="sm:hidden"> @@ -46,19 +48,19 @@ export function ProfileNavigation({ const op = e.currentTarget.value as typeof current; switch (op) { case "details": { - window.location.href = privatePages.myAccountDetails.url({}); + navigateTo(privatePages.myAccountDetails.url({})); return; } case "delete": { - window.location.href = privatePages.myAccountDelete.url({}); + navigateTo(privatePages.myAccountDelete.url({})); return; } case "credentials": { - window.location.href = privatePages.myAccountPassword.url({}); + navigateTo(privatePages.myAccountPassword.url({})); return; } case "cashouts": { - window.location.href = privatePages.myAccountCashouts.url({}); + navigateTo(privatePages.myAccountCashouts.url({})); return; } default: diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx index f21134aa1..037849804 100644 --- a/packages/demobank-ui/src/pages/QrCodeSection.tsx +++ b/packages/demobank-ui/src/pages/QrCodeSection.tsx @@ -19,7 +19,7 @@ import { HttpStatusCode, stringifyWithdrawUri, TranslatedString, - WithdrawUriResult, + WithdrawUriResult } from "@gnu-taler/taler-util"; import { LocalNotificationBanner, @@ -30,6 +30,7 @@ import { Fragment, h, VNode } from "preact"; import { useEffect } from "preact/hooks"; import { QR } from "../components/QR.js"; import { useBankCoreApiContext } from "../context/config.js"; +import { useTalerWalletIntegrationAPI } from "../context/wallet-integration.js"; import { useBackendState } from "../hooks/backend.js"; export function QrCodeSection({ @@ -40,25 +41,15 @@ export function QrCodeSection({ onAborted: () => void; }): VNode { const { i18n } = useTranslationContext(); + const walletInegrationApi = useTalerWalletIntegrationAPI() const talerWithdrawUri = stringifyWithdrawUri(withdrawUri); const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; useEffect(() => { - // Taler Wallet WebExtension is listening to headers response and tab updates. - // In the SPA there is no header response with the Taler URI so - // this hack manually triggers the tab update after the QR is in the DOM. - // WebExtension will be using - // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated - document.title = `${document.title} ${withdrawUri.withdrawalOperationId}`; - const meta = document.createElement("meta"); - meta.setAttribute("name", "taler-uri"); - meta.setAttribute("content", talerWithdrawUri); - document.head.insertBefore( - meta, - document.head.children.length ? document.head.children[0] : null, - ); + walletInegrationApi.publishTalerAction(withdrawUri) }, []); + const [notification, notify, handleError] = useLocalNotification(); const { api } = useBankCoreApiContext(); diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx index 1e48b818a..9f7f46c4f 100644 --- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -21,6 +21,7 @@ import { TranslatedString, assertUnreachable, parseWithdrawUri, + stringifyWithdrawUri, } from "@gnu-taler/taler-util"; import { Attention, @@ -41,6 +42,8 @@ import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { OperationState } from "./OperationState/index.js"; import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js"; +import { useTalerWalletIntegrationAPI } from "../context/wallet-integration.js"; +import { useNavigationContext } from "../context/navigation.js"; const RefAmount = forwardRef(InputAmount); @@ -57,18 +60,32 @@ function OldWithdrawalForm({ }): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); + + // const walletInegrationApi = useTalerWalletIntegrationAPI() + // const { navigateTo } = useNavigationContext(); + const [bankState, updateBankState] = useBankState(); + const { api } = useBankCoreApiContext(); const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const { api } = useBankCoreApiContext(); const [amountStr, setAmountStr] = useState<string | undefined>( `${settings.maxWithdrawalAmount}`, ); const [notification, notify, handleError] = useLocalNotification(); if (bankState.currentWithdrawalOperationId) { + // FIXME: doing the preventDefault is not optimal + + // const suri = stringifyWithdrawUri({ + // bankIntegrationApiBaseUrl: api.getIntegrationAPI().baseUrl, + // withdrawalOperationId: bankState.currentWithdrawalOperationId, + // }); + // const uri = parseWithdrawUri(suri)! + const url = privatePages.operationDetails.url({ + wopid: bankState.currentWithdrawalOperationId, + }) return ( <Attention type="warning" title={i18n.str`There is an operation already`}> <span ref={focus ? doAutoFocus : undefined} /> @@ -77,9 +94,13 @@ function OldWithdrawalForm({ </i18n.Translate>{" "} <a class="font-semibold text-yellow-700 hover:text-yellow-600" - href={privatePages.operationDetails.url({ - wopid: bankState.currentWithdrawalOperationId, - })} + href={url} + // onClick={(e) => { + // e.preventDefault() + // walletInegrationApi.publishTalerAction(uri, () => { + // navigateTo(url) + // }) + // }} > <i18n.Translate>this page</i18n.Translate> </a> @@ -324,7 +345,7 @@ export function WalletWithdrawForm({ onAuthorizationRequired={onAuthorizationRequired} routeClose={routeCancel} onAbort={onOperationAborted} - // route={routeCancel} + // route={routeCancel} /> )} </div> diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx index 3cf552f39..03f6556af 100644 --- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -75,29 +75,39 @@ export function WithdrawalQRCode({ if (data.status === "aborted") { return ( - <section id="main" class="content"> - <h1 class="nav">{i18n.str`Operation aborted`}</h1> - <section> - <p> - <i18n.Translate> - The wire transfer to the Taler Exchange operator's account was - aborted, your balance was not affected. - </i18n.Translate> - </p> - <p> - <i18n.Translate> - You can close this page now or continue to the account page. - </i18n.Translate> - </p> + <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> + <div> + <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100"> + <svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> + </svg> + </div> + <div class="mt-3 text-center sm:mt-5"> + <h3 + class="text-base font-semibold leading-6 text-gray-900" + id="modal-title" + > + <i18n.Translate>Operation aborted</i18n.Translate> + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-500"> + <i18n.Translate> + The wire transfer to the Taler Exchange operator's account was + aborted from somewhere else, your balance was not affected. + </i18n.Translate> + </p> + </div> + </div> + </div> + <div class="mt-5 sm:mt-6"> <a href={routeClose.url({})} - class="pure-button pure-button-primary" - style={{ float: "right" }} + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" > <i18n.Translate>Continue</i18n.Translate> </a> - </section> - </section> + </div> + </div> ); } diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx index 75e0a9b84..46da5c847 100644 --- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx +++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -316,7 +316,7 @@ export function CreateCashout({ const cashoutDisabled = config.supported_tan_channels.length < 1 || !resultAccount.body.cashout_payto_uri; - console.log("disab", cashoutDisabled); + const cashoutAccount = !resultAccount.body.cashout_payto_uri ? undefined : parsePaytoUri(resultAccount.body.cashout_payto_uri); diff --git a/packages/demobank-ui/src/route.ts b/packages/demobank-ui/src/route.ts index 72b405791..912ba274d 100644 --- a/packages/demobank-ui/src/route.ts +++ b/packages/demobank-ui/src/route.ts @@ -13,7 +13,7 @@ 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 { useEffect, useState } from "preact/hooks"; +import { useNavigationContext } from "./context/navigation.js"; export function urlPattern< T extends Record<string, string> = Record<string, never>, @@ -24,29 +24,6 @@ export function urlPattern< }; } -// export function Router({ -// pageList, -// onNotFound, -// }: { -// pageList: Array<PageEntry<unknown>>; -// onNotFound: () => VNode; -// }): VNode { -// const current = useCurrentLocation<unknown>(pageList); -// if (current !== undefined) { -// const d = current.page.url -// if (typeof current.page.url === "string") { -// const p = current.page.url -// return create(current.page.view, {}); -// } -// const p = current.page.url -// return create(current.page.view, current.values); -// } -// return onNotFound(); -// } -// type PagesMap<T extends object> = { -// [name in keyof T]: PageDefinition<any>; -// }; - export type RouteDefinition<T> = { pattern: RegExp; url: (p: T) => string; @@ -72,17 +49,9 @@ export type RouteParamsType< type Location<E, T extends RouteMap<E>, NAME extends keyof T> = { parent: T; name: NAME; - // mapped values from params and url values: RouteParamsType<T, NAME>; }; -const STARTUP_SPA_LOCATION = - typeof window !== "undefined" ? window.location.hash.substring(1) : "/"; -const STARTUP_SPA_PARAMS = - typeof window !== "undefined" - ? new URLSearchParams(window.location.search) - : new URLSearchParams(); - /** * Search path in the pageList * get the values from the path found @@ -91,11 +60,11 @@ const STARTUP_SPA_PARAMS = * @param path * @param params */ -function doSync<DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM>( +function findMatch<DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM>( pagesMap: RM, pageList: Array<ROUTES>, path: string, - params: URLSearchParams, + params: Record<string, string>, ): Location<DEF, RM, ROUTES> | undefined { for (let idx = 0; idx < pageList.length; idx++) { const name = pageList[idx]; @@ -103,9 +72,10 @@ function doSync<DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM>( if (found !== null) { const values = found.groups === undefined ? {} : structuredClone(found.groups); - params.forEach((v, k) => { - values[k] = v; - }); + + Object.entries(params).forEach(([key, value]) => { + values[key] = value + }) // @ts-expect-error values is a map string which is equivalent to the RouteParamsType return { name, parent: pagesMap, values }; @@ -114,87 +84,13 @@ function doSync<DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM>( return undefined; } -const PopStateEventType = "popstate"; - export function useCurrentLocation< DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM, >(pagesMap: RM) { const pageList = Object.keys(pagesMap) as Array<ROUTES>; - const [currentLocation, setCurrentLocation] = useState< - Location<DEF, RM, ROUTES> | undefined - >(doSync(pagesMap, pageList, STARTUP_SPA_LOCATION, STARTUP_SPA_PARAMS)); - useEffect(() => { - window.addEventListener(PopStateEventType, () => { - const path = window.location.hash.substring(1); - console.log("event", path); - const l = doSync( - pagesMap, - pageList, - path, - new URLSearchParams(window.location.search), - ); - setCurrentLocation(l); - }); - }, []); - function routeTo<N extends ROUTES>( - n: N, - values: RouteParamsType<RM, N>, - ): void { - setCurrentLocation({ - parent: pagesMap, - name: n, - values, - }); - } - return [currentLocation, routeTo] as const; -} - -// function doestUrlMatchToRoute( -// url: string, -// route: string, -// ): undefined | Record<string, string> { -// const paramsPattern = /(?:\?([^#]*))?$/; + const { path, params } = useNavigationContext() -// const urlSeg = url.replace(paramsPattern, "").split("/"); -// const routeSeg = route.split("/"); -// let max = Math.max(urlSeg.length, routeSeg.length); - -// const result: Record<string, string> = {}; -// for (let i = 0; i < max; i++) { -// if (routeSeg[i] && routeSeg[i].charAt(0) === ":") { -// const param = routeSeg[i].replace(/(^:|[+*?]+$)/g, ""); - -// const flags = (routeSeg[i].match(/[+*?]+$/) || EMPTY)[0] || ""; -// const plus = ~flags.indexOf("+"); -// const star = ~flags.indexOf("*"); -// const val = urlSeg[i] || ""; - -// if (!val && !star && (flags.indexOf("?") < 0 || plus)) { -// return undefined; -// } -// result[param] = decodeURIComponent(val); -// if (plus || star) { -// result[param] = urlSeg.slice(i).map(decodeURIComponent).join("/"); -// break; -// } -// } else if (routeSeg[i] !== urlSeg[i]) { -// return undefined; -// } -// } - -// const params = url.match(paramsPattern); -// if (params && params[1]) { -// const paramList = params[1].split("&"); -// for (let i = 0; i < paramList.length; i++) { -// const idx = paramList[i].indexOf("="); -// const name = paramList[i].substring(0, idx); -// const value = paramList[i].substring(idx + 1); -// result[decodeURIComponent(name)] = decodeURIComponent(value); -// } -// } - -// return result; -// } -// const EMPTY: Record<string, string> = {}; + return findMatch(pagesMap, pageList, path, params); +} |