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/.changeset/staking-widget.md b/.changeset/staking-widget.md new file mode 100644 index 000000000..a4f1c12be --- /dev/null +++ b/.changeset/staking-widget.md @@ -0,0 +1,39 @@ +--- +'@ton/walletkit': major +'@ton/appkit': minor +'@ton/appkit-react': minor +--- + +**Staking widget and provider API** + +Breaking changes in `@ton/walletkit`: +- `getSupportedUnstakeModes()` removed from `StakingProviderInterface` and `StakingProvider`; replaced by `getStakingProviderMetadata(network?)` which returns full static metadata (including unstake modes) +- `getSupportedNetworks()` added as abstract method to `StakingProvider` — existing custom subclasses must implement it +- `DefiManagerError` renamed to `DefiError` +- `lstExchangeRate` renamed to `exchangeRate` in `StakingProviderInfo` +- `StakingProviderMetadata` shape changed: flat token fields replaced with `stakeToken: StakingTokenInfo` and optional `receiveToken?: StakingTokenInfo` + +Breaking changes in `@ton/appkit`: +- `getStakingProviders()` return type changed from `string[]` to `StakingProviderInterface[]` + +New in `@ton/walletkit`: +- Added `StakingTokenInfo` type (exported) +- `contractAddress` is now optional in `StakingProviderMetadata` (for custodial providers) +- Added `isReversed` to `StakingQuoteParams` for reversed unstake quotes +- `TonStakersStakingProvider` accepts `metadataOverride` in config; constructor deep-merges overrides with defaults +- `BaseProvider` moved from `interfaces` to `models/core` and re-exported +- Added `TokenAddress` type (`'ton' | UserFriendlyAddress`) +- `StakingErrorCode` now exported +- `DefiError.UNSUPPORTED_NETWORK` error code added +- `StakingManager`, `StakingProvider`, `StakingError` are now value exports (not only type exports) + +New in `@ton/appkit`: +- Added actions: `getStakingProvider`, `getStakingProviderMetadata`, `watchStakingProviders` +- Added utilities: `truncateDecimals`, `calcMaxSpendable` +- `StakingProviderMetadata` and `StakingTokenInfo` now exported from `@ton/appkit` + +New in `@ton/appkit-react`: +- Added `StakingWidget` — full stake/unstake UI with reversed quotes, balance display, and unstake mode selector +- New components: `StakingWidgetProvider`, `StakingWidgetUi`, `StakingInfo`, `StakingBalanceBlock`, `SelectUnstakeMode` +- New hooks: `useStakingProvider`, `useStakingProviders`, `useStakingProviderInfo`, `useStakingProviderMetadata`, `useStakingQuote`, `useBuildStakeTransaction`, `useStakedBalance` +- Added English localizations for all staking UI strings diff --git a/.changeset/swap-widget.md b/.changeset/swap-widget.md new file mode 100644 index 000000000..0750f294c --- /dev/null +++ b/.changeset/swap-widget.md @@ -0,0 +1,41 @@ +--- +'@ton/walletkit': major +'@ton/appkit': minor +'@ton/appkit-react': minor +--- + +**Swap widget and provider API** + +Breaking changes in `@ton/walletkit`: +- `DefiManagerError` renamed to `DefiError`; update any `catch (e instanceof DefiManagerError)` or direct import +- `SwapFee` type removed; `fee` field removed from `SwapQuote` +- `getSupportedNetworks()` and `getMetadata()` added as abstract methods to `SwapProvider` — existing custom provider subclasses must implement them + +New in `@ton/walletkit`: +- Added `SwapProviderMetadata` and `SwapProviderMetadataOverride` types +- `getMetadata()` on `SwapProviderInterface` returns static display info (name, logo, URL) +- `getSupportedNetworks()` on `SwapProviderInterface` returns supported networks +- `DeDustSwapProvider` and `OmnistonSwapProvider` expose metadata; both accept `metadataOverride` in config +- `getProviders()` replaces `getRegisteredProviders()` — returns `SwapProviderInterface[]` instead of `string[]` +- `removeProvider()` added to `DefiManagerAPI` +- Re-registering a provider with an existing id now replaces it instead of throwing +- `DefiError.UNSUPPORTED_NETWORK` error code added +- `SwapError`, `SwapManager`, `SwapProvider` are now value exports (not only type exports) +- Providers emit `provider:registered` and `provider:default-changed` events on `AppKit`'s event emitter + +New in `@ton/appkit`: +- Added actions: `getSwapProvider`, `getSwapProviders`, `watchSwapProviders`, `setDefaultSwapProvider` +- `getSwapQuote` now resolves the active network automatically when `network` is omitted +- Added utilities: `calcFiatValue`, `formatLargeValue`, `debounce`, `calcMaxSpendable`, `getTonShortfall` + +New in `@ton/appkit-react`: +- Added `SwapWidget` — full-featured swap UI with token selection, amount input, slippage settings, provider picker, and top-up flow +- New components: `SwapField`, `SwapFlipButton`, `SwapInfo`, `SwapSettingsButton`, `SwapSettingsModal`, `SwapTokenSelectModal`, `SwapWidgetProvider`, `SwapWidgetUi` +- New hooks: `useSwapProvider`, `useSwapProviders`, `useSwapQuote`, `useBuildSwapTransaction` +- Added generic `LowBalanceModal` component (shared with staking widget) +- New utility hooks: `useDebounceCallback`, `useDebounceValue`, `useUnmount` +- New shared components: `Input`, `Modal`, `Dialog`, `Skeleton`, `Tabs`, `InfoBlock`, `Collapsible`, `CenteredAmountInput`, `AmountPresets`, `TokenSelectModal`, `Logo`, `AmountReversed` +- Added `AppKitUIToken` type for CSS custom property tokens +- `useAppKit`, `useAppKitTheme`, `useI18n` moved to `features/settings` (still re-exported from the package root — no import path change needed) +- `CircleIcon` renamed to `Logo` with an extended API; replace `` with `` +- Added English localizations for all swap UI strings diff --git a/.superset/config.json b/.superset/config.json new file mode 100644 index 000000000..f806b5255 --- /dev/null +++ b/.superset/config.json @@ -0,0 +1,5 @@ +{ + "setup": [], + "teardown": [], + "run": [] +} diff --git a/apps/appkit-minter/package.json b/apps/appkit-minter/package.json index c5d5806c3..f7f105b1f 100644 --- a/apps/appkit-minter/package.json +++ b/apps/appkit-minter/package.json @@ -22,6 +22,10 @@ "@ton/crypto": "catalog:", "@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..8e6f33d5a 100644 --- a/apps/appkit-minter/src/app.tsx +++ b/apps/appkit-minter/src/app.tsx @@ -12,8 +12,7 @@ import { AppKitProvider } from '@ton/appkit-react'; import { appKit } from '@/core/configs/app-kit'; import { AppRouter, ThemeProvider, ToasterProvider } from '@/core/components'; -import './core/styles/app.css'; -import '@ton/appkit-react/styles.css'; +import './core/styles/index.css'; const queryClient = new QueryClient(); 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..73694e235 100644 --- a/apps/appkit-minter/src/core/components/index.ts +++ b/apps/appkit-minter/src/core/components/index.ts @@ -7,8 +7,14 @@ */ // 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'; 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..0a56a72bd 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,7 @@ 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, SignMessagePage } from '@/pages'; export const AppRouter: React.FC = () => { // Enable global real-time balance updates @@ -50,6 +50,9 @@ 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..c35d6e82c --- /dev/null +++ b/apps/appkit-minter/src/core/components/layout/app-sidebar.tsx @@ -0,0 +1,101 @@ +/** + * 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 { Coins, ArrowLeftRight, Sparkles, BookOpen, Github, PenLine } 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 { WalletInfo } 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: '/sign', label: 'Sign Message', icon: PenLine }, +]; + +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/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..4e4ce1d32 100644 --- a/apps/appkit-minter/src/core/components/layout/layout.tsx +++ b/apps/appkit-minter/src/core/components/layout/layout.tsx @@ -8,42 +8,43 @@ import type React from 'react'; import { TonConnectButton } from '@ton/appkit-react'; -import { Layers } from 'lucide-react'; +import { AppSidebar } from './app-sidebar'; 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..057b3e8b1 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/packages/appkit-react/src/components/ton-icon/index.ts b/apps/appkit-minter/src/core/components/separator/index.ts similarity index 75% rename from packages/appkit-react/src/components/ton-icon/index.ts rename to apps/appkit-minter/src/core/components/separator/index.ts index a608b766e..319a2d70b 100644 --- a/packages/appkit-react/src/components/ton-icon/index.ts +++ b/apps/appkit-minter/src/core/components/separator/index.ts @@ -6,4 +6,4 @@ * */ -export { TonIcon, TonIconCircle } from './ton-icon'; +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..5db23e1e1 --- /dev/null +++ b/apps/appkit-minter/src/core/components/sidebar/sidebar.tsx @@ -0,0 +1,665 @@ +/** + * 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 ( + -
- - {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/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..b923eca2d 100644 --- a/apps/appkit-minter/src/features/network/components/network-picker.tsx +++ b/apps/appkit-minter/src/features/network/components/network-picker.tsx @@ -42,7 +42,7 @@ export const NetworkPicker: FC> = ({ className, ...prop } 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/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 index 4f2928385..7a7d74798 100644 --- a/apps/appkit-minter/src/features/wallet/components/wallet-info.tsx +++ b/apps/appkit-minter/src/features/wallet/components/wallet-info.tsx @@ -8,35 +8,24 @@ import { useState, useCallback } from 'react'; import type { ComponentProps, FC } from 'react'; -import { useSelectedWallet, Network } from '@ton/appkit-react'; +import { useSelectedWallet } 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}`; -}; +import { cn } from '@/core/lib/utils'; const truncateAddress = (address: string): string => { if (address.length <= 12) { return address; } - return `${address.slice(0, 6)}…${address.slice(-6)}`; + return `${address.slice(0, 4)}…${address.slice(-4)}`; }; -export const WalletInfo: FC> = (props) => { +export const WalletInfo: FC> = ({ className, ...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; @@ -45,48 +34,36 @@ export const WalletInfo: FC> = (props) => { setTimeout(() => setCopied(false), 2000); }, [address]); - return ( - -
-
-
- -
+ if (!wallet) return null; - {!wallet &&

Connect wallet to mint

} - - {wallet && ( -
-

- {truncateAddress(address)} -

+ return ( +
+
+ +
-

Network: {networkLabel}

-
- )} -
+ + {truncateAddress(address)} + - {wallet && ( - - )} -
- + +
); }; diff --git a/apps/appkit-minter/src/main.tsx b/apps/appkit-minter/src/main.tsx index 420ea6f7f..04bb2bcfc 100644 --- a/apps/appkit-minter/src/main.tsx +++ b/apps/appkit-minter/src/main.tsx @@ -13,8 +13,6 @@ import { createRoot } from 'react-dom/client'; import { App } from './app'; -import './core/styles/index.css'; - createRoot(document.getElementById('root')!).render( diff --git a/apps/appkit-minter/src/pages/index.ts b/apps/appkit-minter/src/pages/index.ts index d55767204..4c2c5f47f 100644 --- a/apps/appkit-minter/src/pages/index.ts +++ b/apps/appkit-minter/src/pages/index.ts @@ -7,3 +7,6 @@ */ export { MinterPage } from './minter-page'; +export { SwapPage } from './swap-page'; +export { StakingPage } from './staking-page'; +export { SignMessagePage } from './sign-message-page'; diff --git a/apps/appkit-minter/src/pages/minter-page.tsx b/apps/appkit-minter/src/pages/minter-page.tsx index 429683429..85836a76f 100644 --- a/apps/appkit-minter/src/pages/minter-page.tsx +++ b/apps/appkit-minter/src/pages/minter-page.tsx @@ -12,47 +12,21 @@ import { useSelectedWallet } from '@ton/appkit-react'; import { TokensCard } 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/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..7754012b5 --- /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[] = [ + { + symbol: 'TON', + name: 'Toncoin', + decimals: 9, + address: 'ton', + network: Network.mainnet(), + logo: 'https://asset.ston.fi/img/EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c/c8d21a3d93f9b574381e0a8d8f16d48b325dd8f54ce172f599c1e9d6c62f03f7', + }, + { + symbol: 'USD₮', + name: 'Tether USD', + decimals: 6, + address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', + network: Network.mainnet(), + rate: '1', + logo: 'https://asset.ston.fi/img/EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs/1a87edfee9a28b05578853952e5effb8cc30af1e0fb90043aa2ce19dce490849', + }, + { + symbol: 'STON', + name: 'STON', + decimals: 9, + address: 'EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO', + network: Network.mainnet(), + logo: 'https://asset.ston.fi/img/EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO/7c9798ce1e64707fb4cb8f025d4060f66b386ed381b50498e3b88731cedeffe8', + }, + { + symbol: 'XAUt0', + name: 'Tether Gold', + decimals: 6, + address: 'EQA1R_LuQCLHlMgOo1S4G7Y7W1cd0FrAkbA10Zq7rddKxi9k', + network: Network.mainnet(), + logo: 'https://asset.ston.fi/img/EQA1R_LuQCLHlMgOo1S4G7Y7W1cd0FrAkbA10Zq7rddKxi9k/4aaaa7c30d7811bced81ded6bc116dcc82a78c6aea53d6012fd586a5826963ad', + }, + { + symbol: 'USDe', + name: 'Ethena USDe', + decimals: 6, + address: 'EQAIb6KmdfdDR7CN1GBqVJuP25iCnLKCvBlJ07Evuu2dzP5f', + network: Network.mainnet(), + rate: '1', + logo: 'https://asset.ston.fi/img/EQAIb6KmdfdDR7CN1GBqVJuP25iCnLKCvBlJ07Evuu2dzP5f/dbcc67993cd4aad4845a97a4a9722c6cb618123997c8112c29d4932b2739c4cd', + }, + { + symbol: 'tsTON', + name: 'Tonstakers TON', + decimals: 9, + address: 'EQC98_qAmNEptUtPc7W6xdHh_ZHrBUFpw5Ft_IzNU20QAJav', + network: Network.mainnet(), + logo: 'https://asset.ston.fi/img/EQC98_qAmNEptUtPc7W6xdHh_ZHrBUFpw5Ft_IzNU20QAJav/38f530facb209e4696b8aef17af51df94d16bd879926c517b07d25841da287b7', + }, + { + symbol: 'GEMSTON', + name: 'GEMSTON', + decimals: 9, + address: 'EQBX6K9aXVl3nXINCyPPL86C4ONVmQ8vK360u6dykFKXpHCa', + network: Network.mainnet(), + logo: 'https://asset.ston.fi/img/EQBX6K9aXVl3nXINCyPPL86C4ONVmQ8vK360u6dykFKXpHCa/c6ab1e58e3b9b58a7429d38b7feab731afae2f66dc301a6c42041fdf7e9d7c9c', + }, + { + symbol: 'UTYA', + name: 'Utya', + decimals: 9, + address: 'EQBaCgUwOoc6gHCNln_oJzb0mVs79YG7wYoavh-o1ItaneLA', + network: Network.mainnet(), + logo: 'https://asset.ston.fi/img/EQBaCgUwOoc6gHCNln_oJzb0mVs79YG7wYoavh-o1ItaneLA/727e6cc971afdfa8ed9c698d0909eee9de344a0b6766ff5e4ddcc3323449d6f6', + }, + { + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + address: 'EQBTkLAhEteZCRgRe_xMs5ZE0bMrduYxKbyzGCpXXW8dRWOT', + network: Network.mainnet(), + logo: 'https://asset.ston.fi/img/EQBTkLAhEteZCRgRe_xMs5ZE0bMrduYxKbyzGCpXXW8dRWOT/6267787665c30c2500dbde048e2f8a6a6d7ec58633ea038723f4ce1fab337ccb', + }, +]; + +export const SwapPage: React.FC = () => { + return ( + + + + + + ); +}; 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/providers/register-provider.ts b/demo/examples/src/appkit/actions/providers/register-provider.ts index 0faccd2c6..9718878f8 100644 --- a/demo/examples/src/appkit/actions/providers/register-provider.ts +++ b/demo/examples/src/appkit/actions/providers/register-provider.ts @@ -8,14 +8,15 @@ import type { AppKit } from '@ton/appkit'; import { registerProvider } from '@ton/appkit'; -import { OmnistonSwapProvider } from '@ton/walletkit/swap/omniston'; +import { createOmnistonProvider } from '@ton/walletkit/swap/omniston'; export const registerProviderExample = (appKit: AppKit) => { // SAMPLE_START: REGISTER_PROVIDER - const omnistonProvider = new OmnistonSwapProvider({ - defaultSlippageBps: 100, // 1% - }); - - registerProvider(appKit, omnistonProvider); + registerProvider( + appKit, + createOmnistonProvider({ + defaultSlippageBps: 100, // 1% + }), + ); // SAMPLE_END: REGISTER_PROVIDER }; 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/staking/staking.test.ts b/demo/examples/src/appkit/actions/staking/staking.test.ts new file mode 100644 index 000000000..905fdf189 --- /dev/null +++ b/demo/examples/src/appkit/actions/staking/staking.test.ts @@ -0,0 +1,101 @@ +/** + * 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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AppKit } from '@ton/appkit'; +import { Network } from '@ton/walletkit'; +import type { WalletInterface } from '@ton/appkit'; + +import { stakingExample } from './staking-actions'; + +describe('Staking Actions Examples', () => { + let appKit: AppKit; + let consoleSpy: ReturnType; + let mockGetQuote: ReturnType; + let mockBuildStakeTransaction: ReturnType; + let mockGetStakedBalance: ReturnType; + let mockGetStakingProviderInfo: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + appKit = new AppKit({ + networks: { + [Network.mainnet().chainId]: {}, + }, + }); + + mockGetQuote = vi.fn(); + mockBuildStakeTransaction = vi.fn(); + mockGetStakedBalance = vi.fn(); + mockGetStakingProviderInfo = vi.fn(); + + vi.spyOn(appKit.stakingManager, 'getProvider').mockImplementation( + (id) => + ({ + providerId: id || 'tonstakers', + getStakingProviderMetadata: () => ({ + name: 'Tonstakers', + receiveToken: { ticker: 'tsTON', decimals: 9, address: 'ton' }, + }), + }) as never, + ); + vi.spyOn(appKit.stakingManager, 'getProviders').mockReturnValue([]); + // @ts-expect-error - internal access + vi.spyOn(appKit.stakingManager, 'getQuote').mockImplementation(mockGetQuote); + // @ts-expect-error - internal access + vi.spyOn(appKit.stakingManager, 'buildStakeTransaction').mockImplementation(mockBuildStakeTransaction); + // @ts-expect-error - internal access + vi.spyOn(appKit.stakingManager, 'getStakedBalance').mockImplementation(mockGetStakedBalance); + // @ts-expect-error - internal access + vi.spyOn(appKit.stakingManager, 'getStakingProviderInfo').mockImplementation(mockGetStakingProviderInfo); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + const setupMockWallet = () => { + const mockWallet = { + getAddress: () => 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + getWalletId: () => 'mock-wallet-id', + getNetwork: () => 'mainnet', + sendTransaction: vi.fn(), + } as unknown as WalletInterface; + + appKit.walletsManager.setWallets([mockWallet]); + return mockWallet; + }; + + describe('stakingExample', () => { + it('should complete the full staking flow', async () => { + setupMockWallet(); + const mockQuote = { amountOut: '1.05' }; + const mockTxRequest = { messages: [] }; + const mockBalance = { stakedBalance: '42' }; + const mockInfo = { apy: '4.2%' }; + + mockGetQuote.mockResolvedValue(mockQuote); + mockBuildStakeTransaction.mockResolvedValue(mockTxRequest); + mockGetStakedBalance.mockResolvedValue(mockBalance); + mockGetStakingProviderInfo.mockResolvedValue(mockInfo); + + await stakingExample(appKit); + + expect(mockGetQuote).toHaveBeenCalled(); + expect(mockBuildStakeTransaction).toHaveBeenCalled(); + expect(mockGetStakedBalance).toHaveBeenCalled(); + expect(mockGetStakingProviderInfo).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith('Staking Quote:', mockQuote); + expect(consoleSpy).toHaveBeenCalledWith('Stake Transaction:', mockTxRequest); + expect(consoleSpy).toHaveBeenCalledWith('Staked Balance:', mockBalance); + expect(consoleSpy).toHaveBeenCalledWith('Provider Info:', mockInfo); + }); + }); +}); diff --git a/demo/examples/src/appkit/actions/swap/swap-actions.ts b/demo/examples/src/appkit/actions/swap/swap-actions.ts index ace40c84c..39101744d 100644 --- a/demo/examples/src/appkit/actions/swap/swap-actions.ts +++ b/demo/examples/src/appkit/actions/swap/swap-actions.ts @@ -8,13 +8,45 @@ import type { AppKit } from '@ton/appkit'; import { Network } from '@ton/appkit'; -import { getSwapManager, getSwapQuote, buildSwapTransaction, sendTransaction } from '@ton/appkit'; +import { + getSwapManager, + getSwapProvider, + getSwapProviders, + setDefaultSwapProvider, + 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: GET_SWAP_PROVIDERS + const swapProviders = getSwapProviders(appKit); + console.log( + 'Registered providers:', + swapProviders.map((p) => p.providerId), + ); + // SAMPLE_END: GET_SWAP_PROVIDERS + + // SAMPLE_START: SET_DEFAULT_SWAP_PROVIDER + setDefaultSwapProvider(appKit, { providerId: 'stonfi' }); + // SAMPLE_END: SET_DEFAULT_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 +67,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..955be3a25 100644 --- a/demo/examples/src/appkit/actions/swap/swap.test.ts +++ b/demo/examples/src/appkit/actions/swap/swap.test.ts @@ -30,15 +30,20 @@ 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 vi.spyOn(appKit.swapManager, 'buildSwapTransaction').mockImplementation(mockBuildSwapTransaction); + vi.spyOn(appKit.swapManager, 'setDefaultProvider').mockImplementation(() => {}); }); afterEach(() => { diff --git a/demo/examples/src/appkit/hooks/staking/staking.test.tsx b/demo/examples/src/appkit/hooks/staking/staking.test.tsx new file mode 100644 index 000000000..911cf62ba --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/staking.test.tsx @@ -0,0 +1,193 @@ +/** + * 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 { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import * as AppKitReact from '@ton/appkit-react'; + +import { UseStakingProvidersExample } from './use-staking-providers'; +import { UseStakingProviderExample } from './use-staking-provider'; +import { UseStakingQuoteExample } from './use-staking-quote'; +import { UseStakedBalanceExample } from './use-staked-balance'; +import { UseStakingProviderInfoExample } from './use-staking-provider-info'; +import { UseStakingProviderMetadataExample } from './use-staking-provider-metadata'; +import { UseBuildStakeTransactionExample } from './use-build-stake-transaction'; + +vi.mock('@ton/appkit-react', async () => { + const actual = await vi.importActual('@ton/appkit-react'); + return { + ...actual, + useStakingProviders: vi.fn(), + useStakingProvider: vi.fn(), + useStakingQuote: vi.fn(), + useStakedBalance: vi.fn(), + useStakingProviderInfo: vi.fn(), + useStakingProviderMetadata: vi.fn(), + useBuildStakeTransaction: vi.fn(), + useSendTransaction: vi.fn(), + }; +}); + +describe('Staking Hooks Examples', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('UseStakingProvidersExample', () => { + it('should render the list of provider ids', () => { + vi.mocked(AppKitReact.useStakingProviders).mockReturnValue([ + { providerId: 'tonstakers' }, + { providerId: 'whales' }, + ] as unknown as AppKitReact.UseStakingProvidersReturnType); + + render(); + expect(screen.getByText('tonstakers')).toBeDefined(); + expect(screen.getByText('whales')).toBeDefined(); + }); + }); + + describe('UseStakingProviderExample', () => { + it('should render staking provider', () => { + vi.mocked(AppKitReact.useStakingProvider).mockReturnValue({ + providerId: 'tonstakers', + } as unknown as AppKitReact.UseStakingProviderReturnType); + + render(); + expect(screen.getByText('Result: tonstakers')).toBeDefined(); + }); + }); + + describe('UseStakingQuoteExample', () => { + it('should render loading state', () => { + // @ts-expect-error - mock + vi.mocked(AppKitReact.useStakingQuote).mockReturnValue({ + isLoading: true, + data: undefined, + error: null, + }); + + render(); + expect(screen.getByText('Loading quote...')).toBeDefined(); + }); + + it('should render quote amount', () => { + // @ts-expect-error - mock + vi.mocked(AppKitReact.useStakingQuote).mockReturnValue({ + isLoading: false, + data: { amountOut: '1.05' }, + error: null, + }); + + render(); + expect(screen.getByText('Expected Output: 1.05')).toBeDefined(); + }); + }); + + describe('UseStakedBalanceExample', () => { + it('should render balance', () => { + // @ts-expect-error - mock + vi.mocked(AppKitReact.useStakedBalance).mockReturnValue({ + isLoading: false, + data: { stakedBalance: '42' }, + }); + + render(); + expect(screen.getByText('Staked Balance: 42')).toBeDefined(); + }); + }); + + describe('UseStakingProviderInfoExample', () => { + it('should render APY', () => { + // @ts-expect-error - mock + vi.mocked(AppKitReact.useStakingProviderInfo).mockReturnValue({ + isLoading: false, + data: { apy: '4.2%' }, + }); + + render(); + expect(screen.getByText('APY: 4.2%')).toBeDefined(); + }); + }); + + describe('UseStakingProviderMetadataExample', () => { + it('should render receive token ticker', () => { + vi.mocked(AppKitReact.useStakingProviderMetadata).mockReturnValue({ + receiveToken: { ticker: 'tsTON' }, + } as unknown as AppKitReact.UseStakingProviderMetadataReturnType); + + render(); + expect(screen.getByText('Receive Token: tsTON')).toBeDefined(); + }); + }); + + describe('UseBuildStakeTransactionExample', () => { + it('should call buildTx and sendTx on button click', async () => { + const mockQuote = { amountOut: '1.05' }; + const mockTransaction = { to: 'address', value: '100' }; + const mockBuildTx = vi.fn().mockResolvedValue(mockTransaction); + const mockSendTx = vi.fn().mockResolvedValue(true); + + // @ts-expect-error - mock + vi.mocked(AppKitReact.useStakingQuote).mockReturnValue({ + data: mockQuote, + isLoading: false, + error: null, + }); + + // @ts-expect-error - mock + vi.mocked(AppKitReact.useBuildStakeTransaction).mockReturnValue({ + mutateAsync: mockBuildTx, + isPending: false, + }); + + // @ts-expect-error - mock + vi.mocked(AppKitReact.useSendTransaction).mockReturnValue({ + mutateAsync: mockSendTx, + isPending: false, + }); + + render(); + fireEvent.click(screen.getByText('Stake')); + + await waitFor(() => { + expect(mockBuildTx).toHaveBeenCalledWith({ + quote: mockQuote, + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + }); + }); + + await waitFor(() => { + expect(mockSendTx).toHaveBeenCalledWith(mockTransaction); + }); + }); + + it('should disable button when processing', () => { + // @ts-expect-error - mock + vi.mocked(AppKitReact.useStakingQuote).mockReturnValue({ + data: { amountOut: '1.05' }, + isLoading: false, + }); + + // @ts-expect-error - mock + vi.mocked(AppKitReact.useBuildStakeTransaction).mockReturnValue({ + mutateAsync: vi.fn(), + isPending: true, + }); + + // @ts-expect-error - mock + vi.mocked(AppKitReact.useSendTransaction).mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + }); + + render(); + const button = screen.getByText('Processing...'); + expect(button.closest('button')?.disabled).toBe(true); + }); + }); +}); diff --git a/demo/examples/src/appkit/hooks/staking/use-build-stake-transaction.tsx b/demo/examples/src/appkit/hooks/staking/use-build-stake-transaction.tsx new file mode 100644 index 000000000..3128b86b0 --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/use-build-stake-transaction.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 { useBuildStakeTransaction, useSendTransaction, useStakingQuote } from '@ton/appkit-react'; + +/* eslint-disable no-console */ + +export const UseBuildStakeTransactionExample = () => { + // SAMPLE_START: USE_BUILD_STAKE_TRANSACTION + const { data: quote } = useStakingQuote({ + amount: '10', + direction: 'stake', + }); + + const { mutateAsync: buildTx, isPending: isBuilding } = useBuildStakeTransaction(); + const { mutateAsync: sendTx, isPending: isSending } = useSendTransaction(); + + const handleStake = async () => { + if (!quote) return; + try { + const transaction = await buildTx({ + quote, + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + }); + await sendTx(transaction); + } catch (e) { + console.error(e); + } + }; + + const isPending = isBuilding || isSending; + + return ( +
+ +
+ ); + // SAMPLE_END: USE_BUILD_STAKE_TRANSACTION +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staked-balance.tsx b/demo/examples/src/appkit/hooks/staking/use-staked-balance.tsx new file mode 100644 index 000000000..88829ddb0 --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/use-staked-balance.tsx @@ -0,0 +1,21 @@ +/** + * 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 { useStakedBalance } from '@ton/appkit-react'; + +export const UseStakedBalanceExample = () => { + // SAMPLE_START: USE_STAKED_BALANCE + const { data: balance, isLoading } = useStakedBalance({ + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + }); + + if (isLoading) return
Loading balance...
; + + return
Staked Balance: {balance?.stakedBalance}
; + // SAMPLE_END: USE_STAKED_BALANCE +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staking-provider-info.tsx b/demo/examples/src/appkit/hooks/staking/use-staking-provider-info.tsx new file mode 100644 index 000000000..8ea9fe07c --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/use-staking-provider-info.tsx @@ -0,0 +1,21 @@ +/** + * 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 { useStakingProviderInfo } from '@ton/appkit-react'; + +export const UseStakingProviderInfoExample = () => { + // SAMPLE_START: USE_STAKING_PROVIDER_INFO + const { data: info, isLoading } = useStakingProviderInfo({ + providerId: 'tonstakers', + }); + + if (isLoading) return
Loading info...
; + + return
APY: {info?.apy}
; + // SAMPLE_END: USE_STAKING_PROVIDER_INFO +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staking-provider-metadata.tsx b/demo/examples/src/appkit/hooks/staking/use-staking-provider-metadata.tsx new file mode 100644 index 000000000..0b4112f2e --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/use-staking-provider-metadata.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 { useStakingProviderMetadata } from '@ton/appkit-react'; + +export const UseStakingProviderMetadataExample = () => { + // SAMPLE_START: USE_STAKING_PROVIDER_METADATA + const metadata = useStakingProviderMetadata(); + return
Receive Token: {metadata?.receiveToken?.ticker}
; + // SAMPLE_END: USE_STAKING_PROVIDER_METADATA +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staking-provider.tsx b/demo/examples/src/appkit/hooks/staking/use-staking-provider.tsx new file mode 100644 index 000000000..977e6c7d2 --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/use-staking-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 { useStakingProvider } from '@ton/appkit-react'; + +export const UseStakingProviderExample = () => { + // SAMPLE_START: USE_STAKING_PROVIDER + const provider = useStakingProvider({ id: 'tonstakers' }); + return
Result: {provider ? provider.providerId : 'null'}
; + // SAMPLE_END: USE_STAKING_PROVIDER +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staking-providers.tsx b/demo/examples/src/appkit/hooks/staking/use-staking-providers.tsx new file mode 100644 index 000000000..63550038d --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/use-staking-providers.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 { useStakingProviders } from '@ton/appkit-react'; + +export const UseStakingProvidersExample = () => { + // SAMPLE_START: USE_STAKING_PROVIDERS + const providers = useStakingProviders(); + return ( +
    + {providers.map((p) => ( +
  • {p.providerId}
  • + ))} +
+ ); + // SAMPLE_END: USE_STAKING_PROVIDERS +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staking-quote.tsx b/demo/examples/src/appkit/hooks/staking/use-staking-quote.tsx new file mode 100644 index 000000000..9ee5002e5 --- /dev/null +++ b/demo/examples/src/appkit/hooks/staking/use-staking-quote.tsx @@ -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 { useStakingQuote } from '@ton/appkit-react'; + +export const UseStakingQuoteExample = () => { + // SAMPLE_START: USE_STAKING_QUOTE + const { + data: quote, + isLoading, + error, + } = useStakingQuote({ + amount: '10', + direction: 'stake', + }); + + if (isLoading) return
Loading quote...
; + if (error) return
Error: {error.message}
; + + return
Expected Output: {quote?.amountOut}
; + // SAMPLE_END: USE_STAKING_QUOTE +}; diff --git a/demo/examples/src/appkit/hooks/staking/use-staking.tsx b/demo/examples/src/appkit/hooks/staking/use-staking.tsx deleted file mode 100644 index 4b879cdb2..000000000 --- a/demo/examples/src/appkit/hooks/staking/use-staking.tsx +++ /dev/null @@ -1,29 +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 { useStakingQuote, useStakedBalance } from '@ton/appkit-react'; - -export const UseStakingExample = () => { - // SAMPLE_START: USE_STAKING - const { data: quote } = useStakingQuote({ - amount: '1000000000', - direction: 'stake', - }); - - const { data: balance } = useStakedBalance({ - userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', - }); - - return ( -
-
Staking Quote: {quote?.amountOut}
-
Staked Balance: {balance?.stakedBalance}
-
- ); - // 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..e65d0e81f 100644 --- a/demo/examples/src/appkit/hooks/swap/swap.test.tsx +++ b/demo/examples/src/appkit/hooks/swap/swap.test.tsx @@ -12,6 +12,8 @@ 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'; +import { UseSwapProvidersExample } from './use-swap-providers'; // Mock the whole module vi.mock('@ton/appkit-react', async () => { @@ -21,6 +23,8 @@ vi.mock('@ton/appkit-react', async () => { useSwapQuote: vi.fn(), useBuildSwapTransaction: vi.fn(), useSendTransaction: vi.fn(), + useSwapProvider: vi.fn(), + useSwapProviders: vi.fn(), }; }); @@ -71,6 +75,31 @@ describe('Swap Hooks Examples', () => { }); }); + describe('UseSwapProviderExample', () => { + it('should render swap provider', () => { + vi.mocked(AppKitReact.useSwapProvider).mockReturnValue([ + { providerId: 'stonfi' } as unknown as AppKitReact.UseSwapProviderReturnType[0], + () => {}, + ]); + + render(); + expect(screen.getByText('Result: stonfi')).toBeDefined(); + }); + }); + + describe('UseSwapProvidersExample', () => { + it('should render the list of provider names', () => { + vi.mocked(AppKitReact.useSwapProviders).mockReturnValue([ + { providerId: 'stonfi', getMetadata: () => ({ name: 'STON.fi' }) }, + { providerId: 'dedust', getMetadata: () => ({ name: 'DeDust' }) }, + ] as unknown as AppKitReact.UseSwapProvidersReturnType); + + render(); + expect(screen.getByText('STON.fi')).toBeDefined(); + expect(screen.getByText('DeDust')).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..3d536b619 --- /dev/null +++ b/demo/examples/src/appkit/hooks/swap/use-swap-provider.tsx @@ -0,0 +1,21 @@ +/** + * 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, setProviderId] = useSwapProvider(); + return ( +
+
Result: {provider ? provider.providerId : 'null'}
+ +
+ ); + // SAMPLE_END: USE_SWAP_PROVIDER +}; diff --git a/demo/examples/src/appkit/hooks/swap/use-swap-providers.tsx b/demo/examples/src/appkit/hooks/swap/use-swap-providers.tsx new file mode 100644 index 000000000..0b7280935 --- /dev/null +++ b/demo/examples/src/appkit/hooks/swap/use-swap-providers.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 { useSwapProviders } from '@ton/appkit-react'; + +export const UseSwapProvidersExample = () => { + // SAMPLE_START: USE_SWAP_PROVIDERS + const providers = useSwapProviders(); + return ( +
    + {providers.map((p) => ( +
  • {p.getMetadata().name}
  • + ))} +
+ ); + // SAMPLE_END: USE_SWAP_PROVIDERS +}; 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/dedust.ts b/demo/examples/src/appkit/swap/dedust.ts index b9297d611..03bfa6841 100644 --- a/demo/examples/src/appkit/swap/dedust.ts +++ b/demo/examples/src/appkit/swap/dedust.ts @@ -9,16 +9,17 @@ import type { AppKit } from '@ton/appkit'; import { Network, getSwapQuote } from '@ton/appkit'; import type { DeDustProviderOptions } from '@ton/walletkit/swap/dedust'; -import { DeDustSwapProvider } from '@ton/appkit/swap/dedust'; +import { createDeDustProvider } from '@ton/appkit/swap/dedust'; export const dedustQuickStartExample = (kit: AppKit) => { // SAMPLE_START: DEDUST_QUICK_START - const provider = new DeDustSwapProvider({ - defaultSlippageBps: 100, // 1% - referralAddress: 'EQ...', - referralFeeBps: 50, // 0.5% - }); - kit.registerProvider(provider); + kit.registerProvider( + createDeDustProvider({ + defaultSlippageBps: 100, // 1% + referralAddress: 'EQ...', + referralFeeBps: 50, // 0.5% + }), + ); // SAMPLE_END: DEDUST_QUICK_START }; @@ -72,11 +73,12 @@ export const dedustOverridingReferralExample = async (appKit: AppKit) => { const USDT = { address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', decimals: 6 }; // Global referrer in config - const provider = new DeDustSwapProvider({ - referralAddress: 'EQ...global', - referralFeeBps: 50, - }); - appKit.registerProvider(provider); + appKit.registerProvider( + createDeDustProvider({ + referralAddress: 'EQ...global', + referralFeeBps: 50, + }), + ); // Override for specific quote const quote = await getSwapQuote(appKit, { diff --git a/demo/examples/src/appkit/swap/omniston.ts b/demo/examples/src/appkit/swap/omniston.ts index db7bad4c2..29282cf9d 100644 --- a/demo/examples/src/appkit/swap/omniston.ts +++ b/demo/examples/src/appkit/swap/omniston.ts @@ -8,16 +8,17 @@ import { AppKit, Network, registerProvider, getSwapQuote } from '@ton/appkit'; import type { OmnistonProviderOptions } from '@ton/walletkit/swap/omniston'; -import { DeDustSwapProvider } from '@ton/appkit/swap/dedust'; -import { OmnistonSwapProvider } from '@ton/appkit/swap/omniston'; +import { createDeDustProvider } from '@ton/appkit/swap/dedust'; +import { createOmnistonProvider } from '@ton/appkit/swap/omniston'; export const omnistonQuickStartExample = (kit: AppKit) => { // SAMPLE_START: OMNISTON_QUICK_START - const provider = new OmnistonSwapProvider({ - defaultSlippageBps: 100, // 1% - quoteTimeoutMs: 10000, - }); - kit.registerProvider(provider); + kit.registerProvider( + createOmnistonProvider({ + defaultSlippageBps: 100, // 1% + quoteTimeoutMs: 10000, + }), + ); // SAMPLE_END: OMNISTON_QUICK_START }; @@ -64,11 +65,12 @@ export const omnistonOverridingReferralExample = async (appKit: AppKit) => { const USDT = { address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', decimals: 6 }; // Global referrer in config - const provider = new OmnistonSwapProvider({ - referrerAddress: 'EQ...global', - referrerFeeBps: 10, - }); - appKit.registerProvider(provider); + appKit.registerProvider( + createOmnistonProvider({ + referrerAddress: 'EQ...global', + referrerFeeBps: 10, + }), + ); // Override for specific quote const quote = await getSwapQuote(appKit, { @@ -108,11 +110,11 @@ export const swapProviderInitExample = async () => { }, }, providers: [ - new OmnistonSwapProvider({ + createOmnistonProvider({ apiUrl: 'https://api.ston.fi', defaultSlippageBps: 100, // 1% }), - new DeDustSwapProvider({ + createDeDustProvider({ defaultSlippageBps: 100, referralAddress: 'EQ...', // Optional }), @@ -138,8 +140,8 @@ export const swapProviderRegisterExample = async () => { }); // 2. Register swap providers - registerProvider(appKit, new OmnistonSwapProvider({ defaultSlippageBps: 100 })); - registerProvider(appKit, new DeDustSwapProvider({ defaultSlippageBps: 100 })); + registerProvider(appKit, createOmnistonProvider({ defaultSlippageBps: 100 })); + registerProvider(appKit, createDeDustProvider({ defaultSlippageBps: 100 })); // SAMPLE_END: SWAP_PROVIDER_REGISTER return appKit; 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..05a896eab --- /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 = [ + { + address: 'ton', + symbol: 'TON', + name: 'Toncoin', + decimals: 9, + network: Network.mainnet(), + logo: 'https://ton.org/symbol.png', + }, + { + address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', + symbol: 'USDT', + name: 'Tether', + decimals: 6, + network: Network.mainnet(), + 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/store/slices/walletCoreSlice.ts b/demo/wallet-core/src/store/slices/walletCoreSlice.ts index c21c6b357..f1e4b06d0 100644 --- a/demo/wallet-core/src/store/slices/walletCoreSlice.ts +++ b/demo/wallet-core/src/store/slices/walletCoreSlice.ts @@ -17,7 +17,7 @@ import { createTonCenterStreamingProvider, } from '@ton/walletkit'; import type { ITonWalletKit } from '@ton/walletkit'; -import { OmnistonSwapProvider } from '@ton/walletkit/swap/omniston'; +import { createOmnistonProvider } from '@ton/walletkit/swap/omniston'; import { createTonstakersProvider } from '@ton/walletkit/staking/tonstakers'; import { createComponentLogger } from '../../utils/logger'; @@ -100,7 +100,7 @@ function createWalletKitInstance(walletKitConfig?: WalletKitConfig): ITonWalletK }, }) as ITonWalletKit; - walletKit.swap.registerProvider(new OmnistonSwapProvider()); + walletKit.swap.registerProvider(createOmnistonProvider()); const streamingProvider = walletKitConfig?.tonApiProvider === 'tonapi' ? createTonApiStreamingProvider : createTonCenterStreamingProvider; diff --git a/package.json b/package.json index 46dddaaef..67dcb9790 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", diff --git a/packages/appkit-react/.storybook/app-kit.ts b/packages/appkit-react/.storybook/app-kit.ts new file mode 100644 index 000000000..67ba0f626 --- /dev/null +++ b/packages/appkit-react/.storybook/app-kit.ts @@ -0,0 +1,39 @@ +/** + * 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 } from '@ton/appkit'; +import { createTonConnectConnector } from '@ton/appkit'; +import { createOmnistonProvider } from '@ton/appkit/swap/omniston'; +import { createDeDustProvider } from '@ton/appkit/swap/dedust'; +import { createTonstakersProvider } from '@ton/appkit/staking/tonstakers'; + +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: [createOmnistonProvider(), createDeDustProvider(), createTonstakersProvider()], +}); 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/CLAUDE.md b/packages/appkit-react/CLAUDE.md index 3fdb1befd..1b58037ee 100644 --- a/packages/appkit-react/CLAUDE.md +++ b/packages/appkit-react/CLAUDE.md @@ -7,6 +7,20 @@ - For creating new UI components, use the `add-ui-component` skill. - Every component must have a Storybook story (`.stories.tsx`). +## Hook ordering in components and custom hooks + +Where possible, group hooks in this order inside component/hook bodies. The goal is top-down dataflow: inputs → data → derivations → side-effects. + +1. **Local state** — `useState`, `useRef`, `useReducer`, `useDebounceValue`, and state-like wrappers. +2. **Queries and external readers** — `useQuery`-based hooks, wallet/network selectors (`useAddress`, `useNetwork`, balance hooks, etc.). +3. **Derivations** — `useMemo` and simple computed values derived from local state or query data (e.g. validation, formatted strings, flags). +4. **Mutations** — `useMutation`-based hooks (`useBuildSwapTransaction`, `useSendTransaction`, etc.) and simple flags derived from them. +5. **Callbacks / functions** — `useCallback` and inline handlers. +6. **Effects** — `useEffect`, `useLayoutEffect`. + +Exception: tightly coupled values may sit next to each other even if the strict order is broken — e.g. a `useMemo` that exists +only to prepare input for a hook right below it can live next to that hook. + ## Styling - Always use CSS Modules (`.module.css`). diff --git a/packages/appkit-react/README.md b/packages/appkit-react/README.md index 756b02e93..46ad5d6ee 100644 --- a/packages/appkit-react/README.md +++ b/packages/appkit-react/README.md @@ -216,11 +216,11 @@ const appKit = new AppKit({ }, }, providers: [ - new OmnistonSwapProvider({ + createOmnistonProvider({ apiUrl: 'https://api.ston.fi', defaultSlippageBps: 100, // 1% }), - new DeDustSwapProvider({ + createDeDustProvider({ defaultSlippageBps: 100, referralAddress: 'EQ...', // Optional }), @@ -240,24 +240,55 @@ AppKit supports staking through various providers (e.g., Tonstakers). The stakin ### Hooks -Use `useStakingQuote` to get a staking/unstaking quote and `useBuildStakeTransaction` or `useBuildUnstakeTransaction` to build the transaction. +Use `useStakingQuote` to get a staking/unstaking quote and `useBuildStakeTransaction` to build the transaction. [Read more about Staking](https://github.com/ton-connect/kit/tree/main/packages/appkit/docs/staking.md) ```tsx -const { data: quote } = useStakingQuote({ - amount: '1000000000', +const { + data: quote, + isLoading, + error, +} = useStakingQuote({ + amount: '10', direction: 'stake', }); -const { data: balance } = useStakedBalance({ - userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', +if (isLoading) return
Loading quote...
; +if (error) return
Error: {error.message}
; + +return
Expected Output: {quote?.amountOut}
; +``` + +```tsx +const { data: quote } = useStakingQuote({ + amount: '10', + direction: 'stake', }); +const { mutateAsync: buildTx, isPending: isBuilding } = useBuildStakeTransaction(); +const { mutateAsync: sendTx, isPending: isSending } = useSendTransaction(); + +const handleStake = async () => { + if (!quote) return; + try { + const transaction = await buildTx({ + quote, + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + }); + await sendTx(transaction); + } catch (e) { + console.error(e); + } +}; + +const isPending = isBuilding || isSending; + return (
-
Staking Quote: {quote?.amountOut}
-
Staked Balance: {balance?.stakedBalance}
+
); ``` 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 067309e34..ddeec6bdc 100644 --- a/packages/appkit-react/docs/hooks.md +++ b/packages/appkit-react/docs/hooks.md @@ -694,6 +694,155 @@ return ( ); ``` +### `useSwapProvider` + +Hook to read and change the currently selected swap provider. Returns a tuple `[provider, setProviderId]` — mirrors `useSelectedWallet`. + +```tsx +const [provider, setProviderId] = useSwapProvider(); +return ( +
+
Result: {provider ? provider.providerId : 'null'}
+ +
+); +``` + +### `useSwapProviders` + +Hook to get all registered swap providers. The returned array keeps a stable reference until the provider list changes, so it is safe to use with `useSyncExternalStore`. + +```tsx +const providers = useSwapProviders(); +return ( +
    + {providers.map((p) => ( +
  • {p.getMetadata().name}
  • + ))} +
+); +``` + +## Staking + +### `useStakingProviders` + +Hook to get all registered staking providers. The returned array keeps a stable reference until the provider list changes. + +```tsx +const providers = useStakingProviders(); +return ( +
    + {providers.map((p) => ( +
  • {p.providerId}
  • + ))} +
+); +``` + +### `useStakingProvider` + +Hook to get a specific staking provider by id (or the default when no id is passed). + +```tsx +const provider = useStakingProvider({ id: 'tonstakers' }); +return
Result: {provider ? provider.providerId : 'null'}
; +``` + +### `useStakingQuote` + +Hook to get a quote for staking or unstaking a given amount. + +```tsx +const { + data: quote, + isLoading, + error, +} = useStakingQuote({ + amount: '10', + direction: 'stake', +}); + +if (isLoading) return
Loading quote...
; +if (error) return
Error: {error.message}
; + +return
Expected Output: {quote?.amountOut}
; +``` + +### `useStakedBalance` + +Hook to get the user's currently staked balance. + +```tsx +const { data: balance, isLoading } = useStakedBalance({ + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', +}); + +if (isLoading) return
Loading balance...
; + +return
Staked Balance: {balance?.stakedBalance}
; +``` + +### `useStakingProviderInfo` + +Hook to get live info about a staking provider (APY, limits, etc.). + +```tsx +const { data: info, isLoading } = useStakingProviderInfo({ + providerId: 'tonstakers', +}); + +if (isLoading) return
Loading info...
; + +return
APY: {info?.apy}
; +``` + +### `useStakingProviderMetadata` + +Hook to get static metadata about a staking provider (name, receive token, etc.). + +```tsx +const metadata = useStakingProviderMetadata(); +return
Receive Token: {metadata?.receiveToken?.ticker}
; +``` + +### `useBuildStakeTransaction` + +Hook to build a stake transaction from a previously fetched quote. + +```tsx +const { data: quote } = useStakingQuote({ + amount: '10', + direction: 'stake', +}); + +const { mutateAsync: buildTx, isPending: isBuilding } = useBuildStakeTransaction(); +const { mutateAsync: sendTx, isPending: isSending } = useSendTransaction(); + +const handleStake = async () => { + if (!quote) return; + try { + const transaction = await buildTx({ + quote, + userAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + }); + await sendTx(transaction); + } catch (e) { + console.error(e); + } +}; + +const isPending = isBuilding || isSending; + +return ( +
+ +
+); +``` + ## Transaction ### `useSendTransaction` diff --git a/packages/appkit-react/src/components/block/block.stories.tsx b/packages/appkit-react/src/components/block/block.stories.tsx deleted file mode 100644 index a14b28716..000000000 --- a/packages/appkit-react/src/components/block/block.stories.tsx +++ /dev/null @@ -1,53 +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 { Meta, StoryObj } from '@storybook/react'; - -import { Block } from './block'; - -const meta: Meta = { - title: 'Private/Components/Block', - component: Block, - tags: ['autodocs'], - argTypes: { - direction: { - control: 'radio', - options: ['row', 'column'], - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Column: Story = { - args: { - direction: 'column', - children: ( - <> -
Item 1
-
Item 2
-
Item 3
- - ), - }, -}; - -export const Row: Story = { - args: { - direction: 'row', - children: ( - <> -
Item 1
-
Item 2
-
Item 3
- - ), - }, -}; diff --git a/packages/appkit-react/src/components/button/button.module.css b/packages/appkit-react/src/components/button/button.module.css deleted file mode 100644 index 9482cf6e8..000000000 --- a/packages/appkit-react/src/components/button/button.module.css +++ /dev/null @@ -1,38 +0,0 @@ -.button { - composes: bodyMedium from "../../styles/typography.module.css"; - appearance: none; - border: none; - outline: none; - cursor: pointer; - box-sizing: border-box; - padding: 8px 16px; - border-radius: 20px; - - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - - background-color: var(--ta-color-primary); - color: var(--ta-color-primary-foreground); - transition: transform 0.125s ease-in-out, background-color 0.2s ease; - will-change: transform; -} - -.button:hover { - transform: scale(1.02); -} - -.button:active { - transform: scale(0.98); -} - -.button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.button:disabled:hover, -.button:disabled:active { - transform: none; -} diff --git a/packages/appkit-react/src/components/button/button.stories.tsx b/packages/appkit-react/src/components/button/button.stories.tsx deleted file mode 100644 index a268465b5..000000000 --- a/packages/appkit-react/src/components/button/button.stories.tsx +++ /dev/null @@ -1,58 +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 { Meta, StoryObj } from '@storybook/react'; - -import { Button } from './button'; - -const meta: Meta = { - title: 'Public/Components/Button', - component: Button, - tags: ['autodocs'], - argTypes: { - disabled: { - control: 'boolean', - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - children: 'Click me', - }, -}; - -export const Disabled: Story = { - args: { - children: 'Disabled Button', - disabled: true, - }, -}; - -export const WithIcon: Story = { - args: { - children: ( - <> - - - - Add Item - - ), - }, -}; diff --git a/packages/appkit-react/src/components/button/button.tsx b/packages/appkit-react/src/components/button/button.tsx deleted file mode 100644 index a9f9dce44..000000000 --- a/packages/appkit-react/src/components/button/button.tsx +++ /dev/null @@ -1,16 +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 { FC, ComponentProps } from 'react'; -import clsx from 'clsx'; - -import styles from './button.module.css'; - -export const Button: FC> = ({ className, ...props }) => { - return + ))} +
+ ); +}; diff --git a/packages/appkit-react/src/components/shared/amount-presets/index.ts b/packages/appkit-react/src/components/shared/amount-presets/index.ts new file mode 100644 index 000000000..640926678 --- /dev/null +++ b/packages/appkit-react/src/components/shared/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/shared/button-with-connect/button-with-connect.tsx b/packages/appkit-react/src/components/shared/button-with-connect/button-with-connect.tsx new file mode 100644 index 000000000..b4e53b9dc --- /dev/null +++ b/packages/appkit-react/src/components/shared/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 '../../ui/button'; +import { Button } from '../../ui/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 + + + ) : ( + + )} + + + ); +}; diff --git a/packages/appkit-react/src/components/button/index.ts b/packages/appkit-react/src/components/shared/option-switcher/index.ts similarity index 82% rename from packages/appkit-react/src/components/button/index.ts rename to packages/appkit-react/src/components/shared/option-switcher/index.ts index 4d25fa681..263b997be 100644 --- a/packages/appkit-react/src/components/button/index.ts +++ b/packages/appkit-react/src/components/shared/option-switcher/index.ts @@ -6,4 +6,4 @@ * */ -export { Button } from './button'; +export * from './option-switcher'; diff --git a/packages/appkit-react/src/components/shared/option-switcher/option-switcher.module.css b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.module.css new file mode 100644 index 000000000..581026f99 --- /dev/null +++ b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.module.css @@ -0,0 +1,18 @@ +.button { + composes: bodyMedium from "../../../styles/typography.module.css"; + + display: inline-flex; + height: 20px; + align-items: center; + gap: 4px; + padding: 0; + background: transparent; + border: none; + color: var(--ta-color-text); + cursor: pointer; +} + +.button:disabled { + cursor: default; + color: var(--ta-color-text-secondary); +} diff --git a/packages/appkit-react/src/components/shared/option-switcher/option-switcher.stories.tsx b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.stories.tsx new file mode 100644 index 000000000..d6485dfab --- /dev/null +++ b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.stories.tsx @@ -0,0 +1,64 @@ +/** + * 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 { fn } from 'storybook/test'; + +import { OptionSwitcher } from './option-switcher'; + +const meta: Meta = { + title: 'Components/Shared/OptionSwitcher', + component: OptionSwitcher, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +const PROVIDER_OPTIONS = [ + { value: 'stonfi', label: 'STON.fi' }, + { value: 'dedust', label: 'DeDust' }, + { value: 'omniston', label: 'Omniston' }, +]; + +const SLIPPAGE_OPTIONS = [ + { value: '50', label: '0.50%' }, + { value: '100', label: '1.00%' }, + { value: '200', label: '2.00%' }, +]; + +export const Default: Story = { + render: () => { + const Wrapper = () => { + const [value, setValue] = useState('stonfi'); + return ; + }; + return ; + }, +}; + +export const Disabled: Story = { + args: { + value: 'stonfi', + options: PROVIDER_OPTIONS, + onChange: fn(), + disabled: true, + }, +}; + +export const Slippage: Story = { + render: () => { + const Wrapper = () => { + const [value, setValue] = useState('50'); + return ; + }; + return ; + }, +}; diff --git a/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx new file mode 100644 index 000000000..5225213bc --- /dev/null +++ b/packages/appkit-react/src/components/shared/option-switcher/option-switcher.tsx @@ -0,0 +1,55 @@ +/** + * 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 clsx from 'clsx'; + +import { ChevronsIcon } from '../../ui/icons'; +import { Select } from '../../ui/select'; +import styles from './option-switcher.module.css'; + +export interface OptionSwitcherOption { + value: string; + label: string; +} + +export interface OptionSwitcherProps { + /** Currently selected option value. */ + value: string | undefined; + /** Available options. */ + options: OptionSwitcherOption[]; + /** Called when the user picks an option. */ + onChange: (value: string) => void; + /** When true, the trigger is non-interactive and dimmed. */ + disabled?: boolean; + className?: string; +} + +/** + * Compact selector used inside settings modals next to a label. + */ +export const OptionSwitcher: FC = ({ value, options, onChange, disabled, className }) => { + const current = options.find((option) => option.value === value); + const currentLabel = current?.label ?? value ?? '—'; + + return ( + + + {currentLabel} + + + + {options.map((option) => ( + + {option.label} + + ))} + + + ); +}; diff --git a/packages/appkit-react/src/components/shared/settings-button/index.ts b/packages/appkit-react/src/components/shared/settings-button/index.ts new file mode 100644 index 000000000..c3ddee4b5 --- /dev/null +++ b/packages/appkit-react/src/components/shared/settings-button/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 * from './settings-button'; diff --git a/packages/appkit-react/src/components/shared/settings-button/settings-button.module.css b/packages/appkit-react/src/components/shared/settings-button/settings-button.module.css new file mode 100644 index 000000000..3310aaf9c --- /dev/null +++ b/packages/appkit-react/src/components/shared/settings-button/settings-button.module.css @@ -0,0 +1,11 @@ +.settingsButton { + flex-shrink: 0; + width: 50px; + height: 50px; + aspect-ratio: 1 / 1; +} + +.settingsButton svg { + width: 24px; + height: 24px; +} diff --git a/packages/appkit-react/src/components/shared/settings-button/settings-button.stories.tsx b/packages/appkit-react/src/components/shared/settings-button/settings-button.stories.tsx new file mode 100644 index 000000000..c6218a3d5 --- /dev/null +++ b/packages/appkit-react/src/components/shared/settings-button/settings-button.stories.tsx @@ -0,0 +1,25 @@ +/** + * 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 { SettingsButton } from './settings-button'; + +const meta: Meta = { + title: 'Components/Shared/SettingsButton', + component: SettingsButton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onClick: () => {}, + }, +}; diff --git a/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx b/packages/appkit-react/src/components/shared/settings-button/settings-button.tsx new file mode 100644 index 000000000..b87a55949 --- /dev/null +++ b/packages/appkit-react/src/components/shared/settings-button/settings-button.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 { ComponentProps, FC } from 'react'; +import clsx from 'clsx'; + +import { Button } from '../../ui/button'; +import { SlidersIcon } from '../../ui/icons'; +import styles from './settings-button.module.css'; + +export interface SettingsButtonProps extends ComponentProps { + onClick?: () => void; +} + +export const SettingsButton: FC = ({ onClick, className, ...props }) => { + return ( + + ); +}; diff --git a/packages/appkit-react/src/components/shared/token-select-modal/index.ts b/packages/appkit-react/src/components/shared/token-select-modal/index.ts new file mode 100644 index 000000000..8188dd7ec --- /dev/null +++ b/packages/appkit-react/src/components/shared/token-select-modal/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { TokenSelectModal } from './token-select-modal'; +export type { TokenSelectModalProps } from './token-select-modal'; diff --git a/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.module.css b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.module.css new file mode 100644 index 000000000..5c051fe7a --- /dev/null +++ b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.module.css @@ -0,0 +1,27 @@ +.searchWrapper { + margin-bottom: 16px; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 16px; + height: 400px; + max-height: 100%; + overflow-y: auto; + flex: 1; +} + +.empty { + padding: 32px 0; +} + +.emptyText { + composes: bodyRegular from "../../../styles/typography.module.css"; + margin: 0; + color: var(--ta-color-text-secondary); + text-align: center; +} diff --git a/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.stories.tsx b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.stories.tsx new file mode 100644 index 000000000..1d965c17a --- /dev/null +++ b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.stories.tsx @@ -0,0 +1,68 @@ +/** + * 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 { STORY_TOKENS } from '../../../storybook/fixtures/tokens'; +import { Button } from '../../ui/button'; +import { TokenSelectModal } from './token-select-modal'; +import type { AppkitUIToken } from '../../../types/appkit-ui-token'; + +const meta: Meta = { + title: 'Components/Shared/TokenSelectModal', + component: TokenSelectModal, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(null); + + return ( + <> + + setOpen(false)} + tokens={STORY_TOKENS} + onSelect={setSelected} + title="Select Token" + searchPlaceholder="Search by name or symbol" + /> + + ); + }, +}; + +export const Empty: Story = { + render: () => { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} + tokens={[]} + onSelect={() => {}} + title="Select Token" + searchPlaceholder="Search by name or symbol" + /> + + ); + }, +}; diff --git a/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx new file mode 100644 index 000000000..0689a6902 --- /dev/null +++ b/packages/appkit-react/src/components/shared/token-select-modal/token-select-modal.tsx @@ -0,0 +1,103 @@ +/** + * 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 { compareAddress } from '@ton/appkit'; + +import { Input } from '../../ui/input/input'; +import { Modal } from '../../ui/modal/modal'; +import { SearchIcon } from '../../ui/icons'; +import { CurrencyItem } from '../../../features/balances'; +import { useI18n } from '../../../features/settings/hooks/use-i18n'; +import type { AppkitUIToken } from '../../../types/appkit-ui-token'; +import styles from './token-select-modal.module.css'; + +export interface TokenSelectModalProps { + open: boolean; + onClose: () => void; + tokens: AppkitUIToken[]; + onSelect: (token: AppkitUIToken) => void; + title: string; + searchPlaceholder?: string; +} + +export const TokenSelectModal: FC = ({ + open, + onClose, + tokens, + onSelect, + title, + searchPlaceholder, +}) => { + const { t } = useI18n(); + const [search, setSearch] = useState(''); + + const filtered = tokens.filter( + (token) => + token.symbol.toLowerCase().includes(search.toLowerCase()) || + token.name.toLowerCase().includes(search.toLowerCase()) || + compareAddress(token.address, search), + ); + + const handleSelect = (token: AppkitUIToken) => () => { + onSelect(token); + onClose(); + setSearch(''); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + onClose(); + setSearch(''); + } + }; + + return ( + + + + + + + setSearch(e.target.value)} + autoFocus + /> + + + +
+ {tokens.length === 0 ? ( +
+

{t('tokenSelect.emptyForNetwork')}

+
+ ) : filtered.length === 0 ? ( +
+

{t('tokenSelect.emptyNoMatch')}

+

{t('tokenSelect.emptyTryAddress')}

+
+ ) : ( +
    + {filtered.map((token) => ( + + ))} +
+ )} +
+
+ ); +}; diff --git a/packages/appkit-react/src/components/ton-icon/ton-icon.stories.tsx b/packages/appkit-react/src/components/ton-icon/ton-icon.stories.tsx deleted file mode 100644 index 068f195be..000000000 --- a/packages/appkit-react/src/components/ton-icon/ton-icon.stories.tsx +++ /dev/null @@ -1,79 +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 { Meta, StoryObj } from '@storybook/react'; - -import { TonIcon, TonIconCircle } from './ton-icon'; - -const meta: Meta = { - title: 'Private/Components/TonIcon', - component: TonIcon, - tags: ['autodocs'], - argTypes: { - size: { - control: { type: 'range', min: 12, max: 64, step: 4 }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - size: 24, - }, -}; - -export const Small: Story = { - args: { - size: 16, - }, -}; - -export const Large: Story = { - args: { - size: 48, - }, -}; - -export const CustomColor: Story = { - args: { - size: 32, - style: { color: '#0098EB' }, - }, -}; - -export const Circle: StoryObj = { - render: (args) => , - args: { - size: 48, - }, -}; - -export const CircleSmall: StoryObj = { - render: (args) => , - args: { - size: 24, - }, -}; - -export const CircleLarge: StoryObj = { - render: (args) => , - args: { - size: 72, - }, -}; diff --git a/packages/appkit-react/src/components/ton-icon/ton-icon.tsx b/packages/appkit-react/src/components/ton-icon/ton-icon.tsx deleted file mode 100644 index d4179ba27..000000000 --- a/packages/appkit-react/src/components/ton-icon/ton-icon.tsx +++ /dev/null @@ -1,46 +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 { FC, ComponentProps } from 'react'; - -export interface TonIconProps extends Omit, 'width' | 'height'> { - size?: number; -} - -export const TonIcon: FC = ({ size = 16, ...props }) => { - return ( - - - - ); -}; - -export const TonIconCircle: FC = ({ size = 16, ...props }) => { - return ( - - - - - - - - - - - - ); -}; diff --git a/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.module.css b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.module.css new file mode 100644 index 000000000..a909d3a19 --- /dev/null +++ b/packages/appkit-react/src/components/ui/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/ui/amount-reversed/amount-reversed.tsx b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.tsx new file mode 100644 index 000000000..ba2911779 --- /dev/null +++ b/packages/appkit-react/src/components/ui/amount-reversed/amount-reversed.tsx @@ -0,0 +1,65 @@ +/** + * 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'; +import { FlipIcon } from '../icons'; + +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/ui/amount-reversed/index.ts b/packages/appkit-react/src/components/ui/amount-reversed/index.ts new file mode 100644 index 000000000..665f5f879 --- /dev/null +++ b/packages/appkit-react/src/components/ui/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/ui/block/block.module.css similarity index 77% rename from packages/appkit-react/src/components/block/block.module.css rename to packages/appkit-react/src/components/ui/block/block.module.css index 5de8b960d..102bbef8c 100644 --- a/packages/appkit-react/src/components/block/block.module.css +++ b/packages/appkit-react/src/components/ui/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/ui/block/block.stories.tsx b/packages/appkit-react/src/components/ui/block/block.stories.tsx new file mode 100644 index 000000000..c54dbba60 --- /dev/null +++ b/packages/appkit-react/src/components/ui/block/block.stories.tsx @@ -0,0 +1,65 @@ +/** + * 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 { Block } from './block'; + +const meta: Meta = { + title: 'Components/UI/Block', + component: Block, + tags: ['autodocs'], + argTypes: { + direction: { + control: 'radio', + options: ['row', 'column'], + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Column: Story = { + args: { + direction: 'column', + children: ( + <> +
+ Item 1 +
+
+ Item 2 +
+
+ Item 3 +
+ + ), + }, +}; + +export const Row: Story = { + args: { + direction: 'row', + children: ( + <> +
+ Item 1 +
+
+ Item 2 +
+
+ Item 3 +
+ + ), + }, +}; diff --git a/packages/appkit-react/src/components/block/block.tsx b/packages/appkit-react/src/components/ui/block/block.tsx similarity index 100% rename from packages/appkit-react/src/components/block/block.tsx rename to packages/appkit-react/src/components/ui/block/block.tsx diff --git a/packages/appkit-react/src/components/block/index.ts b/packages/appkit-react/src/components/ui/block/index.ts similarity index 100% rename from packages/appkit-react/src/components/block/index.ts rename to packages/appkit-react/src/components/ui/block/index.ts diff --git a/packages/appkit-react/src/components/ui/button/button.module.css b/packages/appkit-react/src/components/ui/button/button.module.css new file mode 100644 index 000000000..028fde668 --- /dev/null +++ b/packages/appkit-react/src/components/ui/button/button.module.css @@ -0,0 +1,131 @@ +.button { + appearance: none; + border: none; + outline: none; + cursor: pointer; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: opacity 0.15s ease-in-out; + text-decoration: none; + width: fit-content; +} + +.button:hover:not(:disabled):not(.loading) { + opacity: 0.85; +} + +.button:active:not(:disabled):not(.loading) { + opacity: 0.65; +} + +.button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.loading { + cursor: wait; +} + +/* Sizes */ +.l { + composes: bodySemibold from "../../../styles/typography.module.css"; + padding: 12px 16px; +} + +.m { + composes: labelSemibold from "../../../styles/typography.module.css"; + padding: 12px 16px; +} + +.s { + composes: labelSemibold from "../../../styles/typography.module.css"; + padding: 9px 12px; +} + +/* Border radius */ +.radiusS { + border-radius: var(--ta-border-radius-s); +} + +.radiusM { + border-radius: var(--ta-border-radius-m); +} + +.radiusL { + border-radius: var(--ta-border-radius-l); +} + +.radiusXl { + border-radius: var(--ta-border-radius-xl); +} + +.radius2xl { + border-radius: var(--ta-border-radius-2xl); +} + +.radiusFull { + border-radius: var(--ta-border-radius-full); +} + +/* Variants */ +.fill { + background-color: var(--ta-color-primary); + color: var(--ta-color-primary-foreground); +} + +.secondary { + background-color: var(--ta-color-background-secondary); + color: var(--ta-color-text); +} + +.bezeled { + background-color: var(--ta-color-background-bezeled); + color: var(--ta-color-primary); +} + +.gray { + background-color: var(--ta-color-background-tertiary); + color: var(--ta-color-text); +} + +.ghost { + background-color: transparent; + color: var(--ta-color-text); +} + +.ghost:hover:not(:disabled):not(.loading) { + background-color: var(--ta-color-background-tertiary); +} + +.fullWidth { + width: 100%; +} + +.innerIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.spinner { + width: 18px; + height: 18px; + border: 2.5px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: button-spin 0.6s linear infinite; +} + +@keyframes button-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/packages/appkit-react/src/components/ui/button/button.stories.tsx b/packages/appkit-react/src/components/ui/button/button.stories.tsx new file mode 100644 index 000000000..67539ef1a --- /dev/null +++ b/packages/appkit-react/src/components/ui/button/button.stories.tsx @@ -0,0 +1,121 @@ +/** + * 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 { Button } from './button'; + +const meta: Meta = { + title: 'Components/UI/Button', + component: Button, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['s', 'm', 'l', 'unset'], + }, + borderRadius: { + control: 'select', + options: ['s', 'm', 'l', 'xl', '2xl', 'full'], + }, + variant: { + control: 'select', + options: ['fill', 'secondary', 'bezeled', 'gray', 'ghost', 'unstyled'], + }, + disabled: { + control: 'boolean', + }, + loading: { + control: 'boolean', + }, + fullWidth: { + control: 'boolean', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Fill: Story = { + args: { + children: 'Action', + variant: 'fill', + size: 'l', + }, +}; + +export const Bezeled: Story = { + args: { + children: 'Action', + variant: 'bezeled', + size: 'l', + }, +}; + +export const Gray: Story = { + args: { + children: 'Action', + variant: 'gray', + size: 'l', + }, +}; + +export const Sizes: Story = { + render: (args) => ( +
+ + + +
+ ), + args: { + variant: 'fill', + }, +}; + +export const Variants: Story = { + render: (args) => ( +
+ + + +
+ ), + args: { + size: 'l', + }, +}; + +export const Loading: Story = { + args: { + children: 'Loading Button', + loading: true, + }, +}; + +export const Unstyled: Story = { + args: { + children: 'Bare button', + variant: 'unstyled', + size: 'unset', + }, +}; diff --git a/packages/appkit-react/src/components/ui/button/button.tsx b/packages/appkit-react/src/components/ui/button/button.tsx new file mode 100644 index 000000000..5b868b76d --- /dev/null +++ b/packages/appkit-react/src/components/ui/button/button.tsx @@ -0,0 +1,99 @@ +/** + * 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 { forwardRef } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; +import clsx from 'clsx'; + +import styles from './button.module.css'; + +export type ButtonSize = 's' | 'm' | 'l' | 'unset'; +export type ButtonBorderRadius = 's' | 'm' | 'l' | 'xl' | '2xl' | 'full'; +export type ButtonVariant = 'fill' | 'secondary' | 'bezeled' | 'gray' | 'ghost' | 'unstyled'; + +export interface ButtonProps extends ComponentProps<'button'> { + /** + * Size class applied to the button. Pass `'unset'` to skip the size class + * entirely (no padding, no typography) — useful with `variant="unstyled"`. + */ + size?: ButtonSize; + borderRadius?: ButtonBorderRadius; + /** + * Visual variant. Use `'unstyled'` to opt out of all built-in styling — + * the consumer is fully responsible for visuals via `className`. The + * Button still provides ref forwarding, `disabled`/`loading` plumbing, + * and `icon`/`children` rendering. + */ + variant?: ButtonVariant; + loading?: boolean; + fullWidth?: boolean; + icon?: ReactNode; +} + +const SIZE_DEFAULT_RADIUS: Record, ButtonBorderRadius> = { + s: '2xl', + m: 'l', + l: 'xl', +}; + +const RADIUS_CLASS: Record = { + s: 'radiusS', + m: 'radiusM', + l: 'radiusL', + xl: 'radiusXl', + '2xl': 'radius2xl', + full: 'radiusFull', +}; + +export const Button = forwardRef( + ( + { + className, + size = 'm', + borderRadius, + variant = 'fill', + loading = false, + fullWidth = false, + disabled, + icon, + children, + ...props + }, + ref, + ) => { + const radius = borderRadius ?? (size === 'unset' ? undefined : SIZE_DEFAULT_RADIUS[size]); + + return ( + + ); + }, +); + +Button.displayName = 'Button'; diff --git a/packages/appkit-react/src/components/ui/button/index.ts b/packages/appkit-react/src/components/ui/button/index.ts new file mode 100644 index 000000000..628283402 --- /dev/null +++ b/packages/appkit-react/src/components/ui/button/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 { Button } from './button'; +export type { ButtonProps } from './button'; diff --git a/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.module.css new file mode 100644 index 000000000..f2dece67a --- /dev/null +++ b/packages/appkit-react/src/components/ui/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/ui/centered-amount-input/centered-amount-input.stories.tsx b/packages/appkit-react/src/components/ui/centered-amount-input/centered-amount-input.stories.tsx new file mode 100644 index 000000000..83ac3b280 --- /dev/null +++ b/packages/appkit-react/src/components/ui/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: 'Components/UI/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: () =>