diff --git a/.changeset/smooth-bushes-wear.md b/.changeset/smooth-bushes-wear.md new file mode 100644 index 000000000..22c49a6d1 --- /dev/null +++ b/.changeset/smooth-bushes-wear.md @@ -0,0 +1,21 @@ +--- +'@ton/appkit-react': patch +'@ton/walletkit': patch +'@ton/appkit': patch +--- + +- `@ton/appkit`: + - added `getSwapProvider` and `watchSwapProviders` actions + - added swap-related events and types to `AppKit` core + - added `calcFiatValue` and `formatLargeValue` amount utilities + - added `debounce` utility function +- `@ton/walletkit`: + - added `SwapProviderMetadata` interface + - added `getMetadata()` method to `SwapProvider` + - added metadata support to `DeDustSwapProvider` and `OmnistonSwapProvider` +- `@ton/appkit-react`: + - added `SwapWidget` and related UI components (`SwapField`, `SwapSettings`, `TokenSelector`, etc.) + - added `SwapWidgetProvider` for swap state management + - added hooks for swap: `useSwapProvider`, `useSwapQuote`, `useBuildSwapTransaction` + - added `useDebounceCallback`, `useDebounceValue`, and `useUnmount` utility hooks + - added English localizations for swap features diff --git a/.changeset/staking-ui-and-metadata-refactor.md b/.changeset/staking-ui-and-metadata-refactor.md new file mode 100644 index 000000000..798415e36 --- /dev/null +++ b/.changeset/staking-ui-and-metadata-refactor.md @@ -0,0 +1,22 @@ +--- +'@ton/appkit-react': patch +'@ton/walletkit': patch +'@ton/appkit': patch +--- + +- `@ton/walletkit`: + - refactored `StakingProviderMetadata`: flat token fields replaced with `stakeToken: StakingTokenInfo` object and optional `receiveToken?: StakingTokenInfo` group to support both liquid and custodial staking providers + - made `contractAddress` optional in `StakingProviderMetadata` for custodial providers without on-chain contracts + - renamed `lstExchangeRate` to `exchangeRate` in `StakingProviderInfo` + - added `StakingTokenInfo` type export + - added `isReversed` parameter to `StakingQuoteParams` for reversed unstake quotes + - added deep-merge support for metadata overrides in `TonStakersStakingProvider` constructor + - added `getStakingProvider` and `watchStakingProviders` to `DefiManager` +- `@ton/appkit`: + - added `getStakingProviderMetadata`, `getStakingProvider`, and `watchStakingProviders` actions + - added `truncateDecimals` and `formatLargeValue` amount utilities + - exported `StakingTokenInfo` type +- `@ton/appkit-react`: + - added `StakingWidget` with full stake/unstake UI, balance display, reversed quotes, and unstake mode selector + - updated base design tokens to TonConnect colors + - added staking hooks and i18n translations diff --git a/.gitignore b/.gitignore index 8aefa2c73..b7b100717 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ packages/walletkit-android-bridge/*.sh release-artifacts *.tgz +apps/appkit-minter/Caddyfile diff --git a/apps/appkit-minter/package.json b/apps/appkit-minter/package.json index c5d5806c3..e9d0a3675 100644 --- a/apps/appkit-minter/package.json +++ b/apps/appkit-minter/package.json @@ -20,8 +20,13 @@ "@ton/appkit-react": "workspace:*", "@ton/core": "catalog:", "@ton/crypto": "catalog:", + "@privy-io/react-auth": "^3.22.1", "@tonconnect/sdk": "catalog:", "@tonconnect/ui-react": "catalog:", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-separator": "1.1.8", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-tooltip": "1.2.8", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/appkit-minter/src/app.tsx b/apps/appkit-minter/src/app.tsx index 90b232ce5..a1e38a01a 100644 --- a/apps/appkit-minter/src/app.tsx +++ b/apps/appkit-minter/src/app.tsx @@ -9,11 +9,13 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AppKitProvider } from '@ton/appkit-react'; +import { PrivyBridgeProvider } from './privyProvider'; + import { appKit } from '@/core/configs/app-kit'; -import { AppRouter, ThemeProvider, ToasterProvider } from '@/core/components'; +import { ENV_PRIVY_APP_ID } from '@/core/configs/env'; +import { AppRouter, ThemeProvider, ToasterProvider, TelegramPrivyAutoConnect } from '@/core/components'; -import './core/styles/app.css'; -import '@ton/appkit-react/styles.css'; +import './core/styles/index.css'; const queryClient = new QueryClient(); @@ -22,8 +24,17 @@ export const App = () => { - - + + + + + {ENV_PRIVY_APP_ID && ( + <> + + {/* */} + + )} + diff --git a/apps/appkit-minter/src/core/components/app-logo/app-logo.tsx b/apps/appkit-minter/src/core/components/app-logo/app-logo.tsx new file mode 100644 index 000000000..0eb0073ea --- /dev/null +++ b/apps/appkit-minter/src/core/components/app-logo/app-logo.tsx @@ -0,0 +1,62 @@ +/** + * 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 { cn } from '@/core/lib/utils'; + +export const AppLogo: FC> = ({ className, ...props }) => { + return ( +
+ +
+ ); +}; diff --git a/apps/appkit-minter/src/features/swap/index.ts b/apps/appkit-minter/src/core/components/app-logo/index.ts similarity index 79% rename from apps/appkit-minter/src/features/swap/index.ts rename to apps/appkit-minter/src/core/components/app-logo/index.ts index 7857dca09..34fa898aa 100644 --- a/apps/appkit-minter/src/features/swap/index.ts +++ b/apps/appkit-minter/src/core/components/app-logo/index.ts @@ -6,4 +6,4 @@ * */ -export * from './components/swap-button'; +export { AppLogo } from './app-logo'; diff --git a/apps/appkit-minter/src/core/components/common/button.tsx b/apps/appkit-minter/src/core/components/common/button.tsx deleted file mode 100644 index b30647602..000000000 --- a/apps/appkit-minter/src/core/components/common/button.tsx +++ /dev/null @@ -1,62 +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. - * - */ - -import type React from 'react'; -import { Loader2 } from 'lucide-react'; - -import { cn } from '@/core/lib/utils'; - -interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; - size?: 'sm' | 'md' | 'lg'; - isLoading?: boolean; - children: React.ReactNode; -} - -export const Button: React.FC = ({ - variant = 'primary', - size = 'md', - isLoading = false, - children, - disabled, - className = '', - ...props -}) => { - const baseClasses = - 'font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center'; - - const variantClasses = { - primary: 'bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring shadow-md hover:shadow-lg', - secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 focus:ring-ring', - danger: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive', - ghost: 'hover:bg-accent hover:text-accent-foreground focus:ring-ring', - }; - - const sizeClasses = { - sm: 'px-3 py-2 text-sm', - md: 'px-4 py-2.5 text-base', - lg: 'px-6 py-3 text-lg', - }; - - return ( - - ); -}; diff --git a/apps/appkit-minter/src/core/components/common/index.ts b/apps/appkit-minter/src/core/components/common/index.ts index a19bc8d08..3689cf03a 100644 --- a/apps/appkit-minter/src/core/components/common/index.ts +++ b/apps/appkit-minter/src/core/components/common/index.ts @@ -6,5 +6,4 @@ * */ -export { Button } from './button'; export { Card } from './card'; diff --git a/apps/appkit-minter/src/core/components/index.ts b/apps/appkit-minter/src/core/components/index.ts index e32131718..c9b37a0f1 100644 --- a/apps/appkit-minter/src/core/components/index.ts +++ b/apps/appkit-minter/src/core/components/index.ts @@ -7,8 +7,18 @@ */ // Common components -export { Button, Card } from './common'; +export { Card } from './common'; export { ToasterProvider } from './common/toaster-provider'; // Layout components export { Layout, AppRouter, ThemeProvider } from './layout'; + +// UI components +export { Sidebar } from './sidebar'; +export { Sheet } from './sheet'; +export { Separator } from './separator'; +export { Tooltip } from './tooltip'; + +// Integrations +export { TelegramPrivyAutoConnect } from './telegram-privy-auto-connect'; +export { PrivyDebugPanel } from './privy-debug-panel'; diff --git a/apps/appkit-minter/src/core/components/layout/app-router.tsx b/apps/appkit-minter/src/core/components/layout/app-router.tsx index 26babde0f..86d053037 100644 --- a/apps/appkit-minter/src/core/components/layout/app-router.tsx +++ b/apps/appkit-minter/src/core/components/layout/app-router.tsx @@ -11,7 +11,16 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { useWatchBalance, useWatchTransactions, useWatchJettons } from '@ton/appkit-react'; import { toast } from 'sonner'; -import { MinterPage } from '@/pages'; +import { + MinterPage, + StakingPage, + SwapPage, + OnrampPage, + SignMessagePage, + NftPurchasePage, + NftPurchaseCollectionPage, + GaslessPage, +} from '@/pages'; export const AppRouter: React.FC = () => { // Enable global real-time balance updates @@ -50,6 +59,13 @@ export const AppRouter: React.FC = () => { } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/apps/appkit-minter/src/core/components/layout/app-sidebar.tsx b/apps/appkit-minter/src/core/components/layout/app-sidebar.tsx new file mode 100644 index 000000000..c41131aa2 --- /dev/null +++ b/apps/appkit-minter/src/core/components/layout/app-sidebar.tsx @@ -0,0 +1,104 @@ +/** + * 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 React from 'react'; +import { Zap, Coins, ArrowLeftRight, Sparkles, BookOpen, Github, PenLine, CreditCard, ShoppingBag } from 'lucide-react'; +import { Link, NavLink } from 'react-router-dom'; + +import { AppLogo } from '../app-logo'; + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/core/components/sidebar'; +import { NetworkPicker } from '@/features/network'; +import { WalletSelector } from '@/features/wallet'; + +const NAV_LINKS: readonly { to: string; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ + { to: '/', label: 'Mint', icon: Sparkles }, + { to: '/swap', label: 'Swap', icon: ArrowLeftRight }, + { to: '/staking', label: 'Staking', icon: Coins }, + { to: '/onramp', label: 'Buy', icon: CreditCard }, + { to: '/buy-nft', label: 'Buy NFT', icon: ShoppingBag }, + { to: '/sign', label: 'Sign Message', icon: PenLine }, + { to: '/gasless', label: 'Gasless', icon: Zap }, +]; + +const EXTERNAL_LINKS: readonly { href: string; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ + { href: 'https://docs.ton.org/ecosystem/appkit/overview', label: 'Docs', icon: BookOpen }, + { href: 'https://github.com/ton-connect/kit', label: 'GitHub', icon: Github }, +]; + +export const AppSidebar: React.FC = () => { + const { setOpenMobile, isMobile } = useSidebar(); + + const closeOnMobile = () => { + if (isMobile) setOpenMobile(false); + }; + + return ( + + + + + + NFT Minter + + + + + + + + + {NAV_LINKS.map(({ to, label, icon: Icon }) => ( + + + {({ isActive }) => ( + + + {label} + + )} + + + ))} + + + + + + + + {EXTERNAL_LINKS.map(({ href, label, icon: Icon }) => ( + + + + + {label} + + + + ))} + +
+ +
+ +
+
+ ); +}; diff --git a/apps/appkit-minter/src/core/components/layout/header-wallet.tsx b/apps/appkit-minter/src/core/components/layout/header-wallet.tsx new file mode 100644 index 000000000..b68b9c732 --- /dev/null +++ b/apps/appkit-minter/src/core/components/layout/header-wallet.tsx @@ -0,0 +1,72 @@ +/** + * 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 { TonConnectButton, useSelectedWallet, useDisconnect } from '@ton/appkit-react'; +import { Wallet, Check, Copy, LogOut } from 'lucide-react'; + +const truncateAddress = (address: string): string => { + if (address.length <= 12) return address; + return `${address.slice(0, 4)}…${address.slice(-4)}`; +}; + +export const HeaderWallet = () => { + const [wallet] = useSelectedWallet(); + const { mutate: disconnect, isPending: isDisconnecting } = useDisconnect(); + const [copied, setCopied] = useState(false); + + const address = wallet?.getAddress() ?? ''; + const connectorId = wallet?.connectorId ?? ''; + + const handleCopy = useCallback(async () => { + if (!address) return; + await navigator.clipboard.writeText(address); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [address]); + + const handleDisconnect = useCallback(() => { + if (!connectorId) return; + disconnect({ connectorId }); + }, [connectorId, disconnect]); + + return ( +
+ {wallet && ( +
+
+ +
+ + {truncateAddress(address)} + + + +
+ )} + +
+ ); +}; diff --git a/apps/appkit-minter/src/core/components/layout/index.ts b/apps/appkit-minter/src/core/components/layout/index.ts index cb45e3000..235914793 100644 --- a/apps/appkit-minter/src/core/components/layout/index.ts +++ b/apps/appkit-minter/src/core/components/layout/index.ts @@ -7,5 +7,6 @@ */ export { Layout } from './layout'; +export { AppSidebar } from './app-sidebar'; export { AppRouter } from './app-router'; export { ThemeProvider } from './theme-provider'; diff --git a/apps/appkit-minter/src/core/components/layout/layout.tsx b/apps/appkit-minter/src/core/components/layout/layout.tsx index 22a88bcd6..e140c0917 100644 --- a/apps/appkit-minter/src/core/components/layout/layout.tsx +++ b/apps/appkit-minter/src/core/components/layout/layout.tsx @@ -7,43 +7,44 @@ */ import type React from 'react'; -import { TonConnectButton } from '@ton/appkit-react'; -import { Layers } from 'lucide-react'; +import { AppSidebar } from './app-sidebar'; +import { HeaderWallet } from './header-wallet'; import { ThemeSwitcher } from './theme-switcher'; -import { NetworkPicker } from '@/features/network'; +import { Separator } from '@/core/components/separator'; +import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/core/components/sidebar'; interface LayoutProps { - children: React.ReactNode; title?: string; + children: React.ReactNode; } -export const Layout: React.FC = ({ children, title = 'NFT Minter' }) => { +export const Layout: React.FC = ({ title, children }) => { return ( -
-
-
-
-
- -
-

{title}

-
- -
- - - -
-
-
- -
{children}
- -
-

Powered by AppKit & TonConnect

-
-
+ + + +
+ + + {title && ( + <> + +

{title}

+ + )} + + + +
+ +
{children}
+ +
+

Powered by AppKit & TonConnect

