merchant-backoffice

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

commit 61f845b3dcd71b74a802b5e791ec1ea0d3c03c87
parent 4323da43fcae7ddad122f25b8013eada29791fed
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu, 24 Jun 2021 09:22:57 -0300

duration picker

Diffstat:
Apackages/frontend/src/components/form/DurationPicker.scss | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/components/form/DurationPicker.stories.tsx | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/components/form/DurationPicker.tsx | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/frontend/src/components/form/InputDuration.tsx | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/frontend/src/components/instance/DefaultInstanceFormFields.tsx | 1+
Mpackages/frontend/src/components/modal/index.tsx | 12++++++++++++
Mpackages/frontend/src/paths/instance/orders/create/CreatePage.tsx | 19++++++++++---------
Mpackages/frontend/src/paths/instance/orders/create/index.tsx | 3+--
Mpackages/frontend/src/utils/constants.ts | 2+-
9 files changed, 386 insertions(+), 31 deletions(-)

diff --git a/packages/frontend/src/components/form/DurationPicker.scss b/packages/frontend/src/components/form/DurationPicker.scss @@ -0,0 +1,71 @@ + +.rdp-picker { + display: flex; + height: 175px; +} + +@media (max-width: 400px) { + .rdp-picker { + width: 250px; + } +} + +.rdp-masked-div { + overflow: hidden; + height: 175px; + position: relative; +} + +.rdp-column-container { + flex-grow: 1; + display: inline-block; +} + +.rdp-column { + position: absolute; + z-index: 0; + width: 100%; +} + +.rdp-reticule { + border: 0; + border-top: 2px solid rgba(109, 202, 236, 1); + height: 2px; + position: absolute; + width: 80%; + margin: 0; + z-index: 100; + left: 50%; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); +} + +.rdp-text-overlay { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + height: 35px; + font-size: 20px; + left: 50%; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); +} + +.rdp-cell div { + font-size: 17px; + color: gray; + font-style: italic; +} + +.rdp-cell { + display: flex; + align-items: center; + justify-content: center; + height: 35px; + font-size: 18px; +} + +.rdp-center { + font-size: 25px; +} diff --git a/packages/frontend/src/components/form/DurationPicker.stories.tsx b/packages/frontend/src/components/form/DurationPicker.stories.tsx @@ -0,0 +1,50 @@ +/* + 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 { h, FunctionalComponent } from 'preact'; +import { useState } from 'preact/hooks'; +import { DurationPicker as TestedComponent } from './DurationPicker'; + + +export default { + title: 'Components/Picker/Duration', + component: TestedComponent, + argTypes: { + onCreate: { action: 'onCreate' }, + goBack: { action: 'goBack' }, + } +}; + +function createExample<Props>(Component: FunctionalComponent<Props>, props: Partial<Props>) { + const r = (args: any) => <Component {...args} /> + r.args = props + return r +} + +export const Example = createExample(TestedComponent, { + days: true, minutes: true, hours: true, seconds: true, + value: 10000000 +}); + +export const WithState = () => { + const [v,s] = useState<number>(1000000) + return <TestedComponent value={v} onChange={s} days minutes hours seconds /> +} diff --git a/packages/frontend/src/components/form/DurationPicker.tsx b/packages/frontend/src/components/form/DurationPicker.tsx @@ -0,0 +1,154 @@ +/* + 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 { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useTranslator } from "../../i18n"; +import './DurationPicker.scss' + +export interface Props { + hours?: boolean; + minutes?: boolean; + seconds?: boolean; + days?: boolean; + onChange: (value: number) => void; + value: number +} + +// inspiration taken from https://github.com/flurmbo/react-duration-picker +export function DurationPicker({ days, hours, minutes, seconds, onChange, value }: Props): VNode { + const s = 1000 + const m = s * 60 + const h = m * 60 + const d = h * 24 + const i18n = useTranslator() + + return <div class="rdp-picker"> + {days && <DurationColumn unit={i18n`days`} max={99} + value={Math.floor(value / d)} + onDecrease={value >= d ? () => onChange(value - d) : undefined} + onIncrease={value < 99 * d ? () => onChange(value + d) : undefined} + onChange={diff => onChange(value + diff * d)} + />} + {hours && <DurationColumn unit={i18n`hours`} max={23} min={1} + value={Math.floor(value / h) % 24} + onDecrease={value >= h ? () => onChange(value - h) : undefined} + onIncrease={value < 99 * d ? () => onChange(value + h) : undefined} + onChange={diff => onChange(value + diff * h)} + />} + {minutes && <DurationColumn unit={i18n`minutes`} max={59} min={1} + value={Math.floor(value / m) % 60} + onDecrease={value >= m ? () => onChange(value - m) : undefined} + onIncrease={value < 99 * d ? () => onChange(value + m) : undefined} + onChange={diff => onChange(value + diff * m)} + />} + {seconds && <DurationColumn unit={i18n`seconds`} max={59} + value={Math.floor(value / s) % 60} + onDecrease={value >= s ? () => onChange(value - s) : undefined} + onIncrease={value < 99 * d ? () => onChange(value + s) : undefined} + onChange={diff => onChange(value + diff * s)} + />} + </div> +} + +interface ColProps { + unit: string, + min?: number, + max: number, + value: number, + onIncrease?: () => void; + onDecrease?: () => void; + onChange?: (diff: number) => void; +} + +function InputNumber({ initial, onChange }: { initial: number, onChange: (n: number) => void }) { + const [value, handler] = useState<{v:string}>({ + v: toTwoDigitString(initial) + }) + + return <input + value={value.v} + onBlur={(e) => onChange(parseInt(value.v, 10))} + onInput={(e) => { + e.preventDefault() + const n = Number.parseInt(e.currentTarget.value, 10); + if (isNaN(n)) return handler({v:toTwoDigitString(initial)}) + return handler({v:toTwoDigitString(n)}) + }} + style={{ width: 50, border: 'none', fontSize: 'inherit', background: 'inherit' }} /> +} + +function DurationColumn({ unit, min = 0, max, value, onIncrease, onDecrease, onChange }: ColProps): VNode { + + const cellHeight = 35 + return ( + <div class="rdp-column-container"> + <div class="rdp-masked-div"> + <hr class="rdp-reticule" style={{ top: cellHeight * 2 - 1 }} /> + <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} /> + + <div class="rdp-column" style={{ top: 0 }}> + + <div class="rdp-cell" key={value - 1}> + {onDecrease && <button style={{ width: '100%', textAlign: 'center', margin: 5 }} + onClick={onDecrease}> + <span class="icon"> + <i class="mdi mdi-chevron-up" /> + </span> + </button>} + </div> + <div class="rdp-cell" key={value - 1}> + {value > min ? toTwoDigitString(value - 1) : ''} + </div> + <div class="rdp-cell rdp-center" key={value}> + {onChange ? + <InputNumber initial={value} onChange={(n) => onChange(n - value)} /> : + toTwoDigitString(value) + } + <div>{unit}</div> + </div> + + <div class="rdp-cell" key={value + 1}> + {value < max ? toTwoDigitString(value + 1) : ''} + </div> + + <div class="rdp-cell" key={value - 1}> + {onIncrease && <button style={{ width: '100%', textAlign: 'center', margin: 5 }} + onClick={onIncrease}> + <span class="icon"> + <i class="mdi mdi-chevron-down" /> + </span> + </button>} + </div> + + </div> + </div> + </div> + ); +} + + +function toTwoDigitString(n: number) { + if (n < 10) { + return `0${n}`; + } + return `${n}`; +} +\ No newline at end of file diff --git a/packages/frontend/src/components/form/InputDuration.tsx b/packages/frontend/src/components/form/InputDuration.tsx @@ -18,33 +18,99 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { formatDuration, intervalToDuration } from "date-fns"; +import { intervalToDuration, formatDuration } from "date-fns"; import { h, VNode } from "preact"; -import { RelativeTime } from "../../declaration"; +import { useState } from "preact/hooks"; +import { Translate, useTranslator } from "../../i18n"; +import { SimpleModal } from "../modal"; +import { DurationPicker } from "./DurationPicker"; import { InputProps, useField } from "./useField"; -import { InputWithAddon } from "./InputWithAddon"; export interface Props<T> extends InputProps<T> { expand?: boolean; readonly?: boolean; + withForever?: boolean; } -export function InputDuration<T>({ name, expand, placeholder, tooltip, label, help, readonly }: Props<keyof T>): VNode { - const { value } = useField<T>(name); - return <InputWithAddon<T> name={name} readonly={readonly} addonAfter={readableDuration(value as any)} - expand={expand} - label={label} placeholder={placeholder} help={help} tooltip={tooltip} - toStr={(v?: RelativeTime) => `${(v && v.d_ms !== "forever" && v.d_ms ? v.d_ms : '')}`} - fromStr={(v: string) => ({ d_ms: (parseInt(v, 10)) || undefined })} - /> -} +export function InputDuration<T>({ name, expand, placeholder, tooltip, label, help, readonly, withForever }: Props<keyof T>): VNode { + const [opened, setOpened] = useState(false) + const i18n = useTranslator() -function readableDuration(duration?: RelativeTime): string { - if (!duration) return "" - if (duration.d_ms === "forever") return "forever" - try { - return formatDuration(intervalToDuration({ start: 0, end: duration.d_ms })) - } catch (e) { - return '' + const { error, required, value, onChange } = useField<T>(name); + let strValue = '' + if (!value) { + strValue = '' + } else if (value.d_ms === 'forever') { + strValue = i18n`forever` + } else { + strValue = formatDuration(intervalToDuration({ start: 0, end: value.d_ms }), { + locale: { + formatDistance: (name, value) => { + switch(name) { + case 'xMonths': return i18n`${value}M`; + case 'xYears': return i18n`${value}Y`; + case 'xDays': return i18n`${value}d`; + case 'xHours': return i18n`${value}h`; + case 'xMinutes': return i18n`${value}min`; + case 'xSeconds': return i18n`${value}sec`; + } + }, + localize: { + day: () => 's', + month: () => 'm', + ordinalNumber: () => 'th', + dayPeriod: () => 'p', + quarter: () => 'w', + era: () => 'e' + } + }, + }) } + + return <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {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={strValue} + placeholder={placeholder} + onClick={() => { if (!readonly) setOpened(true) }} + /> + {required && <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span>} + {help} + </p> + <div class="control" onClick={() => { if (!readonly) setOpened(true) }}> + <a class="button is-static" > + <span class="icon"><i class="mdi mdi-clock" /></span> + </a> + </div> + </div> + {error && <p class="help is-danger">{error}</p>} + </div> + {!readonly && <span data-tooltip={i18n`change value to empty`}> + <button class="button is-info mr-3" onClick={() => onChange(undefined as any)} ><Translate>clear</Translate></button> + </span>} + {withForever && <span data-tooltip={i18n`change value to never`}> + <button class="button is-info" onClick={() => onChange({ d_ms: 'forever' } as any)}><Translate>forever</Translate></button> + </span>} + </div> + {opened && <SimpleModal onCancel={() => setOpened(false)}> + <DurationPicker days hours minutes + value={!value || value.d_ms === 'forever' ? 0 : value.d_ms} + onChange={(v) => { onChange({ d_ms: v } as any) }} + /> + </SimpleModal>} + </div> } diff --git a/packages/frontend/src/components/instance/DefaultInstanceFormFields.tsx b/packages/frontend/src/components/instance/DefaultInstanceFormFields.tsx @@ -79,6 +79,7 @@ export function DefaultInstanceFormFields({ readonlyId, showId }: { readonlyId?: <InputDuration<Entity> name="default_wire_transfer_delay" label={i18n`Default wire transfer delay`} + withForever 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/frontend/src/components/modal/index.tsx b/packages/frontend/src/components/modal/index.tsx @@ -82,6 +82,18 @@ export function ContinueModal({ active, description, onCancel, onConfirm, childr </div> } +export function SimpleModal({onCancel, children}: any):VNode { + return <div class="modal is-active"> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card"> + <section class="modal-card-body is-main-section"> + {children} + </section> + </div> + <button class="modal-close is-large " aria-label="close" onClick={onCancel} /> +</div> +} + export function ClearConfirmModal({ description, onCancel, onClear, onConfirm, children }: Props & { onClear?: () => void }): VNode { return <div class="modal is-active"> <div class="modal-background " onClick={onCancel} /> diff --git a/packages/frontend/src/paths/instance/orders/create/CreatePage.tsx b/packages/frontend/src/paths/instance/orders/create/CreatePage.tsx @@ -121,27 +121,28 @@ function undefinedIfEmpty<T>(obj: T): T | 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) - const [value, valueHandler] = useState(with_defaults(instanceConfig)) - - const inventoryList = Object.values(value.inventoryProducts || {}) - const productList = Object.values(value.products || {}) - const i18n = useTranslator() + 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 + (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, 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 && value.payments.refund_deadline && isBefore(value.payments.refund_deadline, value.payments.pay_deadline) ? + value.payments.pay_deadline && isBefore(value.payments.refund_deadline, value.payments.pay_deadline) ? i18n`pay deadline cannot be before refund deadline` : undefined ) ), @@ -151,8 +152,8 @@ export function CreatePage({ onCreate, onBack, instanceConfig, instanceInventory 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 + !isAfter(value.payments.refund_deadline, value.payments.auto_refund_deadline) ? + i18n`auto refund cannot be after refund deadline` : undefined ) ) ), diff --git a/packages/frontend/src/paths/instance/orders/create/index.tsx b/packages/frontend/src/paths/instance/orders/create/index.tsx @@ -79,4 +79,4 @@ export default function OrderCreate({ onConfirm, onBack, onLoadError, onNotFound instanceInventory={inventoryResult.data} /> </Fragment> -} -\ No newline at end of file +} diff --git a/packages/frontend/src/utils/constants.ts b/packages/frontend/src/utils/constants.ts @@ -23,7 +23,7 @@ export const PAYTO_REGEX = /^payto:\/\/[a-zA-Z][a-zA-Z0-9-.]+(\/[a-zA-Z0-9\-\.\~\(\)@_%:!$&'*+,;=]*)*\??((amount|receiver-name|sender-name|instruction|message)=[a-zA-Z0-9\-\.\~\(\)@_%:!$'*+,;=]*&?)*$/ export const PAYTO_WIRE_METHOD_LOOKUP = /payto:\/\/([a-zA-Z][a-zA-Z0-9-.]+)\/.*/ -export const AMOUNT_REGEX = /^[a-zA-Z]*:[0-9][0-9,]*\.?[0-9,]*$/ +export const AMOUNT_REGEX = /^[a-zA-Z][a-zA-Z]*:[0-9][0-9,]*\.?[0-9,]*$/ export const INSTANCE_ID_LOOKUP = /^\/instances\/([^/]*)\/?$/