gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-merchant-backoffice] 05/05: duration picker


From: gnunet
Subject: [taler-merchant-backoffice] 05/05: duration picker
Date: Thu, 24 Jun 2021 14:30:14 +0200

This is an automated email from the git hooks/post-receive script.

sebasjm pushed a commit to branch master
in repository merchant-backoffice.

commit 61f845b3dcd71b74a802b5e791ec1ea0d3c03c87
Author: Sebastian <sebasjm@gmail.com>
AuthorDate: Thu Jun 24 09:22:57 2021 -0300

    duration picker
---
 .../src/components/form/DurationPicker.scss        |  71 ++++++++++
 .../src/components/form/DurationPicker.stories.tsx |  50 +++++++
 .../src/components/form/DurationPicker.tsx         | 154 +++++++++++++++++++++
 .../frontend/src/components/form/InputDuration.tsx | 104 +++++++++++---
 .../instance/DefaultInstanceFormFields.tsx         |   1 +
 packages/frontend/src/components/modal/index.tsx   |  12 ++
 .../paths/instance/orders/create/CreatePage.tsx    |  19 +--
 .../src/paths/instance/orders/create/index.tsx     |   2 +-
 packages/frontend/src/utils/constants.ts           |   2 +-
 9 files changed, 385 insertions(+), 30 deletions(-)

diff --git a/packages/frontend/src/components/form/DurationPicker.scss 
b/packages/frontend/src/components/form/DurationPicker.scss
new file mode 100644
index 0000000..a355753
--- /dev/null
+++ 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
new file mode 100644
index 0000000..275c80f
--- /dev/null
+++ 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
new file mode 100644
index 0000000..2d51f2d
--- /dev/null
+++ 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
index 30afd65..76e9022 100644
--- 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
index 2bfbeda..2d7f93f 100644
--- 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
index 963dc05..a427215 100644
--- 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
index 87c9cc5..22fa2f3 100644
--- 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
index 71f5b7f..c447c4b 100644
--- 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
index cbf4342..403adb9 100644
--- 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\/([^/]*)\/?$/
 

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]