diff --git a/web-marketplace/src/components/organisms/BuyFiatModal/BuyFiatModal.tsx b/web-marketplace/src/components/organisms/BuyFiatModal/BuyFiatModal.tsx new file mode 100644 index 0000000000..a2f9b9c53e --- /dev/null +++ b/web-marketplace/src/components/organisms/BuyFiatModal/BuyFiatModal.tsx @@ -0,0 +1,46 @@ +import { Box } from '@mui/material'; + +import OutlinedButton from 'web-components/src/components/buttons/OutlinedButton'; +import Card from 'web-components/src/components/cards/Card'; +import SellOrderNotFoundIcon from 'web-components/src/components/icons/SellOrderNotFoundIcon'; +import Modal from 'web-components/src/components/modal'; +import { Title } from 'web-components/src/components/typography'; + +import { UseStateSetter } from 'types/react/use-state'; + +import { UserCanPurchaseCreditsType } from './BuyFiatModal.types'; + +interface BuyFiatModalProps { + title: string; + content: React.ReactNode; + button: { text: string; href: string | null }; + userCanPurchaseCredits: UserCanPurchaseCreditsType; + onClose: UseStateSetter; + handleClick: () => void; +} + +export const BuyFiatModal = ({ + title, + content, + button, + userCanPurchaseCredits, + onClose, + handleClick, +}: BuyFiatModalProps) => { + return ( + onClose({ ...userCanPurchaseCredits, openModal: false })} + className="w-[560px] !py-40 !px-30" + > + + + + {title} + + {content} + {button.text} + + + ); +}; diff --git a/web-marketplace/src/components/organisms/BuyFiatModal/BuyFiatModal.types.ts b/web-marketplace/src/components/organisms/BuyFiatModal/BuyFiatModal.types.ts new file mode 100644 index 0000000000..cbfc16054b --- /dev/null +++ b/web-marketplace/src/components/organisms/BuyFiatModal/BuyFiatModal.types.ts @@ -0,0 +1,4 @@ +export type UserCanPurchaseCreditsType = { + openModal: boolean; + amountAvailable: number; +}; diff --git a/web-marketplace/src/pages/BuyCredits/BuyCredits.Form.tsx b/web-marketplace/src/pages/BuyCredits/BuyCredits.Form.tsx index eef9807eb7..f9f93c313a 100644 --- a/web-marketplace/src/pages/BuyCredits/BuyCredits.Form.tsx +++ b/web-marketplace/src/pages/BuyCredits/BuyCredits.Form.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { msg, Trans } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe, Stripe, StripeElements } from '@stripe/stripe-js'; import { useQuery } from '@tanstack/react-query'; @@ -20,7 +22,9 @@ import { getAllowedDenomQuery } from 'lib/queries/react-query/ecocredit/marketpl import { getPaymentMethodsQuery } from 'lib/queries/react-query/registry-server/getPaymentMethodsQuery/getPaymentMethodsQuery'; import { useWallet } from 'lib/wallet/wallet'; +import { useFetchSellOrders } from 'features/marketplace/BuySellOrderFlow/hooks/useFetchSellOrders'; import { UISellOrderInfo } from 'pages/Projects/AllProjects/AllProjects.types'; +import { normalizeToUISellOrderInfo } from 'pages/Projects/hooks/useProjectsSellOrders.utils'; import { CREDIT_VINTAGE_OPTIONS, CREDITS_AMOUNT, @@ -28,12 +32,16 @@ import { CURRENCY_AMOUNT, SELL_ORDERS, } from 'components/molecules/CreditsAmount/CreditsAmount.constants'; +import { getCurrencyAmount } from 'components/molecules/CreditsAmount/CreditsAmount.utils'; +import { findDisplayDenom } from 'components/molecules/DenomLabel/DenomLabel.utils'; import { AgreePurchaseForm } from 'components/organisms/AgreePurchaseForm/AgreePurchaseForm'; import { AgreePurchaseFormSchemaType } from 'components/organisms/AgreePurchaseForm/AgreePurchaseForm.schema'; import { AgreePurchaseFormFiat } from 'components/organisms/AgreePurchaseForm/AgreePurchaseFormFiat'; +import { BuyFiatModal } from 'components/organisms/BuyFiatModal/BuyFiatModal'; import { ChooseCreditsForm } from 'components/organisms/ChooseCreditsForm/ChooseCreditsForm'; import { ChooseCreditsFormSchemaType } from 'components/organisms/ChooseCreditsForm/ChooseCreditsForm.schema'; import { CardSellOrder } from 'components/organisms/ChooseCreditsForm/ChooseCreditsForm.types'; +import { getFilteredCryptoSellOrders } from 'components/organisms/ChooseCreditsForm/ChooseCreditsForm.utils'; import { useLoginData } from 'components/organisms/LoginButton/hooks/useLoginData'; import { LoginFlow } from 'components/organisms/LoginFlow/LoginFlow'; import { PaymentInfoForm } from 'components/organisms/PaymentInfoForm/PaymentInfoForm'; @@ -49,6 +57,7 @@ import { CardDetails, PaymentOptionsType, } from './BuyCredits.types'; +import { findMatchingSellOrders } from './BuyCredits.utils'; import { usePurchase } from './hooks/usePurchase'; type Props = { @@ -67,6 +76,7 @@ type Props = { projectHref: string; cardDetails?: CardDetails; }; + export const BuyCreditsForm = ({ paymentOption, setPaymentOption, @@ -95,6 +105,7 @@ export const BuyCreditsForm = ({ onButtonClick, } = useLoginData({}); const navigate = useNavigate(); + const { _ } = useLingui(); const setErrorBannerTextAtom = useSetAtom(errorBannerTextAtom); const setConnectWalletModal = useSetAtom(connectWalletModalAtom); @@ -102,6 +113,12 @@ export const BuyCreditsForm = ({ const [paymentOptionCryptoClicked, setPaymentOptionCryptoClicked] = useAtom( paymentOptionCryptoClickedAtom, ); + const [userCanPurchaseCredits, setUserCanPurchaseCredits] = useState({ + openModal: false, + amountAvailable: 0, + }); + + const { refetchSellOrders } = useFetchSellOrders(); const cardDisabled = cardSellOrders.length === 0; @@ -165,35 +182,61 @@ export const BuyCreditsForm = ({ stripe?: Stripe | null, elements?: StripeElements | null, ) => { - const { retirementReason, country, stateProvince, postalCode } = values; - const { - sellOrders: selectedSellOrders, - email, - name, - savePaymentMethod, - createAccount: createActiveAccount, - // subscribeNewsletter, TODO - // followProject, - } = data; + const sellOrders = await refetchSellOrders(); + const requestedSellOrders = findMatchingSellOrders( + data, + sellOrders?.map(normalizeToUISellOrderInfo), + ); + const currentAvailableCredits = requestedSellOrders.reduce( + (credits, sellOrder) => credits + Number(sellOrder.quantity), + 0, + ); + const sellCanProceed = + data.creditsAmount && data.creditsAmount < currentAvailableCredits; + const partialCreditsAvailable = + data.creditsAmount && data.creditsAmount > currentAvailableCredits; - if (selectedSellOrders) - purchase({ - paymentOption, - selectedSellOrders, - retiring, - retirementReason, - country, - stateProvince, - postalCode, + if (sellCanProceed) { + const { retirementReason, country, stateProvince, postalCode } = values; + const { + sellOrders: selectedSellOrders, email, name, savePaymentMethod, - createActiveAccount, - paymentMethodId, - stripe, - elements, - confirmationTokenId, + createAccount: createActiveAccount, + // subscribeNewsletter, TODO + // followProject, + } = data; + + if (selectedSellOrders) + purchase({ + paymentOption, + selectedSellOrders, + retiring, + retirementReason, + country, + stateProvince, + postalCode, + email, + name, + savePaymentMethod, + createActiveAccount, + paymentMethodId, + stripe, + elements, + confirmationTokenId, + }); + } else if (partialCreditsAvailable) { + setUserCanPurchaseCredits({ + openModal: true, + amountAvailable: currentAvailableCredits, + }); + } else { + setUserCanPurchaseCredits({ + openModal: true, + amountAvailable: 0, }); + } }, [ confirmationTokenId, @@ -201,10 +244,58 @@ export const BuyCreditsForm = ({ paymentMethodId, paymentOption, purchase, + refetchSellOrders, retiring, ], ); + const fiatModalConfig = + userCanPurchaseCredits.amountAvailable > 0 + ? { + title: _( + msg`Sorry, another user has purchased some or all of the credits you selected!`, + ), + content: ( + <> +

