commit 3ee6de02c72e7164fdfe7f67ff0678b58e996f93 parent f2dfda17f27d5a842eaa77711e411313ddfc6039 Author: Sebastian <sebasjm@gmail.com> Date: Tue, 13 Apr 2021 15:28:58 -0300 order creation Diffstat:
15 files changed, 506 insertions(+), 117 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -26,10 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - product detail: we could have some button that brings us to the detailed screen for the product - order id field to go - -frontend, too many redirects -BUGS TEST CASES: -https://git.taler.net/anastasis.git/tree/src/cli/test_anastasis_reducer_enter_secret.sh + - input number - navigation to another instance should not do full refresh - cleanup instance and token management, because code is a mess and can be refactored diff --git a/packages/frontend/src/components/form/Field.tsx b/packages/frontend/src/components/form/Field.tsx @@ -115,6 +115,16 @@ export function useField<T>(name: keyof T) { } } +export function useGroupField<T>(name: keyof T) { + const f = useContext<FormType<T>>(FormContext) + if (!f) return {} + + const RE = new RegExp(`^${name}`) + return { + hasError: Object.keys(f.errors).some(e => RE.test(e)) + } +} + // export function Field<T>({ name, info, readonly }: Props<T>): VNode { // const {errors, object, valueHandler, updateField} = useForm<T>() diff --git a/packages/frontend/src/components/form/InputCurrency.tsx b/packages/frontend/src/components/form/InputCurrency.tsx @@ -18,7 +18,7 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { h } from "preact"; +import { ComponentChildren, h } from "preact"; import { Amount } from "../../declaration"; import { InputWithAddon } from "./InputWithAddon"; @@ -27,14 +27,16 @@ export interface Props<T> { readonly?: boolean; expand?: boolean; currency: string; + addonAfter?: ComponentChildren; } -export function InputCurrency<T>({ name, readonly, expand, currency }: Props<T>) { +export function InputCurrency<T>({ name, readonly, expand, currency, addonAfter }: Props<T>) { return <InputWithAddon<T> name={name} readonly={readonly} addonBefore={currency} + addonAfter={addonAfter} inputType='number' expand={expand} toStr={(v?: Amount) => v?.split(':')[1] || ''} fromStr={(v: string) => !v ? '' : `${currency}:${v}`} - inputExtra={{min:0}} + inputExtra={{ min: 0 }} /> } diff --git a/packages/frontend/src/components/form/InputDate.tsx b/packages/frontend/src/components/form/InputDate.tsx @@ -0,0 +1,79 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { format } from "date-fns"; +import { ComponentChildren, Fragment, h } from "preact"; +import { Message, useMessage } from "preact-messages"; +import { useState } from "preact/hooks"; +import { Amount } from "../../declaration"; +import { DatePicker } from "./DatePicker"; +import { useField } from "./Field"; +import { InputWithAddon } from "./InputWithAddon"; + +export interface Props<T> { + name: keyof T; + readonly?: boolean; + expand?: boolean; +} + +export function InputDate<T>({ name, readonly, expand }: Props<T>) { + const [opened, setOpened] = useState(false) + const { error, value, onChange } = useField<T>(name); + + const placeholder = useMessage(`fields.instance.${name}.placeholder`); + const tooltip = useMessage(`fields.instance.${name}.tooltip`); + + return <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <Message id={`fields.instance.${name}.label`} /> + {tooltip && <span class="icon" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + <p class={expand ? "control is-expanded" : "control"}> + <input class="input" type="text" + readonly value={!value ? '' : format(value, 'yyyy/MM/dd HH:mm:ss')} + placeholder="pick a date" + onClick={() => setOpened(true)} + /> + <Message id={`fields.instance.${name}.help`}> </Message> + </p> + <div class="control" onClick={() => setOpened(true)}> + <a class="button is-static" > + <span class="icon"><i class="mdi mdi-calendar" /></span> + </a> + </div> + </div> + {error ? <p class="help is-danger"><Message id={`validation.${error.type}`} fields={error.params}>{error.message}</Message></p> : null} + </div> + </div> + <DatePicker + opened={opened} + closeFunction={() => setOpened(false)} + dateReceiver={(d) => onChange(d as any)} + /> + </div>; +} + diff --git a/packages/frontend/src/components/form/InputGroup.tsx b/packages/frontend/src/components/form/InputGroup.tsx @@ -21,6 +21,7 @@ import { ComponentChildren, h, VNode } from "preact"; import { Message } from "preact-messages"; import { useState } from "preact/hooks"; +import { useField, useGroupField } from "./Field"; export interface Props<T> { name: keyof T; @@ -30,9 +31,11 @@ export interface Props<T> { export function InputGroup<T>({ name, children, alternative}: Props<T>): VNode { const [active, setActive] = useState(false); + const group = useGroupField<T>(name); + return <div class="card"> <header class="card-header"> - <p class="card-header-title"> + <p class={ !group?.hasError ? "card-header-title" : "card-header-title has-text-danger"}> <Message id={`fields.instance.${String(name)}.label`} /> </p> <button class="card-header-icon" aria-label="more options" onClick={(): void => setActive(!active)}> diff --git a/packages/frontend/src/components/form/InputSearchProduct.tsx b/packages/frontend/src/components/form/InputSearchProduct.tsx @@ -95,13 +95,21 @@ function ProductList({ name, onSelect }: ProductListProps) { <div class="dropdown-item">loading...</div> </div> } else if (result.ok && !!name) { - products = <div class="dropdown-content"> - {result.data.filter(p => re.test(p.description)).map(p => ( - <div class="dropdown-item" onClick={() => onSelect(p)}> - {p.description} + if (!result.data.length) { + products = <div class="dropdown-content"> + <div class="dropdown-item"> + no products found </div> - ))} - </div> + </div> + } else { + products = <div class="dropdown-content"> + {result.data.filter(p => re.test(p.description)).map(p => ( + <div class="dropdown-item" onClick={() => onSelect(p)}> + {p.description} + </div> + ))} + </div> + } } return <div class="dropdown is-active"> <div class="dropdown-menu" id="dropdown-menu" role="menu"> diff --git a/packages/frontend/src/components/form/InputWithAddon.tsx b/packages/frontend/src/components/form/InputWithAddon.tsx @@ -27,8 +27,8 @@ export interface Props<T> { readonly?: boolean; expand?: boolean; inputType?: 'text' | 'number'; - addonBefore?: string | VNode; - addonAfter?: string | VNode; + addonBefore?: ComponentChildren; + addonAfter?: ComponentChildren; toStr?: (v?: any) => string; fromStr?: (s: string) => any; inputExtra?: any, diff --git a/packages/frontend/src/declaration.d.ts b/packages/frontend/src/declaration.d.ts @@ -130,19 +130,19 @@ export namespace MerchantBackend { description_i18n?: { [lang_tag: string]: string }; // The number of units of the product to deliver to the customer. - quantity?: Integer; + quantity: Integer; // The unit in which the product is measured (liters, kilograms, packages, etc.) - unit?: string; + unit: string; // The price of the product; this is the total price for quantity times unit of this product. - price?: Amount; + price: Amount; // An optional base64-encoded product image - image?: ImageDataUrl; + image: ImageDataUrl; // a list of taxes paid by the merchant for this product. Can be empty. - taxes?: Tax[]; + taxes: Tax[]; // time indicating when this product should be delivered delivery_date?: Timestamp; @@ -380,7 +380,7 @@ export namespace MerchantBackend { // If the frontend does NOT specify a payment deadline, how long should // offers we make be valid by default? - default_pay_deadline: RelativeTime; + default_pay_delay: RelativeTime; // Authentication configuration. // Does not contain the token when token auth is configured. diff --git a/packages/frontend/src/hooks/instance.ts b/packages/frontend/src/hooks/instance.ts @@ -78,7 +78,13 @@ export function useInstanceDetails(): HttpResponse<MerchantBackend.Instances.Que url: `${baseUrl}/instances/${id}`, token: instanceToken } - const { data, error, isValidating } = useSWR<HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>, HttpError>([`/private/`, token, url], fetcher) + const { data, error, isValidating } = useSWR<HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>, HttpError>([`/private/`, token, url], fetcher, { + refreshInterval:0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }) if (isValidating) return {loading:true, data: data?.data} if (data) return data diff --git a/packages/frontend/src/messages/en.po b/packages/frontend/src/messages/en.po @@ -410,3 +410,100 @@ msgstr "Products Taxes" msgid "fields.instance.pricing.net.label" msgstr "Net" + + +msgid "fields.instance.payments.label" +msgstr "Payments" + + + +msgid "fields.instance.payments.auto_refund_deadline.label" +msgstr "Auto Refund Deadline" + + + +msgid "fields.instance.payments.refund_deadline.label" +msgstr "Refund Deadline" + + + +msgid "fields.instance.payments.pay_deadline.label" +msgstr "Pay Deadline" + + + +msgid "fields.instance.payments.delivery_date.label" +msgstr "Delivery Date" + +msgid "fields.instance.payments.delivery_location.label" +msgstr "Delivery Location" + + + +msgid "fields.instance.payments.max_fee.label" +msgstr "Max Fee" + + + +msgid "fields.instance.payments.max_wire_fee.label" +msgstr "Max Wire Fee" + + + +msgid "fields.instance.payments.wire_fee_amortization.label" +msgstr "Wire Fee Amortization" + + + +msgid "fields.instance.payments.fullfilment_url.label" +msgstr "Fillfilment URL" + + + +msgid "fields.instance.payments.delivery_location.country.label" +msgstr "Country" + + + +msgid "fields.instance.payments.delivery_location.address_lines.label" +msgstr "Adress Lines" + + + +msgid "fields.instance.payments.delivery_location.building_number.label" +msgstr "Building Number" + + + +msgid "fields.instance.payments.delivery_location.building_name.label" +msgstr "Building Name" + + + +msgid "fields.instance.payments.delivery_location.street.label" +msgstr "Stree" + + + +msgid "fields.instance.payments.delivery_location.post_code.label" +msgstr "Post Code" + + + +msgid "fields.instance.payments.delivery_location.town_location.label" +msgstr "Town Location" + +msgid "fields.instance.payments.delivery_location.town.label" +msgstr "Town" + +msgid "fields.instance.payments.delivery_location.district.label" +msgstr "District" + +msgid "fields.instance.payments.delivery_location.country_subdivision.label" +msgstr "Country Subdivision" + +msgid "fields.instance.extra.label" +msgstr "Extra information" + +msgid "fields.instance.extra.tooltip" +msgstr "Must be a JSON formatted string" diff --git a/packages/frontend/src/paths/instance/orders/create/CreatePage.tsx b/packages/frontend/src/paths/instance/orders/create/CreatePage.tsx @@ -30,6 +30,11 @@ import { InventoryProductForm } from "./InventoryProductForm"; import { NonInventoryProductFrom } from "./NonInventoryProductForm"; import { InputCurrency } from "../../../../components/form/InputCurrency"; import { Input } from "../../../../components/form/Input"; +import { OrderCreateSchema as schema } from '../../../../schemas/index'; +import * as yup from 'yup'; +import { InputDate } from "../../../../components/form/InputDate"; +import { useInstanceDetails } from "../../../../hooks/instance"; +import { add } from "date-fns"; interface Props { onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; @@ -40,7 +45,9 @@ function with_defaults(): Entity { return { inventoryProducts: {}, products: [], - pricing: {} as any + pricing: {} as any, + payments: {} as any, + extra: '' }; } @@ -58,27 +65,57 @@ interface Pricing { order_price: string; summary: string; } +interface Payments { + refund_deadline?: Date; + pay_deadline?: Date; + auto_refund_deadline?: Date; + delivery_date?: Date; + delivery_location?: MerchantBackend.Location; + max_fee?: string; + max_wire_fee?: string; + wire_fee_amortization?: number; + fullfilment_url?: string; +} interface Entity { inventoryProducts: ProductMap, - products: MerchantBackend.Products.ProductAddDetail[], + products: MerchantBackend.Product[], pricing: Pricing; + payments: Payments; + extra:string; } export function CreatePage({ onCreate, onBack }: Props): VNode { const [value, valueHandler] = useState(with_defaults()) const [errors, setErrors] = useState<FormErrors<Entity>>({}) - // const submit = (): void => { - // try { - // // schema.validateSync(value, { abortEarly: false }) - // // const order = schema.cast(value) as Entity - // // onCreate({ order }); - // } catch (err) { - // const errors = err.inner as yup.ValidationError[] - // const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: { type: cur.type, params: cur.params, message: cur.message } }), {}) - // setErrors(pathMessages) - // } - // } + const inventoryList = Object.values(value.inventoryProducts) + const productList = Object.values(value.products) + + const submit = (): void => { + try { + schema.validateSync(value, { abortEarly: false }) + const order = schema.cast(value) + + const request: MerchantBackend.Orders.PostOrderRequest = { + order: { + amount: order.pricing.order_price, + summary: order.pricing.summary, + products: productList, + extra: value.extra, + }, + inventory_products: inventoryList.map(p => ({ + product_id: p.product.id, + quantity: p.quantity + })), + } + onCreate(request); + } catch (err) { + const errors = err.inner as yup.ValidationError[] + const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: { type: cur.type, params: cur.params, message: cur.message } }), {}) + setErrors(pathMessages) + } + } + const config = useConfigContext() const addProductToTheInventoryList = (product: MerchantBackend.Products.ProductDetail & WithId, quantity: number) => { @@ -97,7 +134,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { }) } - const addNewProduct = (product: MerchantBackend.Products.ProductAddDetail) => { + const addNewProduct = (product: MerchantBackend.Product) => { valueHandler(v => { const products = [...v.products, product] return ({ ...v, products }) @@ -112,16 +149,13 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { }) } - const [editingProduct, setEditingProduct] = useState<MerchantBackend.Products.ProductAddDetail | undefined>(undefined) + const [editingProduct, setEditingProduct] = useState<MerchantBackend.Product | undefined>(undefined) - const inventoryList = Object.values(value.inventoryProducts) - const productList = Object.values(value.products) - - const totalPriceInventory = inventoryList.reduce((prev, cur) => sumPrices(multiplyPrice(cur.product.price, cur.quantity), prev), ':0') - const totalPriceProducts = productList.reduce((prev, cur) => sumPrices(multiplyPrice(cur.price, cur.total_stock), prev), ':0') + const totalPriceInventory = inventoryList.reduce((prev, cur) => sumPrices(prev, multiplyPrice(cur.product.price, cur.quantity)), `${config.currency}:0`) + const totalPriceProducts = productList.reduce((prev, cur) => sumPrices(prev, multiplyPrice(cur.price, cur.quantity)), `${config.currency}:0`) - const totalTaxInventory = inventoryList.reduce((prev, cur) => sumPrices(multiplyPrice(cur.product.taxes.reduce((prev, cur) => sumPrices(cur.tax, prev), ':0'), cur.quantity), prev), ':0') - const totalTaxProducts = productList.reduce((prev, cur) => sumPrices(multiplyPrice(cur.taxes.reduce((prev, cur) => sumPrices(cur.tax, prev), ':0'), cur.total_stock), prev), ':0') + const totalTaxInventory = inventoryList.reduce((prev, cur) => sumPrices(prev, multiplyPrice(cur.product.taxes.reduce((prev, cur) => sumPrices(prev, cur.tax), `${config.currency}:0`), cur.quantity)), `${config.currency}:0`) + const totalTaxProducts = productList.reduce((prev, cur) => sumPrices(prev, multiplyPrice(cur.taxes.reduce((prev, cur) => sumPrices(prev, cur.tax), `${config.currency}:0`), cur.quantity)), `${config.currency}:0`) const hasProducts = inventoryList.length > 0 || productList.length > 0 const totalPrice = sumPrices(totalPriceInventory, totalPriceProducts) @@ -129,15 +163,50 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { useEffect(() => { valueHandler(v => { - return ({...v, pricing: { - ...v.pricing, - products_price: totalPrice, - products_taxes: totalTax, - order_price: totalPrice, - net: subtractPrices(totalPrice, totalTax), - }}) + return ({ + ...v, pricing: { + ...v.pricing, + products_price: totalPrice, + products_taxes: totalTax, + order_price: totalPrice, + net: subtractPrices(totalPrice, totalTax), + } + }) }) - }, [hasProducts, totalPrice, totalTax, value.pricing]) + }, [hasProducts, totalPrice, totalTax]) + + + const discountOrRise = rate(value.pricing.order_price, totalPrice) + useEffect(() => { + valueHandler(v => { + return ({ + ...v, pricing: { + ...v.pricing, + net: subtractPrices(v.pricing.order_price, totalTax), + } + }) + }) + }, [value.pricing.order_price]) + + const details_response = useInstanceDetails() + + useEffect(() => { + if (details_response.ok) { + valueHandler(v => { + const defaultPayDeadline = !details_response.data.default_pay_delay || details_response.data.default_pay_delay.d_ms === "forever" ? undefined : add(new Date(), {seconds: details_response.data.default_pay_delay.d_ms/1000}) + return ({ + ...v, payments: { + ...v.payments, + max_wire_fee: details_response.data.default_max_wire_fee, + max_fee: details_response.data.default_max_deposit_fee, + wire_fee_amortization: details_response.data.default_wire_fee_amortization, + pay_deadline: defaultPayDeadline, + refund_deadline: defaultPayDeadline, + } + }) + }) + } + }, [details_response.ok]) return <div> @@ -198,7 +267,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <InputGroup name="products" alternative={ productList.length > 0 && <p> {productList.length} products, - in {productList.reduce((prev, cur) => cur.total_stock + prev, 0)} units, + in {productList.reduce((prev, cur) => cur.quantity + prev, 0)} units, with a total price of {totalPriceProducts} </p> }> @@ -224,10 +293,10 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <td>image</td> <td >{entry.description}</td> <td > - {entry.total_stock} {entry.unit} + {entry.quantity} {entry.unit} </td> <td >{entry.price}</td> - <td >{multiplyPrice(entry.price, entry.total_stock)}</td> + <td >{multiplyPrice(entry.price, entry.quantity)}</td> <td class="is-actions-cell right-sticky"> <div class="buttons is-right"> <button class="button is-small is-success jb-modal" type="button" onClick={(): void => { @@ -249,22 +318,59 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { </InputGroup> <FormProvider<Entity> errors={errors} object={value} valueHandler={valueHandler as any}> - {hasProducts ? <Fragment> - <InputCurrency name="pricing.products_price" readonly currency={config.currency}/> - <InputCurrency name="pricing.products_taxes" readonly currency={config.currency}/> - <InputCurrency name="pricing.order_price" currency={config.currency} /> - <InputCurrency name="pricing.net" readonly currency={config.currency} /> - </Fragment> : <Fragment> + {hasProducts ? + <Fragment> + <InputCurrency name="pricing.products_price" readonly currency={config.currency} /> + <InputCurrency name="pricing.products_taxes" readonly currency={config.currency} /> + <InputCurrency name="pricing.order_price" currency={config.currency} + addonAfter={value.pricing.order_price !== totalPrice && (discountOrRise < 1 ? + `discount of %${Math.round((1 - discountOrRise) * 100)}` : + `rise of %${Math.round((discountOrRise - 1) * 100)}`) + } + /> + <InputCurrency name="pricing.net" readonly currency={config.currency} /> + </Fragment> : <InputCurrency name="pricing.order_price" currency={config.currency} /> - </Fragment>} - - <Input name="pricing.summary" /> - + } + + <Input name="pricing.summary" inputType="multiline" /> + + + <InputGroup name="payments"> + <InputDate name="payments.auto_refund_deadline" /> + <InputDate name="payments.refund_deadline" /> + <InputDate name="payments.pay_deadline" /> + + <InputDate name="payments.delivery_date" /> + { value.payments.delivery_date && <InputGroup name="payments.delivery_location" > + <Input name="payments.delivery_location.country" /> + <Input name="payments.delivery_location.address_lines" inputType="multiline" + toStr={(v: string[] | undefined) => !v ? '' : v.join('\n')} + fromStr={(v: string) => v.split('\n')} + /> + <Input name="payments.delivery_location.building_number" /> + <Input name="payments.delivery_location.building_name" /> + <Input name="payments.delivery_location.street" /> + <Input name="payments.delivery_location.post_code" /> + <Input name="payments.delivery_location.town_location" /> + <Input name="payments.delivery_location.town" /> + <Input name="payments.delivery_location.district" /> + <Input name="payments.delivery_location.country_subdivision" /> + </InputGroup> } + + <InputCurrency name="payments.max_fee" currency={config.currency} /> + <InputCurrency name="payments.max_wire_fee" currency={config.currency} /> + <Input name="payments.wire_fee_amortization" /> + <Input name="payments.fullfilment_url" /> + </InputGroup> + <InputGroup name="extra"> + <Input name="extra" inputType="multiline" /> + </InputGroup> </FormProvider> <div class="buttons is-right mt-5"> {onBack && <button class="button" onClick={onBack} ><Message id="Cancel" /></button>} - {/* <button class="button is-success" onClick={submit} ><Message id="Confirm" /></button> */} + <button class="button is-success" onClick={submit} ><Message id="Confirm" /></button> </div> </div> @@ -293,3 +399,13 @@ const subtractPrices = (one: string, two: string) => { const [, valueTwo] = two.split(':') return `${currency}:${parseInt(valueOne, 10) - parseInt(valueTwo, 10)}` } + +const rate = (one?: string, two?: string) => { + const [, valueOne] = (one || '').split(':') + const [, valueTwo] = (two || '').split(':') + const intOne = parseInt(valueOne, 10) + const intTwo = parseInt(valueTwo, 10) + if (!intTwo) return intOne + return intOne / intTwo +} + diff --git a/packages/frontend/src/paths/instance/orders/create/NonInventoryProductForm.tsx b/packages/frontend/src/paths/instance/orders/create/NonInventoryProductForm.tsx @@ -5,7 +5,7 @@ import { MerchantBackend } from "../../../../declaration"; import { useListener } from "../../../../hooks"; import { ProductForm } from "../../products/create/ProductForm"; -type Entity = MerchantBackend.Products.ProductAddDetail +type Entity = MerchantBackend.Product interface Props { onAddProduct: (p: Entity) => void; @@ -20,19 +20,31 @@ export function NonInventoryProductFrom({ value, onAddProduct }: Props) { setShowCreateProduct(editing) }, [editing]) - const [ submitForm, addFormSubmitter ] = useListener<Entity | undefined>((result) => { + const [ submitForm, addFormSubmitter ] = useListener<Partial<MerchantBackend.Products.ProductAddDetail> | undefined>((result) => { if (result) { setShowCreateProduct(false) - onAddProduct(result) + onAddProduct({ + quantity: result.total_stock || 0, + taxes: result.taxes || [], + description: result.description || '', + image: result.image || '', + price: result.price || '', + unit: result.unit || '' + }) } }) + + const initial: Partial<MerchantBackend.Products.ProductAddDetail> = { + ...value, + total_stock: value?.quantity || 0, + } return <Fragment> <div class="buttons"> <button class="button is-success" onClick={() => setShowCreateProduct(true)} >add new product</button> </div> {showCreateProduct && <ConfirmModal active onCancel={() => setShowCreateProduct(false)} onConfirm={submitForm}> - <ProductForm initial={value} onSubscribe={addFormSubmitter} /> + <ProductForm initial={initial} onSubscribe={addFormSubmitter} /> </ConfirmModal>} </Fragment> } \ No newline at end of file diff --git a/packages/frontend/src/paths/instance/orders/details/DetailPage.tsx b/packages/frontend/src/paths/instance/orders/details/DetailPage.tsx @@ -37,7 +37,7 @@ interface Props { onBack: () => void; selected: Entity; id: string; - onRefund: (id:string, value: MerchantBackend.Orders.RefundRequest) => void; + onRefund: (id: string, value: MerchantBackend.Orders.RefundRequest) => void; } interface KeyValue { @@ -132,8 +132,9 @@ function ClaimedPage({ id, order }: { id: string; order: MerchantBackend.Orders. textOverflow: 'ellipsis', // maxWidth: '100%', }}> - <p>pay at: <a href={order.contract_terms.fulfillment_url} rel="nofollow" target="new">{order.contract_terms.fulfillment_url}</a></p> - <p>{format(new Date(order.contract_terms.timestamp.t_ms), 'yyyy/MM/dd HH:mm:ss')}</p> + {/* <a href={order.order_status_url} rel="nofollow" target="new">{order.order_status_url}</a> */} + <p>pay at: <b>missing value, there is no order_status_url</b></p> + <p>created at: {format(new Date(order.contract_terms.timestamp.t_ms), 'yyyy-MM-dd HH:mm:ss')}</p> </div> </div> </div> @@ -179,7 +180,7 @@ function ClaimedPage({ id, order }: { id: string; order: MerchantBackend.Orders. </section> </div> } -function PaidPage({ id, order, onRefund }: { id: string; order: MerchantBackend.Orders.CheckPaymentPaidResponse, onRefund: (id:string) => void }) { +function PaidPage({ id, order, onRefund }: { id: string; order: MerchantBackend.Orders.CheckPaymentPaidResponse, onRefund: (id: string) => void }) { const events: Event[] = [] events.push({ when: new Date(), @@ -226,19 +227,19 @@ function PaidPage({ id, order, onRefund }: { id: string; order: MerchantBackend. }) }) if (order.contract_terms.wire_transfer_deadline.t_ms !== 'never' && - order.contract_terms.wire_transfer_deadline.t_ms < new Date().getTime() ) events.push({ - when: new Date(order.contract_terms.wire_transfer_deadline.t_ms - 1000*10), - description: `wired (faked)`, - type: 'wired', - }) + order.contract_terms.wire_transfer_deadline.t_ms < new Date().getTime()) events.push({ + when: new Date(order.contract_terms.wire_transfer_deadline.t_ms - 1000 * 10), + description: `wired (faked)`, + type: 'wired', + }) events.sort((a, b) => a.when.getTime() - b.when.getTime()) - const [value, valueHandler] = useState<Partial<Paid>>({...order, fee: 'COL:0.1'} as any) + const [value, valueHandler] = useState<Partial<Paid>>({ ...order, fee: 'COL:0.1' } as any) const [errors, setErrors] = useState<KeyValue>({}) const config = useConfigContext() const refundable = !order.refunded && - new Date().getTime() <order.contract_terms.refund_deadline.t_ms + new Date().getTime() < order.contract_terms.refund_deadline.t_ms return <div> <section class="section"> @@ -275,7 +276,7 @@ function PaidPage({ id, order, onRefund }: { id: string; order: MerchantBackend. <div class="level-item"> <h1 class="title"> <div class="buttons"> - { refundable && <button class="button is-danger" onClick={() => onRefund(id) }>refund</button> } + {refundable && <button class="button is-danger" onClick={() => onRefund(id)}>refund</button>} <button class="button is-info" onClick={() => { if (order.contract_terms.fulfillment_url) copyToClipboard(order.contract_terms.fulfillment_url) }}>copy url</button> @@ -315,7 +316,7 @@ function PaidPage({ id, order, onRefund }: { id: string; order: MerchantBackend. <Input name="contract_terms.summary" readonly inputType="multiline" /> <InputCurrency name="contract_terms.amount" readonly currency={config.currency} /> <InputCurrency name="fee" readonly currency={config.currency} /> - { order.refunded && <InputCurrency<Paid> name="refund_amount" readonly currency={config.currency} /> } + {order.refunded && <InputCurrency<Paid> name="refund_amount" readonly currency={config.currency} />} <InputCurrency<Paid> name="deposit_total" readonly currency={config.currency} /> <Input<Paid> name="order_status" readonly /> </FormProvider> @@ -347,6 +348,22 @@ function UnpaidPage({ id, order }: { id: string; order: MerchantBackend.Orders.C <div class="tag is-dark">unpaid</div> </div> </div> + + <div class="level"> + <div class="level-left" style={{ maxWidth: '100%' }}> + <div class="level-item" style={{ maxWidth: '100%' }}> + <div class="content" style={{ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + // maxWidth: '100%', + }}> + <p>pay at: <a href={order.order_status_url} rel="nofollow" target="new">{order.order_status_url}</a></p> + <p>created at: <b>missing value, there is no contract term yet</b></p> + </div> + </div> + </div> + </div> </div> </section> @@ -370,7 +387,7 @@ function UnpaidPage({ id, order }: { id: string; order: MerchantBackend.Orders.C export function DetailPage({ id, selected, onRefund }: Props): VNode { const [showRefund, setShowRefund] = useState<string | undefined>(undefined) - const DetailByStatus = function (){ + const DetailByStatus = function () { switch (selected.order_status) { case 'claimed': return <ClaimedPage id={id} order={selected} /> case 'paid': return <PaidPage id={id} order={selected} onRefund={(order) => setShowRefund(id)} /> @@ -378,7 +395,7 @@ export function DetailPage({ id, selected, onRefund }: Props): VNode { default: return <div>unknown order status</div> } } - + return <Fragment> <NotificationCard notification={{ message: 'DEMO WARNING', diff --git a/packages/frontend/src/paths/instance/products/create/ProductForm.tsx b/packages/frontend/src/paths/instance/products/create/ProductForm.tsx @@ -12,20 +12,17 @@ type Entity = MerchantBackend.Products.ProductAddDetail interface Props { onSubscribe: (c:() => Entity|undefined) => void; - initial?: Entity; + initial?: Partial<Entity>; } export function ProductForm({onSubscribe, initial}:Props) { - const [value, valueHandler] = useState<Partial<Entity>>(initial || { - taxes:[] - }) + const [value, valueHandler] = useState<Partial<Entity>>(initial||{}) const [errors, setErrors] = useState<FormErrors<Entity>>({}) const submit = useCallback((): Entity|undefined => { try { schema.validateSync(value, { abortEarly: false }) - return schema.cast(value) as any as Entity - // onCreate(schema.cast(value) as any as Entity ); + return value as MerchantBackend.Products.ProductAddDetail } catch (err) { const errors = err.inner as yup.ValidationError[] const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: { type: cur.type, params: cur.params, message: cur.message } }), {}) @@ -44,7 +41,7 @@ export function ProductForm({onSubscribe, initial}:Props) { <Input<Entity> name="description" /> <InputCurrency<Entity> name="price" currency={config.currency} /> - <Input<Entity> name="total_stock" inputType="number" /> + <Input<Entity> name="total_stock" inputType="number" fromStr={(v) => parseInt(v, 10)} toStr={(v) => ""+v} inputExtra={{min:0}} /> </FormProvider> </div> diff --git a/packages/frontend/src/schemas/index.ts b/packages/frontend/src/schemas/index.ts @@ -14,11 +14,12 @@ 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 { isAfter, isFuture } from 'date-fns'; import * as yup from 'yup'; import { AMOUNT_REGEX, PAYTO_REGEX } from "../utils/constants"; @@ -45,8 +46,21 @@ function listOfPayToUrisAreValid(values?: (string | undefined)[]): boolean { function currencyWithAmountIsValid(value?: string): boolean { return !!value && AMOUNT_REGEX.test(value) } +function currencyGreaterThan0(value?: string) { + if (value) { + try { + const [,amount] = value.split(':') + const intAmount = parseInt(amount,10) + return intAmount > 0 + } catch { + return false + } + } + return true +} + export const InstanceSchema = yup.object().shape({ - id: yup.string().required().meta({type: 'url'}), + id: yup.string().required().meta({ type: 'url' }), name: yup.string().required(), auth: yup.object().shape({ method: yup.string().matches(/^(external|token)$/), @@ -54,16 +68,16 @@ export const InstanceSchema = yup.object().shape({ }), payto_uris: yup.array().of(yup.string()) .min(1) - .meta({type: 'array'}) + .meta({ type: 'array' }) .test('payto', '{path} is not valid', listOfPayToUrisAreValid), default_max_deposit_fee: yup.string() .required() .test('amount', 'the amount is not valid', currencyWithAmountIsValid) - .meta({type: 'amount'}), + .meta({ type: 'amount' }), default_max_wire_fee: yup.string() .required() .test('amount', '{path} is not valid', currencyWithAmountIsValid) - .meta({type: 'amount'}), + .meta({ type: 'amount' }), default_wire_fee_amortization: yup.number() .required(), address: yup.object().shape({ @@ -77,7 +91,7 @@ export const InstanceSchema = yup.object().shape({ town: yup.string(), district: yup.string().optional(), country_subdivision: yup.string().optional(), - }).meta({type:'group'}), + }).meta({ type: 'group' }), jurisdiction: yup.object().shape({ country: yup.string().optional(), address_lines: yup.array().of(yup.string()).max(7).optional(), @@ -89,42 +103,73 @@ export const InstanceSchema = yup.object().shape({ town: yup.string(), district: yup.string().optional(), country_subdivision: yup.string().optional(), - }).meta({type:'group'}), + }).meta({ type: 'group' }), default_pay_delay: yup.object() .shape({ d_ms: yup.number() }) .required() .meta({ type: 'duration' }), - // .transform(numberToDuration), + // .transform(numberToDuration), default_wire_transfer_delay: yup.object() .shape({ d_ms: yup.number() }) .required() .meta({ type: 'duration' }), - // .transform(numberToDuration), + // .transform(numberToDuration), }) export const InstanceUpdateSchema = InstanceSchema.clone().omit(['id']); export const InstanceCreateSchema = InstanceSchema.clone(); export const RefoundSchema = yup.object().shape({ - mainReason: yup.string().required(), - description: yup.string().required(), + mainReason: yup.string().required(), + description: yup.string().required(), refund: yup.string() .required() - .test('amount', 'the amount is not valid', currencyWithAmountIsValid), + .test('amount', 'the amount is not valid', currencyWithAmountIsValid) + .test('amount_positive', 'the amount is not valid', currencyGreaterThan0), }) +const stringIsValidJSON = (value?: string) => { + const p = value?.trim() + if (!p) return true; + try { + JSON.parse(p) + return true + } catch { + return false + } +} export const OrderCreateSchema = yup.object().shape({ - summary: yup.string().required(), - amount: yup.string() - .required() - .test('amount', 'the amount is not valid', currencyWithAmountIsValid), + pricing: yup.object().required().shape({ + summary: yup.string().ensure().required(), + order_price: yup.string() + .ensure() + .required() + .test('amount', 'the amount is not valid', currencyWithAmountIsValid) + .test('amount_positive', 'the amount should be greater than 0', currencyGreaterThan0), + }), + extra: yup.string().test('extra', 'is not a JSON format', stringIsValidJSON), + payments: yup.object().required().shape({ + refund_deadline: yup.date() + .test('future', 'should be in the future', (d) => d ? isFuture(d) : true), + pay_deadline: yup.date() + .test('future', 'should be in the future', (d) => d ? isFuture(d) : true), + auto_refund_deadline: yup.date() + .test('future', 'should be in the future', (d) => d ? isFuture(d) : true), + delivery_date: yup.date() + .test('future', 'should be in the future', (d) => d ? isFuture(d) : true), + }).test('payment', 'dates', (d) => { + if (d.pay_deadline && d.refund_deadline && isAfter(d.refund_deadline, d.pay_deadline)) { + return new yup.ValidationError('pay deadline should be greater than refund','asd','payments.pay_deadline') + } + return true + }) }) export const ProductCreateSchema = yup.object().shape({ - description: yup.string().required(), - price:yup.string() - .required() - .test('amount', 'the amount is not valid', currencyWithAmountIsValid), - total_stock: yup.number().required(), + description: yup.string().required(), + price: yup.string() + .required() + .test('amount', 'the amount is not valid', currencyWithAmountIsValid), + total_stock: yup.number().required(), })