+
+
+
); }; diff --git a/apps/appkit-minter/src/core/components/layout/theme-switcher.tsx b/apps/appkit-minter/src/core/components/layout/theme-switcher.tsx index 30d315453..886ed1a59 100644 --- a/apps/appkit-minter/src/core/components/layout/theme-switcher.tsx +++ b/apps/appkit-minter/src/core/components/layout/theme-switcher.tsx @@ -7,6 +7,7 @@ */ import { Moon, Sun } from 'lucide-react'; +import { Button } from '@ton/appkit-react'; import { useTheme } from '@/core/hooks'; @@ -14,12 +15,9 @@ export function ThemeSwitcher() { const { theme, setTheme } = useTheme(); return ( - + ); } diff --git a/apps/appkit-minter/src/core/components/privy-debug-panel/index.ts b/apps/appkit-minter/src/core/components/privy-debug-panel/index.ts new file mode 100644 index 000000000..95822c3c0 --- /dev/null +++ b/apps/appkit-minter/src/core/components/privy-debug-panel/index.ts @@ -0,0 +1,9 @@ +/** + * 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 { PrivyDebugPanel } from './privy-debug-panel'; diff --git a/apps/appkit-minter/src/core/components/privy-debug-panel/privy-debug-panel.tsx b/apps/appkit-minter/src/core/components/privy-debug-panel/privy-debug-panel.tsx new file mode 100644 index 000000000..37771968a --- /dev/null +++ b/apps/appkit-minter/src/core/components/privy-debug-panel/privy-debug-panel.tsx @@ -0,0 +1,144 @@ +/** + * 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, useState } from 'react'; +import type { ReactNode } from 'react'; +import { usePrivy } from '@privy-io/react-auth'; +import { useSelectedWallet, useConnectedWallets, useConnectors, useConnectorById } from '@ton/appkit-react'; +import { PRIVY_DEFAULT_CONNECTOR_ID } from '@ton/appkit'; +import type { PrivyConnector, PrivyConnectorDebugInfo } from '@ton/appkit'; +import { Bug, ChevronDown, ChevronUp } from 'lucide-react'; + +function isInsideTelegram(): boolean { + if (typeof window === 'undefined') return false; + const initData = (window as Window & { Telegram?: { WebApp?: { initData?: string } } }).Telegram?.WebApp?.initData; + return typeof initData === 'string' && initData.length > 0; +} + +const Row = ({ label, value }: { label: string; value: ReactNode }) => ( +
+ {label} + {value} +
+); + +const Pill = ({ tone, children }: { tone: 'ok' | 'off' | 'warn'; children: ReactNode }) => { + const toneClass = + tone === 'ok' + ? 'bg-green-500/15 text-green-500' + : tone === 'warn' + ? 'bg-amber-500/15 text-amber-500' + : 'bg-muted/40 text-muted-foreground'; + return ( + + {children} + + ); +}; + +export const PrivyDebugPanel = () => { + const [open, setOpen] = useState(false); + const { ready, authenticated, user } = usePrivy(); + const [selectedWallet] = useSelectedWallet(); + const connectedWallets = useConnectedWallets(); + const connectors = useConnectors(); + const privyConnector = useConnectorById(PRIVY_DEFAULT_CONNECTOR_ID) as PrivyConnector | undefined; + + const [debugInfo, setDebugInfo] = useState(null); + useEffect(() => { + if (!privyConnector) { + setDebugInfo(null); + return; + } + const tick = () => setDebugInfo(privyConnector.getDebugInfo()); + tick(); + const handle = window.setInterval(tick, 500); + return () => window.clearInterval(handle); + }, [privyConnector]); + + const insideTelegram = isInsideTelegram(); + const tonAccount = user?.linkedAccounts?.find( + (a) => a.type === 'wallet' && 'chainType' in a && a.chainType === 'ton', + ); + + const statusTone: 'ok' | 'warn' | 'off' = + debugInfo?.status === 'ready' ? 'ok' : debugInfo?.status === 'error' ? 'warn' : 'off'; + + return ( +
+ + + {open && ( +
+
+ Privy +
+ + + + + a.type).join(', ') : '—'} + /> + + +
+ PrivyConnector +
+ + + {debugInfo.error} : '—'} + /> + + + + +
+ AppKit +
+ `${c.id}:${c.type}`).join(', ') : '—'} + /> + + + + + + +
+ )} +
+ ); +}; diff --git a/apps/appkit-minter/src/core/components/separator/index.ts b/apps/appkit-minter/src/core/components/separator/index.ts new file mode 100644 index 000000000..319a2d70b --- /dev/null +++ b/apps/appkit-minter/src/core/components/separator/index.ts @@ -0,0 +1,9 @@ +/** + * 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 { Separator } from './separator'; diff --git a/apps/appkit-minter/src/core/components/separator/separator.tsx b/apps/appkit-minter/src/core/components/separator/separator.tsx new file mode 100644 index 000000000..9413ef728 --- /dev/null +++ b/apps/appkit-minter/src/core/components/separator/separator.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. + * + */ + +'use client'; + +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; + +import { cn } from '@/core/lib/utils'; + +function Separator({ + className, + orientation = 'horizontal', + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/appkit-minter/src/core/components/sheet/index.ts b/apps/appkit-minter/src/core/components/sheet/index.ts new file mode 100644 index 000000000..b4c29ced4 --- /dev/null +++ b/apps/appkit-minter/src/core/components/sheet/index.ts @@ -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. + * + */ + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} from './sheet'; diff --git a/apps/appkit-minter/src/core/components/sheet/sheet.tsx b/apps/appkit-minter/src/core/components/sheet/sheet.tsx new file mode 100644 index 000000000..ebf91e050 --- /dev/null +++ b/apps/appkit-minter/src/core/components/sheet/sheet.tsx @@ -0,0 +1,111 @@ +/** + * 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 * as React from 'react'; +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { XIcon } from 'lucide-react'; + +import { cn } from '@/core/lib/utils'; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = 'right', + ...props +}: React.ComponentProps & { + side?: 'top' | 'right' | 'bottom' | 'left'; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }; diff --git a/packages/appkit-react/src/components/circle-icon/index.ts b/apps/appkit-minter/src/core/components/sidebar/index.ts similarity index 84% rename from packages/appkit-react/src/components/circle-icon/index.ts rename to apps/appkit-minter/src/core/components/sidebar/index.ts index 50e5106bb..99688d5eb 100644 --- a/packages/appkit-react/src/components/circle-icon/index.ts +++ b/apps/appkit-minter/src/core/components/sidebar/index.ts @@ -6,4 +6,4 @@ * */ -export * from './circle-icon'; +export * from './sidebar'; diff --git a/apps/appkit-minter/src/core/components/sidebar/sidebar.tsx b/apps/appkit-minter/src/core/components/sidebar/sidebar.tsx new file mode 100644 index 000000000..c92f32abf --- /dev/null +++ b/apps/appkit-minter/src/core/components/sidebar/sidebar.tsx @@ -0,0 +1,664 @@ +/** + * 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 * as React from 'react'; +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { PanelLeftIcon } from 'lucide-react'; +import { Slot } from 'radix-ui'; +import { Button, Skeleton, Input } from '@ton/appkit-react'; + +import { Separator } from '../separator'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../sheet'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; + +import { useIsMobile } from '@/core/hooks/use-mobile'; +import { cn } from '@/core/lib/utils'; + +const SIDEBAR_COOKIE_NAME = 'sidebar_state'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = '16rem'; +const SIDEBAR_WIDTH_MOBILE = '18rem'; +const SIDEBAR_WIDTH_ICON = '3rem'; +const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; + +type SidebarContextProps = { + state: 'expanded' | 'collapsed'; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.'); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<'div'> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed'; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + +
+ {children} +
+
+ ); +} + +function Sidebar({ + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + dir, + ...props +}: React.ComponentProps<'div'> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === 'none') { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { + const { toggleSidebar } = useSidebar(); + + return ( + + setIsOpen(false)} /> + + ); +}; diff --git a/apps/appkit-minter/src/features/balances/components/deposit-modal.tsx b/apps/appkit-minter/src/features/balances/components/deposit-modal.tsx new file mode 100644 index 000000000..aedede5cf --- /dev/null +++ b/apps/appkit-minter/src/features/balances/components/deposit-modal.tsx @@ -0,0 +1,275 @@ +/** + * 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 { + Button, + Input, + Modal, + useAppKit, + useConnectedWallets, + useJettonsByAddress, + useSelectedWallet, +} from '@ton/appkit-react'; +import { + createTransferJettonTransactionForWallet, + createTransferTonTransactionForWallet, + getErrorMessage, + getFormattedJettonInfo, + PRIVY_DEFAULT_CONNECTOR_ID, + TONCONNECT_DEFAULT_CONNECTOR_ID, +} from '@ton/appkit'; +import type { WalletInterface } from '@ton/appkit'; +import { getBalanceByAddressQueryKey, getJettonsByAddressQueryKey } from '@ton/appkit/queries'; +import { useQueryClient } from '@tanstack/react-query'; + +import { TransactionStatus } from '@/features/transaction'; + +interface DepositModalProps { + isOpen: boolean; + onClose: () => void; +} + +const TON_ASSET_KEY = '__ton__'; + +const connectorLabel = (connectorId: string): string => { + if (connectorId === TONCONNECT_DEFAULT_CONNECTOR_ID) return 'TonConnect'; + if (connectorId === PRIVY_DEFAULT_CONNECTOR_ID) return 'Privy'; + return connectorId; +}; + +const truncateAddress = (address: string): string => { + if (address.length <= 12) return address; + return `${address.slice(0, 4)}…${address.slice(-4)}`; +}; + +const walletOptionLabel = (wallet: WalletInterface): string => + `${connectorLabel(wallet.connectorId)} — ${truncateAddress(wallet.getAddress())}`; + +export const DepositModal: FC = ({ isOpen, onClose }) => { + const appKit = useAppKit(); + const queryClient = useQueryClient(); + const wallets = useConnectedWallets(); + const [selectedWallet] = useSelectedWallet(); + + const defaultSourceId = selectedWallet?.getWalletId() ?? wallets[0]?.getWalletId() ?? ''; + const [sourceId, setSourceId] = useState(defaultSourceId); + const [destinationId, setDestinationId] = useState( + wallets.find((w) => w.getWalletId() !== defaultSourceId)?.getWalletId() ?? '', + ); + const [assetKey, setAssetKey] = useState(TON_ASSET_KEY); + const [amount, setAmount] = useState(''); + const [comment, setComment] = useState(''); + const [error, setError] = useState(null); + const [txBoc, setTxBoc] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const sourceWallet = useMemo(() => wallets.find((w) => w.getWalletId() === sourceId), [wallets, sourceId]); + const destinationWallet = useMemo( + () => wallets.find((w) => w.getWalletId() === destinationId), + [wallets, destinationId], + ); + + const destinationOptions = useMemo(() => wallets.filter((w) => w.getWalletId() !== sourceId), [wallets, sourceId]); + + const { data: sourceJettonsData } = useJettonsByAddress({ + address: sourceWallet?.getAddress(), + network: sourceWallet?.getNetwork(), + }); + const sourceJettons = sourceJettonsData?.jettons ?? []; + + const resetForm = () => { + setAmount(''); + setComment(''); + setError(null); + setTxBoc(null); + setAssetKey(TON_ASSET_KEY); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSourceChange = (walletId: string) => { + setSourceId(walletId); + setAssetKey(TON_ASSET_KEY); + if (walletId === destinationId) { + const nextDest = wallets.find((w) => w.getWalletId() !== walletId)?.getWalletId() ?? ''; + setDestinationId(nextDest); + } + }; + + const invalidateBalances = async () => { + if (!sourceWallet || !destinationWallet) return; + const sourceNetwork = sourceWallet.getNetwork(); + const destinationNetwork = destinationWallet.getNetwork(); + const keys = [ + getBalanceByAddressQueryKey({ address: sourceWallet.getAddress(), network: sourceNetwork }), + getBalanceByAddressQueryKey({ address: destinationWallet.getAddress(), network: destinationNetwork }), + getJettonsByAddressQueryKey({ address: sourceWallet.getAddress(), network: sourceNetwork }), + getJettonsByAddressQueryKey({ address: destinationWallet.getAddress(), network: destinationNetwork }), + ]; + await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey }))); + }; + + const handleSubmit = async () => { + if (!sourceWallet || !destinationWallet || !amount) return; + + setIsSubmitting(true); + setError(null); + + try { + const recipientAddress = destinationWallet.getAddress(); + let request; + + if (assetKey === TON_ASSET_KEY) { + request = createTransferTonTransactionForWallet(sourceWallet, { + recipientAddress, + amount, + comment: comment || undefined, + }); + } else { + const jetton = sourceJettons.find((j) => j.address === assetKey); + if (!jetton) throw new Error('Selected jetton not found in source wallet'); + const info = getFormattedJettonInfo(jetton); + if (!info.decimals) throw new Error('Jetton decimals unavailable'); + + request = await createTransferJettonTransactionForWallet(appKit, sourceWallet, { + jettonAddress: jetton.address, + jettonDecimals: info.decimals, + recipientAddress, + amount, + comment: comment || undefined, + }); + } + + const { boc } = await sourceWallet.sendTransaction(request); + setTxBoc(boc); + void invalidateBalances(); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setIsSubmitting(false); + } + }; + + const canSubmit = !!sourceWallet && !!destinationWallet && !!amount && !isSubmitting && sourceId !== destinationId; + + return ( + !open && handleClose()}> + {txBoc ? ( +
+ + +
+ ) : ( + <> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + Amount + + + setAmount(e.target.value)} + placeholder="0.00" + step="any" + min="0" + /> + + + + + + Comment (optional) + + + setComment(e.target.value)} + placeholder="Add a comment" + /> + + + + {error && ( +
+

{error}

+
+ )} +
+ +
+ + +
+ + )} +
+ ); +}; diff --git a/apps/appkit-minter/src/features/balances/components/token-transfer-modal.tsx b/apps/appkit-minter/src/features/balances/components/token-transfer-modal.tsx index 1b69cef37..21fe3fbaa 100644 --- a/apps/appkit-minter/src/features/balances/components/token-transfer-modal.tsx +++ b/apps/appkit-minter/src/features/balances/components/token-transfer-modal.tsx @@ -9,10 +9,9 @@ import React, { useMemo, useState } from 'react'; import type { Jetton } from '@ton/appkit'; import { getFormattedJettonInfo, getErrorMessage } from '@ton/appkit'; -import { SendTonButton, SendJettonButton } from '@ton/appkit-react'; -import { Gem, X } from 'lucide-react'; +import { SendTonButton, SendJettonButton, Button, Input, Modal } from '@ton/appkit-react'; +import { Gem } from 'lucide-react'; -import { Button } from '@/core/components'; import { TransactionStatus } from '@/features/transaction'; interface TokenTransferModalProps { @@ -73,155 +72,143 @@ export const TokenTransferModal: React.FC = ({ onClose(); }; - if (!isOpen || !tokenInfo.decimals) return null; + if (!tokenInfo.decimals) return null; return ( -
-
-
-
-
-
- {tokenInfo.image ? ( - {tokenInfo.name} - ) : tokenType === 'TON' ? ( - - ) : ( - - {tokenInfo.symbol?.slice(0, 2)} - - )} -
-
-

Transfer {tokenInfo.name}

-

- Balance: {tokenInfo.balance} {tokenInfo.symbol} -

-
-
- -
- - {txBoc ? ( -
- - -
+ !open && handleClose()}> +
+
+ {tokenInfo.image ? ( + {tokenInfo.name} + ) : tokenType === 'TON' ? ( + ) : ( - <> -
-
- - setRecipientAddress(e.target.value)} - placeholder="Enter TON address" - className="w-full px-3 py-2 bg-input border border-border rounded-lg focus:ring-2 focus:ring-ring focus:border-ring text-sm text-foreground placeholder:text-muted-foreground" - /> -
- -
- - setAmount(e.target.value)} - placeholder="0.00" - step="any" - min="0" - className="w-full px-3 py-2 bg-input border border-border rounded-lg focus:ring-2 focus:ring-ring focus:border-ring text-sm text-foreground placeholder:text-muted-foreground" - /> -
- -
- - setComment(e.target.value)} - placeholder="Add a comment" - className="w-full px-3 py-2 bg-input border border-border rounded-lg focus:ring-2 focus:ring-ring focus:border-ring text-sm text-foreground placeholder:text-muted-foreground" - /> -
- - {transferError && ( -
-

{transferError}

-
- )} + {tokenInfo.symbol?.slice(0, 2)} + )} +
+
+

Available Balance

+

+ {tokenInfo.balance} {tokenInfo.symbol} +

+
+
+ + {txBoc ? ( +
+ + +
+ ) : ( + <> +
+ + + Recipient Address + + + setRecipientAddress(e.target.value)} + placeholder="Enter TON address" + /> + + + + + + Amount + + + setAmount(e.target.value)} + placeholder="0.00" + step="any" + min="0" + /> + + + + + + Comment (optional) + + + setComment(e.target.value)} + placeholder="Add a comment" + /> + + + + {transferError && ( +
+

{transferError}

+ )} +
-
- {tokenType === 'TON' && ( - setTransferError(getErrorMessage(error))} - onSuccess={(data) => setTxBoc(data.boc)} +
+ {tokenType === 'TON' && ( + setTransferError(getErrorMessage(error))} + onSuccess={(data) => setTxBoc(data.boc)} + > + {({ isLoading, onSubmit, disabled, text }) => ( + - )} - + {text} + )} - - {tokenType === 'JETTON' && jetton?.address && ( - setTransferError(getErrorMessage(error))} - onSuccess={(data) => setTxBoc(data.boc)} + + )} + + {tokenType === 'JETTON' && jetton?.address && ( + setTransferError(getErrorMessage(error))} + onSuccess={(data) => setTxBoc(data.boc)} + > + {({ isLoading, onSubmit, disabled, text }) => ( + - )} - + {text} + )} + + )} - -
- - )} -
-
-
+ +
+ + )} + ); }; diff --git a/apps/appkit-minter/src/features/balances/components/tokens-card.tsx b/apps/appkit-minter/src/features/balances/components/tokens-card.tsx index c64afec63..a0cdcd87f 100644 --- a/apps/appkit-minter/src/features/balances/components/tokens-card.tsx +++ b/apps/appkit-minter/src/features/balances/components/tokens-card.tsx @@ -12,10 +12,11 @@ import type { Jetton } from '@ton/appkit'; import { getFormattedJettonInfo } from '@ton/appkit'; import { CurrencyItem, useJettons, useBalance } from '@ton/appkit-react'; import { AlertCircle } from 'lucide-react'; +import { Button } from '@ton/appkit-react'; import { TokenTransferModal } from './token-transfer-modal'; -import { Card, Button } from '@/core/components'; +import { Card } from '@/core/components'; interface SelectedToken { type: 'TON' | 'JETTON'; @@ -55,7 +56,7 @@ export const TokensCard: FC> = (props) => {

Failed to load balances

-
@@ -74,26 +75,27 @@ export const TokensCard: FC> = (props) => { ) : (
{/* Summary */} -
-

+

+

{totalTokens} {totalTokens === 1 ? 'Asset' : 'Assets'}

