Add onPurchaseSuccess callback for Pay (#4101)

## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds `onPurchaseSuccess` callback to various components and hooks in the codebase, triggering when a user completes a purchase using thirdweb pay.

### Detailed summary
- Added `onPurchaseSuccess` callback to `PayEmbed`, `ConnectButton`, `TransactionButton`, and `useSendTransaction`
- Updated props in multiple components to include `onSuccess` callback parameter
- Added type definitions for `BuyWithCryptoStatus` and `BuyWithFiatStatus`
- Implemented logic to call `onSuccess` callback upon successful purchase completion

> The following files were skipped due to too many changes: `packages/thirdweb/src/react/web/ui/ConnectWallet/screens/Buy/fiat/FiatFlow.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
MananTank
2024-08-14 21:05:41 +00:00
parent 874ef7af4e
commit 03a809a33f
14 changed files with 173 additions and 3 deletions
+52
View File
@@ -0,0 +1,52 @@
---
"thirdweb": patch
---
Add `onPurchaseSuccess` callback to `PayEmbed`, `ConnectButton`, `TransactionButton` and `useSendTransaction` and gets called when user completes the purchase using thirdweb pay.
```tsx
<PayEmbed
client={client}
payOptions={{
onPurchaseSuccess(info) {
console.log("purchase success", info);
},
}}
/>
```
```tsx
<ConnectButton
client={client}
detailsModal={{
payOptions: {
onPurchaseSuccess(info) {
console.log("purchase success", info);
},
},
}}
/>
```
```tsx
<TransactionButton
transaction={...}
payModal={{
onPurchaseSuccess(info) {
console.log("purchase success", info);
},
}}
>
Some Transaction
</TransactionButton>
```
```ts
const sendTransaction = useSendTransaction({
payModal: {
onPurchaseSuccess(info) {
console.log("purchase success", info);
},
},
});
```
+3
View File
@@ -18,5 +18,8 @@
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
} }
} }
Submodule apps/hardhat-boilerplate added at 638fd58ec8
@@ -1,5 +1,7 @@
import type { Chain } from "../../../../chains/types.js"; import type { Chain } from "../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../client/client.js"; import type { ThirdwebClient } from "../../../../client/client.js";
import type { BuyWithCryptoStatus } from "../../../../pay/buyWithCrypto/getStatus.js";
import type { BuyWithFiatStatus } from "../../../../pay/buyWithFiat/getStatus.js";
import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js"; import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js";
import type { Prettify } from "../../../../utils/type-utils.js"; import type { Prettify } from "../../../../utils/type-utils.js";
import type { Account, Wallet } from "../../../../wallets/interfaces/wallet.js"; import type { Account, Wallet } from "../../../../wallets/interfaces/wallet.js";
@@ -89,6 +91,21 @@ export type PayUIOptions = Prettify<
* This details will be stored with the purchase and can be retrieved later via the status API or Webhook * This details will be stored with the purchase and can be retrieved later via the status API or Webhook
*/ */
purchaseData?: object; purchaseData?: object;
/**
* Callback to be called when the user successfully completes the purchase.
*/
onPurchaseSuccess?: (
info:
| {
type: "crypto";
status: BuyWithCryptoStatus;
}
| {
type: "fiat";
status: BuyWithFiatStatus;
},
) => void;
} & (FundWalletOptions | DirectPaymentOptions | TranasctionOptions) } & (FundWalletOptions | DirectPaymentOptions | TranasctionOptions)
>; >;
@@ -1,5 +1,7 @@
import { type UseMutationResult, useMutation } from "@tanstack/react-query"; import { type UseMutationResult, useMutation } from "@tanstack/react-query";
import type { Chain } from "../../../../chains/types.js"; import type { Chain } from "../../../../chains/types.js";
import type { BuyWithCryptoStatus } from "../../../../pay/buyWithCrypto/getStatus.js";
import type { BuyWithFiatStatus } from "../../../../pay/buyWithFiat/getStatus.js";
import type { GaslessOptions } from "../../../../transaction/actions/gasless/types.js"; import type { GaslessOptions } from "../../../../transaction/actions/gasless/types.js";
import { sendTransaction } from "../../../../transaction/actions/send-transaction.js"; import { sendTransaction } from "../../../../transaction/actions/send-transaction.js";
import type { WaitForReceiptOptions } from "../../../../transaction/actions/wait-for-tx-receipt.js"; import type { WaitForReceiptOptions } from "../../../../transaction/actions/wait-for-tx-receipt.js";
@@ -46,6 +48,20 @@ export type SendTransactionPayModalConfig =
testMode?: boolean; testMode?: boolean;
}; };
purchaseData?: object; purchaseData?: object;
/**
* Callback to be called when the user successfully completes the purchase.
*/
onPurchaseSuccess?: (
info:
| {
type: "crypto";
status: BuyWithCryptoStatus;
}
| {
type: "fiat";
status: BuyWithFiatStatus;
},
) => void;
} }
| false; | false;
@@ -132,6 +132,7 @@ export function useSendTransaction(config: SendTransactionConfig = {}) {
mode: "transaction", mode: "transaction",
transaction: data.tx, transaction: data.tx,
metadata: payModal?.metadata, metadata: payModal?.metadata,
onPurchaseSuccess: payModal?.onPurchaseSuccess,
}} }}
/>, />,
); );
@@ -4,6 +4,8 @@ import type { Chain } from "../../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../../client/client.js"; import type { ThirdwebClient } from "../../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../constants/addresses.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../../constants/addresses.js";
import type { GetBuyWithCryptoQuoteParams } from "../../../../../../pay/buyWithCrypto/getQuote.js"; import type { GetBuyWithCryptoQuoteParams } from "../../../../../../pay/buyWithCrypto/getQuote.js";
import type { BuyWithCryptoStatus } from "../../../../../../pay/buyWithCrypto/getStatus.js";
import type { BuyWithFiatStatus } from "../../../../../../pay/buyWithFiat/getStatus.js";
import { isSwapRequiredPostOnramp } from "../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js"; import { isSwapRequiredPostOnramp } from "../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js";
import { formatNumber } from "../../../../../../utils/formatNumber.js"; import { formatNumber } from "../../../../../../utils/formatNumber.js";
import type { Account } from "../../../../../../wallets/interfaces/wallet.js"; import type { Account } from "../../../../../../wallets/interfaces/wallet.js";
@@ -204,6 +206,26 @@ function BuyScreenContent(props: BuyScreenContentProps) {
// screens ---------------------------- // screens ----------------------------
const onSwapSuccess = useCallback(
(_status: BuyWithCryptoStatus) => {
props.payOptions.onPurchaseSuccess?.({
type: "crypto",
status: _status,
});
},
[props.payOptions.onPurchaseSuccess],
);
const onFiatSuccess = useCallback(
(_status: BuyWithFiatStatus) => {
props.payOptions.onPurchaseSuccess?.({
type: "fiat",
status: _status,
});
},
[props.payOptions.onPurchaseSuccess],
);
if (screen.id === "connect-payer-wallet") { if (screen.id === "connect-payer-wallet") {
return ( return (
<WalletSwitcherConnectionScreen <WalletSwitcherConnectionScreen
@@ -259,6 +281,7 @@ function BuyScreenContent(props: BuyScreenContentProps) {
id: "buy-with-crypto", id: "buy-with-crypto",
}); });
}} }}
onSuccess={onSwapSuccess}
/> />
); );
} }
@@ -284,6 +307,7 @@ function BuyScreenContent(props: BuyScreenContentProps) {
onDone={onDone} onDone={onDone}
isEmbed={props.isEmbed} isEmbed={props.isEmbed}
payer={payer} payer={payer}
onSuccess={onFiatSuccess}
/> />
); );
} }
@@ -1,7 +1,10 @@
import { useState } from "react"; import { useCallback, useState } from "react";
import type { ThirdwebClient } from "../../../../../../../client/client.js"; import type { ThirdwebClient } from "../../../../../../../client/client.js";
import type { BuyWithFiatQuote } from "../../../../../../../pay/buyWithFiat/getQuote.js"; import type { BuyWithFiatQuote } from "../../../../../../../pay/buyWithFiat/getQuote.js";
import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; import {
type BuyWithFiatStatus,
getBuyWithFiatStatus,
} from "../../../../../../../pay/buyWithFiat/getStatus.js";
import { isSwapRequiredPostOnramp } from "../../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js"; import { isSwapRequiredPostOnramp } from "../../../../../../../pay/buyWithFiat/isSwapRequiredPostOnramp.js";
import { openOnrampPopup } from "../openOnRamppopup.js"; import { openOnrampPopup } from "../openOnRamppopup.js";
import { addPendingTx } from "../swap/pendingSwapTx.js"; import { addPendingTx } from "../swap/pendingSwapTx.js";
@@ -48,6 +51,7 @@ export function FiatFlow(props: {
transactionMode: boolean; transactionMode: boolean;
isEmbed: boolean; isEmbed: boolean;
payer: PayerInfo; payer: PayerInfo;
onSuccess: (status: BuyWithFiatStatus) => void;
}) { }) {
const hasTwoSteps = isSwapRequiredPostOnramp(props.quote); const hasTwoSteps = isSwapRequiredPostOnramp(props.quote);
const [screen, setScreen] = useState<Screen>( const [screen, setScreen] = useState<Screen>(
@@ -101,10 +105,21 @@ export function FiatFlow(props: {
}} }}
transactionMode={props.transactionMode} transactionMode={props.transactionMode}
isEmbed={props.isEmbed} isEmbed={props.isEmbed}
onSuccess={props.onSuccess}
/> />
); );
} }
const onPostOnrampSuccess = useCallback(() => {
// report the status of fiat status instead of post onramp swap status when post onramp swap is successful
getBuyWithFiatStatus({
intentId: props.quote.intentId,
client: props.client,
}).then((status) => {
props.onSuccess(status);
});
}, [props.onSuccess, props.quote.intentId, props.client]);
if (screen.id === "postonramp-swap") { if (screen.id === "postonramp-swap") {
return ( return (
<PostOnRampSwapFlow <PostOnRampSwapFlow
@@ -120,6 +135,7 @@ export function FiatFlow(props: {
transactionMode={props.transactionMode} transactionMode={props.transactionMode}
isEmbed={props.isEmbed} isEmbed={props.isEmbed}
payer={props.payer} payer={props.payer}
onSuccess={onPostOnrampSuccess}
/> />
); );
} }
@@ -41,9 +41,10 @@ export function OnrampStatusScreen(props: {
onShowSwapFlow: (status: BuyWithFiatStatus) => void; onShowSwapFlow: (status: BuyWithFiatStatus) => void;
transactionMode: boolean; transactionMode: boolean;
isEmbed: boolean; isEmbed: boolean;
onSuccess: ((status: BuyWithFiatStatus) => void) | undefined;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { openedWindow } = props; const { openedWindow, onSuccess } = props;
const statusQuery = useBuyWithFiatStatus({ const statusQuery = useBuyWithFiatStatus({
intentId: props.intentId, intentId: props.intentId,
client: props.client, client: props.client,
@@ -62,6 +63,18 @@ export function OnrampStatusScreen(props: {
uiStatus = "completed"; uiStatus = "completed";
} }
const purchaseCbCalled = useRef(false);
useEffect(() => {
if (purchaseCbCalled.current || !onSuccess) {
return;
}
if (statusQuery.data?.status === "ON_RAMP_TRANSFER_COMPLETED") {
purchaseCbCalled.current = true;
onSuccess(statusQuery.data);
}
}, [onSuccess, statusQuery.data]);
// close the onramp popup if onramp is completed // close the onramp popup if onramp is completed
useEffect(() => { useEffect(() => {
if (!openedWindow || !statusQuery.data) { if (!openedWindow || !statusQuery.data) {
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ThirdwebClient } from "../../../../../../../client/client.js"; import type { ThirdwebClient } from "../../../../../../../client/client.js";
import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js";
import type { BuyWithCryptoStatus } from "../../../../../../../pay/buyWithCrypto/getStatus.js";
import { getPostOnRampQuote } from "../../../../../../../pay/buyWithFiat/getPostOnRampQuote.js"; import { getPostOnRampQuote } from "../../../../../../../pay/buyWithFiat/getPostOnRampQuote.js";
import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js";
import { iconSize } from "../../../../../../core/design-system/index.js"; import { iconSize } from "../../../../../../core/design-system/index.js";
@@ -23,6 +24,7 @@ export function PostOnRampSwap(props: {
transactionMode: boolean; transactionMode: boolean;
isEmbed: boolean; isEmbed: boolean;
payer: PayerInfo; payer: PayerInfo;
onSuccess: ((status: BuyWithCryptoStatus) => void) | undefined;
}) { }) {
const [lockedOnRampQuote, setLockedOnRampQuote] = useState< const [lockedOnRampQuote, setLockedOnRampQuote] = useState<
BuyWithCryptoQuote | undefined BuyWithCryptoQuote | undefined
@@ -130,6 +132,7 @@ export function PostOnRampSwap(props: {
}} }}
transactionMode={props.transactionMode} transactionMode={props.transactionMode}
isEmbed={props.isEmbed} isEmbed={props.isEmbed}
onSuccess={props.onSuccess}
/> />
); );
} }
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import type { ThirdwebClient } from "../../../../../../../client/client.js"; import type { ThirdwebClient } from "../../../../../../../client/client.js";
import type { BuyWithCryptoStatus } from "../../../../../../../pay/buyWithCrypto/getStatus.js";
import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js"; import type { BuyWithFiatStatus } from "../../../../../../../pay/buyWithFiat/getStatus.js";
import type { PayerInfo } from "../types.js"; import type { PayerInfo } from "../types.js";
import { type BuyWithFiatPartialQuote, FiatSteps } from "./FiatSteps.js"; import { type BuyWithFiatPartialQuote, FiatSteps } from "./FiatSteps.js";
@@ -22,6 +23,7 @@ export function PostOnRampSwapFlow(props: {
transactionMode: boolean; transactionMode: boolean;
isEmbed: boolean; isEmbed: boolean;
payer: PayerInfo; payer: PayerInfo;
onSuccess: ((status: BuyWithCryptoStatus) => void) | undefined;
}) { }) {
const [statusForSwap, setStatusForSwap] = useState< const [statusForSwap, setStatusForSwap] = useState<
BuyWithFiatStatus | undefined BuyWithFiatStatus | undefined
@@ -38,6 +40,7 @@ export function PostOnRampSwapFlow(props: {
transactionMode={props.transactionMode} transactionMode={props.transactionMode}
isEmbed={props.isEmbed} isEmbed={props.isEmbed}
payer={props.payer} payer={props.payer}
onSuccess={props.onSuccess}
/> />
); );
} }
@@ -73,6 +73,8 @@ export function FiatDetailsScreen(props: {
setStopPolling(true); setStopPolling(true);
}} }}
payer={props.payer} payer={props.payer}
// viewing history - ignore onSuccess
onSuccess={undefined}
/> />
); );
} }
@@ -3,6 +3,7 @@ import { getCachedChain } from "../../../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../../../client/client.js"; import type { ThirdwebClient } from "../../../../../../../client/client.js";
import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js"; import { NATIVE_TOKEN_ADDRESS } from "../../../../../../../constants/addresses.js";
import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js";
import type { BuyWithCryptoStatus } from "../../../../../../../pay/buyWithCrypto/getStatus.js";
import type { TokenInfo } from "../../../../../../core/utils/defaultTokens.js"; import type { TokenInfo } from "../../../../../../core/utils/defaultTokens.js";
import { type ERC20OrNativeToken, NATIVE_TOKEN } from "../../nativeToken.js"; import { type ERC20OrNativeToken, NATIVE_TOKEN } from "../../nativeToken.js";
import type { PayerInfo } from "../types.js"; import type { PayerInfo } from "../types.js";
@@ -20,6 +21,7 @@ type SwapFlowProps = {
onTryAgain: () => void; onTryAgain: () => void;
transactionMode: boolean; transactionMode: boolean;
isEmbed: boolean; isEmbed: boolean;
onSuccess: ((status: BuyWithCryptoStatus) => void) | undefined;
}; };
export function SwapFlow(props: SwapFlowProps) { export function SwapFlow(props: SwapFlowProps) {
@@ -84,6 +86,7 @@ export function SwapFlow(props: SwapFlowProps) {
transactionMode={props.transactionMode} transactionMode={props.transactionMode}
isEmbed={props.isEmbed} isEmbed={props.isEmbed}
quote={quote} quote={quote}
onSuccess={props.onSuccess}
/> />
); );
} }
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { ThirdwebClient } from "../../../../../../../client/client.js"; import type { ThirdwebClient } from "../../../../../../../client/client.js";
import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js"; import type { BuyWithCryptoQuote } from "../../../../../../../pay/buyWithCrypto/getQuote.js";
import type { BuyWithCryptoStatus } from "../../../../../../../pay/buyWithCrypto/getStatus.js";
import { iconSize } from "../../../../../../core/design-system/index.js"; import { iconSize } from "../../../../../../core/design-system/index.js";
import { useBuyWithCryptoStatus } from "../../../../../../core/hooks/pay/useBuyWithCryptoStatus.js"; import { useBuyWithCryptoStatus } from "../../../../../../core/hooks/pay/useBuyWithCryptoStatus.js";
import { invalidateWalletBalance } from "../../../../../../core/providers/invalidateWalletBalance.js"; import { invalidateWalletBalance } from "../../../../../../core/providers/invalidateWalletBalance.js";
@@ -26,7 +27,10 @@ export function SwapStatusScreen(props: {
transactionMode: boolean; transactionMode: boolean;
isEmbed: boolean; isEmbed: boolean;
quote: BuyWithCryptoQuote; quote: BuyWithCryptoQuote;
onSuccess: ((status: BuyWithCryptoStatus) => void) | undefined;
}) { }) {
const { onSuccess } = props;
const swapStatus = useBuyWithCryptoStatus({ const swapStatus = useBuyWithCryptoStatus({
client: props.client, client: props.client,
transactionHash: props.swapTxHash, transactionHash: props.swapTxHash,
@@ -46,6 +50,18 @@ export function SwapStatusScreen(props: {
uiStatus = "partialSuccess"; uiStatus = "partialSuccess";
} }
const purchaseCbCalled = useRef(false);
useEffect(() => {
if (purchaseCbCalled.current || !onSuccess) {
return;
}
if (swapStatus.data?.status === "COMPLETED") {
purchaseCbCalled.current = true;
onSuccess(swapStatus.data);
}
}, [onSuccess, swapStatus]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const balanceInvalidated = useRef(false); const balanceInvalidated = useRef(false);
useEffect(() => { useEffect(() => {