merchant-backoffice

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

commit b56c499a55de0b54b3cf407bdc955abd51c36d16
parent 4f796ddc0d90a97021ded390de28fc32e6c12b6e
Author: Sebastian <sebasjm@gmail.com>
Date:   Tue, 30 Nov 2021 16:12:20 -0300

format

Diffstat:
Mpackages/merchant-backoffice/src/components/form/InputPaytoForm.tsx | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mpackages/merchant-backoffice/src/components/instance/DefaultInstanceFormFields.tsx | 135++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/merchant-backoffice/src/i18n/index.tsx | 54+++++++++++++++++++++++++++++++-----------------------
Mpackages/merchant-backoffice/src/paths/admin/create/CreatePage.tsx | 257++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mpackages/merchant-backoffice/src/paths/admin/create/index.tsx | 56++++++++++++++++++++++++++++++++------------------------
Mpackages/merchant-backoffice/src/paths/instance/orders/create/CreatePage.tsx | 539++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/merchant-backoffice/src/paths/instance/update/UpdatePage.tsx | 288+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/merchant-backoffice/src/paths/instance/update/index.tsx | 86++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
8 files changed, 1059 insertions(+), 638 deletions(-)

diff --git a/packages/merchant-backoffice/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice/src/components/form/InputPaytoForm.tsx @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode, Fragment } from "preact"; import { useCallback, useState } from "preact/hooks"; import { Translate, useTranslator } from "../../i18n"; @@ -33,135 +33,231 @@ export interface Props<T> extends InputProps<T> { // https://datatracker.ietf.org/doc/html/rfc8905 type Entity = { - target: string, - path: string, - path1: string, - path2: string, - host: string, - account: string, + target: string; + path: string; + path1: string; + path2: string; + host: string; + account: string; options: { - 'receiver-name'?: string, - sender?: string, - message?: string, - amount?: string, - instruction?: string, - [name: string]: string | undefined, - }, -} + "receiver-name"?: string; + sender?: string; + message?: string; + amount?: string; + instruction?: string; + [name: string]: string | undefined; + }; +}; // const targets = ['ach', 'bic', 'iban', 'upi', 'bitcoin', 'ilp', 'void', 'x-taler-bank'] -const targets = ['iban', 'x-taler-bank'] -const defaultTarget = { target: 'iban', options: {} } +const targets = ["iban", "x-taler-bank"]; +const defaultTarget = { target: "iban", options: {} }; function undefinedIfEmpty<T>(obj: T): T | undefined { - return Object.keys(obj).some(k => (obj as any)[k] !== undefined) ? obj : undefined + return Object.keys(obj).some((k) => (obj as any)[k] !== undefined) + ? obj + : undefined; } -export function InputPaytoForm<T>({ name, readonly, label, tooltip }: Props<keyof T>): VNode { - const { value: paytos, onChange, } = useField<T>(name); +export function InputPaytoForm<T>({ + name, + readonly, + label, + tooltip, +}: Props<keyof T>): VNode { + const { value: paytos, onChange } = useField<T>(name); - const [value, valueHandler] = useState<Partial<Entity>>(defaultTarget) + const [value, valueHandler] = useState<Partial<Entity>>(defaultTarget); if (value.path1) { if (value.path2) { - value.path = `/${value.path1}/${value.path2}` + value.path = `/${value.path1}/${value.path2}`; } else { - value.path = `/${value.path1}` + value.path = `/${value.path1}`; } } - const i18n = useTranslator() + const i18n = useTranslator(); - const url = new URL(`payto://${value.target}${value.path}`) - const ops = value.options! - Object.keys(ops).forEach(opt_key => { - const opt_value = ops[opt_key] - if (opt_value) url.searchParams.set(opt_key, opt_value) - }) - const paytoURL = url.toString() + const url = new URL(`payto://${value.target}${value.path}`); + const ops = value.options!; + Object.keys(ops).forEach((opt_key) => { + const opt_value = ops[opt_key]; + if (opt_value) url.searchParams.set(opt_key, opt_value); + }); + const paytoURL = url.toString(); const errors: FormErrors<Entity> = { target: !value.target ? i18n`required` : undefined, - path1: !value.path1 ? i18n`required` : ( - value.target === 'iban' ? ( - value.path1.length < 4 ? i18n`IBAN numbers usually have more that 4 digits` : ( - value.path1.length > 34 ? i18n`IBAN numbers usually have less that 34 digits` : - undefined - ) - ): undefined - ), - path2: value.target === 'x-taler-bank' ? (!value.path2 ? i18n`required` : undefined) : undefined, + path1: !value.path1 + ? i18n`required` + : value.target === "iban" + ? value.path1.length < 4 + ? i18n`IBAN numbers usually have more that 4 digits` + : value.path1.length > 34 + ? i18n`IBAN numbers usually have less that 34 digits` + : undefined + : undefined, + path2: + value.target === "x-taler-bank" + ? !value.path2 + ? i18n`required` + : undefined + : undefined, options: undefinedIfEmpty({ - 'receiver-name': !value.options?.["receiver-name"] ? i18n`required` : undefined, - }) - } + "receiver-name": !value.options?.["receiver-name"] + ? i18n`required` + : undefined, + }), + }; - const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined + ); const submit = useCallback((): void => { - const alreadyExists = paytos.findIndex((x:string) => x === paytoURL) !== -1; + const alreadyExists = + paytos.findIndex((x: string) => x === paytoURL) !== -1; if (!alreadyExists) { - onChange([paytoURL, ...paytos] as any) + onChange([paytoURL, ...paytos] as any); } - valueHandler(defaultTarget) - }, [value]) - + valueHandler(defaultTarget); + }, [value]); //FIXME: translating plural singular return ( <InputGroup name="payto" label={label} fixed tooltip={tooltip}> - <FormProvider<Entity> name="tax" errors={errors} object={value} valueHandler={valueHandler} > - - <InputSelector<Entity> name="target" label={i18n`Target type`} tooltip={i18n`Method to use for wire transfer`} values={targets} /> - - {value.target === 'ach' && <Fragment> - <Input<Entity> name="path1" label={i18n`Routing`} tooltip={i18n`Routing number.`} /> - <Input<Entity> name="path2" label={i18n`Account`} tooltip={i18n`Account number.`} /> - </Fragment>} - {value.target === 'bic' && <Fragment> - <Input<Entity> name="path1" label={i18n`Code`} tooltip={i18n`Business Identifier Code.`} /> - </Fragment>} - {value.target === 'iban' && <Fragment> - <Input<Entity> name="path1" label={i18n`Account`} tooltip={i18n`Bank Account Number.`} /> - </Fragment>} - {value.target === 'upi' && <Fragment> - <Input<Entity> name="path1" label={i18n`Account`} tooltip={i18n`Unified Payment Interface.`} /> - </Fragment>} - {value.target === 'bitcoin' && <Fragment> - <Input<Entity> name="path1" label={i18n`Address`} tooltip={i18n`Bitcoin protocol.`} /> - </Fragment>} - {value.target === 'ilp' && <Fragment> - <Input<Entity> name="path1" label={i18n`Address`} tooltip={i18n`Interledger protocol.`} /> - </Fragment>} - {value.target === 'void' && <Fragment> - </Fragment>} - {value.target === 'x-taler-bank' && <Fragment> - <Input<Entity> name="path1" label={i18n`Host`} tooltip={i18n`Bank host.`} /> - <Input<Entity> name="path2" label={i18n`Account`} tooltip={i18n`Bank account.`} /> - </Fragment>} - - <Input name="options.receiver-name" label={i18n`Name`} tooltip={i18n`Bank account owner's name.`} /> + <FormProvider<Entity> + name="tax" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <InputSelector<Entity> + name="target" + label={i18n`Target type`} + tooltip={i18n`Method to use for wire transfer`} + values={targets} + /> + + {value.target === "ach" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Routing`} + tooltip={i18n`Routing number.`} + /> + <Input<Entity> + name="path2" + label={i18n`Account`} + tooltip={i18n`Account number.`} + /> + </Fragment> + )} + {value.target === "bic" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Code`} + tooltip={i18n`Business Identifier Code.`} + /> + </Fragment> + )} + {value.target === "iban" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Account`} + tooltip={i18n`Bank Account Number.`} + /> + </Fragment> + )} + {value.target === "upi" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Account`} + tooltip={i18n`Unified Payment Interface.`} + /> + </Fragment> + )} + {value.target === "bitcoin" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Address`} + tooltip={i18n`Bitcoin protocol.`} + /> + </Fragment> + )} + {value.target === "ilp" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Address`} + tooltip={i18n`Interledger protocol.`} + /> + </Fragment> + )} + {value.target === "void" && <Fragment></Fragment>} + {value.target === "x-taler-bank" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Host`} + tooltip={i18n`Bank host.`} + /> + <Input<Entity> + name="path2" + label={i18n`Account`} + tooltip={i18n`Bank account.`} + /> + </Fragment> + )} + + <Input + name="options.receiver-name" + label={i18n`Name`} + tooltip={i18n`Bank account owner's name.`} + /> <div class="field is-horizontal"> <div class="field-label is-normal" /> - <div class="field-body" style={{ display: 'block' }}> - {paytos.map((v: any, i: number) => <div key={i} class="tags has-addons mt-3 mb-0 mr-3" style={{ flexWrap: 'nowrap' }}> - <span class="tag is-medium is-info mb-0" style={{ maxWidth: '90%' }}>{v}</span> - <a class="tag is-medium is-danger is-delete mb-0" onClick={() => { - onChange(paytos.filter((f: any) => f !== v) as any); - }} /> - </div> - )} + <div class="field-body" style={{ display: "block" }}> + {paytos.map((v: any, i: number) => ( + <div + key={i} + class="tags has-addons mt-3 mb-0 mr-3" + style={{ flexWrap: "nowrap" }} + > + <span + class="tag is-medium is-info mb-0" + style={{ maxWidth: "90%" }} + > + {v} + </span> + <a + class="tag is-medium is-danger is-delete mb-0" + onClick={() => { + onChange(paytos.filter((f: any) => f !== v) as any); + }} + /> + </div> + ))} {!paytos.length && i18n`No accounts yet.`} </div> </div> <div class="buttons is-right mt-5"> - <button class="button is-info" + <button + class="button is-info" data-tooltip={i18n`add tax to the tax list`} disabled={hasErrors} - onClick={submit}><Translate>Add</Translate></button> + onClick={submit} + > + <Translate>Add</Translate> + </button> </div> </FormProvider> </InputGroup> - ) + ); } diff --git a/packages/merchant-backoffice/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice/src/components/instance/DefaultInstanceFormFields.tsx @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { Fragment, h } from "preact"; import { useBackendContext } from "../../context/backend"; @@ -31,56 +31,85 @@ import { InputLocation } from "../form/InputLocation"; import { InputPaytoForm } from "../form/InputPaytoForm"; import { InputWithAddon } from "../form/InputWithAddon"; -export function DefaultInstanceFormFields({ readonlyId, showId }: { readonlyId?: boolean; showId: boolean }) { +export function DefaultInstanceFormFields({ + readonlyId, + showId, +}: { + readonlyId?: boolean; + showId: boolean; +}) { const i18n = useTranslator(); const backend = useBackendContext(); - return <Fragment> - {showId && <InputWithAddon<Entity> name="id" - addonBefore={`${backend.url}/instances/`} readonly={readonlyId} - label={i18n`Identifier`} - tooltip={i18n`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`} /> - } - - <Input<Entity> name="name" - label={i18n`Business name`} - tooltip={i18n`Legal name of the business represented by this instance.`} /> - - <InputPaytoForm<Entity> name="payto_uris" - label={i18n`Bank account`} - tooltip={i18n`URI specifying bank account for crediting revenue.`} /> - - <InputCurrency<Entity> name="default_max_deposit_fee" - label={i18n`Default max deposit fee`} - tooltip={i18n`Maximum deposit fees this merchant is willing to pay per order by default.`} /> - - <InputCurrency<Entity> name="default_max_wire_fee" - label={i18n`Default max wire fee`} - tooltip={i18n`Maximum wire fees this merchant is willing to pay per wire transfer by default.`} /> - - <Input<Entity> name="default_wire_fee_amortization" - label={i18n`Default wire fee amortization`} - tooltip={i18n`Number of orders excess wire transfer fees will be divided by to compute per order surcharge.`} /> - - <InputGroup name="address" - label={i18n`Address`} - tooltip={i18n`Physical location of the merchant.`}> - <InputLocation name="address" /> - </InputGroup> - - <InputGroup name="jurisdiction" - label={i18n`Jurisdiction`} - tooltip={i18n`Jurisdiction for legal disputes with the merchant.`}> - <InputLocation name="jurisdiction" /> - </InputGroup> - - <InputDuration<Entity> name="default_pay_delay" - label={i18n`Default payment delay`} - withForever - tooltip={i18n`Time customers have to pay an order before the offer expires by default.`} /> - - <InputDuration<Entity> name="default_wire_transfer_delay" - label={i18n`Default wire transfer delay`} - tooltip={i18n`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`} /> - - </Fragment>; + return ( + <Fragment> + {showId && ( + <InputWithAddon<Entity> + name="id" + addonBefore={`${backend.url}/instances/`} + readonly={readonlyId} + label={i18n`Identifier`} + tooltip={i18n`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`} + /> + )} + + <Input<Entity> + name="name" + label={i18n`Business name`} + tooltip={i18n`Legal name of the business represented by this instance.`} + /> + + <InputPaytoForm<Entity> + name="payto_uris" + label={i18n`Bank account`} + tooltip={i18n`URI specifying bank account for crediting revenue.`} + /> + + <InputCurrency<Entity> + name="default_max_deposit_fee" + label={i18n`Default max deposit fee`} + tooltip={i18n`Maximum deposit fees this merchant is willing to pay per order by default.`} + /> + + <InputCurrency<Entity> + name="default_max_wire_fee" + label={i18n`Default max wire fee`} + tooltip={i18n`Maximum wire fees this merchant is willing to pay per wire transfer by default.`} + /> + + <Input<Entity> + name="default_wire_fee_amortization" + label={i18n`Default wire fee amortization`} + tooltip={i18n`Number of orders excess wire transfer fees will be divided by to compute per order surcharge.`} + /> + + <InputGroup + name="address" + label={i18n`Address`} + tooltip={i18n`Physical location of the merchant.`} + > + <InputLocation name="address" /> + </InputGroup> + + <InputGroup + name="jurisdiction" + label={i18n`Jurisdiction`} + tooltip={i18n`Jurisdiction for legal disputes with the merchant.`} + > + <InputLocation name="jurisdiction" /> + </InputGroup> + + <InputDuration<Entity> + name="default_pay_delay" + label={i18n`Default payment delay`} + withForever + tooltip={i18n`Time customers have to pay an order before the offer expires by default.`} + /> + + <InputDuration<Entity> + name="default_wire_transfer_delay" + label={i18n`Default wire transfer delay`} + tooltip={i18n`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`} + /> + </Fragment> + ); } diff --git a/packages/merchant-backoffice/src/i18n/index.tsx b/packages/merchant-backoffice/src/i18n/index.tsx @@ -27,23 +27,25 @@ import { useTranslationContext } from "../context/translation"; export function useTranslator() { const ctx = useTranslationContext(); - const jed = ctx.handler - return function str(stringSeq: TemplateStringsArray, ...values: any[]): string { + const jed = ctx.handler; + return function str( + stringSeq: TemplateStringsArray, + ...values: any[] + ): string { const s = toI18nString(stringSeq); - if (!s) return s + if (!s) return s; const tr = jed .translate(s) .ifPlural(1, s) .fetch(...values); return tr; - } + }; } - /** * Convert template strings to a msgid */ - function toI18nString(stringSeq: ReadonlyArray<string>): string { +function toI18nString(stringSeq: ReadonlyArray<string>): string { let s = ""; for (let i = 0; i < stringSeq.length; i++) { s += stringSeq[i]; @@ -54,7 +56,6 @@ export function useTranslator() { return s; } - interface TranslateSwitchProps { target: number; children: ComponentChildren; @@ -88,7 +89,7 @@ interface TranslateProps { function getTranslatedChildren( translation: string, - children: ComponentChildren, + children: ComponentChildren ): ComponentChild[] { const tr = translation.split(/%(\d+)\$s/); const childArray = children instanceof Array ? children : [children]; @@ -110,7 +111,7 @@ function getTranslatedChildren( // Text result.push(tr[i]); } else { - const childIdx = Number.parseInt(tr[i],10) - 1; + const childIdx = Number.parseInt(tr[i], 10) - 1; result.push(placeholderChildren[childIdx]); } } @@ -131,9 +132,9 @@ function getTranslatedChildren( */ export function Translate({ children }: TranslateProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation: string = ctx.handler.ngettext(s, s, 1); - const result = getTranslatedChildren(translation, children) + const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; } @@ -154,14 +155,16 @@ export function TranslateSwitch({ children, target }: TranslateSwitchProps) { let plural: VNode<TranslationPluralProps> | undefined; // const children = this.props.children; if (children) { - (children instanceof Array ? children : [children]).forEach((child: any) => { - if (child.type === TranslatePlural) { - plural = child; + (children instanceof Array ? children : [children]).forEach( + (child: any) => { + if (child.type === TranslatePlural) { + plural = child; + } + if (child.type === TranslateSingular) { + singular = child; + } } - if (child.type === TranslateSingular) { - singular = child; - } - }); + ); } if (!singular || !plural) { console.error("translation not found"); @@ -182,9 +185,12 @@ interface TranslationPluralProps { /** * See [[TranslateSwitch]]. */ -export function TranslatePlural({ children, target }: TranslationPluralProps): VNode { +export function TranslatePlural({ + children, + target, +}: TranslationPluralProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation = ctx.handler.ngettext(s, s, 1); const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; @@ -193,11 +199,13 @@ export function TranslatePlural({ children, target }: TranslationPluralProps): V /** * See [[TranslateSwitch]]. */ -export function TranslateSingular({ children, target }: TranslationPluralProps): VNode { +export function TranslateSingular({ + children, + target, +}: TranslationPluralProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation = ctx.handler.ngettext(s, s, target); const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; - } diff --git a/packages/merchant-backoffice/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice/src/paths/admin/create/CreatePage.tsx @@ -15,15 +15,18 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import * as yup from 'yup'; +import * as yup from "yup"; import { AsyncButton } from "../../../components/exception/AsyncButton"; -import { FormErrors, FormProvider } from "../../../components/form/FormProvider"; +import { + FormErrors, + FormProvider, +} from "../../../components/form/FormProvider"; import { SetTokenNewInstanceModal } from "../../../components/modal"; import { MerchantBackend } from "../../../declaration"; import { Translate, useTranslator } from "../../../i18n"; @@ -31,10 +34,9 @@ import { DefaultInstanceFormFields } from "../../../components/instance/DefaultI import { INSTANCE_ID_REGEX, PAYTO_REGEX } from "../../../utils/constants"; import { Amounts } from "@gnu-taler/taler-util"; -export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage & { - auth_token?: string -} - +export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage & { + auth_token?: string; +}; interface Props { onCreate: (d: Entity) => Promise<void>; @@ -53,137 +55,180 @@ function with_defaults(id?: string): Partial<Entity> { } function undefinedIfEmpty<T>(obj: T): T | undefined { - return Object.keys(obj).some(k => (obj as any)[k] !== undefined) ? obj : undefined + return Object.keys(obj).some((k) => (obj as any)[k] !== undefined) + ? obj + : undefined; } export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { - const [value, valueHandler] = useState(with_defaults(forceId)) + const [value, valueHandler] = useState(with_defaults(forceId)); const [isTokenSet, updateIsTokenSet] = useState<boolean>(false); - const [isTokenDialogActive, updateIsTokenDialogActive] = useState<boolean>(false); + const [isTokenDialogActive, updateIsTokenDialogActive] = + useState<boolean>(false); - const i18n = useTranslator() + const i18n = useTranslator(); const errors: FormErrors<Entity> = { - id: !value.id ? i18n`required` : (!INSTANCE_ID_REGEX.test(value.id) ? i18n`is not valid` : undefined), + id: !value.id + ? i18n`required` + : !INSTANCE_ID_REGEX.test(value.id) + ? i18n`is not valid` + : undefined, name: !value.name ? i18n`required` : undefined, payto_uris: - !value.payto_uris || !value.payto_uris.length ? i18n`required` : ( - undefinedIfEmpty(value.payto_uris.map(p => { - return !PAYTO_REGEX.test(p) ? i18n`is not valid` : undefined - })) - ), - default_max_deposit_fee: - !value.default_max_deposit_fee ? i18n`required` : ( - !Amounts.parse(value.default_max_deposit_fee) ? i18n`invalid format` : - undefined - ), - default_max_wire_fee: - !value.default_max_wire_fee ? i18n`required` : ( - !Amounts.parse(value.default_max_wire_fee) ? i18n`invalid format` : - undefined - ), + !value.payto_uris || !value.payto_uris.length + ? i18n`required` + : undefinedIfEmpty( + value.payto_uris.map((p) => { + return !PAYTO_REGEX.test(p) ? i18n`is not valid` : undefined; + }) + ), + default_max_deposit_fee: !value.default_max_deposit_fee + ? i18n`required` + : !Amounts.parse(value.default_max_deposit_fee) + ? i18n`invalid format` + : undefined, + default_max_wire_fee: !value.default_max_wire_fee + ? i18n`required` + : !Amounts.parse(value.default_max_wire_fee) + ? i18n`invalid format` + : undefined, default_wire_fee_amortization: - value.default_wire_fee_amortization === undefined ? i18n`required` : ( - isNaN(value.default_wire_fee_amortization) ? i18n`is not a number` : ( - value.default_wire_fee_amortization < 1 ? i18n`must be 1 or greater` : - undefined - ) - ), - default_pay_delay: - !value.default_pay_delay ? i18n`required` : undefined, - default_wire_transfer_delay: - !value.default_wire_transfer_delay ? i18n`required` : undefined, + value.default_wire_fee_amortization === undefined + ? i18n`required` + : isNaN(value.default_wire_fee_amortization) + ? i18n`is not a number` + : value.default_wire_fee_amortization < 1 + ? i18n`must be 1 or greater` + : undefined, + default_pay_delay: !value.default_pay_delay ? i18n`required` : undefined, + default_wire_transfer_delay: !value.default_wire_transfer_delay + ? i18n`required` + : undefined, address: undefinedIfEmpty({ address_lines: - value.address?.address_lines && value.address?.address_lines.length > 7 ? i18n`max 7 lines` : - undefined + value.address?.address_lines && value.address?.address_lines.length > 7 + ? i18n`max 7 lines` + : undefined, }), jurisdiction: undefinedIfEmpty({ - address_lines: value.address?.address_lines && value.address?.address_lines.length > 7 ? i18n`max 7 lines` : - undefined + address_lines: + value.address?.address_lines && value.address?.address_lines.length > 7 + ? i18n`max 7 lines` + : undefined, }), }; - const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined + ); const submit = (): Promise<void> => { // use conversion instead of this const newToken = value.auth_token; value.auth_token = undefined; - value.auth = newToken === null || newToken === undefined ? { method: "external" } : { method: "token", token: `secret-token:${newToken}` }; - if (!value.address) value.address = {} - if (!value.jurisdiction) value.jurisdiction = {} + value.auth = + newToken === null || newToken === undefined + ? { method: "external" } + : { method: "token", token: `secret-token:${newToken}` }; + if (!value.address) value.address = {}; + if (!value.jurisdiction) value.jurisdiction = {}; // remove above use conversion // schema.validateSync(value, { abortEarly: false }) return onCreate(value as Entity); - } + }; function updateToken(token: string | null) { - valueHandler(old => ({ ...old, auth_token: token === null ? undefined : token })) + valueHandler((old) => ({ + ...old, + auth_token: token === null ? undefined : token, + })); } - return <div> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - {isTokenDialogActive && <SetTokenNewInstanceModal - onCancel={() => { - updateIsTokenDialogActive(false); - updateIsTokenSet(false); - }} - onClear={() => { - updateToken(null); - updateIsTokenDialogActive(false); - updateIsTokenSet(true); - }} - onConfirm={(newToken) => { - updateToken(newToken); updateIsTokenDialogActive(false); - updateIsTokenSet(true); - }} - />} - </div> - <div class="column" /> - </div> - - <section class="hero is-hero-bar"> - <div class="hero-body"> - <div class="level"> - <div class="level-item has-text-centered"> - <h1 class="title"> - <button class="button is-danger has-tooltip-bottom" - data-tooltip={i18n`change authorization configuration`} - onClick={() => updateIsTokenDialogActive(true)} > - <div class="icon is-centered"><i class="mdi mdi-lock-reset" /></div> - <span><Translate>Set access token</Translate></span> - </button> - </h1> - </div> - </div> - </div></section> - - - <section class="section is-main-section"> + return ( + <div> <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> - - <FormProvider<Entity> errors={errors} object={value} valueHandler={valueHandler} > - - <DefaultInstanceFormFields readonlyId={!!forceId} showId={true} /> - - </FormProvider> - - <div class="buttons is-right mt-5"> - {onBack && <button class="button" onClick={onBack}><Translate>Cancel</Translate></button>} - <AsyncButton onClick={submit} disabled={!isTokenSet || hasErrors} data-tooltip={ - hasErrors ? i18n`Need to complete marked fields and choose authorization method` : 'confirm operation' - }><Translate>Confirm</Translate></AsyncButton> - </div> - + {isTokenDialogActive && ( + <SetTokenNewInstanceModal + onCancel={() => { + updateIsTokenDialogActive(false); + updateIsTokenSet(false); + }} + onClear={() => { + updateToken(null); + updateIsTokenDialogActive(false); + updateIsTokenSet(true); + }} + onConfirm={(newToken) => { + updateToken(newToken); + updateIsTokenDialogActive(false); + updateIsTokenSet(true); + }} + /> + )} </div> <div class="column" /> </div> - </section> - </div> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-item has-text-centered"> + <h1 class="title"> + <button + class="button is-danger has-tooltip-bottom" + data-tooltip={i18n`change authorization configuration`} + onClick={() => updateIsTokenDialogActive(true)} + > + <div class="icon is-centered"> + <i class="mdi mdi-lock-reset" /> + </div> + <span> + <Translate>Set access token</Translate> + </span> + </button> + </h1> + </div> + </div> + </div> + </section> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider<Entity> + errors={errors} + object={value} + valueHandler={valueHandler} + > + <DefaultInstanceFormFields readonlyId={!!forceId} showId={true} /> + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <Translate>Cancel</Translate> + </button> + )} + <AsyncButton + onClick={submit} + disabled={!isTokenSet || hasErrors} + data-tooltip={ + hasErrors + ? i18n`Need to complete marked fields and choose authorization method` + : "confirm operation" + } + > + <Translate>Confirm</Translate> + </AsyncButton> + </div> + </div> + <div class="column" /> + </div> + </section> + </div> + ); } diff --git a/packages/merchant-backoffice/src/paths/admin/create/index.tsx b/packages/merchant-backoffice/src/paths/admin/create/index.tsx @@ -14,9 +14,9 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { NotificationCard } from "../../../components/menu"; @@ -36,31 +36,39 @@ export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage; export default function Create({ onBack, onConfirm, forceId }: Props): VNode { const { createInstance } = useAdminAPI(); - const [notif, setNotif] = useState<Notification | undefined>(undefined) + const [notif, setNotif] = useState<Notification | undefined>(undefined); const [createdOk, setCreatedOk] = useState<Entity | undefined>(undefined); - const i18n = useTranslator() + const i18n = useTranslator(); if (createdOk) { - return <InstanceCreatedSuccessfully entity={createdOk} onConfirm={onConfirm} /> + return ( + <InstanceCreatedSuccessfully entity={createdOk} onConfirm={onConfirm} /> + ); } - return <Fragment> - <NotificationCard notification={notif} /> - - <CreatePage - onBack={onBack} - forceId={forceId} - onCreate={(d: MerchantBackend.Instances.InstanceConfigurationMessage) => { - return createInstance(d).then(() => { - setCreatedOk(d) - }).catch((error) => { - setNotif({ - message: i18n`Failed to create instance`, - type: "ERROR", - description: error.message - }) - }) - }} /> - </Fragment> + return ( + <Fragment> + <NotificationCard notification={notif} /> + <CreatePage + onBack={onBack} + forceId={forceId} + onCreate={( + d: MerchantBackend.Instances.InstanceConfigurationMessage + ) => { + return createInstance(d) + .then(() => { + setCreatedOk(d); + }) + .catch((error) => { + setNotif({ + message: i18n`Failed to create instance`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </Fragment> + ); } diff --git a/packages/merchant-backoffice/src/paths/instance/orders/create/CreatePage.tsx b/packages/merchant-backoffice/src/paths/instance/orders/create/CreatePage.tsx @@ -15,15 +15,18 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { add, isAfter, isBefore, isFuture } from "date-fns"; import { Amounts } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { FormProvider, FormErrors } from "../../../../components/form/FormProvider"; +import { + FormProvider, + FormErrors, +} from "../../../../components/form/FormProvider"; import { Input } from "../../../../components/form/Input"; import { InputCurrency } from "../../../../components/form/InputCurrency"; import { InputDate } from "../../../../components/form/InputDate"; @@ -33,7 +36,7 @@ import { ProductList } from "../../../../components/product/ProductList"; import { useConfigContext } from "../../../../context/config"; import { Duration, MerchantBackend, WithId } from "../../../../declaration"; import { Translate, useTranslator } from "../../../../i18n"; -import { OrderCreateSchema as schema } from '../../../../schemas/index'; +import { OrderCreateSchema as schema } from "../../../../schemas/index"; import { rate } from "../../../../utils/amount"; import { InventoryProductForm } from "../../../../components/product/InventoryProductForm"; import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm"; @@ -43,7 +46,7 @@ interface Props { onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; onBack?: () => void; instanceConfig: InstanceConfig; - instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[], + instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[]; } interface InstanceConfig { default_max_wire_fee: string; @@ -53,9 +56,10 @@ interface InstanceConfig { } function with_defaults(config: InstanceConfig): Partial<Entity> { - const defaultPayDeadline = !config.default_pay_delay || config.default_pay_delay.d_ms === "forever" ? - undefined : - add(new Date(), { seconds: config.default_pay_delay.d_ms / 1000 }) + const defaultPayDeadline = + !config.default_pay_delay || config.default_pay_delay.d_ms === "forever" + ? undefined + : add(new Date(), { seconds: config.default_pay_delay.d_ms / 1000 }); return { inventoryProducts: {}, @@ -69,12 +73,12 @@ function with_defaults(config: InstanceConfig): Partial<Entity> { refund_deadline: defaultPayDeadline, }, shipping: {}, - extra: '' + extra: "", }; } interface ProductAndQuantity { - product: MerchantBackend.Products.ProductDetail & WithId, + product: MerchantBackend.Products.ProductDetail & WithId; quantity: number; } export interface ProductMap { @@ -100,8 +104,8 @@ interface Payments { wire_fee_amortization?: number; } interface Entity { - inventoryProducts: ProductMap, - products: MerchantBackend.Product[], + inventoryProducts: ProductMap; + products: MerchantBackend.Product[]; pricing: Partial<Pricing>; payments: Partial<Payments>; shipping: Partial<Shipping>; @@ -110,65 +114,88 @@ interface Entity { const stringIsValidJSON = (value: string) => { try { - JSON.parse(value.trim()) - return true + JSON.parse(value.trim()); + return true; } catch { - return false + return false; } -} +}; function undefinedIfEmpty<T>(obj: T): T | undefined { - return Object.keys(obj).some(k => (obj as any)[k] !== undefined) ? obj : undefined + return Object.keys(obj).some((k) => (obj as any)[k] !== undefined) + ? obj + : undefined; } -export function CreatePage({ onCreate, onBack, instanceConfig, instanceInventory }: Props): VNode { - const [value, valueHandler] = useState(with_defaults(instanceConfig)) - const config = useConfigContext() - const zero = Amounts.getZero(config.currency) +export function CreatePage({ + onCreate, + onBack, + instanceConfig, + instanceInventory, +}: Props): VNode { + const [value, valueHandler] = useState(with_defaults(instanceConfig)); + const config = useConfigContext(); + const zero = Amounts.getZero(config.currency); const inventoryList = Object.values(value.inventoryProducts || {}); const productList = Object.values(value.products || {}); const i18n = useTranslator(); - + const errors: FormErrors<Entity> = { pricing: undefinedIfEmpty({ summary: !value.pricing?.summary ? i18n`required` : undefined, - order_price: !value.pricing?.order_price ? i18n`required` : ( - (Amounts.parse(value.pricing.order_price)?.value || 0) <= 0 ? - i18n`must be greater than 0` : undefined - ) + order_price: !value.pricing?.order_price + ? i18n`required` + : (Amounts.parse(value.pricing.order_price)?.value || 0) <= 0 + ? i18n`must be greater than 0` + : undefined, }), - extra: value.extra && !stringIsValidJSON(value.extra) ? i18n`not a valid json` : undefined, + extra: + value.extra && !stringIsValidJSON(value.extra) + ? i18n`not a valid json` + : undefined, payments: undefinedIfEmpty({ - refund_deadline: !value.payments?.refund_deadline ? i18n`required` : ( - !isFuture(value.payments.refund_deadline) ? i18n`should be in the future` : ( - value.payments.pay_deadline && isBefore(value.payments.refund_deadline, value.payments.pay_deadline) ? - i18n`pay deadline cannot be before refund deadline` : undefined - ) - ), - pay_deadline: !value.payments?.pay_deadline ? i18n`required` : ( - !isFuture(value.payments.pay_deadline) ? i18n`should be in the future` : undefined - ), - auto_refund_deadline: !value.payments?.auto_refund_deadline ? undefined : ( - !isFuture(value.payments.auto_refund_deadline) ? i18n`should be in the future` : ( - !value.payments?.refund_deadline ? i18n`should have a refund deadline` : ( - !isAfter(value.payments.refund_deadline, value.payments.auto_refund_deadline) ? - i18n`auto refund cannot be after refund deadline` : undefined + refund_deadline: !value.payments?.refund_deadline + ? i18n`required` + : !isFuture(value.payments.refund_deadline) + ? i18n`should be in the future` + : value.payments.pay_deadline && + isBefore(value.payments.refund_deadline, value.payments.pay_deadline) + ? i18n`pay deadline cannot be before refund deadline` + : undefined, + pay_deadline: !value.payments?.pay_deadline + ? i18n`required` + : !isFuture(value.payments.pay_deadline) + ? i18n`should be in the future` + : undefined, + auto_refund_deadline: !value.payments?.auto_refund_deadline + ? undefined + : !isFuture(value.payments.auto_refund_deadline) + ? i18n`should be in the future` + : !value.payments?.refund_deadline + ? i18n`should have a refund deadline` + : !isAfter( + value.payments.refund_deadline, + value.payments.auto_refund_deadline ) - ) - ), + ? i18n`auto refund cannot be after refund deadline` + : undefined, }), shipping: undefinedIfEmpty({ - delivery_date: !value.shipping?.delivery_date ? undefined : ( - !isFuture(value.shipping.delivery_date) ? i18n`should be in the future` : undefined - ), + delivery_date: !value.shipping?.delivery_date + ? undefined + : !isFuture(value.shipping.delivery_date) + ? i18n`should be in the future` + : undefined, }), - } - const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) + }; + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined + ); const submit = (): void => { - const order = schema.cast(value) + const order = schema.cast(value); if (!value.payments) return; if (!value.shipping) return; @@ -178,198 +205,322 @@ export function CreatePage({ onCreate, onBack, instanceConfig, instanceInventory summary: order.pricing.summary, products: productList, extra: value.extra, - pay_deadline: value.payments.pay_deadline ? { t_ms: Math.floor(value.payments.pay_deadline.getTime() / 1000) * 1000 } : undefined, - wire_transfer_deadline: value.payments.pay_deadline ? { t_ms: Math.floor(value.payments.pay_deadline.getTime() / 1000) * 1000 } : undefined, - refund_deadline: value.payments.refund_deadline ? { t_ms: Math.floor(value.payments.refund_deadline.getTime() / 1000) * 1000 } : undefined, + pay_deadline: value.payments.pay_deadline + ? { + t_ms: + Math.floor(value.payments.pay_deadline.getTime() / 1000) * 1000, + } + : undefined, + wire_transfer_deadline: value.payments.pay_deadline + ? { + t_ms: + Math.floor(value.payments.pay_deadline.getTime() / 1000) * 1000, + } + : undefined, + refund_deadline: value.payments.refund_deadline + ? { + t_ms: + Math.floor(value.payments.refund_deadline.getTime() / 1000) * + 1000, + } + : undefined, wire_fee_amortization: value.payments.wire_fee_amortization, max_fee: value.payments.max_fee, max_wire_fee: value.payments.max_wire_fee, - delivery_date: value.shipping.delivery_date ? { t_ms: value.shipping.delivery_date.getTime() } : undefined, + delivery_date: value.shipping.delivery_date + ? { t_ms: value.shipping.delivery_date.getTime() } + : undefined, delivery_location: value.shipping.delivery_location, fulfillment_url: value.shipping.fullfilment_url, }, - inventory_products: inventoryList.map(p => ({ + inventory_products: inventoryList.map((p) => ({ product_id: p.product.id, - quantity: p.quantity + quantity: p.quantity, })), - } + }; onCreate(request); - } + }; - const addProductToTheInventoryList = (product: MerchantBackend.Products.ProductDetail & WithId, quantity: number) => { - valueHandler(v => { - const inventoryProducts = { ...v.inventoryProducts } - inventoryProducts[product.id] = { product, quantity } - return ({ ...v, inventoryProducts }) - }) - } + const addProductToTheInventoryList = ( + product: MerchantBackend.Products.ProductDetail & WithId, + quantity: number + ) => { + valueHandler((v) => { + const inventoryProducts = { ...v.inventoryProducts }; + inventoryProducts[product.id] = { product, quantity }; + return { ...v, inventoryProducts }; + }); + }; const removeProductFromTheInventoryList = (id: string) => { - valueHandler(v => { - const inventoryProducts = { ...v.inventoryProducts } - delete inventoryProducts[id] - return ({ ...v, inventoryProducts }) - }) - } + valueHandler((v) => { + const inventoryProducts = { ...v.inventoryProducts }; + delete inventoryProducts[id]; + return { ...v, inventoryProducts }; + }); + }; const addNewProduct = async (product: MerchantBackend.Product) => { - return valueHandler(v => { - const products = v.products ? [...v.products, product] : [] - return ({ ...v, products }) - }) - } + return valueHandler((v) => { + const products = v.products ? [...v.products, product] : []; + return { ...v, products }; + }); + }; const removeFromNewProduct = (index: number) => { - valueHandler(v => { - const products = v.products ? [...v.products] : [] - products.splice(index, 1) - return ({ ...v, products }) - }) - } + valueHandler((v) => { + const products = v.products ? [...v.products] : []; + products.splice(index, 1); + return { ...v, products }; + }); + }; - const [editingProduct, setEditingProduct] = useState<MerchantBackend.Product | undefined>(undefined) + const [editingProduct, setEditingProduct] = useState< + MerchantBackend.Product | undefined + >(undefined); const totalPriceInventory = inventoryList.reduce((prev, cur) => { - const p = Amounts.parseOrThrow(cur.product.price) - return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount - }, zero) + const p = Amounts.parseOrThrow(cur.product.price); + return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount; + }, zero); const totalPriceProducts = productList.reduce((prev, cur) => { - if (!cur.price) return zero - const p = Amounts.parseOrThrow(cur.price) - return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount - }, zero) + if (!cur.price) return zero; + const p = Amounts.parseOrThrow(cur.price); + return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount; + }, zero); - const hasProducts = inventoryList.length > 0 || productList.length > 0 - const totalPrice = Amounts.add(totalPriceInventory, totalPriceProducts) + const hasProducts = inventoryList.length > 0 || productList.length > 0; + const totalPrice = Amounts.add(totalPriceInventory, totalPriceProducts); const totalAsString = Amounts.stringify(totalPrice.amount); - const allProducts = productList.concat(inventoryList.map(asProduct)) + const allProducts = productList.concat(inventoryList.map(asProduct)); useEffect(() => { - valueHandler(v => { - return ({ - ...v, pricing: { + valueHandler((v) => { + return { + ...v, + pricing: { ...v.pricing, products_price: hasProducts ? totalAsString : undefined, order_price: hasProducts ? totalAsString : undefined, - } - }) - }) - }, [hasProducts, totalAsString]) - - const discountOrRise = rate(value.pricing?.order_price || `${config.currency}:0`, totalAsString) - - return <div> - - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - - {/* // FIXME: translating plural singular */} - <InputGroup name="inventory_products" label={i18n`Manage products in order`} alternative={ - allProducts.length > 0 && <p> - {allProducts.length} products - with a total price of {totalAsString}. - </p> - } tooltip={i18n`Manage list of products in the order.`}> - - <InventoryProductForm - currentProducts={value.inventoryProducts || {}} - onAddProduct={addProductToTheInventoryList} - inventory={instanceInventory} - /> - - <NonInventoryProductFrom productToEdit={editingProduct} onAddProduct={(p) => { - setEditingProduct(undefined) - return addNewProduct(p) - }} /> - - {allProducts.length > 0 && - <ProductList list={allProducts} - actions={[{ - name: i18n`Remove`, - tooltip: i18n`Remove this product from the order.`, - handler: (e, index) => { - if (e.product_id) { - removeProductFromTheInventoryList(e.product_id) - } else { - removeFromNewProduct(index); - setEditingProduct(e); - } - } - }]} + }, + }; + }); + }, [hasProducts, totalAsString]); + + const discountOrRise = rate( + value.pricing?.order_price || `${config.currency}:0`, + totalAsString + ); + + return ( + <div> + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + {/* // FIXME: translating plural singular */} + <InputGroup + name="inventory_products" + label={i18n`Manage products in order`} + alternative={ + allProducts.length > 0 && ( + <p> + {allProducts.length} products with a total price of{" "} + {totalAsString}. + </p> + ) + } + tooltip={i18n`Manage list of products in the order.`} + > + <InventoryProductForm + currentProducts={value.inventoryProducts || {}} + onAddProduct={addProductToTheInventoryList} + inventory={instanceInventory} /> - } - </InputGroup> - - <FormProvider<Entity> errors={errors} object={value} valueHandler={valueHandler as any}> - {hasProducts ? - <Fragment> - <InputCurrency name="pricing.products_price" label={i18n`Total price`} readonly tooltip={i18n`total product price added up`} /> - <InputCurrency name="pricing.order_price" - label={i18n`Total price`} - addonAfter={discountOrRise > 0 && (discountOrRise < 1 ? - `discount of %${Math.round((1 - discountOrRise) * 100)}` : - `rise of %${Math.round((discountOrRise - 1) * 100)}`) - } - tooltip={i18n`Amount to be paid by the customer`} - /> - </Fragment> : - <InputCurrency name="pricing.order_price" label={i18n`Order price`} tooltip={i18n`final order price`} /> - } - <Input name="pricing.summary" inputType="multiline" label={i18n`Summary`} tooltip={i18n`Title of the order to be shown to the customer`} /> + <NonInventoryProductFrom + productToEdit={editingProduct} + onAddProduct={(p) => { + setEditingProduct(undefined); + return addNewProduct(p); + }} + /> - <InputGroup name="shipping" label={i18n`Shipping and Fulfillment`} initialActive > - <InputDate name="shipping.delivery_date" label={i18n`Delivery date`} tooltip={i18n`Deadline for physical delivery assured by the merchant.`} /> - {value.shipping?.delivery_date && - <InputGroup name="shipping.delivery_location" label={i18n`Location`} tooltip={i18n`address where the products will be delivered`} > - <InputLocation name="shipping.delivery_location" /> - </InputGroup> - } - <Input name="shipping.fullfilment_url" label={i18n`Fulfillment URL`} tooltip={i18n`URL to which the user will be redirected after successful payment.`} /> + {allProducts.length > 0 && ( + <ProductList + list={allProducts} + actions={[ + { + name: i18n`Remove`, + tooltip: i18n`Remove this product from the order.`, + handler: (e, index) => { + if (e.product_id) { + removeProductFromTheInventoryList(e.product_id); + } else { + removeFromNewProduct(index); + setEditingProduct(e); + } + }, + }, + ]} + /> + )} </InputGroup> - <InputGroup name="payments" label={i18n`Taler payment options`} tooltip={i18n`Override default Taler payment settings for this order`}> - <InputDate name="payments.pay_deadline" label={i18n`Payment deadline`} tooltip={i18n`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`} /> - <InputDate name="payments.refund_deadline" label={i18n`Refund deadline`} tooltip={i18n`Time until which the order can be refunded by the merchant.`} /> - <InputDate name="payments.auto_refund_deadline" label={i18n`Auto-refund deadline`} tooltip={i18n`Time until which the wallet will automatically check for refunds without user interaction.`} /> + <FormProvider<Entity> + errors={errors} + object={value} + valueHandler={valueHandler as any} + > + {hasProducts ? ( + <Fragment> + <InputCurrency + name="pricing.products_price" + label={i18n`Total price`} + readonly + tooltip={i18n`total product price added up`} + /> + <InputCurrency + name="pricing.order_price" + label={i18n`Total price`} + addonAfter={ + discountOrRise > 0 && + (discountOrRise < 1 + ? `discount of %${Math.round( + (1 - discountOrRise) * 100 + )}` + : `rise of %${Math.round((discountOrRise - 1) * 100)}`) + } + tooltip={i18n`Amount to be paid by the customer`} + /> + </Fragment> + ) : ( + <InputCurrency + name="pricing.order_price" + label={i18n`Order price`} + tooltip={i18n`final order price`} + /> + )} - <InputCurrency name="payments.max_fee" label={i18n`Maximum deposit fee`} tooltip={i18n`Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} /> - <InputCurrency name="payments.max_wire_fee" label={i18n`Maximum wire fee`} tooltip={i18n`Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.`} /> - <InputNumber name="payments.wire_fee_amortization" label={i18n`Wire fee amortization`} tooltip={i18n`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`} /> - </InputGroup> + <Input + name="pricing.summary" + inputType="multiline" + label={i18n`Summary`} + tooltip={i18n`Title of the order to be shown to the customer`} + /> - <InputGroup name="extra" label={i18n`Additional information`} tooltip={i18n`Custom information to be included in the contract for this order.`}> - <Input name="extra" inputType="multiline" label={`Value`} tooltip={i18n`You must enter a value in JavaScript Object Notation (JSON).`} /> - </InputGroup> - </FormProvider> + <InputGroup + name="shipping" + label={i18n`Shipping and Fulfillment`} + initialActive + > + <InputDate + name="shipping.delivery_date" + label={i18n`Delivery date`} + tooltip={i18n`Deadline for physical delivery assured by the merchant.`} + /> + {value.shipping?.delivery_date && ( + <InputGroup + name="shipping.delivery_location" + label={i18n`Location`} + tooltip={i18n`address where the products will be delivered`} + > + <InputLocation name="shipping.delivery_location" /> + </InputGroup> + )} + <Input + name="shipping.fullfilment_url" + label={i18n`Fulfillment URL`} + tooltip={i18n`URL to which the user will be redirected after successful payment.`} + /> + </InputGroup> + + <InputGroup + name="payments" + label={i18n`Taler payment options`} + tooltip={i18n`Override default Taler payment settings for this order`} + > + <InputDate + name="payments.pay_deadline" + label={i18n`Payment deadline`} + tooltip={i18n`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`} + /> + <InputDate + name="payments.refund_deadline" + label={i18n`Refund deadline`} + tooltip={i18n`Time until which the order can be refunded by the merchant.`} + /> + <InputDate + name="payments.auto_refund_deadline" + label={i18n`Auto-refund deadline`} + tooltip={i18n`Time until which the wallet will automatically check for refunds without user interaction.`} + /> - <div class="buttons is-right mt-5"> - {onBack && <button class="button" onClick={onBack} ><Translate>Cancel</Translate></button>} - <button class="button is-success" onClick={submit} disabled={hasErrors} ><Translate>Confirm</Translate></button> + <InputCurrency + name="payments.max_fee" + label={i18n`Maximum deposit fee`} + tooltip={i18n`Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} + /> + <InputCurrency + name="payments.max_wire_fee" + label={i18n`Maximum wire fee`} + tooltip={i18n`Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.`} + /> + <InputNumber + name="payments.wire_fee_amortization" + label={i18n`Wire fee amortization`} + tooltip={i18n`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`} + /> + </InputGroup> + + <InputGroup + name="extra" + label={i18n`Additional information`} + tooltip={i18n`Custom information to be included in the contract for this order.`} + > + <Input + name="extra" + inputType="multiline" + label={`Value`} + tooltip={i18n`You must enter a value in JavaScript Object Notation (JSON).`} + /> + </InputGroup> + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <Translate>Cancel</Translate> + </button> + )} + <button + class="button is-success" + onClick={submit} + disabled={hasErrors} + > + <Translate>Confirm</Translate> + </button> + </div> </div> - + <div class="column" /> </div> - <div class="column" /> - </div> - </section> - - </div> + </section> + </div> + ); } function asProduct(p: ProductAndQuantity) { - return ({ + return { product_id: p.product.id, image: p.product.image, price: p.product.price, unit: p.product.unit, quantity: p.quantity, description: p.product.description, - taxes: p.product.taxes - }) + taxes: p.product.taxes, + }; } diff --git a/packages/merchant-backoffice/src/paths/instance/update/UpdatePage.tsx b/packages/merchant-backoffice/src/paths/instance/update/UpdatePage.tsx @@ -15,191 +15,247 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import * as yup from 'yup'; +import * as yup from "yup"; import { AsyncButton } from "../../../components/exception/AsyncButton"; -import { FormProvider, FormErrors } from "../../../components/form/FormProvider"; +import { + FormProvider, + FormErrors, +} from "../../../components/form/FormProvider"; import { UpdateTokenModal } from "../../../components/modal"; import { useInstanceContext } from "../../../context/instance"; import { MerchantBackend } from "../../../declaration"; import { Translate, useTranslator } from "../../../i18n"; -import { InstanceUpdateSchema as schema } from '../../../schemas'; +import { InstanceUpdateSchema as schema } from "../../../schemas"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields"; import { PAYTO_REGEX } from "../../../utils/constants"; import { Amounts } from "@gnu-taler/taler-util"; - -type Entity = MerchantBackend.Instances.InstanceReconfigurationMessage & { - auth_token?: string -} +type Entity = MerchantBackend.Instances.InstanceReconfigurationMessage & { + auth_token?: string; +}; //MerchantBackend.Instances.InstanceAuthConfigurationMessage interface Props { onUpdate: (d: Entity) => void; - onChangeAuth: (d: MerchantBackend.Instances.InstanceAuthConfigurationMessage) => Promise<void>; + onChangeAuth: ( + d: MerchantBackend.Instances.InstanceAuthConfigurationMessage + ) => Promise<void>; selected: MerchantBackend.Instances.QueryInstancesResponse; isLoading: boolean; onBack: () => void; } -function convert(from: MerchantBackend.Instances.QueryInstancesResponse): Entity { - const { accounts, ...rest } = from - const payto_uris = accounts.filter(a => a.active).map(a => a.payto_uri) +function convert( + from: MerchantBackend.Instances.QueryInstancesResponse +): Entity { + const { accounts, ...rest } = from; + const payto_uris = accounts.filter((a) => a.active).map((a) => a.payto_uri); const defaults = { default_wire_fee_amortization: 1, default_pay_delay: { d_ms: 1000 * 60 * 60 }, //one hour default_wire_transfer_delay: { d_ms: 1000 * 60 * 60 * 2 }, //two hours - } + }; return { ...defaults, ...rest, payto_uris }; } function getTokenValuePart(t?: string): string | undefined { - if (!t) return t + if (!t) return t; const match = /secret-token:(.*)/.exec(t); if (!match || !match[1]) return undefined; - return match[1] + return match[1]; } function undefinedIfEmpty<T>(obj: T): T | undefined { - return Object.keys(obj).some(k => (obj as any)[k] !== undefined) ? obj : undefined + return Object.keys(obj).some((k) => (obj as any)[k] !== undefined) + ? obj + : undefined; } -export function UpdatePage({ onUpdate, onChangeAuth, selected, onBack }: Props): VNode { - const { id, token } = useInstanceContext() - const currentTokenValue = getTokenValuePart(token) +export function UpdatePage({ + onUpdate, + onChangeAuth, + selected, + onBack, +}: Props): VNode { + const { id, token } = useInstanceContext(); + const currentTokenValue = getTokenValuePart(token); function updateToken(token: string | undefined | null) { - const value = token && token.startsWith('secret-token:') ? - token.substring('secret-token:'.length) : token + const value = + token && token.startsWith("secret-token:") + ? token.substring("secret-token:".length) + : token; if (!token) { - onChangeAuth({ method: 'external' }) + onChangeAuth({ method: "external" }); } else { - onChangeAuth({ method: 'token', token: `secret-token:${value}` }) + onChangeAuth({ method: "token", token: `secret-token:${value}` }); } } - const [value, valueHandler] = useState<Partial<Entity>>(convert(selected)) + const [value, valueHandler] = useState<Partial<Entity>>(convert(selected)); - const i18n = useTranslator() + const i18n = useTranslator(); const errors: FormErrors<Entity> = { name: !value.name ? i18n`required` : undefined, payto_uris: - !value.payto_uris || !value.payto_uris.length ? i18n`required` : ( - undefinedIfEmpty(value.payto_uris.map(p => { - return !PAYTO_REGEX.test(p) ? i18n`is not valid` : undefined - })) - ), - default_max_deposit_fee: - !value.default_max_deposit_fee ? i18n`required` : ( - !Amounts.parse(value.default_max_deposit_fee) ? i18n`invalid format` : - undefined - ), - default_max_wire_fee: - !value.default_max_wire_fee ? i18n`required` : ( - !Amounts.parse(value.default_max_wire_fee) ? i18n`invalid format` : - undefined - ), + !value.payto_uris || !value.payto_uris.length + ? i18n`required` + : undefinedIfEmpty( + value.payto_uris.map((p) => { + return !PAYTO_REGEX.test(p) ? i18n`is not valid` : undefined; + }) + ), + default_max_deposit_fee: !value.default_max_deposit_fee + ? i18n`required` + : !Amounts.parse(value.default_max_deposit_fee) + ? i18n`invalid format` + : undefined, + default_max_wire_fee: !value.default_max_wire_fee + ? i18n`required` + : !Amounts.parse(value.default_max_wire_fee) + ? i18n`invalid format` + : undefined, default_wire_fee_amortization: - value.default_wire_fee_amortization === undefined ? i18n`required` : ( - isNaN(value.default_wire_fee_amortization) ? i18n`is not a number` : ( - value.default_wire_fee_amortization < 1 ? i18n`must be 1 or greater` : - undefined - ) - ), - default_pay_delay: - !value.default_pay_delay ? i18n`required` : undefined, - default_wire_transfer_delay: - !value.default_wire_transfer_delay ? i18n`required` : undefined, + value.default_wire_fee_amortization === undefined + ? i18n`required` + : isNaN(value.default_wire_fee_amortization) + ? i18n`is not a number` + : value.default_wire_fee_amortization < 1 + ? i18n`must be 1 or greater` + : undefined, + default_pay_delay: !value.default_pay_delay ? i18n`required` : undefined, + default_wire_transfer_delay: !value.default_wire_transfer_delay + ? i18n`required` + : undefined, address: undefinedIfEmpty({ address_lines: - value.address?.address_lines && value.address?.address_lines.length > 7 ? i18n`max 7 lines` : - undefined + value.address?.address_lines && value.address?.address_lines.length > 7 + ? i18n`max 7 lines` + : undefined, }), jurisdiction: undefinedIfEmpty({ - address_lines: value.address?.address_lines && value.address?.address_lines.length > 7 ? i18n`max 7 lines` : - undefined + address_lines: + value.address?.address_lines && value.address?.address_lines.length > 7 + ? i18n`max 7 lines` + : undefined, }), }; - const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined + ); const submit = async (): Promise<void> => { await onUpdate(schema.cast(value)); - await onBack() - return Promise.resolve() - } + await onBack(); + return Promise.resolve(); + }; const [active, setActive] = useState(false); - 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"><Translate>Instance id</Translate>: <b>{id}</b></span> + 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"> + <Translate>Instance id</Translate>: <b>{id}</b> + </span> + </div> </div> - </div> - <div class="level-right"> - <div class="level-item"> - <h1 class="title"> - <button class="button is-danger" - data-tooltip={i18n`Change the authorization method use for this instance.`} - onClick={(): void => { setActive(!active); }} > - <div class="icon is-left"><i class="mdi mdi-lock-reset" /></div> - <span><Translate>Manage access token</Translate></span> - </button> - </h1> + <div class="level-right"> + <div class="level-item"> + <h1 class="title"> + <button + class="button is-danger" + data-tooltip={i18n`Change the authorization method use for this instance.`} + onClick={(): void => { + setActive(!active); + }} + > + <div class="icon is-left"> + <i class="mdi mdi-lock-reset" /> + </div> + <span> + <Translate>Manage access token</Translate> + </span> + </button> + </h1> + </div> </div> </div> </div> - </div></section> - - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - {active && <UpdateTokenModal oldToken={currentTokenValue} - onCancel={() => { setActive(false); }} - onClear={() => { updateToken(null); setActive(false); }} - onConfirm={(newToken) => { - updateToken(newToken); setActive(false) - }} - />} - </div> - <div class="column" /> - </div> - <hr /> - - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - <FormProvider<Entity> errors={errors} object={value} valueHandler={valueHandler} > - - <DefaultInstanceFormFields showId={false} /> - - </FormProvider> + </section> - <div class="buttons is-right mt-4"> - <button class="button" onClick={onBack} data-tooltip="cancel operation"><Translate>Cancel</Translate></button> - - <AsyncButton onClick={submit} data-tooltip={ - hasErrors ? i18n`Need to complete marked fields` : 'confirm operation' - } disabled={hasErrors} ><Translate>Confirm</Translate></AsyncButton> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + {active && ( + <UpdateTokenModal + oldToken={currentTokenValue} + onCancel={() => { + setActive(false); + }} + onClear={() => { + updateToken(null); + setActive(false); + }} + onConfirm={(newToken) => { + updateToken(newToken); + setActive(false); + }} + /> + )} </div> + <div class="column" /> </div> - <div class="column" /> - </div> + <hr /> - </section> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider<Entity> + errors={errors} + object={value} + valueHandler={valueHandler} + > + <DefaultInstanceFormFields showId={false} /> + </FormProvider> - </div > + <div class="buttons is-right mt-4"> + <button + class="button" + onClick={onBack} + data-tooltip="cancel operation" + > + <Translate>Cancel</Translate> + </button> + <AsyncButton + onClick={submit} + data-tooltip={ + hasErrors + ? i18n`Need to complete marked fields` + : "confirm operation" + } + disabled={hasErrors} + > + <Translate>Confirm</Translate> + </AsyncButton> + </div> + </div> + <div class="column" /> + </div> + </section> + </div> + ); } diff --git a/packages/merchant-backoffice/src/paths/instance/update/index.tsx b/packages/merchant-backoffice/src/paths/instance/update/index.tsx @@ -18,7 +18,12 @@ import { Loading } from "../../../components/exception/loading"; import { useInstanceContext } from "../../../context/instance"; import { MerchantBackend } from "../../../declaration"; import { HttpError, HttpResponse } from "../../../hooks/backend"; -import { useInstanceAPI, useInstanceDetails, useManagedInstanceDetails, useManagementAPI } from "../../../hooks/instance"; +import { + useInstanceAPI, + useInstanceDetails, + useManagedInstanceDetails, + useManagementAPI, +} from "../../../hooks/instance"; import { UpdatePage } from "./UpdatePage"; export interface Props { @@ -29,41 +34,65 @@ export interface Props { onNotFound: () => VNode; onLoadError: (e: HttpError) => VNode; onUpdateError: (e: HttpError) => void; - } export default function Update(props: Props): VNode { const { updateInstance, clearToken, setNewToken } = useInstanceAPI(); - const result = useInstanceDetails() - return CommonUpdate(props, result, updateInstance, clearToken, setNewToken) + const result = useInstanceDetails(); + return CommonUpdate(props, result, updateInstance, clearToken, setNewToken); } -export function AdminUpdate(props:Props & {instanceId:string}): VNode { - const { updateInstance, clearToken, setNewToken } = useManagementAPI(props.instanceId); - const result = useManagedInstanceDetails(props.instanceId) - return CommonUpdate(props, result, updateInstance, clearToken, setNewToken) +export function AdminUpdate(props: Props & { instanceId: string }): VNode { + const { updateInstance, clearToken, setNewToken } = useManagementAPI( + props.instanceId + ); + const result = useManagedInstanceDetails(props.instanceId); + return CommonUpdate(props, result, updateInstance, clearToken, setNewToken); } -function CommonUpdate({ onBack, onConfirm, onLoadError, onNotFound, onUpdateError, onUnauthorized }: Props, result: HttpResponse<MerchantBackend.Instances.QueryInstancesResponse>, updateInstance: any, clearToken: any, setNewToken: any): VNode { - const { changeToken } = useInstanceContext() +function CommonUpdate( + { + onBack, + onConfirm, + onLoadError, + onNotFound, + onUpdateError, + onUnauthorized, + }: Props, + result: HttpResponse<MerchantBackend.Instances.QueryInstancesResponse>, + updateInstance: any, + clearToken: any, + setNewToken: any +): VNode { + const { changeToken } = useInstanceContext(); - 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) + 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> - <UpdatePage - onBack={onBack} - isLoading={false} - selected={result.data} - onUpdate={(d: MerchantBackend.Instances.InstanceReconfigurationMessage): Promise<void> => { - return updateInstance(d).then(onConfirm).catch(onUpdateError) - }} - onChangeAuth={(d: MerchantBackend.Instances.InstanceAuthConfigurationMessage): Promise<void> => { - const apiCall = d.method === 'external' ? clearToken() : setNewToken(d.token!); - return apiCall.then(() => changeToken(d.token)).then(onConfirm).catch(onUpdateError) - }} + return ( + <Fragment> + <UpdatePage + onBack={onBack} + isLoading={false} + selected={result.data} + onUpdate={( + d: MerchantBackend.Instances.InstanceReconfigurationMessage + ): Promise<void> => { + return updateInstance(d).then(onConfirm).catch(onUpdateError); + }} + onChangeAuth={( + d: MerchantBackend.Instances.InstanceAuthConfigurationMessage + ): Promise<void> => { + const apiCall = + d.method === "external" ? clearToken() : setNewToken(d.token!); + return apiCall + .then(() => changeToken(d.token)) + .then(onConfirm) + .catch(onUpdateError); + }} /> - </Fragment> -} -\ No newline at end of file + </Fragment> + ); +}