diff options
Diffstat (limited to 'packages/demobank-ui/src/components')
18 files changed, 1711 insertions, 0 deletions
diff --git a/packages/demobank-ui/src/components/AsyncButton.tsx b/packages/demobank-ui/src/components/AsyncButton.tsx new file mode 100644 index 000000000..0c4305668 --- /dev/null +++ b/packages/demobank-ui/src/components/AsyncButton.tsx @@ -0,0 +1,66 @@ +/* + 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 { ComponentChildren, h, VNode } from 'preact'; +import { useLayoutEffect, useRef } from 'preact/hooks'; +// import { LoadingModal } from "../modal"; +import { useAsync } from '../hooks/async'; +// import { Translate } from "../../i18n"; + +type Props = { + children: ComponentChildren; + disabled?: boolean; + onClick?: () => Promise<void>; + grabFocus?: boolean; + [rest: string]: any; +}; + +export function AsyncButton({ + onClick, + grabFocus, + disabled, + children, + ...rest +}: Props): VNode { + const { isLoading, request } = useAsync(onClick); + + const buttonRef = useRef<HTMLButtonElement>(null); + useLayoutEffect(() => { + if (grabFocus) + buttonRef.current?.focus(); + + }, [grabFocus]); + + // if (isSlow) { + // return <LoadingModal onCancel={cancel} />; + // } + if (isLoading) + return <button class="button">Loading...</button>; + + + return ( + <span data-tooltip={rest['data-tooltip']} style={{ marginLeft: 5 }}> + <button {...rest} ref={buttonRef} onClick={request} disabled={disabled}> + {children} + </button> + </span> + ); +} diff --git a/packages/demobank-ui/src/components/FileButton.tsx b/packages/demobank-ui/src/components/FileButton.tsx new file mode 100644 index 000000000..dba86ccbf --- /dev/null +++ b/packages/demobank-ui/src/components/FileButton.tsx @@ -0,0 +1,57 @@ +import { h, VNode } from 'preact'; +import { useRef, useState } from 'preact/hooks'; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; + +export interface FileTypeContent { + content: string; + type: string; + name: string; +} + +interface Props { + label: string; + onChange: (v: FileTypeContent | undefined) => void; +} +export function FileButton(props: Props): VNode { + const fileInputRef = useRef<HTMLInputElement>(null); + const [sizeError, setSizeError] = useState(false); + return ( + <div> + <button class="button" onClick={(e) => fileInputRef.current?.click()}> + <span>{props.label}</span> + </button> + <input + ref={fileInputRef} + style={{ display: 'none' }} + type="file" + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) + return props.onChange(undefined); + + console.log(f); + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true); + return props.onChange(undefined); + } + setSizeError(false); + return f[0].arrayBuffer().then((b) => { + const content = new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + '', + ); + return props.onChange({ + content, + name: f[0].name, + type: f[0].type, + }); + }); + }} + /> + {sizeError && ( + <p class="help is-danger">File should be smaller than 1 MB</p> + )} + </div> + ); +} diff --git a/packages/demobank-ui/src/components/Notifications.tsx b/packages/demobank-ui/src/components/Notifications.tsx new file mode 100644 index 000000000..09329442a --- /dev/null +++ b/packages/demobank-ui/src/components/Notifications.tsx @@ -0,0 +1,74 @@ +/* + 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'; + +export interface Notification { + message: string; + description?: string | VNode; + type: MessageType; +} + +export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS'; + +interface Props { + notifications: Notification[]; + removeNotification?: (n: Notification) => void; +} + +function messageStyle(type: MessageType): string { + switch (type) { + case 'INFO': + return 'message is-info'; + case 'WARN': + return 'message is-warning'; + case 'ERROR': + return 'message is-danger'; + case 'SUCCESS': + return 'message is-success'; + default: + return 'message'; + } +} + +export function Notifications({ + notifications, + removeNotification, +}: Props): VNode { + return ( + <div class="block"> + {notifications.map((n, i) => ( + <article key={i} class={messageStyle(n.type)}> + <div class="message-header"> + <p>{n.message}</p> + {removeNotification && ( + <button + class="delete" + onClick={() => removeNotification && removeNotification(n)} + /> + )} + </div> + {n.description && <div class="message-body">{n.description}</div>} + </article> + ))} + </div> + ); +} diff --git a/packages/demobank-ui/src/components/QR.tsx b/packages/demobank-ui/src/components/QR.tsx new file mode 100644 index 000000000..ee5b73c69 --- /dev/null +++ b/packages/demobank-ui/src/components/QR.tsx @@ -0,0 +1,48 @@ +/* + 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/> + */ + +import { h, VNode } from 'preact'; +import { useEffect, useRef } from 'preact/hooks'; +import qrcode from 'qrcode-generator'; + +export function QR({ text }: { text: string }): VNode { + const divRef = useRef<HTMLDivElement>(null); + useEffect(() => { + const qr = qrcode(0, 'L'); + qr.addData(text); + qr.make(); + if (divRef.current) + divRef.current.innerHTML = qr.createSvgTag({ + scalable: true, + }); + }); + + return ( + <div + style={{ + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'left', + }} + > + <div + style={{ width: '50%', minWidth: 200, maxWidth: 300 }} + ref={divRef} + /> + </div> + ); +} diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx new file mode 100644 index 000000000..5338c548e --- /dev/null +++ b/packages/demobank-ui/src/components/app.tsx @@ -0,0 +1,14 @@ +import { FunctionalComponent, h } from 'preact'; +import { TranslationProvider } from '../context/translation'; +import { BankHome } from '../pages/home/index'; +import { Menu } from './menu'; + +const App: FunctionalComponent = () => { + return ( + <TranslationProvider> + <BankHome /> + </TranslationProvider> + ); +}; + +export default App; diff --git a/packages/demobank-ui/src/components/fields/DateInput.tsx b/packages/demobank-ui/src/components/fields/DateInput.tsx new file mode 100644 index 000000000..06ec4b6a7 --- /dev/null +++ b/packages/demobank-ui/src/components/fields/DateInput.tsx @@ -0,0 +1,90 @@ +import { format, subYears } from 'date-fns'; +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; +import { DatePicker } from '../picker/DatePicker'; + +export interface DateInputProps { + label: string; + grabFocus?: boolean; + tooltip?: string; + error?: string; + years?: Array<number>; + onConfirm?: () => void; + bind: [string, (x: string) => void]; +} + +export function DateInput(props: DateInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) + inputRef.current?.focus(); + + }, [props.grabFocus]); + const [opened, setOpened] = useState(false); + + const value = props.bind[0] || ''; + const [dirty, setDirty] = useState(false); + const showError = dirty && props.error; + + const calendar = subYears(new Date(), 30); + + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control"> + <div class="field has-addons"> + <p class="control"> + <input + type="text" + class={showError ? 'input is-danger' : 'input'} + value={value} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) + props.onConfirm() + + }} + onInput={(e) => { + const text = e.currentTarget.value; + setDirty(true); + props.bind[1](text); + }} + ref={inputRef} + /> + </p> + <p class="control"> + <a + class="button" + onClick={() => { + setOpened(true); + }} + > + <span class="icon"> + <i class="mdi mdi-calendar" /> + </span> + </a> + </p> + </div> + </div> + <p class="help">Using the format yyyy-mm-dd</p> + {showError && <p class="help is-danger">{props.error}</p>} + <DatePicker + opened={opened} + initialDate={calendar} + years={props.years} + closeFunction={() => setOpened(false)} + dateReceiver={(d) => { + setDirty(true); + const v = format(d, 'yyyy-MM-dd'); + props.bind[1](v); + }} + /> + </div> + ); +} diff --git a/packages/demobank-ui/src/components/fields/EmailInput.tsx b/packages/demobank-ui/src/components/fields/EmailInput.tsx new file mode 100644 index 000000000..8b64264ed --- /dev/null +++ b/packages/demobank-ui/src/components/fields/EmailInput.tsx @@ -0,0 +1,57 @@ +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +export interface TextInputProps { + label: string; + grabFocus?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + onConfirm?: () => void; + bind: [string, (x: string) => void]; +} + +export function EmailInput(props: TextInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) + inputRef.current?.focus(); + + }, [props.grabFocus]); + const value = props.bind[0]; + const [dirty, setDirty] = useState(false); + const showError = dirty && props.error; + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control has-icons-right"> + <input + value={value} + required + placeholder={props.placeholder} + type="email" + class={showError ? 'input is-danger' : 'input'} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) + props.onConfirm() + + }} + onInput={(e) => { + setDirty(true); + props.bind[1]((e.target as HTMLInputElement).value); + }} + ref={inputRef} + style={{ display: 'block' }} + /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} + </div> + ); +} diff --git a/packages/demobank-ui/src/components/fields/FileInput.tsx b/packages/demobank-ui/src/components/fields/FileInput.tsx new file mode 100644 index 000000000..17413b907 --- /dev/null +++ b/packages/demobank-ui/src/components/fields/FileInput.tsx @@ -0,0 +1,104 @@ +/* + 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 { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; + +export interface FileTypeContent { + content: string; + type: string; + name: string; +} + +export interface FileInputProps { + label: string; + grabFocus?: boolean; + disabled?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + onChange: (v: FileTypeContent | undefined) => void; +} + +export function FileInput(props: FileInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) + inputRef.current?.focus(); + + }, [props.grabFocus]); + + const fileInputRef = useRef<HTMLInputElement>(null); + const [sizeError, setSizeError] = useState(false); + return ( + <div class="field"> + <label class="label"> + <a class="button" onClick={(e) => fileInputRef.current?.click()}> + <div class="icon is-small "> + <i class="mdi mdi-folder" /> + </div> + <span> + {props.label} + </span> + </a> + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control"> + <input + ref={fileInputRef} + style={{ display: 'none' }} + type="file" + // name={String(name)} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) + return props.onChange(undefined); + + console.log(f) + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true); + return props.onChange(undefined); + } + setSizeError(false); + return f[0].arrayBuffer().then((b) => { + const b64 = btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + '', + ), + ); + return props.onChange({content: `data:${f[0].type};base64,${b64}`, name: f[0].name, type: f[0].type}); + }); + }} + /> + {props.error && <p class="help is-danger">{props.error}</p>} + {sizeError && ( + <p class="help is-danger">File should be smaller than 1 MB</p> + )} + </div> + </div> + ); +} diff --git a/packages/demobank-ui/src/components/fields/ImageInput.tsx b/packages/demobank-ui/src/components/fields/ImageInput.tsx new file mode 100644 index 000000000..98457af21 --- /dev/null +++ b/packages/demobank-ui/src/components/fields/ImageInput.tsx @@ -0,0 +1,93 @@ +/* + 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 { useLayoutEffect, useRef, useState } from 'preact/hooks'; +import emptyImage from '../../assets/empty.png'; +import { TextInputProps } from './TextInput'; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; + +export function ImageInput(props: TextInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) + inputRef.current?.focus(); + + }, [props.grabFocus]); + + const value = props.bind[0]; + // const [dirty, setDirty] = useState(false) + const image = useRef<HTMLInputElement>(null); + const [sizeError, setSizeError] = useState(false); + function onChange(v: string): void { + // setDirty(true); + props.bind[1](v); + } + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control"> + <img + src={!value ? emptyImage : value} + style={{ width: 200, height: 200 }} + onClick={() => image.current?.click()} + /> + <input + ref={image} + style={{ display: 'none' }} + type="file" + name={String(name)} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) + return onChange(emptyImage); + + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true); + return onChange(emptyImage); + } + setSizeError(false); + return f[0].arrayBuffer().then((b) => { + const b64 = btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + '', + ), + ); + return onChange(`data:${f[0].type};base64,${b64}` as any); + }); + }} + /> + {props.error && <p class="help is-danger">{props.error}</p>} + {sizeError && ( + <p class="help is-danger">Image should be smaller than 1 MB</p> + )} + </div> + </div> + ); +} diff --git a/packages/demobank-ui/src/components/fields/NumberInput.tsx b/packages/demobank-ui/src/components/fields/NumberInput.tsx new file mode 100644 index 000000000..881c61c57 --- /dev/null +++ b/packages/demobank-ui/src/components/fields/NumberInput.tsx @@ -0,0 +1,56 @@ +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +export interface TextInputProps { + label: string; + grabFocus?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + onConfirm?: () => void; + bind: [string, (x: string) => void]; +} + +export function PhoneNumberInput(props: TextInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) + inputRef.current?.focus(); + + }, [props.grabFocus]); + const value = props.bind[0]; + const [dirty, setDirty] = useState(false); + const showError = dirty && props.error; + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control has-icons-right"> + <input + value={value} + type="tel" + placeholder={props.placeholder} + class={showError ? 'input is-danger' : 'input'} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) + props.onConfirm() + + }} + onInput={(e) => { + setDirty(true); + props.bind[1]((e.target as HTMLInputElement).value); + }} + ref={inputRef} + style={{ display: 'block' }} + /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} + </div> + ); +} diff --git a/packages/demobank-ui/src/components/fields/TextInput.tsx b/packages/demobank-ui/src/components/fields/TextInput.tsx new file mode 100644 index 000000000..5cc9f32ad --- /dev/null +++ b/packages/demobank-ui/src/components/fields/TextInput.tsx @@ -0,0 +1,68 @@ +import { h, VNode } from 'preact'; +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; + +export interface TextInputProps { + inputType?: 'text' | 'number' | 'multiline' | 'password'; + label: string; + grabFocus?: boolean; + disabled?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + onConfirm?: () => void; + bind: [string, (x: string) => void]; +} + +const TextInputType = function ({ inputType, grabFocus, ...rest }: any): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (grabFocus) + inputRef.current?.focus(); + + }, [grabFocus]); + + return inputType === 'multiline' ? ( + <textarea {...rest} rows={5} ref={inputRef} style={{ height: 'unset' }} /> + ) : ( + <input {...rest} type={inputType} ref={inputRef} /> + ); +}; + +export function TextInput(props: TextInputProps): VNode { + const value = props.bind[0]; + const [dirty, setDirty] = useState(false); + const showError = dirty && props.error; + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control has-icons-right"> + <TextInputType + inputType={props.inputType} + value={value} + grabFocus={props.grabFocus} + disabled={props.disabled} + placeholder={props.placeholder} + class={showError ? 'input is-danger' : 'input'} + onKeyPress={(e: any) => { + if (e.key === 'Enter' && props.onConfirm) + props.onConfirm(); + + }} + onInput={(e: any) => { + setDirty(true); + props.bind[1]((e.target as HTMLInputElement).value); + }} + style={{ display: 'block' }} + /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} + </div> + ); +} diff --git a/packages/demobank-ui/src/components/menu/LangSelector.tsx b/packages/demobank-ui/src/components/menu/LangSelector.tsx new file mode 100644 index 000000000..221237a5b --- /dev/null +++ b/packages/demobank-ui/src/components/menu/LangSelector.tsx @@ -0,0 +1,101 @@ +/* + 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, Fragment } from 'preact'; +import { useCallback, useEffect, useState } from 'preact/hooks'; +import langIcon from '../../assets/icons/languageicon.svg'; +import { useTranslationContext } from '../../context/translation'; +import { strings as messages } from '../../i18n/strings'; + +type LangsNames = { + [P in keyof typeof messages]: string; +}; + +const names: LangsNames = { + es: 'Español [es]', + en: 'English [en]', + fr: 'Français [fr]', + de: 'Deutsch [de]', + sv: 'Svenska [sv]', + it: 'Italiano [it]', +}; + +function getLangName(s: keyof LangsNames | string): string { + if (names[s]) return names[s]; + return String(s); +} + +// FIXME: explain "like py". +export function LangSelectorLikePy(): VNode { + const [updatingLang, setUpdatingLang] = useState(false); + const { lang, changeLanguage } = useTranslationContext(); + const [hidden, setHidden] = useState(true) + useEffect(() => { + function bodyKeyPress(event:KeyboardEvent) { + if (event.code === 'Escape') + setHidden(true); + + } + function bodyOnClick(event:Event) { + setHidden(true); + } + document.body.addEventListener('click', bodyOnClick) + document.body.addEventListener('keydown', bodyKeyPress as any) + return () => { + document.body.removeEventListener('keydown', bodyKeyPress as any) + document.body.removeEventListener('click', bodyOnClick) + } + },[]) + return ( + <Fragment> + <button name="language" onClick={(ev) => { + setHidden(h => !h); + ev.stopPropagation(); + }}> + {getLangName(lang)} + </button> + <div id="lang" class={hidden ? 'hide' : ''}> + <div style="position: relative; overflow: visible;"> + <div + class="nav" + style="position: absolute; max-height: 60vh; overflow-y: scroll"> + {Object.keys(messages) + .filter((l) => l !== lang) + .map((l) => ( + <a + key={l} + href="#" + class="navbtn langbtn" + value={l} + onClick={() => { + changeLanguage(l); + setUpdatingLang(false); + }}> + {getLangName(l)} + </a> + ))} + <br /> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/demobank-ui/src/components/menu/NavigationBar.tsx b/packages/demobank-ui/src/components/menu/NavigationBar.tsx new file mode 100644 index 000000000..9e540213d --- /dev/null +++ b/packages/demobank-ui/src/components/menu/NavigationBar.tsx @@ -0,0 +1,53 @@ +/* + 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 logo from '../../assets/logo.jpeg'; +import { LangSelectorLikePy as LangSelector } from './LangSelector'; + +interface Props { + onMobileMenu: () => void; + title: string; +} + +export function NavigationBar({ onMobileMenu, title }: Props): VNode { + return ( + <nav + class="navbar is-fixed-top" + role="navigation" + aria-label="main navigation" + > + <div class="navbar-brand"> + <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}> + {title} + </span> + </div> + + <div class="navbar-menu "> + <div class="navbar-end"> + <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> + {/* <LangSelector /> */} + </div> + </div> + </div> + </nav> + ); +} diff --git a/packages/demobank-ui/src/components/menu/SideBar.tsx b/packages/demobank-ui/src/components/menu/SideBar.tsx new file mode 100644 index 000000000..7f9981a1c --- /dev/null +++ b/packages/demobank-ui/src/components/menu/SideBar.tsx @@ -0,0 +1,73 @@ +/* + 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 { Translate } from '../../i18n'; + +interface Props { + mobile?: boolean; +} + +export function Sidebar({ mobile }: Props): VNode { + // const config = useConfigContext(); + const config = { version: 'none' }; + // FIXME: add replacement for __VERSION__ with the current version + const process = { env: { __VERSION__: '0.0.0' } }; + + return ( + <aside class="aside is-placed-left is-expanded"> + <div class="aside-tools"> + <div class="aside-tools-label"> + <div> + <b>euFin bank</b> + </div> + <div + class="is-size-7 has-text-right" + style={{ lineHeight: 0, marginTop: -10 }} + > + Version {process.env.__VERSION__} ({config.version}) + </div> + </div> + </div> + <div class="menu is-menu-main"> + <p class="menu-label"> + <Translate>Bank menu</Translate> + </p> + <ul class="menu-list"> + <li> + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Select option1</Translate> + </span> + </div> + </li> + <li> + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Select option2</Translate> + </span> + </div> + </li> + </ul> + </div> + </aside> + ); +} diff --git a/packages/demobank-ui/src/components/menu/index.tsx b/packages/demobank-ui/src/components/menu/index.tsx new file mode 100644 index 000000000..07e1c5265 --- /dev/null +++ b/packages/demobank-ui/src/components/menu/index.tsx @@ -0,0 +1,135 @@ +/* + 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/> + */ + +import { ComponentChildren, Fragment, h, VNode } from 'preact'; +import Match from 'preact-router/match'; +import { useEffect, useState } from 'preact/hooks'; +import { NavigationBar } from './NavigationBar'; +import { Sidebar } from './SideBar'; + +interface MenuProps { + title: string; +} + +function WithTitle({ + title, + children, +}: { + title: string; + children: ComponentChildren; +}): VNode { + useEffect(() => { + document.title = `${title}`; + }, [title]); + return <Fragment>{children}</Fragment>; +} + +export function Menu({ title }: MenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); + + return ( + <Match> + {({ path }: { path: string }) => { + const titleWithSubtitle = title; // title ? title : (!admin ? getInstanceTitle(path, instance) : getAdminTitle(path, instance)) + return ( + <WithTitle title={titleWithSubtitle}> + <div + class={mobileOpen ? 'has-aside-mobile-expanded' : ''} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={titleWithSubtitle} + /> + + <Sidebar mobile={mobileOpen} /> + </div> + </WithTitle> + ); + }} + </Match> + ); +} + +interface NotYetReadyAppMenuProps { + title: string; + onLogout?: () => void; +} + +interface NotifProps { + notification?: Notification; +} +export function NotificationCard({ + notification: n, +}: NotifProps): VNode | null { + if (!n) return null; + return ( + <div class="notification"> + <div class="columns is-vcentered"> + <div class="column is-12"> + <article + class={ + n.type === 'ERROR' + ? 'message is-danger' + : n.type === 'WARN' + ? 'message is-warning' + : 'message is-info' + } + > + <div class="message-header"> + <p>{n.message}</p> + </div> + {n.description && <div class="message-body">{n.description}</div>} + </article> + </div> + </div> + </div> + ); +} + +export function NotYetReadyAppMenu({ + onLogout, + title, +}: NotYetReadyAppMenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); + + useEffect(() => { + document.title = `Taler Backoffice: ${title}`; + }, [title]); + + return ( + <div + class="has-aside-mobile-expanded" + // class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={title} + /> + {onLogout && <Sidebar mobile={mobileOpen} />} + </div> + ); +} + +export interface Notification { + message: string; + description?: string | VNode; + type: MessageType; +} + +export type ValueOrFunction<T> = T | ((p: T) => T); +export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS'; diff --git a/packages/demobank-ui/src/components/picker/DatePicker.tsx b/packages/demobank-ui/src/components/picker/DatePicker.tsx new file mode 100644 index 000000000..94dbc9458 --- /dev/null +++ b/packages/demobank-ui/src/components/picker/DatePicker.tsx @@ -0,0 +1,356 @@ +/* + 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, Component } from 'preact'; + +interface Props { + closeFunction?: () => void; + dateReceiver?: (d: Date) => void; + initialDate?: Date; + years?: Array<number>; + opened?: boolean; +} +interface State { + displayedMonth: number; + displayedYear: number; + selectYearMode: boolean; + currentDate: Date; +} +const now = new Date(); + +const monthArrShortFull = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +const monthArrShort = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +const dayArr = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +const yearArr: number[] = []; + +// inspired by https://codepen.io/m4r1vs/pen/MOOxyE +export class DatePicker extends Component<Props, State> { + closeDatePicker() { + this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent + } + + /** + * Gets fired when a day gets clicked. + * @param {object} e The event thrown by the <span /> element clicked + */ + dayClicked(e: any) { + const element = e.target; // the actual element clicked + + if (element.innerHTML === '') return false; // don't continue if <span /> empty + + // get date from clicked element (gets attached when rendered) + const date = new Date(element.getAttribute('data-value')); + + // update the state + this.setState({ currentDate: date }); + this.passDateToParent(date); + } + + /** + * returns days in month as array + * @param {number} month the month to display + * @param {number} year the year to display + */ + getDaysByMonth(month: number, year: number) { + const calendar = []; + + const date = new Date(year, month, 1); // month to display + + const firstDay = new Date(year, month, 1).getDay(); // first weekday of month + const lastDate = new Date(year, month + 1, 0).getDate(); // last date of month + + let day: number | null = 0; + + // the calendar is 7*6 fields big, so 42 loops + for (let i = 0; i < 42; i++) { + if (i >= firstDay && day !== null) day = day + 1; + if (day !== null && day > lastDate) day = null; + + // append the calendar Array + calendar.push({ + day: day === 0 || day === null ? null : day, // null or number + date: day === 0 || day === null ? null : new Date(year, month, day), // null or Date() + today: + day === now.getDate() && + month === now.getMonth() && + year === now.getFullYear(), // boolean + }); + } + + return calendar; + } + + /** + * Display previous month by updating state + */ + displayPrevMonth() { + if (this.state.displayedMonth <= 0) + this.setState({ + displayedMonth: 11, + displayedYear: this.state.displayedYear - 1, + }); + else + this.setState({ + displayedMonth: this.state.displayedMonth - 1, + }); + + } + + /** + * Display next month by updating state + */ + displayNextMonth() { + if (this.state.displayedMonth >= 11) + this.setState({ + displayedMonth: 0, + displayedYear: this.state.displayedYear + 1, + }); + else + this.setState({ + displayedMonth: this.state.displayedMonth + 1, + }); + + } + + /** + * Display the selected month (gets fired when clicking on the date string) + */ + displaySelectedMonth() { + if (this.state.selectYearMode) + this.toggleYearSelector(); + else { + if (!this.state.currentDate) return false; + this.setState({ + displayedMonth: this.state.currentDate.getMonth(), + displayedYear: this.state.currentDate.getFullYear(), + }); + } + } + + toggleYearSelector() { + this.setState({ selectYearMode: !this.state.selectYearMode }); + } + + changeDisplayedYear(e: any) { + const element = e.target; + this.toggleYearSelector(); + this.setState({ + displayedYear: parseInt(element.innerHTML, 10), + displayedMonth: 0, + }); + } + + /** + * Pass the selected date to parent when 'OK' is clicked + */ + passSavedDateDateToParent() { + this.passDateToParent(this.state.currentDate); + } + passDateToParent(date: Date) { + if (typeof this.props.dateReceiver === 'function') + this.props.dateReceiver(date); + this.closeDatePicker(); + } + + componentDidUpdate() { + // if (this.state.selectYearMode) { + // document.getElementsByClassName('selected')[0].scrollIntoView(); // works in every browser incl. IE, replace with scrollIntoViewIfNeeded when browsers support it + // } + } + + constructor(props: any) { + super(props); + + this.closeDatePicker = this.closeDatePicker.bind(this); + this.dayClicked = this.dayClicked.bind(this); + this.displayNextMonth = this.displayNextMonth.bind(this); + this.displayPrevMonth = this.displayPrevMonth.bind(this); + this.getDaysByMonth = this.getDaysByMonth.bind(this); + this.changeDisplayedYear = this.changeDisplayedYear.bind(this); + this.passDateToParent = this.passDateToParent.bind(this); + this.toggleYearSelector = this.toggleYearSelector.bind(this); + this.displaySelectedMonth = this.displaySelectedMonth.bind(this); + + const initial = props.initialDate || now; + + this.state = { + currentDate: initial, + displayedMonth: initial.getMonth(), + displayedYear: initial.getFullYear(), + selectYearMode: false, + }; + } + + render() { + const { + currentDate, + displayedMonth, + displayedYear, + selectYearMode, + } = this.state; + + return ( + <div> + <div class={`datePicker ${this.props.opened && 'datePicker--opened'}`}> + <div class="datePicker--titles"> + <h3 + style={{ + color: selectYearMode + ? 'rgba(255,255,255,.87)' + : 'rgba(255,255,255,.57)', + }} + onClick={this.toggleYearSelector} + > + {currentDate.getFullYear()} + </h3> + <h2 + style={{ + color: !selectYearMode + ? 'rgba(255,255,255,.87)' + : 'rgba(255,255,255,.57)', + }} + onClick={this.displaySelectedMonth} + > + {dayArr[currentDate.getDay()]},{' '} + {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()} + </h2> + </div> + + {!selectYearMode && ( + <nav> + <span onClick={this.displayPrevMonth} class="icon"> + <i + style={{ transform: 'rotate(180deg)' }} + class="mdi mdi-forward" + /> + </span> + <h4> + {monthArrShortFull[displayedMonth]} {displayedYear} + </h4> + <span onClick={this.displayNextMonth} class="icon"> + <i class="mdi mdi-forward" /> + </span> + </nav> + )} + + <div class="datePicker--scroll"> + {!selectYearMode && ( + <div class="datePicker--calendar"> + <div class="datePicker--dayNames"> + {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( + <span key={i}>{day}</span> + ))} + </div> + + <div onClick={this.dayClicked} class="datePicker--days"> + {/* + Loop through the calendar object returned by getDaysByMonth(). + */} + + {this.getDaysByMonth( + this.state.displayedMonth, + this.state.displayedYear, + ).map((day) => { + let selected = false; + + if (currentDate && day.date) + selected = + currentDate.toLocaleDateString() === + day.date.toLocaleDateString(); + + return ( + <span + key={day.day} + class={ + (day.today ? 'datePicker--today ' : '') + + (selected ? 'datePicker--selected' : '') + } + disabled={!day.date} + data-value={day.date} + > + {day.day} + </span> + ); + })} + </div> + </div> + )} + + {selectYearMode && ( + <div class="datePicker--selectYear"> + {(this.props.years || yearArr).map((year) => ( + <span + key={year} + class={year === displayedYear ? 'selected' : ''} + onClick={this.changeDisplayedYear} + > + {year} + </span> + ))} + </div> + )} + </div> + </div> + + <div + class="datePicker--background" + onClick={this.closeDatePicker} + style={{ + display: this.props.opened ? 'block' : 'none', + }} + /> + </div> + ); + } +} + +for (let i = 2010; i <= now.getFullYear() + 10; i++) + yearArr.push(i); + diff --git a/packages/demobank-ui/src/components/picker/DurationPicker.stories.tsx b/packages/demobank-ui/src/components/picker/DurationPicker.stories.tsx new file mode 100644 index 000000000..5e9930522 --- /dev/null +++ b/packages/demobank-ui/src/components/picker/DurationPicker.stories.tsx @@ -0,0 +1,55 @@ +/* + 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/demobank-ui/src/components/picker/DurationPicker.tsx b/packages/demobank-ui/src/components/picker/DurationPicker.tsx new file mode 100644 index 000000000..542ff2f01 --- /dev/null +++ b/packages/demobank-ui/src/components/picker/DurationPicker.tsx @@ -0,0 +1,211 @@ +/* + 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 '../../scss/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 ss = 1000; + const ms = ss * 60; + const hs = ms * 60; + const ds = hs * 24; + const i18n = useTranslator(); + + return ( + <div class="rdp-picker"> + {days && ( + <DurationColumn + unit={i18n`days`} + max={99} + value={Math.floor(value / ds)} + onDecrease={value >= ds ? () => onChange(value - ds) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined} + onChange={(diff) => onChange(value + diff * ds)} + /> + )} + {hours && ( + <DurationColumn + unit={i18n`hours`} + max={23} + min={1} + value={Math.floor(value / hs) % 24} + onDecrease={value >= hs ? () => onChange(value - hs) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined} + onChange={(diff) => onChange(value + diff * hs)} + /> + )} + {minutes && ( + <DurationColumn + unit={i18n`minutes`} + max={59} + min={1} + value={Math.floor(value / ms) % 60} + onDecrease={value >= ms ? () => onChange(value - ms) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined} + onChange={(diff) => onChange(value + diff * ms)} + /> + )} + {seconds && ( + <DurationColumn + unit={i18n`seconds`} + max={59} + value={Math.floor(value / ss) % 60} + onDecrease={value >= ss ? () => onChange(value - ss) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined} + onChange={(diff) => onChange(value + diff * ss)} + /> + )} + </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 - 2}> + {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 + 2}> + {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}`; +} |