-
{/* Token List */}
- setSelectedToken({ type: 'TON' })} - icon="./ton.png" - isVerified - /> +
+ setSelectedToken({ type: 'TON' })} + icon="./ton.png" + isVerified + /> +
{/* Jettons */} {jettons.map((jetton) => { @@ -104,16 +106,19 @@ export const TokensCard: FC> = (props) => { } return ( - setSelectedToken({ type: 'JETTON', jetton })} - /> + className="flex items-center justify-between p-3 bg-muted rounded-lg border border-border" + > + setSelectedToken({ type: 'JETTON', jetton })} + /> +
); })}
diff --git a/apps/appkit-minter/src/features/balances/index.ts b/apps/appkit-minter/src/features/balances/index.ts index 49fef305b..f82d93a82 100644 --- a/apps/appkit-minter/src/features/balances/index.ts +++ b/apps/appkit-minter/src/features/balances/index.ts @@ -9,3 +9,4 @@ // Components export { TokensCard } from './components/tokens-card'; export { TokenTransferModal } from './components/token-transfer-modal'; +export { DepositButton } from './components/deposit-button'; diff --git a/apps/appkit-minter/src/features/mint/components/card-generator.tsx b/apps/appkit-minter/src/features/mint/components/card-generator.tsx index 652d26443..90280e948 100644 --- a/apps/appkit-minter/src/features/mint/components/card-generator.tsx +++ b/apps/appkit-minter/src/features/mint/components/card-generator.tsx @@ -12,6 +12,7 @@ import { Sparkles, Coins, AlertCircle } from 'lucide-react'; import { useSelectedWallet, Send } from '@ton/appkit-react'; import { getErrorMessage } from '@ton/appkit'; import { toast } from 'sonner'; +import { Button } from '@ton/appkit-react'; import { CardPreview } from './card-preview'; import { useCardGenerator } from '../hooks/use-card-generator'; @@ -19,7 +20,7 @@ import { useNftMintTransaction } from '../hooks/use-nft-mint-transaction'; import { mintCard } from '../store/actions/mint-card'; import { setMintError } from '../store/actions/set-mint-error'; -import { Button, Card } from '@/core/components'; +import { Card } from '@/core/components'; interface CardGeneratorProps { className?: string; @@ -86,8 +87,12 @@ export const CardGenerator: React.FC = ({ className }) => { {/* Action buttons */}
- @@ -111,11 +116,11 @@ export const CardGenerator: React.FC = ({ className }) => { )} diff --git a/apps/appkit-minter/src/features/network/components/network-picker.tsx b/apps/appkit-minter/src/features/network/components/network-picker.tsx index 016e6ad48..e6464d359 100644 --- a/apps/appkit-minter/src/features/network/components/network-picker.tsx +++ b/apps/appkit-minter/src/features/network/components/network-picker.tsx @@ -7,9 +7,11 @@ */ import type { FC, ComponentProps, ChangeEvent } from 'react'; -import { useDefaultNetwork, useNetworks, useSelectedWallet, Network } from '@ton/appkit-react'; +import { useDefaultNetwork, useNetworks, Network } from '@ton/appkit-react'; import { ChevronDown } from 'lucide-react'; +import { saveStoredNetworkChainId } from '../storage'; + import { cn } from '@/core/lib/utils'; const NETWORK_LABELS: Record = { @@ -25,11 +27,12 @@ const getNetworkLabel = (chainId: string): string => { export const NetworkPicker: FC> = ({ className, ...props }) => { const [defaultNetwork, setDefaultNetwork] = useDefaultNetwork(); const networks = useNetworks(); - const [wallet] = useSelectedWallet(); const handleChange = (e: ChangeEvent) => { const value = e.target.value; + saveStoredNetworkChainId(value); + if (value === '') { setDefaultNetwork(undefined); } else { @@ -37,19 +40,14 @@ export const NetworkPicker: FC> = ({ className, ...prop } }; - if (wallet) { - return null; - } - return ( -
+
setRecipientAddress(e.target.value)} - placeholder="Enter TON address" - className="w-full px-3 py-2 bg-input border border-border rounded-lg focus:ring-2 focus:ring-ring focus:border-ring text-sm text-foreground placeholder:text-muted-foreground" - /> -
- -
- - setComment(e.target.value)} - placeholder="Add a comment" - className="w-full px-3 py-2 bg-input border border-border rounded-lg focus:ring-2 focus:ring-ring focus:border-ring text-sm text-foreground placeholder:text-muted-foreground" - /> -
+ !open && handleClose()}> + {/* NFT Preview */} +
+
+ {nftInfo.image ? ( + {nftInfo.name} + ) : ( + + )} +
+

{nftInfo.name}

+

{nftInfo.collectionName}

+ {nftInfo.description &&

{nftInfo.description}

} +
- {transferError && ( -
-

{transferError}

-
- )} +
+ + + Recipient Address + + + setRecipientAddress(e.target.value)} + placeholder="Enter TON address" + /> + + + + + + Comment (optional) + + + setComment(e.target.value)} + placeholder="Add a comment" + /> + + + + {transferError && ( +
+

{transferError}

+ )} +
-
- { - handleClose(); - toast.success('NFT transferred successfully'); - }} - onError={(error: Error) => { - setTransferError(getErrorMessage(error)); - }} - disabled={!recipientAddress} - > - {({ isLoading, onSubmit, disabled, text }) => ( - - )} - - - -
-
+ )} + + +
-
+ ); }; diff --git a/apps/appkit-minter/src/features/nft/components/nfts-card.tsx b/apps/appkit-minter/src/features/nft/components/nfts-card.tsx index 7534dda2b..8bb1fdd7a 100644 --- a/apps/appkit-minter/src/features/nft/components/nfts-card.tsx +++ b/apps/appkit-minter/src/features/nft/components/nfts-card.tsx @@ -11,10 +11,11 @@ import type { FC, ComponentProps } from 'react'; import type { NFT } from '@ton/appkit'; import { NftItem, useNfts } from '@ton/appkit-react'; import { AlertCircle, Image as ImageIcon } from 'lucide-react'; +import { Button } from '@ton/appkit-react'; import { NftTransferModal } from './nft-transfer-modal'; -import { Card, Button } from '@/core/components'; +import { Card } from '@/core/components'; export const NftsCard: FC> = (props) => { const [selectedNft, setSelectedNft] = useState(null); @@ -38,7 +39,7 @@ export const NftsCard: FC> = (props) => {

Failed to load NFTs

-
@@ -69,7 +70,7 @@ export const NftsCard: FC> = (props) => {

{nfts.length} {nfts.length === 1 ? 'NFT' : 'NFTs'}

-
diff --git a/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts b/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts new file mode 100644 index 000000000..b081f86d4 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/api/getgems-client.ts @@ -0,0 +1,84 @@ +/** + * 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 { + GetGemsBuyResponse, + GetGemsCollection, + GetGemsEnvelope, + GetGemsNftFull, + GetGemsNftsOnSaleResponse, +} from './types'; + +import { ENV_GETGEMS_API_KEY } from '@/core/configs/env'; + +// The real GetGems API (https://api.getgems.io/public-api) does not send +// CORS headers for browser origins, so we route requests through the Vite +// dev-server proxy configured in vite.config.ts. +const BASE_URL = '/getgems-api'; + +async function request(path: string, init?: Parameters[1]): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + ...init, + headers: { + Accept: 'application/json', + Authorization: ENV_GETGEMS_API_KEY, + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...init?.headers, + }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`GetGems ${res.status} ${res.statusText}: ${text || path}`); + } + + const body = (await res.json()) as GetGemsEnvelope | T; + + if (typeof body === 'object' && body !== null && 'success' in body) { + const envelope = body as GetGemsEnvelope; + if (!envelope.success) { + throw new Error(`GetGems request failed: ${path}`); + } + return envelope.response; + } + + return body as T; +} + +export function fetchCollection(collectionAddress: string): Promise { + return request(`/v1/collection/${encodeURIComponent(collectionAddress)}`); +} + +export interface FetchNftsOnSaleParams { + limit?: number; + after?: string | null; +} + +export function fetchNftsOnSale( + collectionAddress: string, + params: FetchNftsOnSaleParams = {}, +): Promise { + const { limit = 30, after } = params; + const query = new URLSearchParams(); + query.set('limit', String(limit)); + if (after) query.set('after', after); + return request( + `/v1/nfts/on-sale/${encodeURIComponent(collectionAddress)}?${query.toString()}`, + ); +} + +export function fetchNft(nftAddress: string): Promise { + return request(`/v1/nft/${encodeURIComponent(nftAddress)}`); +} + +export function buildBuyTransaction(nftAddress: string, version: string): Promise { + return request(`/v1/nfts/buy-fix-price/${encodeURIComponent(nftAddress)}`, { + method: 'POST', + body: JSON.stringify({ version }), + }); +} diff --git a/apps/appkit-minter/src/features/nft_purchase/api/types.ts b/apps/appkit-minter/src/features/nft_purchase/api/types.ts new file mode 100644 index 000000000..452737f49 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/api/types.ts @@ -0,0 +1,72 @@ +/** + * 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 interface GetGemsEnvelope { + success: boolean; + response: T; +} + +export interface GetGemsFixPriceSale { + type?: string; + fullPrice: string; + marketplaceFee?: string; + currency: string; + version: string; + contractAddress?: string; +} + +export type GetGemsSale = GetGemsFixPriceSale | { type?: string; [key: string]: unknown }; + +export interface GetGemsNftOnSale { + address: string; + name?: string | null; + image?: string | null; + sale?: GetGemsSale | null; +} + +export interface GetGemsNftsOnSaleResponse { + items: GetGemsNftOnSale[]; + cursor?: string | null; +} + +export interface GetGemsNftFull { + address: string; + name?: string | null; + description?: string | null; + image?: string | null; + ownerAddress?: string | null; + sale?: GetGemsSale | null; +} + +export interface GetGemsCollection { + address: string; + name?: string | null; + description?: string | null; + image?: string | null; + ownerAddress?: string | null; +} + +export interface GetGemsBuyMessage { + to: string; + amount: string; + payload?: string | null; + stateInit?: string | null; +} + +export interface GetGemsBuyResponse { + uuid: string; + from?: string | null; + timeout: string; + list: GetGemsBuyMessage[]; +} + +export function isFixPriceSale(sale: GetGemsSale | null | undefined): sale is GetGemsFixPriceSale { + if (!sale) return false; + const candidate = sale as GetGemsFixPriceSale; + return typeof candidate.version === 'string' && typeof candidate.fullPrice === 'string'; +} diff --git a/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx b/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx new file mode 100644 index 000000000..b6e414686 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/collection-card.tsx @@ -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 { FC } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronRight, ImageIcon } from 'lucide-react'; + +import { useCollection } from '../hooks/use-collections'; + +interface CollectionCardProps { + address: string; +} + +export const CollectionCard: FC = ({ address }) => { + const { data, isLoading, isError } = useCollection(address); + + const name = data?.name ?? (isLoading ? 'Loading…' : 'Unknown collection'); + const description = data?.description; + const image = data?.image; + + return ( + +
+ {image ? ( + {name} + ) : isLoading ? ( +
+ ) : ( + + )} +
+
+

{name}

+ {description && !isError &&

{description}

} + {isError &&

Failed to load collection info

} +
+ + + ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx b/apps/appkit-minter/src/features/nft_purchase/components/collections-list.tsx new file mode 100644 index 000000000..8acbb02c3 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/collections-list.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 { FC } from 'react'; + +import { useFeaturedCollectionAddresses } from '../hooks/use-collections'; +import { CollectionCard } from './collection-card'; + +import { Card } from '@/core/components'; + +export const CollectionsList: FC = () => { + const addresses = useFeaturedCollectionAddresses(); + + return ( + +
+ {addresses.map((address) => ( + + ))} +
+
+ ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx b/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx new file mode 100644 index 000000000..2760647c2 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/nft-card.tsx @@ -0,0 +1,228 @@ +/** + * 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 { Button, useGaslessConfig, useSelectedWallet } from '@ton/appkit-react'; +import type { Base64String, TransactionRequest, TransactionRequestMessage } from '@ton/appkit'; +import { compareAddress, getJettonWalletAddress, Network } from '@ton/appkit'; +import { beginCell, toNano } from '@ton/core'; +import { Image as ImageIcon, ShoppingCart } from 'lucide-react'; +import { toast } from 'sonner'; + +import { buildBuyTransaction, fetchNft } from '../api/getgems-client'; +import { isFixPriceSale } from '../api/types'; +import type { GetGemsNftFull, GetGemsNftOnSale } from '../api/types'; +import { formatAmount, formatPrice, getCurrencyDecimals, safeBigInt } from '../lib/currency'; +import { buildJettonTransferBody, getJettonMaster } from '../lib/jetton'; +import { PurchaseModal } from './purchase-modal'; +import type { GaslessPurchaseConfig, PurchaseDetails } from './purchase-modal'; + +import { appKit } from '@/core/configs/app-kit'; + +interface NftCardProps { + nft: GetGemsNftOnSale; +} + +const TON_DECIMALS = 9; + +/** Gas attached to the jetton_transfer (matches what GetGems' own UI sends). */ +const JETTON_BUY_GAS = toNano('0.5'); +/** TON forwarded with jetton_notify to the sale contract. */ +const JETTON_FORWARD_TON = toNano('0.35'); + +interface JettonBuyBuildResult { + tx: TransactionRequest; + jettonMaster: string; +} + +async function buildJettonBuyTransaction(args: { + nft: GetGemsNftFull; + userAddress: string; + currency: string; + priceNano: bigint; + saleContract: string; +}): Promise { + const master = getJettonMaster(args.currency); + if (!master) { + throw new Error(`Currency ${args.currency} is not supported yet`); + } + + const userJettonWallet = await getJettonWalletAddress(appKit, { + jettonAddress: master, + ownerAddress: args.userAddress, + }); + + const commentCell = beginCell().storeUint(0, 32).storeStringTail('Bought on getgems.io').endCell(); + + const payload = buildJettonTransferBody({ + queryId: BigInt(Date.now()), + jettonAmount: args.priceNano, + destination: args.saleContract, + responseDestination: args.userAddress, + forwardTonAmount: JETTON_FORWARD_TON, + forwardPayload: commentCell, + }); + + const messages: TransactionRequestMessage[] = [ + { + address: userJettonWallet, + amount: JETTON_BUY_GAS.toString(), + payload: payload as unknown as Base64String, + }, + ]; + + return { + tx: { + validUntil: Math.floor(Date.now() / 1000) + 300, + messages, + }, + jettonMaster: master, + }; +} + +export const NftCard: FC = ({ nft }) => { + const [wallet] = useSelectedWallet(); + const { data: gaslessConfig } = useGaslessConfig(); + const [isLoadingBuy, setIsLoadingBuy] = useState(false); + const [details, setDetails] = useState(null); + + const sale = isFixPriceSale(nft.sale) ? nft.sale : null; + const priceLabel = sale ? `${formatPrice(sale.fullPrice, sale.currency)} ${sale.currency}` : null; + + const isMainnet = wallet?.getNetwork().chainId === Network.mainnet().chainId; + + const supportedGasJettons = useMemo(() => gaslessConfig?.supportedGasJettons ?? [], [gaslessConfig]); + + const isGaslessSupportedFor = (jettonMaster: string): boolean => { + return supportedGasJettons.some((j) => compareAddress(j.jettonMaster, jettonMaster)); + }; + + const handleBuyClick = async () => { + if (isLoadingBuy || !wallet) return; + setIsLoadingBuy(true); + try { + const fresh = await fetchNft(nft.address); + if (!isFixPriceSale(fresh.sale)) { + toast.error('This NFT is not on sale anymore'); + return; + } + + const currency = fresh.sale.currency; + const priceDecimals = getCurrencyDecimals(currency); + const priceRaw = safeBigInt(fresh.sale.fullPrice); + + let tx: TransactionRequest; + let networkFeeRaw: bigint; + let gasless: GaslessPurchaseConfig | undefined; + + if (currency === 'TON') { + const buy = await buildBuyTransaction(nft.address, fresh.sale.version); + const messages: TransactionRequestMessage[] = buy.list.map((item) => { + const message: TransactionRequestMessage = { address: item.to, amount: item.amount }; + if (item.payload) message.payload = item.payload as unknown as Base64String; + if (item.stateInit) message.stateInit = item.stateInit as unknown as Base64String; + return message; + }); + tx = { + validUntil: Math.floor(new Date(buy.timeout).getTime() / 1000), + messages, + }; + networkFeeRaw = buy.list.reduce((acc, item) => acc + safeBigInt(item.amount), 0n); + } else { + const saleContract = fresh.sale.contractAddress; + if (!saleContract) { + throw new Error('Sale contract address is missing'); + } + const built = await buildJettonBuyTransaction({ + nft: fresh, + userAddress: wallet.getAddress(), + currency, + priceNano: priceRaw, + saleContract, + }); + tx = built.tx; + networkFeeRaw = JETTON_BUY_GAS; + if (isGaslessSupportedFor(built.jettonMaster)) { + gasless = { + feeJettonMaster: built.jettonMaster, + messages: built.tx.messages, + }; + } + } + + setDetails({ + nftName: fresh.name ?? nft.name ?? 'Untitled', + nftImage: fresh.image ?? nft.image, + priceAmount: formatAmount(priceRaw, priceDecimals), + priceRaw, + priceCurrency: currency, + networkFeeTon: formatAmount(networkFeeRaw, TON_DECIMALS), + tx, + gasless, + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to prepare purchase'); + } finally { + setIsLoadingBuy(false); + } + }; + + return ( + <> +
+
+ {nft.image ? ( + {nft.name + ) : ( + + )} +
+
+

{nft.name ?? 'Untitled'}

+

{priceLabel ?? 'Not for sale'}

+ {sale && ( +
+ {!wallet ? ( + + ) : !isMainnet ? ( + + ) : ( + + )} +
+ )} +
+
+ + {details && ( + { + if (!open) setDetails(null); + }} + details={details} + onPurchased={() => setDetails(null)} + /> + )} + + ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/nfts-list.tsx b/apps/appkit-minter/src/features/nft_purchase/components/nfts-list.tsx new file mode 100644 index 000000000..e08d72673 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/nfts-list.tsx @@ -0,0 +1,139 @@ +/** + * 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 { useState } from 'react'; +import type { FC } from 'react'; +import { AlertCircle, Image as ImageIcon } from 'lucide-react'; +import { Button } from '@ton/appkit-react'; + +import { useNftsOnSale } from '../hooks/use-nfts-on-sale'; +import { NftCard } from './nft-card'; + +import { Card } from '@/core/components'; + +interface NftsListProps { + collectionAddress: string; +} + +const CURRENCY_FILTERS = ['ALL', 'TON', 'USDT'] as const; +type CurrencyFilter = (typeof CURRENCY_FILTERS)[number]; + +export const NftsList: FC = ({ collectionAddress }) => { + const [currencyFilter, setCurrencyFilter] = useState('ALL'); + + const { + items, + totalLoaded, + loadedPages, + isLoading, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + refetch, + } = useNftsOnSale(collectionAddress, { + currency: currencyFilter === 'ALL' ? undefined : currencyFilter, + }); + + const statsLabel = + totalLoaded > 0 + ? `Showing ${items.length} of ${totalLoaded} loaded (${loadedPages} page${loadedPages === 1 ? '' : 's'})` + : null; + + const controls = ( +
+
+ {CURRENCY_FILTERS.map((option) => ( + + ))} +
+ {statsLabel &&

{statsLabel}

} +
+ ); + + if (isError) { + return ( + + {controls} +
+ +

Failed to load NFTs

+ +
+
+ ); + } + + if (isLoading) { + return ( + + {controls} +
+
+ Loading NFTs... +
+
+ ); + } + + return ( + + {controls} + {items.length === 0 ? ( +
+ {isFetchingNextPage ? ( + <> +
+

+ Searching for {currencyFilter === 'ALL' ? 'NFTs' : `${currencyFilter} sales`}... +

+ + ) : ( + <> + +

+ {currencyFilter === 'ALL' + ? 'No NFTs currently on sale' + : `No ${currencyFilter} sales found across ${totalLoaded} loaded items`} +

+ {hasNextPage && ( + + )} + + )} +
+ ) : ( + <> +
+ {items.map((nft) => ( + + ))} +
+ {hasNextPage && ( +
+ +
+ )} + + )} +
+ ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx b/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx new file mode 100644 index 000000000..a957781b1 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/components/purchase-modal.tsx @@ -0,0 +1,239 @@ +/** + * 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 { useState } from 'react'; +import type { FC } from 'react'; +import { AlertCircle, Image as ImageIcon, ShoppingCart } from 'lucide-react'; +import { + Button, + CryptoOnrampWidget, + Modal, + Send, + useJettons, + useSelectedWallet, + useSendGaslessTransaction, +} from '@ton/appkit-react'; +import type { TransactionRequest, TransactionRequestMessage } from '@ton/appkit'; +import { compareAddress, getErrorMessage, parseUnits } from '@ton/appkit'; +import { toast } from 'sonner'; + +import { formatAmount, getCurrencyDecimals } from '../lib/currency'; +import { JETTON_MASTERS } from '../lib/jetton'; + +import { + ONRAMP_DEFAULT_METHOD_ID, + ONRAMP_DEFAULT_TOKEN_ID, + ONRAMP_PAYMENT_METHODS, + ONRAMP_TOKENS, +} from '@/core/configs/onramp'; + +export interface GaslessPurchaseConfig { + feeJettonMaster: string; + messages: TransactionRequestMessage[]; +} + +export interface PurchaseDetails { + nftName: string; + nftImage?: string | null; + priceAmount: string; + priceRaw: bigint; + priceCurrency: string; + networkFeeTon: string; + tx: TransactionRequest; + gasless?: GaslessPurchaseConfig; +} + +interface PurchaseModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + details: PurchaseDetails; + onPurchased: () => void; +} + +export const PurchaseModal: FC = ({ open, onOpenChange, details, onPurchased }) => { + // Verification warning is temporarily disabled — default to confirmed=true so Buy stays enabled. + // When re-enabling the warning block below, restore the setter: `const [confirmed, setConfirmed] = useState(false);` + const [confirmed] = useState(true); + const [showOnramp, setShowOnramp] = useState(false); + const { mutateAsync: sendGasless, isPending: isGaslessSending } = useSendGaslessTransaction(); + const [wallet] = useSelectedWallet(); + + const isGasless = !!details.gasless; + const isUsdt = details.priceCurrency.toUpperCase() === 'USDT'; + const usdtDecimals = getCurrencyDecimals('USDT'); + + // Read from the jettons list query: its key matches `handleJettonsUpdate`'s + // write key, so `useWatchJettons` streaming updates flow through here. + const { data: jettonsData } = useJettons({ + query: { enabled: isUsdt && !!wallet }, + }); + const usdtJetton = jettonsData?.jettons.find((j) => compareAddress(j.address, JETTON_MASTERS.USDT)); + const usdtBalance = usdtJetton?.balance; + + const gasBufferRaw = isGasless ? 10n ** BigInt(usdtDecimals) : 0n; + const requiredRaw = details.priceRaw + gasBufferRaw; + const balanceRaw = usdtBalance !== undefined ? parseUnits(usdtBalance, usdtDecimals) : undefined; + const hasInsufficientFunds = isUsdt && balanceRaw !== undefined && balanceRaw < requiredRaw; + + const handleGaslessBuy = async () => { + if (!details.gasless) return; + try { + await sendGasless({ + feeJettonMaster: details.gasless.feeJettonMaster, + messages: details.gasless.messages, + }); + toast.success('Purchase sent'); + onPurchased(); + onOpenChange(false); + } catch (error) { + toast.error(getErrorMessage(error instanceof Error ? error : new Error(String(error)))); + } + }; + + return ( + <> + +
+
+
+ {details.nftImage ? ( + {details.nftName} + ) : ( + + )} +
+

{details.nftName}

+
+ +
+
+
+

NFT price

+

Includes service fee and royalties

+
+

+ {details.priceAmount} {details.priceCurrency} +

+
+
+
+

Network fee

+

+ {isGasless + ? `Paid in ${details.priceCurrency} via gasless relayer` + : 'Unused part will be refunded to your wallet'} +

+
+

+ {isGasless ? `Gasless` : `${details.networkFeeTon} TON`} +

+
+
+ + {/* Temporarily disabled — re-enable once verification data is wired in. */} + {/*
+
+ +
+

Warning

+

+ You are buying an NFT without a verification mark. It may be a counterfeit item. + Double-check the NFT before purchase. +

+
+
+ +
*/} + + {hasInsufficientFunds && ( +
+
+ +
+

Not enough funds

+

+ You need {formatAmount(requiredRaw, usdtDecimals)} USDT to complete this + purchase + {isGasless ? ' (includes 1 USDT gasless fee)' : ''}. +

+
+
+ +
+ )} + + {isGasless ? ( + + ) : ( + { + toast.success('Purchase sent'); + onPurchased(); + onOpenChange(false); + }} + onError={(error: Error) => toast.error(getErrorMessage(error))} + disabled={!confirmed || hasInsufficientFunds} + > + {({ isLoading, onSubmit, disabled }) => ( + + )} + + )} +
+
+ + + + + + ); +}; diff --git a/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts b/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts new file mode 100644 index 000000000..631acf644 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/hooks/use-collections.ts @@ -0,0 +1,26 @@ +/** + * 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 { useQuery } from '@tanstack/react-query'; +import type { UseQueryResult } from '@tanstack/react-query'; + +import { fetchCollection } from '../api/getgems-client'; +import type { GetGemsCollection } from '../api/types'; +import { FEATURED_COLLECTION_ADDRESSES } from '../lib/featured-collections'; + +export function useFeaturedCollectionAddresses(): readonly string[] { + return FEATURED_COLLECTION_ADDRESSES; +} + +export function useCollection(address: string): UseQueryResult { + return useQuery({ + queryKey: ['getgems', 'collection', address], + queryFn: () => fetchCollection(address), + staleTime: 5 * 60_000, + }); +} diff --git a/apps/appkit-minter/src/features/nft_purchase/hooks/use-nfts-on-sale.ts b/apps/appkit-minter/src/features/nft_purchase/hooks/use-nfts-on-sale.ts new file mode 100644 index 000000000..86e2d8df8 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/hooks/use-nfts-on-sale.ts @@ -0,0 +1,85 @@ +/** + * 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 } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { UseInfiniteQueryResult } from '@tanstack/react-query'; + +import { fetchNftsOnSale } from '../api/getgems-client'; +import type { GetGemsNftOnSale, GetGemsNftsOnSaleResponse } from '../api/types'; +import { isFixPriceSale } from '../api/types'; + +export interface UseNftsOnSaleOptions { + /** Restrict results to fix-price sales in this currency (case-insensitive, e.g. "USDT"). */ + currency?: string; + /** Page size forwarded to the GetGems API (1..100). Defaults to 100. */ + pageSize?: number; + /** Auto-fetch next pages while the active filter yields zero matches. Defaults to true. */ + autoLoadWhileEmpty?: boolean; +} + +export interface UseNftsOnSaleResult { + /** Items matching the active filter, flattened across all loaded pages. */ + items: GetGemsNftOnSale[]; + /** Total items returned by the API across all loaded pages (pre-filter). */ + totalLoaded: number; + /** Number of pages fetched so far. */ + loadedPages: number; + isLoading: boolean; + isError: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + refetch: UseInfiniteQueryResult['refetch']; +} + +export function useNftsOnSale(collectionAddress: string, options: UseNftsOnSaleOptions = {}): UseNftsOnSaleResult { + const { currency, pageSize = 100, autoLoadWhileEmpty = true } = options; + const currencyFilter = currency?.toUpperCase(); + + const query = useInfiniteQuery({ + queryKey: ['getgems', 'nfts-on-sale', collectionAddress, pageSize], + queryFn: ({ pageParam }) => + fetchNftsOnSale(collectionAddress, { limit: pageSize, after: pageParam as string | null }), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, + staleTime: 30_000, + }); + + const { items, totalLoaded } = useMemo(() => { + const flat = query.data?.pages.flatMap((page) => page.items) ?? []; + const filtered = flat.filter((nft) => { + if (!isFixPriceSale(nft.sale)) return false; + if (currencyFilter && nft.sale.currency.toUpperCase() !== currencyFilter) return false; + return true; + }); + return { items: filtered, totalLoaded: flat.length }; + }, [query.data, currencyFilter]); + + const hasNextPage = query.hasNextPage ?? false; + + useEffect(() => { + if (!autoLoadWhileEmpty) return; + if (items.length > 0) return; + if (!hasNextPage) return; + if (query.isFetching) return; + query.fetchNextPage(); + }, [autoLoadWhileEmpty, items.length, hasNextPage, query]); + + return { + items, + totalLoaded, + loadedPages: query.data?.pages.length ?? 0, + isLoading: query.isLoading, + isError: query.isError, + hasNextPage, + isFetchingNextPage: query.isFetchingNextPage, + fetchNextPage: () => void query.fetchNextPage(), + refetch: query.refetch, + }; +} diff --git a/apps/appkit-minter/src/features/nft_purchase/index.ts b/apps/appkit-minter/src/features/nft_purchase/index.ts new file mode 100644 index 000000000..2973e2802 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/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 { CollectionsList } from './components/collections-list'; +export { NftsList } from './components/nfts-list'; diff --git a/apps/appkit-minter/src/features/nft_purchase/lib/currency.ts b/apps/appkit-minter/src/features/nft_purchase/lib/currency.ts new file mode 100644 index 000000000..c1e21ce3c --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/lib/currency.ts @@ -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. + * + */ + +const CURRENCY_DECIMALS: Record = { + TON: 9, + USDT: 6, + NOT: 9, + DOGS: 9, + HMSTR: 9, +}; + +export function getCurrencyDecimals(currency: string): number { + return CURRENCY_DECIMALS[currency.toUpperCase()] ?? 9; +} + +export function safeBigInt(value: string): bigint { + try { + return BigInt(value); + } catch { + return 0n; + } +} + +export function formatAmount(raw: bigint, decimals: number, maxFraction = 4): string { + if (decimals <= 0) return raw.toString(); + const base = 10n ** BigInt(decimals); + const whole = raw / base; + const frac = raw % base; + const fracStr = frac.toString().padStart(decimals, '0').slice(0, Math.max(0, maxFraction)).replace(/0+$/, ''); + return fracStr ? `${whole}.${fracStr}` : `${whole}`; +} + +export function formatPrice(rawAmount: string, currency: string): string { + const decimals = getCurrencyDecimals(currency); + return formatAmount(safeBigInt(rawAmount), decimals); +} diff --git a/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts b/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.ts new file mode 100644 index 000000000..51aa39e5d --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/lib/featured-collections.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 const FEATURED_COLLECTION_ADDRESSES: readonly string[] = [ + // 'EQC3dNlesgVD8YbAazcauIrXBPfiVhMMr5YYk2in0Mtsz0Bz', // TON DNS + 'EQBIj0uF-qIASqv6qIvcTif2wKSdt4WQc4mcoBywNp5GntuG', // Instant Ramens +]; diff --git a/apps/appkit-minter/src/features/nft_purchase/lib/jetton.ts b/apps/appkit-minter/src/features/nft_purchase/lib/jetton.ts new file mode 100644 index 000000000..b938c9646 --- /dev/null +++ b/apps/appkit-minter/src/features/nft_purchase/lib/jetton.ts @@ -0,0 +1,61 @@ +/** + * 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 { Cell } from '@ton/core'; +import { Address, beginCell } from '@ton/core'; + +export const JETTON_MASTERS: Record = { + USDT: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', +}; + +export function getJettonMaster(currency: string): string | undefined { + return JETTON_MASTERS[currency.toUpperCase()]; +} + +interface JettonTransferParams { + queryId: bigint; + jettonAmount: bigint; + destination: string; + responseDestination: string; + forwardTonAmount: bigint; + forwardPayload?: Cell | null; +} + +/** + * Build the body of a TEP-74 jetton_transfer internal message. + * Returns a Base64-encoded BoC ready to place in a TransactionRequestMessage.payload. + * + * For GetGems FixPriceSale USDT buys the sale contract recognizes the purchase + * from a jetton_notify with an empty forward_payload, so callers should pass + * forwardPayload=null. + */ +export function buildJettonTransferBody({ + queryId, + jettonAmount, + destination, + responseDestination, + forwardTonAmount, + forwardPayload, +}: JettonTransferParams): string { + const builder = beginCell() + .storeUint(0x0f8a7ea5, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(Address.parse(destination)) + .storeAddress(Address.parse(responseDestination)) + .storeBit(false) + .storeCoins(forwardTonAmount); + + if (forwardPayload) { + builder.storeBit(true).storeRef(forwardPayload); + } else { + builder.storeBit(false); + } + + return builder.endCell().toBoc().toString('base64'); +} diff --git a/apps/appkit-minter/src/features/signing/components/sign-message-card.tsx b/apps/appkit-minter/src/features/signing/components/sign-message-card.tsx index fb9a29eed..2672756e1 100644 --- a/apps/appkit-minter/src/features/signing/components/sign-message-card.tsx +++ b/apps/appkit-minter/src/features/signing/components/sign-message-card.tsx @@ -10,8 +10,9 @@ import { useState } from 'react'; import type { FC, ComponentProps } from 'react'; import { useSignText, useSelectedWallet } from '@ton/appkit-react'; import { toast } from 'sonner'; +import { Button } from '@ton/appkit-react'; -import { Card, Button } from '@/core/components'; +import { Card } from '@/core/components'; export const SignMessageCard: FC> = (props) => { const [message, setMessage] = useState(''); @@ -64,10 +65,11 @@ export const SignMessageCard: FC> = (props) => { {/* Sign Button */} @@ -79,7 +81,7 @@ export const SignMessageCard: FC> = (props) => {
{signature}
-
diff --git a/apps/appkit-minter/src/features/staking/components/stake-button.tsx b/apps/appkit-minter/src/features/staking/components/stake-button.tsx index 0a0baa5e0..d23e13fcc 100644 --- a/apps/appkit-minter/src/features/staking/components/stake-button.tsx +++ b/apps/appkit-minter/src/features/staking/components/stake-button.tsx @@ -81,10 +81,12 @@ export const StakeButton: FC = ({ return ( ); }; diff --git a/apps/appkit-minter/src/features/staking/components/staking-card.tsx b/apps/appkit-minter/src/features/staking/components/staking-card.tsx index dfb9f5b2f..57efeb247 100644 --- a/apps/appkit-minter/src/features/staking/components/staking-card.tsx +++ b/apps/appkit-minter/src/features/staking/components/staking-card.tsx @@ -8,13 +8,12 @@ import { useMemo, useState } from 'react'; import type { FC } from 'react'; -import { UnstakeMode, useAddress, useBalance, useStakedBalance } from '@ton/appkit-react'; +import { UnstakeMode, useAddress, useBalance, useStakedBalance, Input, Button } from '@ton/appkit-react'; import type { UnstakeModes } from '@ton/appkit-react'; import { StakeButton } from './stake-button'; import { Card } from '@/core/components'; -import { cn } from '@/core/lib/utils'; const balanceAmountFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 20, @@ -113,26 +112,25 @@ export const StakingCard: FC = () => {
-
- - setAmountInput(e.target.value)} - placeholder="Default: 1" - step="any" - min="0" - className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:ring-2 focus:ring-ring" - /> + + + Amount (optional) + + + setAmountInput(e.target.value)} + placeholder="Default: 1" + step="any" + min="0" + /> + {amountInvalid ? ( -

- Enter a positive number or leave empty to use 1. -

+ Enter a positive number or leave empty to use 1. ) : null} -
+
Tonstakers:
@@ -141,30 +139,28 @@ export const StakingCard: FC = () => { Unstake mode
{UNSTAKE_MODE_OPTIONS.map(({ mode, label }) => ( - + ))}
+

{UNSTAKE_MODE_OPTIONS.find((o) => o.mode === unstakeMode)?.hint}

+ diff --git a/apps/appkit-minter/src/features/swap/components/swap-button.tsx b/apps/appkit-minter/src/features/swap/components/swap-button.tsx deleted file mode 100644 index 58afd4145..000000000 --- a/apps/appkit-minter/src/features/swap/components/swap-button.tsx +++ /dev/null @@ -1,67 +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. - * - */ - -import { useMemo } from 'react'; -import type { FC } from 'react'; -import { Send, useSwapQuote, useNetwork, useAddress, useBuildSwapTransaction } from '@ton/appkit-react'; - -const USDT_ADDRESS = 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs'; -const TON = { address: 'ton', decimals: 9, symbol: 'TON' }; -const USDT = { address: USDT_ADDRESS, decimals: 6, symbol: 'USDT' }; - -interface SwapButtonProps { - amount: string; - direction: 'from' | 'to'; - providerId?: string; -} - -export const SwapButton: FC = ({ amount, direction, providerId }) => { - const network = useNetwork(); - const address = useAddress(); - const from = direction === 'from' ? TON : USDT; - const to = direction === 'to' ? TON : USDT; - const { - data: quote, - isError, - isLoading, - } = useSwapQuote({ - amount, - from, - to, - network, - slippageBps: 100, - providerId, - }); - - const { mutateAsync: buildSwapTransaction } = useBuildSwapTransaction(); - - const handleBuildSwapTransaction = () => { - if (!quote || !address) { - return Promise.reject(new Error('Missing quote or address')); - } - - return buildSwapTransaction({ - quote, - userAddress: address, - }); - }; - - const buttonText = useMemo(() => { - if (isLoading) { - return 'Fetching quote...'; - } - - if (isError || !quote) { - return 'Swap Unavailable'; - } - - return `Swap ${quote.fromAmount} ${from.symbol} -> ${quote.toAmount} ${to.symbol}`; - }, [isLoading, isError, quote]); - - return ; -}; diff --git a/apps/appkit-minter/src/features/wallet/components/wallet-info.tsx b/apps/appkit-minter/src/features/wallet/components/wallet-info.tsx deleted file mode 100644 index 4f2928385..000000000 --- a/apps/appkit-minter/src/features/wallet/components/wallet-info.tsx +++ /dev/null @@ -1,92 +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. - * - */ - -import { useState, useCallback } from 'react'; -import type { ComponentProps, FC } from 'react'; -import { useSelectedWallet, Network } from '@ton/appkit-react'; -import { Wallet, Check, Copy } from 'lucide-react'; - -import { Card } from '@/core/components'; - -const NETWORK_LABELS: Record = { - [Network.mainnet().chainId]: 'Mainnet', - [Network.testnet().chainId]: 'Testnet', - [Network.tetra().chainId]: 'Tetra', -}; - -const getNetworkLabel = (chainId: string): string => { - return NETWORK_LABELS[chainId] ?? `Chain ${chainId}`; -}; - -const truncateAddress = (address: string): string => { - if (address.length <= 12) { - return address; - } - - return `${address.slice(0, 6)}…${address.slice(-6)}`; -}; - -export const WalletInfo: FC> = (props) => { - const [wallet] = useSelectedWallet(); - const [copied, setCopied] = useState(false); - - const address = wallet?.getAddress() ?? ''; - const networkLabel = wallet ? getNetworkLabel(wallet.getNetwork().chainId) : ''; - - const handleCopy = useCallback(async () => { - if (!address) return; - await navigator.clipboard.writeText(address); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [address]); - - return ( - -
-
-
- -
- - {!wallet &&

Connect wallet to mint

} - - {wallet && ( -
-

- {truncateAddress(address)} -

- -

Network: {networkLabel}

-
- )} -
- - {wallet && ( - - )} -
-
- ); -}; diff --git a/apps/appkit-minter/src/features/wallet/components/wallet-selector.tsx b/apps/appkit-minter/src/features/wallet/components/wallet-selector.tsx new file mode 100644 index 000000000..e3c5a840a --- /dev/null +++ b/apps/appkit-minter/src/features/wallet/components/wallet-selector.tsx @@ -0,0 +1,146 @@ +/** + * 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 { useConnectedWallets, useDisconnect, useSelectedWallet } from '@ton/appkit-react'; +import { PRIVY_DEFAULT_CONNECTOR_ID, TONCONNECT_DEFAULT_CONNECTOR_ID } from '@ton/appkit'; +import { Check, Copy, LogOut, Wallet } from 'lucide-react'; + +import { cn } from '@/core/lib/utils'; + +const truncateAddress = (address: string): string => { + if (address.length <= 12) return address; + return `${address.slice(0, 4)}…${address.slice(-4)}`; +}; + +const connectorLabel = (connectorId: string): string => { + if (connectorId === TONCONNECT_DEFAULT_CONNECTOR_ID) return 'TonConnect'; + if (connectorId === PRIVY_DEFAULT_CONNECTOR_ID) return 'Privy'; + return connectorId; +}; + +export const WalletSelector: FC = () => { + const wallets = useConnectedWallets(); + const [selectedWallet, setWalletId] = useSelectedWallet(); + const { mutate: disconnect, isPending: isDisconnecting } = useDisconnect(); + const [copiedWalletId, setCopiedWalletId] = useState(null); + + const selectedWalletId = selectedWallet?.getWalletId() ?? null; + + const distinctConnectorIds = useMemo(() => { + const ids = new Set(); + for (const w of wallets) ids.add(w.connectorId); + return Array.from(ids); + }, [wallets]); + + const handleCopy = useCallback(async (walletId: string, address: string) => { + if (!address) return; + await navigator.clipboard.writeText(address); + setCopiedWalletId(walletId); + setTimeout(() => setCopiedWalletId((prev) => (prev === walletId ? null : prev)), 2000); + }, []); + + const handleSelect = useCallback( + (walletId: string) => { + if (walletId !== selectedWalletId) setWalletId(walletId); + }, + [selectedWalletId, setWalletId], + ); + + const handleDisconnect = useCallback( + (connectorId: string) => { + disconnect({ connectorId }); + }, + [disconnect], + ); + + return ( +
+
+ {wallets.map((wallet) => { + const walletId = wallet.getWalletId(); + const address = wallet.getAddress(); + const isActive = walletId === selectedWalletId; + const isCopied = copiedWalletId === walletId; + + return ( + + ); + })} +
+ +
+ {distinctConnectorIds.map((connectorId) => ( + + ))} +
+
+ ); +}; diff --git a/apps/appkit-minter/src/features/wallet/index.ts b/apps/appkit-minter/src/features/wallet/index.ts index 1063decdb..98f173253 100644 --- a/apps/appkit-minter/src/features/wallet/index.ts +++ b/apps/appkit-minter/src/features/wallet/index.ts @@ -6,4 +6,4 @@ * */ -export { WalletInfo } from './components/wallet-info'; +export { WalletSelector } from './components/wallet-selector'; diff --git a/apps/appkit-minter/src/main.tsx b/apps/appkit-minter/src/main.tsx index 420ea6f7f..a611c5f40 100644 --- a/apps/appkit-minter/src/main.tsx +++ b/apps/appkit-minter/src/main.tsx @@ -12,11 +12,12 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './app'; +import { AppPrivyProvider } from './privyProvider'; -import './core/styles/index.css'; - -createRoot(document.getElementById('root')!).render( - +const root = ( + - , + ); + +createRoot(document.getElementById('root')!).render({root}); diff --git a/apps/appkit-minter/src/pages/gasless-page.tsx b/apps/appkit-minter/src/pages/gasless-page.tsx new file mode 100644 index 000000000..26d33a95c --- /dev/null +++ b/apps/appkit-minter/src/pages/gasless-page.tsx @@ -0,0 +1,153 @@ +/** + * 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 { useState, useMemo, useEffect } from 'react'; +import type { FC } from 'react'; +import { + useGaslessConfig, + useSendGaslessTransaction, + useAddress, + useJettonBalanceByAddress, + useJettonWalletAddress, +} from '@ton/appkit-react'; +import type { Base64String } from '@ton/appkit-react'; +import { parseUnits, createJettonTransferPayload, compareAddress } from '@ton/appkit'; +import { toast } from 'sonner'; + +import { Card, Layout } from '@/core/components'; + +const USDT_MASTER_MAINNET = 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs'; + +export const GaslessPage: FC = () => { + const address = useAddress(); + const { data: gaslessConfig, isLoading: isConfigLoading } = useGaslessConfig(); + const { mutateAsync: sendGasless, isPending: isSending } = useSendGaslessTransaction(); + + const [amount, setAmount] = useState('0.1'); + const [recipient, setRecipient] = useState(''); + + const { data: usdtBalance } = useJettonBalanceByAddress({ + jettonAddress: USDT_MASTER_MAINNET, + ownerAddress: address, + jettonDecimals: 6, + }); + + const { data: usdtWalletAddress } = useJettonWalletAddress({ + jettonAddress: USDT_MASTER_MAINNET, + ownerAddress: address, + }); + + // Set own address as default recipient + useEffect(() => { + if (address && !recipient) { + setRecipient(address); + } + }, [address, recipient]); + + const isUsdtSupported = useMemo(() => { + return gaslessConfig?.supportedGasJettons.some((j) => compareAddress(j.jettonMaster, USDT_MASTER_MAINNET)); + }, [gaslessConfig]); + + const handleSend = async () => { + if (!address || !usdtWalletAddress) { + toast.error(!address ? 'Wallet not connected' : 'Could not resolve USDT wallet address'); + return; + } + + try { + const payload = createJettonTransferPayload({ + amount: parseUnits(amount, 6), // USDT has 6 decimals + destination: recipient, + responseDestination: address, + }); + + await sendGasless({ + feeJettonMaster: USDT_MASTER_MAINNET, + messages: [ + { + address: usdtWalletAddress, + amount: parseUnits('0.06', 9).toString(), + payload: payload.toBoc().toString('base64') as Base64String, + }, + ], + }); + + toast.success('Gasless transaction submitted!'); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Gasless error:', error); + toast.error(error instanceof Error ? error.message : 'Failed to send gasless transaction'); + } + }; + + return ( + + +
+

Send USDT (Gasless)

+

+ Send USDT without having TON for gas. The fee will be paid in USDT. +

+
+ + {!address && ( +
+ Please connect your wallet first. +
+ )} + + {address && ( +
+
+ +
+ {usdtBalance ? Number(usdtBalance).toFixed(2) : '0.00'} USDT +
+
+ +
+ + setRecipient(e.target.value)} + placeholder="Address" + /> +
+ +
+ + setAmount(e.target.value)} + /> +
+ + + + {!isConfigLoading && !isUsdtSupported && ( +

+ Relayer does not support USDT for gas fees on this network. +

+ )} +
+ )} +
+
+ ); +}; diff --git a/apps/appkit-minter/src/pages/index.ts b/apps/appkit-minter/src/pages/index.ts index d55767204..068061ec3 100644 --- a/apps/appkit-minter/src/pages/index.ts +++ b/apps/appkit-minter/src/pages/index.ts @@ -7,3 +7,10 @@ */ export { MinterPage } from './minter-page'; +export { SwapPage } from './swap-page'; +export { StakingPage } from './staking-page'; +export { OnrampPage } from './onramp-page'; +export { SignMessagePage } from './sign-message-page'; +export { NftPurchasePage } from './nft-purchase-page'; +export { NftPurchaseCollectionPage } from './nft-purchase-collection-page'; +export { GaslessPage } from './gasless-page'; diff --git a/apps/appkit-minter/src/pages/minter-page.tsx b/apps/appkit-minter/src/pages/minter-page.tsx index 429683429..52f38752b 100644 --- a/apps/appkit-minter/src/pages/minter-page.tsx +++ b/apps/appkit-minter/src/pages/minter-page.tsx @@ -9,50 +9,25 @@ import type React from 'react'; import { useSelectedWallet } from '@ton/appkit-react'; -import { TokensCard } from '@/features/balances'; +import { TokensCard, DepositButton } from '@/features/balances'; import { CardGenerator } from '@/features/mint'; import { NftsCard } from '@/features/nft'; -import { WalletInfo } from '@/features/wallet'; -import { Card, Layout } from '@/core/components'; -import { SwapButton } from '@/features/swap'; -import { StakingCard } from '@/features/staking'; -import { SignMessageCard } from '@/features/signing'; +import { Layout } from '@/core/components'; export const MinterPage: React.FC = () => { const [wallet] = useSelectedWallet(); const isConnected = !!wallet; return ( - +
- - - {/* Card Generator with integrated mint button */} - {/* Connected wallet assets */} {isConnected && (
+ - - -
-
Default provider:
- - - -
StonFi provider:
- - - -
DeDust provider:
- - -
-
- -
)}
diff --git a/apps/appkit-minter/src/pages/nft-purchase-collection-page.tsx b/apps/appkit-minter/src/pages/nft-purchase-collection-page.tsx new file mode 100644 index 000000000..8f817d44d --- /dev/null +++ b/apps/appkit-minter/src/pages/nft-purchase-collection-page.tsx @@ -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 type { FC } from 'react'; +import { Link, useParams, Navigate } from 'react-router-dom'; +import { ChevronLeft } from 'lucide-react'; + +import { NftsList } from '@/features/nft_purchase'; +import { Layout } from '@/core/components'; + +export const NftPurchaseCollectionPage: FC = () => { + const { collectionAddress } = useParams<{ collectionAddress: string }>(); + + if (!collectionAddress) { + return ; + } + + return ( + +
+ + + Back to collections + + + +
+
+ ); +}; diff --git a/apps/appkit-minter/src/pages/nft-purchase-page.tsx b/apps/appkit-minter/src/pages/nft-purchase-page.tsx new file mode 100644 index 000000000..6a5e58312 --- /dev/null +++ b/apps/appkit-minter/src/pages/nft-purchase-page.tsx @@ -0,0 +1,20 @@ +/** + * 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 { CollectionsList } from '@/features/nft_purchase'; +import { Layout } from '@/core/components'; + +export const NftPurchasePage: FC = () => { + return ( + + + + ); +}; diff --git a/apps/appkit-minter/src/pages/onramp-page.tsx b/apps/appkit-minter/src/pages/onramp-page.tsx new file mode 100644 index 000000000..eab8ec3c3 --- /dev/null +++ b/apps/appkit-minter/src/pages/onramp-page.tsx @@ -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 React from 'react'; +import { CryptoOnrampWidget } from '@ton/appkit-react'; + +import { Card, Layout } from '@/core/components'; +import { + ONRAMP_DEFAULT_METHOD_ID, + ONRAMP_DEFAULT_TOKEN_ID, + ONRAMP_PAYMENT_METHODS, + ONRAMP_TOKENS, +} from '@/core/configs/onramp'; + +export const OnrampPage: React.FC = () => { + return ( + + + + + + ); +}; diff --git a/apps/appkit-minter/src/pages/sign-message-page.tsx b/apps/appkit-minter/src/pages/sign-message-page.tsx new file mode 100644 index 000000000..fef84a09a --- /dev/null +++ b/apps/appkit-minter/src/pages/sign-message-page.tsx @@ -0,0 +1,20 @@ +/** + * 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 React from 'react'; + +import { Layout } from '@/core/components'; +import { SignMessageCard } from '@/features/signing'; + +export const SignMessagePage: React.FC = () => { + return ( + + + + ); +}; diff --git a/apps/appkit-minter/src/pages/staking-page.tsx b/apps/appkit-minter/src/pages/staking-page.tsx new file mode 100644 index 000000000..fe06c3929 --- /dev/null +++ b/apps/appkit-minter/src/pages/staking-page.tsx @@ -0,0 +1,22 @@ +/** + * 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 React from 'react'; +import { StakingWidget } from '@ton/appkit-react'; + +import { Card, Layout } from '@/core/components'; + +export const StakingPage: React.FC = () => { + return ( + + + + + + ); +}; diff --git a/apps/appkit-minter/src/pages/swap-page.tsx b/apps/appkit-minter/src/pages/swap-page.tsx new file mode 100644 index 000000000..352997104 --- /dev/null +++ b/apps/appkit-minter/src/pages/swap-page.tsx @@ -0,0 +1,107 @@ +/** + * 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 React from 'react'; +import { Network } from '@ton/appkit'; +import { SwapWidget } from '@ton/appkit-react'; +import type { AppkitUIToken } from '@ton/appkit-react'; + +import { Card, Layout } from '@/core/components'; + +const TOKENS: AppkitUIToken[] = [ + { + id: 'ton', + symbol: 'TON', + name: 'Toncoin', + decimals: 9, + address: 'ton', + logo: 'https://asset.ston.fi/img/EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c/c8d21a3d93f9b574381e0a8d8f16d48b325dd8f54ce172f599c1e9d6c62f03f7', + }, + { + id: 'usdt', + symbol: 'USD₮', + name: 'Tether USD', + decimals: 6, + address: 'UQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_p0p', + rate: '1', + logo: 'https://asset.ston.fi/img/EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs/1a87edfee9a28b05578853952e5effb8cc30af1e0fb90043aa2ce19dce490849', + }, + { + id: 'ston', + symbol: 'STON', + name: 'STON', + decimals: 9, + address: 'UQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T_sL', + logo: 'https://asset.ston.fi/img/EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO/7c9798ce1e64707fb4cb8f025d4060f66b386ed381b50498e3b88731cedeffe8', + }, + { + id: 'xaut', + symbol: 'XAUt0', + name: 'Tether Gold', + decimals: 6, + address: 'UQA1R_LuQCLHlMgOo1S4G7Y7W1cd0FrAkbA10Zq7rddKxnKh', + logo: 'https://asset.ston.fi/img/EQA1R_LuQCLHlMgOo1S4G7Y7W1cd0FrAkbA10Zq7rddKxi9k/4aaaa7c30d7811bced81ded6bc116dcc82a78c6aea53d6012fd586a5826963ad', + }, + { + id: 'usde', + symbol: 'USDe', + name: 'Ethena USDe', + decimals: 6, + address: 'UQAIb6KmdfdDR7CN1GBqVJuP25iCnLKCvBlJ07Evuu2dzKOa', + rate: '1', + logo: 'https://asset.ston.fi/img/EQAIb6KmdfdDR7CN1GBqVJuP25iCnLKCvBlJ07Evuu2dzP5f/dbcc67993cd4aad4845a97a4a9722c6cb618123997c8112c29d4932b2739c4cd', + }, + { + id: 'tston', + symbol: 'tsTON', + name: 'Tonstakers TON', + decimals: 9, + address: 'UQC98_qAmNEptUtPc7W6xdHh_ZHrBUFpw5Ft_IzNU20QAMtq', + logo: 'https://asset.ston.fi/img/EQC98_qAmNEptUtPc7W6xdHh_ZHrBUFpw5Ft_IzNU20QAJav/38f530facb209e4696b8aef17af51df94d16bd879926c517b07d25841da287b7', + }, + { + id: 'gemston', + symbol: 'GEMSTON', + name: 'GEMSTON', + decimals: 9, + address: 'UQBX6K9aXVl3nXINCyPPL86C4ONVmQ8vK360u6dykFKXpC1f', + logo: 'https://asset.ston.fi/img/EQBX6K9aXVl3nXINCyPPL86C4ONVmQ8vK360u6dykFKXpHCa/c6ab1e58e3b9b58a7429d38b7feab731afae2f66dc301a6c42041fdf7e9d7c9c', + }, + { + id: 'utya', + symbol: 'UTYA', + name: 'Utya', + decimals: 9, + address: 'UQBaCgUwOoc6gHCNln_oJzb0mVs79YG7wYoavh-o1Itanb8F', + logo: 'https://asset.ston.fi/img/EQBaCgUwOoc6gHCNln_oJzb0mVs79YG7wYoavh-o1ItaneLA/727e6cc971afdfa8ed9c698d0909eee9de344a0b6766ff5e4ddcc3323449d6f6', + }, + { + id: 'weth', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + address: 'UQBTkLAhEteZCRgRe_xMs5ZE0bMrduYxKbyzGCpXXW8dRT5W', + logo: 'https://asset.ston.fi/img/EQBTkLAhEteZCRgRe_xMs5ZE0bMrduYxKbyzGCpXXW8dRWOT/6267787665c30c2500dbde048e2f8a6a6d7ec58633ea038723f4ce1fab337ccb', + }, +]; + +export const SwapPage: React.FC = () => { + return ( + + + + + + ); +}; diff --git a/apps/appkit-minter/src/privyProvider.tsx b/apps/appkit-minter/src/privyProvider.tsx new file mode 100644 index 000000000..8706a361f --- /dev/null +++ b/apps/appkit-minter/src/privyProvider.tsx @@ -0,0 +1,29 @@ +/** + * 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 { PrivyProvider } from '@privy-io/react-auth'; +import type { PrivyProviderProps } from '@privy-io/react-auth'; +import { PrivyBridge } from '@ton/appkit-react'; +import type { PrivyBridgeProps } from '@ton/appkit-react'; + +import { ENV_PRIVY_APP_ID } from '@/core/configs/env'; + +export const AppPrivyProvider: FC> = ({ children, ...props }) => { + return ENV_PRIVY_APP_ID ? ( + + <>{children} + + ) : ( + <>{children} + ); +}; + +export const PrivyBridgeProvider: FC = ({ children, ...props }) => { + return <>{ENV_PRIVY_APP_ID ? {children} : <>{children}}; +}; diff --git a/apps/appkit-minter/src/vite-env.d.ts b/apps/appkit-minter/src/vite-env.d.ts index d6a0cb749..29ff32d2c 100644 --- a/apps/appkit-minter/src/vite-env.d.ts +++ b/apps/appkit-minter/src/vite-env.d.ts @@ -11,6 +11,8 @@ interface ImportMetaEnv { readonly VITE_BRIDGE_URL?: string; readonly VITE_TON_API_KEY?: string; + readonly VITE_TON_API_TESTNET_KEY?: string; + readonly VITE_PRIVY_APP_ID?: string; } interface ImportMeta { diff --git a/apps/appkit-minter/vite.config.ts b/apps/appkit-minter/vite.config.ts index 8541ac850..330576b1b 100644 --- a/apps/appkit-minter/vite.config.ts +++ b/apps/appkit-minter/vite.config.ts @@ -19,6 +19,13 @@ export default defineConfig({ server: { port: 5174, allowedHosts: ['localhost', '127.0.0.1', 'local.dev'], + proxy: { + '/getgems-api': { + target: 'https://api.getgems.io', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/getgems-api/, '/public-api'), + }, + }, }, resolve: { alias: { diff --git a/apps/demo-wallet-native/src/features/send/components/token-list-sheet/token-list-sheet.tsx b/apps/demo-wallet-native/src/features/send/components/token-list-sheet/token-list-sheet.tsx index 985958c7d..19754ea74 100644 --- a/apps/demo-wallet-native/src/features/send/components/token-list-sheet/token-list-sheet.tsx +++ b/apps/demo-wallet-native/src/features/send/components/token-list-sheet/token-list-sheet.tsx @@ -63,7 +63,7 @@ export const TokenListSheet: FC = ({ isOpen, onClose, onSel - + @@ -89,7 +89,7 @@ export const TokenListSheet: FC = ({ isOpen, onClose, onSel handleSelectJetton(jetton)} - style={styles.tokenItem} + style={styles.currencyItem} > {image ? ( @@ -143,7 +143,7 @@ const styles = StyleSheet.create(({ sizes, colors }, runtime) => ({ top: 0, right: 12, }, - tokenItem: { + currencyItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', diff --git a/apps/demo-wallet/package.json b/apps/demo-wallet/package.json index fc6e3b1cb..90d0b65dd 100644 --- a/apps/demo-wallet/package.json +++ b/apps/demo-wallet/package.json @@ -25,8 +25,8 @@ "dependencies": { "@demo/v4ledger-adapter": "workspace:*", "@demo/wallet-core": "workspace:*", - "@ledgerhq/hw-transport-webhid": "^6.34.0", - "@ledgerhq/hw-transport-webusb": "^6.33.0", + "@ledgerhq/hw-transport-webhid": "6.34.0", + "@ledgerhq/hw-transport-webusb": "6.33.0", "@tailwindcss/vite": "^4.2.2", "@ton/core": "catalog:", "@ton/crypto": "catalog:", @@ -73,4 +73,4 @@ "vite-bundle-analyzer": "^1.3.7", "webextension-polyfill": "^0.12.0" } -} +} \ No newline at end of file diff --git a/apps/demo-wallet/src/components/SignMessageRequestModal.tsx b/apps/demo-wallet/src/components/SignMessageRequestModal.tsx new file mode 100644 index 000000000..0f1c54c11 --- /dev/null +++ b/apps/demo-wallet/src/components/SignMessageRequestModal.tsx @@ -0,0 +1,198 @@ +/** + * 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 React, { useMemo, useState, useEffect } from 'react'; +import type { SignMessageRequestEvent } from '@ton/walletkit'; +import type { SavedWallet } from '@demo/wallet-core'; +import { useAuth, useSignMessageRequests } from '@demo/wallet-core'; +import { toast } from 'sonner'; + +import { Button } from './Button'; +import { Card } from './Card'; +import { DAppInfo } from './DAppInfo'; +import { WalletPreview } from './WalletPreview'; +import { HoldToSignButton } from './HoldToSignButton'; +import { createComponentLogger } from '../utils/logger'; + +const log = createComponentLogger('SignMessageRequestModal'); + +interface SignMessageRequestModalProps { + request: SignMessageRequestEvent; + savedWallets: SavedWallet[]; + isOpen: boolean; +} + +export const SignMessageRequestModal: React.FC = ({ request, savedWallets, isOpen }) => { + const [isLoading, setIsLoading] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + const { holdToSign } = useAuth(); + const { approveSignMessageRequest, rejectSignMessageRequest } = useSignMessageRequests(); + + const currentWallet = useMemo(() => { + if (!request.walletAddress) return null; + return savedWallets.find((wallet) => wallet.kitWalletId === request.walletId) || null; + }, [savedWallets, request.walletAddress, request.walletId]); + + useEffect(() => { + if (!isOpen) { + setShowSuccess(false); + setIsLoading(false); + } + }, [isOpen]); + + const handleApprove = async () => { + setIsLoading(true); + try { + await approveSignMessageRequest(); + setIsLoading(false); + setShowSuccess(true); + } catch (error) { + log.error('Failed to approve sign message request:', error); + toast.error('Failed to approve sign message', { + description: (error as Error)?.message, + }); + setIsLoading(false); + } + }; + + const handleReject = () => { + rejectSignMessageRequest('User rejected the sign message request'); + }; + + if (!isOpen) return null; + + if (showSuccess) { + return ( +
+ +
+
+
+
+ + + +
+
+
+

Success!

+

Message signed successfully

+
+
+
+
+ ); + } + + const messageCount = request.request?.messages?.length ?? request.request?.items?.length ?? 0; + + return ( +
+
+ +
+
+

+ Sign Message Request +

+

+ A dApp wants you to sign a transaction without broadcasting it +

+
+ + + + {currentWallet && ( +
+ +
+ )} + +
+

Transaction Details

+

+ {messageCount} message{messageCount !== 1 ? 's' : ''} to sign (not broadcast) +

+
+ +
+
+
+ + + +
+
+

+ Warning: This will sign a transaction that the dApp can submit + later. Only approve if you trust the requesting dApp. +

+
+
+
+ +
+ + {holdToSign ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +}; diff --git a/apps/demo-wallet/src/components/index.ts b/apps/demo-wallet/src/components/index.ts index 05e4d3792..757c26d4f 100644 --- a/apps/demo-wallet/src/components/index.ts +++ b/apps/demo-wallet/src/components/index.ts @@ -30,6 +30,7 @@ export { StreamingStatus } from './StreamingStatus'; export { ProtectedRoute } from './ProtectedRoute'; export { RecentTransactions } from './RecentTransactions'; export { SignDataRequestModal } from './SignDataRequestModal'; +export { SignMessageRequestModal } from './SignMessageRequestModal'; export { TraceRow } from './TraceRow'; export { TransactionRequestModal } from './TransactionRequestModal'; export { WalletPreview } from './WalletPreview'; diff --git a/apps/demo-wallet/src/pages/WalletDashboard.tsx b/apps/demo-wallet/src/pages/WalletDashboard.tsx index 61feaece0..aa70f5f1a 100644 --- a/apps/demo-wallet/src/pages/WalletDashboard.tsx +++ b/apps/demo-wallet/src/pages/WalletDashboard.tsx @@ -8,7 +8,14 @@ import React, { useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useWallet, useWalletKit, useTonConnect, useTransactionRequests, useSignDataRequests } from '@demo/wallet-core'; +import { + useWallet, + useWalletKit, + useTonConnect, + useTransactionRequests, + useSignDataRequests, + useSignMessageRequests, +} from '@demo/wallet-core'; import { AnimatedBalance, @@ -18,6 +25,7 @@ import { ConnectRequestModal, TransactionRequestModal, SignDataRequestModal, + SignMessageRequestModal, DisconnectNotifications, NftsCard, RecentTransactions, @@ -64,6 +72,7 @@ export const WalletDashboard: React.FC = () => { const { pendingTransactionRequest, isTransactionModalOpen } = useTransactionRequests(); const { pendingSignDataRequest, isSignDataModalOpen, approveSignDataRequest, rejectSignDataRequest } = useSignDataRequests(); + const { pendingSignMessageRequest, isSignMessageModalOpen } = useSignMessageRequests(); const { error } = useTonWallet(); // Use the paste handler hook @@ -380,6 +389,15 @@ export const WalletDashboard: React.FC = () => { onReject={rejectSignDataRequest} /> )} + + {/* Sign Message Request Modal */} + {pendingSignMessageRequest && ( + + )}
); }; diff --git a/apps/demo-wallet/src/utils/walletManifest.ts b/apps/demo-wallet/src/utils/walletManifest.ts index de8175dd9..27044ad42 100644 --- a/apps/demo-wallet/src/utils/walletManifest.ts +++ b/apps/demo-wallet/src/utils/walletManifest.ts @@ -41,7 +41,6 @@ export function getTonConnectDeviceInfo(): DeviceInfo { export function getTonConnectFeatures(): Feature[] { return [ - 'SendTransaction', { name: 'SendTransaction', maxMessages: 4, diff --git a/demo/examples/package.json b/demo/examples/package.json index 852c6b87a..3468ce426 100644 --- a/demo/examples/package.json +++ b/demo/examples/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@tanstack/react-query": "catalog:", "@ton/appkit": "workspace:*", "@ton/appkit-react": "workspace:*", "@ton/walletkit": "workspace:*", diff --git a/demo/examples/src/appkit/actions/onramp/build-onramp-url.ts b/demo/examples/src/appkit/actions/onramp/build-onramp-url.ts new file mode 100644 index 000000000..0a5c157fd --- /dev/null +++ b/demo/examples/src/appkit/actions/onramp/build-onramp-url.ts @@ -0,0 +1,26 @@ +/** + * 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 { AppKit } from '@ton/appkit'; +import { buildOnrampUrl, getOnrampQuote } from '@ton/appkit'; + +export const buildOnrampUrlExample = async (appKit: AppKit) => { + // SAMPLE_START: BUILD_ONRAMP_URL + const quote = await getOnrampQuote(appKit, { + fiatCurrency: 'USD', + cryptoCurrency: 'TON', + amount: '100', + }); + + const url = await buildOnrampUrl(appKit, { + quote, + userAddress: 'UQ...wallet-address...', + }); + console.log('Onramp URL:', url); + // SAMPLE_END: BUILD_ONRAMP_URL +}; diff --git a/demo/examples/src/appkit/actions/onramp/get-onramp-quote.ts b/demo/examples/src/appkit/actions/onramp/get-onramp-quote.ts new file mode 100644 index 000000000..34269c136 --- /dev/null +++ b/demo/examples/src/appkit/actions/onramp/get-onramp-quote.ts @@ -0,0 +1,22 @@ +/** + * 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 { AppKit } from '@ton/appkit'; +import { getOnrampQuote } from '@ton/appkit'; + +export const getOnrampQuoteExample = async (appKit: AppKit) => { + // SAMPLE_START: GET_ONRAMP_QUOTE + const quote = await getOnrampQuote(appKit, { + fiatCurrency: 'USD', + cryptoCurrency: 'TON', + amount: '100', + isFiatAmount: true, + }); + console.log('Onramp Quote:', quote); + // SAMPLE_END: GET_ONRAMP_QUOTE +}; diff --git a/demo/examples/src/appkit/actions/staking/staking-actions.ts b/demo/examples/src/appkit/actions/staking/staking-actions.ts index 998774e7f..6c3d59c1a 100644 --- a/demo/examples/src/appkit/actions/staking/staking-actions.ts +++ b/demo/examples/src/appkit/actions/staking/staking-actions.ts @@ -13,6 +13,7 @@ import { getStakedBalance, getStakingProviders, getStakingProviderInfo, + getStakingProviderMetadata, } from '@ton/appkit'; export const stakingExample = async (appKit: AppKit) => { @@ -30,6 +31,13 @@ export const stakingExample = async (appKit: AppKit) => { console.log('Provider Info:', providerInfo); // SAMPLE_END: GET_STAKING_PROVIDER_INFO + // SAMPLE_START: GET_STAKING_PROVIDER_METADATA + const providerMetadata = getStakingProviderMetadata(appKit, { + providerId: 'tonstakers', + }); + console.log('Provider Metadata:', providerMetadata); + // SAMPLE_END: GET_STAKING_PROVIDER_METADATA + // SAMPLE_START: GET_STAKING_QUOTE const quote = await getStakingQuote(appKit, { amount: '1000000000', diff --git a/demo/examples/src/appkit/actions/swap/swap-actions.ts b/demo/examples/src/appkit/actions/swap/swap-actions.ts index ace40c84c..cd07d0b66 100644 --- a/demo/examples/src/appkit/actions/swap/swap-actions.ts +++ b/demo/examples/src/appkit/actions/swap/swap-actions.ts @@ -8,13 +8,31 @@ import type { AppKit } from '@ton/appkit'; import { Network } from '@ton/appkit'; -import { getSwapManager, getSwapQuote, buildSwapTransaction, sendTransaction } from '@ton/appkit'; +import { + getSwapManager, + getSwapProvider, + watchSwapProviders, + getSwapQuote, + buildSwapTransaction, + sendTransaction, +} from '@ton/appkit'; export const swapExample = async (appKit: AppKit) => { // SAMPLE_START: GET_SWAP_MANAGER const swapManager = getSwapManager(appKit); // SAMPLE_END: GET_SWAP_MANAGER + // SAMPLE_START: GET_SWAP_PROVIDER + const swapProvider = getSwapProvider(appKit, { id: 'stonfi' }); + // SAMPLE_END: GET_SWAP_PROVIDER + + // SAMPLE_START: WATCH_SWAP_PROVIDERS + const unsubscribe = watchSwapProviders(appKit, { + onChange: () => console.log('Swap providers updated'), + }); + unsubscribe(); + // SAMPLE_END: WATCH_SWAP_PROVIDERS + // SAMPLE_START: GET_SWAP_QUOTE const quote = await getSwapQuote(appKit, { from: { address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', decimals: 6 }, @@ -35,5 +53,5 @@ export const swapExample = async (appKit: AppKit) => { console.log('Swap Transaction:', transactionResponse); // SAMPLE_END: BUILD_SWAP_TRANSACTION - return { swapManager, quote, transactionRequest }; + return { swapManager, swapProvider, quote, transactionRequest }; }; diff --git a/demo/examples/src/appkit/actions/swap/swap.test.ts b/demo/examples/src/appkit/actions/swap/swap.test.ts index b4a367d76..b0d5693b0 100644 --- a/demo/examples/src/appkit/actions/swap/swap.test.ts +++ b/demo/examples/src/appkit/actions/swap/swap.test.ts @@ -30,11 +30,15 @@ describe('Swap Actions Examples', () => { }, }); + // Mock SwapManager mockGetQuote = vi.fn(); mockBuildSwapTransaction = vi.fn(); mockSendTransaction = vi.fn(); - // Mock SwapManager + // @ts-expect-error - internal access + vi.spyOn(appKit.swapManager, 'getProvider').mockImplementation((id) => ({ + providerId: id || 'default', + })); // @ts-expect-error - internal access vi.spyOn(appKit.swapManager, 'getQuote').mockImplementation(mockGetQuote); // @ts-expect-error - internal access diff --git a/demo/examples/src/appkit/actions/transaction/sign-message.ts b/demo/examples/src/appkit/actions/transaction/sign-message.ts new file mode 100644 index 000000000..cb60b48c0 --- /dev/null +++ b/demo/examples/src/appkit/actions/transaction/sign-message.ts @@ -0,0 +1,27 @@ +/** + * 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 { AppKit } from '@ton/appkit'; +import { signMessage } from '@ton/appkit'; + +export const signMessageExample = async (appKit: AppKit) => { + // SAMPLE_START: SIGN_MESSAGE + const result = await signMessage(appKit, { + messages: [ + { + address: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + amount: '100000000', // 0.1 TON in nanotons + }, + ], + }); + + // result.internalBoc is a signed internal message BoC (base64) + // that can be relayed on-chain by a third party (e.g. a gasless relayer). + console.log('Signed Message:', result); + // SAMPLE_END: SIGN_MESSAGE +}; diff --git a/demo/examples/src/appkit/actions/transaction/transaction.test.ts b/demo/examples/src/appkit/actions/transaction/transaction.test.ts index 482dd02a3..d5b24a0f7 100644 --- a/demo/examples/src/appkit/actions/transaction/transaction.test.ts +++ b/demo/examples/src/appkit/actions/transaction/transaction.test.ts @@ -12,6 +12,7 @@ import { Network } from '@ton/walletkit'; import type { WalletInterface } from '@ton/appkit'; import { sendTransactionExample } from './send-transaction'; +import { signMessageExample } from './sign-message'; import { transferTonExample } from './transfer-ton'; import { createTransferTonTransactionExample } from './create-transfer-ton-transaction'; @@ -19,6 +20,7 @@ describe('Transaction Actions Examples', () => { let appKit: AppKit; let consoleSpy: ReturnType; let mockSendTransaction: ReturnType; + let mockSignMessage: ReturnType; beforeEach(() => { vi.clearAllMocks(); @@ -31,6 +33,7 @@ describe('Transaction Actions Examples', () => { }); mockSendTransaction = vi.fn(); + mockSignMessage = vi.fn(); }); afterEach(() => { @@ -43,6 +46,7 @@ describe('Transaction Actions Examples', () => { getWalletId: () => 'mock-wallet-id', getNetwork: () => Network.mainnet(), sendTransaction: mockSendTransaction, + signMessage: mockSignMessage, } as unknown as WalletInterface; appKit.walletsManager.setWallets([mockWallet]); @@ -61,6 +65,18 @@ describe('Transaction Actions Examples', () => { }); }); + describe('signMessageExample', () => { + it('should log signed message result', async () => { + setupMockWallet(); + mockSignMessage.mockResolvedValue({ internalBoc: 'mock-internal-boc' }); + + await signMessageExample(appKit); + + expect(mockSignMessage).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith('Signed Message:', { internalBoc: 'mock-internal-boc' }); + }); + }); + describe('transferTonExample', () => { it('should log transfer result', async () => { setupMockWallet(); diff --git a/demo/examples/src/appkit/hooks/jettons/jettons.test.tsx b/demo/examples/src/appkit/hooks/jettons/jettons.test.tsx index 7d2bcaf2d..798647102 100644 --- a/demo/examples/src/appkit/hooks/jettons/jettons.test.tsx +++ b/demo/examples/src/appkit/hooks/jettons/jettons.test.tsx @@ -8,7 +8,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import * as AppKitReact from '@ton/appkit-react'; +import { + useJettonInfo, + useJettonWalletAddress, + useJettonBalanceByAddress, + useJettonsByAddress, + useJettons, + useTransferJetton, + useWatchJettons, + useWatchJettonsByAddress, +} from '@ton/appkit-react'; import { UseJettonInfoExample } from './use-jetton-info'; import { UseJettonBalanceByAddressExample } from './use-jetton-balance-by-address'; @@ -47,7 +56,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonInfoExample', () => { it('should render loading state', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonInfo).mockReturnValue({ + vi.mocked(useJettonInfo).mockReturnValue({ isLoading: true, data: undefined, error: null, @@ -59,7 +68,7 @@ describe('Jetton Hooks Examples', () => { it('should render error state', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonInfo).mockReturnValue({ + vi.mocked(useJettonInfo).mockReturnValue({ isLoading: false, data: undefined, error: new Error('Failed to fetch'), @@ -71,7 +80,7 @@ describe('Jetton Hooks Examples', () => { it('should render jetton info', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonInfo).mockReturnValue({ + vi.mocked(useJettonInfo).mockReturnValue({ isLoading: false, data: { name: 'Test Jetton', @@ -91,7 +100,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonBalanceByAddressExample', () => { it('should render balance', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonBalanceByAddress).mockReturnValue({ + vi.mocked(useJettonBalanceByAddress).mockReturnValue({ isLoading: false, data: '1000000', error: null, @@ -105,7 +114,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonWalletAddressExample', () => { it('should render wallet address', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonWalletAddress).mockReturnValue({ + vi.mocked(useJettonWalletAddress).mockReturnValue({ isLoading: false, data: 'EQB-mock-address', error: null, @@ -119,7 +128,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonsByAddressExample', () => { it('should render list of jettons', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonsByAddress).mockReturnValue({ + vi.mocked(useJettonsByAddress).mockReturnValue({ isLoading: false, data: { jettons: [ @@ -139,7 +148,7 @@ describe('Jetton Hooks Examples', () => { describe('UseJettonsExample', () => { it('should render list of jettons for current wallet', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettons).mockReturnValue({ + vi.mocked(useJettons).mockReturnValue({ isLoading: false, data: { jettons: [{ walletAddress: 'addr1', info: { name: 'My Jetton' }, balance: '100' }], @@ -156,7 +165,7 @@ describe('Jetton Hooks Examples', () => { it('should call transfer mutation on button click', () => { const mockMutate = vi.fn(); // @ts-expect-error - mock - vi.mocked(AppKitReact.useTransferJetton).mockReturnValue({ + vi.mocked(useTransferJetton).mockReturnValue({ mutate: mockMutate, isPending: false, error: null, @@ -176,7 +185,7 @@ describe('Jetton Hooks Examples', () => { it('should disable button when loading', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useTransferJetton).mockReturnValue({ + vi.mocked(useTransferJetton).mockReturnValue({ mutate: vi.fn(), isPending: true, error: null, @@ -191,7 +200,7 @@ describe('Jetton Hooks Examples', () => { describe('UseWatchJettonsExample', () => { it('should render jetton list', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettons).mockReturnValue({ + vi.mocked(useJettons).mockReturnValue({ isLoading: false, data: { jettons: [{ walletAddress: 'addr1', info: { name: 'My Jetton' }, balance: '100' }], @@ -201,14 +210,14 @@ describe('Jetton Hooks Examples', () => { render(); expect(screen.getByText('My Jetton: 100')).toBeDefined(); - expect(AppKitReact.useWatchJettons).toHaveBeenCalled(); + expect(useWatchJettons).toHaveBeenCalled(); }); }); describe('UseWatchJettonsByAddressExample', () => { it('should render jetton list for address', () => { // @ts-expect-error - mock - vi.mocked(AppKitReact.useJettonsByAddress).mockReturnValue({ + vi.mocked(useJettonsByAddress).mockReturnValue({ isLoading: false, data: { jettons: [{ walletAddress: 'addr2', info: { name: 'Other Jetton' }, balance: '50' }], @@ -218,7 +227,7 @@ describe('Jetton Hooks Examples', () => { render(); expect(screen.getByText('Other Jetton: 50')).toBeDefined(); - expect(AppKitReact.useWatchJettonsByAddress).toHaveBeenCalledWith({ + expect(useWatchJettonsByAddress).toHaveBeenCalledWith({ address: 'UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJKZ', }); }); diff --git a/demo/examples/src/appkit/hooks/onramp/use-onramp-provider.tsx b/demo/examples/src/appkit/hooks/onramp/use-onramp-provider.tsx new file mode 100644 index 000000000..4ba8109b9 --- /dev/null +++ b/demo/examples/src/appkit/hooks/onramp/use-onramp-provider.tsx @@ -0,0 +1,17 @@ +/** + * 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 { useOnrampProvider } from '@ton/appkit-react'; + +export const UseOnrampProviderExample = () => { + // SAMPLE_START: USE_ONRAMP_PROVIDER + const provider = useOnrampProvider({ id: 'moonpay' }); + + return
Provider: {provider?.providerId}
; + // SAMPLE_END: USE_ONRAMP_PROVIDER +}; diff --git a/demo/examples/src/appkit/hooks/onramp/use-onramp-quote.tsx b/demo/examples/src/appkit/hooks/onramp/use-onramp-quote.tsx new file mode 100644 index 000000000..971195261 --- /dev/null +++ b/demo/examples/src/appkit/hooks/onramp/use-onramp-quote.tsx @@ -0,0 +1,22 @@ +/** + * 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 { useOnrampQuote } from '@ton/appkit-react'; + +export const UseOnrampQuoteExample = () => { + // SAMPLE_START: USE_ONRAMP_QUOTE + const { data: quote, isLoading } = useOnrampQuote({ + fiatCurrency: 'USD', + cryptoCurrency: 'TON', + amount: '100', + }); + + if (isLoading) return
Loading quote...
; + return
Quote: {quote?.cryptoAmount} TON
; + // SAMPLE_END: USE_ONRAMP_QUOTE +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staking.tsx b/demo/examples/src/appkit/hooks/staking/use-staking.tsx index 4b879cdb2..5b94b5257 100644 --- a/demo/examples/src/appkit/hooks/staking/use-staking.tsx +++ b/demo/examples/src/appkit/hooks/staking/use-staking.tsx @@ -6,7 +6,7 @@ * */ -import { useStakingQuote, useStakedBalance } from '@ton/appkit-react'; +import { useStakingQuote, useStakedBalance, useStakingProviderMetadata } from '@ton/appkit-react'; export const UseStakingExample = () => { // SAMPLE_START: USE_STAKING @@ -19,10 +19,13 @@ export const UseStakingExample = () => { userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', }); + const metadata = useStakingProviderMetadata(); + return (
Staking Quote: {quote?.amountOut}
Staked Balance: {balance?.stakedBalance}
+
Receive Token Ticker: {metadata?.receiveToken?.ticker}
); // SAMPLE_END: USE_STAKING diff --git a/demo/examples/src/appkit/hooks/swap/swap.test.tsx b/demo/examples/src/appkit/hooks/swap/swap.test.tsx index 6a1e5ff47..b8f151623 100644 --- a/demo/examples/src/appkit/hooks/swap/swap.test.tsx +++ b/demo/examples/src/appkit/hooks/swap/swap.test.tsx @@ -12,6 +12,7 @@ import * as AppKitReact from '@ton/appkit-react'; import { UseSwapQuoteExample } from './use-swap-quote'; import { UseBuildSwapTransactionExample } from './use-build-swap-transaction'; +import { UseSwapProviderExample } from './use-swap-provider'; // Mock the whole module vi.mock('@ton/appkit-react', async () => { @@ -21,6 +22,7 @@ vi.mock('@ton/appkit-react', async () => { useSwapQuote: vi.fn(), useBuildSwapTransaction: vi.fn(), useSendTransaction: vi.fn(), + useSwapProvider: vi.fn(), }; }); @@ -71,6 +73,17 @@ describe('Swap Hooks Examples', () => { }); }); + describe('UseSwapProviderExample', () => { + it('should render swap provider', () => { + vi.mocked(AppKitReact.useSwapProvider).mockReturnValue({ + providerId: 'stonfi', + } as unknown as AppKitReact.UseSwapProviderReturnType); + + render(); + expect(screen.getByText('Result: stonfi')).toBeDefined(); + }); + }); + describe('UseBuildSwapTransactionExample', () => { it('should call buildTx and sendTx on button click', async () => { const mockQuote = { toAmount: '0.99' }; diff --git a/demo/examples/src/appkit/hooks/swap/use-swap-provider.tsx b/demo/examples/src/appkit/hooks/swap/use-swap-provider.tsx new file mode 100644 index 000000000..957bf97de --- /dev/null +++ b/demo/examples/src/appkit/hooks/swap/use-swap-provider.tsx @@ -0,0 +1,16 @@ +/** + * 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 { useSwapProvider } from '@ton/appkit-react'; + +export const UseSwapProviderExample = () => { + // SAMPLE_START: USE_SWAP_PROVIDER + const provider = useSwapProvider({ id: 'stonfi' }); + return
Result: {provider ? provider.providerId : 'null'}
; + // SAMPLE_END: USE_SWAP_PROVIDER +}; diff --git a/demo/examples/src/appkit/hooks/transaction/transaction.test.tsx b/demo/examples/src/appkit/hooks/transaction/transaction.test.tsx index 277ab008e..7d6aee5ef 100644 --- a/demo/examples/src/appkit/hooks/transaction/transaction.test.tsx +++ b/demo/examples/src/appkit/hooks/transaction/transaction.test.tsx @@ -14,6 +14,7 @@ import { Network } from '@ton/walletkit'; import { createWrapper } from '../../../__tests__/test-utils'; import { UseSendTransactionExample } from './use-send-transaction'; +import { UseSignMessageExample } from './use-sign-message'; import { UseTransferTonExample } from './use-transfer-ton'; import { UseWatchTransactionsByAddressExample } from './use-watch-transactions-by-address'; import { UseWatchTransactionsExample } from './use-watch-transactions'; @@ -21,14 +22,17 @@ import { UseWatchTransactionsExample } from './use-watch-transactions'; describe('Transaction Hooks Examples', () => { let mockAppKit: any; let mockSendTransaction: any; + let mockSignMessage: any; const mockBoc = 'te6cckEBAQEAAgAAAEysuc0='; + const mockInternalBoc = 'te6cckEBAQEAAgAAAEysuc1='; const mockNetwork = Network.mainnet(); const mockWallet = { getAddress: () => 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', getNetwork: () => mockNetwork, sendTransaction: vi.fn(), + signMessage: vi.fn(), }; beforeEach(() => { @@ -38,6 +42,9 @@ describe('Transaction Hooks Examples', () => { mockSendTransaction = vi.fn().mockResolvedValue({ boc: mockBoc }); mockWallet.sendTransaction = mockSendTransaction; + mockSignMessage = vi.fn().mockResolvedValue({ internalBoc: mockInternalBoc }); + mockWallet.signMessage = mockSignMessage; + mockAppKit = { getDefaultNetwork: vi.fn(), connectors: [], @@ -116,6 +123,62 @@ describe('Transaction Hooks Examples', () => { }); }); + describe('UseSignMessageExample', () => { + it('should render sign button initially', () => { + render(, { wrapper: createWrapper(mockAppKit) }); + expect(screen.getByText('Sign Message')).toBeDefined(); + }); + + it('should call signMessage on button click', async () => { + render(, { wrapper: createWrapper(mockAppKit) }); + + const button = screen.getByText('Sign Message'); + act(() => { + button.click(); + }); + + await waitFor(() => { + expect(mockSignMessage).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + amount: '100000000', + address: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + }), + ]), + }), + ); + }); + }); + + it('should display internal BOC on success', async () => { + render(, { wrapper: createWrapper(mockAppKit) }); + + const button = screen.getByText('Sign Message'); + act(() => { + button.click(); + }); + + await waitFor(() => { + expect(screen.getByText(`Internal BOC: ${mockInternalBoc}`)).toBeDefined(); + }); + }); + + it('should display error on failure', async () => { + mockSignMessage.mockRejectedValue(new Error('User rejected')); + render(, { wrapper: createWrapper(mockAppKit) }); + + const button = screen.getByText('Sign Message'); + act(() => { + button.click(); + }); + + await waitFor(() => { + expect(screen.getByText('Error: User rejected')).toBeDefined(); + }); + }); + }); + describe('UseTransferTonExample', () => { it('should render transfer button initially', () => { render(, { wrapper: createWrapper(mockAppKit) }); diff --git a/demo/examples/src/appkit/hooks/transaction/use-sign-message.tsx b/demo/examples/src/appkit/hooks/transaction/use-sign-message.tsx new file mode 100644 index 000000000..7f1448428 --- /dev/null +++ b/demo/examples/src/appkit/hooks/transaction/use-sign-message.tsx @@ -0,0 +1,42 @@ +/** + * 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 { useSignMessage } from '@ton/appkit-react'; + +export const UseSignMessageExample = () => { + // SAMPLE_START: USE_SIGN_MESSAGE + const { mutate: signMessage, isPending, error, data } = useSignMessage(); + + const handleSign = () => { + signMessage({ + validUntil: Math.floor(Date.now() / 1000) + 600, // 10 minutes + messages: [ + { + address: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + amount: '100000000', // 0.1 TON in nanotons + }, + ], + }); + }; + + return ( +
+ + {error &&
Error: {error.message}
} + {data && ( +
+

Message Signed!

+

Internal BOC: {data.internalBoc}

+
+ )} +
+ ); + // SAMPLE_END: USE_SIGN_MESSAGE +}; diff --git a/demo/examples/src/appkit/staking/staking-widget.tsx b/demo/examples/src/appkit/staking/staking-widget.tsx new file mode 100644 index 000000000..16714655d --- /dev/null +++ b/demo/examples/src/appkit/staking/staking-widget.tsx @@ -0,0 +1,46 @@ +/** + * 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 { Network } from '@ton/appkit'; +import { StakingWidget } from '@ton/appkit-react'; + +export const StakingWidgetExample = () => { + // SAMPLE_START: STAKING_WIDGET_DEFAULT + // Default UI + return ; + // SAMPLE_END: STAKING_WIDGET_DEFAULT +}; + +export const StakingWidgetCustomExample = () => { + // SAMPLE_START: STAKING_WIDGET_CUSTOM + return ( + + {({ amount, setAmount, sendTransaction, quote, isQuoteLoading, canSubmit }) => ( +
+ setAmount(e.target.value)} + placeholder="Amount to stake" + /> + + {isQuoteLoading ? ( +

Fetching quote...

+ ) : quote ? ( +

You will receive: {quote.amountOut}

+ ) : null} + + +
+ )} +
+ ); + // SAMPLE_END: STAKING_WIDGET_CUSTOM +}; diff --git a/demo/examples/src/appkit/swap/swap-widget.tsx b/demo/examples/src/appkit/swap/swap-widget.tsx new file mode 100644 index 000000000..fef477407 --- /dev/null +++ b/demo/examples/src/appkit/swap/swap-widget.tsx @@ -0,0 +1,61 @@ +/** + * 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 { Network } from '@ton/appkit'; +import { SwapWidget } from '@ton/appkit-react'; + +const tokens = [ + { + id: 'ton', + address: 'ton', + symbol: 'TON', + name: 'Toncoin', + decimals: 9, + logo: 'https://ton.org/symbol.png', + }, + { + id: 'usdt', + address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', + symbol: 'USDT', + name: 'Tether', + decimals: 6, + logo: 'https://tether.to/logo.png', + }, +]; + +export const SwapWidgetExample = () => { + // SAMPLE_START: SWAP_WIDGET + return ; + // SAMPLE_END: SWAP_WIDGET +}; + +export const SwapWidgetDefaultExample = () => { + // SAMPLE_START: SWAP_WIDGET_DEFAULT + return ; + // SAMPLE_END: SWAP_WIDGET_DEFAULT +}; + +export const SwapWidgetCustomExample = () => { + // SAMPLE_START: SWAP_WIDGET_CUSTOM + return ( + + {({ fromAmount, setFromAmount, toAmount, isQuoteLoading, sendSwapTransaction, canSubmit }) => ( +
+ setFromAmount(e.target.value)} placeholder="Sell" /> + +
{isQuoteLoading ? 'Calculating...' : `Receive: ${toAmount}`}
+ + +
+ )} +
+ ); + // SAMPLE_END: SWAP_WIDGET_CUSTOM +}; diff --git a/demo/wallet-core/src/hooks/useWalletStore.ts b/demo/wallet-core/src/hooks/useWalletStore.ts index 3f431415a..a373b8738 100644 --- a/demo/wallet-core/src/hooks/useWalletStore.ts +++ b/demo/wallet-core/src/hooks/useWalletStore.ts @@ -141,6 +141,22 @@ export const useSignDataRequests = () => { ); }; +/** + * Hook for sign message requests + */ +export const useSignMessageRequests = () => { + return useWalletStore( + useShallow((state) => ({ + pendingSignMessageRequest: state.tonConnect.pendingSignMessageRequestEvent, + isSignMessageModalOpen: state.tonConnect.isSignMessageModalOpen, + showSignMessageRequest: state.showSignMessageRequest, + approveSignMessageRequest: state.approveSignMessageRequest, + rejectSignMessageRequest: state.rejectSignMessageRequest, + closeSignMessageModal: state.closeSignMessageModal, + })), + ); +}; + /** * Hook for disconnect events */ diff --git a/demo/wallet-core/src/index.ts b/demo/wallet-core/src/index.ts index 2e1f8fdd9..0d7096352 100644 --- a/demo/wallet-core/src/index.ts +++ b/demo/wallet-core/src/index.ts @@ -26,6 +26,7 @@ export { useTonConnect, useTransactionRequests, useSignDataRequests, + useSignMessageRequests, useDisconnectEvents, useNfts, useJettons, diff --git a/demo/wallet-core/src/store/slices/tonConnectSlice.ts b/demo/wallet-core/src/store/slices/tonConnectSlice.ts index 455d128f9..fc7ae7eff 100644 --- a/demo/wallet-core/src/store/slices/tonConnectSlice.ts +++ b/demo/wallet-core/src/store/slices/tonConnectSlice.ts @@ -12,6 +12,7 @@ import type { SendTransactionRequestEvent, ConnectionRequestEvent, SignDataRequestEvent, + SignMessageRequestEvent, DisconnectionEvent, } from '@ton/walletkit'; @@ -39,6 +40,8 @@ export const createTonConnectSlice: TonConnectSliceCreator = (set: SetState, get isTransactionModalOpen: false, pendingSignDataRequestEvent: undefined, isSignDataModalOpen: false, + pendingSignMessageRequestEvent: undefined, + isSignMessageModalOpen: false, disconnectedSessions: [], }, @@ -85,17 +88,32 @@ export const createTonConnectSlice: TonConnectSliceCreator = (set: SetState, get walletId: selectedWallet.getWalletId(), }; - await state.walletCore.walletKit.approveConnectRequest(event); + const intentResult = await state.walletCore.walletKit.approveConnectRequest(event); set((state) => { state.tonConnect.pendingConnectRequestEvent = undefined; state.tonConnect.isConnectModalOpen = false; }); + + if (intentResult) { + switch (intentResult.type) { + case 'sendTransaction': + get().showTransactionRequest(intentResult); + break; + case 'signMessage': + get().showSignMessageRequest(intentResult); + break; + case 'signData': + get().showSignDataRequest(intentResult); + break; + } + } else { + state.clearCurrentRequestFromQueue(); + } } catch (error) { log.error('Failed to approve connect request:', error); - throw error; - } finally { state.clearCurrentRequestFromQueue(); + throw error; } }, @@ -298,6 +316,77 @@ export const createTonConnectSlice: TonConnectSliceCreator = (set: SetState, get get().clearCurrentRequestFromQueue(); }, + // Sign message request actions + showSignMessageRequest: (request: SignMessageRequestEvent) => { + set((state) => { + state.tonConnect.pendingSignMessageRequestEvent = request; + state.tonConnect.isSignMessageModalOpen = true; + }); + }, + + approveSignMessageRequest: async () => { + const state = get(); + if (!state.tonConnect.pendingSignMessageRequestEvent) { + log.error('No pending sign message request to approve'); + return; + } + if (!state.walletCore.walletKit) { + throw new Error('WalletKit not initialized'); + } + try { + await state.walletCore.walletKit.approveSignMessageRequest(state.tonConnect.pendingSignMessageRequestEvent); + setTimeout(() => { + set((state) => { + state.tonConnect.pendingSignMessageRequestEvent = undefined; + state.tonConnect.isSignMessageModalOpen = false; + }); + state.clearCurrentRequestFromQueue(); + }, 3000); + } catch (error) { + log.error('Failed to approve sign message request:', error); + state.clearCurrentRequestFromQueue(); + throw error; + } + }, + + rejectSignMessageRequest: async (reason?: string) => { + const state = get(); + if (!state.tonConnect.pendingSignMessageRequestEvent) { + log.error('No pending sign message request to reject'); + return; + } + if (!state.walletCore.walletKit) { + set((state) => { + state.tonConnect.pendingSignMessageRequestEvent = undefined; + state.tonConnect.isSignMessageModalOpen = false; + }); + state.clearCurrentRequestFromQueue(); + return; + } + try { + await state.walletCore.walletKit.rejectSignMessageRequest( + state.tonConnect.pendingSignMessageRequestEvent, + reason, + ); + } catch (error) { + log.error('Failed to reject sign message request:', error); + } finally { + set((state) => { + state.tonConnect.pendingSignMessageRequestEvent = undefined; + state.tonConnect.isSignMessageModalOpen = false; + }); + state.clearCurrentRequestFromQueue(); + } + }, + + closeSignMessageModal: () => { + set((state) => { + state.tonConnect.isSignMessageModalOpen = false; + state.tonConnect.pendingSignMessageRequestEvent = undefined; + }); + get().clearCurrentRequestFromQueue(); + }, + // Disconnect events handleDisconnectEvent: (event: DisconnectionEvent) => { log.info('Disconnect event received:', event); @@ -395,6 +484,8 @@ export const createTonConnectSlice: TonConnectSliceCreator = (set: SetState, get get().showTransactionRequest(nextRequest.request); } else if (nextRequest.type === 'signData') { get().showSignDataRequest(nextRequest.request); + } else if (nextRequest.type === 'signMessage') { + get().showSignMessageRequest(nextRequest.request); } }, @@ -494,6 +585,14 @@ export const createTonConnectSlice: TonConnectSliceCreator = (set: SetState, get }); }); + walletKit.onSignMessageRequest((event) => { + log.info('Sign message request received:', event); + get().enqueueRequest({ + type: 'signMessage', + request: event, + }); + }); + walletKit.onDisconnect((event) => { log.info('Disconnect event received:', event); get().handleDisconnectEvent(event); diff --git a/demo/wallet-core/src/types/store.ts b/demo/wallet-core/src/types/store.ts index c44f84708..c01117542 100644 --- a/demo/wallet-core/src/types/store.ts +++ b/demo/wallet-core/src/types/store.ts @@ -18,6 +18,7 @@ import type { ConnectionRequestEvent, SendTransactionRequestEvent, SignDataRequestEvent, + SignMessageRequestEvent, DisconnectionEvent, WalletAdapter, SwapQuote, @@ -151,6 +152,8 @@ export interface TonConnectSlice { isTransactionModalOpen: boolean; pendingSignDataRequestEvent?: SignDataRequestEvent; isSignDataModalOpen: boolean; + pendingSignMessageRequestEvent?: SignMessageRequestEvent; + isSignMessageModalOpen: boolean; disconnectedSessions: DisconnectNotification[]; }; @@ -173,6 +176,12 @@ export interface TonConnectSlice { rejectSignDataRequest: (reason?: string) => Promise; closeSignDataModal: () => void; + // Sign message request actions + showSignMessageRequest: (request: SignMessageRequestEvent) => void; + approveSignMessageRequest: () => Promise; + rejectSignMessageRequest: (reason?: string) => Promise; + closeSignMessageModal: () => void; + // Disconnect event actions handleDisconnectEvent: (event: DisconnectionEvent) => void; clearDisconnectNotifications: () => void; diff --git a/demo/wallet-core/src/types/wallet.ts b/demo/wallet-core/src/types/wallet.ts index ced11714d..8f4e403aa 100644 --- a/demo/wallet-core/src/types/wallet.ts +++ b/demo/wallet-core/src/types/wallet.ts @@ -11,6 +11,7 @@ import type { JSBridgeTransportFunction, StorageAdapter as KitStorageAdapter, SignDataRequestEvent, + SignMessageRequestEvent, SendTransactionRequestEvent, AnalyticsManagerOptions, } from '@ton/walletkit'; @@ -94,7 +95,16 @@ export interface QueuedRequestSignData { request: SignDataRequestEvent; } -export type QueuedRequestData = QueuedRequestConnect | QueuedRequestTransaction | QueuedRequestSignData; +export interface QueuedRequestSignMessage { + type: 'signMessage'; + request: SignMessageRequestEvent; +} + +export type QueuedRequestData = + | QueuedRequestConnect + | QueuedRequestTransaction + | QueuedRequestSignData + | QueuedRequestSignMessage; export type QueuedRequest = QueueRequestBase & QueuedRequestData; diff --git a/demo/wallet-core/src/utils/walletManifest.ts b/demo/wallet-core/src/utils/walletManifest.ts index 92580b82f..3b1c51d19 100644 --- a/demo/wallet-core/src/utils/walletManifest.ts +++ b/demo/wallet-core/src/utils/walletManifest.ts @@ -41,7 +41,6 @@ export function getTonConnectDeviceInfo(): DeviceInfo { export function getTonConnectFeatures(): Feature[] { return [ - 'SendTransaction', { name: 'SendTransaction', maxMessages: 4, diff --git a/package.json b/package.json index 46dddaaef..e0621eab4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "quality:bridge": "turbo quality:bridge", "typecheck": "turbo typecheck", "e2e": "turbo e2e", - "clean": "git clean -xdf node_modules", + "clean": "rimraf node_modules", "clean:workspaces": "turbo run clean", "changeset": "changeset", "version-packages": "changeset version", @@ -43,5 +43,13 @@ "rimraf": "^6.1.2", "turbo": "2.9.6" }, - "packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b" + "packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b", + "pnpm": { + "overrides": { + "@tonconnect/protocol": "catalog:", + "@tonconnect/sdk": "catalog:", + "@tonconnect/ui": "catalog:", + "@tonconnect/ui-react": "catalog:" + } + } } diff --git a/packages/appkit-react/.storybook/app-kit.ts b/packages/appkit-react/.storybook/app-kit.ts new file mode 100644 index 000000000..42fab1f4c --- /dev/null +++ b/packages/appkit-react/.storybook/app-kit.ts @@ -0,0 +1,46 @@ +/** + * 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 { AppKit, Network, createTonConnectConnector } from '@ton/appkit'; +import { OmnistonSwapProvider } from '@ton/appkit/swap/omniston'; +import { TonPayProvider } from '@ton/appkit/onramp/ton-pay'; +import { createTonstakersProvider } from '@ton/appkit/staking/tonstakers'; +import { SwapsXyzCryptoOnrampProvider } from '@ton/appkit/crypto-onramp/swaps-xyz'; + +export const appKit = new AppKit({ + networks: { + [Network.mainnet().chainId]: { + apiClient: { + url: 'https://toncenter.com', + key: '25a9b2326a34b39a5fa4b264fb78fb4709e1bd576fc5e6b176639f5b71e94b0d', + }, + }, + [Network.testnet().chainId]: { + apiClient: { + url: 'https://testnet.toncenter.com', + key: 'd852b54d062f631565761042cccea87fa6337c41eb19b075e6c7fb88898a3992', + }, + }, + }, + connectors: [ + createTonConnectConnector({ + tonConnectOptions: { + manifestUrl: + 'https://raw.githubusercontent.com/ton-connect/demo-dapp-with-react-ui/master/public/tonconnect-manifest.json', + }, + }), + ], + providers: [ + new OmnistonSwapProvider(), + new TonPayProvider(), + createTonstakersProvider(), + new SwapsXyzCryptoOnrampProvider({ + apiKey: '1be323b5c83198191ba640f07f8815b0', + }), + ], +}); diff --git a/packages/appkit-react/.storybook/main.ts b/packages/appkit-react/.storybook/main.ts index 32c21bf0a..555fa6d21 100644 --- a/packages/appkit-react/.storybook/main.ts +++ b/packages/appkit-react/.storybook/main.ts @@ -6,14 +6,25 @@ * */ +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +/** + * 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 { StorybookConfig } from '@storybook/react-vite'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; const config: StorybookConfig = { stories: ['../src/**/*.stories.@(ts|tsx)'], - addons: ['@storybook/addon-docs'], + addons: [getAbsolutePath('@storybook/addon-docs')], + staticDirs: ['./public'], framework: { - name: '@storybook/react-vite', + name: getAbsolutePath('@storybook/react-vite'), options: {}, }, core: { @@ -55,3 +66,7 @@ const config: StorybookConfig = { }; export default config; + +function getAbsolutePath(value: string) { + return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))); +} diff --git a/packages/appkit-react/.storybook/manager.ts b/packages/appkit-react/.storybook/manager.ts new file mode 100644 index 000000000..a658e7a81 --- /dev/null +++ b/packages/appkit-react/.storybook/manager.ts @@ -0,0 +1,15 @@ +/** + * 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 { addons } from 'storybook/manager-api'; + +import theme from './theme'; + +addons.setConfig({ + theme, +}); diff --git a/packages/appkit-react/.storybook/preview.tsx b/packages/appkit-react/.storybook/preview.tsx index 4d45f3f92..8a870129a 100644 --- a/packages/appkit-react/.storybook/preview.tsx +++ b/packages/appkit-react/.storybook/preview.tsx @@ -6,13 +6,28 @@ * */ -import type { Preview } from '@storybook/react'; -import type { Decorator } from '@storybook/react'; +import type { Preview } from '@storybook/react-vite'; +import type { Decorator } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; +import { AppKitProvider } from '../src/providers/app-kit-provider'; import { I18nProvider } from '../src/providers/i18n-provider'; +import { appKit } from './app-kit'; +import theme from './theme'; + import '../src/styles/index.css'; +const queryClient = new QueryClient(); + +const withAppKit: Decorator = (Story) => ( + + + + + +); + const withI18n: Decorator = (Story) => ( @@ -21,8 +36,24 @@ const withI18n: Decorator = (Story) => ( const withTheme: Decorator = (Story, context) => { const theme = context.globals.theme; + + React.useEffect(() => { + document.documentElement.setAttribute('data-ta-theme', theme); + }, [theme]); + return ( -
+
); @@ -45,6 +76,10 @@ const preview: Preview = { }, }, parameters: { + docs: { + theme, + }, + layout: 'fullscreen', actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { @@ -52,15 +87,8 @@ const preview: Preview = { date: /Date$/, }, }, - backgrounds: { - default: 'dark', - values: [ - { name: 'dark', value: '#000000' }, - { name: 'light', value: '#ffffff' }, - ], - }, }, - decorators: [withTheme, withI18n], + decorators: [withTheme, withI18n, withAppKit], }; export default preview; diff --git a/packages/appkit-react/.storybook/public/ton.svg b/packages/appkit-react/.storybook/public/ton.svg new file mode 100644 index 000000000..2eba092f4 --- /dev/null +++ b/packages/appkit-react/.storybook/public/ton.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/appkit-react/.storybook/theme.ts b/packages/appkit-react/.storybook/theme.ts new file mode 100644 index 000000000..484846ac4 --- /dev/null +++ b/packages/appkit-react/.storybook/theme.ts @@ -0,0 +1,51 @@ +/** + * 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 { create } from 'storybook/theming'; + +export default create({ + base: 'dark', + + // Branding + brandTitle: 'TON AppKit', + brandUrl: 'https://github.com/ton-connect/kit', + brandImage: 'ton.svg', + brandTarget: '_self', + + // Colors + colorPrimary: '#0098EA', + colorSecondary: '#0098EA', + + // UI + appBg: '#121214', + appContentBg: '#1E1E1E', + appPreviewBg: '#121214', + appBorderColor: '#2C2C2C', + appBorderRadius: 8, + + // Typography + fontBase: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontCode: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + + // Text colors + textColor: '#FFFFFF', + textInverseColor: '#121214', + + // Toolbar default and active colors + barTextColor: '#909DAB', + barSelectedColor: '#0098EA', + barHoverColor: '#FFFFFF', + barBg: '#121214', + + // Form colors + inputBg: '#1E1E1E', + inputBorder: '#2C2C2C', + inputTextColor: '#FFFFFF', + inputBorderRadius: 8, +}); diff --git a/packages/appkit-react/README.md b/packages/appkit-react/README.md index 756b02e93..fcfc72d4c 100644 --- a/packages/appkit-react/README.md +++ b/packages/appkit-react/README.md @@ -254,10 +254,13 @@ const { data: balance } = useStakedBalance({ userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', }); +const metadata = useStakingProviderMetadata(); + return (
Staking Quote: {quote?.amountOut}
Staked Balance: {balance?.stakedBalance}
+
Receive Token Ticker: {metadata?.receiveToken?.ticker}
); ``` diff --git a/packages/appkit-react/docs/components.md b/packages/appkit-react/docs/components.md index e431508a9..d63760fbf 100644 --- a/packages/appkit-react/docs/components.md +++ b/packages/appkit-react/docs/components.md @@ -105,3 +105,73 @@ A button that triggers the wallet connection flow. ```tsx return ; ``` + +## Staking + +### `StakingWidget` + +A high-level component that provides a complete staking interface. It handles quote fetching, transaction building, and user interactions. + +```tsx +// Default UI +return ; +``` + +#### Custom UI + +You can also use a render function to build a completely custom UI while keeping the staking logic. + +```tsx +return ( + + {({ amount, setAmount, sendTransaction, quote, isQuoteLoading, canSubmit }) => ( +
+ setAmount(e.target.value)} + placeholder="Amount to stake" + /> + + {isQuoteLoading ?

Fetching quote...

: quote ?

You will receive: {quote.amountOut}

: null} + + +
+ )} +
+); +``` + +## Swap + +### `SwapWidget` + +A high-level component that provides a complete swap interface. It handles token selection, quote fetching, and transaction building. + +```tsx +return ; +``` + +#### Custom UI + +You can also use a render function to build a completely custom UI while keeping the swap logic. + +```tsx +return ( + + {({ fromAmount, setFromAmount, toAmount, isQuoteLoading, sendSwapTransaction, canSubmit }) => ( +
+ setFromAmount(e.target.value)} placeholder="Sell" /> + +
{isQuoteLoading ? 'Calculating...' : `Receive: ${toAmount}`}
+ + +
+ )} +
+); +``` diff --git a/packages/appkit-react/docs/hooks.md b/packages/appkit-react/docs/hooks.md index b01154a4a..20716c2b6 100644 --- a/packages/appkit-react/docs/hooks.md +++ b/packages/appkit-react/docs/hooks.md @@ -695,6 +695,86 @@ return ( ); ``` +### `useSwapProvider` + +Hook to get a specific swap provider. Returns the provider instance directly or `null` if not found. + +```tsx +const provider = useSwapProvider({ id: 'stonfi' }); +return
Result: {provider ? provider.providerId : 'null'}
; +``` + +## Onramp + +### `useOnrampQuote` + +Hook to get an onramp quote for a specific fiat/crypto pair. + +```tsx +const { data: quote, isLoading } = useOnrampQuote({ + fiatCurrency: 'USD', + cryptoCurrency: 'TON', + amount: '100', +}); + +if (isLoading) return
Loading quote...
; +return
Quote: {quote?.cryptoAmount} TON
; +``` + +### `useOnrampProvider` + +Hook to get a specific onramp provider. + +```tsx +const provider = useOnrampProvider({ id: 'moonpay' }); + +return
Provider: {provider?.providerId}
; +``` + +### `useOnrampProviders` + +Hook to get all registered onramp providers. + +### `useBuildOnrampUrl` + +Hook to build an onramp URL for redirecting the user to the provider. + +## Staking + +### Staking Hooks + +These hooks allow you to interact with staking providers directly. + +#### `useStakingQuote` +Get a quote for staking or unstaking. + +#### `useStakedBalance` +Get the user's staked balance. + +#### `useStakingProviderMetadata` +Get static metadata about a specific staking provider. + +```tsx +const { data: quote } = useStakingQuote({ + amount: '1000000000', + direction: 'stake', +}); + +const { data: balance } = useStakedBalance({ + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', +}); + +const metadata = useStakingProviderMetadata(); + +return ( +
+
Staking Quote: {quote?.amountOut}
+
Staked Balance: {balance?.stakedBalance}
+
Receive Token Ticker: {metadata?.receiveToken?.ticker}
+
+); +``` + ## Transaction ### `useSendTransaction` @@ -734,6 +814,41 @@ return ( ); ``` +### `useSignMessage` + +Hook to sign a transaction-shaped request without broadcasting it. Returns a signed internal-message BoC that can be relayed on-chain by a third party (e.g. a gasless relayer). Requires wallet support for the `SignMessage` feature. + +```tsx +const { mutate: signMessage, isPending, error, data } = useSignMessage(); + +const handleSign = () => { + signMessage({ + validUntil: Math.floor(Date.now() / 1000) + 600, // 10 minutes + messages: [ + { + address: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + amount: '100000000', // 0.1 TON in nanotons + }, + ], + }); +}; + +return ( +
+ + {error &&
Error: {error.message}
} + {data && ( +
+

Message Signed!

+

Internal BOC: {data.internalBoc}

+
+ )} +
+); +``` + ### `useTransferTon` Hook to simplify transferring TON to another address. diff --git a/packages/appkit-react/package.json b/packages/appkit-react/package.json index 949293502..19b087587 100644 --- a/packages/appkit-react/package.json +++ b/packages/appkit-react/package.json @@ -45,10 +45,12 @@ "dependencies": { "@ton/appkit": "workspace:*", "clsx": "2.1.1", + "qrcode.react": "^4.2.0", "radix-ui": "^1.4.3", "rosetta": "1.1.0" }, "devDependencies": { + "@privy-io/react-auth": "^3.22.1", "@storybook/addon-docs": "10.3.5", "@storybook/react": "10.3.5", "@storybook/react-vite": "10.3.5", @@ -66,6 +68,7 @@ "vite-plugin-node-polyfills": "^0.26.0" }, "peerDependencies": { + "@privy-io/react-auth": ">=3.0.0", "@tanstack/react-query": ">=5.0.0", "@tonconnect/ui": ">=2.4.1", "@tonconnect/ui-react": ">=2.4.1", @@ -73,6 +76,9 @@ "react-dom": ">=18.0.0" }, "peerDependenciesMeta": { + "@privy-io/react-auth": { + "optional": true + }, "@tonconnect/ui": { "optional": true }, diff --git a/packages/appkit-react/src/components/amount-presets/amount-presets.module.css b/packages/appkit-react/src/components/amount-presets/amount-presets.module.css new file mode 100644 index 000000000..e1e0344d9 --- /dev/null +++ b/packages/appkit-react/src/components/amount-presets/amount-presets.module.css @@ -0,0 +1,19 @@ +.container { + width: 100%; + display: grid; + gap: 8px; + justify-content: center; + grid-template-columns: 1fr 1fr 1fr 1fr; +} + +.preset { + width: 100%; + padding: 8px 16px; + cursor: pointer; + white-space: nowrap; +} + +.preset:hover { + background: var(--ta-color-background-secondary); + border-color: var(--ta-color-text-secondary); +} diff --git a/packages/appkit-react/src/components/amount-presets/amount-presets.stories.tsx b/packages/appkit-react/src/components/amount-presets/amount-presets.stories.tsx new file mode 100644 index 000000000..599e1e02c --- /dev/null +++ b/packages/appkit-react/src/components/amount-presets/amount-presets.stories.tsx @@ -0,0 +1,46 @@ +/** + * 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 { fn } from 'storybook/test'; + +import { AmountPresets } from './amount-presets'; + +const meta: Meta = { + title: 'Public/Components/AmountPresets', + component: AmountPresets, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + presets: [ + { label: '10%', amount: '10' }, + { label: '50%', amount: '50' }, + { label: '75%', amount: '75' }, + { label: 'MAX', amount: '100' }, + ], + onPresetSelect: fn(), + }, +}; + +export const WithCurrencySymbol: Story = { + args: { + presets: [ + { label: '10', amount: '10' }, + { label: '50', amount: '50' }, + { label: '100', amount: '100' }, + { label: '500', amount: '500' }, + ], + currencySymbol: '$', + onPresetSelect: fn(), + }, +}; diff --git a/packages/appkit-react/src/components/amount-presets/amount-presets.tsx b/packages/appkit-react/src/components/amount-presets/amount-presets.tsx new file mode 100644 index 000000000..8e51bf860 --- /dev/null +++ b/packages/appkit-react/src/components/amount-presets/amount-presets.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 { ComponentProps, FC } from 'react'; +import clsx from 'clsx'; + +import { Button } from '../button'; +import styles from './amount-presets.module.css'; + +export interface AmountPreset { + label: string; + amount: string; + onSelect?: () => void; +} + +export interface AmountPresetsProps extends ComponentProps<'div'> { + presets: AmountPreset[]; + currencySymbol?: string; + onPresetSelect: (value: string) => void; +} + +export const AmountPresets: FC = ({ + presets, + currencySymbol, + onPresetSelect, + className, + ...props +}) => { + return ( +
+ {presets.map((preset) => ( + + ))} +
+ ); +}; diff --git a/packages/appkit-react/src/components/amount-presets/index.ts b/packages/appkit-react/src/components/amount-presets/index.ts new file mode 100644 index 000000000..640926678 --- /dev/null +++ b/packages/appkit-react/src/components/amount-presets/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 { AmountPresets } from './amount-presets'; +export type { AmountPreset, AmountPresetsProps } from './amount-presets'; diff --git a/packages/appkit-react/src/components/amount-reversed/amount-reversed.module.css b/packages/appkit-react/src/components/amount-reversed/amount-reversed.module.css new file mode 100644 index 000000000..051d28d81 --- /dev/null +++ b/packages/appkit-react/src/components/amount-reversed/amount-reversed.module.css @@ -0,0 +1,34 @@ +.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; +} + +.skeleton { + width: 70px; + height: calc(var(--ta-body-regular-line-height) - 4px); + margin: 2px 0; +} + +.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/components/amount-reversed/amount-reversed.tsx b/packages/appkit-react/src/components/amount-reversed/amount-reversed.tsx new file mode 100644 index 000000000..0e4cc35a9 --- /dev/null +++ b/packages/appkit-react/src/components/amount-reversed/amount-reversed.tsx @@ -0,0 +1,79 @@ +/** + * 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 { formatLargeValue } from '@ton/appkit'; +import clsx from 'clsx'; + +import styles from './amount-reversed.module.css'; +import { Skeleton } from '../skeleton'; + +export interface AmountReversedProps extends ComponentProps<'div'> { + value: string; + onChangeDirection?: () => void; + ticker?: string; + symbol?: string; + decimals?: number; + errorMessage?: string; + isLoading?: boolean; +} + +export const AmountReversed: FC = ({ + value, + onChangeDirection, + ticker, + symbol, + decimals, + errorMessage, + className, + isLoading, + ...props +}) => { + if (errorMessage) { + return ( +
+ {errorMessage} +
+ ); + } + + return ( +
+ {isLoading ? ( + + ) : ( + + {symbol} + {value ? formatLargeValue(value, decimals) : '0'} + {ticker ? ` ${ticker}` : ''} + + )} + + {onChangeDirection && ( + + )} +
+ ); +}; diff --git a/packages/appkit-react/src/components/amount-reversed/index.ts b/packages/appkit-react/src/components/amount-reversed/index.ts new file mode 100644 index 000000000..665f5f879 --- /dev/null +++ b/packages/appkit-react/src/components/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 { AmountReversed } from './amount-reversed'; +export type { AmountReversedProps } from './amount-reversed'; diff --git a/packages/appkit-react/src/components/block/block.module.css b/packages/appkit-react/src/components/block/block.module.css index 5de8b960d..102bbef8c 100644 --- a/packages/appkit-react/src/components/block/block.module.css +++ b/packages/appkit-react/src/components/block/block.module.css @@ -4,7 +4,7 @@ box-sizing: border-box; padding: 16px; border-radius: var(--ta-border-radius-l); - background-color: var(--ta-color-block); + background-color: var(--ta-color-background-secondary); } .row { diff --git a/packages/appkit-react/src/components/block/block.stories.tsx b/packages/appkit-react/src/components/block/block.stories.tsx index a14b28716..e4af9983e 100644 --- a/packages/appkit-react/src/components/block/block.stories.tsx +++ b/packages/appkit-react/src/components/block/block.stories.tsx @@ -6,7 +6,7 @@ * */ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; import { Block } from './block'; @@ -31,9 +31,15 @@ export const Column: Story = { direction: 'column', children: ( <> -
Item 1
-
Item 2
-
Item 3
+
+ Item 1 +
+
+ Item 2 +
+
+ Item 3 +
), }, @@ -44,9 +50,15 @@ export const Row: Story = { direction: 'row', children: ( <> -
Item 1
-
Item 2
-
Item 3
+
+ Item 1 +
+
+ Item 2 +
+
+ Item 3 +
), }, diff --git a/packages/appkit-react/src/components/button-with-connect/button-with-connect.tsx b/packages/appkit-react/src/components/button-with-connect/button-with-connect.tsx new file mode 100644 index 000000000..4eab5f451 --- /dev/null +++ b/packages/appkit-react/src/components/button-with-connect/button-with-connect.tsx @@ -0,0 +1,37 @@ +/** + * 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 { ButtonProps } from '../button'; +import { Button } from '../button'; +import { useConnectors, useConnect, useSelectedWallet } from '../../features/wallets'; +import { useI18n } from '../../features/settings'; + +export const ButtonWithConnect: FC = (props) => { + const connectors = useConnectors(); + const { mutate: connect, isPending: isConnecting } = useConnect(); + const [wallet] = useSelectedWallet(); + const isWalletConnected = wallet !== null; + + const { t } = useI18n(); + + if (!isWalletConnected) { + return ( + + ); + } + + return + + +
+ ), args: { - children: 'Click me', + variant: 'fill', }, }; -export const Disabled: Story = { +export const Variants: Story = { + render: (args) => ( +
+ + + +
+ ), args: { - children: 'Disabled Button', - disabled: true, + size: 'l', }, }; -export const WithIcon: Story = { +export const Loading: Story = { args: { - children: ( - <> - - - - Add Item - - ), + children: 'Loading Button', + loading: true, }, }; diff --git a/packages/appkit-react/src/components/button/button.tsx b/packages/appkit-react/src/components/button/button.tsx index a9f9dce44..787d77568 100644 --- a/packages/appkit-react/src/components/button/button.tsx +++ b/packages/appkit-react/src/components/button/button.tsx @@ -6,11 +6,60 @@ * */ -import type { FC, ComponentProps } from 'react'; +import { forwardRef } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; import clsx from 'clsx'; import styles from './button.module.css'; -export const Button: FC> = ({ className, ...props }) => { - return + ); + }, +); + +Button.displayName = 'Button'; diff --git a/packages/appkit-react/src/components/button/index.ts b/packages/appkit-react/src/components/button/index.ts index 4d25fa681..628283402 100644 --- a/packages/appkit-react/src/components/button/index.ts +++ b/packages/appkit-react/src/components/button/index.ts @@ -7,3 +7,4 @@ */ export { Button } from './button'; +export type { ButtonProps } from './button'; diff --git a/packages/appkit-react/src/components/centered-amount-input/centered-amount-input.module.css b/packages/appkit-react/src/components/centered-amount-input/centered-amount-input.module.css new file mode 100644 index 000000000..4105120d4 --- /dev/null +++ b/packages/appkit-react/src/components/centered-amount-input/centered-amount-input.module.css @@ -0,0 +1,69 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + cursor: text; + width: 100%; + overflow: hidden; +} + +.row { + display: flex; + align-items: baseline; + max-width: 100%; +} + +.input { + composes: inputXl from "../../styles/typography.module.css"; + color: var(--ta-color-text); + border: none; + outline: none; + background: none; + padding: 0; + text-align: right; + min-width: 24px; + max-width: 100%; + box-sizing: content-box; +} + +.input::placeholder { + color: var(--ta-color-text-tertiary); + opacity: 1; +} + +.ticker { + composes: inputXlSymbol from "../../styles/typography.module.css"; + color: var(--ta-color-text-tertiary); + white-space: nowrap; + user-select: none; + margin-left: 0.2em; +} + +.symbol { + composes: inputXl from "../../styles/typography.module.css"; + color: var(--ta-color-text-tertiary); + white-space: nowrap; + user-select: none; +} + +.mirror { + composes: inputXl from "../../styles/typography.module.css"; + position: absolute; + visibility: hidden; + white-space: nowrap; + pointer-events: none; +} + +.measureRow { + display: flex; + align-items: baseline; + position: absolute; + visibility: hidden; + white-space: nowrap; + pointer-events: none; +} + +.measureText { + composes: inputXl from "../../styles/typography.module.css"; +} diff --git a/packages/appkit-react/src/components/centered-amount-input/centered-amount-input.stories.tsx b/packages/appkit-react/src/components/centered-amount-input/centered-amount-input.stories.tsx new file mode 100644 index 000000000..07d11cd0e --- /dev/null +++ b/packages/appkit-react/src/components/centered-amount-input/centered-amount-input.stories.tsx @@ -0,0 +1,52 @@ +/** + * 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 { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CenteredAmountInput } from './centered-amount-input'; + +const meta: Meta = { + title: 'Public/Components/CenteredAmountInput', + component: CenteredAmountInput, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const Template = (props: { symbol?: string; ticker?: string; placeholder?: string; width?: number }) => { + const [value, setValue] = useState(''); + return ( +
+ +
+ ); +}; + +export const WithSymbol: Story = { + render: () =>