From 3f9110d3255951705ed987c686f17c4b41ee5183 Mon Sep 17 00:00:00 2001 From: ap-justin <89639563+ap-justin@users.noreply.github.com> Date: Mon, 8 Jan 2024 17:20:34 +0800 Subject: [PATCH] BG-1019: Bank form to use new wise-proxy (#2631) * container * requirements file * wise currencies * expected funds * requirements query * types * fields * order to preserve state * validation error * async validation * radio type * also return quote id * handle overflow * refresh endpoint * styles * required * dual optional * focus * date * style payment option * rename type * uniform style * remove popover * remove unused * use component classes * name * open sans * load text * required amount * create recipient * remove font-work * unregister to remove residual state * bank statement input * bank statement file * remove unused * prune props * move currency selector * update wise currencies * rename * expected funds * clean bank details * Recipient details form * recipient details form * remove unused * remove unused * currency selector * pass form buttons * setup onsubmit * use in registration * use updated * finished and reset * fix error * get file previews * updates * navigate * remove unused service * remove unused * disabled form * delay focus * remove disabled --- src/components/BankDetails/BankDetails.tsx | 103 +---- .../BankDetails/CurrencyOptions.tsx | 56 +++ .../BankDetails/CurrencySelector.tsx | 74 +--- src/components/BankDetails/ExpectedFunds.tsx | 66 +--- .../AccountRequirementsSelector.tsx | 42 -- .../RecipientDetails/RecipientDetails.tsx | 161 +++----- .../RecipientDetailsForm/Form.tsx | 97 ----- .../RecipientDetailsForm.tsx | 373 +++++++++++++++--- .../RecipientDetailsForm/RequirementField.tsx | 117 ------ .../RecipientDetailsForm/constants.ts | 5 - .../formToCreateRecipientRequest.ts | 79 ---- .../createFieldSchema.ts | 62 --- .../useRecipientDetailsForm/createSchema.ts | 60 --- .../useRecipientDetailsForm/index.ts | 1 - .../useRecipientDetailsForm.ts | 26 -- .../RecipientDetails/helpers/dot.ts | 53 --- .../helpers/getDefaultValues.ts | 81 ---- .../RecipientDetails/helpers/index.ts | 3 - .../RecipientDetails/helpers/isCountry.ts | 9 - .../RecipientDetails/helpers/isTextType.ts | 12 - .../BankDetails/RecipientDetails/types.ts | 35 -- .../useRecipientDetails/getDefaultValues.ts | 82 ---- .../useRecipientDetails/index.ts | 1 - .../useRecipientDetails/mergeRequirements.ts | 161 -------- .../useRecipientDetails.ts | 158 -------- .../useRecipientDetails/useStateReducer.ts | 142 ------- .../BankDetails/UpdateDetailsButton.tsx | 47 --- src/components/BankDetails/index.ts | 1 + src/components/BankDetails/types.ts | 20 +- src/components/BankDetails/useCurrencies.ts | 53 --- src/pages/Admin/Charity/Banking/Banking.tsx | 44 +-- .../Admin/Charity/Banking/FormButtons.tsx | 28 -- .../Registration/Steps/Banking/Banking.tsx | 77 +++- .../Steps/Banking/FormButtons.tsx | 87 +--- .../Registration/Steps/Banking/useSubmit.ts | 35 +- src/services/aws/bankDetails.ts | 113 ------ src/services/aws/wise.ts | 86 +++- src/types/aws/ap/bankDetails.ts | 13 + 38 files changed, 691 insertions(+), 1972 deletions(-) create mode 100644 src/components/BankDetails/CurrencyOptions.tsx delete mode 100644 src/components/BankDetails/RecipientDetails/AccountRequirementsSelector.tsx delete mode 100644 src/components/BankDetails/RecipientDetails/RecipientDetailsForm/Form.tsx delete mode 100644 src/components/BankDetails/RecipientDetails/RecipientDetailsForm/RequirementField.tsx delete mode 100644 src/components/BankDetails/RecipientDetails/RecipientDetailsForm/constants.ts delete mode 100644 src/components/BankDetails/RecipientDetails/RecipientDetailsForm/formToCreateRecipientRequest.ts delete mode 100644 src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/createFieldSchema.ts delete mode 100644 src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/createSchema.ts delete mode 100644 src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/index.ts delete mode 100644 src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/useRecipientDetailsForm.ts delete mode 100644 src/components/BankDetails/RecipientDetails/helpers/dot.ts delete mode 100644 src/components/BankDetails/RecipientDetails/helpers/getDefaultValues.ts delete mode 100644 src/components/BankDetails/RecipientDetails/helpers/index.ts delete mode 100644 src/components/BankDetails/RecipientDetails/helpers/isCountry.ts delete mode 100644 src/components/BankDetails/RecipientDetails/helpers/isTextType.ts delete mode 100644 src/components/BankDetails/RecipientDetails/types.ts delete mode 100644 src/components/BankDetails/RecipientDetails/useRecipientDetails/getDefaultValues.ts delete mode 100644 src/components/BankDetails/RecipientDetails/useRecipientDetails/index.ts delete mode 100644 src/components/BankDetails/RecipientDetails/useRecipientDetails/mergeRequirements.ts delete mode 100644 src/components/BankDetails/RecipientDetails/useRecipientDetails/useRecipientDetails.ts delete mode 100644 src/components/BankDetails/RecipientDetails/useRecipientDetails/useStateReducer.ts delete mode 100644 src/components/BankDetails/UpdateDetailsButton.tsx delete mode 100644 src/components/BankDetails/useCurrencies.ts delete mode 100644 src/services/aws/bankDetails.ts diff --git a/src/components/BankDetails/BankDetails.tsx b/src/components/BankDetails/BankDetails.tsx index 0018f8a3b8..7671a7fed3 100644 --- a/src/components/BankDetails/BankDetails.tsx +++ b/src/components/BankDetails/BankDetails.tsx @@ -1,113 +1,52 @@ -import { ComponentType, useState } from "react"; -import { FormButtonsProps } from "./types"; -import { CreateRecipientRequest } from "types/aws"; -import { FileDropzoneAsset } from "types/components"; +import { useState } from "react"; +import { IFormButtons, OnSubmit } from "./types"; +import { WiseCurrency } from "types/aws"; import Divider from "components/Divider"; -import LoaderRing from "components/LoaderRing"; -import useDebounce from "hooks/useDebounce"; -import { isEmpty } from "helpers"; -import { GENERIC_ERROR_MESSAGE } from "constants/common"; +import useDebouncer from "hooks/useDebouncer"; import CurrencySelector from "./CurrencySelector"; import ExpectedFunds from "./ExpectedFunds"; import RecipientDetails from "./RecipientDetails"; -import UpdateDetailsButton from "./UpdateDetailsButton"; -import useCurrencies from "./useCurrencies"; /** * Denominated in USD */ -const DEFAULT_EXPECTED_MONTHLY_DONATIONS_AMOUNT = 1000; +const DEFAULT_EXPECTED_MONTHLY_DONATIONS_AMOUNT = "1000"; type Props = { - shouldUpdate: boolean; - onInitiateUpdate: () => void; - isSubmitting: boolean; - FormButtons: ComponentType; - onSubmit: ( - request: CreateRecipientRequest, - bankStatementFile: FileDropzoneAsset, - isDirty: boolean - ) => Promise; + FormButtons: IFormButtons; + onSubmit: OnSubmit; }; -export default function BankDetails({ - shouldUpdate, - onInitiateUpdate, - ...props -}: Props) { - if (!shouldUpdate) { - return ( -
- - -
- ); - } - - return ; -} - -function Content({ - FormButtons, - isSubmitting, - onSubmit, -}: Omit) { - // the initial/default value will never be displayed, as ExpectedFunds displays its own internal value (empty by default) - const [expectedMontlyDonations, setExpectedMontlyDonations] = useState( +export default function BankDetails({ FormButtons, onSubmit }: Props) { + const [currency, setCurrency] = useState>( + { code: "USD", name: "United States Dollar" } + ); + const [amount, setAmount] = useState( DEFAULT_EXPECTED_MONTHLY_DONATIONS_AMOUNT ); - - const [debounce, isDebouncing] = useDebounce(); - - const { currencies, isLoading, targetCurrency, setTargetCurrency } = - useCurrencies(); - - if (isLoading) { - return ( -
- Loading... -
- ); - } - - if (isEmpty(currencies) || !targetCurrency) { - return {GENERIC_ERROR_MESSAGE}; - } + const [debouncedAmount] = useDebouncer(amount, 500); + const amnt = /^[1-9]\d*$/.test(debouncedAmount) ? +debouncedAmount : 0; return (
setCurrency(c)} + value={currency} classes={{ combobox: "w-full md:w-80" }} - disabled={isSubmitting} /> setAmount(amount)} classes={{ input: "md:w-80" }} - disabled={isSubmitting} - onChange={(value) => { - // if value is empty or 0 (zero), use the default value so that there's some form to show - const newValue = value || DEFAULT_EXPECTED_MONTHLY_DONATIONS_AMOUNT; - // if new value is the same as the current value, then there's no need to debounce, - // but still call the function to cancel the previous debounce call - const delay = newValue === expectedMontlyDonations ? 0 : 1000; - debounce(() => setExpectedMontlyDonations(newValue), delay); - }} />
); diff --git a/src/components/BankDetails/CurrencyOptions.tsx b/src/components/BankDetails/CurrencyOptions.tsx new file mode 100644 index 0000000000..f2b31ba2bf --- /dev/null +++ b/src/components/BankDetails/CurrencyOptions.tsx @@ -0,0 +1,56 @@ +import { Combobox } from "@headlessui/react"; +import { useCurrencisQuery } from "services/aws/wise"; +import { isEmpty } from "helpers"; + +type Props = { + classes?: string; + searchText?: string; +}; + +export default function Options({ classes = "", searchText = "" }: Props) { + const { data: currencies = [] } = useCurrencisQuery( + {}, + { + selectFromResult({ data = [], ...rest }) { + return { + data: data.filter((c) => { + // check whether query matches either the currency name or any of its keywords + const formatQuery = searchText.toLowerCase().replace(/\s+/g, ""); // ignore spaces and casing + const matchesCode = c.code.toLowerCase().includes(formatQuery); + const matchesName = c.name + .toLowerCase() + .replace(/\s+/g, "") // ignore spaces and casing + .includes(formatQuery); + + return matchesCode || matchesName; + }), + ...rest, + }; + }, + } + ); + + return ( + + {isEmpty(currencies) ? ( +
Nothing found
+ ) : ( + currencies.map(({ code, name }) => ( + + {({ active, selected }) => ( +
+ {code} - {name} +
+ )} +
+ )) + )} +
+ ); +} diff --git a/src/components/BankDetails/CurrencySelector.tsx b/src/components/BankDetails/CurrencySelector.tsx index a1d814fb83..0a5b762541 100644 --- a/src/components/BankDetails/CurrencySelector.tsx +++ b/src/components/BankDetails/CurrencySelector.tsx @@ -1,9 +1,9 @@ -import { Combobox, Transition } from "@headlessui/react"; -import { Fragment, memo, useRef, useState } from "react"; +import { Combobox } from "@headlessui/react"; +import { useState } from "react"; import { WiseCurrency } from "types/aws"; import { DrawerIcon } from "components/Icon"; import { Label } from "components/form"; -import { isEmpty } from "helpers"; +import CurrencyOptions from "./CurrencyOptions"; export type Currency = { code: string; @@ -12,50 +12,27 @@ export type Currency = { type Props = { classes: { combobox: string }; - disabled: boolean; value: Currency; - currencies: Currency[]; onChange: (currency: Currency) => void; }; -function currencyFilter(query: string): (value: Currency) => boolean { - return (currency) => { - // check whether query matches either the currency name or any of its keywords - const formatQuery = query.toLowerCase().replace(/\s+/g, ""); // ignore spaces and casing - const matchesCode = currency.code.toLowerCase().includes(formatQuery); - const matchesName = currency.name - .toLowerCase() - .replace(/\s+/g, "") // ignore spaces and casing - .includes(formatQuery); - - return matchesCode || matchesName; - }; -} - -function CurrencySelector(props: Props) { - const inputRef = useRef(null); +export default function CurrencySelector(props: Props) { const [query, setQuery] = useState(""); - const filteredCurrencies = - query === "" - ? props.currencies - : props.currencies.filter(currencyFilter(query)); - return (
-
); } - -// Should only ever re-render when a new currency is selected, -// not when some unrelated state changes -export default memo(CurrencySelector); diff --git a/src/components/BankDetails/ExpectedFunds.tsx b/src/components/BankDetails/ExpectedFunds.tsx index 09b87a35ff..76b3083be1 100644 --- a/src/components/BankDetails/ExpectedFunds.tsx +++ b/src/components/BankDetails/ExpectedFunds.tsx @@ -1,69 +1,39 @@ -import { Popover, Transition } from "@headlessui/react"; -import { Fragment, useState } from "react"; -import Icon from "components/Icon"; import { Label } from "components/form"; import { APP_NAME } from "constants/env"; type Props = { classes: { input: string }; - disabled: boolean; - onChange: (expectedFunds: number) => void; + onChange: (amount: string) => void; + value: string; + disabled?: boolean; }; export default function ExpectedFunds(props: Props) { - const [value, setValue] = useState(""); - return (
-
- - - <> - - - - {/** Transition is configured so that the popover appears from the top on smaller screens and from the bottom on larger screens*/} - - {/** using `-translate-x-2/4` instead of `-translate-x-1/2` here as the latter occasionally has no effect for some reason */} - - Depending on how much you expect to receive each month via{" "} - {APP_NAME}, different details are required. At this point, we - recommend using a conservative figure - Maybe $1000 per month. - - - - -
+ { - const tvalue = Number(event.target.value); - if (isNaN(tvalue)) { - return event.preventDefault(); - } - setValue(event.target.value); - props.onChange(tvalue); - }} - className={`field-input text-field ${props.classes.input}`} + onChange={(event) => props.onChange(event.target.value)} + className={`field-input text-field ${props.classes.input} invalid:ring-1 invalid:ring-red`} autoComplete="off" spellCheck={false} disabled={props.disabled} inputMode="numeric" /> +

+ Depending on how much you expect to receive each month via {APP_NAME}, + different details are required. At this point, we recommend using a + conservative figure - Maybe $1000 per month. +

); } diff --git a/src/components/BankDetails/RecipientDetails/AccountRequirementsSelector.tsx b/src/components/BankDetails/RecipientDetails/AccountRequirementsSelector.tsx deleted file mode 100644 index d59e323c6c..0000000000 --- a/src/components/BankDetails/RecipientDetails/AccountRequirementsSelector.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { RequirementsData } from "./types"; -import { Label } from "components/form"; - -type Props = { - requirementsDataArray: RequirementsData[]; - className: string; - selectedType: string; - disabled: boolean; - onChange: (requirementsType: RequirementsData) => void; -}; - -export default function AccountRequirementsSelector({ - className = "", - disabled, - onChange, - requirementsDataArray, - selectedType, -}: Props) { - return ( -
- -
- {requirementsDataArray.map((x) => ( - - ))} -
-
- ); -} diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetails.tsx b/src/components/BankDetails/RecipientDetails/RecipientDetails.tsx index 49176cfd95..aaf5383aca 100644 --- a/src/components/BankDetails/RecipientDetails/RecipientDetails.tsx +++ b/src/components/BankDetails/RecipientDetails/RecipientDetails.tsx @@ -1,124 +1,87 @@ -import { ComponentType } from "react"; -import { FormButtonsProps } from "../types"; -import { CreateRecipientRequest } from "types/aws"; -import { FileDropzoneAsset } from "types/components"; -import LoaderRing from "components/LoaderRing"; +import { memo, useState } from "react"; +import { IFormButtons, OnSubmit } from "../types"; +import { useRequirementsQuery } from "services/aws/wise"; +import { Info, LoadingStatus } from "components/Status"; +import { Label } from "components/form"; import { isEmpty } from "helpers"; -import { GENERIC_ERROR_MESSAGE } from "constants/common"; -import { EMAIL_SUPPORT } from "constants/env"; -import { Currency } from "../CurrencySelector"; -import AccountRequirementsSelector from "./AccountRequirementsSelector"; import RecipientDetailsForm from "./RecipientDetailsForm"; -import useRecipientDetails from "./useRecipientDetails"; type Props = { - /** - * The flag is used to display a loading indicator (e.g. when debouncing `expectedMontlyDonations`) without - * having to unmount the component itself - this way the current form state gets stored between form loads - * even when new form requirements are being loaded (when "expectedMontlyDonations" value changes) - */ - isLoading: boolean; - isSubmitting: boolean; - currency: Currency; - expectedMontlyDonations: number; - FormButtons: ComponentType; - onSubmit: ( - request: CreateRecipientRequest, - bankStatementFile: FileDropzoneAsset, - isDirty: boolean - ) => Promise; + currency: string; + amount: number; + FormButtons: IFormButtons; + onSubmit: OnSubmit; }; -const MIN_HEIGHT = "min-h-[20rem]"; - -export default function RecipientDetails({ - isLoading, - isSubmitting, - currency, - expectedMontlyDonations, - FormButtons, - onSubmit, -}: Props) { - const { - focusNewRequirements, - isError, - isLoading: isLoadingRequirements, - requirementsDataArray, - selectedRequirementsData, - changeSelectedType, - handleSubmit, - refreshRequirements, - updateDefaultValues, - } = useRecipientDetails( - isLoading, - currency, - expectedMontlyDonations, - onSubmit +function RecipientDetails({ currency, amount, FormButtons, onSubmit }: Props) { + const { data, isLoading, isError, isFetching } = useRequirementsQuery( + { + amount, + currency, + }, + { skip: !amount } ); - // no need to check `isLoading` from the parent, as it already affects the value of `isLoadingRequirements` - if (isLoadingRequirements) { - return ( -
-
- Loading... -
-
- ); - } + const requirements = data?.requirements || []; + const [selectedIdx, setSelectedIdx] = useState(0); - if (isError) { - return
{GENERIC_ERROR_MESSAGE}
; - } + // when num options is reduced from current selected, revert to first option + const reqIdx = selectedIdx + 1 > requirements.length ? 0 : selectedIdx; - // requirements *can* be empty, check the following example when source currency is USD and target is ALL (Albanian lek): - // https://api.sandbox.transferwise.tech/v1/account-requirements?source=USD&target=ALL&sourceAmount=1000 - if (isEmpty(requirementsDataArray)) { + if (isLoading) { return ( -
- Target currency not supported. Please use a bank account with a - different currency. -
+ + Loading requirements... + ); } - // should never happen when `requirementsDataArray.length > 0` - if (!selectedRequirementsData) { + if (isEmpty(requirements) || isError) { return ( -
- There was an error selecting the requirements data. Please reload the - page and try again. If the error persists, please contact{" "} - {EMAIL_SUPPORT}. -
+ + Target currency {currency} is not + supported. Please use a bank account with a different currency. + ); } return ( -
- + <> + {isFetching && ( + + Refreshing requirements.. + + )} +
+ + +
+ useForm`) - key={`form-${selectedRequirementsData.accountRequirements.type}-${selectedRequirementsData.accountRequirements.fields.length}`} + disabled={isFetching} + quoteId={data?.quoteId ?? ""} + type={requirements[reqIdx].type} currency={currency} - disabled={isSubmitting} - focusNewRequirements={focusNewRequirements} + amount={amount} + fields={requirements[reqIdx]?.fields.flatMap((f) => f.group) || []} FormButtons={FormButtons} - requirementsData={selectedRequirementsData} - onRefresh={refreshRequirements} - onSubmit={handleSubmit} - onUpdateValues={updateDefaultValues} + onSubmit={onSubmit} /> -
+ ); } + +export default memo(RecipientDetails); diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/Form.tsx b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/Form.tsx deleted file mode 100644 index 2b5da9cbf8..0000000000 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/Form.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { ComponentType, useEffect, useRef } from "react"; -import { useFormContext } from "react-hook-form"; -import { FormButtonsProps } from "../../types"; -import { FormValues, RequirementsData } from "../types"; -import { CreateRecipientRequest } from "types/aws"; -import { FileDropzoneAsset } from "types/components"; -import FileDropzone from "components/FileDropzone"; -import { Label } from "components/form"; -import { Currency } from "../../CurrencySelector"; -import RequirementField from "./RequirementField"; -import { MB_LIMIT, VALID_MIME_TYPES } from "./constants"; -import formToCreateRecipientRequest from "./formToCreateRecipientRequest"; - -type Props = { - currency: Currency; - disabled: boolean; - focusNewRequirements: boolean; - FormButtons: ComponentType; - requirementsData: RequirementsData; - onRefresh: (request: CreateRecipientRequest) => Promise; - onSubmit: ( - request: CreateRecipientRequest, - bankStatementFile: FileDropzoneAsset, - isDirty: boolean - ) => Promise; -}; - -export default function Form(props: Props) { - const { - currency, - disabled, - focusNewRequirements, - requirementsData, - FormButtons, - onRefresh, - onSubmit, - } = props; - - const { - handleSubmit, - formState: { isSubmitting, isDirty }, - } = useFormContext(); - - const form = useRef(null); - - const handleSubmission = handleSubmit(async (formValues) => { - const { bankStatementFile, ...bankDetails } = formValues; - const request = formToCreateRecipientRequest(bankDetails); - if (requirementsData.refreshRequired) { - await onRefresh(request); - } else { - await onSubmit(request, bankStatementFile, isDirty); - } - }); - - // this should run only when new requirement fields are added - // manually requesting a form submission will run the validation logic - // and immediately validate all the newly added fields, clearly marking all the - // invalid ones to make it easier for the user to notice them - useEffect(() => { - if (focusNewRequirements && form.current) { - form.current.requestSubmit(); - } - }, [focusNewRequirements]); - - return ( -
- {requirementsData.accountRequirements.fields - .flatMap((field) => field.group) - .map((requirements) => ( - - ))} - -
- - - name="bankStatementFile" - specs={{ mbLimit: MB_LIMIT, mimeTypes: VALID_MIME_TYPES }} - disabled={disabled} - /> -
- - - - ); -} diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/RecipientDetailsForm.tsx b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/RecipientDetailsForm.tsx index c26a243d55..cd7b21c622 100644 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/RecipientDetailsForm.tsx +++ b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/RecipientDetailsForm.tsx @@ -1,52 +1,337 @@ -import { ComponentType, useEffect } from "react"; -import { FormProvider } from "react-hook-form"; -import { FormButtonsProps } from "../../types"; -import { FormValues, RequirementsData } from "../types"; -import { CreateRecipientRequest } from "types/aws"; -import { FileDropzoneAsset } from "types/components"; -import { Currency } from "../../CurrencySelector"; -import Form from "./Form"; -import useRecipientDetailsForm from "./useRecipientDetailsForm"; +import { ErrorMessage } from "@hookform/error-message"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { useForm } from "react-hook-form"; +import { IFormButtons, OnSubmit } from "../../types"; +import { Group, ValidationContent } from "types/aws"; +import { ApplicationMIMEType } from "types/lists"; +import { + useCreateRecipientMutation, + useNewRequirementsMutation, +} from "services/aws/wise"; +import { useErrorContext } from "contexts/ErrorContext"; +import { Label } from "components/form"; +import { isEmpty } from "helpers"; +import { GENERIC_ERROR_MESSAGE } from "constants/common"; type Props = { - currency: Currency; - disabled: boolean; - focusNewRequirements: boolean; - FormButtons: ComponentType; - requirementsData: RequirementsData; - onRefresh: (request: CreateRecipientRequest) => Promise; - onSubmit: ( - request: CreateRecipientRequest, - bankStatementFile: FileDropzoneAsset, - isDirty: boolean - ) => Promise; - onUpdateValues: (formValues: FormValues) => void; + fields: Group[]; + currency: string; + amount: number; + type: string; + quoteId: string; + disabled?: boolean; + FormButtons: IFormButtons; + onSubmit: OnSubmit; }; -export default function RecipientDetailsForm(props: Props) { - const methods = useRecipientDetailsForm( - props.requirementsData.accountRequirements, - props.requirementsData.currentFormValues - ); +export default function RecipientDetailsForm({ + fields, + currency, + type, + quoteId, + amount, + disabled, + onSubmit, + FormButtons, +}: Props) { + const { + register, + handleSubmit, + getValues, + setError, + setFocus, + formState: { errors, isSubmitting }, + } = useForm({ disabled }); + + const { handleError } = useErrorContext(); + const [updateRequirements] = useNewRequirementsMutation(); + const [createRecipient] = useCreateRecipientMutation(); + + async function refresh() { + const { accountHolderName, bankStatement, ...details } = getValues(); + await updateRequirements({ + quoteId, + amount, + currency, + request: { + accountHolderName, + currency, + ownedByCustomer: false, + profile: "{{profileId}}", + type, + details, + }, + }); + } - const { onUpdateValues, onRefresh, ...rest } = props; - const { getValues } = methods; - - // save current form values so that they can be preloaded - // when switching between account requirement types - useEffect(() => { - return () => { - onUpdateValues(getValues()); - }; - }, [getValues, onUpdateValues]); - - const handleRefresh = async (request: CreateRecipientRequest) => { - onUpdateValues(getValues()); // update current form values prior to refreshing the form (which loads new fields) - await onRefresh(request); - }; return ( - -
- + { + try { + const { accountHolderName, bankStatement, ...details } = fv; + + const res = await createRecipient({ + accountHolderName, + currency, + ownedByCustomer: false, + profile: "{{profileId}}", + type, + details, + }); + + if ("data" in res) { + const file = (bankStatement as FileList).item(0)!; + return await onSubmit(res.data, file); + } + + //ERROR handling + const error = res.error as FetchBaseQueryError; + if (error.status !== 422) return handleError(res.error); + + //only handle 422 + const content = error.data as ValidationContent; + + //filter "NOT_VALID" + const _errs = content.errors; + const validations = content.errors.filter( + (err) => err.code === "NOT_VALID" + ); + + if (isEmpty(validations)) { + return handleError(_errs[0].message || GENERIC_ERROR_MESSAGE); + } + + //SET field errors + for (const v of validations) { + setError(v.path, { message: v.message }); + } + + setTimeout(() => { + //focus 1st error only + setFocus(validations[0].path); + //wait a bit for `isSubmitting:false`, as disabled fields can't be focused + }, 50); + } catch (err) { + handleError(err); + } + })} + className="grid gap-5 text-gray-d2" + > + {fields.map((f) => { + const labelRequired = f.required ? true : undefined; + if (f.type === "select") { + return ( +
+ + + +
+ ); + } + + if (f.type === "radio") { + return ( +
+ +
+ {f.valuesAllowed?.map((v) => ( +
+ + +
+ ))} +
+ +
+ ); + } + + if (f.type === "text") { + return ( +
+ + { + const { params, url } = f.validationAsync!; + const res = await fetch(`${url}?${params[0].key}=${v}`); + return res.ok || "invalid"; + } + : undefined, + //onBlur only as text input changes rapidly + onBlur: f.refreshRequirementsOnChange ? refresh : undefined, + shouldUnregister: true, + disabled: isSubmitting, + })} + /> + +
+ ); + } + + if (f.type === "date") { + return ( +
+ + + +
+ ); + } + + return ( +
+ {f.name} +
+ ); + })} + +
+ + = Math.pow(10, MB_LIMIT)) { + return `exceeds ${MB_LIMIT}MB`; + } + + return true; + }, + })} + /> + + +
+ + + ); } diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/RequirementField.tsx b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/RequirementField.tsx deleted file mode 100644 index ce887b2128..0000000000 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/RequirementField.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Path } from "react-hook-form"; -import { FormValues } from "../types"; -import { Group } from "types/aws"; -import { Country } from "types/components"; -import countries from "assets/countries/all.json"; -import CountrySelector from "components/CountrySelector"; -import { Selector } from "components/Selector"; -import { Field, Label } from "components/form"; -import { isEmpty } from "helpers"; -import { Currency } from "../../CurrencySelector"; -import { isCountry, undot } from "../helpers"; - -type Props = { - currency: Currency; - data: Group; - disabled?: boolean; // need this only for CountrySelector - requirementsType: string; -}; - -export default function RequirementField({ - currency, - data, - disabled, - requirementsType, -}: Props) { - const requirementsKey = undot(data.key); - - const name: Path = `requirements.${requirementsKey}`; - - // Optional Wise field names contain " (optional)" at the end - const label = data.name.replace(new RegExp("\\s*\\(optional\\)$"), ""); - - if (data.type === "date") { - return ( - - name={name} - type="date" - label={label} - required={data.required} - classes={{ - input: "date-input uppercase", - container: "field-admin", - }} - disabled={disabled} - /> - ); - } - - if ( - data.type === "text" || - // if by any chance there are fields that are of type `"text"`, but DO have `valuesAllowed` defined, - // they should be treated as selectors - !data.valuesAllowed || - isEmpty(data.valuesAllowed) - ) { - return ( - - name={name} - label={label} - placeholder={data.example} - required={data.required} - classes="field-admin" - disabled={disabled} - /> - ); - } - - if (isCountry(data)) { - return ( -
- - - fieldName={name} - countries={countries.filter( - (country) => - data.valuesAllowed!.find((x) => x.key === country.code) && - isAllowed(country, requirementsType, currency) - )} - placeholder={data.example} - classes={{ - container: "px-4", - input: "text-sm py-4", - error: "field-error", - }} - disabled={disabled} - /> -
- ); - } - - return ( -
- - - name={name} - options={data.valuesAllowed.map((valuesAllowed) => ({ - label: valuesAllowed.name, - value: valuesAllowed.key, - }))} - disabled={disabled} - /> -
- ); -} - -function isAllowed( - country: Country, - requirementsType: string, - currency: Currency -): boolean { - return ( - // SWIFT transfers are not allowed inside USA or US territories, see https://wise.com/help/articles/2932150/guide-to-usd-transfers - !country.name.includes("United States") || - requirementsType !== "swift_code" || - currency.code !== "USD" - ); -} diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/constants.ts b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/constants.ts deleted file mode 100644 index 363c7635de..0000000000 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ApplicationMIMEType } from "types/lists"; - -export const MB_LIMIT = 6; - -export const VALID_MIME_TYPES: ApplicationMIMEType[] = ["application/pdf"]; diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/formToCreateRecipientRequest.ts b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/formToCreateRecipientRequest.ts deleted file mode 100644 index 60b81a09ce..0000000000 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/formToCreateRecipientRequest.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { FormValues } from "../types"; -import { CreateRecipientRequest } from "types/aws"; -import { redot } from "../helpers/dot"; - -export default function formToCreateRecipientRequest( - formValues: Omit -): CreateRecipientRequest { - const { accountHolderName, ...requirements } = formValues.requirements; - - return { - accountHolderName: accountHolderName as string, - currency: formValues.currency, - type: formValues.type, - ownedByCustomer: false, - profile: "{{profileId}}", // AWS replaces it with actual Profile ID - details: Object.entries(requirements).reduce< - CreateRecipientRequest["details"] - >( - (details, [key, value]) => { - if (value == null) { - // if value is null/undefined, it's probably optional, - // so don't include it - return details; - } - - const origKey = redot(key); - - let objToFill = details; - let field = origKey; - - // `address` is the only required field always present in `details` object - // see https://docs.wise.com/api-docs/api-reference/recipient#object - if (origKey.startsWith("address.")) { - objToFill = details["address"] as Record; // address object - field = origKey.split(".")[1]; // address field - } - - if (typeof value === "string") { - // if value is string - fill(objToFill, field, value); - } else if ("code" in value) { - // if value is Country - fill(objToFill, field, value.code); - } else { - // if value is OptionType - fill(objToFill, field, value.value); - } - return details; - }, - { address: {} } - ), - }; -} - -/** - * Since Country matches `Record`, it would be possible to (by mistake) assign - * a whole `country: Country` to any of the appropriate `CreateRecipientRequest.details` - * object fields. - * By using a function that forces the assignment of a `string` value, we avoid this potential - * developer mistake. - * - * const value: Country = {...}; - * objToFill[field] = value; // no compilation error - * --- - * const value: Country = {...}; - * fill(objToFill, field, value); // ERROR - * fill(objToFill, field, value.code); // all good - * - * @param obj object to update - * @param key object key to update - * @param value string value to use - */ -function fill( - obj: Record | undefined>, - key: string, - value: string -) { - obj[key] = value; -} diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/createFieldSchema.ts b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/createFieldSchema.ts deleted file mode 100644 index 710d2bfa53..0000000000 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/createFieldSchema.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { AnyObject, ObjectSchema, StringSchema, object, string } from "yup"; -import { Group } from "types/aws"; -import { OptionType } from "types/components"; -import { logger } from "helpers"; -import { requiredString } from "schemas/string"; - -export function createStringSchema( - requirements: Group -): StringSchema { - let schema = requirements.required ? requiredString : string().nullable(); - - if (requirements.minLength) { - schema = schema.min( - requirements.minLength, - `Must be at least ${requirements.minLength} charasters` - ); - } - if (requirements.maxLength) { - schema = schema.max( - requirements.maxLength, - `Must be at most ${requirements.maxLength} charasters` - ); - } - if (requirements.validationRegexp) { - schema = schema.matches(new RegExp(requirements.validationRegexp), { - message: `Must be a valid ${requirements.name}`, - excludeEmptyString: true, - }); - } - if (requirements.validationAsync) { - const { url, params } = requirements.validationAsync; - schema = schema.test( - "Field's remote validation", - `Must be a valid ${requirements.name}`, - (val) => - // Still waiting on some response from Wise Support on how to handle - // cases when params.length > 1, as it's currently not clear how do this. - fetch(`${url}?${params[0].key}=${val}`) - .then((res) => res.ok) - .catch((err) => { - logger.error("Error fetching accounts requirements"); - logger.error(err); - return false; - }) - ); - } - - return schema; -} - -export function createOptionsTypeSchema( - requirements: Group -): ObjectSchema, AnyObject, any, ""> { - // - since we'll have allowed values set in the component itself, there's only need to check whether the field is required - // - other validations make no sense for selectors ([min/max]Length, validationRegexp etc.) - const schema: ObjectSchema> = object({ - label: requiredString, - value: requirements.required ? requiredString : string(), - }); - - return schema; -} diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/createSchema.ts b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/createSchema.ts deleted file mode 100644 index d5f97eab24..0000000000 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/createSchema.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ObjectSchema, ObjectShape, object } from "yup"; -import { FormValues } from "../../types"; -import { SchemaShape } from "schemas/types"; -import { AccountRequirements, Group } from "types/aws"; -import { Country } from "types/components"; -import { isEmpty } from "helpers"; -import { fileDropzoneAssetShape } from "schemas/file"; -import { requiredString } from "schemas/string"; -import { BYTES_IN_MB } from "constants/common"; -import { isCountry, undot } from "../../helpers"; -import { MB_LIMIT, VALID_MIME_TYPES } from "../constants"; -import { - createOptionsTypeSchema, - createStringSchema, -} from "./createFieldSchema"; - -export default function createSchema( - accountRequirements: AccountRequirements -): ObjectSchema { - return object>({ - bankStatementFile: fileDropzoneAssetShape( - MB_LIMIT * BYTES_IN_MB, - VALID_MIME_TYPES, - true - ), - currency: requiredString, - type: requiredString, - requirements: object( - accountRequirements.fields.reduce((objectShape, field) => { - field.group.forEach((requirements) => { - objectShape[undot(requirements.key)] = getSchema(requirements); - }); - return objectShape; - }, {}) - ), - }) as ObjectSchema; -} - -function getSchema(requirements: Group) { - // type === "date" will be validated using `requirements.validationRegexp` - const schema = - requirements.type === "text" || requirements.type === "date" - ? createStringSchema(requirements) - : // if by some error on Wise's side there are no valuesAllowed provided for a requirement, - // we will it as a "text" field - isEmpty(requirements.valuesAllowed ?? []) - ? createStringSchema(requirements) - : // country-related requirements need to be converted into `type Country`-like objects - isCountry(requirements) - ? object>({ - name: requiredString, - code: requiredString.max( - 2, - "Country code should contain 2 characters" - ), - }) - : createOptionsTypeSchema(requirements); - - return schema; -} diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/index.ts b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/index.ts deleted file mode 100644 index 634f7a2ccb..0000000000 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./useRecipientDetailsForm"; diff --git a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/useRecipientDetailsForm.ts b/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/useRecipientDetailsForm.ts deleted file mode 100644 index c70568f9c8..0000000000 --- a/src/components/BankDetails/RecipientDetails/RecipientDetailsForm/useRecipientDetailsForm/useRecipientDetailsForm.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { yupResolver } from "@hookform/resolvers/yup"; -import { UseFormReturn, useForm } from "react-hook-form"; -import { FormValues } from "../../types"; -import { AccountRequirements } from "types/aws"; -import createSchema from "./createSchema"; - -/** - * - * @param accountRequirements account requirement field data - * @param targetCurrency target currency for the recipient - * @param initValues initial values - * @returns an object `methods` used to initialize `react-hook-form` - */ -export default function useRecipientDetailsForm( - accountRequirements: AccountRequirements, - initValues: FormValues -): UseFormReturn { - const schema = createSchema(accountRequirements); - - const methods = useForm({ - resolver: yupResolver(schema), - defaultValues: initValues, - }); - - return methods; -} diff --git a/src/components/BankDetails/RecipientDetails/helpers/dot.ts b/src/components/BankDetails/RecipientDetails/helpers/dot.ts deleted file mode 100644 index 06181367ee..0000000000 --- a/src/components/BankDetails/RecipientDetails/helpers/dot.ts +++ /dev/null @@ -1,53 +0,0 @@ -const DOT = "."; -const DOLLAR_SIGN = "$"; - -/** - * `react-hook-form` turns dot-field names into nested object fields, causing weird behavior when - * accessing/assigning said fields if they're deeply nested. - * - * E.g. with an object like: - * ``` - * type FormValues = { - * requirements: { - * type: { - * [key: string]: any - * } - * } - * } - * ``` - * setting a field named `address.country` inside `requirements.type` can internally be converted into: - * ``` - * const formValues: FormValues = { - * requirements: { - * type: - * address: { - * country: "some_value" - * } - * } - * } - * } - * ``` - * This is the default behavior, see: - * - https://github.com/react-hook-form/react-hook-form/issues/3351#issuecomment-721996499 - * - https://react-hook-form.com/docs/useform/register - * - * The problem is that it is then difficult to access this `address.country` field, as `react-hook-form` - * gets confused and has no idea how to access the field: - * ``` - * console.log(methods.getValues("requirements.type.address.country")) // returns undefined - * ``` - * - * To solve, turn dots into some other character when creating the form, see: - * https://github.com/react-hook-form/react-hook-form/issues/3755#issuecomment-943408807 - * - * @param key string - * @returns same string with dot characters (.) replaced with dollar signs ($) - * */ -export const undot = (key: string): string => key.replace(DOT, DOLLAR_SIGN); - -/** - * @see {@link undot} - * @param key string - * @returns same string with dollar signs ($) replaced with dot characters (.) - */ -export const redot = (key: string): string => key.replace(DOLLAR_SIGN, DOT); diff --git a/src/components/BankDetails/RecipientDetails/helpers/getDefaultValues.ts b/src/components/BankDetails/RecipientDetails/helpers/getDefaultValues.ts deleted file mode 100644 index 2972792298..0000000000 --- a/src/components/BankDetails/RecipientDetails/helpers/getDefaultValues.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { FormValues } from "../types"; -import { AccountRequirements, Field, Group } from "types/aws"; -import { Country } from "types/components"; -import { asset } from "components/FileDropzone"; -import { isCountry, isTextType, undot } from "."; - -/** - * Creates a FormValues object based on the passed requirements, - * with all fields set to appropriate default values. - * - * @param accountRequirements requirements to process - * @param targetCurrency target currency - * @returns FormValues object with all fields set to appropriate default values - */ -export function getDefaultValues( - accountRequirements: AccountRequirements, - targetCurrency: string -): FormValues { - return { - bankStatementFile: asset([]), - currency: targetCurrency, - type: accountRequirements.type, - requirements: accountRequirements.fields.reduce( - (defaultValues, field) => { - const updated = addRequirementField(defaultValues, field); - return updated; - }, - {} - ), - }; -} - -/** - * Adds a requirement field (set to default value depending on type) to current form requirements. - * - * @param currFormValues current form requirements - * @param field requirement field to add - * @returns current form requirements with the new req. field added - */ -function addRequirementField( - currFormValues: FormValues["requirements"], - field: Field -): FormValues["requirements"] { - const result = field.group.reduce( - (curr, group) => addRequirementGroup(curr, group), - { ...currFormValues } - ); - - return result; -} - -/** - * Adds a requirement group (set to default value depending on type) to current form requirements. - * - * @param currFormValues current form requirements - * @param group requirement group to add - * @returns current form requirements with the new req. group added - */ -export function addRequirementGroup( - currFormValues: FormValues["requirements"], - group: Group -): FormValues["requirements"] { - const updated = { ...currFormValues }; - - const key = undot(group.key); - - if (isTextType(group)) { - updated[key] = null; - } else if (isCountry(group)) { - const country: Country = { code: "", flag: "", name: "" }; - updated[key] = country; - } else { - updated[key] = { - // `requirements.example` contains dropdown placeholder text for `select`; for `radio` it's empty string - label: group.example || "Select...", - value: "", - }; - } - - return updated; -} diff --git a/src/components/BankDetails/RecipientDetails/helpers/index.ts b/src/components/BankDetails/RecipientDetails/helpers/index.ts deleted file mode 100644 index 13efc5f2ab..0000000000 --- a/src/components/BankDetails/RecipientDetails/helpers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./dot"; -export * from "./isCountry"; -export * from "./isTextType"; diff --git a/src/components/BankDetails/RecipientDetails/helpers/isCountry.ts b/src/components/BankDetails/RecipientDetails/helpers/isCountry.ts deleted file mode 100644 index c5d634edff..0000000000 --- a/src/components/BankDetails/RecipientDetails/helpers/isCountry.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Group } from "types/aws"; - -/** - * - * @param requirements account requirement data - * @returns boolean indicating whether the field is a country selector - */ -export const isCountry = (requirements: Group): boolean => - requirements.key.includes("country"); diff --git a/src/components/BankDetails/RecipientDetails/helpers/isTextType.ts b/src/components/BankDetails/RecipientDetails/helpers/isTextType.ts deleted file mode 100644 index f650c0216a..0000000000 --- a/src/components/BankDetails/RecipientDetails/helpers/isTextType.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Group } from "types/aws"; -import { isEmpty } from "helpers"; - -export function isTextType(requirements: Group) { - return ( - requirements.type === "text" || - requirements.type === "date" || - // if by some error on Wise's side there are no valuesAllowed provided for a requirement, - // we will treat it as a "text" field - isEmpty(requirements.valuesAllowed ?? []) - ); -} diff --git a/src/components/BankDetails/RecipientDetails/types.ts b/src/components/BankDetails/RecipientDetails/types.ts deleted file mode 100644 index f1a1231375..0000000000 --- a/src/components/BankDetails/RecipientDetails/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { AccountRequirements, CreateRecipientRequest } from "types/aws"; -import { Country, FileDropzoneAsset, OptionType } from "types/components"; - -export type FormValues = Omit< - CreateRecipientRequest, - // `details` values are simple strings, so swap that out for what a form needs - | "details" - // have default values, see formToCreateRecipientRequest.ts - | "ownedByCustomer" - | "profile" - // even though the field falls outside the `details` object, it is nevertheless - // returned as part of the requirements array from Wise - | "accountHolderName" -> & { - bankStatementFile: FileDropzoneAsset; - requirements: Record | Country>; -}; - -export type RequirementsData = { - /** - * Indicates whether the requirements data should be processed as part of the current `currency + expected monthly donation amount` combination - */ - active: boolean; - accountRequirements: AccountRequirements; - currentFormValues: FormValues; - /** - * Indicates whether new fields were added after refreshing requirements - */ - refreshedRequirementsAdded: boolean; - /** - * Indicates whether requirements refresh is necessary. - * See https://docs.wise.com/api-docs/api-reference/recipient#account-requirements - */ - refreshRequired: boolean; -}; diff --git a/src/components/BankDetails/RecipientDetails/useRecipientDetails/getDefaultValues.ts b/src/components/BankDetails/RecipientDetails/useRecipientDetails/getDefaultValues.ts deleted file mode 100644 index 9d9f10dbf4..0000000000 --- a/src/components/BankDetails/RecipientDetails/useRecipientDetails/getDefaultValues.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { FormValues } from "../types"; -import { AccountRequirements, Field, Group } from "types/aws"; -import { Country } from "types/components"; -import { asset } from "components/FileDropzone"; -import { Currency } from "../../CurrencySelector"; -import { isCountry, isTextType, undot } from "../helpers"; - -/** - * Creates a FormValues object based on the passed requirements, - * with all fields set to appropriate default values. - * - * @param accountRequirements requirements to process - * @param currency target currency - * @returns FormValues object with all fields set to appropriate default values - */ -export function getDefaultValues( - accountRequirements: AccountRequirements, - currency: Currency -): FormValues { - return { - bankStatementFile: asset([]), - currency: currency.code, - type: accountRequirements.type, - requirements: accountRequirements.fields.reduce( - (defaultValues, field) => { - const updated = populateRequirementField(defaultValues, field); - return updated; - }, - {} - ), - }; -} - -/** - * Adds a requirement field (set to default value depending on type) to current form requirements. - * - * @param currFormValues current form requirements - * @param field requirement field to add - * @returns current form requirements with the new req. field added - */ -function populateRequirementField( - currFormValues: FormValues["requirements"], - field: Field -): FormValues["requirements"] { - const result = field.group.reduce( - (curr, group) => populateRequirementGroup(curr, group), - { ...currFormValues } - ); - - return result; -} - -/** - * Adds a requirement group (set to default value depending on type) to current form requirements. - * - * @param currFormValues current form requirements - * @param group requirement group to add - * @returns current form requirements with the new req. group added - */ -export function populateRequirementGroup( - currFormValues: FormValues["requirements"], - group: Group -): FormValues["requirements"] { - const updated = { ...currFormValues }; - - const key = undot(group.key); - - if (isTextType(group)) { - updated[key] = null; - } else if (isCountry(group)) { - const country: Country = { code: "", flag: "", name: "" }; - updated[key] = country; - } else { - updated[key] = { - // `requirements.example` contains dropdown placeholder text for `select`; for `radio` it's empty string - label: group.example || "Select...", - value: "", - }; - } - - return updated; -} diff --git a/src/components/BankDetails/RecipientDetails/useRecipientDetails/index.ts b/src/components/BankDetails/RecipientDetails/useRecipientDetails/index.ts deleted file mode 100644 index 5c4f843696..0000000000 --- a/src/components/BankDetails/RecipientDetails/useRecipientDetails/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./useRecipientDetails"; diff --git a/src/components/BankDetails/RecipientDetails/useRecipientDetails/mergeRequirements.ts b/src/components/BankDetails/RecipientDetails/useRecipientDetails/mergeRequirements.ts deleted file mode 100644 index f5c6db442f..0000000000 --- a/src/components/BankDetails/RecipientDetails/useRecipientDetails/mergeRequirements.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { FormValues, RequirementsData } from "../types"; -import { AccountRequirements } from "types/aws"; -import { isEmpty } from "helpers"; -import { Currency } from "../../CurrencySelector"; -import { getDefaultValues, populateRequirementGroup } from "./getDefaultValues"; - -/** - * Updates current array of requirements data with the updated requirements (newly loaded from Wise). - * Includes: - * - copying any old requirements form state - * - adding any new requirements with their default values - * - * @param requirementsDataArray current array of requirements data containing all previously input form state - * @param updatedRequirements updated requirements based on newly selected currency and expected monthly donation amount - * @param currency target currency for which requirements were loaded - * @param isRefreshed flag indicating whether the fields are being processed after refreshing the requirements - * @returns updated requirements data array - */ -export default function mergeRequirements( - requirementsDataArray: RequirementsData[], - updatedRequirements: AccountRequirements[], - currency: Currency, - isRefreshed = false -): RequirementsData[] { - // separate requirements data array into active and inactive ones based on whether - // the account requirements within are a member of the updated requirements array - const requirementsDataArrays = separate( - requirementsDataArray, - updatedRequirements - ); - - // resulting requirements data array should be a set of both: - // - inactive requirements: to store previous form values - // - active requirements: update currently active form values and add new fiels - const result = requirementsDataArrays.inactive.concat( - updatedRequirements.map((updReq) => { - // Should be undefined when loading for the first time or changing target currency/expected monthly donations. - // Should always be found when refreshing << but not 100% sure how Wise behaves in all cases >>. - const existingReqData = requirementsDataArrays.active.find( - (x) => x.accountRequirements.type === updReq.type - ); - - // requirement type is loaded for the first time, use default values - if (!existingReqData) { - return { - active: true, - accountRequirements: updReq, - currentFormValues: getDefaultValues(updReq, currency), - refreshedRequirementsAdded: false, - refreshRequired: isRefreshRequired(updReq), - }; - } - - // requirement type is among the previously loaded requirements, update the form fields - return getUpdatedRequirementsData(existingReqData, updReq, isRefreshed); - }) - ); - - return result; -} - -/** - * Separates requirements data array into active and inactive ones based on whether - * the account requirements within are a member of the updated requirements array - * @param requirementsDataArray current array of requirements data containing all previously input form state - * @param updatedRequirements updated requirements based on newly selected currency and expected monthly donation amount - * @returns an object containing 2 requirement data arrays separated based on whether they're active or inactive - */ -function separate( - requirementsDataArray: RequirementsData[], - updatedRequirements: AccountRequirements[] -): { - inactive: RequirementsData[]; - active: RequirementsData[]; -} { - const activeRequirementsDataArray: RequirementsData[] = []; - const inactiveRequirementsDataArray: RequirementsData[] = []; - - requirementsDataArray.forEach((prevReqData) => { - const existingReqData = updatedRequirements.find( - (x) => x.type === prevReqData.accountRequirements.type - ); - if (!existingReqData) { - inactiveRequirementsDataArray.push({ - ...prevReqData, - active: false, - }); - } else { - activeRequirementsDataArray.push({ - ...prevReqData, - active: true, - }); - } - }); - return { - inactive: inactiveRequirementsDataArray, - active: activeRequirementsDataArray, - }; -} - -/** - * Checks whether there are any new requirements to add to the form and if so, adds them and sets them - * to the appropriate default value. - * - * @param currReqData current requirements data - * @param updatedReq updated requirements that might include new requirement groups - * @param isRefreshed flag indicating whether the fields are being processed after refreshing the requirements - * @returns object containing previous form values with the new requirements included and set to appropriate default values AND - * a flag indicating whether any new requirements were added to the form - */ -function getUpdatedRequirementsData( - currReqData: RequirementsData, - updatedReq: AccountRequirements, - isRefreshed: boolean -): RequirementsData { - // get current requirement groups - const currGroups = currReqData.accountRequirements.fields.flatMap( - (field) => field.group - ); - - // get only new requirement groups - const newGroups = updatedReq.fields - .flatMap((field) => field.group) - .filter((ng) => !currGroups.find((cg) => cg.key === ng.key)); - - // add new requirement groups (if any) to the current requirements - const newRequirements: FormValues["requirements"] = newGroups.reduce( - (curr, group) => populateRequirementGroup(curr, group), - { ...currReqData.currentFormValues.requirements } - ); - - // update default form values - const newFormValues: FormValues = { - ...currReqData.currentFormValues, - requirements: newRequirements, - }; - - let refreshRequired = false; - if (!isRefreshed) { - // first time loading the requirements, check if any of them require a refresh - refreshRequired = isRefreshRequired(updatedReq); - } else if (currReqData.accountRequirements.type !== updatedReq.type) { - // already refreshed, but not for the current account requirement type, just use the old `refreshRequired` value - refreshRequired = currReqData.refreshRequired; - } - - const data: RequirementsData = { - active: true, - accountRequirements: updatedReq, - currentFormValues: newFormValues, - refreshedRequirementsAdded: !isEmpty(newGroups), - refreshRequired, - }; - return data; -} - -function isRefreshRequired(requirements: AccountRequirements): boolean { - return requirements.fields.some((field) => - field.group.some((group) => group.refreshRequirementsOnChange) - ); -} diff --git a/src/components/BankDetails/RecipientDetails/useRecipientDetails/useRecipientDetails.ts b/src/components/BankDetails/RecipientDetails/useRecipientDetails/useRecipientDetails.ts deleted file mode 100644 index 44b50d51fd..0000000000 --- a/src/components/BankDetails/RecipientDetails/useRecipientDetails/useRecipientDetails.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { useCallback, useEffect } from "react"; -import { FormValues, RequirementsData } from "../types"; -import { CreateRecipientRequest } from "types/aws"; -import { FileDropzoneAsset } from "types/components"; -import { - useCreateQuoteMutation, - useGetAccountRequirementsMutation, - usePostAccountRequirementsMutation, -} from "services/aws/bankDetails"; -import { useErrorContext } from "contexts/ErrorContext"; -import { isEmpty } from "helpers"; -import { UnexpectedStateError } from "errors/errors"; -import { GENERIC_ERROR_MESSAGE } from "constants/common"; -import { Currency } from "../../CurrencySelector"; -import useStateReducer from "./useStateReducer"; - -export default function useRecipientDetails( - isParentLoading: boolean, - currency: Currency, - expectedMontlyDonations: number, - onSubmit: ( - request: CreateRecipientRequest, - bankStatementFile: FileDropzoneAsset, - isDirty: boolean - ) => Promise -) { - const [state, dispatch] = useStateReducer(); - - const { handleError } = useErrorContext(); - - const [createQuote] = useCreateQuoteMutation(); - const [getAccountRequirements] = useGetAccountRequirementsMutation(); - const [postAccountRequirements] = usePostAccountRequirementsMutation(); - - useEffect(() => { - (async () => { - // Need to set this to `true` prior to checking `isParentLoading` to avoid a UI blink: - // How the "blink" would happen otherwise would be like: - // 1. isLoading = false, isParentLoading = true - // << UI is loading >> - // 2. isLoading = false, isParentLoading = false - // << UI is displayed --> BLINK >> - // 3. isLoading = true, isParentLoading = false - // << UI is loading >> - dispatch({ type: "LOADING", payload: true }); - - if (isParentLoading) { - return; - } - - try { - dispatch({ type: "ERROR", payload: false }); - - const quote = await createQuote({ - sourceAmount: expectedMontlyDonations, - targetCurrency: currency.code, - }).unwrap(); - - const requirements = await getAccountRequirements(quote.id).unwrap(); - - // There are some currencies that Wise does not allow to be used (why they allow selecting those currencies is unclear). - // For these currencies, Wise returns no requirement data - if (isEmpty(requirements)) { - throw new Error( - "Target currency not supported. Please use a bank account with a different currency." - ); - } - - dispatch({ - type: "UPDATE_REQUIREMENTS", - payload: { quote, requirements, currency }, - }); - } catch (error) { - handleError(error, GENERIC_ERROR_MESSAGE); - dispatch({ type: "ERROR", payload: true }); - } finally { - dispatch({ type: "LOADING", payload: false }); - } - })(); - }, [ - isParentLoading, - expectedMontlyDonations, - currency, - createQuote, - dispatch, - getAccountRequirements, - handleError, - ]); - - const updateDefaultValues = useCallback( - (formValues: FormValues) => { - try { - dispatch({ type: "UPDATE_FORM_VALUES", payload: formValues }); - } catch (error) { - handleError(error, GENERIC_ERROR_MESSAGE); - dispatch({ type: "ERROR", payload: true }); - } - }, - [dispatch, handleError] - ); - - // no need to have an `isRefreshing` state, as this is handled by `react-hook-form` - // in ./RecipientDetailsForm/Form.tsx - const refreshRequirements = async (request: CreateRecipientRequest) => { - try { - if (!state.quote) { - throw new UnexpectedStateError("No 'quote' present."); - } - - dispatch({ type: "ERROR", payload: false }); - - const requirements = await postAccountRequirements({ - quoteId: state.quote.id, - request, - }).unwrap(); - - dispatch({ - type: "UPDATE_REQUIREMENTS", - payload: { currency: currency, requirements, isRefreshed: true }, - }); - } catch (error) { - handleError(error, GENERIC_ERROR_MESSAGE); - dispatch({ type: "ERROR", payload: true }); - } - }; - - // no need to have an `isSubmitting` state, as this is handled by `react-hook-form` - // in ./RecipientDetailsForm/Form.tsx - const handleSubmit = async ( - request: CreateRecipientRequest, - bankStatementFile: FileDropzoneAsset, - isDirty: boolean - ) => { - try { - dispatch({ type: "ERROR", payload: false }); - await onSubmit(request, bankStatementFile, isDirty); - } catch (error) { - handleError(error, GENERIC_ERROR_MESSAGE); - dispatch({ type: "ERROR", payload: true }); - } - }; - - const changeSelectedType = (data: RequirementsData) => { - dispatch({ type: "CHANGE_SELECTED_REQUIREMENTS_DATA", payload: data }); - }; - - return { - focusNewRequirements: state.focusNewRequirements, - isError: state.isError, - isLoading: state.isLoading || isParentLoading, - requirementsDataArray: state.requirementsDataArray.filter((x) => x.active), - selectedRequirementsData: state.selectedRequirementsData, - changeSelectedType, - handleSubmit, - refreshRequirements, - updateDefaultValues, - }; -} diff --git a/src/components/BankDetails/RecipientDetails/useRecipientDetails/useStateReducer.ts b/src/components/BankDetails/RecipientDetails/useRecipientDetails/useStateReducer.ts deleted file mode 100644 index a03de276e9..0000000000 --- a/src/components/BankDetails/RecipientDetails/useRecipientDetails/useStateReducer.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { useReducer } from "react"; -import { FormValues, RequirementsData } from "../types"; -import { AccountRequirements, Quote } from "types/aws"; -import { UnexpectedStateError } from "errors/errors"; -import { Currency } from "../../CurrencySelector"; -import mergeRequirements from "./mergeRequirements"; - -type State = { - activeRequirementsDataArray: RequirementsData[]; - focusNewRequirements: boolean; - isError: boolean; - isLoading: boolean; - quote: Quote | undefined; // store quote to use to refresh requirements - requirementsDataArray: RequirementsData[]; - selectedRequirementsData: RequirementsData | undefined; -}; - -type ChangeSelectedRequirementsData = { - type: "CHANGE_SELECTED_REQUIREMENTS_DATA"; - payload: RequirementsData; -}; - -type SetError = { - type: "ERROR"; - payload: boolean; -}; - -type SetLoading = { - type: "LOADING"; - payload: boolean; -}; - -type UpdateFormValues = { - type: "UPDATE_FORM_VALUES"; - payload: FormValues; -}; - -type UpdateRequirements = { - type: "UPDATE_REQUIREMENTS"; - payload: { - currency: Currency; - isRefreshed?: boolean; - quote?: Quote; - requirements: AccountRequirements[]; - }; -}; - -type Action = - | ChangeSelectedRequirementsData - | SetError - | SetLoading - | UpdateFormValues - | UpdateRequirements; - -function reducer(state: State, action: Action): State { - switch (action.type) { - case "CHANGE_SELECTED_REQUIREMENTS_DATA": { - return { - ...state, - selectedRequirementsData: action.payload, - focusNewRequirements: false, - }; - } - case "ERROR": { - return { - ...state, - isError: action.payload, - }; - } - case "LOADING": { - return { - ...state, - isLoading: action.payload, - }; - } - case "UPDATE_FORM_VALUES": { - const updated = [...state.requirementsDataArray]; - const toUpdate = updated.find( - (x) => x.accountRequirements.type === action.payload.type - ); - - if (!!toUpdate) { - toUpdate.currentFormValues = action.payload; - return { ...state, requirementsDataArray: updated }; - } - - throw new UnexpectedStateError( - `Trying to update a non existent requirements type: ${action.payload.type}` - ); - } - case "UPDATE_REQUIREMENTS": { - const { - currency, - isRefreshed = false, - quote = state.quote, - requirements, - } = action.payload; - - const requirementsDataArray = mergeRequirements( - [...state.requirementsDataArray], - requirements, - currency, - isRefreshed - ); - const activeRequirementsDataArray = requirementsDataArray.filter( - (x) => x.active - ); - const selectedRequirementsData = - activeRequirementsDataArray.find( - (x) => - x.accountRequirements.type === - state.selectedRequirementsData?.accountRequirements.type - ) ?? activeRequirementsDataArray.at(0); - - return { - ...state, - activeRequirementsDataArray, - focusNewRequirements: isRefreshed, - quote, - requirementsDataArray, - selectedRequirementsData, - }; - } - } -} - -/** - * Updating requirements includes updating the requirement data array based on that, updating - * the selected requirement data item - */ -export default function useStateReducer() { - const state: State = { - activeRequirementsDataArray: [], - focusNewRequirements: false, - isError: false, - isLoading: true, - quote: undefined, - requirementsDataArray: [], - selectedRequirementsData: undefined, - }; - return useReducer(reducer, state); -} diff --git a/src/components/BankDetails/UpdateDetailsButton.tsx b/src/components/BankDetails/UpdateDetailsButton.tsx deleted file mode 100644 index 014bb74c16..0000000000 --- a/src/components/BankDetails/UpdateDetailsButton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Popover, Transition } from "@headlessui/react"; -import { Fragment } from "react"; -import Icon from "components/Icon"; - -type Props = { - onClick: () => void; -}; - -export default function UpdateDetailsButton(props: Props) { - const { onClick } = props; - - return ( -
- - - <> - - - - {/** Transition is configured so that the popover appears from the top on smaller screens and from the left on larger screens*/} - - {/** using `-translate-x-2/4` instead of `-translate-x-1/2` here as the latter has no effect for some reason */} - - Submitting new bank details will void your existing bank - connection and will require a review and approval. Do so with care - to prevent unnecessary payout delays! - - - - -
- ); -} diff --git a/src/components/BankDetails/index.ts b/src/components/BankDetails/index.ts index 3e988316d9..fa0f301557 100644 --- a/src/components/BankDetails/index.ts +++ b/src/components/BankDetails/index.ts @@ -1 +1,2 @@ export { default } from "./BankDetails"; +export type { OnSubmit } from "./types"; diff --git a/src/components/BankDetails/types.ts b/src/components/BankDetails/types.ts index ddee47e2ad..a25fd0aec0 100644 --- a/src/components/BankDetails/types.ts +++ b/src/components/BankDetails/types.ts @@ -1,14 +1,14 @@ +import { ComponentType } from "react"; +import { V1RecipientAccount } from "types/aws"; + export type FormButtonsProps = { disabled?: boolean; - isSubmitted?: boolean; isSubmitting?: boolean; - /** - * Indicates whether new fields were added after refreshing requirements - */ - refreshedRequirementsAdded?: boolean; - /** - * Indicates whether requirements refresh is necessary. - * See https://docs.wise.com/api-docs/api-reference/recipient#account-requirements - */ - refreshRequired?: boolean; }; + +export type IFormButtons = ComponentType; + +export type OnSubmit = ( + recipient: V1RecipientAccount, + bankStatementFile: File +) => Promise; diff --git a/src/components/BankDetails/useCurrencies.ts b/src/components/BankDetails/useCurrencies.ts deleted file mode 100644 index 11c5300751..0000000000 --- a/src/components/BankDetails/useCurrencies.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useState } from "react"; -import { useGetCurrenciesMutation } from "services/aws/bankDetails"; -import { useErrorContext } from "contexts/ErrorContext"; -import { EMAIL_SUPPORT } from "constants/env"; -import { Currency } from "./CurrencySelector"; - -const DEFAULT_TARGET_CURRENCY = "USD"; - -const ERROR_MSG = `Error loading currencies. Please try again later. If the error persists, please contact ${EMAIL_SUPPORT}.`; - -export default function useCurrencies() { - const [currencies, setCurrencies] = useState([]); - const [targetCurrency, setTargetCurrency] = useState(); - const [isLoading, setLoading] = useState(true); - - const { handleError } = useErrorContext(); - - const [getCurrencies] = useGetCurrenciesMutation(); - - useEffect(() => { - (async () => { - try { - const wiseCurrencies = await getCurrencies(null).unwrap(); - - const selectorCurrencies: Currency[] = wiseCurrencies.map((x) => { - const currency: Currency = { - code: x.code, - name: x.name, - }; - return currency; - }); - - const newTargetCurrency = - selectorCurrencies.find((x) => x.code === DEFAULT_TARGET_CURRENCY) ?? - selectorCurrencies[0]; - - setTargetCurrency(newTargetCurrency); - setCurrencies(selectorCurrencies); - } catch (error) { - handleError(error, ERROR_MSG); - } finally { - setLoading(false); - } - })(); - }, [getCurrencies, handleError]); - - return { - currencies, - isLoading, - targetCurrency, - setTargetCurrency, - }; -} diff --git a/src/pages/Admin/Charity/Banking/Banking.tsx b/src/pages/Admin/Charity/Banking/Banking.tsx index df3a69f247..d5dd9055e0 100644 --- a/src/pages/Admin/Charity/Banking/Banking.tsx +++ b/src/pages/Admin/Charity/Banking/Banking.tsx @@ -1,13 +1,9 @@ -import { useState } from "react"; -import { Link } from "react-router-dom"; -import { CreateRecipientRequest } from "types/aws"; -import { FileDropzoneAsset } from "types/components"; +import { Link, useNavigate } from "react-router-dom"; import { useAdminContext } from "pages/Admin/Context"; import { useNewBankingApplicationMutation } from "services/aws/banking-applications"; -import { useCreateRecipientMutation } from "services/aws/wise"; import { useErrorContext } from "contexts/ErrorContext"; import { useModalContext } from "contexts/ModalContext"; -import BankDetails from "components/BankDetails"; +import BankDetails, { type OnSubmit } from "components/BankDetails"; import Group from "components/Group"; import Icon from "components/Icon"; import Prompt from "components/Prompt"; @@ -19,25 +15,16 @@ import FormButtons from "./FormButtons"; export default function Banking() { const { id: endowment_id } = useAdminContext(); - const [isSubmitting, setSubmitting] = useState(false); - const [createRecipient] = useCreateRecipientMutation(); const [newApplication] = useNewBankingApplicationMutation(); const { handleError } = useErrorContext(); const { showModal } = useModalContext(); + const navigate = useNavigate(); - const submit = async ( - request: CreateRecipientRequest, - bankStatementFile: FileDropzoneAsset - ) => { + const submit: OnSubmit = async (recipient, bankStatementFile) => { try { - setSubmitting(true); - - const [bankStatementPreview, recipient] = await Promise.all([ - getFilePreviews({ - bankStatementFile, - }), - createRecipient(request).unwrap(), - ]); + const { bankStatement } = await getFilePreviews({ + bankStatement: { previews: [], files: [bankStatementFile] }, + }); const { id, details, currency } = recipient; //creating account return V1Recipient and doesn't have longAccount summary field @@ -48,17 +35,20 @@ export default function Banking() { wiseRecipientID: id.toString(), bankSummary, endowmentID: endowment_id, - bankStatementFile: bankStatementPreview.bankStatementFile[0], + bankStatementFile: { + name: "bank statement", + publicUrl: bankStatement[0].publicUrl, + }, }).unwrap(); showModal(Prompt, { headline: "Success!", children:

Banking details submitted for review!

, }); + + navigate(`../${adminRoutes.banking}`); } catch (error) { handleError(error, GENERIC_ERROR_MESSAGE); - } finally { - setSubmitting(false); } }; @@ -76,13 +66,7 @@ export default function Banking() { title="Bank account details" description="The following information will be used to register your bank account that will be used to withdraw your funds." > - {}} - onSubmit={submit} - shouldUpdate - /> + ); diff --git a/src/pages/Admin/Charity/Banking/FormButtons.tsx b/src/pages/Admin/Charity/Banking/FormButtons.tsx index a201c5122b..27ccfc3216 100644 --- a/src/pages/Admin/Charity/Banking/FormButtons.tsx +++ b/src/pages/Admin/Charity/Banking/FormButtons.tsx @@ -4,37 +4,9 @@ import { LoadText } from "components/registration"; export default function FormButtons({ disabled = false, isSubmitting = false, - refreshedRequirementsAdded = false, - refreshRequired = false, }: FormButtonsProps) { - if (refreshRequired) { - return ( -
- - After you fill the form, we may need additional information to be able - to submit your bank details for validation. Please fill the form and - click the "Check requirements" button below. - - -
- ); - } - return (
- - {refreshedRequirementsAdded - ? "Please check the form again and fill in all the newly added fields." - : "All requirements are met! Please click continue."} - + +
+ + Back + + + Continue + +
+
+ ); + } return ( -
-

- Setup your banking details +
+ {isChanging && ( + + )} +

+ {isChanging ? "Update" : "Setup"} your banking details

- setShouldUpdate(true)} - isSubmitting={isSubmitting} - onSubmit={submit} - /> +
); } diff --git a/src/pages/Registration/Steps/Banking/FormButtons.tsx b/src/pages/Registration/Steps/Banking/FormButtons.tsx index 6dd97c61fd..5f0bc8fa17 100644 --- a/src/pages/Registration/Steps/Banking/FormButtons.tsx +++ b/src/pages/Registration/Steps/Banking/FormButtons.tsx @@ -5,96 +5,13 @@ import { steps } from "../../routes"; import { useRegState } from "../StepGuard"; export default function FormButtons(props: FormButtonsProps) { - const { isSubmitted, refreshRequired, ...rest } = props; - - if (isSubmitted) { - return ; - } - - if (refreshRequired) { - return ; - } - - return ; -} - -function AlreadySubmitted() { - const { data } = useRegState<5>(); - return ( -
- - You have already successfully completed this step. Click "Continue" to - go to the next step or click "Submit Bank Details" button above to - submit different bank details for review. - -
- - Back - - - Continue - -
-
- ); -} - -function Refresh({ - disabled, - isSubmitting, -}: Omit) { - const { data } = useRegState<5>(); - return ( -
- - After you fill the form, we may need additional information to be able - to submit your bank details for validation. Please fill the form and - click the "Check requirements" button below. - -
- - Back - - -
-
- ); + return ; } -function Submit({ - isSubmitting, - refreshedRequirementsAdded, -}: Pick) { +function Submit({ isSubmitting = false }) { const { data } = useRegState<5>(); return (
- - {refreshedRequirementsAdded - ? "Please check the form again and fill in all the newly added fields." - : "All requirements are met! Please click submit."} -
(); - const [createRecipientAccount] = useCreateRecipientAccountMutation(); const [updateReg] = useUpdateRegMutation(); const { handleError } = useErrorContext(); const navigate = useNavigate(); - const [isSubmitting, setSubmitting] = useState(false); - const submit = async ( - request: CreateRecipientRequest, - bankStatementFile: FileDropzoneAsset, - isDirty: boolean - ) => { + const submit: OnSubmit = async (recipient, file) => { try { - if (!isDirty && data.banking?.BankStatementFile) { - return navigate(`../${steps.summary}`, { state: data.init }); - } - - setSubmitting(true); - const bankStatementPreview = await getFilePreviews({ - bankStatementFile, + bankStatementFile: { previews: [], files: [file] }, }); - // we need to pass this variable to `updateReg` in order to update - // its the cache registration data once the request is succesful - const { wise_recipient_id } = await createRecipientAccount({ - PK: data.contact.PK, - request, - }).unwrap(); - const result = await updateReg({ reference: data.init.reference, type: "banking", BankStatementFile: bankStatementPreview.bankStatementFile[0], - wise_recipient_id, + wise_recipient_id: recipient.id, }); if ("error" in result) { return handleError(result.error); } + return navigate(`../${steps.summary}`, { state: data.init }); } catch (error) { handleError(error, GENERIC_ERROR_MESSAGE); - } finally { - setSubmitting(false); } }; - return { isSubmitting, submit }; + return { submit }; } diff --git a/src/services/aws/bankDetails.ts b/src/services/aws/bankDetails.ts deleted file mode 100644 index 87f0ce5159..0000000000 --- a/src/services/aws/bankDetails.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - AccountRequirements, - CreateRecipientRequest, - Quote, - WiseCurrency, -} from "types/aws"; -import { aws } from "services/aws/aws"; -import { version as v } from "../helpers"; - -export const bank_details_api = aws.injectEndpoints({ - endpoints: (builder) => ({ - createQuote: builder.mutation< - Quote, - { targetCurrency: string; sourceAmount: number } - >({ - query: ({ targetCurrency, sourceAmount }) => ({ - url: `/${v(1)}/wise`, - method: "POST", - body: { - // AWS will replace `{{profileId}}` with the actual profile ID - url: "/v3/profiles/{{profileId}}/quotes", - method: "POST", - payload: JSON.stringify({ - sourceCurrency: "USD", - targetCurrency, - sourceAmount, - }), - }, - }), - }), - createRecipientAccount: builder.mutation< - { wise_recipient_id: number }, - | { - PK: string; - endowmentId?: never; - request: CreateRecipientRequest; - } - | { - PK?: never; - endowmentId: number; - request: CreateRecipientRequest; - } - >({ - query: ({ PK, endowmentId, request }) => ({ - url: `/${v(1)}/wise`, - method: "POST", - body: { - method: "POST", - url: "/v1/accounts", - PK, - endowmentId, - payload: JSON.stringify(request), - }, - }), - }), - getAccountRequirements: builder.mutation({ - query: (quoteId) => ({ - url: `/${v(1)}/wise`, - method: "POST", - body: { - method: "GET", - url: `/v1/quotes/${quoteId}/account-requirements`, - headers: { "Accept-Minor-Version": "1" }, - }, - }), - }), - getCurrencies: builder.mutation({ - query: () => ({ - url: `/${v(1)}/wise`, - method: "POST", - body: { - method: "GET", - url: `/v1/currencies`, - }, - }), - }), - /** - * This endpoint should be used to check whether additional requirement fields need to be loaded. - * - * As per docs: - * Use the GET endpoint to learn what datapoints are required to send a payment to your beneficiary. - * As you build that form, use the POST endpoint to learn if any additional datapoints are required as - * a result of passing a field that has "refreshRequirementsOnChange": true' in the GET Response. - * You should be posting the same recipient account payload that will be posted to v1/accounts. - * - * For more details see https://docs.wise.com/api-docs/api-reference/recipient#account-requirements - */ - postAccountRequirements: builder.mutation< - AccountRequirements[], - { quoteId: string; request: CreateRecipientRequest } - >({ - query: ({ quoteId, request }) => ({ - url: `/${v(1)}/wise`, - method: "POST", - body: { - method: "POST", - url: `/v1/quotes/${quoteId}/account-requirements`, - headers: { "Accept-Minor-Version": "1" }, - payload: JSON.stringify(request), - }, - }), - }), - }), -}); - -export const { - useCreateQuoteMutation, - useCreateRecipientAccountMutation, - useGetAccountRequirementsMutation, - useGetCurrenciesMutation, - - usePostAccountRequirementsMutation, -} = bank_details_api; diff --git a/src/services/aws/wise.ts b/src/services/aws/wise.ts index a6a3299d60..0b8f0a91a5 100644 --- a/src/services/aws/wise.ts +++ b/src/services/aws/wise.ts @@ -1,11 +1,16 @@ import { + AccountRequirements, CreateRecipientRequest, + Quote, V1RecipientAccount, V2RecipientAccount, + WiseCurrency, } from "types/aws"; import { aws } from "../aws/aws"; import { version as v } from "../helpers"; +const baseURL = `/${v(1)}/wise-proxy`; + export const wise = aws.injectEndpoints({ endpoints: (builder) => ({ createRecipient: builder.mutation< @@ -14,7 +19,7 @@ export const wise = aws.injectEndpoints({ >({ query: (payload) => { return { - url: `/${v(1)}/wise-proxy/v1/accounts`, + url: `${baseURL}/v1/accounts`, method: "POST", body: payload, headers: { "Content-Type": "application/json" }, @@ -22,9 +27,84 @@ export const wise = aws.injectEndpoints({ }, }), recipient: builder.query({ - query: (id: string) => `/${v(1)}/wise-proxy/v2/accounts/${id}`, + query: (id: string) => `${baseURL}/v2/accounts/${id}`, + }), + currencis: builder.query({ + query: () => `${baseURL}/v1/currencies`, + }), + + newRequirements: builder.mutation< + AccountRequirements[], + { + quoteId: string; + request: CreateRecipientRequest; + amount: number; + currency: string; + } + >({ + query: ({ quoteId, request }) => { + return { + method: "POST", + url: `${baseURL}/v1/quotes/${quoteId}/account-requirements`, + headers: { "Accept-Minor-Version": "1" }, + body: request, + }; + }, + async onQueryStarted({ currency, amount }, { dispatch, queryFulfilled }) { + const { data } = await queryFulfilled; + dispatch( + wise.util.updateQueryData( + "requirements", + { currency, amount }, + (draft) => { + draft.requirements = data; + } + ) + ); + }, + }), + + requirements: builder.query< + { requirements: AccountRequirements[]; quoteId: string }, + { amount: number; currency: string } + >({ + async queryFn(arg, api, extraOptions, baseQuery) { + const quoteRes = await baseQuery({ + url: `${baseURL}/v3/profiles/{{profileId}}/quotes`, + method: "POST", + body: { + sourceCurrency: "USD", + targetCurrency: arg.currency, + sourceAmount: arg.amount, + }, + }); + + if (quoteRes.error) { + return { error: { status: 500, data: "failed to get quote" } }; + } + const quote = quoteRes.data as Quote; + + const requirementsRes = await baseQuery({ + url: `${baseURL}/v1/quotes/${quote.id}/account-requirements`, + headers: { "Accept-Minor-Version": "1" }, + }); + + const requirements = requirementsRes.data as AccountRequirements[]; + + if (requirementsRes.error) { + return { error: { status: 500, data: "failed to get quote" } }; + } + + return { data: { requirements, quoteId: quote.id } }; + }, }), }), }); -export const { useCreateRecipientMutation, useRecipientQuery } = wise; +export const { + useCreateRecipientMutation, + useRecipientQuery, + useCurrencisQuery, + useRequirementsQuery, + useNewRequirementsMutation, +} = wise; diff --git a/src/types/aws/ap/bankDetails.ts b/src/types/aws/ap/bankDetails.ts index 44e200525f..f7ce5d8c34 100644 --- a/src/types/aws/ap/bankDetails.ts +++ b/src/types/aws/ap/bankDetails.ts @@ -103,3 +103,16 @@ export type WiseCurrency = { countryKeywords: string[]; supportsDecimals: boolean; }; + +type ValidationError = { + code: string; + message: string; + arguments: string[]; //key, value + path: string; +}; + +//https://docs.wise.com/api-docs/features/errors#validation-errors +export type ValidationContent = { + timestamp: string; + errors: ValidationError[]; +};