commit a106b127856d3311a09d2e837a0f24da7aa8e970 parent c4e194b661ed0d5bcdad830d4ae5c35130c0950c Author: Florian Dold <florian@dold.me> Date: Mon, 16 Jun 2025 14:49:10 +0200 implement multi_upload form, cleanup Diffstat:
33 files changed, 735 insertions(+), 553 deletions(-)
diff --git a/packages/aml-backoffice-ui/src/context/ui-forms.ts b/packages/aml-backoffice-ui/src/context/ui-forms.ts @@ -17,12 +17,12 @@ import { codecForUIForms, FormMetadata, + preloadedForms, UiForms, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, createContext, h, VNode } from "preact"; -import { useContext, useState, useEffect } from "preact/hooks"; -import { preloadedForms } from "../forms/index.js"; +import { useContext, useEffect, useState } from "preact/hooks"; /** * @@ -53,7 +53,7 @@ export const UiFormsProvider = ({ fetchUiForms((resp) => { setForms(resp.forms); }); - },[]); + }, []); const value = !forms || !forms.length ? pf : [...pf, ...forms]; diff --git a/packages/aml-backoffice-ui/src/forms/icons.tsx b/packages/aml-backoffice-ui/src/forms/icons.tsx @@ -1,25 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 } from "preact"; - -export const ChevronRightIcon = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> - <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> -</svg> - - -export const ArrowRightIcon = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> - <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /> -</svg> diff --git a/packages/aml-backoffice-ui/src/forms/index.ts b/packages/aml-backoffice-ui/src/forms/index.ts @@ -1,54 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { - form_generic_note, - form_gls_merchant_onboarding, - form_vqf_902_11_customer, - form_vqf_902_11_officer, - form_vqf_902_14, - form_vqf_902_1_customer, - form_vqf_902_1_officer, - form_vqf_902_4, - form_vqf_902_5, - form_vqf_902_9_customer, - form_vqf_902_9_officer, - form_challenger_sms, - form_challenger_postal, - form_challenger_email, - type FormMetadata, - type InternationalizationAPI, -} from "@gnu-taler/web-util/browser"; -import { form_gls_wallet_confirmation } from "../../../web-util/src/forms/gana/gls_wallet_confirmation.js"; - -export const preloadedForms: ( - i18n: InternationalizationAPI, -) => Array<FormMetadata> = (i18n) => [ - form_vqf_902_1_customer(i18n), - form_vqf_902_1_officer(i18n), - form_vqf_902_4(i18n), - form_vqf_902_5(i18n), - form_vqf_902_11_customer(i18n), - form_vqf_902_11_officer(i18n), - form_vqf_902_14(i18n), - form_vqf_902_9_customer(i18n), - form_vqf_902_9_officer(i18n), - form_generic_note(i18n), - form_challenger_email(i18n), - form_challenger_sms(i18n), - form_challenger_postal(i18n), - form_gls_merchant_onboarding(i18n), - form_gls_wallet_confirmation(i18n), -]; diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts @@ -1,88 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 type { - DoubleColumnFormDesign, - DoubleColumnFormSection, - InternationalizationAPI, - UIHandlerId, -} from "@gnu-taler/web-util/browser"; - -export const v1 = (i18n: InternationalizationAPI): DoubleColumnFormDesign => ({ - type: "double-column" as const, - sections: [ - { - title: i18n.str`Simple form`, - fields: [ - { - type: "textArea", - id: "comment" as UIHandlerId, - label: i18n.str`Comment`, - }, - ], - }, - resolutionSection(i18n), - ], - // behavior: function formBehavior( - // v: Partial<Simplest.Form>, - // ): FormState<Simplest.Form> { - // return { - // comment: { - // help: ((v.comment?.length ?? 0) > 100 ? "keep it short" : "") as TranslatedString, - // }, - // threshold: { - // disabled: v.state === TalerExchangeApi.AmlState.frozen, - // }, - // }; - // }, -}); - -export function resolutionSection( - i18n: InternationalizationAPI, -): DoubleColumnFormSection { - return { - title: i18n.str`Resolution`, - fields: [ - { - type: "choiceHorizontal", - id: "state" as UIHandlerId, - label: i18n.str`New state`, - converterId: "TalerExchangeApi.AmlState", - choices: [ - { - value: "frozen", - label: i18n.str`Frozen`, - }, - { - value: "pending", - label: i18n.str`Pending`, - }, - { - value: "normal", - label: i18n.str`Normal`, - }, - ], - }, - { - type: "amount", - id: "threshold" as UIHandlerId, - currency: "NETZBON", - converterId: "Taler.Amount", - label: i18n.str`New threshold`, - }, - ], - }; -} diff --git a/packages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx b/packages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx @@ -12,13 +12,13 @@ import { FormMetadata, FormUI, Loading, + preloadedForms, RouteDefinition, - useForm, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; +import { useFormMeta } from "../../../web-util/src/hooks/useForm.js"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; -import { preloadedForms } from "../forms/index.js"; import { useAccountInformation } from "../hooks/account.js"; import { Officer } from "./Officer.js"; @@ -172,7 +172,10 @@ function ShowForm({ }): VNode { const { i18n } = useTranslationContext(); const differentVersion = form.version !== expectedVersion; - const { model } = useForm(form.config, data); + + const formContext = "FORM_CONTEXT" in data ? data.FORM_CONTEXT : {}; + + const { model, design } = useFormMeta(form, formContext, data); return ( <Fragment> @@ -228,7 +231,7 @@ function ShowForm({ <i18n.Translate>Previous event</i18n.Translate> </a> </div> - <FormUI design={form.config} model={model} disabled /> + <FormUI design={design} model={model} disabled /> </Fragment> ); } diff --git a/packages/aml-backoffice-ui/src/pages/decision/Information.tsx b/packages/aml-backoffice-ui/src/pages/decision/Information.tsx @@ -1,3 +1,4 @@ +import { TalerFormAttributes } from "@gnu-taler/taler-util"; import { ErrorsSummary, FormDesign, @@ -7,15 +8,13 @@ import { InternationalizationAPI, onComponentUnload, UIFieldHandler, - UIHandlerId, useForm, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { useEffect, useState } from "preact/compat"; +import { useEffect, useMemo, useState } from "preact/compat"; import { useUiFormsContext } from "../../context/ui-forms.js"; import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; -import { TalerFormAttributes } from "@gnu-taler/taler-util"; /** * Mark for further investigation and explain decision @@ -75,10 +74,17 @@ function FillCustomerData({ name: "expiration", }; - /** - * Should we always add FORM ID and VERSION into data? - */ - const form = useForm<object>(theForm.config, request.attributes?.data ?? {}); + // FIXME: What about forms with context? + let formDesign: FormDesign; + if (typeof theForm.config === "function") { + const config = theForm.config; + formDesign = useMemo(() => config({}), [theForm]); + } else { + formDesign = theForm.config; + } + + /* FIXME: Should we always add FORM ID and VERSION into data? */ + const form = useForm<object>(formDesign, request.attributes?.data ?? {}); const data = { ...form.status.result, @@ -89,7 +95,7 @@ function FillCustomerData({ const errors = form.status.errors; onComponentUnload(() => { - updateRequest("unload info",{ + updateRequest("unload info", { attributes: { data, expiration, @@ -140,7 +146,7 @@ function FillCustomerData({ </div> <div> {!errors ? undefined : <ErrorsSummary errors={errors as any} />} - <FormUI design={theForm.config} model={form.model} /> + <FormUI design={formDesign} model={form.model} /> </div> </div> ); @@ -168,7 +174,7 @@ function SelectForm({ }, [fid]); onComponentUnload(() => { - updateRequest("unload info no form",{ + updateRequest("unload info no form", { attributes: undefined, }); }); diff --git a/packages/kyc-ui/src/Routing.tsx b/packages/kyc-ui/src/Routing.tsx @@ -86,13 +86,8 @@ function PublicRounting(): VNode { return <div>No access token</div>; } - return ( - <Start - token={currentToken} - /> - ); + return <Start token={currentToken} />; } - case "completed": { return <ChallengeCompleted />; } diff --git a/packages/kyc-ui/src/forms/index.ts b/packages/kyc-ui/src/forms/index.ts @@ -15,7 +15,6 @@ */ import { - AcceptTermOfServiceContext, acceptTos, form_generic_note, form_gls_merchant_onboarding, @@ -32,13 +31,12 @@ import { FormMetadata, InternationalizationAPI, } from "@gnu-taler/web-util/browser"; -import { nameAndDob } from "./nameAndBirthdate.js"; -import { simplest } from "./simplest.js"; +import { nameAndDob } from "../../../web-util/src/forms/gana/nameAndBirthdate.js"; +import { simplest } from "../../../web-util/src/forms/gana/simplest.js"; export const preloadedForms: ( i18n: InternationalizationAPI, - context: object | undefined, -) => Array<FormMetadata> = (i18n, context) => [ +) => Array<FormMetadata> = (i18n) => [ { label: i18n.str`Simple comment`, id: "__simple_comment", @@ -49,7 +47,7 @@ export const preloadedForms: ( label: i18n.str`Terms of Service`, id: "accept-tos", version: 1, - config: acceptTos(i18n, context as AcceptTermOfServiceContext), + config: (context: any) => acceptTos(i18n, context), }, { label: i18n.str`Name and birthdate`, diff --git a/packages/kyc-ui/src/forms/personal-info.ts b/packages/kyc-ui/src/forms/personal-info.ts @@ -1,68 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 type { - DoubleColumnFormDesign, - InternationalizationAPI, - UIHandlerId, -} from "@gnu-taler/web-util/browser"; - -const TALER_SCREEN_ID = 106; - -export const personalInfo = ( - i18n: InternationalizationAPI, -): DoubleColumnFormDesign => ({ - type: "double-column" as const, - sections: [ - { - title: i18n.str`Simple form`, - fields: [ - // { - // type: "absoluteTimeText", - // name: "dateOfDeath", - // label: i18n.str`Date of death`, - // pattern: "dd/MM/yyyy", - // // help: i18n.str`if deceased. format 'dd/MM/yyyy'`, - // help: i18n.str`if deceased'`, - // id: ".birthdate" as UIHandlerId, - // }, - { - type: "choiceStacked", - id: "trucker" as UIHandlerId, - required: true, - label: i18n.str`Are you a cross-border truck driver?`, - choices: [ - { - label: i18n.str`Yes`, - value: "yes", - }, - { - label: i18n.str`No`, - value: "no", - }, - ], - }, - { - type: "amount", - id: "money" as UIHandlerId, - currency: "YEIN", - converterId: "Taler.Amount", - label: i18n.str`How much is in your pockets?`, - }, - ], - }, - ], -}); diff --git a/packages/kyc-ui/src/forms/simplest.ts b/packages/kyc-ui/src/forms/simplest.ts @@ -1,80 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 type { - DoubleColumnFormDesign, - DoubleColumnFormSection, - InternationalizationAPI, - UIHandlerId, -} from "@gnu-taler/web-util/browser"; - -const TALER_SCREEN_ID = 110; - -export const simplest = ( - i18n: InternationalizationAPI, -): DoubleColumnFormDesign => ({ - type: "double-column" as const, - sections: [ - { - title: i18n.str`Simple form`, - fields: [ - { - type: "textArea", - id: "comment" as UIHandlerId, - label: i18n.str`Comment`, - }, - ], - }, - resolutionSection(i18n), - ], -}); - -export function resolutionSection( - i18n: InternationalizationAPI, -): DoubleColumnFormSection { - return { - title: i18n.str`Resolution`, - fields: [ - { - type: "choiceHorizontal", - id: "state" as UIHandlerId, - label: i18n.str`New state`, - converterId: "TalerExchangeApi.AmlState", - choices: [ - { - value: "frozen", - label: i18n.str`Frozen`, - }, - { - value: "pending", - label: i18n.str`Pending`, - }, - { - value: "normal", - label: i18n.str`Normal`, - }, - ], - }, - { - type: "amount", - id: "threshold" as UIHandlerId, - currency: "NETZBON", - converterId: "Taler.Amount", - label: i18n.str`New threshold`, - }, - ], - }; -} diff --git a/packages/kyc-ui/src/pages/FillForm.tsx b/packages/kyc-ui/src/pages/FillForm.tsx @@ -14,7 +14,6 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - AbsoluteTime, AccessToken, HttpStatusCode, KycRequirementInformation, @@ -34,17 +33,15 @@ import { LocalNotificationBanner, useAsyncAsHook, useExchangeApiContext, - useForm, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; +import { useFormMeta } from "../../../web-util/src/hooks/useForm.js"; import { usePreferences } from "../context/preferences.js"; import { useUiFormsContext } from "../context/ui-forms.js"; import { preloadedForms } from "../forms/index.js"; -const TALER_SCREEN_ID = 103; - type Props = { token: AccessToken; formId: string; @@ -56,18 +53,6 @@ type FormType = { form_id: string; [TalerFormAttributes.FORM_ID]: string; [TalerFormAttributes.FORM_VERSION]: number; - // state: TalerExchangeApi.AmlState; -}; - -type KycFormMetadata = { - id: string; - version: number; - when: AbsoluteTime; -}; - -type KycForm = { - header: KycFormMetadata; - payload: object; }; async function getContextByFormId( @@ -112,10 +97,12 @@ async function getContextByFormId( function ShowForm({ theForm, reqId, + formContext, onComplete, }: { theForm: FormMetadata; reqId: KycRequirementInformationId; + formContext: any; onComplete: () => void; }): VNode { const { lib } = useExchangeApiContext(); @@ -123,7 +110,11 @@ function ShowForm({ const [preferences] = usePreferences(); const { i18n } = useTranslationContext(); - const { model: handler, status } = useForm<FormType>(theForm.config, {}); + const { + model: handler, + status, + design, + } = useFormMeta<FormType>(theForm, formContext, {}); const validatedForm = status.status !== "ok" ? undefined : status.result; const submitHandler = @@ -156,7 +147,7 @@ function ShowForm({ <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> <LocalNotificationBanner notification={notification} /> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI model={handler} design={theForm.config} /> + <FormUI model={handler} design={design} /> </div> {preferences.showDebugInfo ? ( @@ -218,7 +209,7 @@ export function FillForm({ ); } const formContext = hook.response; - const theForm = searchForm(i18n, forms, formId, formContext); + const theForm = searchForm(i18n, forms, formId); const reqId = requirement.id; if (!theForm) { return ( @@ -237,21 +228,27 @@ export function FillForm({ ); } - return <ShowForm onComplete={onComplete} reqId={reqId} theForm={theForm} />; + return ( + <ShowForm + onComplete={onComplete} + reqId={reqId} + theForm={theForm} + formContext={formContext} + /> + ); } function searchForm( i18n: InternationalizationAPI, forms: FormMetadata[], formId: string, - context: object | undefined, ): FormMetadata | undefined { { const found = forms.find((v) => v.id === formId); if (found) return found; } { - const pf = preloadedForms(i18n, context); + const pf = preloadedForms(i18n); const found = pf.find((v) => v.id === formId); if (found) return found; } diff --git a/packages/kyc-ui/src/pages/Frame.tsx b/packages/kyc-ui/src/pages/Frame.tsx @@ -26,15 +26,13 @@ import { } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import { useNotifierContext } from "../context/notifier.js"; import { getAllBooleanPreferences, getLabelForPreferences, usePreferences, } from "../context/preferences.js"; import { useSettingsContext } from "../context/settings.js"; -import { useNotifierContext } from "../context/notifier.js"; - -const TALER_SCREEN_ID = 108; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; diff --git a/packages/kyc-ui/src/pages/MissingParams.tsx b/packages/kyc-ui/src/pages/MissingParams.tsx @@ -1,22 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { VNode, h } from "preact"; - -export function MissingParams() :VNode { - return <div> - missing params: {window.location.href} - </div> -} -\ No newline at end of file diff --git a/packages/kyc-ui/src/pages/NonceNotFound.tsx b/packages/kyc-ui/src/pages/NonceNotFound.tsx @@ -16,12 +16,6 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -const TALER_SCREEN_ID = 112; - -type Form = { - email: string; -}; - export function NonceNotFound(): VNode { const { i18n } = useTranslationContext(); diff --git a/packages/kyc-ui/src/pages/Start.tsx b/packages/kyc-ui/src/pages/Start.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { + AbsoluteTime, AccessToken, HttpStatusCode, KycRequirementInformation, @@ -33,10 +34,6 @@ import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useKycInfo } from "../hooks/kyc.js"; import { FillForm } from "./FillForm.js"; -import { useSessionState } from "../hooks/session.js"; -import { AbsoluteTime } from "@gnu-taler/taler-util"; - -const TALER_SCREEN_ID = 104; type Props = { token: AccessToken; @@ -135,6 +132,7 @@ function ShowReqList({ </Fragment> ); } + export function Start({ token }: Props): VNode { const [req, setReq] = useState<KycRequirementInformation>(); if (!req) { diff --git a/packages/kyc-ui/src/pages/TriggerForms.tsx b/packages/kyc-ui/src/pages/TriggerForms.tsx @@ -18,28 +18,27 @@ import { FormUI, LocalNotificationBanner, UIHandlerId, - useExchangeApiContext, - useForm, + useFormMeta, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { preloadedForms } from "../forms/index.js"; -const TALER_SCREEN_ID = 102; - type FormType = { form: string; }; + type Props = { formId?: string; }; + export function TriggerForms({ formId }: Props): VNode { const { i18n } = useTranslationContext(); const [notification, withErrorHandler, notify] = useLocalNotificationHandler(); - const pf = preloadedForms(i18n, {}); + const pf = preloadedForms(i18n); const theForm: FormMetadata = { id: "asd", @@ -64,9 +63,18 @@ export function TriggerForms({ formId }: Props): VNode { ], }, }; - const { model: handler, status } = useForm<FormType>(theForm.config, { - form: formId, - }); + + const { + model: handler, + status, + design, + } = useFormMeta<FormType>( + theForm, + {}, + { + form: formId, + }, + ); const selected = !status.result?.form ? undefined @@ -75,7 +83,7 @@ export function TriggerForms({ formId }: Props): VNode { <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> <LocalNotificationBanner notification={notification} /> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI model={handler} design={theForm.config} /> + <FormUI model={handler} design={design} /> </div> {!selected ? undefined : <ShowForm form={selected} />} </div> @@ -83,12 +91,12 @@ export function TriggerForms({ formId }: Props): VNode { } function ShowForm({ form }: { form: FormMetadata }) { - const { model: handler, status } = useForm<FormType>(form.config, {}); + const { model: handler, design } = useFormMeta<FormType>(form, {}, {}); return ( <Fragment> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI model={handler} design={form.config} /> + <FormUI model={handler} design={design} /> </div> </Fragment> ); diff --git a/packages/kyc-ui/src/pages/TriggerKyc.tsx b/packages/kyc-ui/src/pages/TriggerKyc.tsx @@ -34,21 +34,21 @@ import { LocalNotificationBanner, UIHandlerId, useExchangeApiContext, - useForm, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; - -const TALER_SCREEN_ID = 102; +import { useFormMeta } from "../../../web-util/src/hooks/useForm.js"; type FormType = { amount: AmountJson; }; + type Props = { onKycStarted: (token: AccessToken) => void; }; + export function TriggerKyc({ onKycStarted }: Props): VNode { const { i18n } = useTranslationContext(); const [notification, withErrorHandler, notify] = @@ -80,9 +80,17 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { }, }; - const { model: handler, status } = useForm<FormType>(theForm.config, { - amount: Amounts.parseOrThrow(`${config.config.currency}:1000000`), - }); + const { + model: handler, + status, + design, + } = useFormMeta<FormType>( + theForm, + {}, + { + amount: Amounts.parseOrThrow(`${config.config.currency}:1000000`), + }, + ); const accountPromise = useMemo(async () => { const resp = await lib.exchange.getSeed(); @@ -190,7 +198,7 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> <LocalNotificationBanner notification={notification} /> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI model={handler} design={theForm.config} /> + <FormUI model={handler} design={design} /> </div> <div class="mt-6 flex items-center justify-end gap-x-6"> diff --git a/packages/kyc-ui/tsconfig.json b/packages/kyc-ui/tsconfig.json @@ -27,5 +27,5 @@ "path": "../web-util/" } ], - "include": ["src/**/*"] + "include": ["src/**/*", "../web-util/src/forms/gana/nameAndBirthdate.ts", "../web-util/src/forms/gana/personal-info.ts", "../web-util/src/forms/gana/simplest.ts"] } diff --git a/packages/taler-util/src/taler-form-attributes.ts b/packages/taler-util/src/taler-form-attributes.ts @@ -740,6 +740,18 @@ export const TalerFormAttributes = { * GANA Type: String */ NOTE_TEXT: "NOTE_TEXT" as const, + /** + * Description: Context for form submitted by the user. + * + * GANA Type: JSON + */ + FORM_CONTEXT: "FORM_CONTEXT" as const, + /** + * Description: Map of uploaded files. + * + * GANA Type: MapStrFile + */ + FILE_MAP: "FILE_MAP" as const, } diff --git a/packages/web-util/src/forms/TimePicker.tsx b/packages/web-util/src/forms/TimePicker.tsx @@ -1,109 +1,244 @@ -import { AbsoluteTime } from "@gnu-taler/taler-util" -import { getHours, getMinutes, getSeconds, setHours } from "date-fns" -import { Fragment, VNode, h } from "preact" -import { useTranslationContext } from "../index.browser.js" +/* + This file is part of GNU Taler + (C) 2025 Taler Systems S.A. -export function TimePicker({ value, onChange, onConfirm }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void, onConfirm: () => void }): VNode { - const date = !value ? new Date() : new Date(AbsoluteTime.toStampMs(value)) - const hours = getHours(date) % 12 - const minutes = getMinutes(date) - const seconds = getSeconds(date) + 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. - const { i18n } = useTranslationContext() + 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. - return <Fragment> - <div class="flex flex-col bg-white rounded-t-sm justify-around" > - {/* time selection */} - <div id="" class="bg-[#3b71ca] dark:bg-zinc-700 h-24 rounded-t-lg p-12 flex flex-row items-center justify-center"> - <div class="flex w-full justify-evenly"> - <div class=""> - <span class="relative h-full"> - <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " - style="pointer-events: none;"> - {new String(hours).padStart(2, "0")} - </button> - </span> - <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span> - <span class="relative h-full"> - <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " > - {new String(minutes).padStart(2, "0")} + 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 { AbsoluteTime } from "@gnu-taler/taler-util"; +import { getHours, getMinutes, getSeconds, setHours } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useTranslationContext } from "../index.browser.js"; + +export function TimePicker({ + value, + onChange, + onConfirm, +}: { + value: AbsoluteTime | undefined; + onChange: (v: AbsoluteTime) => void; + onConfirm: () => void; +}): VNode { + const date = !value ? new Date() : new Date(AbsoluteTime.toStampMs(value)); + const hours = getHours(date) % 12; + const minutes = getMinutes(date); + const seconds = getSeconds(date); + + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <div class="flex flex-col bg-white rounded-t-sm justify-around"> + {/* time selection */} + <div + id="" + class="bg-[#3b71ca] dark:bg-zinc-700 h-24 rounded-t-lg p-12 flex flex-row items-center justify-center" + > + <div class="flex w-full justify-evenly"> + <div class=""> + <span class="relative h-full"> + <button + type="button" + class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " + style="pointer-events: none;" + > + {new String(hours).padStart(2, "0")} + </button> + </span> + <span + type="button" + class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " + > + : + </span> + <span class="relative h-full"> + <button + type="button" + class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " + > + {new String(minutes).padStart(2, "0")} + </button> + </span> + <span + type="button" + class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " + > + : + </span> + <span class="relative h-full"> + <button + type="button" + class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " + > + {new String(seconds).padStart(2, "0")} + </button> + </span> + </div> + <div class="flex flex-col justify-center text-[18px] text-[#ffffff8a] "> + <button + type="button" + class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" + > + AM </button> - </span> - <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span> - <span class="relative h-full"> - <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " > - {new String(seconds).padStart(2, "0")} + <button + type="button" + class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" + > + PM </button> - </span> - </div> - <div class="flex flex-col justify-center text-[18px] text-[#ffffff8a] "> - <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" > - AM - </button> - <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" > - PM - </button> + </div> </div> </div> - </div> - {/* clock */} - <div id="" class="mt-2 min-w-[310px] max-w-[325px] min-h-[305px] overflow-x-hidden h-full flex justify-center mx-auto flex-col items-center dark:bg-zinc-500" > - <div class="relative rounded-[100%] w-[260px] h-[260px] cursor-default my-0 mx-auto bg-[#00000012] dark:bg-zinc-600/50 animate-[show-up-clock_350ms_linear]" > + {/* clock */} + <div + id="" + class="mt-2 min-w-[310px] max-w-[325px] min-h-[305px] overflow-x-hidden h-full flex justify-center mx-auto flex-col items-center dark:bg-zinc-500" + > + <div class="relative rounded-[100%] w-[260px] h-[260px] cursor-default my-0 mx-auto bg-[#00000012] dark:bg-zinc-600/50 animate-[show-up-clock_350ms_linear]"> + <span class="top-1/2 left-1/2 w-[6px] h-[6px] -translate-y-1/2 -translate-x-1/2 rounded-[50%] bg-[#3b71ca] absolute"></span> + <div + class="bg-[#3b71ca] bottom-1/2 h-2/5 left-[calc(50%-1px)] rtl:!left-auto origin-[center_bottom_0] rtl:!origin-[50%_50%_0] w-[2px] absolute" + style={{ transform: "rotateZ(60deg)", height: "calc(35% + 1px)" }} + > + {/* <div class="-top-[21px] -left-[15px] w-[4px] border-[14px] border-solid border-[#3b71ca] h-[4px] box-content rounded-[100%] absolute" style="background-color: rgb(25, 118, 210);"></div> */} + </div> - <span class="top-1/2 left-1/2 w-[6px] h-[6px] -translate-y-1/2 -translate-x-1/2 rounded-[50%] bg-[#3b71ca] absolute" ></span> - <div class="bg-[#3b71ca] bottom-1/2 h-2/5 left-[calc(50%-1px)] rtl:!left-auto origin-[center_bottom_0] rtl:!origin-[50%_50%_0] w-[2px] absolute" style={{ transform: "rotateZ(60deg)", height: "calc(35% + 1px)" }}> - {/* <div class="-top-[21px] -left-[15px] w-[4px] border-[14px] border-solid border-[#3b71ca] h-[4px] box-content rounded-[100%] absolute" style="background-color: rgb(25, 118, 210);"></div> */} + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 12).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 114px; bottom: 224px;" + > + <span>0</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 1).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 169px; bottom: 209.263px;" + > + <span>1</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 2).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + data-selected={true} + style="left: 209.263px; bottom: 169px;" + > + <span>2</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 3).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 224px; bottom: 114px;" + > + <span>3</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 4).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 209.263px; bottom: 59px;" + > + <span>4</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 5).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 169px; bottom: 18.7372px;" + > + <span>5</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 6).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 114px; bottom: 4px;" + > + <span>6</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 7).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 59px; bottom: 18.7372px;" + > + <span>7</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 8).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 18.7372px; bottom: 59px;" + > + <span>8</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 9).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 4px; bottom: 114px;" + > + <span>9</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 10).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 18.7372px; bottom: 169px;" + > + <span>10</span> + </span> + <span + onClick={() => + onChange(AbsoluteTime.fromStampMs(setHours(date, 11).getTime())) + } + class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" + style="left: 59px; bottom: 209.263px;" + > + <span>11</span> + </span> </div> - - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 12).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 224px;"> - <span>0</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 1).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 209.263px;"> - <span >1</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 2).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" data-selected={true} style="left: 209.263px; bottom: 169px;" > - <span >2</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 3).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 224px; bottom: 114px;"> - <span >3</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 4).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 209.263px; bottom: 59px;"> - <span >4</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 5).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 18.7372px;"> - <span >5</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 6).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 4px;"> - <span >6</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 7).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 18.7372px;"> - <span >7</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 8).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 59px;"> - <span >8</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 9).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 4px; bottom: 114px;"> - <span >9</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 10).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 169px;"> - <span >10</span> - </span> - <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 11).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 209.263px;"> - <span >11</span> - </span> </div> </div> - </div> - <div id="" class="rounded-b-lg flex justify-between items-center w-full h-[56px] px-[12px] bg-white dark:bg-zinc-500"> - <div class="w-full flex justify-end"> - <button - type="submit" - onClick={onConfirm} - class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - > - <i18n.Translate>Confirm</i18n.Translate> - </button> + <div + id="" + class="rounded-b-lg flex justify-between items-center w-full h-[56px] px-[12px] bg-white dark:bg-zinc-500" + > + <div class="w-full flex justify-end"> + <button + type="submit" + onClick={onConfirm} + class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> </div> - </div> - </Fragment> + </Fragment> + ); } diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -34,9 +34,7 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; -export type FormDesign = - | DoubleColumnFormDesign - | SingleColumnFormDesign; +export type FormDesign = DoubleColumnFormDesign | SingleColumnFormDesign; /** * Form with multiple sections. @@ -595,7 +593,7 @@ export type FormMetadata = { description?: string; id: string; version: number; - config: FormDesign; + config: FormDesign | ((context: any) => FormDesign); }; export interface UiForms { diff --git a/packages/web-util/src/forms/gana/generic_upload.ts b/packages/web-util/src/forms/gana/generic_upload.ts @@ -22,7 +22,7 @@ import { import { TalerFormAttributes } from "@gnu-taler/taler-util"; /** - * Design of the vqf_902_1_officer form. + * Design of the generic upload form. */ export function form_generic_upload( i18n: InternationalizationAPI, diff --git a/packages/web-util/src/forms/gana/index.stories.ts b/packages/web-util/src/forms/gana/index.stories.ts @@ -14,3 +14,5 @@ export * as vqf_902_4_stories from "./VQF_902_4.stories.js"; export * as vqf_902_5_stories from "./VQF_902_5.stories.js"; export * as vqf_902_9_customer_stories from "./VQF_902_9_customer.stories.js"; export * as vqf_902_9_officer_stories from "./VQF_902_9_officer.stories.js"; + +export * as multi_upload_stories from "./multi_upload.stories.js"; diff --git a/packages/web-util/src/forms/gana/multi_upload.stories.tsx b/packages/web-util/src/forms/gana/multi_upload.stories.tsx @@ -0,0 +1,47 @@ +/* + This file is part of GNU Taler + (C) 2022 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 Florian Dold <dold@taler.net> + */ + +import { i18n, setupI18n } from "@gnu-taler/taler-util"; +import * as tests from "../../tests/hook.js"; +import { DefaultForm } from "../forms-ui.js"; +import { design_multi_upload } from "./multi_upload.js"; + +setupI18n("en", {}); + +export const EmptyForm = tests.createExample(DefaultForm, { + initial: {}, + design: design_multi_upload(i18n, { + REQUESTED_FILES: [ + { + REQUESTED_FILE_ID: "doc_xyz", + REQUESTED_FILE_TITLE: "Doc XYZ", + REQUESTED_FILE_DESCRIPTION: "Please upload document XYZ", + }, + { + REQUESTED_FILE_ID: "doc_abc", + REQUESTED_FILE_TITLE: "Doc ABC", + REQUESTED_FILE_DESCRIPTION: "Please upload document ABC", + }, + ], + }), +}); + +export default { title: "multi_upload" }; diff --git a/packages/web-util/src/forms/gana/multi_upload.ts b/packages/web-util/src/forms/gana/multi_upload.ts @@ -0,0 +1,66 @@ +/* + This file is part of GNU Taler + (C) 2025 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero 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 Affero Public License for more details. + + You should have received a copy of the GNU Affero Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + DoubleColumnFormDesign, + FormMetadata, + InternationalizationAPI, +} from "../../index.browser.js"; + +export interface MultiUploadContext { + REQUESTED_FILES: { + REQUESTED_FILE_ID: string; + REQUESTED_FILE_TITLE: string; + REQUESTED_FILE_DESCRIPTION: string; + }[]; +} + +export const form_multi_upload = ( + i18n: InternationalizationAPI, +): FormMetadata => ({ + label: i18n.str`Upload multiple documents`, + description: i18n.str`Upload multiple documents.`, + id: "multi_upload", + version: 1, + config: (context: any) => design_multi_upload(i18n, context), +}); + +/** + * Form for uploading multiple documents. + */ +export function design_multi_upload( + i18n: InternationalizationAPI, + context: MultiUploadContext, +): DoubleColumnFormDesign { + return { + type: "double-column", + sections: context.REQUESTED_FILES.map((x, i) => { + return { + title: i18n.str`Document upload (${x.REQUESTED_FILE_TITLE})`, + description: `${x.REQUESTED_FILE_DESCRIPTION}`, + fields: [ + { + id: `FILE_MAP.${x.REQUESTED_FILE_ID}`, + label: i18n.str`File (PDF)`, + type: "file", + accept: "application/pdf", + required: false, + }, + ], + }; + }), + }; +} diff --git a/packages/kyc-ui/src/forms/nameAndBirthdate.ts b/packages/web-util/src/forms/gana/nameAndBirthdate.ts diff --git a/packages/web-util/src/forms/gana/personal-info.ts b/packages/web-util/src/forms/gana/personal-info.ts @@ -0,0 +1,66 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 type { + DoubleColumnFormDesign, + InternationalizationAPI, + UIHandlerId, +} from "@gnu-taler/web-util/browser"; + +export const personalInfo = ( + i18n: InternationalizationAPI, +): DoubleColumnFormDesign => ({ + type: "double-column" as const, + sections: [ + { + title: i18n.str`Simple form`, + fields: [ + // { + // type: "absoluteTimeText", + // name: "dateOfDeath", + // label: i18n.str`Date of death`, + // pattern: "dd/MM/yyyy", + // // help: i18n.str`if deceased. format 'dd/MM/yyyy'`, + // help: i18n.str`if deceased'`, + // id: ".birthdate" as UIHandlerId, + // }, + { + type: "choiceStacked", + id: "trucker" as UIHandlerId, + required: true, + label: i18n.str`Are you a cross-border truck driver?`, + choices: [ + { + label: i18n.str`Yes`, + value: "yes", + }, + { + label: i18n.str`No`, + value: "no", + }, + ], + }, + { + type: "amount", + id: "money" as UIHandlerId, + currency: "YEIN", + converterId: "Taler.Amount", + label: i18n.str`How much is in your pockets?`, + }, + ], + }, + ], +}); diff --git a/packages/web-util/src/forms/gana/simplest.ts b/packages/web-util/src/forms/gana/simplest.ts @@ -0,0 +1,78 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 type { + DoubleColumnFormDesign, + DoubleColumnFormSection, + InternationalizationAPI, + UIHandlerId, +} from "@gnu-taler/web-util/browser"; + +export const simplest = ( + i18n: InternationalizationAPI, +): DoubleColumnFormDesign => ({ + type: "double-column" as const, + sections: [ + { + title: i18n.str`Simple form`, + fields: [ + { + type: "textArea", + id: "comment" as UIHandlerId, + label: i18n.str`Comment`, + }, + ], + }, + resolutionSection(i18n), + ], +}); + +export function resolutionSection( + i18n: InternationalizationAPI, +): DoubleColumnFormSection { + return { + title: i18n.str`Resolution`, + fields: [ + { + type: "choiceHorizontal", + id: "state" as UIHandlerId, + label: i18n.str`New state`, + converterId: "TalerExchangeApi.AmlState", + choices: [ + { + value: "frozen", + label: i18n.str`Frozen`, + }, + { + value: "pending", + label: i18n.str`Pending`, + }, + { + value: "normal", + label: i18n.str`Normal`, + }, + ], + }, + { + type: "amount", + id: "threshold" as UIHandlerId, + currency: "NETZBON", + converterId: "Taler.Amount", + label: i18n.str`New threshold`, + }, + ], + }; +} diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts @@ -1,17 +1,17 @@ +export * as a5 from "./fields/InputAbsoluteTime.stories.js"; export * as a1 from "./fields/InputAmount.stories.js"; export * as a2 from "./fields/InputArray.stories.js"; export * as a3 from "./fields/InputChoiceHorizontal.stories.js"; export * as a4 from "./fields/InputChoiceStacked.stories.js"; -export * as a5 from "./fields/InputAbsoluteTime.stories.js"; +export * as a15 from "./fields/InputDuration.stories.js"; +export * as a16 from "./fields/InputDurationText.stories.js"; export * as a6 from "./fields/InputFile.stories.js"; export * as a7 from "./fields/InputInteger.stories.js"; +export * as a18 from "./fields/InputIsoDate.stories.js"; +export * as a14 from "./fields/InputSecret.stories.js"; export * as a9 from "./fields/InputSelectMultiple.stories.js"; export * as a10 from "./fields/InputSelectOne.stories.js"; export * as a11 from "./fields/InputText.stories.js"; export * as a12 from "./fields/InputTextArea.stories.js"; export * as a13 from "./fields/InputToggle.stories.js"; -export * as a14 from "./fields/InputSecret.stories.js"; -export * as a15 from "./fields/InputDuration.stories.js"; -export * as a16 from "./fields/InputDurationText.stories.js"; -export * as a18 from "./fields/InputIsoDate.stories.js"; export * as a17 from "./gana/index.stories.js"; diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts @@ -1,3 +1,41 @@ +/* + 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 { InternationalizationAPI } from "@gnu-taler/taler-util"; +import { FormMetadata } from "./forms-types.js"; +import { acceptTos } from "./gana/accept-tos.js"; +import { form_challenger_email } from "./gana/challenger_email.js"; +import { form_challenger_postal } from "./gana/challenger_postal.js"; +import { form_challenger_sms } from "./gana/challenger_sms.js"; +import { form_generic_note } from "./gana/generic_note.js"; +import { form_gls_merchant_onboarding } from "./gana/gls_merchant_onboarding.js"; +import { form_gls_wallet_confirmation } from "./gana/gls_wallet_confirmation.js"; +import { form_multi_upload } from "./gana/multi_upload.js"; +import { nameAndDob } from "./gana/nameAndBirthdate.js"; +import { simplest } from "./gana/simplest.js"; +import { form_vqf_902_11_customer } from "./gana/VQF_902_11_customer.js"; +import { form_vqf_902_11_officer } from "./gana/VQF_902_11_officer.js"; +import { form_vqf_902_14 } from "./gana/VQF_902_14.js"; +import { form_vqf_902_1_customer } from "./gana/VQF_902_1_customer.js"; +import { form_vqf_902_1_officer } from "./gana/VQF_902_1_officer.js"; +import { form_vqf_902_4 } from "./gana/VQF_902_4.js"; +import { form_vqf_902_5 } from "./gana/VQF_902_5.js"; +import { form_vqf_902_9_customer } from "./gana/VQF_902_9_customer.js"; +import { form_vqf_902_9_officer } from "./gana/VQF_902_9_officer.js"; + export * from "./Calendar.js"; export * from "./Caption.js"; export * from "./Dialog.js"; @@ -21,10 +59,10 @@ export * from "./forms-types.js"; export * from "./forms-ui.js"; export * from "./gana/accept-tos.js"; -export * from "./gana/generic_note.js"; export * from "./gana/challenger_email.js"; export * from "./gana/challenger_postal.js"; export * from "./gana/challenger_sms.js"; +export * from "./gana/generic_note.js"; export * from "./gana/gls_merchant_onboarding.js"; export * from "./gana/gls_wallet_confirmation.js"; @@ -42,3 +80,54 @@ export * from "./gana/VQF_902_9_officer.js"; export * from "./Group.js"; export * from "./HtmlIframe.js"; export * from "./TimePicker.js"; + +export const preloadedForms: ( + i18n: InternationalizationAPI, +) => Array<FormMetadata> = (i18n) => [ + { + label: i18n.str`Simple comment`, + id: "__simple_comment", + version: 1, + config: simplest(i18n), + }, + { + label: i18n.str`Terms of Service`, + id: "accept-tos", + version: 1, + config: (context: any) => acceptTos(i18n, context), + }, + { + label: i18n.str`Name and birthdate`, + id: "name_and_dob", + version: 1, + config: nameAndDob(i18n), + }, + form_vqf_902_1_customer(i18n), + form_vqf_902_1_officer(i18n), + form_vqf_902_4(i18n), + form_vqf_902_5(i18n), + form_vqf_902_11_customer(i18n), + form_vqf_902_11_officer(i18n), + form_vqf_902_14(i18n), + form_vqf_902_9_customer(i18n), + form_vqf_902_9_officer(i18n), + form_generic_note(i18n), + form_challenger_email(i18n), + form_challenger_sms(i18n), + form_challenger_postal(i18n), + form_gls_merchant_onboarding(i18n), + form_gls_wallet_confirmation(i18n), + form_multi_upload(i18n), + form_vqf_902_1_customer(i18n), + form_vqf_902_1_officer(i18n), + form_vqf_902_4(i18n), + form_vqf_902_5(i18n), + form_vqf_902_9_customer(i18n), + form_vqf_902_9_officer(i18n), + form_vqf_902_11_customer(i18n), + form_vqf_902_11_officer(i18n), + form_vqf_902_14(i18n), + form_generic_note(i18n), + form_gls_merchant_onboarding(i18n), + form_gls_wallet_confirmation(i18n), +]; diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts @@ -1,26 +1,27 @@ +export * from "./useAsync.js"; +export { + HookError, + HookGenericError, + HookOk, + HookOperationalError, + HookResponse, + HookResponseWithRetry, + useAsyncAsHook, +} from "./useAsyncAsHook.js"; +export { + FormErrors, + FormValues, + RecursivePartial, + undefinedIfEmpty, + useForm, + useFormMeta, +} from "./useForm.js"; export { useLang } from "./useLang.js"; export { - useLocalStorage, buildStorageKey, StorageKey, StorageState, + useLocalStorage, } from "./useLocalStorage.js"; export { useMemoryStorage } from "./useMemoryStorage.js"; export * from "./useNotifications.js"; -export { - useForm, - RecursivePartial, - FormValues, - FormErrors, - undefinedIfEmpty, -} from "./useForm.js"; -export { - useAsyncAsHook, - HookError, - HookOk, - HookResponse, - HookResponseWithRetry, - HookGenericError, - HookOperationalError, -} from "./useAsyncAsHook.js"; -export * from "./useAsync.js"; diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -21,9 +21,10 @@ import { TalerExchangeApi, TranslatedString, } from "@gnu-taler/taler-util"; -import { useState } from "preact/hooks"; +import { useMemo, useState } from "preact/hooks"; import { FormDesign, + FormMetadata, InternationalizationAPI, UIFieldHandler, UIFormElementConfig, @@ -131,12 +132,31 @@ export type FormStatus<T> = * FIMXE: Consider renaming this to FormModel and folding the current FormModel into it. */ export type FormState<T> = { + design: FormDesign; model: FormModel; status: FormStatus<T>; update: (f: FormValues<T>) => void; }; /** + * Hook to instantiate a form from its metadata. + */ +export function useFormMeta<T>( + form: FormMetadata, + formContext: any, + initialValue: RecursivePartial<FormValues<T>>, +): FormState<T> { + let formDesign: FormDesign; + if (typeof form.config === "function") { + const config = form.config; + formDesign = useMemo(() => config(formContext), [form, formContext]); + } else { + formDesign = form.config; + } + return useForm(formDesign, initialValue); +} + +/** * Hook to instantiate a form from its design. */ export function useForm<T>( @@ -166,6 +186,7 @@ export function useForm<T>( update: (f) => { formUpdateHandler(f as any); }, + design, }; } diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts @@ -1,12 +1,12 @@ +export * from "./components/index.js"; +export * from "./context/index.js"; +export * from "./forms/index.js"; export * from "./hooks/index.js"; -export * from "./utils/request.js"; +export { parseGroupImport, renderStories } from "./stories-utils.js"; +export { decodeCrockFromURI, encodeCrockForURI } from "./utils/base64.js"; export * from "./utils/http-impl.browser.js"; export * from "./utils/http-impl.sw.js"; export * from "./utils/observable.js"; +export * from "./utils/request.js"; export * from "./utils/route.js"; export * from "./utils/select-ui-lists.js"; -export * from "./context/index.js"; -export * from "./components/index.js"; -export * from "./forms/index.js"; -export { encodeCrockForURI, decodeCrockFromURI } from "./utils/base64.js"; -export { renderStories, parseGroupImport } from "./stories-utils.js";