diff options
Diffstat (limited to 'packages/merchant-backoffice-ui')
17 files changed, 1400 insertions, 50 deletions
diff --git a/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx b/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx index adfc73e20..817de5f7b 100644 --- a/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx +++ b/packages/merchant-backoffice-ui/src/InstanceRoutes.tsx @@ -43,6 +43,9 @@ import ProductListPage from "./paths/instance/products/list/index.js"; import ProductUpdatePage from "./paths/instance/products/update/index.js"; import TransferListPage from "./paths/instance/transfers/list/index.js"; import TransferCreatePage from "./paths/instance/transfers/create/index.js"; +import TemplateListPage from "./paths/instance/templates/list/index.js"; +import TemplateUpdatePage from "./paths/instance/templates/update/index.js"; +import TemplateCreatePage from "./paths/instance/templates/create/index.js"; import ReservesCreatePage from "./paths/instance/reserves/create/index.js"; import ReservesDetailsPage from "./paths/instance/reserves/details/index.js"; import ReservesListPage from "./paths/instance/reserves/list/index.js"; @@ -78,6 +81,10 @@ export enum InstancePaths { transfers_list = "/transfers", transfers_new = "/transfer/new", + + templates_list = "/templates", + templates_update = "/templates/:tid/update", + templates_new = "/templates/new", } // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -217,7 +224,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { }} > <Route path="/" component={Redirect} to={InstancePaths.order_list} /> - {/** * Admin pages */} @@ -236,7 +242,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { onLoadError={ServerErrorRedirectTo(InstancePaths.error)} /> )} - {admin && ( <Route path={AdminPaths.new_instance} @@ -247,7 +252,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { }} /> )} - {admin && ( <Route path={AdminPaths.update_instance} @@ -261,7 +265,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { onNotFound={NotFoundPage} /> )} - {/** * Update instance page */} @@ -279,7 +282,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { onUnauthorized={LoginPageAccessDenied} onLoadError={ServerErrorRedirectTo(InstancePaths.error)} /> - {/** * Product pages */} @@ -319,7 +321,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { route(InstancePaths.product_list); }} /> - {/** * Order pages */} @@ -356,7 +357,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { route(InstancePaths.order_list); }} /> - {/** * Transfer pages */} @@ -370,7 +370,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { route(InstancePaths.transfers_new); }} /> - <Route path={InstancePaths.transfers_new} component={TransferCreatePage} @@ -381,6 +380,45 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { route(InstancePaths.transfers_list); }} /> + {/** + * Templates pages + */} + <Route + path={InstancePaths.templates_list} + component={TemplateListPage} + onUnauthorized={LoginPageAccessDenied} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onLoadError={ServerErrorRedirectTo(InstancePaths.update)} + onCreate={() => { + route(InstancePaths.templates_new); + }} + onSelect={(id: string) => { + route(InstancePaths.templates_update.replace(":tid", id)); + }} + /> + <Route + path={InstancePaths.templates_update} + component={TemplateUpdatePage} + onConfirm={() => { + route(InstancePaths.templates_list); + }} + onUnauthorized={LoginPageAccessDenied} + onLoadError={ServerErrorRedirectTo(InstancePaths.templates_list)} + onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} + onBack={() => { + route(InstancePaths.templates_list); + }} + /> + <Route + path={InstancePaths.templates_new} + component={TemplateCreatePage} + onConfirm={() => { + route(InstancePaths.templates_list); + }} + onBack={() => { + route(InstancePaths.templates_list); + }} + /> {/** * reserves pages @@ -398,7 +436,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { route(InstancePaths.reserves_new); }} /> - <Route path={InstancePaths.reserves_details} component={ReservesDetailsPage} @@ -409,7 +446,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { route(InstancePaths.reserves_list); }} /> - <Route path={InstancePaths.reserves_new} component={ReservesCreatePage} @@ -420,7 +456,6 @@ export function InstanceRoutes({ id, admin, setInstanceName }: Props): VNode { route(InstancePaths.reserves_list); }} /> - <Route path={InstancePaths.kyc} component={ListKYCPage} /> {/** * Example pages diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx index 1d15bb094..ea49be99a 100644 --- a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx +++ b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -51,12 +51,6 @@ export function Sidebar({ const kycStatus = useInstanceKYCDetails(); const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; - // const withInstanceIdIfNeeded = useCallback(function (path: string) { - // if (mimic) { - // return path + '?instance=' + instance - // } - // return path - // },[instance]) return ( <aside class="aside is-placed-left is-expanded"> @@ -131,6 +125,16 @@ export function Sidebar({ </a> </li> <li> + <a href={"/templates"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-newspaper" /> + </span> + <span class="menu-item-label"> + <Translate>Templates</Translate> + </span> + </a> + </li> + <li> <a href={"/reserves"} class="has-icon"> <span class="icon"> <i class="mdi mdi-cash" /> diff --git a/packages/merchant-backoffice-ui/src/declaration.d.ts b/packages/merchant-backoffice-ui/src/declaration.d.ts index 75bc66201..25b66bdea 100644 --- a/packages/merchant-backoffice-ui/src/declaration.d.ts +++ b/packages/merchant-backoffice-ui/src/declaration.d.ts @@ -1275,6 +1275,90 @@ export namespace MerchantBackend { // } } + namespace Template { + interface TemplateAddDetails { + // Template ID to use. + template_id: string; + + // Human-readable description for the template. + template_description: string; + + // A base64-encoded image selected by the merchant. + // This parameter is optional. + // We are not sure about it. + image?: ImageDataUrl; + + // Additional information in a separate template. + template_contract: TemplateContractDetails; + } + interface TemplateContractDetails { + // Human-readable summary for the template. + summary?: string; + + // The price is imposed by the merchant and cannot be changed by the customer. + // This parameter is optional. + amount?: Amount; + + // Minimum age buyer must have (in years). Default is 0. + minimum_age: Integer; + + // The time the customer need to pay before his order will be deleted. + // It is deleted if the customer did not pay and if the duration is over. + pay_duration: RelativeTime; + } + interface TemplatePatchDetails { + // Human-readable description for the template. + template_description: string; + + // A base64-encoded image selected by the merchant. + // This parameter is optional. + // We are not sure about it. + image?: ImageDataUrl; + + // Additional information in a separate template. + template_contract: TemplateContractDetails; + } + + interface TemplateSummaryResponse { + // List of templates that are present in our backend. + templates: TemplateEntry[]; + } + + interface TemplateEntry { + // Template identifier, as found in the template. + template_id: string; + + // Human-readable description for the template. + template_description: string; + } + + interface TemplateDetails { + // Human-readable description for the template. + template_description: string; + + // A base64-encoded image selected by the merchant. + // This parameter is optional. + // We are not sure about it. + image?: ImageDataUrl; + + // Additional information in a separate template. + template_contract: TemplateContractDetails; + } + + interface UsingTemplateDetails { + // Subject of the template + subject?: string; + + // The amount entered by the customer. + amount?: Amount; + } + + interface UsingTemplateResponse { + // After enter the request. The user will be pay with a taler URL. + taler_url: string; + } + } + interface ContractTerms { // Human-readable description of the whole purchase summary: string; diff --git a/packages/merchant-backoffice-ui/src/hooks/templates.ts b/packages/merchant-backoffice-ui/src/hooks/templates.ts new file mode 100644 index 000000000..3e69d78d0 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/hooks/templates.ts @@ -0,0 +1,280 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { MerchantBackend } from "../declaration.js"; +import { useBackendContext } from "../context/backend.js"; +import { + request, + HttpResponse, + HttpError, + HttpResponseOk, + HttpResponsePaginated, + useMatchMutate, +} from "./backend.js"; +import useSWR from "swr"; +import { useInstanceContext } from "../context/instance.js"; +import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants.js"; +import { useEffect, useState } from "preact/hooks"; + +async function templateFetcher<T>( + url: string, + token: string, + backend: string, + position?: string, + delta?: number, +): Promise<HttpResponseOk<T>> { + const params: any = {}; + if (delta !== undefined) { + params.limit = delta; + } + if (position !== undefined) params.offset = position; + + return request<T>(`${backend}${url}`, { token, params }); +} + +export function useTemplateAPI(): TemplateAPI { + const mutateAll = useMatchMutate(); + 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 createTemplate = async ( + data: MerchantBackend.Template.TemplateAddDetails, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`${url}/private/templates`, { + method: "post", + token, + data, + }); + await mutateAll(/.*private\/templates.*/); + return res; + }; + + const updateTemplate = async ( + templateId: string, + data: MerchantBackend.Template.TemplatePatchDetails, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`${url}/private/templates/${templateId}`, { + method: "patch", + token, + data, + }); + await mutateAll(/.*private\/templates.*/); + return res; + }; + + const deleteTemplate = async ( + templateId: string, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`${url}/private/templates/${templateId}`, { + method: "delete", + token, + }); + await mutateAll(/.*private\/templates.*/); + return res; + }; + + const createOrder = async ( + templateId: string, + data: MerchantBackend.Template.UsingTemplateDetails, + ): Promise< + HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse> + > => { + const res = await request<MerchantBackend.Template.UsingTemplateResponse>( + `${url}/private/templates/${templateId}`, + { + method: "post", + token, + data, + }, + ); + await mutateAll(/.*private\/templates.*/); + return res; + }; + + return { createTemplate, updateTemplate, deleteTemplate, createOrder }; +} + +export interface TemplateAPI { + createTemplate: ( + data: MerchantBackend.Template.TemplateAddDetails, + ) => Promise<HttpResponseOk<void>>; + updateTemplate: ( + id: string, + data: MerchantBackend.Template.TemplatePatchDetails, + ) => Promise<HttpResponseOk<void>>; + deleteTemplate: (id: string) => Promise<HttpResponseOk<void>>; + createOrder: ( + id: string, + data: MerchantBackend.Template.UsingTemplateDetails, + ) => Promise<HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>>; +} + +export interface InstanceTemplateFilter { + //FIXME: add filter to the template list + position?: string; +} + +export function useInstanceTemplates( + args?: InstanceTemplateFilter, + updatePosition?: (id: string) => void, +): HttpResponsePaginated<MerchantBackend.Template.TemplateSummaryResponse> { + 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 [pageBefore, setPageBefore] = useState(1); + const [pageAfter, setPageAfter] = useState(1); + + const totalAfter = pageAfter * PAGE_SIZE; + // const totalBefore = args?.position !== undefined ? pageBefore * PAGE_SIZE : 0; + + /** + * FIXME: this can be cleaned up a little + * + * the logic of double query should be inside the orderFetch so from the hook perspective and cache + * is just one query and one error status + */ + // const { + // data: beforeData, + // error: beforeError, + // isValidating: loadingBefore, + // } = useSWR<HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>, HttpError>( + // [ + // `/private/templates`, + // token, + // url, + // args?.position, + // totalBefore, + // ], + // templateFetcher, + // ); + const { + data: afterData, + error: afterError, + isValidating: loadingAfter, + } = useSWR< + HttpResponseOk<MerchantBackend.Template.TemplateSummaryResponse>, + HttpError + >( + [`/private/templates`, token, url, args?.position, -totalAfter], + templateFetcher, + ); + + //this will save last result + // const [lastBefore, setLastBefore] = useState< + // HttpResponse<MerchantBackend.Template.TemplateSummaryResponse> + // >({ loading: true }); + const [lastAfter, setLastAfter] = useState< + HttpResponse<MerchantBackend.Template.TemplateSummaryResponse> + >({ loading: true }); + useEffect(() => { + if (afterData) setLastAfter(afterData); + // if (beforeData) setLastBefore(beforeData); + }, [afterData /*, beforeData*/]); + + // if (beforeError) return beforeError; + if (afterError) return afterError; + + // if the query returns less that we ask, then we have reach the end or beginning + const isReachingEnd = + afterData && afterData.data.templates.length < totalAfter; + const isReachingStart = false; + // args?.position === undefined + // || + // (beforeData && beforeData.data.templates.length < totalBefore); + + const pagination = { + isReachingEnd, + isReachingStart, + loadMore: () => { + if (!afterData || isReachingEnd) return; + if (afterData.data.templates.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1); + } else { + const from = `${ + afterData.data.templates[afterData.data.templates.length - 1] + .template_id + }`; + if (from && updatePosition) updatePosition(from); + } + }, + loadMorePrev: () => { + // if (!beforeData || isReachingStart) return; + // if (beforeData.data.templates.length < MAX_RESULT_SIZE) { + // setPageBefore(pageBefore + 1); + // } else if (beforeData) { + // const from = `${beforeData.data.templates[beforeData.data.templates.length - 1] + // .template_id + // }`; + // if (from && updatePosition) updatePosition(from); + // } + }, + }; + + const templates = !afterData ? [] : (afterData || lastAfter).data.templates; + // const templates = + // !beforeData || !afterData + // ? [] + // : (beforeData || lastBefore).data.templates + // .slice() + // .reverse() + // .concat((afterData || lastAfter).data.templates); + if (loadingAfter /* || loadingBefore */) + return { loading: true, data: { templates } }; + if (/*beforeData &&*/ afterData) { + return { ok: true, data: { templates }, ...pagination }; + } + return { loading: true }; +} + +export function useTemplateDetails( + templateId: string, +): HttpResponse<MerchantBackend.Template.TemplateDetails> { + 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, isValidating } = useSWR< + HttpResponseOk<MerchantBackend.Template.TemplateDetails>, + HttpError + >([`/private/templates/${templateId}`, token, url], templateFetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }); + + if (isValidating) return { loading: true, data: data?.data }; + if (data) return data; + if (error) return error; + return { loading: true }; +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx index b5fe7611c..315d78c63 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx @@ -23,7 +23,7 @@ import { h, VNode, Fragment } from "preact"; import { useState } from "preact/hooks"; import { Loading } from "../../../../components/exception/loading.js"; import { NotificationCard } from "../../../../components/menu/index.js"; -import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { MerchantBackend } from "../../../../declaration.js"; import { HttpError } from "../../../../hooks/backend.js"; import { InstanceOrderFilter, @@ -44,7 +44,7 @@ interface Props { onCreate: () => void; } -export default function ({ +export default function OrderList({ onUnauthorized, onLoadError, onCreate, @@ -56,13 +56,19 @@ export default function ({ MerchantBackend.Orders.OrderHistoryEntry | undefined >(undefined); - const setNewDate = (date?: Date) => setFilter((prev) => ({ ...prev, date })); + const setNewDate = (date?: Date): void => + setFilter((prev) => ({ ...prev, date })); const result = useInstanceOrders(filter, setNewDate); const { refundOrder, getPaymentURL } = useOrderAPI(); const [notif, setNotif] = useState<Notification | undefined>(undefined); + const i18n = useTranslator(); + const [errorOrderId, setErrorOrderId] = useState<string | undefined>( + undefined, + ); + if (result.clientError && result.isUnauthorized) return onUnauthorized(); if (result.clientError && result.isNotfound) return onNotFound(); if (result.loading) return <Loading />; @@ -78,12 +84,7 @@ export default function ({ ? "is-active" : ""; - const i18n = useTranslator(); - const [errorOrderId, setErrorOrderId] = useState<string | undefined>( - undefined, - ); - - async function testIfOrderExistAndSelect(orderId: string) { + async function testIfOrderExistAndSelect(orderId: string): Promise<void> { if (!orderId) { setErrorOrderId(i18n`Enter an order id`); return; @@ -189,7 +190,7 @@ function RefundModalForTable({ onNotFound, onConfirm, onCancel, -}: RefundProps) { +}: RefundProps): VNode { const result = useOrderDetails(id); if (result.clientError && result.isUnauthorized) return onUnauthorized(); @@ -206,6 +207,6 @@ function RefundModalForTable({ ); } -async function copyToClipboard(text: string) { +async function copyToClipboard(text: string): Promise<void> { return navigator.clipboard.writeText(text); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx new file mode 100644 index 000000000..b81130146 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx @@ -0,0 +1,41 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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, FunctionalComponent } from "preact"; +import { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/Templates/Create", + component: TestedComponent, +}; + +function createExample<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props>, +) { + const r = (args: any) => <Component {...args} />; + r.args = props; + return r; +} + +export const Example = createExample(TestedComponent, { + accounts: ["payto://x-taler-bank/account1", "payto://x-taler-bank/account2"], +}); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx new file mode 100644 index 000000000..dba4b5d14 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -0,0 +1,163 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { InputCurrency } from "../../../../components/form/InputCurrency.js"; +import { InputDuration } from "../../../../components/form/InputDuration.js"; +import { InputNumber } from "../../../../components/form/InputNumber.js"; +import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; +import { useBackendContext } from "../../../../context/backend.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { Translate, useTranslator } from "../../../../i18n/index.js"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; + +type Entity = MerchantBackend.Template.TemplateAddDetails; + +interface Props { + onCreate: (d: Entity) => Promise<void>; + onBack?: () => void; +} + +export function CreatePage({ onCreate, onBack }: Props): VNode { + const i18n = useTranslator(); + const backend = useBackendContext(); + + const [state, setState] = useState<Partial<Entity>>({ + template_contract: { + minimum_age: 0, + pay_duration: { + d_us: 1000 * 1000 * 60 * 30, //30 min + }, + }, + }); + + const errors: FormErrors<Entity> = { + template_id: !state.template_id ? i18n`should not be empty` : undefined, + template_description: !state.template_description + ? i18n`should not be empty` + : undefined, + template_contract: !state.template_contract + ? undefined + : undefinedIfEmpty({ + minimum_age: + state.template_contract.minimum_age < 0 + ? i18n`should be greater that 0` + : undefined, + pay_duration: !state.template_contract.pay_duration + ? i18n`can't be empty` + : state.template_contract.pay_duration.d_us === "forever" + ? undefined + : state.template_contract.pay_duration.d_us < 1000 + ? i18n`to short` + : undefined, + }), + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + return onCreate(state as any); + }; + + return ( + <div> + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider + object={state} + valueHandler={setState} + errors={errors} + > + <InputWithAddon<Entity> + name="template_id" + addonBefore={`${backend.url}/instances/templates/`} + label={i18n`Identifier`} + tooltip={i18n`Name of the template in URLs.`} + /> + + <Input<Entity> + name="template_description" + label={i18n`Description`} + help="" + tooltip={i18n`Describe what this template stands for`} + /> + <Input + name="template_contract.summary" + inputType="multiline" + label={i18n`Order summary`} + tooltip={i18n`Title of the order to be shown to the customer`} + /> + <InputCurrency + name="template_contract.amount" + label={i18n`Order price`} + tooltip={i18n`Order price`} + /> + <InputNumber + name="template_contract.minimum_age" + label={i18n`Minimum age`} + help="" + tooltip={i18n`Is this contract restricted to some age?`} + /> + <InputDuration + name="template_contract.pay_duration" + label={i18n`Payment timeout`} + help="" + tooltip={i18n`How much time has the customer to complete the payment once the order was created.`} + /> + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <Translate>Cancel</Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <Translate>Confirm</Translate> + </AsyncButton> + </div> + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx new file mode 100644 index 000000000..bfedb7369 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx @@ -0,0 +1,63 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useInstanceDetails } from "../../../../hooks/instance.js"; +import { useTemplateAPI } from "../../../../hooks/templates.js"; +import { useTransferAPI } from "../../../../hooks/transfer.js"; +import { useTranslator } from "../../../../i18n/index.js"; +import { Notification } from "../../../../utils/types.js"; +import { CreatePage } from "./CreatePage.js"; + +export type Entity = MerchantBackend.Transfers.TransferInformation; +interface Props { + onBack?: () => void; + onConfirm: () => void; +} + +export default function CreateTransfer({ onConfirm, onBack }: Props): VNode { + const { createTemplate } = useTemplateAPI(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const i18n = useTranslator(); + + return ( + <> + <NotificationCard notification={notif} /> + <CreatePage + onBack={onBack} + onCreate={(request: MerchantBackend.Template.TemplateAddDetails) => { + return createTemplate(request) + .then(() => onConfirm()) + .catch((error) => { + setNotif({ + message: i18n`could not inform template`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx new file mode 100644 index 000000000..702e9ba4a --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { FunctionalComponent, h } from "preact"; +import { ListPage as TestedComponent } from "./ListPage.js"; + +export default { + title: "Pages/Templates/List", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx new file mode 100644 index 000000000..dd983918f --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { MerchantBackend } from "../../../../declaration.js"; +import { useTranslator } from "../../../../i18n/index.js"; +import { CardTable } from "./Table.js"; + +export interface Props { + templates: MerchantBackend.Template.TemplateEntry[]; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; + onCreate: () => void; + onDelete: (e: MerchantBackend.Template.TemplateEntry) => void; + onSelect: (e: MerchantBackend.Template.TemplateEntry) => void; +} + +export function ListPage({ + templates, + onCreate, + onDelete, + onSelect, + onLoadMoreBefore, + onLoadMoreAfter, +}: Props): VNode { + const form = { payto_uri: "" }; + + const i18n = useTranslator(); + return ( + <section class="section is-main-section"> + <CardTable + templates={templates.map((o) => ({ + ...o, + id: String(o.template_id), + }))} + onCreate={onCreate} + onDelete={onDelete} + onSelect={onSelect} + onLoadMoreBefore={onLoadMoreBefore} + hasMoreBefore={!onLoadMoreBefore} + onLoadMoreAfter={onLoadMoreAfter} + hasMoreAfter={!onLoadMoreAfter} + /> + </section> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx new file mode 100644 index 000000000..bc8477039 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx @@ -0,0 +1,207 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { StateUpdater, useState } from "preact/hooks"; +import { MerchantBackend } from "../../../../declaration.js"; +import { Translate, useTranslator } from "../../../../i18n/index.js"; + +type Entity = MerchantBackend.Template.TemplateEntry; + +interface Props { + templates: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + onCreate: () => void; + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; +} + +export function CardTable({ + templates, + onCreate, + onDelete, + onSelect, + onLoadMoreAfter, + onLoadMoreBefore, + hasMoreAfter, + hasMoreBefore, +}: Props): VNode { + const [rowSelection, rowSelectionHandler] = useState<string[]>([]); + + const i18n = useTranslator(); + + return ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-newspaper" /> + </span> + <Translate>Templates</Translate> + </p> + <div class="card-header-icon" aria-label="more options"> + <span class="has-tooltip-left" data-tooltip={i18n`add new templates`}> + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small"> + <i class="mdi mdi-plus mdi-36px" /> + </span> + </button> + </span> + </div> + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {templates.length > 0 ? ( + <Table + instances={templates} + onDelete={onDelete} + onSelect={onSelect} + rowSelection={rowSelection} + rowSelectionHandler={rowSelectionHandler} + onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={onLoadMoreBefore} + hasMoreAfter={hasMoreAfter} + hasMoreBefore={hasMoreBefore} + /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + ); +} +interface TableProps { + rowSelection: string[]; + instances: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; +} + +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({ + instances, + onLoadMoreAfter, + onDelete, + onSelect, + onLoadMoreBefore, + hasMoreAfter, + hasMoreBefore, +}: TableProps): VNode { + const i18n = useTranslator(); + return ( + <div class="table-container"> + {onLoadMoreBefore && ( + <button + class="button is-fullwidth" + data-tooltip={i18n`load more templates before the first one`} + disabled={!hasMoreBefore} + onClick={onLoadMoreBefore} + > + <Translate>load newer templates</Translate> + </button> + )} + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <Translate>ID</Translate> + </th> + <th> + <Translate>Description</Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {instances.map((i) => { + return ( + <tr key={i.template_id}> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.template_id} + </td> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.template_description} + </td> + <td> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n`delete selected templates from the database`} + onClick={() => onDelete(i)} + > + Delete + </button> + </td> + </tr> + ); + })} + </tbody> + </table> + {onLoadMoreAfter && ( + <button + class="button is-fullwidth" + data-tooltip={i18n`load more templates after the last one`} + disabled={!hasMoreAfter} + onClick={onLoadMoreAfter} + > + <Translate>load older templates</Translate> + </button> + )} + </div> + ); +} + +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> + <Translate> + There is no templates yet, add more pressing the + sign + </Translate> + </p> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx new file mode 100644 index 000000000..0d58093d3 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx @@ -0,0 +1,95 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { HttpError } from "../../../../hooks/backend.js"; +import { + useInstanceTemplates, + useTemplateAPI, +} from "../../../../hooks/templates.js"; +import { useTranslator } from "../../../../i18n/index.js"; +import { Notification } from "../../../../utils/types.js"; +import { ListPage } from "./ListPage.js"; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (error: HttpError) => VNode; + onNotFound: () => VNode; + onCreate: () => void; + onSelect: (id: string) => void; +} + +export default function ListTemplates({ + onUnauthorized, + onLoadError, + onCreate, + onSelect, + onNotFound, +}: Props): VNode { + const [position, setPosition] = useState<string | undefined>(undefined); + const i18n = useTranslator(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { deleteTemplate } = useTemplateAPI(); + const result = useInstanceTemplates({ position }, (id) => setPosition(id)); + + if (result.clientError && result.isUnauthorized) return onUnauthorized(); + if (result.clientError && result.isNotfound) return onNotFound(); + if (result.loading) return <Loading />; + if (!result.ok) return onLoadError(result); + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <ListPage + templates={result.data.templates} + onLoadMoreBefore={ + result.isReachingStart ? result.loadMorePrev : undefined + } + onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined} + onCreate={onCreate} + onSelect={(e) => { + onSelect(e.template_id); + }} + onDelete={(e: MerchantBackend.Template.TemplateEntry) => + deleteTemplate(e.template_id) + .then(() => + setNotif({ + message: i18n`template delete successfully`, + type: "SUCCESS", + }), + ) + .catch((error) => + setNotif({ + message: i18n`could not delete the template`, + type: "ERROR", + description: error.message, + }), + ) + } + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx new file mode 100644 index 000000000..8d07cb31f --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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, FunctionalComponent } from "preact"; +import { UpdatePage as TestedComponent } from "./UpdatePage.js"; + +export default { + title: "Pages/Templates/Update", + component: TestedComponent, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx new file mode 100644 index 000000000..42d9e5825 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx @@ -0,0 +1,174 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { InputCurrency } from "../../../../components/form/InputCurrency.js"; +import { InputDuration } from "../../../../components/form/InputDuration.js"; +import { InputNumber } from "../../../../components/form/InputNumber.js"; +import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; +import { ProductForm } from "../../../../components/product/ProductForm.js"; +import { useBackendContext } from "../../../../context/backend.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { useListener } from "../../../../hooks/listener.js"; +import { Translate, useTranslator } from "../../../../i18n/index.js"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; + +type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; + +interface Props { + onUpdate: (d: Entity) => Promise<void>; + onBack?: () => void; + template: Entity; +} + +export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { + const i18n = useTranslator(); + const backend = useBackendContext(); + + const [state, setState] = useState<Partial<Entity>>(template); + + const errors: FormErrors<Entity> = { + template_description: !state.template_description + ? i18n`should not be empty` + : undefined, + template_contract: !state.template_contract + ? undefined + : undefinedIfEmpty({ + minimum_age: + state.template_contract.minimum_age < 0 + ? i18n`should be greater that 0` + : undefined, + pay_duration: !state.template_contract.pay_duration + ? i18n`can't be empty` + : state.template_contract.pay_duration.d_us === "forever" + ? undefined + : state.template_contract.pay_duration.d_us < 1000 + ? i18n`to short` + : undefined, + }), + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + return onUpdate(state as any); + }; + + return ( + <div> + <section class="section"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span class="is-size-4"> + {backend.url}/instances/template/{template.id} + </span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column is-four-fifths"> + <FormProvider + object={state} + valueHandler={setState} + errors={errors} + > + <InputWithAddon<Entity> + name="id" + addonBefore={`templates/`} + readonly + label={i18n`Identifier`} + tooltip={i18n`Name of the template in URLs.`} + /> + + <Input<Entity> + name="template_description" + label={i18n`Description`} + help="" + tooltip={i18n`Describe what this template stands for`} + /> + <Input + name="template_contract.summary" + inputType="multiline" + label={i18n`Order summary`} + tooltip={i18n`Title of the order to be shown to the customer`} + /> + <InputCurrency + name="template_contract.amount" + label={i18n`Order price`} + tooltip={i18n`total product price added up`} + /> + <InputNumber + name="template_contract.minimum_age" + label={i18n`Minimum age`} + help="" + tooltip={i18n`Is this contract restricted to some age?`} + /> + <InputDuration + name="template_contract.pay_duration" + label={i18n`Payment timeout`} + help="" + tooltip={i18n`How much time has the customer to complete the payment once the order was created.`} + /> + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <Translate>Cancel</Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <Translate>Confirm</Translate> + </AsyncButton> + </div> + </div> + </div> + </section> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx new file mode 100644 index 000000000..25dc9abdc --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx @@ -0,0 +1,86 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { HttpError } from "../../../../hooks/backend.js"; +import { useProductAPI, useProductDetails } from "../../../../hooks/product.js"; +import { + useTemplateAPI, + useTemplateDetails, +} from "../../../../hooks/templates.js"; +import { useTranslator } from "../../../../i18n/index.js"; +import { Notification } from "../../../../utils/types.js"; +import { UpdatePage } from "./UpdatePage.js"; + +export type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; + +interface Props { + onBack?: () => void; + onConfirm: () => void; + onUnauthorized: () => VNode; + onNotFound: () => VNode; + onLoadError: (e: HttpError) => VNode; + tid: string; +} +export default function UpdateTemplate({ + tid, + onConfirm, + onBack, + onUnauthorized, + onNotFound, + onLoadError, +}: Props): VNode { + const { updateTemplate } = useTemplateAPI(); + const result = useTemplateDetails(tid); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + + const i18n = useTranslator(); + + if (result.clientError && result.isUnauthorized) return onUnauthorized(); + if (result.clientError && result.isNotfound) return onNotFound(); + if (result.loading) return <Loading />; + if (!result.ok) return onLoadError(result); + + return ( + <Fragment> + <NotificationCard notification={notif} /> + <UpdatePage + template={{ ...result.data, id: tid }} + onBack={onBack} + onUpdate={(data) => { + return updateTemplate(tid, data) + .then(onConfirm) + .catch((error) => { + setNotif({ + message: i18n`could not update template`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss b/packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss index 5fec21bf6..34c40092b 100644 --- a/packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss +++ b/packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss @@ -131,9 +131,9 @@ text-align: center; // there's probably a better way to do this, but wanted to try out CSS grid - grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc( - 100% / 7 - ) calc(100% / 7) calc(100% / 7) calc(100% / 7); + grid-template-columns: + calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) + calc(100% / 7) calc(100% / 7) calc(100% / 7); span { color: var(--secondary-text-color-dark); @@ -147,9 +147,9 @@ width: 100%; display: grid; text-align: center; - grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc( - 100% / 7 - ) calc(100% / 7) calc(100% / 7) calc(100% / 7); + grid-template-columns: + calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) + calc(100% / 7) calc(100% / 7) calc(100% / 7); span { color: var(--primary-text-color-dark); diff --git a/packages/merchant-backoffice-ui/src/utils/amount.ts b/packages/merchant-backoffice-ui/src/utils/amount.ts index bdc37952f..93d6a3a4a 100644 --- a/packages/merchant-backoffice-ui/src/utils/amount.ts +++ b/packages/merchant-backoffice-ui/src/utils/amount.ts @@ -21,18 +21,6 @@ import { import { MerchantBackend } from "../declaration.js"; /** - * sums two prices, - * @param one - * @param two - * @returns - */ -const sumPrices = (one: string, two: string) => { - const [currency, valueOne] = one.split(":"); - const [, valueTwo] = two.split(":"); - return `${currency}:${parseInt(valueOne, 10) + parseInt(valueTwo, 10)}`; -}; - -/** * merge refund with the same description and a difference less than one minute * @param prev list of refunds that will hold the merged refunds * @param cur new refund to add to the list @@ -41,7 +29,7 @@ const sumPrices = (one: string, two: string) => { export function mergeRefunds( prev: MerchantBackend.Orders.RefundDetails[], cur: MerchantBackend.Orders.RefundDetails, -) { +): MerchantBackend.Orders.RefundDetails[] { let tail; if ( @@ -54,19 +42,24 @@ export function mergeRefunds( ) { //more than 1 minute difference + //can't merge refunds, they are different or to distant in time prev.push(cur); return prev; } + const a = Amounts.parseOrThrow(tail.amount); + const b = Amounts.parseOrThrow(cur.amount); + const r = Amounts.add(a, b).amount; + prev[prev.length - 1] = { ...tail, - amount: sumPrices(tail.amount, cur.amount), + amount: Amounts.stringify(r), }; return prev; } -export const rate = (one: string, two: string) => { +export const rate = (one: string, two: string): number => { const a = Amounts.parseOrThrow(one); const b = Amounts.parseOrThrow(two); const af = toFloat(a); @@ -75,6 +68,6 @@ export const rate = (one: string, two: string) => { return af / bf; }; -function toFloat(amount: AmountJson) { +function toFloat(amount: AmountJson): number { return amount.value + amount.fraction / amountFractionalBase; } |