From fcdc6f4738a3e9b301d8b1b835c795f2fe6fd9c8 Mon Sep 17 00:00:00 2001 From: r41ph Date: Mon, 13 Jan 2025 09:57:08 +0000 Subject: [PATCH] feat: save step 2 state and return to step 1 when missing card details --- .../AgreePurchaseForm.Retirement.tsx | 77 +++++++++++++++++-- .../AgreePurchaseForm/AgreePurchaseForm.tsx | 40 +++++++--- .../AgreePurchaseFormFiat.tsx | 9 ++- .../PaymentInfoForm/PaymentInfoForm.tsx | 5 +- .../MultiStepTemplate/MultiStep.context.tsx | 27 ++++--- .../MultiStepTemplate/MultiStepTemplate.tsx | 3 + web-marketplace/src/hooks/useStorage.ts | 5 +- .../src/pages/BuyCredits/BuyCredits.Form.tsx | 44 ++++++++++- .../src/pages/BuyCredits/BuyCredits.atoms.ts | 1 + .../src/pages/BuyCredits/BuyCredits.tsx | 24 +++++- 10 files changed, 193 insertions(+), 42 deletions(-) diff --git a/web-marketplace/src/components/organisms/AgreePurchaseForm/AgreePurchaseForm.Retirement.tsx b/web-marketplace/src/components/organisms/AgreePurchaseForm/AgreePurchaseForm.Retirement.tsx index 9c50591149..3b981b37e3 100644 --- a/web-marketplace/src/components/organisms/AgreePurchaseForm/AgreePurchaseForm.Retirement.tsx +++ b/web-marketplace/src/components/organisms/AgreePurchaseForm/AgreePurchaseForm.Retirement.tsx @@ -1,14 +1,14 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { msg, Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; +import { debounce } from 'lodash'; import Card from 'web-components/src/components/cards/Card'; -import CheckboxLabel from 'web-components/src/components/inputs/new/CheckboxLabel/CheckboxLabel'; import SelectTextField from 'web-components/src/components/inputs/new/SelectTextField/SelectTextField'; import TextField from 'web-components/src/components/inputs/new/TextField/TextField'; import QuestionMarkTooltip from 'web-components/src/components/tooltip/QuestionMarkTooltip'; -import { Body, Title } from 'web-components/src/components/typography'; +import { Title } from 'web-components/src/components/typography'; import { COUNTRY_LABEL, @@ -18,6 +18,9 @@ import { STATE_LABEL, } from 'lib/constants/shared.constants'; +import { BuyCreditsSchemaTypes } from 'pages/BuyCredits/BuyCredits.types'; +import { useMultiStep } from 'components/templates/MultiStepTemplate'; + import { AgreePurchaseFormSchemaType } from './AgreePurchaseForm.schema'; const LocationCountryField = lazy( @@ -38,13 +41,18 @@ type Props = { retiring: boolean }; export const Retirement = ({ retiring }: Props) => { const { _ } = useLingui(); const ctx = useFormContext(); - const { register, formState, control } = ctx; + const { register, formState, control, getValues } = ctx; const { errors } = formState; + const { + data, + handleSave: updateMultiStepData, + activeStep, + } = useMultiStep(); - const anonymousPurchase = useWatch({ - control: control, - name: 'anonymousPurchase', - }); + // const anonymousPurchase = useWatch({ + // control: control, + // name: 'anonymousPurchase', + // }); const country = useWatch({ control: control, name: 'country', @@ -53,6 +61,59 @@ export const Retirement = ({ retiring }: Props) => { control: control, name: 'stateProvince', }); + const retirementReason = useWatch({ + control: control, + name: 'retirementReason', + }); + const postalCode = useWatch({ + control: control, + name: 'postalCode', + }); + + const { anonymousPurchase, followProject, subscribeNewsletter, agreeErpa } = + getValues(); + + useEffect(() => { + debounce(() => { + updateMultiStepData( + { + ...data, + retirementReason, + postalCode, + }, + activeStep, + ); + }, 500)(); + // Intentionally omit `updateMultiStepData` and `data` from the dependency array + // because including them trigger unnecessary renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [retirementReason, postalCode, activeStep]); + + useEffect(() => { + updateMultiStepData( + { + ...data, + country, + stateProvince, + anonymousPurchase, + followProject, + subscribeNewsletter, + agreeErpa, + }, + activeStep, + ); + // Intentionally omit `updateMultiStepData` and `data` from the dependency array + // because including them trigger unnecessary renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + country, + stateProvince, + anonymousPurchase, + followProject, + subscribeNewsletter, + agreeErpa, + activeStep, + ]); return (
diff --git a/web-marketplace/src/components/organisms/AgreePurchaseForm/AgreePurchaseForm.tsx b/web-marketplace/src/components/organisms/AgreePurchaseForm/AgreePurchaseForm.tsx index b4d20907e2..277d9a087d 100644 --- a/web-marketplace/src/components/organisms/AgreePurchaseForm/AgreePurchaseForm.tsx +++ b/web-marketplace/src/components/organisms/AgreePurchaseForm/AgreePurchaseForm.tsx @@ -1,13 +1,15 @@ import { useEffect } from 'react'; -import { useFormState, useWatch } from 'react-hook-form'; +import { DefaultValues, useFormState, useWatch } from 'react-hook-form'; import { msg, Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { Stripe, StripeElements } from '@stripe/stripe-js'; +import { useAtom } from 'jotai'; import CheckboxLabel from 'web-components/src/components/inputs/new/CheckboxLabel/CheckboxLabel'; import { PrevNextButtons } from 'web-components/src/components/molecules/PrevNextButtons/PrevNextButtons'; import { Body } from 'web-components/src/components/typography/Body'; +import { cardDetailsMissingAtom } from 'pages/BuyCredits/BuyCredits.atoms'; import AgreeErpaCheckbox from 'components/atoms/AgreeErpaCheckboxNew'; import Form from 'components/molecules/Form/Form'; import { useZodForm } from 'components/molecules/Form/hook/useZodForm'; @@ -30,6 +32,8 @@ export type AgreePurchaseFormProps = { country?: string; stripe?: Stripe | null; elements?: StripeElements | null; + initialValues?: DefaultValues; + isCardPayment?: boolean; } & TradableProps; export const AgreePurchaseForm = ({ @@ -40,19 +44,17 @@ export const AgreePurchaseForm = ({ elements, goToChooseCredits, imgSrc, + initialValues, + isCardPayment, }: AgreePurchaseFormProps) => { const { _ } = useLingui(); - const { handleBack } = useMultiStep(); - + const { handleBack, handleActiveStep } = useMultiStep(); + const [cardDetailsMissing, setCardDetailsMissing] = useAtom( + cardDetailsMissingAtom, + ); const form = useZodForm({ schema: agreePurchaseFormSchema(retiring), - defaultValues: { - country, - anonymousPurchase: false, - followProject: false, - subscribeNewsletter: false, - agreeErpa: false, - }, + defaultValues: initialValues, mode: 'onBlur', }); const { errors, isValid, isSubmitting } = useFormState({ @@ -76,6 +78,24 @@ export const AgreePurchaseForm = ({ form.setValue('country', country); }, [country, form]); + useEffect(() => { + if (isCardPayment && cardDetailsMissing) { + handleActiveStep(1); + } + }, [ + handleActiveStep, + isCardPayment, + cardDetailsMissing, + setCardDetailsMissing, + ]); + + // reset cardDetailsMissing on unmount + useEffect(() => { + return () => { + setCardDetailsMissing(true); + }; + }, [setCardDetailsMissing]); + return (
{ const stripe = useStripe(); const elements = useElements(); - return ; + return ( + + ); }; diff --git a/web-marketplace/src/components/organisms/PaymentInfoForm/PaymentInfoForm.tsx b/web-marketplace/src/components/organisms/PaymentInfoForm/PaymentInfoForm.tsx index 6ee5889afc..c6b614bac2 100644 --- a/web-marketplace/src/components/organisms/PaymentInfoForm/PaymentInfoForm.tsx +++ b/web-marketplace/src/components/organisms/PaymentInfoForm/PaymentInfoForm.tsx @@ -2,10 +2,12 @@ import { useEffect, useMemo, useState } from 'react'; import { DefaultValues, useFormState, useWatch } from 'react-hook-form'; import { useLingui } from '@lingui/react'; import { Stripe, StripeElements } from '@stripe/stripe-js'; +import { useSetAtom } from 'jotai'; import { PrevNextButtons } from 'web-components/src/components/molecules/PrevNextButtons/PrevNextButtons'; import { UseStateSetter } from 'web-components/src/types/react/useState'; +import { cardDetailsMissingAtom } from 'pages/BuyCredits/BuyCredits.atoms'; import { NEXT, PAYMENT_OPTIONS } from 'pages/BuyCredits/BuyCredits.constants'; import { CardDetails, @@ -62,7 +64,7 @@ export const PaymentInfoForm = ({ data, } = useMultiStep(); const [paymentInfoValid, setPaymentInfoValid] = useState(false); - + const setCardDetailsMissing = useSetAtom(cardDetailsMissingAtom); const form = useZodForm({ schema: paymentInfoFormSchema(paymentOption, wallet), defaultValues: { @@ -110,6 +112,7 @@ export const PaymentInfoForm = ({ { + setCardDetailsMissing(false); const card = paymentOption === PAYMENT_OPTIONS.CARD; if (card && !stripe) { return; diff --git a/web-marketplace/src/components/templates/MultiStepTemplate/MultiStep.context.tsx b/web-marketplace/src/components/templates/MultiStepTemplate/MultiStep.context.tsx index 9e08427a2d..bc53f9da93 100644 --- a/web-marketplace/src/components/templates/MultiStepTemplate/MultiStep.context.tsx +++ b/web-marketplace/src/components/templates/MultiStepTemplate/MultiStep.context.tsx @@ -89,6 +89,7 @@ export type ProviderProps = { steps: Step[]; children?: JSX.Element | JSX.Element[]; withLocalStorage?: boolean; + forceStep?: number; }; export function MultiStepProvider({ @@ -97,18 +98,19 @@ export function MultiStepProvider({ steps, children, withLocalStorage = true, -}: React.PropsWithChildren>): JSX.Element { + forceStep, +}: React.PropsWithChildren>): JSX.Element | null { // we don't pass initialValues to localStorage // to avoid persist the initial empty data structure. // So initially, data (from storage) is `undefined` or // previously persisted data. // If undefined, then we return the initialValues - const { data, saveData, removeData } = useStorage>( + const { data, saveData, removeData, isLoading } = useStorage>( formId, withLocalStorage, ); - const maxAllowedStep = data?.maxAllowedStep || 0; + const maxAllowedStep = forceStep || data?.maxAllowedStep || 0; const { activeStep, @@ -118,18 +120,10 @@ export function MultiStepProvider({ handleActiveStep, goNext, goBack, - } = useSteps(steps.length); + } = useSteps(steps.length, maxAllowedStep); const [resultStatus, setResultStatus] = React.useState(); - // initial step on mount - React.useEffect(() => { - if (data?.maxAllowedStep) { - handleActiveStep(data?.maxAllowedStep); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const handleNext = (): void => { goNext(); }; @@ -202,6 +196,11 @@ export function MultiStepProvider({ resultStatus, }; + // Wait to check local storage before rendering component + if (isLoading) { + return null; + } + return ( {children} @@ -245,8 +244,8 @@ function calculatePercentComplete( return (activeStep * 100) / numSteps; } -function useSteps(numSteps: number): StepManagement { - const [activeStep, setActiveStep] = React.useState(0); +function useSteps(numSteps: number, startStep?: number): StepManagement { + const [activeStep, setActiveStep] = React.useState(startStep || 0); // derived states from activeStep const isLastStep = activeStep === numSteps - 1; diff --git a/web-marketplace/src/components/templates/MultiStepTemplate/MultiStepTemplate.tsx b/web-marketplace/src/components/templates/MultiStepTemplate/MultiStepTemplate.tsx index 3d73f45b15..13a5b320ae 100644 --- a/web-marketplace/src/components/templates/MultiStepTemplate/MultiStepTemplate.tsx +++ b/web-marketplace/src/components/templates/MultiStepTemplate/MultiStepTemplate.tsx @@ -5,6 +5,7 @@ import { StepperSection } from './StepperSection'; type MultiStepProps = ProviderProps & { children: JSX.Element; + forceStep?: number; } & Pick; export function MultiStepTemplate({ @@ -14,6 +15,7 @@ export function MultiStepTemplate({ children, withLocalStorage, classes, + forceStep, }: MultiStepProps): JSX.Element { return ( ({ steps={steps} initialValues={initialValues} withLocalStorage={withLocalStorage} + forceStep={forceStep} > {children} diff --git a/web-marketplace/src/hooks/useStorage.ts b/web-marketplace/src/hooks/useStorage.ts index 2c242bd4f1..d26b5d6691 100644 --- a/web-marketplace/src/hooks/useStorage.ts +++ b/web-marketplace/src/hooks/useStorage.ts @@ -6,6 +6,7 @@ interface StorageApi { data: T | undefined; saveData: React.Dispatch; removeData: () => void; + isLoading: boolean; } export default function useStorage( @@ -13,6 +14,7 @@ export default function useStorage( withLocalStorage: boolean, initialValue?: T, ): StorageApi { + const [isLoading, setIsLoading] = useState(true); const [data, saveData] = useState(() => { // this way, as a fn, this initialization happens just once if (withLocalStorage) { @@ -26,6 +28,7 @@ export default function useStorage( }); useEffect(() => { + setIsLoading(false); if (withLocalStorage && data) { const storedValue = localStorage.getItem(key); const currentValue = storedValue ? JSON.parse(storedValue) : {}; @@ -52,5 +55,5 @@ export default function useStorage( } }; - return { data, saveData, removeData }; + return { data, saveData, removeData, isLoading }; } diff --git a/web-marketplace/src/pages/BuyCredits/BuyCredits.Form.tsx b/web-marketplace/src/pages/BuyCredits/BuyCredits.Form.tsx index de38a1a03c..fa149beb9e 100644 --- a/web-marketplace/src/pages/BuyCredits/BuyCredits.Form.tsx +++ b/web-marketplace/src/pages/BuyCredits/BuyCredits.Form.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe, Stripe, StripeElements } from '@stripe/stripe-js'; import { useQuery } from '@tanstack/react-query'; import { USD_DENOM } from 'config/allowedBaseDenoms'; -import { useAtom, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { UseStateSetter } from 'web-components/src/types/react/useState'; @@ -51,6 +51,7 @@ import { PaymentInfoFormFiat } from 'components/organisms/PaymentInfoForm/Paymen import { useMultiStep } from 'components/templates/MultiStepTemplate'; import { + cardDetailsMissingAtom, paymentOptionAtom, paymentOptionCryptoClickedAtom, } from './BuyCredits.atoms'; @@ -100,6 +101,7 @@ export const BuyCreditsForm = ({ const { wallet, isConnected, activeWalletAddr } = useWallet(); const { activeAccount, privActiveAccount } = useAuth(); const [paymentOption, setPaymentOption] = useAtom(paymentOptionAtom); + const cardDetailsMissing = useAtomValue(cardDetailsMissingAtom); const { isModalOpen, modalState, @@ -158,6 +160,22 @@ export const BuyCreditsForm = ({ setPaymentOption(prev => data?.paymentOption || prev); }, [data, setPaymentOption, setRetiring]); + useEffect(() => { + if ( + paymentOption === PAYMENT_OPTIONS.CARD && + cardDetailsMissing && + activeStep === 2 + ) { + handleActiveStep(1); + } + }, [ + handleActiveStep, + cardDetails, + paymentOption, + activeStep, + cardDetailsMissing, + ]); + const paymentInfoFormSubmit = useCallback( async (values: PaymentInfoFormSchemaType) => { const { paymentMethodId, ...others } = values; @@ -362,13 +380,23 @@ export const BuyCreditsForm = ({ setCardDetails={setCardDetails} /> )} - {activeStep === 2 && ( + {activeStep === 2 && !cardDetailsMissing && ( handleActiveStep(0)} imgSrc="/svg/info-with-hand.svg" country={cardDetails?.country || 'US'} + initialValues={{ + country: data?.country || cardDetails?.country || 'US', + stateProvince: data?.stateProvince || '', + postalCode: data?.postalCode || '', + retirementReason: data?.retirementReason || '', + anonymousPurchase: data?.anonymousPurchase || false, + followProject: data?.followProject || false, + subscribeNewsletter: data?.subscribeNewsletter || false, + agreeErpa: data?.agreeErpa || false, + }} /> )} @@ -403,6 +431,16 @@ export const BuyCreditsForm = ({ goToChooseCredits={() => handleActiveStep(0)} imgSrc="/svg/info-with-hand.svg" country={cardDetails?.country || 'US'} + initialValues={{ + country: data?.country || 'US', + stateProvince: data?.stateProvince || '', + postalCode: data?.postalCode || '', + retirementReason: data?.retirementReason || '', + anonymousPurchase: data?.anonymousPurchase || false, + followProject: data?.followProject || false, + subscribeNewsletter: data?.subscribeNewsletter || false, + agreeErpa: data?.agreeErpa || false, + }} /> )} diff --git a/web-marketplace/src/pages/BuyCredits/BuyCredits.atoms.ts b/web-marketplace/src/pages/BuyCredits/BuyCredits.atoms.ts index cc2f974ecd..54f898bdd7 100644 --- a/web-marketplace/src/pages/BuyCredits/BuyCredits.atoms.ts +++ b/web-marketplace/src/pages/BuyCredits/BuyCredits.atoms.ts @@ -9,3 +9,4 @@ export const spendingCapAtom = atom(null); export const paymentOptionAtom = atom( PAYMENT_OPTIONS.CRYPTO, ); +export const cardDetailsMissingAtom = atom(true); diff --git a/web-marketplace/src/pages/BuyCredits/BuyCredits.tsx b/web-marketplace/src/pages/BuyCredits/BuyCredits.tsx index 048feb8ad7..d865998f44 100644 --- a/web-marketplace/src/pages/BuyCredits/BuyCredits.tsx +++ b/web-marketplace/src/pages/BuyCredits/BuyCredits.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { useWallet } from 'lib/wallet/wallet'; @@ -10,10 +10,10 @@ import { MultiStepTemplate } from 'components/templates/MultiStepTemplate'; import { useGetProject } from 'components/templates/ProjectDetails/hooks/useGetProject'; import { useNavigateToSlug } from 'components/templates/ProjectDetails/hooks/useNavigateToSlug'; -import { paymentOptionAtom } from './BuyCredits.atoms'; +import { cardDetailsMissingAtom, paymentOptionAtom } from './BuyCredits.atoms'; import { PAYMENT_OPTIONS } from './BuyCredits.constants'; import { BuyCreditsForm } from './BuyCredits.Form'; -import { CardDetails } from './BuyCredits.types'; +import { CardDetails, PaymentOptionsType } from './BuyCredits.types'; import { getFormModel } from './BuyCredits.utils'; import { useSummarizePayment } from './hooks/useSummarizePayment'; @@ -37,7 +37,8 @@ export const BuyCredits = () => { useNavigateToSlug(slug, '/buy'); - const paymentOption = useAtomValue(paymentOptionAtom); + const [paymentOption, setPaymentOption] = useAtom(paymentOptionAtom); + const cardDetailsMissing = useAtomValue(cardDetailsMissingAtom); const { wallet, loaded } = useWallet(); useEffect(() => { @@ -73,6 +74,16 @@ export const BuyCredits = () => { projectId: onChainProjectId ?? offChainProject?.id, }); + // update payment option from local storage data + useEffect(() => { + const savedPaymentOption = localStorage.getItem(formModel.formId); + if (savedPaymentOption) { + const paymentOption = + JSON.parse(savedPaymentOption).formValues.paymentOption; + setPaymentOption(paymentOption as PaymentOptionsType); + } + }, [formModel?.formId, setPaymentOption]); + const summarizePayment = useSummarizePayment(setCardDetails); useEffect(() => { if (confirmationTokenId) summarizePayment(confirmationTokenId); @@ -97,6 +108,11 @@ export const BuyCredits = () => { steps={formModel.steps} initialValues={{}} classes={{ formWrap: 'max-w-[942px]' }} + forceStep={ + paymentOption === PAYMENT_OPTIONS.CARD && cardDetailsMissing + ? 1 + : undefined + } >