add use-template button

This commit is contained in:
Sebastian 2023-01-27 12:35:10 -03:00
parent 7a3717125f
commit 1b2b5d62de
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
18 changed files with 312 additions and 28 deletions

View File

@ -48,6 +48,7 @@ import ReservesCreatePage from "./paths/instance/reserves/create/index.js";
import ReservesDetailsPage from "./paths/instance/reserves/details/index.js"; import ReservesDetailsPage from "./paths/instance/reserves/details/index.js";
import ReservesListPage from "./paths/instance/reserves/list/index.js"; import ReservesListPage from "./paths/instance/reserves/list/index.js";
import TemplateCreatePage from "./paths/instance/templates/create/index.js"; import TemplateCreatePage from "./paths/instance/templates/create/index.js";
import TemplateUsePage from "./paths/instance/templates/use/index.js";
import TemplateListPage from "./paths/instance/templates/list/index.js"; import TemplateListPage from "./paths/instance/templates/list/index.js";
import TemplateUpdatePage from "./paths/instance/templates/update/index.js"; import TemplateUpdatePage from "./paths/instance/templates/update/index.js";
import TransferCreatePage from "./paths/instance/transfers/create/index.js"; import TransferCreatePage from "./paths/instance/transfers/create/index.js";
@ -85,6 +86,7 @@ export enum InstancePaths {
templates_list = "/templates", templates_list = "/templates",
templates_update = "/templates/:tid/update", templates_update = "/templates/:tid/update",
templates_new = "/templates/new", templates_new = "/templates/new",
templates_use = "/templates/:tid/use",
} }
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
@ -399,6 +401,9 @@ export function InstanceRoutes({
onCreate={() => { onCreate={() => {
route(InstancePaths.templates_new); route(InstancePaths.templates_new);
}} }}
onNewOrder={(id: string) => {
route(InstancePaths.templates_use.replace(":tid", id));
}}
onSelect={(id: string) => { onSelect={(id: string) => {
route(InstancePaths.templates_update.replace(":tid", id)); route(InstancePaths.templates_update.replace(":tid", id));
}} }}
@ -426,6 +431,19 @@ export function InstanceRoutes({
route(InstancePaths.templates_list); route(InstancePaths.templates_list);
}} }}
/> />
<Route
path={InstancePaths.templates_use}
component={TemplateUsePage}
onOrderCreated={(id: string) => {
route(InstancePaths.order_details.replace(":oid", id));
}}
onUnauthorized={LoginPageAccessDenied}
onLoadError={ServerErrorRedirectTo(InstancePaths.templates_list)}
onNotFound={IfAdminCreateDefaultOr(NotFoundPage)}
onBack={() => {
route(InstancePaths.templates_list);
}}
/>
{/** {/**
* reserves pages * reserves pages

View File

@ -92,6 +92,7 @@ export function Input<T>({
inputType={inputType} inputType={inputType}
placeholder={placeholder} placeholder={placeholder}
readonly={readonly} readonly={readonly}
disabled={readonly}
name={String(name)} name={String(name)}
value={toStr(value)} value={toStr(value)}
onChange={(e: h.JSX.TargetedEvent<HTMLInputElement>): void => onChange={(e: h.JSX.TargetedEvent<HTMLInputElement>): void =>

View File

@ -86,6 +86,7 @@ export function InputWithAddon<T>({
type={inputType} type={inputType}
placeholder={placeholder} placeholder={placeholder}
readonly={readonly} readonly={readonly}
disabled={readonly}
name={String(name)} name={String(name)}
value={toStr(value)} value={toStr(value)}
onChange={(e): void => onChange(fromStr(e.currentTarget.value))} onChange={(e): void => onChange(fromStr(e.currentTarget.value))}

View File

@ -1347,7 +1347,7 @@ export namespace MerchantBackend {
interface UsingTemplateDetails { interface UsingTemplateDetails {
// Subject of the template // Subject of the template
subject?: string; summary?: string;
// The amount entered by the customer. // The amount entered by the customer.
amount?: Amount; amount?: Amount;
@ -1355,7 +1355,8 @@ export namespace MerchantBackend {
interface UsingTemplateResponse { interface UsingTemplateResponse {
// After enter the request. The user will be pay with a taler URL. // After enter the request. The user will be pay with a taler URL.
taler_url: string; order_id: string,
token: string,
} }
} }

View File

@ -62,14 +62,14 @@ export function useTemplateAPI(): TemplateAPI {
return res; return res;
}; };
const createOrder = async ( const createOrderFromTemplate = async (
templateId: string, templateId: string,
data: MerchantBackend.Template.UsingTemplateDetails, data: MerchantBackend.Template.UsingTemplateDetails,
): Promise< ): Promise<
HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse> HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>
> => { > => {
const res = await request<MerchantBackend.Template.UsingTemplateResponse>( const res = await request<MerchantBackend.Template.UsingTemplateResponse>(
`/private/templates/${templateId}`, `/templates/${templateId}`,
{ {
method: "POST", method: "POST",
data, data,
@ -79,7 +79,7 @@ export function useTemplateAPI(): TemplateAPI {
return res; return res;
}; };
return { createTemplate, updateTemplate, deleteTemplate, createOrder }; return { createTemplate, updateTemplate, deleteTemplate, createOrderFromTemplate };
} }
export interface TemplateAPI { export interface TemplateAPI {
@ -91,7 +91,7 @@ export interface TemplateAPI {
data: MerchantBackend.Template.TemplatePatchDetails, data: MerchantBackend.Template.TemplatePatchDetails,
) => Promise<HttpResponseOk<void>>; ) => Promise<HttpResponseOk<void>>;
deleteTemplate: (id: string) => Promise<HttpResponseOk<void>>; deleteTemplate: (id: string) => Promise<HttpResponseOk<void>>;
createOrder: ( createOrderFromTemplate: (
id: string, id: string,
data: MerchantBackend.Template.UsingTemplateDetails, data: MerchantBackend.Template.UsingTemplateDetails,
) => Promise<HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>>; ) => Promise<HttpResponseOk<MerchantBackend.Template.UsingTemplateResponse>>;
@ -174,10 +174,9 @@ export function useInstanceTemplates(
if (afterData.data.templates.length < MAX_RESULT_SIZE) { if (afterData.data.templates.length < MAX_RESULT_SIZE) {
setPageAfter(pageAfter + 1); setPageAfter(pageAfter + 1);
} else { } else {
const from = `${ const from = `${afterData.data.templates[afterData.data.templates.length - 1]
afterData.data.templates[afterData.data.templates.length - 1] .template_id
.template_id }`;
}`;
if (from && updatePosition) updatePosition(from); if (from && updatePosition) updatePosition(from);
} }
}, },

View File

@ -40,8 +40,8 @@ function convert(
const payto_uris = accounts.filter((a) => a.active).map((a) => a.payto_uri); const payto_uris = accounts.filter((a) => a.active).map((a) => a.payto_uri);
const defaults = { const defaults = {
default_wire_fee_amortization: 1, default_wire_fee_amortization: 1,
default_pay_delay: { d_us: 1000 * 60 * 60 }, //one hour default_pay_delay: { d_us: 1000 * 60 * 60 * 1000 }, //one hour
default_wire_transfer_delay: { d_us: 1000 * 60 * 60 * 2 }, //two hours default_wire_transfer_delay: { d_us: 1000 * 60 * 60 * 2 * 1000 }, //two hours
}; };
return { ...defaults, ...rest, payto_uris }; return { ...defaults, ...rest, payto_uris };
} }

View File

@ -59,11 +59,11 @@ export const Example = createExample(TestedComponent, {
default_max_deposit_fee: "TESTKUDOS:2", default_max_deposit_fee: "TESTKUDOS:2",
default_max_wire_fee: "TESTKUDOS:1", default_max_wire_fee: "TESTKUDOS:1",
default_pay_delay: { default_pay_delay: {
d_us: 1000000, d_us: 1000 * 1000, //one second
}, },
default_wire_fee_amortization: 1, default_wire_fee_amortization: 1,
default_wire_transfer_delay: { default_wire_transfer_delay: {
d_us: 100000, d_us: 1000 * 1000, //one second
}, },
merchant_pub: "ASDWQEKASJDKSADJ", merchant_pub: "ASDWQEKASJDKSADJ",
}, },

View File

@ -45,7 +45,7 @@ export const Example = createExample(TestedComponent, {
default_max_deposit_fee: "", default_max_deposit_fee: "",
default_max_wire_fee: "", default_max_wire_fee: "",
default_pay_delay: { default_pay_delay: {
d_us: 1000 * 60 * 60, d_us: 1000 * 1000 * 60 * 60, //one hour
}, },
default_wire_fee_amortization: 1, default_wire_fee_amortization: 1,
}, },

View File

@ -61,7 +61,9 @@ function with_defaults(config: InstanceConfig): Partial<Entity> {
const defaultPayDeadline = const defaultPayDeadline =
!config.default_pay_delay || config.default_pay_delay.d_us === "forever" !config.default_pay_delay || config.default_pay_delay.d_us === "forever"
? undefined ? undefined
: add(new Date(), { seconds: config.default_pay_delay.d_us / 1000 }); : add(new Date(), {
seconds: config.default_pay_delay.d_us / (1000 * 1000),
});
return { return {
inventoryProducts: {}, inventoryProducts: {},

View File

@ -72,7 +72,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
? i18n.str`can't be empty` ? i18n.str`can't be empty`
: state.template_contract.pay_duration.d_us === "forever" : state.template_contract.pay_duration.d_us === "forever"
? undefined ? undefined
: state.template_contract.pay_duration.d_us < 1000 : state.template_contract.pay_duration.d_us < 1000 * 1000 //less than one second
? i18n.str`to short` ? i18n.str`to short`
: undefined, : undefined,
}), }),

View File

@ -31,6 +31,7 @@ export interface Props {
onCreate: () => void; onCreate: () => void;
onDelete: (e: MerchantBackend.Template.TemplateEntry) => void; onDelete: (e: MerchantBackend.Template.TemplateEntry) => void;
onSelect: (e: MerchantBackend.Template.TemplateEntry) => void; onSelect: (e: MerchantBackend.Template.TemplateEntry) => void;
onNewOrder: (e: MerchantBackend.Template.TemplateEntry) => void;
} }
export function ListPage({ export function ListPage({
@ -38,6 +39,7 @@ export function ListPage({
onCreate, onCreate,
onDelete, onDelete,
onSelect, onSelect,
onNewOrder,
onLoadMoreBefore, onLoadMoreBefore,
onLoadMoreAfter, onLoadMoreAfter,
}: Props): VNode { }: Props): VNode {
@ -54,6 +56,7 @@ export function ListPage({
onCreate={onCreate} onCreate={onCreate}
onDelete={onDelete} onDelete={onDelete}
onSelect={onSelect} onSelect={onSelect}
onNewOrder={onNewOrder}
onLoadMoreBefore={onLoadMoreBefore} onLoadMoreBefore={onLoadMoreBefore}
hasMoreBefore={!onLoadMoreBefore} hasMoreBefore={!onLoadMoreBefore}
onLoadMoreAfter={onLoadMoreAfter} onLoadMoreAfter={onLoadMoreAfter}

View File

@ -30,6 +30,7 @@ interface Props {
templates: Entity[]; templates: Entity[];
onDelete: (e: Entity) => void; onDelete: (e: Entity) => void;
onSelect: (e: Entity) => void; onSelect: (e: Entity) => void;
onNewOrder: (e: Entity) => void;
onCreate: () => void; onCreate: () => void;
onLoadMoreBefore?: () => void; onLoadMoreBefore?: () => void;
hasMoreBefore?: boolean; hasMoreBefore?: boolean;
@ -42,6 +43,7 @@ export function CardTable({
onCreate, onCreate,
onDelete, onDelete,
onSelect, onSelect,
onNewOrder,
onLoadMoreAfter, onLoadMoreAfter,
onLoadMoreBefore, onLoadMoreBefore,
hasMoreAfter, hasMoreAfter,
@ -81,6 +83,7 @@ export function CardTable({
instances={templates} instances={templates}
onDelete={onDelete} onDelete={onDelete}
onSelect={onSelect} onSelect={onSelect}
onNewOrder={onNewOrder}
rowSelection={rowSelection} rowSelection={rowSelection}
rowSelectionHandler={rowSelectionHandler} rowSelectionHandler={rowSelectionHandler}
onLoadMoreAfter={onLoadMoreAfter} onLoadMoreAfter={onLoadMoreAfter}
@ -101,6 +104,7 @@ interface TableProps {
rowSelection: string[]; rowSelection: string[];
instances: Entity[]; instances: Entity[];
onDelete: (e: Entity) => void; onDelete: (e: Entity) => void;
onNewOrder: (e: Entity) => void;
onSelect: (e: Entity) => void; onSelect: (e: Entity) => void;
rowSelectionHandler: StateUpdater<string[]>; rowSelectionHandler: StateUpdater<string[]>;
onLoadMoreBefore?: () => void; onLoadMoreBefore?: () => void;
@ -118,6 +122,7 @@ function Table({
instances, instances,
onLoadMoreAfter, onLoadMoreAfter,
onDelete, onDelete,
onNewOrder,
onSelect, onSelect,
onLoadMoreBefore, onLoadMoreBefore,
hasMoreAfter, hasMoreAfter,
@ -164,14 +169,23 @@ function Table({
> >
{i.template_description} {i.template_description}
</td> </td>
<td> <td class="is-actions-cell right-sticky">
<button <div class="buttons is-right">
class="button is-danger is-small has-tooltip-left" <button
data-tooltip={i18n.str`delete selected templates from the database`} class="button is-danger is-small has-tooltip-left"
onClick={() => onDelete(i)} data-tooltip={i18n.str`delete selected templates from the database`}
> onClick={() => onDelete(i)}
Delete >
</button> Delete
</button>
<button
class="button is-info is-small has-tooltip-left"
data-tooltip={i18n.str`delete selected templates from the database`}
onClick={() => onNewOrder(i)}
>
New order
</button>
</div>
</td> </td>
</tr> </tr>
); );

View File

@ -39,6 +39,7 @@ interface Props {
onNotFound: () => VNode; onNotFound: () => VNode;
onCreate: () => void; onCreate: () => void;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onNewOrder: (id: string) => void;
} }
export default function ListTemplates({ export default function ListTemplates({
@ -46,6 +47,7 @@ export default function ListTemplates({
onLoadError, onLoadError,
onCreate, onCreate,
onSelect, onSelect,
onNewOrder,
onNotFound, onNotFound,
}: Props): VNode { }: Props): VNode {
const [position, setPosition] = useState<string | undefined>(undefined); const [position, setPosition] = useState<string | undefined>(undefined);
@ -73,6 +75,9 @@ export default function ListTemplates({
onSelect={(e) => { onSelect={(e) => {
onSelect(e.template_id); onSelect(e.template_id);
}} }}
onNewOrder={(e) => {
onNewOrder(e.template_id);
}}
onDelete={(e: MerchantBackend.Template.TemplateEntry) => onDelete={(e: MerchantBackend.Template.TemplateEntry) =>
deleteTemplate(e.template_id) deleteTemplate(e.template_id)
.then(() => .then(() =>

View File

@ -65,7 +65,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
? i18n.str`can't be empty` ? i18n.str`can't be empty`
: state.template_contract.pay_duration.d_us === "forever" : state.template_contract.pay_duration.d_us === "forever"
? undefined ? undefined
: state.template_contract.pay_duration.d_us < 1000 : state.template_contract.pay_duration.d_us < 1000 * 1000 // less than one second
? i18n.str`to short` ? i18n.str`to short`
: undefined, : undefined,
}), }),

View File

@ -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 { h, VNode, FunctionalComponent } from "preact";
import { CreatePage as TestedComponent } from "./UsePage.js";
export default {
title: "Pages/Templates/Create",
component: TestedComponent,
};

View File

@ -0,0 +1,126 @@
/*
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 { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
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 { MerchantBackend } from "../../../../declaration.js";
type Entity = MerchantBackend.Template.UsingTemplateDetails;
interface Props {
template: MerchantBackend.Template.TemplateDetails;
onCreateOrder: (d: Entity) => Promise<void>;
onBack?: () => void;
}
export function UsePage({ template, onCreateOrder, onBack }: Props): VNode {
const { i18n } = useTranslationContext();
const [state, setState] = useState<Partial<Entity>>({
amount: template.template_contract.amount,
summary: template.template_contract.summary,
});
const errors: FormErrors<Entity> = {
amount:
!template.template_contract.amount && !state.amount
? i18n.str`Amount is required`
: undefined,
summary:
!template.template_contract.summary && !state.summary
? i18n.str`Order summary is required`
: undefined,
};
const hasErrors = Object.keys(errors).some(
(k) => (errors as any)[k] !== undefined,
);
const submitForm = () => {
if (hasErrors) return Promise.reject();
if (template.template_contract.amount) {
delete state.amount;
}
if (template.template_contract.summary) {
delete state.summary;
}
return onCreateOrder(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}
>
<InputCurrency<Entity>
name="amount"
label={i18n.str`Amount`}
readonly={!!template.template_contract.amount}
tooltip={i18n.str`Amount of the order`}
/>
<Input<Entity>
name="summary"
inputType="multiline"
label={i18n.str`Order summary`}
readonly={!!template.template_contract.summary}
tooltip={i18n.str`Title of the order to be shown to the customer`}
/>
</FormProvider>
<div class="buttons is-right mt-5">
{onBack && (
<button class="button" onClick={onBack}>
<i18n.Translate>Cancel</i18n.Translate>
</button>
)}
<AsyncButton
disabled={hasErrors}
data-tooltip={
hasErrors
? i18n.str`Need to complete marked fields`
: "confirm operation"
}
onClick={submitForm}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</div>
</div>
<div class="column" />
</div>
</section>
</div>
);
}

View File

@ -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 { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
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 {
useTemplateAPI,
useTemplateDetails,
} from "../../../../hooks/templates.js";
import { HttpError } from "../../../../utils/request.js";
import { Notification } from "../../../../utils/types.js";
import { UsePage } from "./UsePage.js";
export type Entity = MerchantBackend.Transfers.TransferInformation;
interface Props {
onBack?: () => void;
onOrderCreated: (id: string) => void;
onUnauthorized: () => VNode;
onNotFound: () => VNode;
onLoadError: (e: HttpError) => VNode;
tid: string;
}
export default function TemplateUsePage({
tid,
onOrderCreated,
onBack,
onLoadError,
onNotFound,
onUnauthorized,
}: Props): VNode {
const { createOrderFromTemplate } = useTemplateAPI();
const result = useTemplateDetails(tid);
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
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 (
<>
<NotificationCard notification={notif} />
<UsePage
template={result.data}
onBack={onBack}
onCreateOrder={(
request: MerchantBackend.Template.UsingTemplateDetails,
) => {
return createOrderFromTemplate(tid, request)
.then((res) => onOrderCreated(res.data.order_id))
.catch((error) => {
setNotif({
message: i18n.str`could not create order from template`,
type: "ERROR",
description: error.message,
});
});
}}
/>
</>
);
}

View File

@ -50,11 +50,11 @@ export const Example = createExample(TestedComponent, {
default_max_deposit_fee: "TESTKUDOS:2", default_max_deposit_fee: "TESTKUDOS:2",
default_max_wire_fee: "TESTKUDOS:1", default_max_wire_fee: "TESTKUDOS:1",
default_pay_delay: { default_pay_delay: {
d_us: 1000000, d_us: 1000 * 1000, //one second
}, },
default_wire_fee_amortization: 1, default_wire_fee_amortization: 1,
default_wire_transfer_delay: { default_wire_transfer_delay: {
d_us: 100000, d_us: 1000 * 1000, //one second
}, },
merchant_pub: "ASDWQEKASJDKSADJ", merchant_pub: "ASDWQEKASJDKSADJ",
}, },