From 537cdce38001a4dfc5526e0b7c787656de741bd8 Mon Sep 17 00:00:00 2001 From: VK Date: Tue, 5 May 2026 18:35:36 +0400 Subject: [PATCH 1/4] feat(onramp): start integrating backend --- .../appkit/actions/onramp/build-onramp-url.ts | 7 +- ...t-onramp-quote.ts => get-onramp-quotes.ts} | 12 +- packages/appkit-react/.storybook/app-kit.ts | 2 + .../features/onramp/hooks/use-onramp-quote.ts | 34 --- .../onramp/hooks/use-onramp-quotes.ts | 34 +++ .../onramp-widget-provider.tsx | 96 ++++----- .../onramp-widget-ui/onramp-widget-ui.tsx | 2 +- packages/appkit-react/src/locales/en.ts | 1 + packages/appkit/docs/actions.md | 13 +- packages/appkit/package.json | 14 +- packages/appkit/src/actions/index.ts | 2 +- ...t-onramp-quote.ts => get-onramp-quotes.ts} | 16 +- .../src/onramp/appkit-onramp}/index.ts | 2 +- packages/appkit/src/onramp/index.ts | 8 +- packages/appkit/src/onramp/ton-pay/index.ts | 9 - packages/appkit/src/queries/index.ts | 16 +- .../src/queries/onramp/get-onramp-quote.ts | 45 ---- .../src/queries/onramp/get-onramp-quotes.ts | 47 +++++ packages/walletkit/package.json | 14 +- .../walletkit/src/api/interfaces/OnrampAPI.ts | 20 +- packages/walletkit/src/api/models/index.ts | 2 + .../src/api/models/onramp/OnrampFee.ts | 18 ++ .../src/api/models/onramp/OnrampQuote.ts | 22 +- .../api/models/onramp/OnrampServiceInfo.ts | 24 +++ .../src/defi/onramp/OnrampManager.ts | 59 ++---- .../src/defi/onramp/OnrampProvider.ts | 10 +- .../appkit-onramp/AppkitOnrampProvider.ts | 195 ++++++++++++++++++ .../src/defi/onramp/appkit-onramp/index.ts | 11 + .../src/defi/onramp/appkit-onramp/types.ts | 48 +++++ .../src/defi/onramp/appkit-onramp/utils.ts | 64 ++++++ packages/walletkit/src/defi/onramp/errors.ts | 1 + packages/walletkit/src/defi/onramp/index.ts | 3 +- .../defi/onramp/mercuryo/MercuryoProvider.ts | 140 ------------- .../defi/onramp/moonpay/MoonpayProvider.ts | 122 ----------- .../src/defi/onramp/moonpay/index.ts | 9 - .../src/defi/onramp/ton-pay/TonPayProvider.ts | 123 ----------- .../src/defi/onramp/ton-pay/index.ts | 9 - 37 files changed, 597 insertions(+), 657 deletions(-) rename demo/examples/src/appkit/actions/onramp/{get-onramp-quote.ts => get-onramp-quotes.ts} (53%) delete mode 100644 packages/appkit-react/src/features/onramp/hooks/use-onramp-quote.ts create mode 100644 packages/appkit-react/src/features/onramp/hooks/use-onramp-quotes.ts rename packages/appkit/src/actions/onramp/{get-onramp-quote.ts => get-onramp-quotes.ts} (51%) rename packages/{walletkit/src/defi/onramp/mercuryo => appkit/src/onramp/appkit-onramp}/index.ts (75%) delete mode 100644 packages/appkit/src/onramp/ton-pay/index.ts delete mode 100644 packages/appkit/src/queries/onramp/get-onramp-quote.ts create mode 100644 packages/appkit/src/queries/onramp/get-onramp-quotes.ts create mode 100644 packages/walletkit/src/api/models/onramp/OnrampFee.ts create mode 100644 packages/walletkit/src/api/models/onramp/OnrampServiceInfo.ts create mode 100644 packages/walletkit/src/defi/onramp/appkit-onramp/AppkitOnrampProvider.ts create mode 100644 packages/walletkit/src/defi/onramp/appkit-onramp/index.ts create mode 100644 packages/walletkit/src/defi/onramp/appkit-onramp/types.ts create mode 100644 packages/walletkit/src/defi/onramp/appkit-onramp/utils.ts delete mode 100644 packages/walletkit/src/defi/onramp/mercuryo/MercuryoProvider.ts delete mode 100644 packages/walletkit/src/defi/onramp/moonpay/MoonpayProvider.ts delete mode 100644 packages/walletkit/src/defi/onramp/moonpay/index.ts delete mode 100644 packages/walletkit/src/defi/onramp/ton-pay/TonPayProvider.ts delete mode 100644 packages/walletkit/src/defi/onramp/ton-pay/index.ts diff --git a/demo/examples/src/appkit/actions/onramp/build-onramp-url.ts b/demo/examples/src/appkit/actions/onramp/build-onramp-url.ts index 184260bd6..063ead6e0 100644 --- a/demo/examples/src/appkit/actions/onramp/build-onramp-url.ts +++ b/demo/examples/src/appkit/actions/onramp/build-onramp-url.ts @@ -7,16 +7,19 @@ */ import type { AppKit } from '@ton/appkit'; -import { buildOnrampUrl, getOnrampQuote } from '@ton/appkit/onramp'; +import { buildOnrampUrl, getOnrampQuotes } from '@ton/appkit/onramp'; export const buildOnrampUrlExample = async (appKit: AppKit) => { // SAMPLE_START: BUILD_ONRAMP_URL - const quote = await getOnrampQuote(appKit, { + const quotes = await getOnrampQuotes(appKit, { fiatCurrency: 'USD', cryptoCurrency: 'TON', amount: '100', }); + const [quote] = quotes; + if (!quote) throw new Error('No onramp quotes available'); + const url = await buildOnrampUrl(appKit, { quote, userAddress: 'UQ...wallet-address...', diff --git a/demo/examples/src/appkit/actions/onramp/get-onramp-quote.ts b/demo/examples/src/appkit/actions/onramp/get-onramp-quotes.ts similarity index 53% rename from demo/examples/src/appkit/actions/onramp/get-onramp-quote.ts rename to demo/examples/src/appkit/actions/onramp/get-onramp-quotes.ts index 868cc30e4..654a59d8e 100644 --- a/demo/examples/src/appkit/actions/onramp/get-onramp-quote.ts +++ b/demo/examples/src/appkit/actions/onramp/get-onramp-quotes.ts @@ -7,16 +7,16 @@ */ import type { AppKit } from '@ton/appkit'; -import { getOnrampQuote } from '@ton/appkit/onramp'; +import { getOnrampQuotes } from '@ton/appkit/onramp'; -export const getOnrampQuoteExample = async (appKit: AppKit) => { - // SAMPLE_START: GET_ONRAMP_QUOTE - const quote = await getOnrampQuote(appKit, { +export const getOnrampQuotesExample = async (appKit: AppKit) => { + // SAMPLE_START: GET_ONRAMP_QUOTES + const quotes = await getOnrampQuotes(appKit, { fiatCurrency: 'USD', cryptoCurrency: 'TON', amount: '100', isFiatAmount: true, }); - console.log('Onramp Quote:', quote); - // SAMPLE_END: GET_ONRAMP_QUOTE + console.log('Onramp Quotes:', quotes); + // SAMPLE_END: GET_ONRAMP_QUOTES }; diff --git a/packages/appkit-react/.storybook/app-kit.ts b/packages/appkit-react/.storybook/app-kit.ts index a875ed300..486cdd4ab 100644 --- a/packages/appkit-react/.storybook/app-kit.ts +++ b/packages/appkit-react/.storybook/app-kit.ts @@ -11,6 +11,7 @@ import { createOmnistonProvider } from '@ton/appkit/swap/omniston'; import { createDeDustProvider } from '@ton/appkit/swap/dedust'; import { createTonstakersProvider } from '@ton/appkit/staking/tonstakers'; import { createLayerswapProvider } from '@ton/appkit/crypto-onramp/layerswap'; +import { createAppkitOnrampProvider } from '@ton/appkit/onramp/appkit-onramp'; export const appKit = new AppKit({ networks: { @@ -40,5 +41,6 @@ export const appKit = new AppKit({ createDeDustProvider(), createTonstakersProvider(), createLayerswapProvider(), + createAppkitOnrampProvider({ apiKey: 'ak_test_66546d3cebb69dc4397570d65aad14dd' }), ], }); diff --git a/packages/appkit-react/src/features/onramp/hooks/use-onramp-quote.ts b/packages/appkit-react/src/features/onramp/hooks/use-onramp-quote.ts deleted file mode 100644 index 5d8a5922d..000000000 --- a/packages/appkit-react/src/features/onramp/hooks/use-onramp-quote.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -'use client'; - -import { getOnrampQuoteQueryOptions } from '@ton/appkit/queries'; -import type { GetOnrampQuoteData, GetOnrampQuoteErrorType, GetOnrampQuoteQueryConfig } from '@ton/appkit/queries'; - -import { useAppKit } from '../../settings'; -import { useQuery } from '../../../libs/query'; -import type { UseQueryReturnType } from '../../../libs/query'; - -export type UseOnrampQuoteParameters = GetOnrampQuoteQueryConfig; - -export type UseOnrampQuoteReturnType = UseQueryReturnType< - selectData, - GetOnrampQuoteErrorType ->; - -/** - * Hook to get onramp quote - */ -export const useOnrampQuote = ( - parameters: UseOnrampQuoteParameters = {}, -): UseOnrampQuoteReturnType => { - const appKit = useAppKit(); - - return useQuery(getOnrampQuoteQueryOptions(appKit, parameters)); -}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-onramp-quotes.ts b/packages/appkit-react/src/features/onramp/hooks/use-onramp-quotes.ts new file mode 100644 index 000000000..55d414c07 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-onramp-quotes.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import { getOnrampQuotesQueryOptions } from '@ton/appkit/queries'; +import type { GetOnrampQuotesData, GetOnrampQuotesErrorType, GetOnrampQuotesQueryConfig } from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseOnrampQuotesParameters = GetOnrampQuotesQueryConfig; + +export type UseOnrampQuotesReturnType = UseQueryReturnType< + selectData, + GetOnrampQuotesErrorType +>; + +/** + * Hook to get onramp quotes from all registered providers (results are flattened). + */ +export const useOnrampQuotes = ( + parameters: UseOnrampQuotesParameters = {}, +): UseOnrampQuotesReturnType => { + const appKit = useAppKit(); + + return useQuery(getOnrampQuotesQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx index 6b4e28977..47c761733 100644 --- a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx @@ -19,10 +19,8 @@ import type { CurrencySectionConfig, } from '../../../types'; import { ONRAMP_CURRENCIES } from '../../../mock-data/currencies'; -import { ONRAMP_PROVIDERS as MOCK_PROVIDERS } from '../../../mock-data/providers'; import { DEFAULT_ONRAMP_PRESETS } from '../../../constants'; -import { useOnrampProviders } from '../../../hooks/use-onramp-providers'; -import { useOnrampQuote } from '../../../hooks/use-onramp-quote'; +import { useOnrampQuotes } from '../../../hooks/use-onramp-quotes'; import { useBuildOnrampUrl } from '../../../hooks/use-build-onramp-url'; import { useConnectedWallets } from '../../../../wallets/hooks/use-connected-wallets'; @@ -143,78 +141,82 @@ export const OnrampWidgetProvider: FC = ({ const [amount, setAmount] = useState(''); const [amountInputMode, setAmountInputMode] = useState('currency'); + const [selectedProvider, setSelectedProvider] = useState(null); - // Get real registered providers - const registeredProviders = useOnrampProviders(); - - const providers = useMemo(() => { - if (registeredProviders.length === 0) { - return MOCK_PROVIDERS; - } - - return registeredProviders.map((rp) => { - const metadata = MOCK_PROVIDERS.find((m) => m.id === rp.providerId); - return { - id: rp.providerId, - name: metadata?.name ?? rp.providerId, - description: metadata?.description ?? '', - logo: metadata?.logo ?? '', - }; - }); - }, [registeredProviders]); - - const [selectedProvider, setSelectedProvider] = useState( - () => providers[0] ?? null, - ); - - // Update selected provider if it's no longer in the list or if the list was initially empty - useEffect(() => { - if (!selectedProvider || !providers.find((p) => p.id === selectedProvider.id)) { - if (providers.length > 0) { - setSelectedProvider(providers[0]!); - } - } - }, [providers, selectedProvider]); - - const { data: quote, isLoading: isQuoteLoading } = useOnrampQuote({ + const { + data: quotes, + isLoading: isQuoteLoading, + error: quotesError, + } = useOnrampQuotes({ fiatCurrency: selectedCurrency.code, cryptoCurrency: selectedToken?.symbol ?? 'TON', amount: amount || '0', isFiatAmount: amountInputMode === 'currency', - providerId: selectedProvider?.id ?? '', query: { - enabled: !!amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0 && !!selectedProvider, + enabled: !!amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0, + retry: false, }, }); + const providers = useMemo( + () => + quotes?.map((q) => ({ + id: q.serviceInfo?.id ?? q.providerId, + name: q.serviceInfo?.name ?? q.providerId, + description: q.serviceInfo?.paymentMethods?.join(', ') ?? '', + logo: q.serviceInfo?.lightLogo ?? '', + })) ?? [], + [quotes], + ); + + const selectedQuote = useMemo(() => { + if (!quotes || quotes.length === 0) return undefined; + if (selectedProvider) { + const match = quotes.find((q) => (q.serviceInfo?.id ?? q.providerId) === selectedProvider.id); + if (match) return match; + } + return quotes[0]; + }, [quotes, selectedProvider]); + const convertedAmount = useMemo(() => { - if (!quote) return ''; - return amountInputMode === 'currency' ? quote.cryptoAmount : quote.fiatAmount; - }, [quote, amountInputMode]); + if (!selectedQuote) return ''; + return amountInputMode === 'currency' ? selectedQuote.cryptoAmount : selectedQuote.fiatAmount; + }, [selectedQuote, amountInputMode]); + + useEffect(() => { + if (selectedProvider && providers.find((p) => p.id === selectedProvider.id)) return; + setSelectedProvider(providers[0] ?? null); + }, [providers, selectedProvider]); const numericAmount = parseFloat(amount); - const error = !isNaN(numericAmount) && numericAmount > ERROR_THRESHOLD ? 'noQuotesFound' : undefined; + const error = useMemo(() => { + if (quotesError) { + const code = (quotesError as { code?: string }).code; + return code === 'PAIR_NOT_SUPPORTED' ? 'pairNotSupported' : 'noQuotesFound'; + } + if (!isNaN(numericAmount) && numericAmount > ERROR_THRESHOLD) return 'noQuotesFound'; + return undefined; + }, [quotesError, numericAmount]); const canContinue = - amount !== '' && !isNaN(numericAmount) && numericAmount > 0 && !!quote && !error && !!selectedProvider; + amount !== '' && !isNaN(numericAmount) && numericAmount > 0 && !!selectedQuote && !error && !!selectedProvider; const { mutateAsync: buildUrl } = useBuildOnrampUrl(); const wallets = useConnectedWallets(); const activeWallet = wallets?.[0]; const onContinue = useCallback(async () => { - if (!canContinue || !quote || !activeWallet || !selectedProvider) return; + if (!canContinue || !selectedQuote || !activeWallet) return; try { const url = await buildUrl({ - quote, + quote: selectedQuote, userAddress: activeWallet.getAddress(), - providerId: selectedProvider.id, }); window.open(url, '_blank'); } catch { // silently swallow — redirect is best-effort } - }, [canContinue, quote, activeWallet, selectedProvider, buildUrl]); + }, [canContinue, selectedQuote, activeWallet, buildUrl]); const onReset = useCallback(() => { setAmount(''); diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx index dd501e681..8747f0672 100644 --- a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx @@ -106,7 +106,7 @@ export const OnrampWidgetUI: FC = ({ + + setIsTokenSelectOpen(false)} diff --git a/packages/appkit-react/src/locales/en.ts b/packages/appkit-react/src/locales/en.ts index c69e38ffe..b131c569f 100644 --- a/packages/appkit-react/src/locales/en.ts +++ b/packages/appkit-react/src/locales/en.ts @@ -149,7 +149,7 @@ export default { connectWallet: 'Connect a wallet to continue', tonPayError: 'Failed to start TonPay checkout', youGet: 'You get', - exchangeRate: 'Exchange rate', + yourBalance: 'Your balance', }, // Staking diff --git a/packages/appkit/src/onramp/index.ts b/packages/appkit/src/onramp/index.ts index 6e2eb6325..cb6152dbd 100644 --- a/packages/appkit/src/onramp/index.ts +++ b/packages/appkit/src/onramp/index.ts @@ -7,6 +7,22 @@ */ // fiat-onramp: not ready — exported via sub-path to keep off main @ton/appkit API + +export { OnrampProvider, OnrampManager, OnrampError } from '@ton/walletkit'; + +export type { + OnrampAPI, + OnrampProviderInterface, + OnrampProviderMetadata, + OnrampProviderMetadataOverride, + OnrampQuote, + OnrampQuoteParams, + OnrampParams, + OnrampServiceInfo, + OnrampFee, + OnrampFeeType, +} from '@ton/walletkit'; + export { getOnrampProvider, type GetOnrampProviderOptions, diff --git a/packages/walletkit/src/api/interfaces/OnrampAPI.ts b/packages/walletkit/src/api/interfaces/OnrampAPI.ts index 71f98b811..9287458bd 100644 --- a/packages/walletkit/src/api/interfaces/OnrampAPI.ts +++ b/packages/walletkit/src/api/interfaces/OnrampAPI.ts @@ -6,7 +6,7 @@ * */ -import type { OnrampParams, OnrampQuote, OnrampQuoteParams } from '../models'; +import type { OnrampParams, OnrampProviderMetadata, OnrampQuote, OnrampQuoteParams } from '../models'; import type { DefiManagerAPI } from './DefiManagerAPI'; import type { DefiProvider } from './DefiProvider'; @@ -42,6 +42,11 @@ export interface OnrampProviderInterface { return [Network.mainnet()]; } + getMetadata() { + return { + name: 'AppKit Onramp', + url: 'https://ton.org/dev', + isReversedAmountSupported: false, + }; + } + async getQuote(params: OnrampQuoteParams): Promise { const url = new URL(`${this.baseUrl}/onramp/get-quote`); url.searchParams.set('amount', params.amount); From 80d06efcdcd5d419e1565e64477c09220ea5ef92 Mon Sep 17 00:00:00 2001 From: VK Date: Tue, 5 May 2026 21:29:26 +0400 Subject: [PATCH 3/4] feat(onramp): polishing --- .../onramp-widget-provider/index.ts | 6 +- .../onramp-widget-provider/onramp-context.ts | 106 ++++++++ .../onramp-widget-provider.tsx | 254 +++++------------- .../use-onramp-quote.ts | 98 +++++++ .../use-onramp-token-state.ts | 56 ++++ .../use-onramp-validation.ts | 48 ++++ .../onramp-widget-ui/onramp-widget-ui.tsx | 12 +- .../onramp-widget/onramp-widget.stories.tsx | 6 +- .../fiat-onramp/utils/map-onramp-error.ts | 33 +++ packages/appkit-react/src/locales/en.ts | 4 + 10 files changed, 420 insertions(+), 203 deletions(-) create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-context.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-quote.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-token-state.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-validation.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/utils/map-onramp-error.ts diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/index.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/index.ts index 2b79e6063..5fc591d3d 100644 --- a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/index.ts +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/index.ts @@ -6,5 +6,7 @@ * */ -export { OnrampWidgetProvider, useOnrampContext } from './onramp-widget-provider'; -export type { OnrampProviderProps, OnrampContextType } from './onramp-widget-provider'; +export { OnrampWidgetProvider } from './onramp-widget-provider'; +export type { OnrampProviderProps } from './onramp-widget-provider'; +export { OnrampContext, useOnrampContext } from './onramp-context'; +export type { OnrampContextType } from './onramp-context'; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-context.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-context.ts new file mode 100644 index 000000000..238ed347a --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-context.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { createContext, useContext } from 'react'; +import type { OnrampQuote } from '@ton/appkit/onramp'; + +import type { TokenSectionConfig } from '../../../../../components/token-select-modal'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; +import { ONRAMP_CURRENCIES } from '../../../mock-data/currencies'; +import { DEFAULT_ONRAMP_PRESETS } from '../../../constants'; +import type { + OnrampCurrency, + OnrampProvider as OnrampWidgetProviderType, + AmountInputMode, + OnrampAmountPreset, + CurrencySectionConfig, +} from '../../../types'; + +export interface OnrampContextType { + /** Full list of available tokens to buy */ + tokens: AppkitUIToken[]; + /** Optional section configs for grouping tokens in the selector */ + tokenSections?: TokenSectionConfig[]; + /** Currently selected token to buy */ + selectedToken: AppkitUIToken | null; + setSelectedToken: (token: AppkitUIToken) => void; + + /** Available fiat currencies */ + currencies: OnrampCurrency[]; + /** Optional section configs for grouping currencies */ + currencySections?: CurrencySectionConfig[]; + /** Currently selected fiat currency */ + selectedCurrency: OnrampCurrency; + setSelectedCurrency: (currency: OnrampCurrency) => void; + + /** Current amount input value */ + amount: string; + setAmount: (value: string) => void; + /** Whether user is entering token amount or fiat amount */ + amountInputMode: AmountInputMode; + setAmountInputMode: (mode: AmountInputMode) => void; + /** Converted amount in the opposite denomination (from the selected quote) */ + convertedAmount: string; + /** Preset amount values */ + presetAmounts: OnrampAmountPreset[]; + + /** Available payment providers (derived from received quotes) */ + providers: OnrampWidgetProviderType[]; + /** Currently selected payment provider */ + selectedProvider: OnrampWidgetProviderType | null; + setSelectedProvider: (provider: OnrampWidgetProviderType) => void; + /** Quote tied to the currently selected provider */ + selectedQuote?: OnrampQuote; + /** Whether registered providers support reversed (crypto-amount) quotes */ + isReversedAmountSupported: boolean; + + /** Validation/fetch error i18n key, null when everything is ok */ + error: string | null; + /** Whether quotes are being fetched */ + isLoadingQuote: boolean; + /** Whether the user can proceed (valid amount + quote available + provider selected) */ + canSubmit: boolean; + + /** Reset widget to initial state */ + onReset: () => void; + /** Execute the onramp (build URL and redirect) */ + onContinue: () => void; +} + +const defaultContext: OnrampContextType = { + tokens: [], + tokenSections: undefined, + selectedToken: null, + setSelectedToken: () => {}, + currencies: [], + currencySections: undefined, + selectedCurrency: ONRAMP_CURRENCIES[0]!, + setSelectedCurrency: () => {}, + amount: '', + setAmount: () => {}, + amountInputMode: 'currency', + setAmountInputMode: () => {}, + convertedAmount: '', + presetAmounts: DEFAULT_ONRAMP_PRESETS, + providers: [], + selectedProvider: null, + setSelectedProvider: () => {}, + selectedQuote: undefined, + isReversedAmountSupported: false, + error: null, + isLoadingQuote: false, + canSubmit: false, + onReset: () => {}, + onContinue: () => {}, +}; + +export const OnrampContext = createContext(defaultContext); + +export const useOnrampContext = (): OnrampContextType => { + return useContext(OnrampContext); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx index 717a78671..e34bd3bda 100644 --- a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx @@ -6,119 +6,20 @@ * */ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import type { FC, PropsWithChildren } from 'react'; -import type { OnrampQuote } from '@ton/appkit/onramp'; import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; import type { TokenSectionConfig } from '../../../../../components/token-select-modal'; -import type { - OnrampCurrency, - OnrampProvider as OnrampWidgetProviderType, - AmountInputMode, - OnrampAmountPreset, - CurrencySectionConfig, -} from '../../../types'; +import type { CurrencySectionConfig } from '../../../types'; import { ONRAMP_CURRENCIES } from '../../../mock-data/currencies'; import { DEFAULT_ONRAMP_PRESETS } from '../../../constants'; -import { useOnrampQuotes } from '../../../hooks/use-onramp-quotes'; -import { useOnrampProviders } from '../../../hooks/use-onramp-providers'; import { useBuildOnrampUrl } from '../../../hooks/use-build-onramp-url'; -import { useConnectedWallets } from '../../../../wallets/hooks/use-connected-wallets'; -import { useDebounceValue } from '../../../../../hooks/use-debounce-value'; - -export type { AppkitUIToken }; - -const ERROR_THRESHOLD = 10000000; -const QUOTE_DEBOUNCE_MS = 500; - -export interface OnrampContextType { - /** Full list of available tokens to buy */ - tokens: AppkitUIToken[]; - /** Optional section configs for grouping tokens in the selector */ - tokenSections?: TokenSectionConfig[]; - /** Optional section configs for grouping currencies in the selector */ - currencySections?: CurrencySectionConfig[]; - /** Currently selected token to buy */ - selectedToken: AppkitUIToken | null; - /** Select a token to buy */ - setSelectedToken: (token: AppkitUIToken) => void; - - /** Available fiat currencies */ - currencies: OnrampCurrency[]; - /** Currently selected fiat currency */ - selectedCurrency: OnrampCurrency; - /** Select a fiat currency */ - setSelectedCurrency: (currency: OnrampCurrency) => void; - - /** Current amount input value */ - amount: string; - /** Set the amount value */ - setAmount: (value: string) => void; - /** Whether user is entering token amount or fiat amount */ - amountInputMode: AmountInputMode; - /** Switch between token / currency input mode */ - setAmountInputMode: (mode: AmountInputMode) => void; - /** Mocked converted amount in the opposite denomination */ - convertedAmount: string; - /** Preset amount values */ - presetAmounts: OnrampAmountPreset[]; - - /** Available payment providers */ - providers: OnrampWidgetProviderType[]; - /** Currently selected provider */ - selectedProvider: OnrampWidgetProviderType | null; - /** Select a provider */ - setSelectedProvider: (provider: OnrampWidgetProviderType) => void; - /** Quote tied to the currently selected provider */ - selectedQuote?: OnrampQuote; - /** Whether the registered providers support reversed (crypto-amount) quotes */ - isReversedAmountSupported: boolean; - - /** Whether amount is valid and user can proceed */ - canContinue: boolean; - /** Current error, e.g. 'noQuotesFound' */ - error?: string; - /** Loading state for quotes */ - isLoading: boolean; - /** Reset widget to initial state */ - onReset: () => void; - /** Execute the onramp (build URL and redirect) */ - onContinue: () => void; -} - -const defaultContext: OnrampContextType = { - tokens: [], - tokenSections: undefined, - currencySections: undefined, - selectedToken: null, - setSelectedToken: () => {}, - currencies: [], - selectedCurrency: ONRAMP_CURRENCIES[0]!, - setSelectedCurrency: () => {}, - amount: '', - setAmount: () => {}, - amountInputMode: 'currency', - setAmountInputMode: () => {}, - convertedAmount: '', - presetAmounts: DEFAULT_ONRAMP_PRESETS, - providers: [], - selectedProvider: null, - setSelectedProvider: () => {}, - selectedQuote: undefined, - isReversedAmountSupported: false, - canContinue: false, - error: undefined, - isLoading: false, - onReset: () => {}, - onContinue: () => {}, -}; - -export const OnrampContext = createContext(defaultContext); - -export const useOnrampContext = () => { - return useContext(OnrampContext); -}; +import { useAddress } from '../../../../wallets'; +import { OnrampContext } from './onramp-context'; +import { useOnrampTokenState } from './use-onramp-token-state'; +import { useOnrampQuote } from './use-onramp-quote'; +import { useOnrampValidation } from './use-onramp-validation'; export interface OnrampProviderProps extends PropsWithChildren { /** Full list of tokens available for purchase */ @@ -141,116 +42,80 @@ export const OnrampWidgetProvider: FC = ({ defaultTokenId, defaultCurrencyId, }) => { - const [selectedToken, setSelectedToken] = useState( - () => tokens.find((t) => t.id === defaultTokenId) ?? tokens[0] ?? null, - ); - - const [selectedCurrency, setSelectedCurrency] = useState( - () => ONRAMP_CURRENCIES.find((c) => c.id === defaultCurrencyId) ?? ONRAMP_CURRENCIES[0]!, - ); - - const [amount, setAmount] = useState(''); - const [amountInputMode, setAmountInputMode] = useState('currency'); - const [selectedProvider, setSelectedProvider] = useState(null); - - const registeredProviders = useOnrampProviders(); - const isReversedAmountSupported = useMemo( - () => - registeredProviders.length > 0 && - registeredProviders.every((p) => p.getMetadata().isReversedAmountSupported ?? true), - [registeredProviders], - ); - - const [amountDebounced] = useDebounceValue(amount, QUOTE_DEBOUNCE_MS); + // 1. Local state + const { + selectedToken, + setSelectedToken, + selectedCurrency, + setSelectedCurrency, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + amountDecimals, + } = useOnrampTokenState({ tokens, defaultTokenId, defaultCurrencyId }); + + // 2. Queries and external readers + const userAddress = useAddress(); const { - data: quotes, - isLoading: isQuoteLoading, - error: quotesError, - } = useOnrampQuotes({ - fiatCurrency: selectedCurrency.code, - cryptoCurrency: selectedToken?.symbol ?? 'TON', - amount: amountDebounced || '0', - isFiatAmount: amountInputMode === 'currency', - query: { - enabled: !!amountDebounced && !isNaN(parseFloat(amountDebounced)) && parseFloat(amountDebounced) > 0, - retry: false, - }, + amountDebounced, + quoteError, + isQuoteFetching, + providers, + selectedProvider, + setSelectedProvider, + selectedQuote, + convertedAmount, + isReversedAmountSupported, + } = useOnrampQuote({ selectedToken, selectedCurrency, amount, amountInputMode }); + + // 3. Derivations + const { error, canSubmit } = useOnrampValidation({ + amount, + amountDebounced, + amountDecimals, + quoteError, + hasQuote: !!selectedQuote, + hasSelectedProvider: !!selectedProvider, }); - const providers = useMemo( - () => - quotes?.map((q) => ({ - id: q.serviceInfo?.id ?? q.providerId, - name: q.serviceInfo?.name ?? q.providerId, - description: q.serviceInfo?.paymentMethods?.join(', ') ?? '', - logo: q.serviceInfo?.lightLogo ?? '', - })) ?? [], - [quotes], - ); - - const selectedQuote = useMemo(() => { - if (!quotes || quotes.length === 0) return undefined; - if (selectedProvider) { - const match = quotes.find((q) => (q.serviceInfo?.id ?? q.providerId) === selectedProvider.id); - if (match) return match; - } - return quotes[0]; - }, [quotes, selectedProvider]); - - const convertedAmount = useMemo(() => { - if (!selectedQuote) return ''; - return amountInputMode === 'currency' ? selectedQuote.cryptoAmount : selectedQuote.fiatAmount; - }, [selectedQuote, amountInputMode]); + const isLoadingQuote = isQuoteFetching || amount !== amountDebounced; useEffect(() => { - if (selectedProvider && providers.find((p) => p.id === selectedProvider.id)) return; - setSelectedProvider(providers[0] ?? null); - }, [providers, selectedProvider]); - - const numericAmount = parseFloat(amount); - const error = useMemo(() => { - if (quotesError) { - const code = (quotesError as { code?: string }).code; - return code === 'PAIR_NOT_SUPPORTED' ? 'pairNotSupported' : 'noQuotesFound'; + if (!isReversedAmountSupported && amountInputMode === 'token') { + setAmountInputMode('currency'); } - if (!isNaN(numericAmount) && numericAmount > ERROR_THRESHOLD) return 'noQuotesFound'; - return undefined; - }, [quotesError, numericAmount]); - const canContinue = - amount !== '' && !isNaN(numericAmount) && numericAmount > 0 && !!selectedQuote && !error && !!selectedProvider; + }, [isReversedAmountSupported, amountInputMode, setAmountInputMode]); + // 4. Mutations const { mutateAsync: buildUrl } = useBuildOnrampUrl(); - const wallets = useConnectedWallets(); - const activeWallet = wallets?.[0]; + // 5. Callbacks const onContinue = useCallback(async () => { - if (!canContinue || !selectedQuote || !activeWallet) return; + if (!canSubmit || !selectedQuote || !userAddress) return; try { - const url = await buildUrl({ - quote: selectedQuote, - userAddress: activeWallet.getAddress(), - }); + const url = await buildUrl({ quote: selectedQuote, userAddress }); window.open(url, '_blank'); } catch { // silently swallow — redirect is best-effort } - }, [canContinue, selectedQuote, activeWallet, buildUrl]); + }, [canSubmit, selectedQuote, userAddress, buildUrl]); const onReset = useCallback(() => { setAmount(''); setAmountInputMode('currency'); - }, []); + }, [setAmount, setAmountInputMode]); const value = useMemo( () => ({ tokens, tokenSections, - currencySections, selectedToken, setSelectedToken, currencies: ONRAMP_CURRENCIES, + currencySections, selectedCurrency, setSelectedCurrency, amount, @@ -264,28 +129,33 @@ export const OnrampWidgetProvider: FC = ({ setSelectedProvider, selectedQuote, isReversedAmountSupported, - canContinue, error, - isLoading: isQuoteLoading, + isLoadingQuote, + canSubmit, onReset, onContinue, }), [ tokens, tokenSections, - currencySections, selectedToken, + setSelectedToken, + currencySections, selectedCurrency, + setSelectedCurrency, amount, + setAmount, amountInputMode, + setAmountInputMode, convertedAmount, providers, selectedProvider, + setSelectedProvider, selectedQuote, isReversedAmountSupported, - canContinue, error, - isQuoteLoading, + isLoadingQuote, + canSubmit, onReset, onContinue, ], diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-quote.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-quote.ts new file mode 100644 index 000000000..40d832b84 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-quote.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useMemo, useState } from 'react'; +import { keepPreviousData } from '@tanstack/react-query'; + +import { useOnrampQuotes } from '../../../hooks/use-onramp-quotes'; +import { useOnrampProviders } from '../../../hooks/use-onramp-providers'; +import { useDebounceValue } from '../../../../../hooks/use-debounce-value'; +import type { AmountInputMode, OnrampCurrency, OnrampProvider as OnrampWidgetProviderType } from '../../../types'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; + +const QUOTE_DEBOUNCE_MS = 500; + +interface UseOnrampQuoteOptions { + selectedToken: AppkitUIToken | null; + selectedCurrency: OnrampCurrency; + amount: string; + amountInputMode: AmountInputMode; +} + +export const useOnrampQuote = ({ selectedToken, selectedCurrency, amount, amountInputMode }: UseOnrampQuoteOptions) => { + const [selectedProvider, setSelectedProvider] = useState(null); + const [amountDebounced] = useDebounceValue(amount, QUOTE_DEBOUNCE_MS); + + const registeredProviders = useOnrampProviders(); + const isReversedAmountSupported = useMemo( + () => + registeredProviders.length > 0 && + registeredProviders.every((p) => p.getMetadata().isReversedAmountSupported ?? true), + [registeredProviders], + ); + + const { + data: quotes, + isFetching: isQuoteFetching, + error: quoteError, + } = useOnrampQuotes({ + fiatCurrency: selectedCurrency.code, + cryptoCurrency: selectedToken?.symbol ?? 'TON', + amount: amountDebounced || '0', + isFiatAmount: amountInputMode === 'currency', + query: { + enabled: !!amountDebounced && !isNaN(parseFloat(amountDebounced)) && parseFloat(amountDebounced) > 0, + retry: false, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }, + }); + + const providers = useMemo( + () => + quotes?.map((q) => ({ + id: q.serviceInfo?.id ?? q.providerId, + name: q.serviceInfo?.name ?? q.providerId, + description: q.serviceInfo?.paymentMethods?.join(', ') ?? '', + logo: q.serviceInfo?.lightLogo ?? '', + })) ?? [], + [quotes], + ); + + const selectedQuote = useMemo(() => { + if (!quotes || quotes.length === 0) return undefined; + if (selectedProvider) { + const match = quotes.find((q) => (q.serviceInfo?.id ?? q.providerId) === selectedProvider.id); + if (match) return match; + } + return quotes[0]; + }, [quotes, selectedProvider]); + + const convertedAmount = useMemo(() => { + if (!selectedQuote) return ''; + return amountInputMode === 'currency' ? selectedQuote.cryptoAmount : selectedQuote.fiatAmount; + }, [selectedQuote, amountInputMode]); + + useEffect(() => { + if (selectedProvider && providers.find((p) => p.id === selectedProvider.id)) return; + setSelectedProvider(providers[0] ?? null); + }, [providers, selectedProvider]); + + return { + amountDebounced, + quotes: quotes ?? null, + quoteError, + isQuoteFetching, + providers, + selectedProvider, + setSelectedProvider, + selectedQuote, + convertedAmount, + isReversedAmountSupported, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-token-state.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-token-state.ts new file mode 100644 index 000000000..f21deb28a --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-token-state.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useState } from 'react'; +import { validateNumericString } from '@ton/appkit'; + +import { ONRAMP_CURRENCIES } from '../../../mock-data/currencies'; +import type { AmountInputMode, OnrampCurrency } from '../../../types'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; + +interface UseOnrampTokenStateOptions { + tokens: AppkitUIToken[]; + defaultTokenId?: string; + defaultCurrencyId?: string; +} + +const FIAT_DECIMALS = 2; + +const pickToken = (tokens: AppkitUIToken[], defaultId?: string): AppkitUIToken | null => + tokens.find((t) => t.id === defaultId) ?? tokens[0] ?? null; + +const pickCurrency = (defaultId?: string): OnrampCurrency => + ONRAMP_CURRENCIES.find((c) => c.id === defaultId) ?? ONRAMP_CURRENCIES[0]!; + +export const useOnrampTokenState = ({ tokens, defaultTokenId, defaultCurrencyId }: UseOnrampTokenStateOptions) => { + const [selectedToken, setSelectedToken] = useState(() => pickToken(tokens, defaultTokenId)); + const [selectedCurrency, setSelectedCurrency] = useState(() => pickCurrency(defaultCurrencyId)); + const [amount, setAmountRaw] = useState(''); + const [amountInputMode, setAmountInputMode] = useState('currency'); + + const amountDecimals = amountInputMode === 'token' ? (selectedToken?.decimals ?? 0) : FIAT_DECIMALS; + + const setAmount = useCallback( + (value: string) => { + if (value === '' || validateNumericString(value, amountDecimals)) setAmountRaw(value); + }, + [amountDecimals], + ); + + return { + selectedToken, + setSelectedToken, + selectedCurrency, + setSelectedCurrency, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + amountDecimals, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-validation.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-validation.ts new file mode 100644 index 000000000..023d46810 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-validation.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; + +import { hasTooManyDecimals } from '../../../../../utils/validate-amount'; +import { mapOnrampError } from '../utils/map-onramp-error'; + +interface UseOnrampValidationOptions { + amount: string; + amountDebounced: string; + amountDecimals?: number; + quoteError: Error | null; + hasQuote: boolean; + hasSelectedProvider: boolean; +} + +interface UseOnrampValidationResult { + error: string | null; + canSubmit: boolean; +} + +export const useOnrampValidation = ({ + amount, + amountDebounced, + amountDecimals, + quoteError, + hasQuote, + hasSelectedProvider, +}: UseOnrampValidationOptions): UseOnrampValidationResult => { + const tooManyDecimals = hasTooManyDecimals(amount, amountDecimals); + + const mappedError = useMemo(() => { + if (tooManyDecimals) return 'onramp.tooManyDecimals'; + if (quoteError && amountDebounced) return mapOnrampError(quoteError); + return null; + }, [tooManyDecimals, quoteError, amountDebounced]); + + const canSubmit = + (parseFloat(amount) || 0) > 0 && !tooManyDecimals && mappedError === null && hasQuote && hasSelectedProvider; + + return { error: mappedError, canSubmit }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx index ab3af0692..93212072d 100644 --- a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx @@ -43,9 +43,9 @@ export const OnrampWidgetUI: FC = ({ providers, selectedQuote, isReversedAmountSupported, - canContinue, + canSubmit, error, - isLoading, + isLoadingQuote, onContinue, setSelectedProvider, }) => { @@ -117,19 +117,19 @@ export const OnrampWidgetUI: FC = ({ ( - {({ selectedToken, selectedCurrency, amount, setAmount, canContinue }) => ( + {({ selectedToken, selectedCurrency, amount, setAmount, canSubmit }) => (
-
)} diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/utils/map-onramp-error.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/utils/map-onramp-error.ts new file mode 100644 index 000000000..3ed786f08 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/utils/map-onramp-error.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { OnrampError } from '@ton/appkit/onramp'; + +import { mapDefiError } from '../../../../../utils/map-defi-error'; + +/** + * Map a thrown onramp error to an i18n key. Tries onramp-specific codes first, + * falls back to the shared {@link mapDefiError}, and finally to a generic + * `onramp.genericError`. + */ +export const mapOnrampError = (error: unknown): string => { + if (error instanceof OnrampError) { + switch (error.code) { + case OnrampError.PAIR_NOT_SUPPORTED: + return 'onramp.pairNotSupported'; + case OnrampError.QUOTE_FAILED: + return 'onramp.noQuotesFound'; + case OnrampError.URL_BUILD_FAILED: + return 'onramp.urlBuildFailed'; + case OnrampError.PROVIDER_ERROR: + return 'onramp.providerError'; + } + } + + return mapDefiError(error) ?? 'onramp.genericError'; +}; diff --git a/packages/appkit-react/src/locales/en.ts b/packages/appkit-react/src/locales/en.ts index b131c569f..867162dd4 100644 --- a/packages/appkit-react/src/locales/en.ts +++ b/packages/appkit-react/src/locales/en.ts @@ -150,6 +150,10 @@ export default { tonPayError: 'Failed to start TonPay checkout', youGet: 'You get', yourBalance: 'Your balance', + tooManyDecimals: 'Too many decimal places', + providerError: 'Onramp provider unavailable', + urlBuildFailed: 'Could not build the onramp link', + genericError: 'Unable to get a quote', }, // Staking From aa13ebbcf118cb533dc84e664c938afc19c0e48c Mon Sep 17 00:00:00 2001 From: VK Date: Tue, 9 Jun 2026 16:35:51 +0400 Subject: [PATCH 4/4] feat(fiat-onramp): get onramp files back --- .../onramp-amount-reversed/index.ts | 10 + .../onramp-amount-reversed.module.css | 28 +++ .../onramp-amount-reversed.tsx | 18 ++ .../components/onramp-currency-item/index.ts | 10 + .../onramp-currency-item.tsx | 31 +++ .../onramp-currency-select-modal/index.ts | 10 + .../onramp-currency-select-modal.tsx | 86 ++++++++ .../onramp-currency-select-modal/utils.ts | 48 ++++ .../components/onramp-provider-item/index.ts | 10 + .../onramp-provider-item.module.css | 25 +++ .../onramp-provider-item.tsx | 36 +++ .../onramp-provider-select/index.ts | 10 + .../onramp-provider-select.module.css | 7 + .../onramp-provider-select.tsx | 41 ++++ .../onramp/hooks/use-build-onramp-url.ts | 44 ++++ .../onramp/hooks/use-onramp-provider.ts | 40 ++++ .../onramp/hooks/use-onramp-providers.ts | 35 +++ .../onramp/hooks/use-onramp-quotes.ts | 34 +++ .../appkit-react/src/features/onramp/index.ts | 3 + .../features/onramp/mock-data/currencies.ts | 33 +++ .../features/onramp/mock-data/providers.ts | 36 +++ .../appkit-react/src/features/onramp/types.ts | 69 ++++++ .../fiat-onramp/onramp-info-block/index.ts | 10 + .../onramp-info-block.stories.tsx | 54 +++++ .../onramp-info-block/onramp-info-block.tsx | 67 ++++++ .../onramp-info-block/use-onramp-balance.ts | 38 ++++ .../onramp-widget-provider/index.ts | 12 + .../onramp-widget-provider/onramp-context.ts | 106 +++++++++ .../onramp-widget-provider.tsx | 165 ++++++++++++++ .../use-onramp-quote.ts | 98 +++++++++ .../use-onramp-token-state.ts | 56 +++++ .../use-onramp-validation.ts | 48 ++++ .../fiat-onramp/onramp-widget-ui/index.ts | 10 + .../onramp-widget-ui.module.css | 28 +++ .../onramp-widget-ui/onramp-widget-ui.tsx | 161 ++++++++++++++ .../fiat-onramp/onramp-widget/index.ts | 10 + .../onramp-widget/onramp-widget.stories.tsx | 69 ++++++ .../onramp-widget/onramp-widget.tsx | 50 +++++ .../fiat-onramp/utils/map-onramp-error.ts | 33 +++ .../onramp/widgets/ton-pay-widget/index.ts | 10 + .../ton-pay-widget/ton-pay-widget.module.css | 35 +++ .../ton-pay-widget/ton-pay-widget.stories.tsx | 28 +++ .../widgets/ton-pay-widget/ton-pay-widget.tsx | 208 ++++++++++++++++++ 43 files changed, 1960 insertions(+) create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/index.ts create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/onramp-amount-reversed.module.css create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/onramp-amount-reversed.tsx create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-currency-item/index.ts create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-currency-item/onramp-currency-item.tsx create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/index.ts create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/onramp-currency-select-modal.tsx create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/utils.ts create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-provider-item/index.ts create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-provider-item/onramp-provider-item.module.css create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-provider-item/onramp-provider-item.tsx create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-provider-select/index.ts create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-provider-select/onramp-provider-select.module.css create mode 100644 packages/appkit-react/src/features/onramp/components/onramp-provider-select/onramp-provider-select.tsx create mode 100644 packages/appkit-react/src/features/onramp/hooks/use-build-onramp-url.ts create mode 100644 packages/appkit-react/src/features/onramp/hooks/use-onramp-provider.ts create mode 100644 packages/appkit-react/src/features/onramp/hooks/use-onramp-providers.ts create mode 100644 packages/appkit-react/src/features/onramp/hooks/use-onramp-quotes.ts create mode 100644 packages/appkit-react/src/features/onramp/mock-data/currencies.ts create mode 100644 packages/appkit-react/src/features/onramp/mock-data/providers.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/index.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/onramp-info-block.stories.tsx create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/onramp-info-block.tsx create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/use-onramp-balance.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/index.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-context.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-quote.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-token-state.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-validation.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/index.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.module.css create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/index.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/onramp-widget.stories.tsx create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/onramp-widget.tsx create mode 100644 packages/appkit-react/src/features/onramp/widgets/fiat-onramp/utils/map-onramp-error.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/index.ts create mode 100644 packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.module.css create mode 100644 packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.stories.tsx create mode 100644 packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.tsx diff --git a/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/index.ts b/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/index.ts new file mode 100644 index 000000000..d07d5150b --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampAmountReversed } from './onramp-amount-reversed'; +export type { OnrampAmountReversedProps } from './onramp-amount-reversed'; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/onramp-amount-reversed.module.css b/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/onramp-amount-reversed.module.css new file mode 100644 index 000000000..07bacc131 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/onramp-amount-reversed.module.css @@ -0,0 +1,28 @@ +.container { + composes: bodySemibold from '../../../../styles/typography.module.css'; + + display: flex; + align-items: center; + justify-content: center; + cursor: text; + width: 100%; + overflow: hidden; + color: var(--ta-color-text-secondary); + gap: 8px; +} + +.changeDirection { + width: 14px; + height: 14px; + background-color: transparent; + border: none; + cursor: pointer; + padding: 0; + outline: none; + color: var(--ta-color-text); + transition: opacity 0.2s ease-in-out; +} + +.changeDirection:hover { + opacity: 0.8; +} diff --git a/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/onramp-amount-reversed.tsx b/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/onramp-amount-reversed.tsx new file mode 100644 index 000000000..25da63c55 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-amount-reversed/onramp-amount-reversed.tsx @@ -0,0 +1,18 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; + +import { AmountReversed } from '../../../../components/ui/amount-reversed'; +import type { AmountReversedProps } from '../../../../components/ui/amount-reversed'; + +export type OnrampAmountReversedProps = AmountReversedProps; + +export const OnrampAmountReversed: FC = ({ decimals, ...props }) => ( + +); diff --git a/packages/appkit-react/src/features/onramp/components/onramp-currency-item/index.ts b/packages/appkit-react/src/features/onramp/components/onramp-currency-item/index.ts new file mode 100644 index 000000000..fe67367b2 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-currency-item/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampCurrencyItem } from './onramp-currency-item'; +export type { OnrampCurrencyItemProps } from './onramp-currency-item'; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-currency-item/onramp-currency-item.tsx b/packages/appkit-react/src/features/onramp/components/onramp-currency-item/onramp-currency-item.tsx new file mode 100644 index 000000000..be0e40347 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-currency-item/onramp-currency-item.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { ComponentProps, FC } from 'react'; + +import { CurrencyItem } from '../../../../components/shared/currency-item'; +import type { OnrampCurrency } from '../../types'; + +export interface OnrampCurrencyItemProps extends ComponentProps { + currency: OnrampCurrency; +} + +export const OnrampCurrencyItem: FC = ({ currency, ...props }) => { + return ( + + + + + {currency.name} + + + {currency.code} + + + ); +}; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/index.ts b/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/index.ts new file mode 100644 index 000000000..58d97ad04 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampCurrencySelectModal } from './onramp-currency-select-modal'; +export type { OnrampCurrencySelectModalProps } from './onramp-currency-select-modal'; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/onramp-currency-select-modal.tsx b/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/onramp-currency-select-modal.tsx new file mode 100644 index 000000000..4d9d69913 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/onramp-currency-select-modal.tsx @@ -0,0 +1,86 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo, useState } from 'react'; +import type { FC } from 'react'; + +import { CurrencySelect } from '../../../../components/shared/currency-select-modal'; +import type { OnrampCurrency, CurrencySectionConfig } from '../../types'; +import { OnrampCurrencyItem } from '../onramp-currency-item'; +import { useI18n } from '../../../settings/hooks/use-i18n'; +import { filterCurrencies, groupCurrencySections } from './utils'; +import type { CurrencySection } from './utils'; + +export interface OnrampCurrencySelectModalProps { + open: boolean; + onClose: () => void; + currencies: OnrampCurrency[]; + currencySections?: CurrencySectionConfig[]; + onSelect: (currency: OnrampCurrency) => void; +} + +export const OnrampCurrencySelectModal: FC = ({ + open, + onClose, + currencies, + currencySections, + onSelect, +}) => { + const { t } = useI18n(); + const [search, setSearch] = useState(''); + + const displaySections = useMemo((): CurrencySection[] => { + if (search) { + return [{ title: '', currencies: filterCurrencies(currencies, search) }]; + } + if (currencySections) { + return groupCurrencySections(currencies, currencySections, t('tokenSelect.otherCurrencies')); + } + return [{ title: '', currencies }]; + }, [currencies, currencySections, search, t]); + + const isEmpty = displaySections.every((s) => s.currencies.length === 0); + + const handleSelect = (currency: OnrampCurrency) => () => { + onSelect(currency); + onClose(); + setSearch(''); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + onClose(); + setSearch(''); + } + }; + + return ( + + + + + {displaySections.map((section) => ( + + {section.title && {section.title}} + {section.currencies.map((currency) => ( + + ))} + + ))} + + + ); +}; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/utils.ts b/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/utils.ts new file mode 100644 index 000000000..66057a46a --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-currency-select-modal/utils.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { OnrampCurrency, CurrencySectionConfig } from '../../types'; + +export interface CurrencySection { + title: string; + currencies: OnrampCurrency[]; +} + +export const filterCurrencies = (currencies: OnrampCurrency[], search: string): OnrampCurrency[] => { + if (!search) return currencies; + const lower = search.toLowerCase(); + return currencies.filter((c) => c.name.toLowerCase().includes(lower) || c.code.toLowerCase().includes(lower)); +}; + +export const groupCurrencySections = ( + currencies: OnrampCurrency[], + sections: CurrencySectionConfig[], + otherTitle: string, +): CurrencySection[] => { + const currencyById = new Map(currencies.map((c) => [c.id, c])); + const assignedIds = new Set(); + + const result: CurrencySection[] = sections.map(({ title, ids }) => ({ + title, + currencies: ids.flatMap((id) => { + const c = currencyById.get(id); + if (c) { + assignedIds.add(id); + return [c]; + } + return []; + }), + })); + + const remaining = currencies.filter((c) => !assignedIds.has(c.id)); + if (remaining.length > 0) { + result.push({ title: otherTitle, currencies: remaining }); + } + + return result.filter((s) => s.currencies.length > 0); +}; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-provider-item/index.ts b/packages/appkit-react/src/features/onramp/components/onramp-provider-item/index.ts new file mode 100644 index 000000000..0213d7109 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-provider-item/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampProviderItem } from './onramp-provider-item'; +export type { OnrampProviderItemProps } from './onramp-provider-item'; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-provider-item/onramp-provider-item.module.css b/packages/appkit-react/src/features/onramp/components/onramp-provider-item/onramp-provider-item.module.css new file mode 100644 index 000000000..815d8f0bd --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-provider-item/onramp-provider-item.module.css @@ -0,0 +1,25 @@ +.container { + display: flex; + text-align: left; + align-items: center; + padding: 16px; + gap: 12px; + border: var(--ta-border-width-m) solid var(--ta-color-background-tertiary); + border-radius: var(--ta-border-radius-xl); + cursor: pointer; + background: transparent; +} + +.icon { + border-radius: var(--ta-border-radius-s); +} + +.name { + composes: bodySemibold from "../../../../styles/typography.module.css"; + color: var(--ta-color-text); +} + +.methods { + composes: labelRegular from "../../../../styles/typography.module.css"; + color: var(--ta-color-text-secondary); +} diff --git a/packages/appkit-react/src/features/onramp/components/onramp-provider-item/onramp-provider-item.tsx b/packages/appkit-react/src/features/onramp/components/onramp-provider-item/onramp-provider-item.tsx new file mode 100644 index 000000000..6de9a4f49 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-provider-item/onramp-provider-item.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { ComponentProps, FC } from 'react'; +import clsx from 'clsx'; + +import type { OnrampProvider } from '../../types'; +import { Logo } from '../../../../components/ui/logo'; +import styles from './onramp-provider-item.module.css'; + +export interface OnrampProviderItemProps extends ComponentProps<'button'> { + provider: OnrampProvider; +} + +export const OnrampProviderItem: FC = ({ provider, className, ...props }) => { + return ( + + ); +}; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-provider-select/index.ts b/packages/appkit-react/src/features/onramp/components/onramp-provider-select/index.ts new file mode 100644 index 000000000..2853976e1 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-provider-select/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampProviderSelect } from './onramp-provider-select'; +export type { OnrampProviderSelectProps } from './onramp-provider-select'; diff --git a/packages/appkit-react/src/features/onramp/components/onramp-provider-select/onramp-provider-select.module.css b/packages/appkit-react/src/features/onramp/components/onramp-provider-select/onramp-provider-select.module.css new file mode 100644 index 000000000..bcac7f253 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-provider-select/onramp-provider-select.module.css @@ -0,0 +1,7 @@ +.list { + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} diff --git a/packages/appkit-react/src/features/onramp/components/onramp-provider-select/onramp-provider-select.tsx b/packages/appkit-react/src/features/onramp/components/onramp-provider-select/onramp-provider-select.tsx new file mode 100644 index 000000000..7af5d8f72 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/components/onramp-provider-select/onramp-provider-select.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; + +import { Modal } from '../../../../components/ui/modal'; +import type { OnrampProvider } from '../../types'; +import styles from './onramp-provider-select.module.css'; +import { OnrampProviderItem } from '../onramp-provider-item'; +import { useI18n } from '../../../settings/hooks/use-i18n'; + +export interface OnrampProviderSelectProps { + open: boolean; + onClose: () => void; + providers: OnrampProvider[]; + onSelect: (provider: OnrampProvider) => void; +} + +export const OnrampProviderSelect: FC = ({ open, onClose, providers, onSelect }) => { + const { t } = useI18n(); + + const handleSelect = (provider: OnrampProvider) => () => { + onSelect(provider); + onClose(); + }; + + return ( + !isOpen && onClose()} title={t('onramp.checkout')}> +
+ {providers.map((provider) => ( + + ))} +
+
+ ); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-build-onramp-url.ts b/packages/appkit-react/src/features/onramp/hooks/use-build-onramp-url.ts new file mode 100644 index 000000000..9d45f6ec3 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-build-onramp-url.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import type { UseMutationResult } from '@tanstack/react-query'; +import { buildOnrampUrlMutationOptions } from '@ton/appkit/queries'; +import type { + BuildOnrampUrlData, + BuildOnrampUrlErrorType, + BuildOnrampUrlMutationOptions, + BuildOnrampUrlVariables, +} from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useMutation } from '../../../libs/query'; + +export type UseBuildOnrampUrlParameters = BuildOnrampUrlMutationOptions; + +export type UseBuildOnrampUrlReturnType = UseMutationResult< + BuildOnrampUrlData, + BuildOnrampUrlErrorType, + BuildOnrampUrlVariables, + context +>; + +/** + * Hook to build onramp URL + */ +export const useBuildOnrampUrl = ( + parameters: UseBuildOnrampUrlParameters = {}, +): UseBuildOnrampUrlReturnType => { + const appKit = useAppKit(); + + return useMutation({ + ...buildOnrampUrlMutationOptions(appKit), + ...parameters.mutation, + }); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-onramp-provider.ts b/packages/appkit-react/src/features/onramp/hooks/use-onramp-provider.ts new file mode 100644 index 000000000..4cd81af39 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-onramp-provider.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useSyncExternalStore, useCallback } from 'react'; +import { getOnrampProvider, watchOnrampProviders } from '@ton/appkit/onramp'; +import type { GetOnrampProviderOptions, GetOnrampProviderReturnType } from '@ton/appkit/onramp'; + +import { useAppKit } from '../../settings/hooks/use-app-kit'; + +export type UseOnrampProviderReturnType = GetOnrampProviderReturnType; + +/** + * Hook to get onramp provider + */ +export const useOnrampProvider = (options: GetOnrampProviderOptions = {}): UseOnrampProviderReturnType | undefined => { + const appKit = useAppKit(); + const { id } = options; + + const subscribe = useCallback( + (onChange: () => void) => { + return watchOnrampProviders(appKit, { onChange }); + }, + [appKit], + ); + + const getSnapshot = useCallback(() => { + try { + return getOnrampProvider(appKit, { id }); + } catch { + return undefined; + } + }, [appKit, id]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-onramp-providers.ts b/packages/appkit-react/src/features/onramp/hooks/use-onramp-providers.ts new file mode 100644 index 000000000..712ccd308 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-onramp-providers.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useSyncExternalStore, useCallback } from 'react'; +import { getOnrampProviders, watchOnrampProviders } from '@ton/appkit/onramp'; +import type { GetOnrampProvidersReturnType } from '@ton/appkit/onramp'; + +import { useAppKit } from '../../settings/hooks/use-app-kit'; + +export type UseOnrampProvidersReturnType = GetOnrampProvidersReturnType; + +/** + * Hook to get all registered onramp providers + */ +export const useOnrampProviders = (): UseOnrampProvidersReturnType => { + const appKit = useAppKit(); + + const subscribe = useCallback( + (onChange: () => void) => { + return watchOnrampProviders(appKit, { onChange }); + }, + [appKit], + ); + + const getSnapshot = useCallback(() => { + return getOnrampProviders(appKit); + }, [appKit]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; diff --git a/packages/appkit-react/src/features/onramp/hooks/use-onramp-quotes.ts b/packages/appkit-react/src/features/onramp/hooks/use-onramp-quotes.ts new file mode 100644 index 000000000..55d414c07 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/hooks/use-onramp-quotes.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use client'; + +import { getOnrampQuotesQueryOptions } from '@ton/appkit/queries'; +import type { GetOnrampQuotesData, GetOnrampQuotesErrorType, GetOnrampQuotesQueryConfig } from '@ton/appkit/queries'; + +import { useAppKit } from '../../settings'; +import { useQuery } from '../../../libs/query'; +import type { UseQueryReturnType } from '../../../libs/query'; + +export type UseOnrampQuotesParameters = GetOnrampQuotesQueryConfig; + +export type UseOnrampQuotesReturnType = UseQueryReturnType< + selectData, + GetOnrampQuotesErrorType +>; + +/** + * Hook to get onramp quotes from all registered providers (results are flattened). + */ +export const useOnrampQuotes = ( + parameters: UseOnrampQuotesParameters = {}, +): UseOnrampQuotesReturnType => { + const appKit = useAppKit(); + + return useQuery(getOnrampQuotesQueryOptions(appKit, parameters)); +}; diff --git a/packages/appkit-react/src/features/onramp/index.ts b/packages/appkit-react/src/features/onramp/index.ts index 4f4545def..17993bf2b 100644 --- a/packages/appkit-react/src/features/onramp/index.ts +++ b/packages/appkit-react/src/features/onramp/index.ts @@ -6,9 +6,12 @@ * */ +// export * from './widgets/fiat-onramp/onramp-widget'; // fiat-onramp: not ready + export * from './widgets/crypto-onramp/crypto-onramp-widget'; export * from './widgets/crypto-onramp/crypto-onramp-widget-ui'; export * from './widgets/crypto-onramp/crypto-onramp-widget-provider'; +export * from './widgets/ton-pay-widget'; export { useCryptoOnrampProvider, type UseCryptoOnrampProviderReturnType } from './hooks/use-crypto-onramp-provider'; export { diff --git a/packages/appkit-react/src/features/onramp/mock-data/currencies.ts b/packages/appkit-react/src/features/onramp/mock-data/currencies.ts new file mode 100644 index 000000000..edb84bebf --- /dev/null +++ b/packages/appkit-react/src/features/onramp/mock-data/currencies.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { OnrampCurrency } from '../types'; + +export const ONRAMP_CURRENCIES: OnrampCurrency[] = [ + { + id: 'eur', + code: 'EUR', + name: 'Euro', + symbol: '\u20AC', + logo: 'https://static.moonpay.com/widget/currencies/eur.svg', + }, + { + id: 'usd', + code: 'USD', + name: 'US Dollar', + symbol: '$', + logo: 'https://static.moonpay.com/widget/currencies/usd.svg', + }, + { + id: 'gbp', + code: 'GBP', + name: 'Pound Sterling', + symbol: '\u00A3', + logo: 'https://static.moonpay.com/widget/currencies/gbp.svg', + }, +]; diff --git a/packages/appkit-react/src/features/onramp/mock-data/providers.ts b/packages/appkit-react/src/features/onramp/mock-data/providers.ts new file mode 100644 index 000000000..41c037297 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/mock-data/providers.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { OnrampProvider } from '../types'; + +export const ONRAMP_PROVIDERS: OnrampProvider[] = [ + { + id: 'moonpay', + name: 'MoonPay', + description: 'SEPA, PayPal, Debit Card and other options', + logo: 'https://images-serviceprovider.meld.io/MOONPAY/short_logo_light.png', + }, + { + id: 'transak', + name: 'Transak', + description: 'Debit Card, Apple Pay, Google Pay, SEPA', + logo: 'https://cdn.meld.io/images-serviceprovider/TRANSAK/short_logo_light.png', + }, + { + id: 'binance', + name: 'Binance', + description: 'Debit Card, Apple Pay, Binance Cash Balance and other options', + logo: 'https://cdn.meld.io/images-serviceprovider/BINANCECONNECT/short_logo_light.png', + }, + { + id: 'mercuryo', + name: 'Mercuryo', + description: 'Debit Card, Apple Pay, Google Pay, SEPA', + logo: 'https://static.mercuryo.io/logo/mercuryo-logo.svg', + }, +]; diff --git a/packages/appkit-react/src/features/onramp/types.ts b/packages/appkit-react/src/features/onramp/types.ts index 9ecac4d74..b43bb48d7 100644 --- a/packages/appkit-react/src/features/onramp/types.ts +++ b/packages/appkit-react/src/features/onramp/types.ts @@ -6,7 +6,76 @@ * */ +export interface OnrampCurrency { + id: string; + code: string; + name: string; + symbol?: string; + logo?: string; +} + +export interface OnrampProvider { + id: string; + name: string; + description?: string; + logo?: string; +} + +export interface CurrencySectionConfig { + title: string; + ids: string[]; +} + +export type AmountInputMode = 'token' | 'currency'; + export interface OnrampAmountPreset { amount: string; label: string; } + +export interface CryptoPaymentMethod { + id: string; + /** Token symbol, e.g. "USDC", "USDT" */ + symbol: string; + /** Token name, e.g. "USD Coin", "Tether" */ + name: string; + /** Human-readable network name, e.g. "Base", "BSC" */ + network: string; + /** Source chain id as string (decimal), e.g. "8453", "56" — passed as srcChainId to the onramp provider */ + networkId: string; + /** Number of decimals for the token */ + decimals: number; + /** Token contract address on the source network (empty string / zero address for native) */ + address: string; + logo?: string; + networkLogo?: string; +} + +export interface PaymentMethodSectionConfig { + title: string; + ids: string[]; +} + +/** + * Target token (what the user is buying on TON) in the crypto onramp widget. + * Kept separate from AppkitUIToken because `address` is the raw form expected + * by the onramp provider (e.g. "0x0000000000000000000000000000000000000000" + * for native TON, "EQCx..." for USDT jetton master). + */ +export interface CryptoOnrampToken { + id: string; + /** Token symbol, e.g. "TON", "USDT" */ + symbol: string; + /** Full token name, e.g. "Toncoin", "Tether" */ + name: string; + /** Number of decimals for the token */ + decimals: number; + /** Address as the onramp provider expects it */ + address: string; + logo?: string; +} + +export interface CryptoOnrampTokenSectionConfig { + title: string; + ids: string[]; +} diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/index.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/index.ts new file mode 100644 index 000000000..daafb19b9 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampInfoBlock } from './onramp-info-block'; +export type { OnrampInfoBlockProps } from './onramp-info-block'; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/onramp-info-block.stories.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/onramp-info-block.stories.tsx new file mode 100644 index 000000000..558eee074 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/onramp-info-block.stories.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Network } from '@ton/appkit'; + +import { OnrampInfoBlock } from './onramp-info-block'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; + +const TON_TOKEN: AppkitUIToken = { + id: 'ton', + symbol: 'TON', + name: 'Toncoin', + decimals: 9, + address: 'ton', + logo: 'https://asset.ston.fi/img/EQDQoc5M3Bh8eWFephi9bClhevelbZZvWhkqdo80XuY_0qXv', + network: Network.mainnet(), +}; + +const meta: Meta = { + title: 'Public/Features/Onramp/Internal/OnrampInfoBlock', + component: OnrampInfoBlock, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + selectedToken: TON_TOKEN, + selectedQuote: { + fiatCurrency: 'USD', + cryptoCurrency: 'TON', + fiatAmount: '100', + cryptoAmount: '50.123456', + rate: '0.50123456', + providerId: 'appkit-onramp', + }, + isLoading: false, + }, +}; + +export const Loading: Story = { + args: { + selectedToken: TON_TOKEN, + selectedQuote: undefined, + isLoading: true, + }, +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/onramp-info-block.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/onramp-info-block.tsx new file mode 100644 index 000000000..52e428e71 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/onramp-info-block.tsx @@ -0,0 +1,67 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import type { OnrampQuote } from '@ton/appkit/onramp'; + +import { InfoBlock } from '../../../../../components/ui/info-block'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; +import { useConnectedWallets } from '../../../../wallets/hooks/use-connected-wallets'; +import { useI18n } from '../../../../settings/hooks/use-i18n'; +import { formatOnrampAmount } from '../../crypto-onramp/utils/format-onramp-amount'; +import { useOnrampBalance } from './use-onramp-balance'; + +export interface OnrampInfoBlockProps { + selectedToken: AppkitUIToken | null; + selectedQuote?: OnrampQuote; + isLoading: boolean; + className?: string; +} + +export const OnrampInfoBlock: FC = ({ selectedToken, selectedQuote, isLoading, className }) => { + const { t } = useI18n(); + + const wallets = useConnectedWallets(); + const activeWallet = wallets?.[0]; + const isWalletConnected = !!activeWallet; + const { targetBalance, isLoadingTargetBalance } = useOnrampBalance({ + selectedToken, + userAddress: activeWallet?.getAddress(), + }); + + return ( + + + {t('onramp.youGet')} + + {isLoading ? ( + + ) : ( + + {formatOnrampAmount(selectedQuote?.cryptoAmount, selectedToken?.decimals)}{' '} + {selectedToken?.symbol} + + )} + + + {isWalletConnected && ( + + {t('onramp.yourBalance')} + + {isLoadingTargetBalance ? ( + + ) : ( + + {formatOnrampAmount(targetBalance || '0', selectedToken?.decimals)} {selectedToken?.symbol} + + )} + + )} + + ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/use-onramp-balance.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/use-onramp-balance.ts new file mode 100644 index 000000000..75f5fb243 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-info-block/use-onramp-balance.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useBalance } from '../../../../balances/hooks/use-balance'; +import { useJettonBalanceByAddress } from '../../../../jettons/hooks/use-jetton-balance-by-address'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; + +const NATIVE_TON_TOKEN_ADDRESS = 'ton'; + +interface UseOnrampBalanceOptions { + selectedToken: AppkitUIToken | null; + userAddress: string | undefined; +} + +export const useOnrampBalance = ({ selectedToken, userAddress }: UseOnrampBalanceOptions) => { + const isNativeTonTarget = selectedToken?.address === NATIVE_TON_TOKEN_ADDRESS; + + const { data: nativeBalanceData, isLoading: isNativeBalanceLoading } = useBalance({ + query: { enabled: isNativeTonTarget && !!userAddress, refetchInterval: 5000 }, + }); + + const { data: jettonBalanceData, isLoading: isJettonBalanceLoading } = useJettonBalanceByAddress({ + jettonAddress: !isNativeTonTarget ? selectedToken?.address : undefined, + ownerAddress: userAddress, + jettonDecimals: selectedToken?.decimals, + query: { enabled: !isNativeTonTarget && !!selectedToken?.address && !!userAddress, refetchInterval: 5000 }, + }); + + return { + targetBalance: (isNativeTonTarget ? nativeBalanceData : jettonBalanceData) ?? '', + isLoadingTargetBalance: isNativeTonTarget ? isNativeBalanceLoading : isJettonBalanceLoading, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/index.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/index.ts new file mode 100644 index 000000000..5fc591d3d --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/index.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampWidgetProvider } from './onramp-widget-provider'; +export type { OnrampProviderProps } from './onramp-widget-provider'; +export { OnrampContext, useOnrampContext } from './onramp-context'; +export type { OnrampContextType } from './onramp-context'; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-context.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-context.ts new file mode 100644 index 000000000..6fe8e668f --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-context.ts @@ -0,0 +1,106 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { createContext, useContext } from 'react'; +import type { OnrampQuote } from '@ton/appkit/onramp'; + +import type { TokenSectionConfig } from '../../../../../components/shared/token-select-modal'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; +import { ONRAMP_CURRENCIES } from '../../../mock-data/currencies'; +import { DEFAULT_ONRAMP_PRESETS } from '../../../constants'; +import type { + OnrampCurrency, + OnrampProvider as OnrampWidgetProviderType, + AmountInputMode, + OnrampAmountPreset, + CurrencySectionConfig, +} from '../../../types'; + +export interface OnrampContextType { + /** Full list of available tokens to buy */ + tokens: AppkitUIToken[]; + /** Optional section configs for grouping tokens in the selector */ + tokenSections?: TokenSectionConfig[]; + /** Currently selected token to buy */ + selectedToken: AppkitUIToken | null; + setSelectedToken: (token: AppkitUIToken) => void; + + /** Available fiat currencies */ + currencies: OnrampCurrency[]; + /** Optional section configs for grouping currencies */ + currencySections?: CurrencySectionConfig[]; + /** Currently selected fiat currency */ + selectedCurrency: OnrampCurrency; + setSelectedCurrency: (currency: OnrampCurrency) => void; + + /** Current amount input value */ + amount: string; + setAmount: (value: string) => void; + /** Whether user is entering token amount or fiat amount */ + amountInputMode: AmountInputMode; + setAmountInputMode: (mode: AmountInputMode) => void; + /** Converted amount in the opposite denomination (from the selected quote) */ + convertedAmount: string; + /** Preset amount values */ + presetAmounts: OnrampAmountPreset[]; + + /** Available payment providers (derived from received quotes) */ + providers: OnrampWidgetProviderType[]; + /** Currently selected payment provider */ + selectedProvider: OnrampWidgetProviderType | null; + setSelectedProvider: (provider: OnrampWidgetProviderType) => void; + /** Quote tied to the currently selected provider */ + selectedQuote?: OnrampQuote; + /** Whether registered providers support reversed (crypto-amount) quotes */ + isReversedAmountSupported: boolean; + + /** Validation/fetch error i18n key, null when everything is ok */ + error: string | null; + /** Whether quotes are being fetched */ + isLoadingQuote: boolean; + /** Whether the user can proceed (valid amount + quote available + provider selected) */ + canSubmit: boolean; + + /** Reset widget to initial state */ + onReset: () => void; + /** Execute the onramp (build URL and redirect) */ + onContinue: () => void; +} + +const defaultContext: OnrampContextType = { + tokens: [], + tokenSections: undefined, + selectedToken: null, + setSelectedToken: () => {}, + currencies: [], + currencySections: undefined, + selectedCurrency: ONRAMP_CURRENCIES[0]!, + setSelectedCurrency: () => {}, + amount: '', + setAmount: () => {}, + amountInputMode: 'currency', + setAmountInputMode: () => {}, + convertedAmount: '', + presetAmounts: DEFAULT_ONRAMP_PRESETS, + providers: [], + selectedProvider: null, + setSelectedProvider: () => {}, + selectedQuote: undefined, + isReversedAmountSupported: false, + error: null, + isLoadingQuote: false, + canSubmit: false, + onReset: () => {}, + onContinue: () => {}, +}; + +export const OnrampContext = createContext(defaultContext); + +export const useOnrampContext = (): OnrampContextType => { + return useContext(OnrampContext); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx new file mode 100644 index 000000000..69d099e28 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/onramp-widget-provider.tsx @@ -0,0 +1,165 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useEffect, useMemo } from 'react'; +import type { FC, PropsWithChildren } from 'react'; + +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; +import type { TokenSectionConfig } from '../../../../../components/shared/token-select-modal'; +import type { CurrencySectionConfig } from '../../../types'; +import { ONRAMP_CURRENCIES } from '../../../mock-data/currencies'; +import { DEFAULT_ONRAMP_PRESETS } from '../../../constants'; +import { useBuildOnrampUrl } from '../../../hooks/use-build-onramp-url'; +import { useAddress } from '../../../../wallets'; +import { OnrampContext } from './onramp-context'; +import { useOnrampTokenState } from './use-onramp-token-state'; +import { useOnrampQuote } from './use-onramp-quote'; +import { useOnrampValidation } from './use-onramp-validation'; + +export interface OnrampProviderProps extends PropsWithChildren { + /** Full list of tokens available for purchase */ + tokens: AppkitUIToken[]; + /** Optional section configs for grouping tokens in the selector */ + tokenSections?: TokenSectionConfig[]; + /** Optional section configs for grouping currencies in the selector */ + currencySections?: CurrencySectionConfig[]; + /** Id of the token pre-selected for purchase */ + defaultTokenId?: string; + /** Id of the fiat currency pre-selected */ + defaultCurrencyId?: string; +} + +export const OnrampWidgetProvider: FC = ({ + children, + tokens, + tokenSections, + currencySections, + defaultTokenId, + defaultCurrencyId, +}) => { + // 1. Local state + const { + selectedToken, + setSelectedToken, + selectedCurrency, + setSelectedCurrency, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + amountDecimals, + } = useOnrampTokenState({ tokens, defaultTokenId, defaultCurrencyId }); + + // 2. Queries and external readers + const userAddress = useAddress(); + + const { + amountDebounced, + quoteError, + isQuoteFetching, + providers, + selectedProvider, + setSelectedProvider, + selectedQuote, + convertedAmount, + isReversedAmountSupported, + } = useOnrampQuote({ selectedToken, selectedCurrency, amount, amountInputMode }); + + // 3. Derivations + const { error, canSubmit } = useOnrampValidation({ + amount, + amountDebounced, + amountDecimals, + quoteError, + hasQuote: !!selectedQuote, + hasSelectedProvider: !!selectedProvider, + }); + + const isLoadingQuote = isQuoteFetching || amount !== amountDebounced; + + useEffect(() => { + if (!isReversedAmountSupported && amountInputMode === 'token') { + setAmountInputMode('currency'); + } + }, [isReversedAmountSupported, amountInputMode, setAmountInputMode]); + + // 4. Mutations + const { mutateAsync: buildUrl } = useBuildOnrampUrl(); + + // 5. Callbacks + const onContinue = useCallback(async () => { + if (!canSubmit || !selectedQuote || !userAddress) return; + + try { + const url = await buildUrl({ quote: selectedQuote, userAddress }); + window.open(url, '_blank'); + } catch { + // silently swallow — redirect is best-effort + } + }, [canSubmit, selectedQuote, userAddress, buildUrl]); + + const onReset = useCallback(() => { + setAmount(''); + setAmountInputMode('currency'); + }, [setAmount, setAmountInputMode]); + + const value = useMemo( + () => ({ + tokens, + tokenSections, + selectedToken, + setSelectedToken, + currencies: ONRAMP_CURRENCIES, + currencySections, + selectedCurrency, + setSelectedCurrency, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + convertedAmount, + presetAmounts: DEFAULT_ONRAMP_PRESETS, + providers, + selectedProvider, + setSelectedProvider, + selectedQuote, + isReversedAmountSupported, + error, + isLoadingQuote, + canSubmit, + onReset, + onContinue, + }), + [ + tokens, + tokenSections, + selectedToken, + setSelectedToken, + currencySections, + selectedCurrency, + setSelectedCurrency, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + convertedAmount, + providers, + selectedProvider, + setSelectedProvider, + selectedQuote, + isReversedAmountSupported, + error, + isLoadingQuote, + canSubmit, + onReset, + onContinue, + ], + ); + + return {children}; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-quote.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-quote.ts new file mode 100644 index 000000000..40d832b84 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-quote.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useMemo, useState } from 'react'; +import { keepPreviousData } from '@tanstack/react-query'; + +import { useOnrampQuotes } from '../../../hooks/use-onramp-quotes'; +import { useOnrampProviders } from '../../../hooks/use-onramp-providers'; +import { useDebounceValue } from '../../../../../hooks/use-debounce-value'; +import type { AmountInputMode, OnrampCurrency, OnrampProvider as OnrampWidgetProviderType } from '../../../types'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; + +const QUOTE_DEBOUNCE_MS = 500; + +interface UseOnrampQuoteOptions { + selectedToken: AppkitUIToken | null; + selectedCurrency: OnrampCurrency; + amount: string; + amountInputMode: AmountInputMode; +} + +export const useOnrampQuote = ({ selectedToken, selectedCurrency, amount, amountInputMode }: UseOnrampQuoteOptions) => { + const [selectedProvider, setSelectedProvider] = useState(null); + const [amountDebounced] = useDebounceValue(amount, QUOTE_DEBOUNCE_MS); + + const registeredProviders = useOnrampProviders(); + const isReversedAmountSupported = useMemo( + () => + registeredProviders.length > 0 && + registeredProviders.every((p) => p.getMetadata().isReversedAmountSupported ?? true), + [registeredProviders], + ); + + const { + data: quotes, + isFetching: isQuoteFetching, + error: quoteError, + } = useOnrampQuotes({ + fiatCurrency: selectedCurrency.code, + cryptoCurrency: selectedToken?.symbol ?? 'TON', + amount: amountDebounced || '0', + isFiatAmount: amountInputMode === 'currency', + query: { + enabled: !!amountDebounced && !isNaN(parseFloat(amountDebounced)) && parseFloat(amountDebounced) > 0, + retry: false, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }, + }); + + const providers = useMemo( + () => + quotes?.map((q) => ({ + id: q.serviceInfo?.id ?? q.providerId, + name: q.serviceInfo?.name ?? q.providerId, + description: q.serviceInfo?.paymentMethods?.join(', ') ?? '', + logo: q.serviceInfo?.lightLogo ?? '', + })) ?? [], + [quotes], + ); + + const selectedQuote = useMemo(() => { + if (!quotes || quotes.length === 0) return undefined; + if (selectedProvider) { + const match = quotes.find((q) => (q.serviceInfo?.id ?? q.providerId) === selectedProvider.id); + if (match) return match; + } + return quotes[0]; + }, [quotes, selectedProvider]); + + const convertedAmount = useMemo(() => { + if (!selectedQuote) return ''; + return amountInputMode === 'currency' ? selectedQuote.cryptoAmount : selectedQuote.fiatAmount; + }, [selectedQuote, amountInputMode]); + + useEffect(() => { + if (selectedProvider && providers.find((p) => p.id === selectedProvider.id)) return; + setSelectedProvider(providers[0] ?? null); + }, [providers, selectedProvider]); + + return { + amountDebounced, + quotes: quotes ?? null, + quoteError, + isQuoteFetching, + providers, + selectedProvider, + setSelectedProvider, + selectedQuote, + convertedAmount, + isReversedAmountSupported, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-token-state.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-token-state.ts new file mode 100644 index 000000000..f21deb28a --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-token-state.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useState } from 'react'; +import { validateNumericString } from '@ton/appkit'; + +import { ONRAMP_CURRENCIES } from '../../../mock-data/currencies'; +import type { AmountInputMode, OnrampCurrency } from '../../../types'; +import type { AppkitUIToken } from '../../../../../types/appkit-ui-token'; + +interface UseOnrampTokenStateOptions { + tokens: AppkitUIToken[]; + defaultTokenId?: string; + defaultCurrencyId?: string; +} + +const FIAT_DECIMALS = 2; + +const pickToken = (tokens: AppkitUIToken[], defaultId?: string): AppkitUIToken | null => + tokens.find((t) => t.id === defaultId) ?? tokens[0] ?? null; + +const pickCurrency = (defaultId?: string): OnrampCurrency => + ONRAMP_CURRENCIES.find((c) => c.id === defaultId) ?? ONRAMP_CURRENCIES[0]!; + +export const useOnrampTokenState = ({ tokens, defaultTokenId, defaultCurrencyId }: UseOnrampTokenStateOptions) => { + const [selectedToken, setSelectedToken] = useState(() => pickToken(tokens, defaultTokenId)); + const [selectedCurrency, setSelectedCurrency] = useState(() => pickCurrency(defaultCurrencyId)); + const [amount, setAmountRaw] = useState(''); + const [amountInputMode, setAmountInputMode] = useState('currency'); + + const amountDecimals = amountInputMode === 'token' ? (selectedToken?.decimals ?? 0) : FIAT_DECIMALS; + + const setAmount = useCallback( + (value: string) => { + if (value === '' || validateNumericString(value, amountDecimals)) setAmountRaw(value); + }, + [amountDecimals], + ); + + return { + selectedToken, + setSelectedToken, + selectedCurrency, + setSelectedCurrency, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + amountDecimals, + }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-validation.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-validation.ts new file mode 100644 index 000000000..023d46810 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-provider/use-onramp-validation.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; + +import { hasTooManyDecimals } from '../../../../../utils/validate-amount'; +import { mapOnrampError } from '../utils/map-onramp-error'; + +interface UseOnrampValidationOptions { + amount: string; + amountDebounced: string; + amountDecimals?: number; + quoteError: Error | null; + hasQuote: boolean; + hasSelectedProvider: boolean; +} + +interface UseOnrampValidationResult { + error: string | null; + canSubmit: boolean; +} + +export const useOnrampValidation = ({ + amount, + amountDebounced, + amountDecimals, + quoteError, + hasQuote, + hasSelectedProvider, +}: UseOnrampValidationOptions): UseOnrampValidationResult => { + const tooManyDecimals = hasTooManyDecimals(amount, amountDecimals); + + const mappedError = useMemo(() => { + if (tooManyDecimals) return 'onramp.tooManyDecimals'; + if (quoteError && amountDebounced) return mapOnrampError(quoteError); + return null; + }, [tooManyDecimals, quoteError, amountDebounced]); + + const canSubmit = + (parseFloat(amount) || 0) > 0 && !tooManyDecimals && mappedError === null && hasQuote && hasSelectedProvider; + + return { error: mappedError, canSubmit }; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/index.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/index.ts new file mode 100644 index 000000000..dd9854310 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampWidgetUI } from './onramp-widget-ui'; +export type { OnrampWidgetRenderProps } from './onramp-widget-ui'; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.module.css b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.module.css new file mode 100644 index 000000000..1d5dcc782 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.module.css @@ -0,0 +1,28 @@ +.widget { + box-sizing: border-box; + width: 100%; + max-width: 370px; + display: flex; + flex-direction: column; +} + +.selectors { + margin-bottom: 24px; +} + +.inputSection { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 28px 0; + margin-bottom: 24px; +} + +.presets { + margin-bottom: 16px; +} + +.info { + margin-top: 16px; +} diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx new file mode 100644 index 000000000..a0fb15ac6 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget-ui/onramp-widget-ui.tsx @@ -0,0 +1,161 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useState } from 'react'; +import type { FC } from 'react'; + +import { Button } from '../../../../../components/ui/button'; +import type { OnrampContextType } from '../onramp-widget-provider'; +import { OnrampTokenSelectors } from '../../../components/onramp-token-selectors'; +import { CenteredAmountInput } from '../../../../../components/ui/centered-amount-input'; +import { AmountPresets } from '../../../../../components/shared/amount-presets'; +import { TokenSelectModal } from '../../../../../components/shared/token-select-modal'; +import { OnrampCurrencySelectModal } from '../../../components/onramp-currency-select-modal'; +import { OnrampProviderSelect } from '../../../components/onramp-provider-select'; +import { OnrampInfoBlock } from '../onramp-info-block'; +import styles from './onramp-widget-ui.module.css'; +import { OnrampAmountReversed } from '../../../components/onramp-amount-reversed'; +import type { OnrampProvider } from '../../../types'; +import { useI18n } from '../../../../settings/hooks/use-i18n'; + +export type OnrampWidgetRenderProps = OnrampContextType; + +export const OnrampWidgetUI: FC = ({ + tokens, + tokenSections, + selectedToken, + setSelectedToken, + currencies, + currencySections, + selectedCurrency, + setSelectedCurrency, + amount, + setAmount, + amountInputMode, + setAmountInputMode, + convertedAmount, + presetAmounts, + providers, + selectedQuote, + isReversedAmountSupported, + canSubmit, + error, + isLoadingQuote, + onContinue, + setSelectedProvider, +}) => { + const { t } = useI18n(); + const [isTokenSelectOpen, setIsTokenSelectOpen] = useState(false); + + const [isCurrencySelectOpen, setIsCurrencySelectOpen] = useState(false); + const [isProviderSelectOpen, setIsProviderSelectOpen] = useState(false); + + const handleContinue = useCallback(() => { + setIsProviderSelectOpen(true); + }, []); + + const handleProviderSelected = useCallback( + (provider: OnrampProvider) => { + setSelectedProvider(provider); + // We give it a small timeout or wait for state update to ensure + // the buildOnrampUrl uses the correct providerId if it's derived from state. + // In our provider, buildOnrampUrl uses selectedProvider.id from state. + setTimeout(() => { + onContinue(); + setIsProviderSelectOpen(false); + }, 0); + }, + [onContinue, setSelectedProvider], + ); + + return ( +
+ setIsTokenSelectOpen(true)} + onToClick={() => setIsCurrencySelectOpen(true)} + /> + +
+ + setAmountInputMode(amountInputMode === 'token' ? 'currency' : 'token') + : undefined + } + ticker={amountInputMode === 'token' ? undefined : selectedToken?.symbol} + symbol={amountInputMode === 'token' ? selectedCurrency.symbol : undefined} + decimals={amountInputMode === 'token' ? 2 : (selectedToken?.decimals ?? 0)} + /> +
+ + { + setAmountInputMode('currency'); + setAmount(value); + }} + /> + + + + + + setIsTokenSelectOpen(false)} + tokens={tokens} + tokenSections={tokenSections} + onSelect={setSelectedToken} + title={t('onramp.selectToken')} + searchPlaceholder={t('onramp.searchToken')} + /> + + setIsCurrencySelectOpen(false)} + currencies={currencies} + currencySections={currencySections} + onSelect={setSelectedCurrency} + /> + + setIsProviderSelectOpen(false)} + providers={providers} + onSelect={handleProviderSelected} + /> +
+ ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/index.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/index.ts new file mode 100644 index 000000000..8c8b72d43 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OnrampWidget } from './onramp-widget'; +export type { OnrampWidgetProps } from './onramp-widget'; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/onramp-widget.stories.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/onramp-widget.stories.tsx new file mode 100644 index 000000000..fb858c352 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/onramp-widget.stories.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { STORY_TOKENS } from '../../../../../storybook/fixtures/tokens'; +import { OnrampWidget } from './onramp-widget'; + +const meta: Meta = { + title: 'Public/Features/Onramp/OnrampWidget', + component: OnrampWidget, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + tokens: STORY_TOKENS, + defaultTokenId: 'ton', + defaultCurrencyId: 'usd', + tokenSections: [{ title: 'Popular', ids: ['ton', 'usdt'] }], + currencySections: [{ title: 'Popular', ids: ['usd', 'eur'] }], + }, +}; + +export const CustomUI: Story = { + args: { + tokens: STORY_TOKENS, + defaultTokenId: 'ton', + defaultCurrencyId: 'usd', + }, + render: (args) => ( + + {({ selectedToken, selectedCurrency, amount, setAmount, canSubmit }) => ( +
+
+ Buy {selectedToken?.symbol} with {selectedCurrency.code} +
+ setAmount(e.target.value)} + placeholder="0" + inputMode="decimal" + style={{ fontSize: 32, fontWeight: 'bold', border: 'none', outline: 'none' }} + /> + +
+ )} +
+ ), +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/onramp-widget.tsx b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/onramp-widget.tsx new file mode 100644 index 000000000..24d3c1cab --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/onramp-widget/onramp-widget.tsx @@ -0,0 +1,50 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC, ReactNode } from 'react'; + +import type { OnrampWidgetRenderProps } from '../onramp-widget-ui'; +import { OnrampWidgetUI } from '../onramp-widget-ui'; +import { OnrampWidgetProvider, useOnrampContext } from '../onramp-widget-provider'; +import type { OnrampProviderProps } from '../onramp-widget-provider'; + +export interface OnrampWidgetProps extends Omit { + /** Custom render function — when provided, replaces the default widget UI */ + children?: (props: OnrampWidgetRenderProps) => ReactNode; +} + +const OnrampWidgetContent: FC<{ children?: (props: OnrampWidgetRenderProps) => ReactNode }> = ({ children }) => { + const ctx = useOnrampContext(); + + if (children) { + return <>{children(ctx)}; + } + + return ; +}; + +export const OnrampWidget: FC = ({ + children, + tokens, + tokenSections, + currencySections, + defaultTokenId, + defaultCurrencyId, +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/utils/map-onramp-error.ts b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/utils/map-onramp-error.ts new file mode 100644 index 000000000..3ed786f08 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/fiat-onramp/utils/map-onramp-error.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { OnrampError } from '@ton/appkit/onramp'; + +import { mapDefiError } from '../../../../../utils/map-defi-error'; + +/** + * Map a thrown onramp error to an i18n key. Tries onramp-specific codes first, + * falls back to the shared {@link mapDefiError}, and finally to a generic + * `onramp.genericError`. + */ +export const mapOnrampError = (error: unknown): string => { + if (error instanceof OnrampError) { + switch (error.code) { + case OnrampError.PAIR_NOT_SUPPORTED: + return 'onramp.pairNotSupported'; + case OnrampError.QUOTE_FAILED: + return 'onramp.noQuotesFound'; + case OnrampError.URL_BUILD_FAILED: + return 'onramp.urlBuildFailed'; + case OnrampError.PROVIDER_ERROR: + return 'onramp.providerError'; + } + } + + return mapDefiError(error) ?? 'onramp.genericError'; +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/index.ts b/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/index.ts new file mode 100644 index 000000000..ea36f5990 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { TonPayWidget } from './ton-pay-widget'; +export type { TonPayWidgetProps } from './ton-pay-widget'; diff --git a/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.module.css b/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.module.css new file mode 100644 index 000000000..f5d2bbd23 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.module.css @@ -0,0 +1,35 @@ +.widget { + box-sizing: border-box; + width: 100%; + max-width: 370px; + display: flex; + flex-direction: column; +} + +.selectors { + display: grid; + gap: 4px; + padding: 2px; + width: 100%; + grid-template-columns: 1fr 1fr; + margin-bottom: 64px; +} + +.selector { + width: 100%; +} + +.input { + margin-bottom: 64px; +} + +.presets { + margin-bottom: 16px; +} + +.error { + composes: footnoteRegular from '../../../../styles/typography.module.css'; + margin-top: 12px; + color: var(--ta-color-error); + text-align: center; +} diff --git a/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.stories.tsx b/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.stories.tsx new file mode 100644 index 000000000..ad82292d8 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.stories.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { STORY_TOKENS } from '../../../../storybook/fixtures/tokens'; +import { TonPayWidget } from './ton-pay-widget'; + +const meta: Meta = { + title: 'Public/Features/Onramp/TonPayWidget', + component: TonPayWidget, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + tokens: STORY_TOKENS, + defaultTokenId: 'ton', + }, +}; diff --git a/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.tsx b/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.tsx new file mode 100644 index 000000000..39845c390 --- /dev/null +++ b/packages/appkit-react/src/features/onramp/widgets/ton-pay-widget/ton-pay-widget.tsx @@ -0,0 +1,208 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useMemo, useState } from 'react'; +import type { FC } from 'react'; + +import { Button } from '../../../../components/ui/button'; +import { CenteredAmountInput } from '../../../../components/ui/centered-amount-input'; +import { TokenSelector } from '../../../../components/shared/token-selector'; +import type { AppkitUIToken } from '../../../../types/appkit-ui-token'; +import { TokenSelectModal } from '../../../../components/shared/token-select-modal'; +import type { TokenSectionConfig } from '../../../../components/shared/token-select-modal'; +import { useConnectedWallets } from '../../../wallets/hooks/use-connected-wallets'; +import { useI18n } from '../../../settings/hooks/use-i18n'; +import { OnrampCurrencySelectModal } from '../../components/onramp-currency-select-modal'; +import { ONRAMP_CURRENCIES } from '../../mock-data/currencies'; +import type { OnrampAmountPreset, OnrampCurrency, CurrencySectionConfig } from '../../types'; +import styles from './ton-pay-widget.module.css'; +import { AmountPresets } from '../../../../components/shared/amount-presets'; + +const TON_PAY_API_URL = 'https://testnet.pay.ton.org/api/merchant/v1/create-moonpay-transfer'; +const SUPPORTED_ASSETS = ['TON', 'USDT'] as const; + +const DEFAULT_CURRENCIES: OnrampCurrency[] = ONRAMP_CURRENCIES.filter((c) => c.id === 'usd'); + +const DEFAULT_PRESET_AMOUNTS = ['50', '100', '250', '500']; + +export interface TonPayWidgetProps { + /** Tokens available to buy — only TON and USDT are supported by TonPay */ + tokens: AppkitUIToken[]; + /** Optional section configs for grouping tokens in the selector */ + tokenSections?: TokenSectionConfig[]; + /** Id of the token pre-selected for purchase */ + defaultTokenId?: string; + /** Fiat currencies to show in the selector. Defaults to USD only. */ + currencies?: OnrampCurrency[]; + /** Optional section configs for grouping currencies in the selector */ + currencySections?: CurrencySectionConfig[]; + /** Id of the fiat currency pre-selected */ + defaultCurrencyId?: string; + /** Pre-filled amount */ + defaultAmount?: string; + /** Preset amount buttons (crypto amounts). Defaults to 50/100/250/500 with the token ticker. */ + presetAmounts?: OnrampAmountPreset[]; +} + +export const TonPayWidget: FC = ({ + tokens, + tokenSections, + defaultTokenId, + currencies = DEFAULT_CURRENCIES, + currencySections, + defaultCurrencyId, + defaultAmount = '', + presetAmounts, +}) => { + const { t } = useI18n(); + + const supportedTokens = useMemo( + () => + tokens.filter((token) => + SUPPORTED_ASSETS.includes(token.symbol.toUpperCase() as (typeof SUPPORTED_ASSETS)[number]), + ), + [tokens], + ); + + const [selectedToken, setSelectedToken] = useState( + () => supportedTokens.find((t) => t.id === defaultTokenId) ?? supportedTokens[0] ?? null, + ); + const [selectedCurrency, setSelectedCurrency] = useState( + () => currencies.find((c) => c.id === defaultCurrencyId) ?? currencies[0] ?? null, + ); + const [amount, setAmount] = useState(defaultAmount); + const [isTokenSelectOpen, setIsTokenSelectOpen] = useState(false); + const [isCurrencySelectOpen, setIsCurrencySelectOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const wallets = useConnectedWallets(); + const activeWallet = wallets?.[0]; + + const numericAmount = parseFloat(amount); + const isAmountValid = !isNaN(numericAmount) && numericAmount > 0; + const canContinue = isAmountValid && !!selectedToken && !!activeWallet && !isSubmitting; + const isCurrencyReadOnly = currencies.length <= 1; + const isTokenReadOnly = supportedTokens.length <= 1; + + const effectivePresets = useMemo(() => { + if (presetAmounts) return presetAmounts; + + return DEFAULT_PRESET_AMOUNTS.map((amount) => ({ + amount, + label: amount, + })); + }, [presetAmounts]); + + const handleContinue = useCallback(async () => { + if (!canContinue || !selectedToken || !activeWallet) return; + + setIsSubmitting(true); + setError(null); + + try { + const response = await fetch(TON_PAY_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asset: selectedToken.symbol.toUpperCase(), + amount: numericAmount, + recipientAddr: activeWallet.getAddress(), + directTopUp: true, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + if (!data.link) { + throw new Error('Missing redirect URL'); + } + + window.open(data.link, '_blank'); + } catch { + setError(t('onramp.tonPayError')); + } finally { + setIsSubmitting(false); + } + }, [canContinue, selectedToken, activeWallet, numericAmount, t]); + + const errorMessage = error ?? (!activeWallet ? t('onramp.connectWallet') : null); + + return ( +
+
+ setIsTokenSelectOpen(true)} + /> + + setIsCurrencySelectOpen(true)} + /> +
+ + + + + + + + {errorMessage &&
{errorMessage}
} + + {!isTokenReadOnly && ( + setIsTokenSelectOpen(false)} + tokens={supportedTokens} + tokenSections={tokenSections} + onSelect={setSelectedToken} + title={t('onramp.selectToken')} + searchPlaceholder={t('onramp.searchToken')} + /> + )} + + {!isCurrencyReadOnly && ( + setIsCurrencySelectOpen(false)} + currencies={currencies} + currencySections={currencySections} + onSelect={setSelectedCurrency} + /> + )} +
+ ); +};