+ amount now available in + {` ${findDisplayDenom({ + allowedDenoms: allowedDenomsData?.allowedDenoms, + bankDenom: data?.currency?.askDenom!, + baseDenom: data?.currency?.askBaseDenom!, + })}`} +

+ {userCanPurchaseCredits.amountAvailable} + + ), + button: { text: _(msg`Choose new credits`), href: null }, + } + : { + title: _( + msg`Sorry, another user has purchased all of the available credits from this project`, + ), + content: ( +

+ + Because we use blockchain technology, if another user purchases + the credits before you check out, you’ll need to choose + different credits. + +

+ ), + button: { text: _(msg`search for new credits`), href: '/projects' }, + }; + + const filteredCryptoSellOrders = useMemo( + () => + getFilteredCryptoSellOrders({ + askDenom: data.currency?.askDenom, + cryptoSellOrders, + retiring, + }), + [cryptoSellOrders, data.currency?.askDenom, retiring], + ); + return (
@@ -329,6 +420,43 @@ export const BuyCreditsForm = ({ wallets={walletsUiConfig} modalState={modalState} /> + { + // If there is no credits available, we need to navigate to the projects page + if (fiatModalConfig.button.href) { + navigate(fiatModalConfig.button.href); + } else { + setUserCanPurchaseCredits({ + ...userCanPurchaseCredits, + openModal: false, + }); + // After a purchase attempt where there's partial credits availablility, + // we need to update the form with the new credits and currency amounts. + handleSaveNext({ + ...data, + [CREDITS_AMOUNT]: userCanPurchaseCredits.amountAvailable, + [CURRENCY_AMOUNT]: getCurrencyAmount({ + currentCreditsAmount: userCanPurchaseCredits.amountAvailable, + card: paymentOption === PAYMENT_OPTIONS.CARD, + orderedSellOrders: + paymentOption === PAYMENT_OPTIONS.CARD + ? cardSellOrders.sort((a, b) => a.usdPrice - b.usdPrice) + : filteredCryptoSellOrders?.sort( + (a, b) => Number(a.askAmount) - Number(b.askAmount), + ) || [], + creditTypePrecision: creditTypeData?.creditType?.precision, + }).currencyAmount, + }); + window.scrollTo(0, 0); + handleActiveStep(0); + } + }} + />
); }; diff --git a/web-marketplace/src/pages/BuyCredits/BuyCredits.utils.ts b/web-marketplace/src/pages/BuyCredits/BuyCredits.utils.ts index 6c513b694a..c1501f3ea6 100644 --- a/web-marketplace/src/pages/BuyCredits/BuyCredits.utils.ts +++ b/web-marketplace/src/pages/BuyCredits/BuyCredits.utils.ts @@ -1,12 +1,11 @@ import { msg } from '@lingui/macro'; -import { Project } from 'generated/sanity-graphql'; import { TranslatorType } from 'lib/i18n/i18n.types'; import { UISellOrderInfo } from 'pages/Projects/AllProjects/AllProjects.types'; import { PAYMENT_OPTIONS } from './BuyCredits.constants'; -import { PaymentOptionsType } from './BuyCredits.types'; +import { BuyCreditsSchemaTypes, PaymentOptionsType } from './BuyCredits.types'; type GetFormModelParams = { _: TranslatorType; @@ -42,3 +41,14 @@ export const getFormModel = ({ ], }; }; + +export const findMatchingSellOrders = ( + data: BuyCreditsSchemaTypes, + sellOrders: UISellOrderInfo[] | undefined, +) => { + if (!sellOrders) return []; + + const sellOrderIds = data?.sellOrders?.map(order => order.sellOrderId); + + return sellOrders.filter(order => sellOrderIds?.includes(order.id)); +};