merchant-backoffice

ZZZ: Inactive/Deprecated
Log | Files | Refs | Submodules | README

commit e76acb3878c13104210631e429ca9cbbb3a6af8d
parent 3cacc8a3c31ca3cda779a4e5c7be997b3daf2fdf
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon,  8 Mar 2021 09:19:27 -0300

skeletons for order, products, transfers and tips

Diffstat:
MCHANGELOG.md | 22++++++++++++++++++----
Mpackages/frontend/src/AdminRoutes.tsx | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/frontend/src/InstanceRoutes.tsx | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/frontend/src/components/menu/NavigationBar.tsx | 2+-
Mpackages/frontend/src/components/menu/SideBar.tsx | 30+++++++++++++++++++-----------
Mpackages/frontend/src/context/backend.ts | 2++
Mpackages/frontend/src/declaration.d.ts | 614++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/frontend/src/hooks/backend.ts | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/frontend/src/hooks/index.ts | 22++++++++++++++++------
Mpackages/frontend/src/index.tsx | 32++++++++++++++++++++++++++++++--
Mpackages/frontend/src/messages/en.po | 30++++++++++++++++++++++++++++++
Mpackages/frontend/src/routes/admin/list/Table.tsx | 15+++++++++------
Mpackages/frontend/src/routes/admin/list/View.tsx | 1-
Mpackages/frontend/src/routes/instance/details/index.tsx | 14+++++++-------
Apackages/frontend/src/routes/instance/orders/create/index.tsx | 6++++++
Apackages/frontend/src/routes/instance/orders/list/Table.tsx | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/routes/instance/orders/list/index.tsx | 36++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/routes/instance/orders/update/index.tsx | 6++++++
Apackages/frontend/src/routes/instance/products/create/index.tsx | 6++++++
Apackages/frontend/src/routes/instance/products/list/Table.tsx | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/routes/instance/products/list/index.tsx | 45+++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/routes/instance/products/update/index.tsx | 6++++++
Apackages/frontend/src/routes/instance/tips/create/index.tsx | 6++++++
Apackages/frontend/src/routes/instance/tips/list/Table.tsx | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/routes/instance/tips/list/index.tsx | 38++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/routes/instance/tips/update/index.tsx | 6++++++
Apackages/frontend/src/routes/instance/transfers/create/index.tsx | 6++++++
Apackages/frontend/src/routes/instance/transfers/list/Table.tsx | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/routes/instance/transfers/list/index.tsx | 37+++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/routes/instance/transfers/update/index.tsx | 6++++++
Mpackages/frontend/src/routes/instance/update/index.tsx | 4++--
Mpackages/frontend/src/utils/functions.ts | 13++++++++++++-
Apackages/frontend/src/utils/table.ts | 20++++++++++++++++++++
33 files changed, 2035 insertions(+), 51 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -12,11 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - red color when input is invalid (onchange) - validate everything using onChange - feature: input as date format - - bug: there is missing a mutate call when updating to remove the instance from cache - - add order section - - add product section - - add tips section - implement better error handling (improve creation of duplicated instances) - replace Yup and type definition with a taler-library for the purpose (first wait Florian to refactor wallet core) - add more doc style comments @@ -24,8 +20,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - configure prettier - prune scss styles to reduce size - create a loading page to be use when the data is not ready + - some way to copy the url of a created instance + - change the admin title to "instances" if we are listing the instances and "settings: $ID" on updating instances + - fix mobile: some things are still on the left + - update title with: Taler Backoffice: $PAGE_TITLE + - instance id in instance list should be clickable + - edit button to go to instance settings + - notifications should tale place between title and content, and not disapear + - confirmation page when creating instances + - if there is enough space for tables in mobile, make the scrollables ## [Unreleased] + - add order section + - add product section + - add tips section + - add transfers section + - initial state before login + - logout takes you to a initial state, not showing error messages + +## [0.0.3] - 2021-03-04 - submit form on key press == enter - version of backoffice in sidebar - fixed login dialog on mobile @@ -39,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - remove checkbox from auth token, use button (manage auth) - auth token config as popup with 3 actions (clear (sure?), cancel, set token) - new password enpoint + - bug: there is missing a mutate call when updating to remove the instance from cache ## [0.0.2] - 2021-02-25 diff --git a/packages/frontend/src/AdminRoutes.tsx b/packages/frontend/src/AdminRoutes.tsx @@ -15,7 +15,7 @@ */ import { h, VNode } from "preact"; import Router, { route, Route } from "preact-router"; -import { RootPaths, Redirect } from "./index"; +import { RootPaths, Redirect, InstancePaths } from "./index"; import { MerchantBackend } from "./declaration"; import { useMessageTemplate } from "preact-messages"; import { Notification } from "./utils/types"; @@ -25,6 +25,23 @@ import InstanceListPage from './routes/admin/list'; import InstanceCreatePage from "./routes/admin/create"; import NotFoundPage from './routes/notfound'; +import ProductListPage from './routes/instance/products/list' +import ProductCreatePage from './routes/instance/products/create' +import ProductUpdatePage from './routes/instance/products/update' + +import OrderListPage from './routes/instance/orders/list' +import OrderCreatePage from './routes/instance/orders/create' +import OrderUpdatePage from './routes/instance/orders/update' + +import TipListPage from './routes/instance/tips/list' +import TipCreatePage from './routes/instance/tips/create' +import TipUpdatePage from './routes/instance/tips/update' + +import TransferListPage from './routes/instance/transfers/list' +import TransferCreatePage from './routes/instance/transfers/create' +import LoginPage from "./routes/login"; +import { SwrError } from "./hooks/backend"; + interface Props { pushNotification: (n: Notification) => void; instances: MerchantBackend.Instances.Instance[] @@ -32,6 +49,10 @@ interface Props { } export function AdminRoutes({ instances, pushNotification, addTokenCleaner }: Props): VNode { const i18n = useMessageTemplate(); + + // const [token, updateToken] = useBackendInstanceToken(id); + // const { changeBackend } = useBackendContext(); + const updateLoginStatus = () => null; return <Router> <Route path={RootPaths.root} component={Redirect} to={RootPaths.list_instances} /> @@ -78,6 +99,72 @@ export function AdminRoutes({ instances, pushNotification, addTokenCleaner }: Pr parent="/instance/:id" /> + <Route path={InstancePaths.product_list} + component={ProductListPage} + onUnauthorized={() => <LoginPage + withMessage={{ message: i18n`Access denied`, description: i18n`Check your token is valid`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + + onLoadError={(error: SwrError) => <LoginPage + withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + /> + <Route path={InstancePaths.product_update} + component={ProductUpdatePage} + /> + <Route path={InstancePaths.product_new} + component={ProductCreatePage} + /> + + <Route path={InstancePaths.order_list} + component={OrderListPage} + onUnauthorized={() => <LoginPage + withMessage={{ message: i18n`Access denied`, description: i18n`Check your token is valid`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + + onLoadError={(error: SwrError) => <LoginPage + withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + /> + <Route path={InstancePaths.order_update} + component={OrderUpdatePage} + /> + <Route path={InstancePaths.order_new} + component={OrderCreatePage} + /> + + <Route path={InstancePaths.tips_list} + component={TipListPage} + onUnauthorized={() => <LoginPage + withMessage={{ message: i18n`Access denied`, description: i18n`Check your token is valid`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + + onLoadError={(error: SwrError) => <LoginPage + withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + /> + <Route path={InstancePaths.tips_update} + component={TipUpdatePage} + /> + <Route path={InstancePaths.tips_new} + component={TipCreatePage} + /> + + <Route path={InstancePaths.transfers_list} + component={TransferListPage} + onUnauthorized={() => <LoginPage + withMessage={{ message: i18n`Access denied`, description: i18n`Check your token is valid`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + + onLoadError={(error: SwrError) => <LoginPage + withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + /> + <Route path={InstancePaths.transfers_new} + component={TransferCreatePage} + /> + + <Route default component={NotFoundPage} /> </Router> diff --git a/packages/frontend/src/InstanceRoutes.tsx b/packages/frontend/src/InstanceRoutes.tsx @@ -34,6 +34,21 @@ import InstanceUpdatePage from "./routes/instance/update"; import DetailPage from './routes/instance/details'; import NotFoundPage from './routes/notfound'; +import ProductListPage from './routes/instance/products/list' +import ProductCreatePage from './routes/instance/products/create' +import ProductUpdatePage from './routes/instance/products/update' + +import OrderListPage from './routes/instance/orders/list' +import OrderCreatePage from './routes/instance/orders/create' +import OrderUpdatePage from './routes/instance/orders/update' + +import TipListPage from './routes/instance/tips/list' +import TipCreatePage from './routes/instance/tips/create' +import TipUpdatePage from './routes/instance/tips/update' + +import TransferListPage from './routes/instance/transfers/list' +import TransferCreatePage from './routes/instance/transfers/create' + export interface Props { id: string; pushNotification: (n: Notification) => void; @@ -61,7 +76,7 @@ export function InstanceRoutes({ id, pushNotification, addTokenCleaner, parent } return <InstanceContextProvider value={value}> <Router> - <Route path={(!parent? "" : parent) + InstancePaths.details} + <Route path={(!parent ? "" : parent) + InstancePaths.details} component={DetailPage} onUnauthorized={() => <LoginPage @@ -71,9 +86,9 @@ export function InstanceRoutes({ id, pushNotification, addTokenCleaner, parent } onLoadError={(error: SwrError) => <LoginPage withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} onConfirm={updateLoginStatus} />} - /> + /> - <Route path={(!parent? "" : parent) + InstancePaths.update} + <Route path={(!parent ? "" : parent) + InstancePaths.update} component={InstanceUpdatePage} onUnauthorized={() => <LoginPage @@ -98,6 +113,71 @@ export function InstanceRoutes({ id, pushNotification, addTokenCleaner, parent } }} /> + <Route path={(!parent ? "" : parent) + InstancePaths.product_list} + component={ProductListPage} + onUnauthorized={() => <LoginPage + withMessage={{ message: i18n`Access denied`, description: i18n`Check your token is valid`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + + onLoadError={(error: SwrError) => <LoginPage + withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + /> + <Route path={(!parent ? "" : parent) + InstancePaths.product_update} + component={ProductUpdatePage} + /> + <Route path={(!parent ? "" : parent) + InstancePaths.product_new} + component={ProductCreatePage} + /> + + <Route path={(!parent ? "" : parent) + InstancePaths.order_list} + component={OrderListPage} + onUnauthorized={() => <LoginPage + withMessage={{ message: i18n`Access denied`, description: i18n`Check your token is valid`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + + onLoadError={(error: SwrError) => <LoginPage + withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + /> + <Route path={(!parent ? "" : parent) + InstancePaths.order_update} + component={OrderUpdatePage} + /> + <Route path={(!parent ? "" : parent) + InstancePaths.order_new} + component={OrderCreatePage} + /> + + <Route path={(!parent ? "" : parent) + InstancePaths.tips_list} + component={TipListPage} + onUnauthorized={() => <LoginPage + withMessage={{ message: i18n`Access denied`, description: i18n`Check your token is valid`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + + onLoadError={(error: SwrError) => <LoginPage + withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + /> + <Route path={(!parent ? "" : parent) + InstancePaths.tips_update} + component={TipUpdatePage} + /> + <Route path={(!parent ? "" : parent) + InstancePaths.tips_new} + component={TipCreatePage} + /> + + <Route path={(!parent ? "" : parent) + InstancePaths.transfers_list} + component={TransferListPage} + onUnauthorized={() => <LoginPage + withMessage={{ message: i18n`Access denied`, description: i18n`Check your token is valid`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + + onLoadError={(error: SwrError) => <LoginPage + withMessage={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR', }} + onConfirm={updateLoginStatus} />} + /> + <Route path={(!parent ? "" : parent) + InstancePaths.transfers_new} + component={TransferCreatePage} + /> + <Route default component={NotFoundPage} /> </Router> </InstanceContextProvider>; diff --git a/packages/frontend/src/components/menu/NavigationBar.tsx b/packages/frontend/src/components/menu/NavigationBar.tsx @@ -48,7 +48,7 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode { <img src={logo} style={{ height: 50, maxHeight: 50 }} /> </a> <div class="navbar-end"> - <div class="navbar-item"> + <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> <LangSelector /> </div> </div> diff --git a/packages/frontend/src/components/menu/SideBar.tsx b/packages/frontend/src/components/menu/SideBar.tsx @@ -60,28 +60,36 @@ export function Sidebar({ mobile, instance, onLogout }: Props): VNode { </Fragment>} <p class="menu-label">Instance</p> <ul class="menu-list"> - { instance && <li> - <a href="/update" class="has-icon"> - <span class="icon"><i class="mdi mdi-square-edit-outline" /></span> - <span class="menu-item-label">Settings</span> - </a> - </li> } + {instance && <Fragment> + <li> + <a href="/update" class="has-icon"> + <span class="icon"><i class="mdi mdi-square-edit-outline" /></span> + <span class="menu-item-label">Settings</span> + </a> + </li> + </Fragment>} <li> - <a href="/forms" class="has-icon"> + <a href="/o" class="has-icon"> <span class="icon"><i class="mdi mdi-square-edit-outline" /></span> <span class="menu-item-label">Orders</span> </a> </li> <li> - <a href="/profile" class="has-icon"> + <a href="/p" class="has-icon"> + <span class="icon"><i class="mdi mdi-account-circle" /></span> + <span class="menu-item-label">Products</span> + </a> + </li> + <li> + <a href="/t" class="has-icon"> <span class="icon"><i class="mdi mdi-account-circle" /></span> - <span class="menu-item-label">Inventory</span> + <span class="menu-item-label">Transfers</span> </a> </li> <li> - <a href="/profile" class="has-icon"> + <a href="/r" class="has-icon"> <span class="icon"><i class="mdi mdi-account-circle" /></span> - <span class="menu-item-label">Tipping</span> + <span class="menu-item-label">Tips</span> </a> </li> </ul> diff --git a/packages/frontend/src/context/backend.ts b/packages/frontend/src/context/backend.ts @@ -19,6 +19,7 @@ import { StateUpdater, useContext } from 'preact/hooks' export interface BackendContextType { url: string; token?: string; + triedToLog: boolean; changeBackend: (url: string) => void; resetBackend: () => void; // clearTokens: () => void; @@ -43,6 +44,7 @@ const BackendContext = createContext<BackendContextType>({ url: '', lang: 'en', token: undefined, + triedToLog: false, changeBackend: () => null, resetBackend: () => null, // clearTokens: () => null, diff --git a/packages/frontend/src/declaration.d.ts b/packages/frontend/src/declaration.d.ts @@ -21,8 +21,13 @@ +type HashCode = string; type EddsaPublicKey = string; +type EddsaSignature = string; +type WireTransferIdentifierRawP = string; type RelativeTime = Duration; +type ImageDataUrl = string; + interface Timestamp { // Milliseconds since epoch, or the special // value "forever" to represent an event that will @@ -54,6 +59,64 @@ export namespace MerchantBackend { tax: Amount; } + interface Auditor { + // official name + name: string; + + // Auditor's public key + auditor_pub: EddsaPublicKey; + + // Base URL of the auditor + url: string; + } + interface Exchange { + // the exchange's base URL + url: string; + + // master public key of the exchange + master_pub: EddsaPublicKey; + } + + interface Product { + // merchant-internal identifier for the product. + product_id?: string; + + // Human-readable product description. + description: string; + + // Map from IETF BCP 47 language tags to localized descriptions + description_i18n?: { [lang_tag: string]: string }; + + // The number of units of the product to deliver to the customer. + quantity?: Integer; + + // The unit in which the product is measured (liters, kilograms, packages, etc.) + unit?: string; + + // The price of the product; this is the total price for quantity times unit of this product. + price?: Amount; + + // An optional base64-encoded product image + image?: ImageDataUrl; + + // a list of taxes paid by the merchant for this product. Can be empty. + taxes?: Tax[]; + + // time indicating when this product should be delivered + delivery_date?: Timestamp; + } + interface Merchant { + // label for a location with the business address of the merchant + address: Location; + + // the merchant's legal name of business + name: string; + + // label for a location that denotes the jurisdiction for disputes. + // Some of the typical fields for a location (such as a street address) may be absent. + jurisdiction: Location; + } + interface VersionResponse { // libtool-style representation of the Merchant protocol version, see // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning @@ -305,7 +368,7 @@ export namespace MerchantBackend { } - namespace Inventory { + namespace Products { // POST /private/products interface ProductAddDetail { @@ -464,4 +527,553 @@ export namespace MerchantBackend { // DELETE /private/products/$PRODUCT_ID } + + namespace Orders { + interface OrderHistory { + // timestamp-sorted array of all orders matching the query. + // The order of the sorting depends on the sign of delta. + orders: OrderHistoryEntry[]; + } + interface OrderHistoryEntry { + + // order ID of the transaction related to this entry. + order_id: string; + + // row ID of the order in the database + row_id: number; + + // when the order was created + timestamp: Timestamp; + + // the amount of money the order is for + amount: Amount; + + // the summary of the order + summary: string; + + // whether some part of the order is refundable, + // that is the refund deadline has not yet expired + // and the total amount refunded so far is below + // the value of the original transaction. + refundable: boolean; + + // whether the order has been paid or not + paid: boolean; + } + + interface PostOrderRequest { + // The order must at least contain the minimal + // order detail, but can override all + order: Order; + + // if set, the backend will then set the refund deadline to the current + // time plus the specified delay. If it's not set, refunds will not be + // possible. + refund_delay?: RelativeTime; + + // specifies the payment target preferred by the client. Can be used + // to select among the various (active) wire methods supported by the instance. + payment_target?: string; + + // specifies that some products are to be included in the + // order from the inventory. For these inventory management + // is performed (so the products must be in stock) and + // details are completed from the product data of the backend. + inventory_products?: MinimalInventoryProduct[]; + + // Specifies a lock identifier that was used to + // lock a product in the inventory. Only useful if + // manage_inventory is set. Used in case a frontend + // reserved quantities of the individual products while + // the shopping card was being built. Multiple UUIDs can + // be used in case different UUIDs were used for different + // products (i.e. in case the user started with multiple + // shopping sessions that were combined during checkout). + lock_uuids?: UUID[]; + + // Should a token for claiming the order be generated? + // False can make sense if the ORDER_ID is sufficiently + // high entropy to prevent adversarial claims (like it is + // if the backend auto-generates one). Default is 'true'. + create_token?: boolean; + + } + type Order = MinimalOrderDetail | ContractTerms; + + interface MinimalOrderDetail { + // Amount to be paid by the customer + amount: Amount; + + // Short summary of the order + summary: string; + + // URL that will show that the order was successful after + // it has been paid for. Optional. When POSTing to the + // merchant, the placeholder "${ORDER_ID}" will be + // replaced with the actual order ID (useful if the + // order ID is generated server-side and needs to be + // in the URL). + fulfillment_url?: string; + } + + // FIXME: Where is this being used? + // type ProductSpecification = (MinimalInventoryProduct | Product); + + interface MinimalInventoryProduct { + // Which product is requested (here mandatory!) + product_id: string; + + // How many units of the product are requested + quantity: Integer; + } + interface PostOrderResponse { + // Order ID of the response that was just created + order_id: string; + + // Token that authorizes the wallet to claim the order. + // Provided only if "create_token" was set to 'true' + // in the request. + token?: ClaimToken; + } + interface OutOfStockResponse { + + // Product ID of an out-of-stock item + product_id: string; + + // Requested quantity + requested_quantity: Integer; + + // Available quantity (must be below requested_quanitity) + available_quantity: Integer; + + // When do we expect the product to be again in stock? + // Optional, not given if unknown. + restock_expected?: Timestamp; + } + + interface ForgetRequest { + + // Array of valid JSON paths to forgettable fields in the order's + // contract terms. + fields: string[]; + } + interface RefundRequest { + // Amount to be refunded + refund: Amount; + + // Human-readable refund justification + reason: string; + } + interface MerchantRefundResponse { + + // URL (handled by the backend) that the wallet should access to + // trigger refund processing. + // taler://refund/... + taler_refund_uri: string; + + // Contract hash that a client may need to authenticate an + // HTTP request to obtain the above URI in a wallet-friendly way. + h_contract: HashCode; + } + + } + + namespace Tips { + + // GET /private/reserves + interface TippingReserveStatus { + // Array of all known reserves (possibly empty!) + reserves: ReserveStatusEntry[]; + } + interface ReserveStatusEntry { + // Public key of the reserve + reserve_pub: EddsaPublicKey; + + // Timestamp when it was established + creation_time: Timestamp; + + // Timestamp when it expires + expiration_time: Timestamp; + + // Initial amount as per reserve creation call + merchant_initial_amount: Amount; + + // Initial amount as per exchange, 0 if exchange did + // not confirm reserve creation yet. + exchange_initial_amount: Amount; + + // Amount picked up so far. + pickup_amount: Amount; + + // Amount approved for tips that exceeds the pickup_amount. + committed_amount: Amount; + + // Is this reserve active (false if it was deleted but not purged) + active: boolean; + } + + interface ReserveCreateRequest { + // Amount that the merchant promises to put into the reserve + initial_balance: Amount; + + // Exchange the merchant intends to use for tipping + exchange_url: string; + + // Desired wire method, for example "iban" or "x-taler-bank" + wire_method: string; + } + interface ReserveCreateConfirmation { + // Public key identifying the reserve + reserve_pub: EddsaPublicKey; + + // Wire account of the exchange where to transfer the funds + payto_uri: string; + } + interface TipCreateRequest { + // Amount that the customer should be tipped + amount: Amount; + + // Justification for giving the tip + justification: string; + + // URL that the user should be directed to after tipping, + // will be included in the tip_token. + next_url: string; + } + interface TipCreateConfirmation { + // Unique tip identifier for the tip that was created. + tip_id: HashCode; + + // taler://tip URI for the tip + taler_tip_uri: string; + + // URL that will directly trigger processing + // the tip when the browser is redirected to it + tip_status_url: string; + + // when does the tip expire + tip_expiration: Timestamp; + } + + } + + namespace Transfers { + + interface TransferList { + // list of all the transfers that fit the filter that we know + transfers: TransferDetails[]; + } + interface TransferDetails { + // how much was wired to the merchant (minus fees) + credit_amount: Amount; + + // raw wire transfer identifier identifying the wire transfer (a base32-encoded value) + wtid: string; + + // target account that received the wire transfer + payto_uri: string; + + // base URL of the exchange that made the wire transfer + exchange_url: string; + + // Serial number identifying the transfer in the merchant backend. + // Used for filgering via offset. + transfer_serial_id: number; + + // Time of the execution of the wire transfer by the exchange, according to the exchange + // Only provided if we did get an answer from the exchange. + execution_time?: Timestamp; + + // True if we checked the exchange's answer and are happy with it. + // False if we have an answer and are unhappy, missing if we + // do not have an answer from the exchange. + verified?: boolean; + + // True if the merchant uses the POST /transfers API to confirm + // that this wire transfer took place (and it is thus not + // something merely claimed by the exchange). + confirmed?: boolean; + } + + interface TransferInformation { + // how much was wired to the merchant (minus fees) + credit_amount: Amount; + + // raw wire transfer identifier identifying the wire transfer (a base32-encoded value) + wtid: WireTransferIdentifierRawP; + + // target account that received the wire transfer + payto_uri: string; + + // base URL of the exchange that made the wire transfer + exchange_url: string; + } + interface MerchantTrackTransferResponse { + // Total amount transferred + total: Amount; + + // Applicable wire fee that was charged + wire_fee: Amount; + + // Time of the execution of the wire transfer by the exchange, according to the exchange + execution_time: Timestamp; + + // details about the deposits + deposits_sums: MerchantTrackTransferDetail[]; + } + interface MerchantTrackTransferDetail { + // Business activity associated with the wire transferred amount + // deposit_value. + order_id: string; + + // The total amount the exchange paid back for order_id. + deposit_value: Amount; + + // applicable fees for the deposit + deposit_fee: Amount; + } + + type ExchangeConflictDetails = WireFeeConflictDetails | TrackTransferConflictDetails + // Note: this is not the full 'proof' of missbehavior, as + // the bogus message from the exchange with a signature + // over the 'different' wire fee is missing. + // + // This information is NOT provided by the current implementation, + // because this would be quite expensive to generate and is + // hardly needed _here_. Once we add automated reports for + // the Taler auditor, we need to generate this data anyway + // and should probably return it here as well. + interface WireFeeConflictDetails { + // Numerical error code: + code: "TALER_EC_MERCHANT_PRIVATE_POST_TRANSFERS_BAD_WIRE_FEE"; + + // Text describing the issue for humans. + hint: string; + + + // Wire fee (wrongly) charged by the exchange, breaking the + // contract affirmed by the exchange_sig. + wire_fee: Amount; + + // Timestamp of the wire transfer + execution_time: Timestamp; + + // The expected wire fee (as signed by the exchange) + expected_wire_fee: Amount; + + // Expected closing fee (needed to verify signature) + expected_closing_fee: Amount; + + // Start date of the expected fee structure + start_date: Timestamp; + + // End date of the expected fee structure + end_date: Timestamp; + + // Signature of the exchange affirming the expected fee structure + master_sig: EddsaSignature; + + // Master public key of the exchange + master_pub: EddsaPublicKey; + } + interface TrackTransferConflictDetails { + // Numerical error code + code: "TALER_EC_MERCHANT_PRIVATE_POST_TRANSFERS_CONFLICTING_REPORTS"; + + // Text describing the issue for humans. + hint: string; + + // Offset in the exchange_transfer where the + // exchange's response fails to match the exchange_deposit_proof. + conflict_offset: number; + + // The response from the exchange which tells us when the + // coin was returned to us, except that it does not match + // the expected value of the coin. + // + // This field is NOT provided by the current implementation, + // because this would be quite expensive to generate and is + // hardly needed _here_. Once we add automated reports for + // the Taler auditor, we need to generate this data anyway + // and should probably return it here as well. + // exchange_transfer?: TrackTransferResponse; + + // Public key of the exchange used to sign the response to + // our deposit request. + deposit_exchange_pub: EddsaPublicKey; + + // Signature of the exchange signing the (conflicting) response. + // Signs over a struct TALER_DepositConfirmationPS. + deposit_exchange_sig: EddsaSignature; + + // Hash of the merchant's bank account the wire transfer went to + h_wire: HashCode; + + // Hash of the contract terms with the conflicting deposit. + h_contract_terms: HashCode; + + // At what time the exchange received the deposit. Needed + // to verify the \exchange_sig\. + deposit_timestamp: Timestamp; + + // At what time the refund possibility expired (needed to verify exchange_sig). + refund_deadline: Timestamp; + + // Public key of the coin for which we have conflicting information. + coin_pub: EddsaPublicKey; + + // Amount the exchange counted the coin for in the transfer. + amount_with_fee: Amount; + + // Expected value of the coin. + coin_value: Amount; + + // Expected deposit fee of the coin. + coin_fee: Amount; + + // Expected deposit fee of the coin. + deposit_fee: Amount; + + } + + // interface TrackTransferProof { + // // signature from the exchange made with purpose + // // TALER_SIGNATURE_EXCHANGE_CONFIRM_WIRE_DEPOSIT + // exchange_sig: EddsaSignature; + + // // public EdDSA key of the exchange that was used to generate the signature. + // // Should match one of the exchange's signing keys from /keys. Again given + // // explicitly as the client might otherwise be confused by clock skew as to + // // which signing key was used. + // exchange_pub: EddsaSignature; + + // // hash of the wire details (identical for all deposits) + // // Needed to check the exchange_sig + // h_wire: HashCode; + // } + + } + + + interface ContractTerms { + // Human-readable description of the whole purchase + summary: string; + + // Map from IETF BCP 47 language tags to localized summaries + summary_i18n?: { [lang_tag: string]: string }; + + // Unique, free-form identifier for the proposal. + // Must be unique within a merchant instance. + // For merchants that do not store proposals in their DB + // before the customer paid for them, the order_id can be used + // by the frontend to restore a proposal from the information + // encoded in it (such as a short product identifier and timestamp). + order_id: string; + + // Total price for the transaction. + // The exchange will subtract deposit fees from that amount + // before transferring it to the merchant. + amount: Amount; + + // The URL for this purchase. Every time is is visited, the merchant + // will send back to the customer the same proposal. Clearly, this URL + // can be bookmarked and shared by users. + fulfillment_url?: string; + + // Maximum total deposit fee accepted by the merchant for this contract + max_fee: Amount; + + // Maximum wire fee accepted by the merchant (customer share to be + // divided by the 'wire_fee_amortization' factor, and further reduced + // if deposit fees are below 'max_fee'). Default if missing is zero. + max_wire_fee: Amount; + + // Over how many customer transactions does the merchant expect to + // amortize wire fees on average? If the exchange's wire fee is + // above 'max_wire_fee', the difference is divided by this number + // to compute the expected customer's contribution to the wire fee. + // The customer's contribution may further be reduced by the difference + // between the 'max_fee' and the sum of the actual deposit fees. + // Optional, default value if missing is 1. 0 and negative values are + // invalid and also interpreted as 1. + wire_fee_amortization: number; + + // List of products that are part of the purchase (see Product). + products: Product[]; + + // Time when this contract was generated + timestamp: Timestamp; + + // After this deadline has passed, no refunds will be accepted. + refund_deadline: Timestamp; + + // After this deadline, the merchant won't accept payments for the contact + pay_deadline: Timestamp; + + // Transfer deadline for the exchange. Must be in the + // deposit permissions of coins used to pay for this order. + wire_transfer_deadline: Timestamp; + + // Merchant's public key used to sign this proposal; this information + // is typically added by the backend Note that this can be an ephemeral key. + merchant_pub: EddsaPublicKey; + + // Base URL of the (public!) merchant backend API. + // Must be an absolute URL that ends with a slash. + merchant_base_url: string; + + // More info about the merchant, see below + merchant: Merchant; + + // The hash of the merchant instance's wire details. + h_wire: HashCode; + + // Wire transfer method identifier for the wire method associated with h_wire. + // The wallet may only select exchanges via a matching auditor if the + // exchange also supports this wire method. + // The wire transfer fees must be added based on this wire transfer method. + wire_method: string; + + // Any exchanges audited by these auditors are accepted by the merchant. + auditors: Auditor[]; + + // Exchanges that the merchant accepts even if it does not accept any auditors that audit them. + exchanges: Exchange[]; + + // Delivery location for (all!) products. + delivery_location?: Location; + + // Time indicating when the order should be delivered. + // May be overwritten by individual products. + delivery_date?: Timestamp; + + // Nonce generated by the wallet and echoed by the merchant + // in this field when the proposal is generated. + nonce: string; + + // Specifies for how long the wallet should try to get an + // automatic refund for the purchase. If this field is + // present, the wallet should wait for a few seconds after + // the purchase and then automatically attempt to obtain + // a refund. The wallet should probe until "delay" + // after the payment was successful (i.e. via long polling + // or via explicit requests with exponential back-off). + // + // In particular, if the wallet is offline + // at that time, it MUST repeat the request until it gets + // one response from the merchant after the delay has expired. + // If the refund is granted, the wallet MUST automatically + // recover the payment. This is used in case a merchant + // knows that it might be unable to satisfy the contract and + // desires for the wallet to attempt to get the refund without any + // customer interaction. Note that it is NOT an error if the + // merchant does not grant a refund. + auto_refund?: RelativeTime; + + // Extra data that is only interpreted by the merchant frontend. + // Useful when the merchant needs to store extra information on a + // contract without storing it separately in their database. + extra?: any; + } + } diff --git a/packages/frontend/src/hooks/backend.ts b/packages/frontend/src/hooks/backend.ts @@ -52,6 +52,7 @@ interface RequestOptions { method?: Methods; token?: string; data?: any; + params?: any; } @@ -69,11 +70,12 @@ async function request(url: string, options: RequestOptions = {}): Promise<any> const res = await axios({ - method: options.method || 'get', url, responseType: 'json', headers, - data: options.data + method: options.method || 'get', + data: options.data, + params: options.params }) return res.data } catch (e) { @@ -88,6 +90,10 @@ function fetcher(url: string, token: string, backend: string) { return request(`${backend}${url}`, { token }) } +function transferFetcher(url: string, token: string, backend: string) { + return request(`${backend}${url}`, { token, params: { payto_uri: '' } }) +} + interface AdminMutateAPI { createInstance: (data: MerchantBackend.Instances.InstanceConfigurationMessage) => Promise<void>; deleteInstance: (id: string) => Promise<void>; @@ -117,6 +123,218 @@ export function useAdminMutateAPI(): AdminMutateAPI { return { createInstance, deleteInstance } } +interface ProductMutateAPI { + createProduct: (data: MerchantBackend.Products.ProductAddDetail) => Promise<void>; + updateProduct: (id: string, data: MerchantBackend.Products.ProductPatchDetail) => Promise<void>; + deleteProduct: (id: string) => Promise<void>; + lockProduct: (id: string, data: MerchantBackend.Products.LockRequest) => Promise<void>; +} + + +export function useProductMutateAPI(): ProductMutateAPI { + const { url: baseUrl, token: adminToken } = useBackendContext() + const { token: instanceToken, id, admin } = useInstanceContext() + + const { url, token } = !admin ? { + url: baseUrl, token: adminToken + } : { + url: `${baseUrl}/instances/${id}`, token: instanceToken + } + + + const createProduct = async (data: MerchantBackend.Products.ProductAddDetail): Promise<void> => { + await request(`${url}/private/products`, { + method: 'post', + token, + data + }) + + if (adminToken) mutate(['/private/products', adminToken, baseUrl], null) + mutate([`/private/products`, token, url], null) + } + + const updateProduct = async (productId: string, data: MerchantBackend.Products.ProductPatchDetail): Promise<void> => { + await request(`${url}/private/products/${productId}`, { + method: 'patch', + token, + data + }) + + if (adminToken) mutate(['/private/products', adminToken, baseUrl], null) + mutate([`/private/products`, token, url], null) + } + + const deleteProduct = async (productId: string): Promise<void> => { + await request(`${url}/private/products/${productId}`, { + method: 'delete', + token, + }) + + if (adminToken) mutate(['/private/products', adminToken, baseUrl], null) + mutate([`/private/products`, token, url], null) + } + + const lockProduct = async (productId: string, data: MerchantBackend.Products.LockRequest): Promise<void> => { + await request(`${url}/private/products/${productId}/lock`, { + method: 'post', + token, + data + }) + + if (adminToken) mutate(['/private/products', adminToken, baseUrl], null) + mutate([`/private/products`, token, url], null) + } + + return { createProduct, updateProduct, deleteProduct, lockProduct } +} + +interface OrderMutateAPI { + //FIXME: add OutOfStockResponse on 410 + createOrder: (data: MerchantBackend.Orders.PostOrderRequest) => Promise<MerchantBackend.Orders.PostOrderResponse>; + forgetOrder: (id: string, data: MerchantBackend.Orders.ForgetRequest) => Promise<void>; + deleteOrder: (id: string) => Promise<void>; +} + +export function useOrderMutateAPI(): OrderMutateAPI { + const { url: baseUrl, token: adminToken } = useBackendContext() + const { token: instanceToken, id, admin } = useInstanceContext() + + const { url, token } = !admin ? { + url: baseUrl, token: adminToken + } : { + url: `${baseUrl}/instances/${id}`, token: instanceToken + } + + const createOrder = async (data: MerchantBackend.Orders.PostOrderRequest): Promise<MerchantBackend.Orders.PostOrderResponse> => { + const res = await request(`${url}/private/orders`, { + method: 'post', + token, + data + }) + + if (adminToken) mutate(['/private/orders', adminToken, baseUrl], null) + mutate([`/private/orders`, token, url], null) + return res + } + const forgetOrder = async (orderId: string, data: MerchantBackend.Orders.ForgetRequest): Promise<void> => { + await request(`${url}/private/orders/${orderId}/forget`, { + method: 'patch', + token, + data + }) + + if (adminToken) mutate(['/private/orders', adminToken, baseUrl], null) + mutate([`/private/orders`, token, url], null) + } + const deleteOrder = async (orderId: string): Promise<void> => { + await request(`${url}/private/orders/${orderId}`, { + method: 'delete', + token + }) + + if (adminToken) mutate(['/private/orders', adminToken, baseUrl], null) + mutate([`/private/orders`, token, url], null) + } + return { createOrder, forgetOrder, deleteOrder } +} + +interface TransferMutateAPI { + informTransfer: (data: MerchantBackend.Transfers.TransferInformation) => Promise<MerchantBackend.Transfers.MerchantTrackTransferResponse>; +} + +export function useTransferMutateAPI(): TransferMutateAPI { + const { url: baseUrl, token: adminToken } = useBackendContext() + const { token: instanceToken, id, admin } = useInstanceContext() + + const { url, token } = !admin ? { + url: baseUrl, token: adminToken + } : { + url: `${baseUrl}/instances/${id}`, token: instanceToken + } + + const informTransfer = async (data: MerchantBackend.Transfers.TransferInformation): Promise<MerchantBackend.Transfers.MerchantTrackTransferResponse> => { + const res = await request(`${url}/private/transfers`, { + method: 'post', + token, + data + }) + + if (adminToken) mutate(['/private/transfers', adminToken, baseUrl], null) + mutate([`/private/transfers`, token, url], null) + return res + } + + return { informTransfer } +} + +interface TipsMutateAPI { + createReserve: (data: MerchantBackend.Tips.ReserveCreateRequest) => Promise<MerchantBackend.Tips.ReserveCreateConfirmation>; + authorizeTipReserve: (id: string, data: MerchantBackend.Tips.TipCreateRequest) => Promise<MerchantBackend.Tips.TipCreateConfirmation>; + authorizeTip: (data: MerchantBackend.Tips.TipCreateRequest) => Promise<MerchantBackend.Tips.TipCreateConfirmation>; + deleteReserve: (id: string) => Promise<void>; +} + +export function useTipsMutateAPI(): TipsMutateAPI { + const { url: baseUrl, token: adminToken } = useBackendContext() + const { token: instanceToken, id, admin } = useInstanceContext() + + const { url, token } = !admin ? { + url: baseUrl, token: adminToken + } : { + url: `${baseUrl}/instances/${id}`, token: instanceToken + } + + //reserves + const createReserve = async (data: MerchantBackend.Tips.ReserveCreateRequest): Promise<MerchantBackend.Tips.ReserveCreateConfirmation> => { + const res = await request(`${url}/private/reserves`, { + method: 'post', + token, + data + }) + + if (adminToken) mutate(['/private/reserves', adminToken, baseUrl], null) + mutate([`/private/reserves`, token, url], null) + return res + } + + const authorizeTipReserve = async (pub: string, data: MerchantBackend.Tips.TipCreateRequest): Promise<MerchantBackend.Tips.TipCreateConfirmation> => { + const res = await request(`${url}/private/reserves/${pub}/authorize-tip`, { + method: 'post', + token, + data + }) + + if (adminToken) mutate(['/private/reserves', adminToken, baseUrl], null) + mutate([`/private/reserves`, token, url], null) + return res + } + + const authorizeTip = async (data: MerchantBackend.Tips.TipCreateRequest): Promise<MerchantBackend.Tips.TipCreateConfirmation> => { + const res = await request(`${url}/private/tips`, { + method: 'post', + token, + data + }) + + if (adminToken) mutate(['/private/reserves', adminToken, baseUrl], null) + mutate([`/private/reserves`, token, url], null) + return res + } + + const deleteReserve = async (pub: string): Promise<void> => { + await request(`${url}/private/reserves/${pub}`, { + method: 'delete', + token, + }) + + if (adminToken) mutate(['/private/reserves', adminToken, baseUrl], null) + mutate([`/private/reserves`, token, url], null) + } + + + return { createReserve, authorizeTip, authorizeTipReserve, deleteReserve } +} + interface InstaceMutateAPI { updateInstance: (data: MerchantBackend.Instances.InstanceReconfigurationMessage, a?: MerchantBackend.Instances.InstanceAuthConfigurationMessage) => Promise<void>; deleteInstance: () => Promise<void>; @@ -128,7 +346,7 @@ export function useInstanceMutateAPI(): InstaceMutateAPI { const { url: baseUrl, token: adminToken } = useBackendContext() const { token, id, admin } = useInstanceContext() - const url = !admin ? baseUrl: `${baseUrl}/instances/${id}` + const url = !admin ? baseUrl : `${baseUrl}/instances/${id}` const updateInstance = async (instance: MerchantBackend.Instances.InstanceReconfigurationMessage, auth?: MerchantBackend.Instances.InstanceAuthConfigurationMessage): Promise<void> => { await request(`${url}/private/`, { @@ -187,17 +405,77 @@ export function useBackendInstances(): HttpResponse<MerchantBackend.Instances.In return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } } -export function useBackendInstance(): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> { +export function useInstanceDetails(): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> { const { url: baseUrl } = useBackendContext(); const { token, id, admin } = useInstanceContext(); - const url = !admin ? baseUrl: `${baseUrl}/instances/${id}` + const url = !admin ? baseUrl : `${baseUrl}/instances/${id}` const { data, error } = useSWR<MerchantBackend.Instances.QueryInstancesResponse, SwrError>([`/private/`, token, url], fetcher) return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } } +export function useInstanceProducts(): HttpResponse<MerchantBackend.Products.InventorySummaryResponse> { + const { url: baseUrl, token: baseToken } = useBackendContext(); + const { token: instanceToken, id, admin } = useInstanceContext(); + + const { url, token } = !admin ? { + url: baseUrl, token: baseToken + } : { + url: `${baseUrl}/instances/${id}`, token: instanceToken + } + + const { data, error } = useSWR<MerchantBackend.Products.InventorySummaryResponse, SwrError>([`/private/products`, token, url], fetcher) + + return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } +} + +export function useInstanceOrders(): HttpResponse<MerchantBackend.Orders.OrderHistory> { + const { url: baseUrl, token: baseToken } = useBackendContext(); + const { token: instanceToken, id, admin } = useInstanceContext(); + + const { url, token } = !admin ? { + url: baseUrl, token: baseToken + } : { + url: `${baseUrl}/instances/${id}`, token: instanceToken + } + + const { data, error } = useSWR<MerchantBackend.Orders.OrderHistory, SwrError>([`/private/orders`, token, url], fetcher) + + return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } +} + +export function useInstanceTips(): HttpResponse<MerchantBackend.Tips.TippingReserveStatus> { + const { url: baseUrl, token: baseToken } = useBackendContext(); + const { token: instanceToken, id, admin } = useInstanceContext(); + + const { url, token } = !admin ? { + url: baseUrl, token: baseToken + } : { + url: `${baseUrl}/instances/${id}`, token: instanceToken + } + + const { data, error } = useSWR<MerchantBackend.Tips.TippingReserveStatus, SwrError>([`/private/reserves`, token, url], fetcher) + + return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } +} + +export function useInstanceTransfers(): HttpResponse<MerchantBackend.Transfers.TransferList> { + const { url: baseUrl, token: baseToken } = useBackendContext(); + const { token: instanceToken, id, admin } = useInstanceContext(); + + const { url, token } = !admin ? { + url: baseUrl, token: baseToken + } : { + url: `${baseUrl}/instances/${id}`, token: instanceToken + } + + const { data, error } = useSWR<MerchantBackend.Transfers.TransferList, SwrError>([`/private/transfers`, token, url], transferFetcher) + + return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } +} + export function useBackendConfig(): HttpResponse<MerchantBackend.VersionResponse> { const { url, token } = useBackendContext() const { data, error } = useSWR<MerchantBackend.VersionResponse, SwrError>(['/config', token, url], fetcher) diff --git a/packages/frontend/src/hooks/index.ts b/packages/frontend/src/hooks/index.ts @@ -25,18 +25,28 @@ import { ValueOrFunction } from '../utils/types'; export function useBackendContextState() { const [lang, setLang] = useLang() - const [url, changeBackend, resetBackend] = useBackendURL(); + const [url, triedToLog, changeBackend, resetBackend] = useBackendURL(); const [token, updateToken] = useBackendDefaultToken(); - return { url, token, changeBackend, updateToken, lang, setLang, resetBackend } + + return { url, token, triedToLog, changeBackend, updateToken, lang, setLang, resetBackend } } -export function useBackendURL(): [string, StateUpdater<string>, () => void] { +export function useBackendURL(): [string, boolean, StateUpdater<string>, () => void] { const [value, setter] = useNotNullLocalStorage('backend-url', typeof window !== 'undefined' ? window.location.origin : '') - const checkedSetter = (v: ValueOrFunction<string>) => setter(p => (v instanceof Function ? v(p) : v).replace(/\/$/, '')) - const reset = () => checkedSetter(typeof window !== 'undefined' ? window.location.origin : '') - return [value, checkedSetter, reset] + const [triedToLog, setTriedToLog] = useLocalStorage('tried-login') + + const checkedSetter = (v: ValueOrFunction<string>) => { + setTriedToLog('yes') + return setter(p => (v instanceof Function ? v(p) : v).replace(/\/$/, '')) + } + + const resetBackend = () => { + setTriedToLog(undefined) + } + return [value, !!triedToLog, checkedSetter, resetBackend] } + export function useBackendDefaultToken(): [string | undefined, StateUpdater<string | undefined>] { return useLocalStorage('backend-token') } diff --git a/packages/frontend/src/index.tsx b/packages/frontend/src/index.tsx @@ -48,6 +48,21 @@ export enum RootPaths { export enum InstancePaths { details = '/', update = '/update', + + product_list = '/p', + product_update = '/p/:pid/update', + product_new = '/p/new', + + order_list = '/o', + order_update = '/p/:oid/update', + order_new = '/o/new', + + tips_list = '/r', + tips_update = '/r/:rid/update', + tips_new = '/r/new', + + transfers_list = '/t', + transfers_new = '/t/new', } export function Redirect({ to }: { to: string }): null { @@ -71,7 +86,7 @@ export default function Application(): VNode { function ApplicationStatusRoutes(): VNode { const { notifications, pushNotification, removeNotification } = useNotifications() - const { changeBackend, updateToken, resetBackend } = useBackendContext() + const { changeBackend, triedToLog, updateToken, resetBackend } = useBackendContext() const backendConfig = useBackendConfig(); const i18n = useMessageTemplate() @@ -88,6 +103,19 @@ function ApplicationStatusRoutes(): VNode { const v = `${backendConfig.data?.currency} ${backendConfig.data?.version}` const ctx = useMemo(() => ({ currency: backendConfig.data?.currency || '', version: backendConfig.data?.version || '' }), [v]) + if (!triedToLog) { + return <div id="app"> + <Menu /> + <LoginPage + onConfirm={(url: string, token?: string) => { + changeBackend(url) + if (token) updateToken(token) + route(RootPaths.list_instances) + }} + /> + </div> + } + if (!backendConfig.data) { if (!backendConfig.error) return <div class="is-loading" /> @@ -125,7 +153,7 @@ function ApplicationStatusRoutes(): VNode { return <div id="app" class="has-navbar-fixed-top"> <ConfigContextProvider value={ctx}> <Notifications notifications={notifications} removeNotification={removeNotification} /> - <Route default component={ApplicationReadyRoutes} pushNotification={pushNotification} addTokenCleaner={addTokenCleanerMemo} clearAllTokens={clearAllTokens} /> : + <Route default component={ApplicationReadyRoutes} pushNotification={pushNotification} addTokenCleaner={addTokenCleanerMemo} clearAllTokens={clearAllTokens} /> </ConfigContextProvider> </div> } diff --git a/packages/frontend/src/messages/en.po b/packages/frontend/src/messages/en.po @@ -218,5 +218,35 @@ msgstr "Login required" msgid "Please enter your auth token. Token should have \"secret-token:\" and start with Bearer or ApiKey" msgstr "Please enter your auth token. Token should have \"secret-token:\" and start with Bearer or ApiKey" +msgid "Orders" +msgstr "Orders" +msgid "fields.order.amount.label" +msgstr "Amount" +msgid "fields.order.summary.label" +msgstr "Summary" + +msgid "fields.order.paid.label" +msgstr "Paid" + +msgid "Products" +msgstr "Products" + +msgid "fields.product.id.label" +msgstr "Id" + +msgid "Transfers" +msgstr "Transfers" + +msgid "Tips" +msgstr "Tips" + +msgid "fields.tips.committed_amount.label" +msgstr "Commited Amount" + +msgid "fields.tips.exchange_initial_amount.label" +msgstr "Exchange Initial Amount" + +msgid "fields.tips.merchant_initial_amount.label" +msgstr "Merchant Initial Amount" diff --git a/packages/frontend/src/routes/admin/list/Table.tsx b/packages/frontend/src/routes/admin/list/Table.tsx @@ -14,10 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** - * - * @author Sebastian Javier Marchano (sebasjm) - */ +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ import { h, VNode } from "preact" import { Message } from "preact-messages" @@ -118,10 +118,13 @@ function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, onDelet <span class="check" /> </label> </td> - <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.id}</td> - <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.name}</td> + <td><a onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.id}</a></td> + <td >{i.name}</td> <td class="is-actions-cell"> <div class="buttons is-right"> + <button class="button is-small is-success jb-modal" type="button" onClick={(): void => onUpdate(i.id)}> + <span class="icon"><i class="mdi mdi-pen" /></span> + </button> <button class="button is-small is-danger jb-modal" type="button" onClick={(): void => onDelete(i)}> <span class="icon"><i class="mdi mdi-trash-can" /></span> </button> diff --git a/packages/frontend/src/routes/admin/list/View.tsx b/packages/frontend/src/routes/admin/list/View.tsx @@ -22,7 +22,6 @@ import { h, VNode } from "preact"; import { MerchantBackend } from "../../../declaration"; import { CardTable } from './Table'; -import { Message } from "preact-messages"; interface Props { instances: MerchantBackend.Instances.Instance[]; diff --git a/packages/frontend/src/routes/instance/details/index.tsx b/packages/frontend/src/routes/instance/details/index.tsx @@ -17,7 +17,7 @@ import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useInstanceContext } from "../../../context/backend"; import { Notification } from "../../../utils/types"; -import { useBackendInstance, useInstanceMutateAPI, SwrError } from "../../../hooks/backend"; +import { useInstanceDetails, useInstanceMutateAPI, SwrError } from "../../../hooks/backend"; import { DetailPage } from "./DetailPage"; import { DeleteModal } from "../../../components/modal"; @@ -31,14 +31,14 @@ interface Props { export default function Detail({ onUpdate, onLoadError, onUnauthorized, pushNotification, onDelete }: Props): VNode { const { id } = useInstanceContext() - const details = useBackendInstance() + const result = useInstanceDetails() const [deleting, setDeleting] = useState<boolean>(false) const { deleteInstance } = useInstanceMutateAPI() - if (!details.data) { - if (details.unauthorized) return onUnauthorized() - if (details.error) return onLoadError(details.error) + if (!result.data) { + if (result.unauthorized) return onUnauthorized() + if (result.error) return onLoadError(result.error) return <div> loading .... </div> @@ -46,12 +46,12 @@ export default function Detail({ onUpdate, onLoadError, onUnauthorized, pushNoti return <Fragment> <DetailPage - selected={details.data} + selected={result.data} onUpdate={onUpdate} onDelete={() => setDeleting(true)} /> {deleting && <DeleteModal - element={{ name: details.data.name, id }} + element={{ name: result.data.name, id }} onCancel={() => setDeleting(false)} onConfirm={async (): Promise<void> => { try { diff --git a/packages/frontend/src/routes/instance/orders/create/index.tsx b/packages/frontend/src/routes/instance/orders/create/index.tsx @@ -0,0 +1,5 @@ +import { h, VNode } from 'preact'; + +export default function ():VNode { + return <div>order create page</div> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/orders/list/Table.tsx b/packages/frontend/src/routes/instance/orders/list/Table.tsx @@ -0,0 +1,153 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { h, VNode } from "preact" +import { Message } from "preact-messages" +import { StateUpdater, useEffect, useState } from "preact/hooks" +import { MerchantBackend, WidthId } from "../../../../declaration" +import { Actions, buildActions } from "../../../../utils/table"; + +type Entity = MerchantBackend.Orders.OrderHistoryEntry & {id: string} + +interface Props { + instances: Entity[]; + onUpdate: (id: string) => void; + onDelete: (id: Entity) => void; + onCreate: () => void; + selected?: boolean; +} + +export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: Props): VNode { + const [actionQueue, actionQueueHandler] = useState<Actions<Entity>[]>([]); + const [rowSelection, rowSelectionHandler] = useState<string[]>([]) + + useEffect(() => { + if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'DELETE') { + onDelete(actionQueue[0].element) + actionQueueHandler(actionQueue.slice(1)) + } + }, [actionQueue, selected, onDelete]) + + useEffect(() => { + if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'UPDATE') { + onUpdate(actionQueue[0].element.id) + actionQueueHandler(actionQueue.slice(1)) + } + }, [actionQueue, selected, onUpdate]) + + + return <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"><span class="icon"><i class="mdi mdi-account-multiple" /></span><Message id="Orders" /></p> + + <div class="card-header-icon" aria-label="more options"> + + <button class={rowSelection.length > 0 ? "button is-danger" : "is-hidden"} + type="button" + onClick={(): void => actionQueueHandler(buildActions(instances, rowSelection, 'DELETE'))} + > + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + <div class="card-header-icon" aria-label="more options"> + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small" ><i class="mdi mdi-plus mdi-36px" /></span> + </button> + </div> + + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {instances.length > 0 ? + <Table instances={instances} onUpdate={onUpdate} onDelete={onDelete} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} /> : + <EmptyTable /> + } + </div> + </div> + </div> + </div> +} +interface TableProps { + rowSelection: string[]; + instances: Entity[]; + onUpdate: (id: string) => void; + onDelete: (id: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; +} + +function toggleSelected<T>(id: T): (prev: T[]) => T[] { + return (prev: T[]): T[] => prev.indexOf(id) == -1 ? [...prev, id] : prev.filter(e => e != id) +} + +function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, onDelete }: TableProps): VNode { + return ( + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.length === instances.length} onClick={(): void => rowSelectionHandler(rowSelection.length === instances.length ? [] : instances.map(i => i.id))} /> + <span class="check" /> + </label> + </th> + <th><Message id="fields.order.amount.label" /></th> + <th><Message id="fields.order.summary.label" /></th> + <th><Message id="fields.order.paid.label" /></th> + <th /> + </tr> + </thead> + <tbody> + {instances.map(i => { + return <tr> + <td class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.indexOf(i.id) != -1} onClick={(): void => rowSelectionHandler(toggleSelected(i.id))} /> + <span class="check" /> + </label> + </td> + <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.amount}</td> + <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.summary}</td> + <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.paid}</td> + <td class="is-actions-cell"> + <div class="buttons is-right"> + <button class="button is-small is-danger jb-modal" type="button" onClick={(): void => onDelete(i)}> + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + </td> + </tr> + })} + + </tbody> + </table>) +} + +function EmptyTable(): VNode { + return <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"><i class="mdi mdi-emoticon-sad mdi-48px" /></span> + </p> + <p><Message id="There is no instances yet, add more pressing the + sign" /></p> + </div> +} + + diff --git a/packages/frontend/src/routes/instance/orders/list/index.tsx b/packages/frontend/src/routes/instance/orders/list/index.tsx @@ -0,0 +1,35 @@ +import { h, VNode } from 'preact'; +import { useConfigContext } from '../../../../context/backend'; +import { MerchantBackend } from '../../../../declaration'; +import { SwrError, useInstanceOrders, useOrderMutateAPI, useProductMutateAPI } from '../../../../hooks/backend'; +import { CardTable } from './Table'; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (e: SwrError) => VNode; + onCreate: () => void; +} +export default function ({ onUnauthorized, onLoadError, onCreate }: Props): VNode { + const result = useInstanceOrders() + const { createOrder, deleteOrder } = useOrderMutateAPI() + const { currency } = useConfigContext() + if (!result.data) { + if (result.unauthorized) return onUnauthorized() + if (result.error) return onLoadError(result.error) + return <div> + loading .... + </div> + } + return <section class="section is-main-section"> + <CardTable instances={result.data.orders.map(o => ({ ...o, id: o.order_id }))} + onCreate={() => createOrder({ + order: { + amount: `${currency}:${Math.floor(Math.random() * 20 + 1)}`, + summary: `some summary with a random number ${Math.floor(Math.random() * 20 + 1)}`, + } + })} + onDelete={(order: MerchantBackend.Orders.OrderHistoryEntry) => deleteOrder(order.order_id)} + onUpdate={() => null} + /> + </section> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/orders/update/index.tsx b/packages/frontend/src/routes/instance/orders/update/index.tsx @@ -0,0 +1,5 @@ +import { h, VNode } from 'preact'; + +export default function ():VNode { + return <div>order update page</div> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/products/create/index.tsx b/packages/frontend/src/routes/instance/products/create/index.tsx @@ -0,0 +1,5 @@ +import { h, VNode } from 'preact'; + +export default function ():VNode { + return <div>product list page</div> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/products/list/Table.tsx b/packages/frontend/src/routes/instance/products/list/Table.tsx @@ -0,0 +1,147 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { h, VNode } from "preact" +import { Message } from "preact-messages" +import { StateUpdater, useEffect, useState } from "preact/hooks" +import { MerchantBackend } from "../../../../declaration" +import { Actions, buildActions } from "../../../../utils/table" + +type Entity = MerchantBackend.Products.InventoryEntry & { id: string } + +interface Props { + instances: Entity[]; + onUpdate: (id: string) => void; + onDelete: (id: Entity) => void; + onCreate: () => void; + selected?: boolean; +} + +export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: Props): VNode { + const [actionQueue, actionQueueHandler] = useState<Actions<Entity>[]>([]); + const [rowSelection, rowSelectionHandler] = useState<string[]>([]) + + useEffect(() => { + if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'DELETE') { + onDelete(actionQueue[0].element) + actionQueueHandler(actionQueue.slice(1)) + } + }, [actionQueue, selected, onDelete]) + + useEffect(() => { + if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'UPDATE') { + onUpdate(actionQueue[0].element.id) + actionQueueHandler(actionQueue.slice(1)) + } + }, [actionQueue, selected, onUpdate]) + + + return <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"><span class="icon"><i class="mdi mdi-account-multiple" /></span><Message id="Products" /></p> + + <div class="card-header-icon" aria-label="more options"> + + <button class={rowSelection.length > 0 ? "button is-danger" : "is-hidden"} + type="button" onClick={(): void => actionQueueHandler(buildActions(instances, rowSelection, 'DELETE'))} > + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + <div class="card-header-icon" aria-label="more options"> + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small" ><i class="mdi mdi-plus mdi-36px" /></span> + </button> + </div> + + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {instances.length > 0 ? + <Table instances={instances} onUpdate={onUpdate} onDelete={onDelete} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} /> : + <EmptyTable /> + } + </div> + </div> + </div> + </div> +} +interface TableProps { + rowSelection: string[]; + instances: Entity[]; + onUpdate: (id: string) => void; + onDelete: (id: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; +} + +function toggleSelected<T>(id: T): (prev: T[]) => T[] { + return (prev: T[]): T[] => prev.indexOf(id) == -1 ? [...prev, id] : prev.filter(e => e != id) +} + +function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, onDelete }: TableProps): VNode { + return ( + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.length === instances.length} onClick={(): void => rowSelectionHandler(rowSelection.length === instances.length ? [] : instances.map(i => i.id))} /> + <span class="check" /> + </label> + </th> + <th><Message id="fields.product.id.label" /></th> + <th /> + </tr> + </thead> + <tbody> + {instances.map(i => { + return <tr> + <td class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.indexOf(i.id) != -1} onClick={(): void => rowSelectionHandler(toggleSelected(i.id))} /> + <span class="check" /> + </label> + </td> + <td onClick={(): void => onUpdate(i.id)} style={{ cursor: 'pointer' }} >{i.id}</td> + <td class="is-actions-cell"> + <div class="buttons is-right"> + <button class="button is-small is-danger jb-modal" type="button" onClick={(): void => onDelete(i)}> + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + </td> + </tr> + })} + + </tbody> + </table>) +} + +function EmptyTable(): VNode { + return <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"><i class="mdi mdi-emoticon-sad mdi-48px" /></span> + </p> + <p><Message id="There is no instances yet, add more pressing the + sign" /></p> + </div> +} + + diff --git a/packages/frontend/src/routes/instance/products/list/index.tsx b/packages/frontend/src/routes/instance/products/list/index.tsx @@ -0,0 +1,44 @@ +import { h, VNode } from 'preact'; +import { create } from 'yup/lib/Reference'; +import { SwrError, useInstanceProducts, useProductMutateAPI } from '../../../../hooks/backend'; +import { CardTable } from './Table'; +import logo from '../../../../assets/logo.jpeg'; +import { useConfigContext } from '../../../../context/backend'; +import { MerchantBackend } from '../../../../declaration'; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (e: SwrError) => VNode; +} +export default function ({ onUnauthorized, onLoadError }: Props): VNode { + const result = useInstanceProducts() + const { createProduct, deleteProduct } = useProductMutateAPI() + const { currency } = useConfigContext() + if (!result.data) { + if (result.unauthorized) return onUnauthorized() + if (result.error) return onLoadError(result.error) + return <div> + loading .... + </div> + } + return <section class="section is-main-section"> + <CardTable instances={result.data.products.map(o => ({ ...o, id: o.product_id }))} + onCreate={() => createProduct({ + product_id: `${Math.floor(Math.random() * 999999 + 1)}`, + address: {}, + description: '', + description_i18n: { + en: '', es: '' + }, + image: {} as string, //WTF? + price: `${currency}:${Math.floor(Math.random() * 20 + 1)}`, + taxes: [], + total_stock: Math.floor(Math.random() * 20 + 1), + unit: 'units', + next_restock: { t_ms: 'never' }, //WTF? should not be required + })} + onDelete={(prod: MerchantBackend.Products.InventoryEntry) => deleteProduct(prod.product_id)} + onUpdate={() => null} + /> + </section> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/products/update/index.tsx b/packages/frontend/src/routes/instance/products/update/index.tsx @@ -0,0 +1,5 @@ +import { h, VNode } from 'preact'; + +export default function ():VNode { + return <div>product update page</div> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/tips/create/index.tsx b/packages/frontend/src/routes/instance/tips/create/index.tsx @@ -0,0 +1,5 @@ +import { h, VNode } from 'preact'; + +export default function ():VNode { + return <div>tip create page</div> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/tips/list/Table.tsx b/packages/frontend/src/routes/instance/tips/list/Table.tsx @@ -0,0 +1,149 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { h, VNode } from "preact" +import { Message } from "preact-messages" +import { StateUpdater, useEffect, useState } from "preact/hooks" +import { MerchantBackend } from "../../../../declaration" +import { Actions, buildActions } from "../../../../utils/table" + +type Entity = MerchantBackend.Tips.ReserveStatusEntry & { id: string } + +interface Props { + instances: Entity[]; + onUpdate: (id: string) => void; + onDelete: (id: Entity) => void; + onCreate: () => void; + selected?: boolean; +} + +export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: Props): VNode { + const [actionQueue, actionQueueHandler] = useState<Actions<Entity>[]>([]); + const [rowSelection, rowSelectionHandler] = useState<string[]>([]) + + useEffect(() => { + if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'DELETE') { + onDelete(actionQueue[0].element) + actionQueueHandler(actionQueue.slice(1)) + } + }, [actionQueue, selected, onDelete]) + + useEffect(() => { + if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'UPDATE') { + onUpdate(actionQueue[0].element.id) + actionQueueHandler(actionQueue.slice(1)) + } + }, [actionQueue, selected, onUpdate]) + + + return <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"><span class="icon"><i class="mdi mdi-account-multiple" /></span><Message id="Tips" /></p> + + <div class="card-header-icon" aria-label="more options"> + + <button class={rowSelection.length > 0 ? "button is-danger" : "is-hidden"} + type="button" onClick={(): void => actionQueueHandler(buildActions(instances, rowSelection, 'DELETE'))} > + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + <div class="card-header-icon" aria-label="more options"> + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small" ><i class="mdi mdi-plus mdi-36px" /></span> + </button> + </div> + + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {instances.length > 0 ? + <Table instances={instances} onUpdate={onUpdate} onDelete={onDelete} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} /> : + <EmptyTable /> + } + </div> + </div> + </div> + </div> +} +interface TableProps { + rowSelection: string[]; + instances: Entity[]; + onUpdate: (id: string) => void; + onDelete: (id: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; +} + +function toggleSelected<T>(id: T): (prev: T[]) => T[] { + return (prev: T[]): T[] => prev.indexOf(id) == -1 ? [...prev, id] : prev.filter(e => e != id) +} + +function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, onDelete }: TableProps): VNode { + return ( + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.length === instances.length} onClick={(): void => rowSelectionHandler(rowSelection.length === instances.length ? [] : instances.map(i => i.id))} /> + <span class="check" /> + </label> + </th> + <th><Message id="fields.tips.committed_amount.label" /></th> + <th><Message id="fields.tips.exchange_initial_amount.label" /></th> + <th><Message id="fields.tips.merchant_initial_amount.label" /></th> + <th /> + </tr> + </thead> + <tbody> + {instances.map(i => { + return <tr> + <td class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.indexOf(i.id) != -1} onClick={(): void => rowSelectionHandler(toggleSelected(i.id))} /> + <span class="check" /> + </label> + </td> + <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.committed_amount}</td> + <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.exchange_initial_amount}</td> + <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.merchant_initial_amount}</td> + <td class="is-actions-cell"> + <div class="buttons is-right"> + <button class="button is-small is-danger jb-modal" type="button" onClick={(): void => onDelete(i)}> + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + </td> + </tr> + })} + + </tbody> + </table>) +} + +function EmptyTable(): VNode { + return <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"><i class="mdi mdi-emoticon-sad mdi-48px" /></span> + </p> + <p><Message id="There is no instances yet, add more pressing the + sign" /></p> + </div> +} diff --git a/packages/frontend/src/routes/instance/tips/list/index.tsx b/packages/frontend/src/routes/instance/tips/list/index.tsx @@ -0,0 +1,37 @@ +import { h, VNode } from 'preact'; +import { useConfigContext } from '../../../../context/backend'; +import { MerchantBackend } from '../../../../declaration'; +import { SwrError, useInstanceMutateAPI, useInstanceTips, useTipsMutateAPI } from '../../../../hooks/backend'; +import { CardTable } from './Table'; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (e: SwrError) => VNode; +} +export default function ({ onUnauthorized, onLoadError }: Props): VNode { + const result = useInstanceTips() + const { createReserve, deleteReserve } = useTipsMutateAPI() + const { currency } = useConfigContext() + if (!result.data) { + if (result.unauthorized) return onUnauthorized() + if (result.error) return onLoadError(result.error) + return <div> + loading .... + </div> + } + return <section class="section is-main-section"> + <CardTable instances={result.data.reserves.filter(r => r.active).map(o => ({ ...o, id: o.reserve_pub }))} + onCreate={() => createReserve({ + // explode with basic + wire_method: 'x-taler-bank', + initial_balance: `${currency}:${Math.floor(Math.random() * 20 + 1)}`, + //explode with 1 + // hangs with /asd/asd/ + // http://localhost:8081/ + exchange_url: 'http://exchange.taler:8081', + })} + onDelete={(reserve: MerchantBackend.Tips.ReserveStatusEntry) => deleteReserve(reserve.reserve_pub)} + onUpdate={() => null} + /> + </section> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/tips/update/index.tsx b/packages/frontend/src/routes/instance/tips/update/index.tsx @@ -0,0 +1,5 @@ +import { h, VNode } from 'preact'; + +export default function ():VNode { + return <div>tip update page</div> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/transfers/create/index.tsx b/packages/frontend/src/routes/instance/transfers/create/index.tsx @@ -0,0 +1,5 @@ +import { h, VNode } from 'preact'; + +export default function ():VNode { + return <div>transfer create page</div> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/transfers/list/Table.tsx b/packages/frontend/src/routes/instance/transfers/list/Table.tsx @@ -0,0 +1,149 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { h, VNode } from "preact" +import { Message } from "preact-messages" +import { StateUpdater, useEffect, useState } from "preact/hooks" +import { MerchantBackend } from "../../../../declaration" +import { Actions, buildActions } from "../../../../utils/table" + +type Entity = MerchantBackend.Transfers.TransferDetails & { id: string } + +interface Props { + instances: Entity[]; + onUpdate: (id: string) => void; + onDelete: (id: Entity) => void; + onCreate: () => void; + selected?: boolean; +} + +export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: Props): VNode { + const [actionQueue, actionQueueHandler] = useState<Actions<Entity>[]>([]); + const [rowSelection, rowSelectionHandler] = useState<string[]>([]) + + useEffect(() => { + if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'DELETE') { + onDelete(actionQueue[0].element) + actionQueueHandler(actionQueue.slice(1)) + } + }, [actionQueue, selected, onDelete]) + + useEffect(() => { + if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'UPDATE') { + onUpdate(actionQueue[0].element.id) + actionQueueHandler(actionQueue.slice(1)) + } + }, [actionQueue, selected, onUpdate]) + + + return <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"><span class="icon"><i class="mdi mdi-account-multiple" /></span><Message id="Transfers" /></p> + + <div class="card-header-icon" aria-label="more options"> + + <button class={rowSelection.length > 0 ? "button is-danger" : "is-hidden"} + type="button" onClick={(): void => actionQueueHandler(buildActions(instances, rowSelection, 'DELETE'))} > + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + <div class="card-header-icon" aria-label="more options"> + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small" ><i class="mdi mdi-plus mdi-36px" /></span> + </button> + </div> + + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {instances.length > 0 ? + <Table instances={instances} onUpdate={onUpdate} onDelete={onDelete} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} /> : + <EmptyTable /> + } + </div> + </div> + </div> + </div> +} +interface TableProps { + rowSelection: string[]; + instances: Entity[]; + onUpdate: (id: string) => void; + onDelete: (id: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; +} + +function toggleSelected<T>(id: T): (prev: T[]) => T[] { + return (prev: T[]): T[] => prev.indexOf(id) == -1 ? [...prev, id] : prev.filter(e => e != id) +} + +function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, onDelete }: TableProps): VNode { + return ( + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.length === instances.length} onClick={(): void => rowSelectionHandler(rowSelection.length === instances.length ? [] : instances.map(i => i.id))} /> + <span class="check" /> + </label> + </th> + <th><Message id="fields.instance.id.label" /></th> + <th><Message id="fields.instance.name.label" /></th> + <th /> + </tr> + </thead> + <tbody> + {instances.map(i => { + return <tr> + <td class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.indexOf(i.id) != -1} onClick={(): void => rowSelectionHandler(toggleSelected(i.id))} /> + <span class="check" /> + </label> + </td> + <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.credit_amount}</td> + <td onClick={(): void => onUpdate(i.id)} style={{cursor: 'pointer'}} >{i.exchange_url}</td> + <td class="is-actions-cell"> + <div class="buttons is-right"> + <button class="button is-small is-danger jb-modal" type="button" onClick={(): void => onDelete(i)}> + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + </td> + </tr> + })} + + </tbody> + </table>) +} + +function EmptyTable(): VNode { + return <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"><i class="mdi mdi-emoticon-sad mdi-48px" /></span> + </p> + <p><Message id="There is no instances yet, add more pressing the + sign" /></p> + </div> +} + + diff --git a/packages/frontend/src/routes/instance/transfers/list/index.tsx b/packages/frontend/src/routes/instance/transfers/list/index.tsx @@ -0,0 +1,36 @@ +import { h, VNode } from 'preact'; +import { useConfigContext } from '../../../../context/backend'; +import { SwrError, useInstanceTransfers, useTransferMutateAPI } from '../../../../hooks/backend'; +import { CardTable } from './Table'; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (e: SwrError) => VNode; +} +export default function ({ onUnauthorized, onLoadError }: Props): VNode { + const result = useInstanceTransfers() + const { informTransfer } = useTransferMutateAPI() + const { currency } = useConfigContext() + if (!result.data) { + if (result.unauthorized) return onUnauthorized() + if (result.error) return onLoadError(result.error) + return <div> + loading .... + </div> + } + return <section class="section is-main-section"> + <CardTable instances={result.data.transfers.map(o => ({ ...o, id: String(o.transfer_serial_id) }))} + onCreate={() => informTransfer({ + wtid: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + + // exchange: payto://x-taler-bank/bank.taler:5882/exchangeminator + // payto://x-taler-bank/bank.taler:5882/9?subject=qwe&amount=COL:10 + payto_uri: 'payto://x-taler-bank/bank.taler:5882/blogger', + exchange_url: 'http://exchange.taler:8081/', + credit_amount: 'COL:2' + })} + onDelete={() => null} + onUpdate={() => null} + /> + </section> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/transfers/update/index.tsx b/packages/frontend/src/routes/instance/transfers/update/index.tsx @@ -0,0 +1,5 @@ +import { h, VNode } from 'preact'; + +export default function ():VNode { + return <div>order update page</div> +} +\ No newline at end of file diff --git a/packages/frontend/src/routes/instance/update/index.tsx b/packages/frontend/src/routes/instance/update/index.tsx @@ -18,7 +18,7 @@ import { useState } from "preact/hooks"; import { UpdateTokenModal } from "../../../components/modal"; import { useInstanceContext } from "../../../context/backend"; import { MerchantBackend } from "../../../declaration"; -import { SwrError, useBackendInstance, useInstanceMutateAPI } from "../../../hooks/backend"; +import { SwrError, useInstanceDetails, useInstanceMutateAPI } from "../../../hooks/backend"; import { UpdatePage } from "./UpdatePage"; interface Props { @@ -35,7 +35,7 @@ interface Props { export default function Update({ onBack, onConfirm, onLoadError, onUpdateError, onUnauthorized }: Props): VNode { const { updateInstance, setNewToken, clearToken } = useInstanceMutateAPI(); const [updatingToken, setUpdatingToken] = useState<boolean>(false) - const details = useBackendInstance() + const details = useInstanceDetails() const { id, token } = useInstanceContext() if (!details.data) { diff --git a/packages/frontend/src/utils/functions.ts b/packages/frontend/src/utils/functions.ts @@ -20,7 +20,18 @@ export function hasKey<O>(obj: O, key: string | number | symbol): key is keyof O return key in obj } +declare global { + interface Window { MerchantBackoffice: any; } +} + +if (typeof window !== "undefined") { + window.MerchantBackoffice = window.MerchantBackoffice || { + missing_locales: [], + getMissingTranslation: () => Array.from(new Set(window.MerchantBackoffice.missing_locales)).filter(i => i).map(i => `msgid "${i}"\nmsgstr ""\n`).join('\n') + }; +} + export function onTranslationError(error: MessageError) { if (typeof window === "undefined") return; - (window as any)['missing_locale'] = ([] as string[]).concat((window as any)['missing_locale']).concat(error.path.join()) + window.MerchantBackoffice.missing_locales = window.MerchantBackoffice.missing_locales.concat(error.path.join()) } diff --git a/packages/frontend/src/utils/table.ts b/packages/frontend/src/utils/table.ts @@ -0,0 +1,20 @@ + + +export interface Actions<T extends WithId> { + element: T; + type: 'DELETE' | 'UPDATE'; +} + +function notEmpty<TValue>(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined; +} + +interface WithId { + id: string +} + +export function buildActions<T extends WithId>(intances: T[], selected: string[], action: 'DELETE'): Actions<T>[] { + return selected.map(id => intances.find(i => i.id === id)) + .filter(notEmpty) + .map(id => ({ element: id, type: action })) +}