summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2022-12-19 16:25:09 -0300
committerSebastian <sebasjm@gmail.com>2022-12-19 16:25:09 -0300
commitcf7f819685c19adfe6443a6bdd10f3afe10b247d (patch)
tree20ace56c90d25a05995dce04432733445646e51e
parent45691dc991945d8c0a3d4bc95078bd1af5932927 (diff)
downloadwallet-core-cf7f819685c19adfe6443a6bdd10f3afe10b247d.tar.gz
wallet-core-cf7f819685c19adfe6443a6bdd10f3afe10b247d.tar.bz2
wallet-core-cf7f819685c19adfe6443a6bdd10f3afe10b247d.zip
templates
-rw-r--r--packages/merchant-backoffice-ui/src/InstanceRoutes.tsx57
-rw-r--r--packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx16
-rw-r--r--packages/merchant-backoffice-ui/src/declaration.d.ts84
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/templates.ts280
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx23
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/create/Create.stories.tsx41
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx163
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/create/index.tsx63
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/List.stories.tsx28
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/ListPage.tsx64
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx207
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx95
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/update/Update.stories.tsx32
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx174
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/templates/update/index.tsx86
-rw-r--r--packages/merchant-backoffice-ui/src/scss/_custom-calendar.scss12
-rw-r--r--packages/merchant-backoffice-ui/src/utils/amount.ts25
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;
}