diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 000000000..61df3717a --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,103 @@ +name: Deploy Demo + +on: + push: + branches: + - '**' + delete: + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: pages-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + build-and-deploy: + if: github.event_name != 'delete' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Set environment variables + env: + BRANCH: ${{ github.ref_name }} + REPO_NAME: ${{ github.event.repository.name }} + OWNER: ${{ github.repository_owner }} + run: | + # Base path: /repo/ for main, /repo/branch/ for other branches + if [ "$BRANCH" = "main" ]; then + BASE_PATH="/${REPO_NAME}/" + DEST_DIR="." + else + BASE_PATH="/${REPO_NAME}/${BRANCH}/" + DEST_DIR="${BRANCH}" + fi + + # Full URL for manifest + APP_URL="https://${OWNER}.github.io${BASE_PATH}" + + echo "VITE_BASE_PATH=${BASE_PATH}" >> $GITHUB_ENV + echo "VITE_APP_URL=${APP_URL}" >> $GITHUB_ENV + echo "DEST_DIR=${DEST_DIR}" >> $GITHUB_ENV + + echo "Building for: ${APP_URL}" + echo "Destination: ${DEST_DIR}" + + - name: Build demo + run: pnpm build --filter tonconnect-demo + env: + VITE_APP_URL: ${{ env.VITE_APP_URL }} + VITE_BASE_PATH: ${{ env.VITE_BASE_PATH }} + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./apps/tonconnect-demo/dist + destination_dir: ${{ env.DEST_DIR }} + keep_files: true + + cleanup: + if: github.event_name == 'delete' && github.event.ref_type == 'branch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: gh-pages + continue-on-error: true + + - name: Remove stale deployment + env: + BRANCH: ${{ github.event.ref }} + run: | + # Skip if gh-pages doesn't exist or branch dir doesn't exist + if [ ! -d "$BRANCH" ]; then + echo "No deployment found for branch: $BRANCH" + exit 0 + fi + + # Don't delete root + if [ "$BRANCH" = "." ] || [ "$BRANCH" = "" ]; then + echo "Skipping invalid branch name" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + rm -rf "$BRANCH" + git add -A + git commit -m "Cleanup: remove deployment for deleted branch $BRANCH" || echo "No changes to commit" + git push origin gh-pages diff --git a/apps/tonconnect-demo/.gitignore b/apps/tonconnect-demo/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/apps/tonconnect-demo/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/tonconnect-demo/README.md b/apps/tonconnect-demo/README.md new file mode 100644 index 000000000..fa297a4fc --- /dev/null +++ b/apps/tonconnect-demo/README.md @@ -0,0 +1,84 @@ +# TonConnect Demo + +A demo application for testing and demonstrating TonConnect wallet integration features. + +## Features + +- **Transaction**: Send transactions with customizable parameters +- **Sign Data**: Test data signing capabilities (text, cell, binary) +- **Subscription**: Manage wallet subscriptions +- **Ton Proof**: Authenticate using TON Proof +- **Settings**: Configure TonConnect UI appearance and behavior + +## Getting Started + +```bash +# Install dependencies +pnpm install + +# Start development server +pnpm dev --filter tonconnect-demo + +# Build for production +VITE_APP_URL=https://your-domain.com pnpm build --filter tonconnect-demo +``` + +## Deployment + +The `tonconnect-manifest.json` is generated at build time based on the `VITE_APP_URL` environment variable. + +```bash +# Local build +VITE_APP_URL=https://your-domain.com pnpm build --filter tonconnect-demo + +# Or set in CI/CD environment variables (Vercel, GitHub Actions, etc.) +``` + +If `VITE_APP_URL` is not set, it defaults to `http://localhost:5173`. + +## DevTools + +The demo includes hidden developer tools for testing and debugging. + +### How to Activate + +1. Click on the "TonConnect Demo" title **5 times quickly** (within 2 seconds) +2. A toast notification will confirm "DevTools unlocked!" +3. A new "DevTools" tab will appear + +### Available Features + +#### QA Mode + +Enables testing mode from `@tonconnect/sdk`: + +- Disables strict validations (errors become console warnings) +- Allows cross-network transactions (e.g., mainnet tx when wallet is on testnet) +- Uses staging wallets list instead of production +- Shows injected wallets in the list + +**Note**: Changing QA Mode requires a page reload to take full effect. + +#### Mobile Console (Eruda) + +Enables [Eruda](https://github.com/liriliri/eruda) - a mobile-friendly developer console: + +- Console logs viewer +- Network requests inspector +- DOM elements explorer +- Storage viewer (localStorage, sessionStorage, cookies) + +Useful for debugging on mobile devices where browser DevTools are not available. + +### Hiding DevTools + +Click "Lock DevTools" button in the DevTools tab to hide it again. You can always re-activate it using the secret tap. + +## Tech Stack + +- React 19 +- TypeScript +- Vite +- Tailwind CSS +- shadcn/ui components +- MSW (Mock Service Worker) for API mocking diff --git a/apps/tonconnect-demo/components.json b/apps/tonconnect-demo/components.json new file mode 100644 index 000000000..1537d5030 --- /dev/null +++ b/apps/tonconnect-demo/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/tonconnect-demo/eslint.config.js b/apps/tonconnect-demo/eslint.config.js new file mode 100644 index 000000000..38d3b9d55 --- /dev/null +++ b/apps/tonconnect-demo/eslint.config.js @@ -0,0 +1,43 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + // React Compiler rules are too strict for demo app patterns + 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/preserve-manual-memoization': 'off', + }, + }, + // shadcn/ui components - allow non-component exports and empty interfaces + { + files: ['src/components/ui/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + }, + }, + // Context files - allow exporting context + provider together + { + files: ['src/context/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, +]) diff --git a/apps/tonconnect-demo/index.html b/apps/tonconnect-demo/index.html new file mode 100644 index 000000000..dfa141770 --- /dev/null +++ b/apps/tonconnect-demo/index.html @@ -0,0 +1,22 @@ + + + + + + + + TonConnect Demo + + + +
+ + + diff --git a/apps/tonconnect-demo/package.json b/apps/tonconnect-demo/package.json new file mode 100644 index 000000000..9765cee86 --- /dev/null +++ b/apps/tonconnect-demo/package.json @@ -0,0 +1,82 @@ +{ + "name": "tonconnect-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/language": "^6.12.1", + "@codemirror/view": "^6.39.11", + "@hookform/resolvers": "^5.2.2", + "@lezer/highlight": "^1.2.3", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.18", + "@ton/core": "^0.62.1", + "@ton/crypto": "3.3.0", + "@ton/ton": "^16.1.0", + "@tonconnect/sdk": "workspace:*", + "@tonconnect/ui-react": "workspace:*", + "@uiw/react-codemirror": "^4.25.4", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "autoprefixer": "^10.4.23", + "buffer": "^6.0.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "crc-32": "^1.2.2", + "eruda": "^3.4.3", + "jose": "^6.1.3", + "lucide-react": "^0.562.0", + "msw": "^2.12.7", + "next-themes": "^0.4.6", + "postcss": "^8.5.6", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tailwindcss-animate": "^1.0.7", + "tweetnacl": "^1.0.3", + "yaml": "^2.8.2", + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.8", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + }, + "msw": { + "workerDirectory": [ + "public" + ] + } +} \ No newline at end of file diff --git a/apps/tonconnect-demo/public/favicon.png b/apps/tonconnect-demo/public/favicon.png new file mode 100644 index 000000000..0f2afc34e Binary files /dev/null and b/apps/tonconnect-demo/public/favicon.png differ diff --git a/apps/tonconnect-demo/public/mockServiceWorker.js b/apps/tonconnect-demo/public/mockServiceWorker.js new file mode 100644 index 000000000..9f6b16be1 --- /dev/null +++ b/apps/tonconnect-demo/public/mockServiceWorker.js @@ -0,0 +1,360 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.7' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Bypass event source requests (TonConnect SSE). + const url = new URL(event.request.url) + if (url.pathname.endsWith('/events')) { + return + } + + // Bypass cross-origin requests except /api/* + if (url.origin !== location.origin && !url.pathname.startsWith('/api')) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/tonconnect-demo/public/privacy-policy.txt b/apps/tonconnect-demo/public/privacy-policy.txt new file mode 100644 index 000000000..72e27fee3 --- /dev/null +++ b/apps/tonconnect-demo/public/privacy-policy.txt @@ -0,0 +1,10 @@ +This is a demonstration file for the TonConnect manifest. + +In a real application, this file may contain your Privacy Policy, for example: +- What data you collect +- How you use and store this data +- User rights +- etc. + +The privacyPolicyUrl field in tonconnect-manifest.json points to this file. +Wallets may display this link to users during connection. diff --git a/apps/tonconnect-demo/public/terms-of-use.txt b/apps/tonconnect-demo/public/terms-of-use.txt new file mode 100644 index 000000000..578007bd4 --- /dev/null +++ b/apps/tonconnect-demo/public/terms-of-use.txt @@ -0,0 +1,10 @@ +This is a demonstration file for the TonConnect manifest. + +In a real application, this file may contain your Terms of Use, for example: +- Service description and usage rules +- User responsibilities +- Liability limitations +- etc. + +The termsOfUseUrl field in tonconnect-manifest.json points to this file. +Wallets may display this link to users during connection. diff --git a/apps/tonconnect-demo/src/App.tsx b/apps/tonconnect-demo/src/App.tsx new file mode 100644 index 000000000..1c7d9cfe5 --- /dev/null +++ b/apps/tonconnect-demo/src/App.tsx @@ -0,0 +1,15 @@ +import { TonConnectUIProvider } from '@tonconnect/ui-react' +import { DevToolsProvider } from '@/context/DevToolsContext' +import { DemoContent } from '@/components/DemoContent' + +function App() { + return ( + + + + + + ) +} + +export default App diff --git a/apps/tonconnect-demo/src/components/DemoContent.tsx b/apps/tonconnect-demo/src/components/DemoContent.tsx new file mode 100644 index 000000000..e8c234e43 --- /dev/null +++ b/apps/tonconnect-demo/src/components/DemoContent.tsx @@ -0,0 +1,94 @@ +import { useMemo } from "react" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Toaster } from "@/components/ui/sonner" +import { SettingsProvider } from "@/context/SettingsContext" +import { useDevToolsContext } from "@/context/DevToolsContext" +import { useHashTab } from "@/hooks/useHashTab" +import { useSdkLogs } from "@/hooks/useSdkLogs" +import { Header } from "./Header" +import { RpcLogViewer } from "./shared/RpcLogViewer" +import { TransactionTab, SignDataTab, SubscriptionTab, ConnectTab, SettingsTab, DevToolsTab } from "./tabs" + +const ALL_TABS = ["transaction", "sign", "subscription", "connect", "settings", "devtools"] as const +const PUBLIC_TABS = ALL_TABS.filter(t => t !== "devtools") +const DEFAULT_TAB = "transaction" + +function DemoContentInner() { + const { isUnlocked, rpcLogsEnabled } = useDevToolsContext() + + // SDK RPC logs (only active when toggle is enabled) + const { logs, clearLogs } = useSdkLogs(rpcLogsEnabled) + + // Valid tabs depend on DevTools unlock state + const validTabs = useMemo( + () => (isUnlocked ? [...ALL_TABS] : [...PUBLIC_TABS]), + [isUnlocked] + ) + + // Sync tab with URL hash, auto-redirect if tab becomes invalid + const [tab, setTab] = useHashTab(validTabs, DEFAULT_TAB) + + return ( +
+
+ +
+ + + Transaction + Sign Data + Subscription + Connect + Settings + {isUnlocked && ( + DevTools + )} + + + + + + + + + + + + + + + + + + + + + + + {isUnlocked && ( + + + + )} + +
+ + {/* RPC Logs footer - visible on all tabs when enabled */} + {rpcLogsEnabled && ( +
+ +
+ )} + + +
+ ) +} + +export function DemoContent() { + return ( + + + + ) +} diff --git a/apps/tonconnect-demo/src/components/Header.tsx b/apps/tonconnect-demo/src/components/Header.tsx new file mode 100644 index 000000000..7d9a1ed5b --- /dev/null +++ b/apps/tonconnect-demo/src/components/Header.tsx @@ -0,0 +1,103 @@ +import { TonConnectButton } from "@tonconnect/ui-react" +import { useSettingsContext } from "@/context/SettingsContext" +import { useDevToolsContext } from "@/context/DevToolsContext" +import { Monitor, Sun, Moon } from "lucide-react" +import { useRef, useCallback } from "react" +import { toast } from "sonner" +import type { ThemeOption } from "@/hooks/useSettings" + +const SECRET_TAP_COUNT = 5 +const SECRET_TAP_TIMEOUT = 2000 // 2 seconds window for taps + +export function Header() { + const { theme, setTheme } = useSettingsContext() + const { isUnlocked, unlockDevTools } = useDevToolsContext() + + const tapCountRef = useRef(0) + const tapTimeoutRef = useRef | null>(null) + + const handleTitleClick = useCallback(() => { + if (isUnlocked) return // Already unlocked + + tapCountRef.current += 1 + + // Clear existing timeout + if (tapTimeoutRef.current) { + clearTimeout(tapTimeoutRef.current) + } + + // Check if reached required taps + if (tapCountRef.current >= SECRET_TAP_COUNT) { + tapCountRef.current = 0 + unlockDevTools() + toast.success("DevTools unlocked!", { + description: "Check the DevTools tab to configure debug options" + }) + return + } + + // Reset counter after timeout + tapTimeoutRef.current = setTimeout(() => { + tapCountRef.current = 0 + }, SECRET_TAP_TIMEOUT) + }, [isUnlocked, unlockDevTools]) + + const themes: { value: ThemeOption; icon: typeof Monitor; label: string }[] = [ + { value: "system", icon: Monitor, label: "System" }, + { value: "light", icon: Sun, label: "Light" }, + { value: "dark", icon: Moon, label: "Dark" }, + ] + + const currentThemeIndex = themes.findIndex(t => t.value === theme) + const CurrentIcon = themes[currentThemeIndex]?.icon || Monitor + + const cycleTheme = useCallback(() => { + const nextIndex = (currentThemeIndex + 1) % themes.length + setTheme(themes[nextIndex].value) + }, [currentThemeIndex, setTheme]) + + return ( +
+
+
+

+ TonConnect Demo +

+

+ Test and demonstrate wallet integration +

+
+
+ {/* Theme toggle - single button on mobile, segmented on desktop */} + +
+ {themes.map(({ value, icon: Icon, label }) => ( + + ))} +
+ +
+
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/NetworkPicker.tsx b/apps/tonconnect-demo/src/components/NetworkPicker.tsx new file mode 100644 index 000000000..9dc8e252a --- /dev/null +++ b/apps/tonconnect-demo/src/components/NetworkPicker.tsx @@ -0,0 +1,67 @@ +import { useEffect } from "react" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Label } from "@/components/ui/label" +import { useSettingsContext } from "@/context/SettingsContext" +import { useTonConnectUI, useTonWallet } from "@tonconnect/ui-react" + +function getNetworkLabel(chain: string): string { + switch (chain) { + case "-239": return "Mainnet" + case "-3": return "Testnet" + default: return chain + } +} + +export function NetworkPicker() { + const { selectedNetwork, setSelectedNetwork } = useSettingsContext() + const [tonConnectUI] = useTonConnectUI() + const wallet = useTonWallet() + + const isConnected = !!wallet + const walletNetwork = wallet?.account?.chain + + // Sync selected network with TonConnect SDK (only when not connected) + useEffect(() => { + if (!isConnected) { + const chainId = selectedNetwork || undefined + tonConnectUI.setConnectionNetwork(chainId) + } + }, [selectedNetwork, tonConnectUI, isConnected]) + + // When connected, show wallet's network + // When not connected, show selected network or "any" + const displayValue = isConnected && walletNetwork ? walletNetwork : (selectedNetwork || "any") + const handleChange = (v: string) => setSelectedNetwork(v === "any" ? "" : v) + + return ( +
+ + + {isConnected && ( +

+ Network is determined by connected wallet +

+ )} +
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/BaseResultCard.tsx b/apps/tonconnect-demo/src/components/shared/BaseResultCard.tsx new file mode 100644 index 000000000..da321e0da --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/BaseResultCard.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from "react" +import { Button } from "@/components/ui/button" +import { JsonViewer } from "./JsonViewer" +import { X, RotateCcw } from "lucide-react" + +/** Base result data that all result types share */ +export interface BaseResultData { + id: string + timestamp: number + requestSnapshot: string + response: string + status: "success" | "error" +} + +/** JsonViewer configuration options */ +export interface JsonViewerConfig { + maxHeight?: number + defaultExpanded?: boolean + inlineThreshold?: number +} + +/** Props for BaseResultCard */ +export interface BaseResultCardProps { + result: BaseResultData + statusBar: ReactNode + responseFooter?: ReactNode + onDismiss?: () => void + onLoadToForm?: () => void + requestViewerProps?: JsonViewerConfig + responseViewerProps?: JsonViewerConfig +} + +export function BaseResultCard({ + result, + statusBar, + responseFooter, + onDismiss, + onLoadToForm, + requestViewerProps = {}, + responseViewerProps = {}, +}: BaseResultCardProps) { + return ( +
+ {/* Header: timestamp + dismiss */} +
+ + {new Date(result.timestamp).toLocaleTimeString()} + + {onDismiss && ( + + )} +
+ + {/* Status Bar */} + {statusBar} + + {/* Content: 2-column layout on desktop */} +
+ {/* Left column: Request Sent */} +
+ +
+ + {/* Right column: Response + optional footer */} +
+ + {responseFooter} +
+
+ + {/* Footer: Load to form button */} + {onLoadToForm && ( +
+ +
+ )} +
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/ConnectedWalletCard.tsx b/apps/tonconnect-demo/src/components/shared/ConnectedWalletCard.tsx new file mode 100644 index 000000000..99d13f486 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/ConnectedWalletCard.tsx @@ -0,0 +1,260 @@ +import { useState, useMemo } from "react" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { JsonViewer } from "./JsonViewer" +import { Circle, Unplug, ChevronRight, Copy, Check, Smartphone, Monitor, Package, Link } from "lucide-react" +import { toast } from "sonner" +import { toUserFriendlyAddress, CHAIN, type Feature } from "@tonconnect/sdk" +import type { Wallet } from "@tonconnect/ui-react" + +interface ConnectedWalletCardProps { + wallet: Wallet | null + isAuthenticated?: boolean // kept for API compatibility, not used in UI + onDisconnect: () => void +} + +function getNetworkName(chain: string): string { + return chain === '-239' ? 'Mainnet' : 'Testnet' +} + +function getPlatformIcon(platform: string) { + if (['iphone', 'ipad', 'android'].includes(platform)) { + return + } + return +} + +// Deduplicate and format features +function processFeatures(features: Feature[]): string[] { + const seen = new Map() + + for (const feature of features) { + if (typeof feature === 'string') { + if (!seen.has(feature)) { + seen.set(feature, feature) + } + } else { + const f = feature as { name: string; maxMessages?: number; extraCurrencySupported?: boolean; types?: string[] } + let formatted = f.name + + if (f.name === 'SendTransaction') { + const details: string[] = [] + if (f.maxMessages) details.push(`${f.maxMessages}`) + if (f.extraCurrencySupported) details.push('extra') + formatted = details.length > 0 ? `SendTx(${details.join(',')})` : 'SendTx' + } else if (f.name === 'SignData' && f.types) { + const types = f.types.map(t => t === 'binary' ? 'bin' : t) + formatted = `SignData(${types.join(',')})` + } + + seen.set(f.name, formatted) + } + } + + return Array.from(seen.values()) +} + +// Truncate address showing start and end (e.g., "UQDa...bfef") +function truncateAddress(address: string, startChars = 8, endChars = 4): string { + if (address.length <= startChars + endChars + 3) return address + return `${address.slice(0, startChars)}...${address.slice(-endChars)}` +} + +function CopyButton({ text, label = "Copied" }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + toast.success(label) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +export function ConnectedWalletCard({ wallet, onDisconnect }: ConnectedWalletCardProps) { + const isConnected = !!wallet + const hasProof = wallet?.connectItems?.tonProof && !('error' in wallet.connectItems.tonProof) + + const friendlyAddress = isConnected + ? toUserFriendlyAddress(wallet.account.address, wallet.account.chain === CHAIN.TESTNET) + : '' + + const deviceFeatures = wallet?.device.features + const features = useMemo(() => { + if (!deviceFeatures) return [] + return processFeatures(deviceFeatures) + }, [deviceFeatures]) + + // Responsive address display + const shortAddress = truncateAddress(friendlyAddress, 10, 6) + const mediumAddress = truncateAddress(friendlyAddress, 16, 8) + + return ( + + + {isConnected ? ( + <> + {/* Row 1: Status + Info (left) | Disconnect (right, fixed) */} +
+ {/* Left side - wraps as needed */} +
+ {/* Status */} +
+ + Connected +
+ + {/* Address - truncated with visible end */} +
+ + {shortAddress} + {mediumAddress} + {friendlyAddress} + + +
+ + {/* Network + TonProof (wrap together) */} +
+ + {getNetworkName(wallet.account.chain)} + + {hasProof && ( + + TonProof + + )} +
+
+ + {/* Right side - Disconnect ALWAYS top-right */} + +
+ + {/* Row 2: Wallet details + Features + Raw toggle */} + + {/* Wallet info row - with Features inline on lg+ */} +
+
+ {/* Wallet name */} + {wallet.device.appName} + + + {/* Platform */} +
+ {getPlatformIcon(wallet.device.platform)} + Platform: + {wallet.device.platform} +
+ + + {/* Version */} +
+ + Version: + {wallet.device.appVersion} +
+ + + {/* Provider */} +
+ + Provider: + {wallet.provider} +
+ + {/* Features - inline on lg+ */} + {features.length > 0 && ( + <> + +
+ Features: + {features.map((feature, i) => ( + + {feature} + + ))} +
+ + )} +
+ + {/* Raw toggle - on lg+ stays on this row, right side */} + + + Raw + +
+ + {/* Features + Raw toggle row - ONLY on smaller screens (< lg) */} +
+ {/* Features */} + {features.length > 0 ? ( +
+ Features: + {features.map((feature, i) => ( + + {feature} + + ))} +
+ ) : ( +
+ )} + + {/* Raw toggle */} + + + Raw + +
+ + {/* Raw JSON content */} + + + + + + ) : ( +
+ + + No wallet connected — use the options below + +
+ )} + + + ) +} diff --git a/apps/tonconnect-demo/src/components/shared/ConnectionEventsCard.tsx b/apps/tonconnect-demo/src/components/shared/ConnectionEventsCard.tsx new file mode 100644 index 000000000..0d25440a7 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/ConnectionEventsCard.tsx @@ -0,0 +1,549 @@ +import { useState, useMemo } from "react" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { JsonViewer } from "./JsonViewer" +import { + ChevronRight, + ChevronDown, + Plug, + Unplug, + RefreshCw, + XCircle, + Copy, + Trash2, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import type { ConnectionOperation } from "@/types/connection-events" +import { + formatOperationTime, + formatAddress, + formatChainName, + formatPlatformName, + formatProvider, + formatProofTimestamp, + processFeatures, +} from "@/utils/connection-formatters" + +// ============ SECTION CONFIG ============ + +const SECTION_CONFIG = { + connect: { + showRequested: true, + responseTitle: 'WALLET RESPONSE' + }, + disconnect: { + showRequested: false, + responseTitle: 'DISCONNECT EVENT' + }, + restore_session: { + showRequested: false, + responseTitle: 'RESTORED SESSION' + } +} as const + +// ============ HELPER COMPONENTS ============ + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ) +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation() + try { + await navigator.clipboard.writeText(text) + toast.success(`${label} copied`) + } catch { + toast.error("Failed to copy") + } + } + + return ( + + ) +} + +// ============ RAW DATA SECTION ============ + +interface RawDataSectionProps { + request?: unknown + response?: unknown +} + +function RawDataSection({ request, response }: RawDataSectionProps) { + const [tab, setTab] = useState<'request' | 'response' | 'both'>('both') + + const hasRequest = request !== undefined && request !== null + const hasResponse = response !== undefined && response !== null + + if (!hasRequest && !hasResponse) return null + + const getContent = () => { + if (tab === 'request') return JSON.stringify(request, null, 2) + if (tab === 'response') return JSON.stringify(response, null, 2) + return JSON.stringify({ request, response }, null, 2) + } + + const countLines = (obj: unknown) => JSON.stringify(obj, null, 2).split('\n').length + + const handleCopyAll = async () => { + try { + await navigator.clipboard.writeText(getContent()) + toast.success("Copied to clipboard") + } catch { + toast.error("Failed to copy") + } + } + + return ( +
+
+ {/* Tab buttons */} +
+
+ {hasRequest && ( + + )} + {hasResponse && ( + + )} + {hasRequest && hasResponse && ( + + )} +
+ +
+ + +
+
+ ) +} + +// ============ OPERATION ROW ============ + +interface OperationRowProps { + operation: ConnectionOperation + onDelete?: () => void +} + +function OperationRow({ operation, onDelete }: OperationRowProps) { + const [expanded, setExpanded] = useState(false) + + const isConnect = operation.type === 'connect' + const isDisconnect = operation.type === 'disconnect' + const isSuccess = operation.response?.success !== false + const hasError = operation.response?.error + const wallet = operation.response?.wallet + + // Check if TonProof data exists in response (more reliable than checking request) + const hasTonProofData = !!wallet?.tonProof + + const config = SECTION_CONFIG[operation.type] + + // Format features using the shared utility + const features = wallet?.device.features + const formattedFeatures = useMemo(() => { + if (!features) return [] + return processFeatures(features) + }, [features]) + + // Determine icon and color + let Icon = Plug + let iconColor = 'text-green-500' + let labelColor = 'text-green-600 dark:text-green-400' + + if (isDisconnect) { + Icon = Unplug + iconColor = 'text-muted-foreground' + labelColor = 'text-muted-foreground' + } else if (hasError) { + Icon = XCircle + iconColor = 'text-red-500' + labelColor = 'text-red-600 dark:text-red-400' + } else if (operation.type === 'restore_session') { + Icon = RefreshCw + iconColor = 'text-blue-500' + labelColor = 'text-blue-600 dark:text-blue-400' + } + + return ( +
+ {/* Collapsed Header */} + + + {/* Expanded Content - VERTICAL LAYOUT */} + {expanded && ( +
+ + {/* REQUESTED Section - only for connect */} + {config.showRequested && operation.request && ( +
+
+
+ Items: + {operation.request.items.join(', ')} +
+ {operation.request.payload && ( +
+ Challenge: + + {operation.request.payload} + + +
+ )} +
+
+ )} + + {/* RESPONSE Section with adaptive title */} +
+ {hasError ? ( + // Error state +
+
+ + Error +
+
+ Code: + {operation.response!.error!.code} + Message: + {operation.response!.error!.message} +
+
+ ) : wallet ? ( + // Success - 3 column grid +
+ {/* Account Column */} +
+
Account
+
+
+ Address +
+ + {formatAddress(wallet.account.address, 10, 8)} + + +
+
+
+ Network + + {formatChainName(wallet.account.chain)} + +
+ {wallet.account.publicKey && ( +
+ Public Key +
+ + {formatAddress(wallet.account.publicKey, 10, 8)} + + +
+
+ )} +
+
+ + {/* Device Column */} +
+
Device
+
+
+ Wallet + {wallet.device.appName} {wallet.device.appVersion} +
+
+ Platform + {formatPlatformName(wallet.device.platform)} +
+
+ Provider + + {formatProvider(wallet.provider)} + +
+ {formattedFeatures.length > 0 && ( +
+ Features +
+ {formattedFeatures.map((f, i) => ( + + {f} + + ))} +
+
+ )} +
+
+ + {/* TonProof Column - only if TonProof data exists */} + {hasTonProofData && ( +
+
TonProof
+ {wallet.tonProof ? ( +
+
+ Domain + + {wallet.tonProof.domain} + +
+
+ Timestamp + + {formatProofTimestamp(wallet.tonProof.timestamp)} + +
+
+ Signature +
+ + {formatAddress(wallet.tonProof.signature, 10, 8)} + + +
+
+
+ ) : ( +

+ Requested but not provided +

+ )} +
+ )} +
+ ) : isDisconnect ? ( + // Disconnect info +
+
+ Initiated by + + {operation.initiator || 'wallet'} + + {operation.previousWalletName && ( + <> + Wallet was + {operation.previousWalletName} + + )} + {operation.previousAddress && ( + <> + Address was + + {formatAddress(operation.previousAddress, 8, 6)} + + + )} +
+
+ ) : ( +

No response data

+ )} +
+ + {/* RAW DATA Section */} + + + {/* Delete button */} + {onDelete && ( +
+ +
+ )} +
+ )} +
+ ) +} + +// ============ MAIN COMPONENT ============ + +interface ConnectionEventsCardProps { + operations: ConnectionOperation[] + onClear?: () => void + onDelete?: (id: string) => void +} + +export function ConnectionEventsCard({ + operations, + onClear, + onDelete, +}: ConnectionEventsCardProps) { + const [isOpen, setIsOpen] = useState(false) + + if (operations.length === 0) return null + + return ( + + {/* Header */} +
+ + + Connection Events + ({operations.length}) + + + {onClear && ( + + )} +
+ + +
+ {operations.map((op) => ( + onDelete(op.id) : undefined} + /> + ))} +
+
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/FieldInfoModal.tsx b/apps/tonconnect-demo/src/components/shared/FieldInfoModal.tsx new file mode 100644 index 000000000..9864a738e --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/FieldInfoModal.tsx @@ -0,0 +1,48 @@ +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import type { FieldInfo } from "@/data/field-info" + +interface FieldInfoModalProps { + open: boolean + onOpenChange: (open: boolean) => void + info: FieldInfo +} + +export function FieldInfoModal({ open, onOpenChange, info }: FieldInfoModalProps) { + return ( + + + + {info.name} +

{info.summary}

+
+ +
+ {info.content} +
+ + {info.links && info.links.length > 0 && ( +
+ {info.links.map((link, index) => ( + + {link.title} ↗ + + ))} +
+ )} +
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/FieldLabel.tsx b/apps/tonconnect-demo/src/components/shared/FieldLabel.tsx new file mode 100644 index 000000000..b0ef822d3 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/FieldLabel.tsx @@ -0,0 +1,35 @@ +import { useState, type ReactNode } from "react" +import { Label } from "@/components/ui/label" +import { FieldInfoModal } from "./FieldInfoModal" +import { getFieldInfo } from "@/data/field-info" +import { Info } from "lucide-react" + +interface FieldLabelProps { + htmlFor?: string + fieldId: string + children: ReactNode + className?: string +} + +export function FieldLabel({ htmlFor, fieldId, children, className }: FieldLabelProps) { + const [open, setOpen] = useState(false) + const info = getFieldInfo(fieldId) + + return ( + <> +
+ + {info && ( + + )} +
+ {info && } + + ) +} diff --git a/apps/tonconnect-demo/src/components/shared/FormContainer.tsx b/apps/tonconnect-demo/src/components/shared/FormContainer.tsx new file mode 100644 index 000000000..90136a766 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/FormContainer.tsx @@ -0,0 +1,384 @@ +import { useState, useEffect, useMemo } from "react" +import type { ReactNode } from "react" +import CodeMirror from "@uiw/react-codemirror" +import { json } from "@codemirror/lang-json" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { JsonViewer } from "./JsonViewer" +import { AlertCircle, AlertTriangle, Copy, ChevronDown, RotateCcw, Loader2 } from "lucide-react" +import { toast } from "sonner" +import { useSettingsContext } from "@/context/SettingsContext" +import { createTonConnectTheme } from "@/lib/codemirror-theme" +import type { ValidationResult } from "@/utils/validator" + +export interface PresetOption { + id: string + name: string + description: string +} + +type EditorMode = "form" | "raw" + +interface FormContainerProps { + // Metadata + title: string + submitButtonText?: string + codeEditorHeight?: string + + // Content + formContent: ReactNode + requestJson: string + + // Callbacks + onJsonChange?: (json: string) => void + onSend: () => void + onSendRaw?: (json: string) => void + + // Validation + validateJson?: (json: string) => ValidationResult + + // State + isConnected: boolean + isLoading?: boolean + + // Presets + presets?: PresetOption[] + onPresetSelect?: (presetId: string) => void +} + +function isValidJson(str: string): boolean { + try { + JSON.parse(str) + return true + } catch { + return false + } +} + +export function FormContainer({ + title, + submitButtonText = "Send Transaction", + codeEditorHeight = "400px", + formContent, + requestJson, + onJsonChange, + onSend, + onSendRaw, + validateJson, + isConnected, + isLoading = false, + presets, + onPresetSelect, +}: FormContainerProps) { + const [mode, setMode] = useState("form") + const [editedJson, setEditedJson] = useState(requestJson) + const [validationResult, setValidationResult] = useState(null) + + const { theme } = useSettingsContext() + + // Determine if dark mode based on theme setting + const isDark = useMemo(() => { + if (theme === "system") { + return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches + } + return theme === "dark" + }, [theme]) + + // Create reactive CodeMirror theme + const codemirrorTheme = useMemo(() => createTonConnectTheme(isDark), [isDark]) + + // Sync JSON when in form mode or when requestJson changes + useEffect(() => { + if (mode === "form") { + setEditedJson(requestJson) + setValidationResult(null) // Clear validation when switching to form + } + }, [requestJson, mode]) + + // Handle mode switch + const handleModeChange = (newMode: EditorMode) => { + if (newMode === mode) return + + if (mode === "raw" && newMode === "form") { + // Code → Form: check validity first + if (!isValidJson(editedJson)) { + // Syntax error - confirm discard + if (!confirm("Invalid JSON syntax. Discard changes and switch to Form?")) { + return + } + setEditedJson(requestJson) + setValidationResult(null) + setMode("form") + return + } + + // Check schema validation + if (validateJson) { + const result = validateJson(editedJson) + if (!result.valid) { + // Schema warnings - confirm discard + if (!confirm("JSON has validation errors. Some data may be lost. Switch to Form anyway?")) { + return + } + } + } + + // Apply changes + onJsonChange?.(editedJson) + setValidationResult(null) + } + + if (mode === "form" && newMode === "raw") { + // Form → Code: sync JSON + setEditedJson(requestJson) + setValidationResult(null) + } + + setMode(newMode) + } + + // Reset editor to form state + const handleReset = () => { + setEditedJson(requestJson) + setValidationResult(null) + toast.success("Reset to form state") + } + + // Actually send the transaction (internal) + const doSend = () => { + if (onSendRaw) { + onSendRaw(editedJson) + } else { + onJsonChange?.(editedJson) + onSend() + } + } + + // Handle send - validates first in Code mode + const handleSend = () => { + if (mode === "form") { + onSend() + } else { + // Code mode - validate before sending + if (!isValidJson(editedJson)) { + setValidationResult({ valid: false, errors: [{ path: "root", message: "Invalid JSON syntax" }] }) + return + } + + // Run schema validation + if (validateJson) { + const result = validateJson(editedJson) + if (!result.valid) { + // Show warnings and DON'T send - user must click "Send Anyway" + setValidationResult(result) + return + } + } + + // All validation passed - send + doSend() + } + } + + // Send anyway - bypasses schema validation (but still checks syntax) + const handleSendAnyway = () => { + if (!isValidJson(editedJson)) { + return // Syntax errors still block + } + setValidationResult(null) + doSend() + } + + // Determine validation state (only shown after Send click) + const hasSyntaxError = validationResult?.errors.some(e => e.message === "Invalid JSON syntax") + const hasSchemaWarnings = validationResult && !validationResult.valid && !hasSyntaxError + + // Send button disabled state - disabled if not connected or loading + const sendDisabled = !isConnected || isLoading + + return ( +
+ + {/* Header: Title + Toggle + Send */} + +

{title}

+ +
+ {/* Presets Dropdown */} + {presets && presets.length > 0 && ( + + + + + + {presets.map((preset) => ( + onPresetSelect?.(preset.id)} + className="flex flex-col items-start gap-0.5 cursor-pointer" + > + {preset.name} + {preset.description} + + ))} + + + )} + + {/* Segmented Toggle */} +
+ + +
+ + {/* Send Button */} + +
+
+ + + {mode === "form" ? ( + // Form mode: 2 columns in one card +
+ {/* LEFT: Form */} +
+

Configure

+ {formContent} +
+ + {/* RIGHT: Preview (with left border on lg) */} +
+
+

Request Preview

+ +
+ +
+
+ ) : ( + // Code mode: Full width editor with toolbar +
+ {/* Toolbar */} +
+ + +
+ + { + setEditedJson(value) + // Clear validation when user edits + if (validationResult) { + setValidationResult(null) + } + }} + extensions={[json(), ...codemirrorTheme]} + theme="none" + height={codeEditorHeight} + className="rounded-md border overflow-hidden" + /> + + {/* Syntax Error (shown after Send attempt) */} + {hasSyntaxError && ( + + + Invalid JSON syntax. Please fix before sending. + + )} + + {/* Schema Warnings (shown after Send attempt, blocks until "Send Anyway") */} + {hasSchemaWarnings && ( + + + + + {validationResult!.errors.map(e => `${e.path}: ${e.message}`).join("; ")} + + + + + )} + +
+ )} +
+
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/HistoryList.tsx b/apps/tonconnect-demo/src/components/shared/HistoryList.tsx new file mode 100644 index 000000000..3a93c38fb --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/HistoryList.tsx @@ -0,0 +1,426 @@ +import { useState, useMemo, useCallback } from "react" +import { fromNano } from "@ton/core" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { JsonViewer } from "./JsonViewer" +import { useHistory, type HistoryEntry } from "@/hooks/useHistory" +import { + ChevronDown, + ChevronRight, + CheckCircle, + XCircle, + Clock, + RotateCcw, + Copy, + Trash2, + History, + ExternalLink, + Search, + Loader2, +} from "lucide-react" +import { toast } from "sonner" +import { getExplorerUrl } from "@/utils/explorer-utils" +import { getNormalizedExtMessageHash } from "@/utils/transaction-utils" + +interface HistoryListProps { + currentWallet: string | null + onLoadToForm: (requestRaw: string) => void +} + +function formatTime(timestamp: number): string { + const now = Date.now() + const diff = now - timestamp + + // Less than 1 minute ago + if (diff < 60000) { + return "just now" + } + + // Less than 1 hour ago + if (diff < 3600000) { + const mins = Math.floor(diff / 60000) + return `${mins}m ago` + } + + // Today - show time + const date = new Date(timestamp) + const today = new Date() + if (date.toDateString() === today.toDateString()) { + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + } + + // Yesterday + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) { + return "yesterday" + } + + // Older - show date + return date.toLocaleDateString([], { month: "short", day: "numeric" }) +} + +function formatAmount(request: Record): string { + try { + const messages = request.messages as Array<{ amount?: string }> | undefined + if (messages && messages.length > 0) { + const totalNano = messages.reduce((sum, msg) => { + return sum + BigInt(msg.amount || "0") + }, BigInt(0)) + const ton = fromNano(totalNano) + const num = parseFloat(ton) + if (num >= 1) { + return `${num.toFixed(2)} TON` + } + if (num >= 0.01) { + return `${num.toFixed(3)} TON` + } + return `${ton} TON` + } + } catch { + // Ignore + } + return "" +} + +function getMessageCount(request: Record): number { + const messages = request.messages as Array | undefined + return messages?.length || 0 +} + +function StatusIcon({ status }: { status: HistoryEntry["status"] }) { + switch (status) { + case "confirmed": + return + case "success": + return + case "error": + return + case "expired": + return + default: + return + } +} + +function statusLabel(status: HistoryEntry["status"]): string { + switch (status) { + case "confirmed": + return "Confirmed" + case "success": + return "Sent" + case "error": + return "Error" + case "expired": + return "Expired" + default: + return status + } +} + +interface HistoryEntryRowProps { + entry: HistoryEntry + expanded: boolean + onToggle: () => void + onLoadToForm: () => void + onDelete: () => void +} + +interface TransactionDetails { + lt: string + hash: string + fee: string + timestamp: number +} + +function HistoryEntryRow({ + entry, + expanded, + onToggle, + onLoadToForm, + onDelete, +}: HistoryEntryRowProps) { + const amount = formatAmount(entry.request) + const msgCount = getMessageCount(entry.request) + + // Compute hash from BOC (only if boc exists) + const hash = useMemo(() => { + if (!entry.boc) return null + try { + return getNormalizedExtMessageHash(entry.boc) + } catch { + return null + } + }, [entry.boc]) + + // Blockchain check state + const [checkLoading, setCheckLoading] = useState(false) + const [txDetails, setTxDetails] = useState(null) + const [checkError, setCheckError] = useState(null) + + const checkBlockchain = useCallback(async () => { + if (!hash) return + + setCheckLoading(true) + setCheckError(null) + + const endpoint = entry.network === "testnet" + ? "https://testnet.toncenter.com/api/v3" + : "https://toncenter.com/api/v3" + + try { + const response = await fetch( + `${endpoint}/transactionsByMessage?msg_hash=${hash}&direction=in` + ) + const data = await response.json() + + if (data.transactions?.length > 0) { + const tx = data.transactions[0] + setTxDetails({ + lt: tx.lt, + hash: tx.hash, + fee: tx.total_fees, + timestamp: tx.now, + }) + } else { + setCheckError("Transaction not found in blockchain") + } + } catch (err) { + setCheckError(err instanceof Error ? err.message : "Network error") + } finally { + setCheckLoading(false) + } + }, [hash, entry.network]) + + return ( +
+ {/* Collapsed row - clickable header */} + + + {/* Expanded content */} + {expanded && ( +
+ {/* 2-column layout for Request / Response+Hash */} +
+ {/* Left column: Request */} +
+ +
+ + {/* Right column: Response + Hash + Blockchain */} +
+ + + {/* Hash with explorer link */} + {hash && ( +
+
+ +
+ + +
+
+ + {hash} + + + {/* Check blockchain button */} + {!txDetails && ( + + )} + + {/* Error */} + {checkError && ( +

{checkError}

+ )} + + {/* Transaction details */} + {txDetails && ( + + + Transaction Confirmed + + LT: {txDetails.lt} • Fee: {txDetails.fee} nanotons • {new Date(txDetails.timestamp * 1000).toLocaleString()} + + + )} +
+ )} +
+
+ + {/* Actions */} +
+ + +
+
+ )} +
+ ) +} + +export function HistoryList({ currentWallet, onLoadToForm }: HistoryListProps) { + const history = useHistory() + const [expanded, setExpanded] = useState>({}) + const [sectionOpen, setSectionOpen] = useState(false) + + const entries = useMemo(() => { + return currentWallet ? history.getByWallet(currentWallet) : [] + }, [currentWallet, history]) + + if (!currentWallet || entries.length === 0) { + return null + } + + const toggleEntry = (id: string) => { + setExpanded(prev => ({ ...prev, [id]: !prev[id] })) + } + + const handleClear = () => { + if (currentWallet) { + history.clearWallet(currentWallet) + } + } + + return ( + + {/* Section header */} +
+ + {sectionOpen ? ( + + ) : ( + + )} + + HISTORY ({entries.length}) + + + +
+ + +
+ {entries.map(entry => ( + toggleEntry(entry.id)} + onLoadToForm={() => onLoadToForm(entry.requestRaw)} + onDelete={() => history.deleteEntry(entry.id)} + /> + ))} +
+
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/HowItWorksCard.tsx b/apps/tonconnect-demo/src/components/shared/HowItWorksCard.tsx new file mode 100644 index 000000000..594100713 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/HowItWorksCard.tsx @@ -0,0 +1,174 @@ +import { useMemo } from "react" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ExternalLink, Check } from "lucide-react" +import { getSectionInfo } from "@/data/field-info" +import { useDevToolsContext } from "@/context/DevToolsContext" + +// Feature lists for each section +const SECTION_FEATURES: Record = { + connect: [ + "Simple or authenticated connection", + "TonProof cryptographic verification", + "Domain-bound ownership proof", + "Replay attack protection", + ], + transaction: [ + "Multiple messages per transaction", + "Custom payloads for smart contracts", + "Contract deployment support", + "Batch operations", + ], + signData: [ + "Text, binary, cell formats", + "Domain-bound signatures", + "Timestamp protection", + "Off-chain verification", + ], +} + +interface HowItWorksCardProps { + sectionId: string +} + +export function HowItWorksCard({ sectionId }: HowItWorksCardProps) { + const { docsHidden } = useDevToolsContext() + const info = getSectionInfo(sectionId) + const features = SECTION_FEATURES[sectionId] || [] + + // Extract h2 headings from markdown for navigation + const content = info?.content + const headings = useMemo(() => { + if (!content) return [] + const matches = content.match(/^## (.+)$/gm) + return matches?.map(h => h.replace("## ", "")) || [] + }, [content]) + + // Hide if docs are hidden or no info available + if (docsHidden || !info) return null + + const scrollToHeading = (heading: string) => { + // Find the heading element and scroll to it + const slug = heading.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "") + const element = document.getElementById(slug) + element?.scrollIntoView({ behavior: "smooth", block: "start" }) + } + + return ( + + + {info.name} + + +
+ + {/* LEFT: Summary - hidden on mobile */} +
+ {/* Summary */} +

{info.summary}

+ + {/* Features checklist */} + {features.length > 0 && ( +
+

Key Features

+
    + {features.map((feature, i) => ( +
  • + + {feature} +
  • + ))} +
+
+ )} + + {/* Navigation */} + {headings.length > 0 && ( + + )} + + {/* Links */} + {info.links && info.links.length > 0 && ( +
+ {info.links.map((link, i) => ( + + {link.title} + + + ))} +
+ )} +
+ + {/* RIGHT: Full markdown content */} +
+ { + const text = String(children) + const id = text.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "") + return

{children}

+ } + }} + > + {info.content} +
+
+ +
+ + {/* Links on mobile - shown only on mobile */} + {info.links && info.links.length > 0 && ( +
+ {info.links.map((link, i) => ( + + {link.title} + + + ))} +
+ )} +
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/JsonViewer.tsx b/apps/tonconnect-demo/src/components/shared/JsonViewer.tsx new file mode 100644 index 000000000..02a2700d0 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/JsonViewer.tsx @@ -0,0 +1,214 @@ +import { useMemo, useState } from "react" +import CodeMirror from "@uiw/react-codemirror" +import { json } from "@codemirror/lang-json" +import { javascript } from "@codemirror/lang-javascript" +import { EditorView } from "@codemirror/view" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { Copy, ChevronRight, ChevronDown } from "lucide-react" +import { useSettingsContext } from "@/context/SettingsContext" +import { copyToClipboard } from "@/utils/clipboard" +import { createTonConnectTheme } from "@/lib/codemirror-theme" + +interface JsonViewerProps { + title: string + json: string + /** Default expanded state */ + defaultExpanded?: boolean + /** Max height for long JSON (default 200px) */ + maxHeight?: number + /** Show inline for short JSON (default 80 chars) */ + inlineThreshold?: number + /** Allow collapsing (default true) */ + collapsible?: boolean + /** Language for syntax highlighting (default json) */ + language?: "json" | "typescript" +} + +export function JsonViewer({ + title, + json: jsonString, + defaultExpanded = true, + maxHeight = 200, + inlineThreshold = 80, + collapsible = true, + language = "json", +}: JsonViewerProps) { + const [expanded, setExpanded] = useState(defaultExpanded) + const { theme } = useSettingsContext() + + const isDark = useMemo(() => { + if (theme === "system") { + return typeof window !== "undefined" && + window.matchMedia("(prefers-color-scheme: dark)").matches + } + return theme === "dark" + }, [theme]) + + const codemirrorTheme = useMemo(() => createTonConnectTheme(isDark), [isDark]) + + // Language extension for syntax highlighting + const langExtension = useMemo(() => { + return language === "typescript" + ? javascript({ jsx: true, typescript: true }) + : json() + }, [language]) + + // Parse and format content + const { formatted, lineCount, isShort } = useMemo(() => { + // For TypeScript/JS, don't try to parse as JSON + if (language === "typescript") { + const lines = jsonString.split("\n") + return { + formatted: jsonString, + lineCount: lines.length, + isShort: false, // Never show inline for code + } + } + + // For JSON, try to parse and pretty-print + try { + const parsed = JSON.parse(jsonString) + const formatted = JSON.stringify(parsed, null, 2) + const lines = formatted.split("\n") + return { + formatted, + lineCount: lines.length, + isShort: formatted.length <= inlineThreshold && lines.length <= 2, + } + } catch { + return { formatted: jsonString, lineCount: 1, isShort: jsonString.length <= inlineThreshold } + } + }, [jsonString, inlineThreshold, language]) + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation() // Don't toggle collapsible + await copyToClipboard(formatted) + } + + // Calculate height: line-height ~18px + padding + const calculatedHeight = useMemo(() => { + const lineHeight = 18 + const padding = 16 + const naturalHeight = lineCount * lineHeight + padding + return Math.min(naturalHeight, maxHeight) + }, [lineCount, maxHeight]) + + // Short JSON - show inline without collapsible + if (isShort) { + return ( +
+
+ + +
+ + {formatted} + +
+ ) + } + + // Non-collapsible mode - just show CodeMirror with optional header + if (!collapsible) { + return ( +
+ {title && ( +
+ + +
+ )} + +
+ ) + } + + // Long content - collapsible with CodeMirror + return ( + +
+ + {expanded ? ( + + ) : ( + + )} + {title} + {!expanded && ( + + ({lineCount} lines) + + )} + + +
+ + + +
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/ResultCard.tsx b/apps/tonconnect-demo/src/components/shared/ResultCard.tsx new file mode 100644 index 000000000..01b37f722 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/ResultCard.tsx @@ -0,0 +1,125 @@ +import { useTonWallet, CHAIN } from "@tonconnect/ui-react" +import { BaseResultCard } from "./BaseResultCard" +import { StatusBar, type StatusVariant } from "./StatusBar" +import { TransactionDetails } from "./TransactionDetails" +import { useTransactionTracker, type TransactionStatus } from "@/hooks/useTransactionTracker" +import { CheckCircle, XCircle, Loader2 } from "lucide-react" +import type { OperationResult } from "@/hooks/useTransaction" + +interface ResultCardProps { + result: OperationResult + onDismiss?: () => void + onLoadToForm?: () => void +} + +/** Maps transaction status to StatusBar variant */ +function getVariant(status: TransactionStatus, isError: boolean): StatusVariant { + if (isError) return "error" + switch (status) { + case "confirmed": + return "success" + case "expired": + return "error" + default: + return "pending" + } +} + +/** Gets icon for status */ +function getIcon(status: TransactionStatus, isError: boolean) { + if (isError) return + switch (status) { + case "confirmed": + return + case "expired": + return + default: + return + } +} + +/** Gets title for status */ +function getTitle(status: TransactionStatus, isError: boolean, hasBoc: boolean): string { + if (isError) return "Error" + switch (status) { + case "confirmed": + return "Confirmed" + case "expired": + return "Expired" + case "pending": + case "idle": + return hasBoc ? "Pending" : "Sent" + } +} + +/** Gets subtitle for confirmed transactions */ +function getConfirmedSubtitle(transaction: { lt: string; fee: string; timestamp: number }): string { + const time = new Date(transaction.timestamp * 1000).toLocaleTimeString() + const feeInTon = (parseInt(transaction.fee) / 1e9).toFixed(6) + return `LT: ${transaction.lt} • Fee: ${feeInTon} TON • ${time}` +} + +export function ResultCard({ result, onDismiss, onLoadToForm }: ResultCardProps) { + const wallet = useTonWallet() + const network = wallet?.account.chain === CHAIN.TESTNET ? "testnet" : "mainnet" + + // Transaction tracking (if we have boc) + const tracking = useTransactionTracker({ + boc: result.boc || null, + validUntil: result.validUntil || 0, + network, + }) + + const isError = result.status === "error" + const hasBoc = !!result.boc + const effectiveStatus = isError ? "idle" : tracking.status + + // Build subtitle + let subtitle: string | undefined + if (isError) { + // Try to extract error message from response + try { + const parsed = JSON.parse(result.response) + subtitle = parsed.message || parsed.error || undefined + } catch { + subtitle = undefined + } + } else if (effectiveStatus === "confirmed" && tracking.transaction) { + subtitle = getConfirmedSubtitle(tracking.transaction) + } else if (effectiveStatus === "expired") { + subtitle = "Transaction not found before validUntil" + } else if (effectiveStatus === "pending" && tracking.error) { + subtitle = `${tracking.error}, retrying...` + } else if (effectiveStatus === "pending") { + subtitle = "Waiting for confirmation..." + } + + const statusBar = ( + + ) + + const responseFooter = hasBoc ? ( + + ) : undefined + + return ( + + ) +} diff --git a/apps/tonconnect-demo/src/components/shared/RpcLogCard.tsx b/apps/tonconnect-demo/src/components/shared/RpcLogCard.tsx new file mode 100644 index 000000000..5e9d4a8f7 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/RpcLogCard.tsx @@ -0,0 +1,161 @@ +import { useState, useMemo } from "react" +import { Card } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { ChevronDown, Clock, CheckCircle2, XCircle } from "lucide-react" +import { cn } from "@/lib/utils" +import { JsonViewer } from "./JsonViewer" +import type { RpcLogEntry, RpcLogStatus } from "@/hooks/useSdkLogs" + +interface RpcLogCardProps { + entry: RpcLogEntry + defaultOpen?: boolean +} + +const statusConfig: Record = { + pending: { icon: Clock, color: "text-yellow-500", label: "Pending" }, + success: { icon: CheckCircle2, color: "text-green-500", label: "Success" }, + error: { icon: XCircle, color: "text-red-500", label: "Error" }, +} + +function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) +} + +function formatDuration(start: number, end?: number): string { + if (!end) { + const elapsed = Math.floor((Date.now() - start) / 1000) + return `${elapsed}s` + } + const ms = end - start + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` +} + +/** Parse params array - each element may be a JSON string */ +function parseParams(params: unknown): unknown[] { + if (!Array.isArray(params)) return [] + + return params.map(param => { + if (typeof param === 'string') { + try { + return JSON.parse(param) + } catch { + return param + } + } + return param + }) +} + +export function RpcLogCard({ entry, defaultOpen = false }: RpcLogCardProps) { + const [isOpen, setIsOpen] = useState(defaultOpen) + const { icon: StatusIcon, color, label } = statusConfig[entry.status] + + // Parse params from request + const parsedParams = useMemo(() => { + const req = entry.request as Record + return parseParams(req.params) + }, [entry.request]) + + return ( + + + + + + + +
+
+ {/* Left column: Request + Parsed Params */} +
+ + {/* Parsed Params */} + {parsedParams.length > 0 && ( +
+ {parsedParams.map((param, i) => ( + + ))} +
+ )} +
+ + {/* Right column: Response */} + {entry.response ? ( + + ) : ( +
+ Response +
+ Waiting... +
+
+ )} +
+
+
+
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/RpcLogViewer.tsx b/apps/tonconnect-demo/src/components/shared/RpcLogViewer.tsx new file mode 100644 index 000000000..0e42af862 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/RpcLogViewer.tsx @@ -0,0 +1,68 @@ +import { useMemo } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Radio, Trash2 } from "lucide-react" +import { RpcLogCard } from "./RpcLogCard" +import type { RpcLogEntry } from "@/hooks/useSdkLogs" + +interface RpcLogViewerProps { + logs: RpcLogEntry[] + onClear: () => void +} + +export function RpcLogViewer({ logs, onClear }: RpcLogViewerProps) { + const pendingCount = useMemo( + () => logs.filter(l => l.status === "pending").length, + [logs] + ) + const hasLogs = logs.length > 0 + + return ( + + +
+
+ + RPC Logs + {pendingCount > 0 && ( + + {pendingCount} + + )} +
+ {hasLogs && ( + + )} +
+ + Raw JSON-RPC requests and responses from SDK + +
+ + {hasLogs ? ( +
+ {logs.map((entry, index) => ( + + ))} +
+ ) : ( +
+ +

No RPC logs yet

+

+ Send a transaction or sign data to see logs here +

+
+ )} +
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/SignDataResultCard.tsx b/apps/tonconnect-demo/src/components/shared/SignDataResultCard.tsx new file mode 100644 index 000000000..dc81f3759 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/SignDataResultCard.tsx @@ -0,0 +1,113 @@ +import { BaseResultCard } from "./BaseResultCard" +import { StatusBar } from "./StatusBar" +import { VerificationResult } from "./VerificationResult" +import { Button } from "@/components/ui/button" +import { CheckCircle, XCircle, Loader2, ShieldCheck, Server } from "lucide-react" +import type { SignDataOperationResult } from "@/hooks/useSignData" +import type { VerificationResult as VerificationResultType } from "@/utils/sign-data-verification" + +interface SignDataResultCardProps { + result: SignDataOperationResult + onDismiss?: () => void + onLoadToForm?: () => void + // Verification + canVerify: boolean + onVerifyClient: () => void + onVerifyServer: () => void + isVerifyingClient: boolean + isVerifyingServer: boolean + clientResult: VerificationResultType | null + serverResult: VerificationResultType | null +} + +export function SignDataResultCard({ + result, + onDismiss, + onLoadToForm, + canVerify, + onVerifyClient, + onVerifyServer, + isVerifyingClient, + isVerifyingServer, + clientResult, + serverResult, +}: SignDataResultCardProps) { + const isSuccess = result.status === "success" + const isError = result.status === "error" + + // Build subtitle for error + let subtitle: string | undefined + if (isError) { + try { + const parsed = JSON.parse(result.response) + subtitle = parsed.message || parsed.error || undefined + } catch { + subtitle = undefined + } + } + + // Verification buttons (only for success) + const verificationActions = isSuccess ? ( +
+ + +
+ ) : undefined + + // Verification results (inline under StatusBar) + const hasVerificationResults = clientResult || serverResult + const verificationContent = hasVerificationResults ? ( +
+ {clientResult && } + {serverResult && } +
+ ) : undefined + + const statusBar = ( + : } + title={isSuccess ? "Signed" : "Error"} + subtitle={subtitle} + actions={verificationActions} + > + {verificationContent} + + ) + + return ( + + ) +} diff --git a/apps/tonconnect-demo/src/components/shared/StatusBar.tsx b/apps/tonconnect-demo/src/components/shared/StatusBar.tsx new file mode 100644 index 000000000..d47b3f7c1 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/StatusBar.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react" +import { cn } from "@/lib/utils" + +export type StatusVariant = "pending" | "success" | "error" + +export interface StatusBarProps { + variant: StatusVariant + icon: ReactNode + title: string + subtitle?: string + timer?: string + actions?: ReactNode + children?: ReactNode +} + +const variantStyles: Record = { + pending: "bg-yellow-50 border-yellow-200 text-yellow-900 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-100", + success: "bg-green-50 border-green-200 text-green-900 dark:bg-green-950 dark:border-green-800 dark:text-green-100", + error: "bg-red-50 border-red-200 text-red-900 dark:bg-red-950 dark:border-red-800 dark:text-red-100", +} + +export function StatusBar({ + variant, + icon, + title, + subtitle, + timer, + actions, + children, +}: StatusBarProps) { + return ( +
+ {/* Main row: icon + title + timer/actions */} +
+
+ {icon} + {title} +
+
+ {timer && ( + {timer} + )} + {actions} +
+
+ + {/* Subtitle if present */} + {subtitle && ( +
+ {subtitle} +
+ )} + + {/* Children (BOC/Hash, verification results) */} + {children && ( +
+
+ {children} +
+
+ )} +
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/TransactionDetails.tsx b/apps/tonconnect-demo/src/components/shared/TransactionDetails.tsx new file mode 100644 index 000000000..4d44c2e82 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/TransactionDetails.tsx @@ -0,0 +1,78 @@ +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Copy, ExternalLink } from "lucide-react" +import { getExplorerUrl } from "@/utils/explorer-utils" +import { copyToClipboard } from "@/utils/clipboard" + +interface TransactionDetailsProps { + boc: string + hash: string | null + network: "mainnet" | "testnet" +} + +export function TransactionDetails({ boc, hash, network }: TransactionDetailsProps) { + return ( +
+ {/* BOC - truncated with length indicator */} +
+ +
+ copyToClipboard(boc)} + title="Click to copy full BOC" + > + {boc.slice(0, 50)}... + + +
+
+ + {/* Hash - full display with word-break */} + {hash && ( +
+
+ +
+ + +
+
+ + {hash} + +
+ )} +
+ ) +} diff --git a/apps/tonconnect-demo/src/components/shared/VerificationResult.tsx b/apps/tonconnect-demo/src/components/shared/VerificationResult.tsx new file mode 100644 index 000000000..d7d7335e8 --- /dev/null +++ b/apps/tonconnect-demo/src/components/shared/VerificationResult.tsx @@ -0,0 +1,36 @@ +import { CheckCircle, XCircle } from "lucide-react" +import type { VerificationResult as VerificationResultType } from "@/utils/sign-data-verification" + +interface VerificationResultProps { + title: string // "Client" | "Server" + result: VerificationResultType +} + +export function VerificationResult({ title, result }: VerificationResultProps) { + const isValid = result.valid + + return ( +
+ {/* Title + status */} +
+ {isValid ? ( + + ) : ( + + )} + + {title}: {isValid ? "Valid" : "Invalid"} + +
+ + {/* Details */} + {result.details && ( +
+ Address {result.details.addressMatch ? "✓" : "✗"} •{" "} + Public key {result.details.publicKeyMatch ? "✓" : "✗"} •{" "} + Signature {result.details.signatureValid ? "✓" : "✗"} +
+ )} +
+ ) +} diff --git a/apps/tonconnect-demo/src/components/tabs/ConnectTab.tsx b/apps/tonconnect-demo/src/components/tabs/ConnectTab.tsx new file mode 100644 index 000000000..dae864ec8 --- /dev/null +++ b/apps/tonconnect-demo/src/components/tabs/ConnectTab.tsx @@ -0,0 +1,562 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { useConnect } from "@/hooks/useConnect" +import { ConnectedWalletCard } from "@/components/shared/ConnectedWalletCard" +import { ConnectionEventsCard } from "@/components/shared/ConnectionEventsCard" +import { JsonViewer } from "@/components/shared/JsonViewer" +import { HowItWorksCard } from "@/components/shared/HowItWorksCard" +import { FieldLabel } from "@/components/shared/FieldLabel" +import { + Plug, + RefreshCw, + Key, + ShieldCheck, + User, + Loader2, + XCircle, + AlertCircle, + ChevronRight, + Copy, + Check +} from "lucide-react" +import { useState } from "react" +import { copyToClipboard } from "@/utils/clipboard" + +// Inline copy button for text flow +function InlineCopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await copyToClipboard(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +// Verification checks from backend +interface VerifyChecks { + jwtValid: boolean + payloadMatch: boolean + publicKeyMatch: boolean + addressMatch: boolean + domainAllowed: boolean + timestampValid: boolean + signatureValid: boolean +} + + +// Display proof data from wallet +interface TonProof { + timestamp: number + domain: { lengthBytes: number; value: string } + payload: string + signature: string + state_init?: string +} + +interface WalletResponseDisplayProps { + proof: TonProof | null + address: string | null + publicKey: string | null + checks: VerifyChecks | null + payloadToken: string | null +} + +function CheckMark({ valid }: { valid: boolean }) { + return valid ? ( + + ) : ( + + ) +} + +function WalletResponseDisplay({ proof, address, publicKey, checks, payloadToken }: WalletResponseDisplayProps) { + return ( +
+
Wallet Response
+ +
+ {/* timestamp */} +

+ timestamp + {proof ? ( + <> + {proof.timestamp} + + ({new Date(proof.timestamp * 1000).toLocaleTimeString('en-US', { hour12: false })}) + + {checks && } + + ) : ( + signing time (unix) + )} +

+ + {/* domain */} +

+ domain + {proof ? ( + <> + {proof.domain.value} + {checks && } + + ) : ( + requesting domain + )} +

+ + {/* payload */} +

+ payload + {proof ? ( + <> + copyToClipboard(proof.payload)} + className="font-mono bg-primary/10 py-0.5 rounded break-all cursor-pointer hover:bg-primary/20 transition-colors [box-decoration-break:clone] [-webkit-box-decoration-break:clone]" + title="Click to copy" + > + {proof.payload} + + {checks && } + + ) : ( + your challenge + )} +

+ + {/* signature */} +

+ signature + {proof ? ( + <> + copyToClipboard(proof.signature)} + className="font-mono bg-primary/10 py-0.5 rounded break-all cursor-pointer hover:bg-primary/20 transition-colors [box-decoration-break:clone] [-webkit-box-decoration-break:clone]" + title="Click to copy" + > + {proof.signature} + + {checks && } + + ) : ( + Ed25519 signature + )} +

+
+ + {/* Account data (from wallet.account) */} +
+ {/* address */} +

+ address + {address ? ( + <> + copyToClipboard(address)} + className="font-mono bg-primary/10 py-0.5 rounded break-all cursor-pointer hover:bg-primary/20 transition-colors [box-decoration-break:clone] [-webkit-box-decoration-break:clone]" + title="Click to copy" + > + {address} + + {checks && } + + ) : ( + wallet address + )} +

+ + {/* public key */} +

+ public key + {publicKey ? ( + <> + copyToClipboard(publicKey)} + className="font-mono bg-primary/10 py-0.5 rounded break-all cursor-pointer hover:bg-primary/20 transition-colors [box-decoration-break:clone] [-webkit-box-decoration-break:clone]" + title="Click to copy" + > + {publicKey} + + {checks && } + + ) : ( + Ed25519 public key + )} +

+
+ + {/* Implementation details */} + + + + Implementation details + + +

+ JWT + {payloadToken ? ( + <> + copyToClipboard(payloadToken)} + className="font-mono bg-primary/10 py-0.5 rounded break-all cursor-pointer hover:bg-primary/20 transition-colors [box-decoration-break:clone] [-webkit-box-decoration-break:clone]" + title="Click to copy" + > + {payloadToken} + + {checks && } + + ) : ( + stateless auth token + )} +

+
+
+
+ ) +} + +function PayloadDisplay({ response }: { response: Record }) { + const payloadToken = String(response.payloadToken || '') + const payloadTokenHash = String(response.payloadTokenHash || '') + + return ( +
+ {/* Challenge - inline text flow */} +

+ Challenge{' '} + + {payloadTokenHash} + + +

+ + {/* Implementation detail */} + + + + Implementation details (JWT) + + +
+ + payloadToken + +
+ {payloadToken} +
+

+ This demo uses JWT to create a stateless backend. Your implementation may differ. +

+
+
+
+
+ ) +} + +export function ConnectTab() { + const { + wallet, + hasProof, + isAuthenticated, + + operations, + clearOperations, + deleteOperation, + + isGeneratingPayload, + isConnecting, + isVerifying, + isFetchingAccount, + + connect, + disconnect, + generatePayload, + connectWithProof, + verifyProof, + getAccountInfo, + + payloadResult, + verifyResult, + accountResult, + + canConnect, + canConnectWithProof, + canVerify, + canGetAccount + } = useConnect() + + return ( +
+ {/* Connected Wallet Card - TOP */} + + + {/* Two columns: 1fr / 2fr */} +
+ {/* Left: Simple Connection */} + + + + + Simple Connection + + + Connect wallet without TonProof + + + + + + + + {/* Right: TonProof Connect */} + + + + + TonProof Connect + + + Connect with cryptographic proof of wallet ownership + + + + {/* Step 1: Backend Challenge */} +
+
+
+ 1 +
+
+
+ + Backend Challenge + + +
+

Your backend generates a challenge for wallet to sign

+
+ {payloadResult?.status === 'error' && ( + + )} +
+ +
+ {/* Loading state */} + {isGeneratingPayload && !payloadResult && ( +
+ + Generating challenge... +
+ )} + + {/* Payload Data */} + {payloadResult?.status === 'success' && payloadResult.response != null && ( + } /> + )} + + {/* Error */} + {payloadResult?.status === 'error' && ( + + + {payloadResult.error} + + )} +
+
+ + {/* Step 2: Connect with Proof */} +
+
+
+ 2 +
+
+
+ + Connect with Proof + + +
+

Opens wallet with TonProof request

+
+
+
+ + {/* Step 3: Verify Proof */} +
+
+
+ 3 +
+
+
+ + Verify Proof + + +
+

+ Send proof to backend for cryptographic verification +

+
+
+ +
+ + {/* Wallet Response */} +
+ + + {/* Example: Using Auth Token (collapsible, only after verification) */} + {isAuthenticated && ( + + + + Example: Using the Auth Token + Optional + + +
+

+ After successful verification, your backend returns an auth token. + This example shows how to use it for authenticated API calls. +

+ + {accountResult && ( +
+ {accountResult.status === 'error' && ( + + + {accountResult.error} + + )} + {accountResult.request != null && ( + + )} + {accountResult.status === 'success' && accountResult.response != null && ( + + )} +
+ )} +
+
+
+ )} +
+
+
+ + {/* Connection Events */} + + + {/* How It Works */} + +
+ ) +} diff --git a/apps/tonconnect-demo/src/components/tabs/DevToolsTab.tsx b/apps/tonconnect-demo/src/components/tabs/DevToolsTab.tsx new file mode 100644 index 000000000..420489f33 --- /dev/null +++ b/apps/tonconnect-demo/src/components/tabs/DevToolsTab.tsx @@ -0,0 +1,283 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { useDevToolsContext } from "@/context/DevToolsContext" +import { AlertTriangle, Lock, RotateCcw, Terminal, Bug, Radio, BookOpen } from "lucide-react" + +export function DevToolsTab() { + const { + qaMode, + setQaMode, + erudaEnabled, + setErudaEnabled, + rpcLogsEnabled, + setRpcLogsEnabled, + docsHidden, + setDocsHidden, + showDeprecated, + setShowDeprecated, + showExperimental, + setShowExperimental, + lockDevTools, + resetAll, + } = useDevToolsContext() + + return ( +
+ {/* Warning Banner */} +
+ +
+

Developer Tools

+

+ These settings are for development and testing only. Do not use in production. +

+
+
+ +
+ {/* QA Mode */} + + + + + QA Mode + SDK + + + SDK development mode: disables validations and uses staging wallets list + + + +
+
+ +

+ Page will reload when changed +

+
+ +
+ +
+

When enabled:

+
    +
  • Validation errors become console warnings
  • +
  • Cross-network transactions allowed
  • +
  • Uses staging wallets list
  • +
  • Shows injected wallets
  • +
+
+
+
+ + {/* Eruda Console */} + + + + + Mobile Console + + + Eruda console for mobile debugging + + + +
+
+ +

+ Shows debug console on screen +

+
+ +
+ +
+

Eruda provides:

+
    +
  • Console logs viewer
  • +
  • Network requests inspector
  • +
  • DOM elements explorer
  • +
  • Storage viewer
  • +
+
+
+
+ + {/* RPC Logs */} + + + + + RPC Logs + + + Capture raw SDK requests and responses + + + +
+
+ +

+ Intercepts console.debug from SDK +

+
+ +
+ +
+

Shows raw JSON-RPC data:

+
    +
  • Request payloads with id
  • +
  • Response payloads
  • +
  • Request/response timing
  • +
  • Error details
  • +
+
+
+
+ + {/* Documentation */} + + + + + Documentation + + + Show or hide inline documentation + + + +
+
+ +

+ Hides "How it works" sections +

+
+ +
+ +
+

When hidden:

+
    +
  • HowItWorks cards are hidden
  • +
  • Cleaner interface for demos
  • +
  • Field tooltips remain visible
  • +
+
+
+
+ +
+ + {/* Actions, Info & Deprecated */} +
+ + + Actions + Manage DevTools settings + + + + + + + + + + Information + Current DevTools state + + +
+

localStorage keys:

+
    +
  • devtools:qa-mode: {localStorage.getItem('devtools:qa-mode') ?? 'null'}
  • +
  • devtools:eruda: {localStorage.getItem('devtools:eruda') ?? 'null'}
  • +
  • devtools:rpc-logs: {localStorage.getItem('devtools:rpc-logs') ?? 'null'}
  • +
  • devtools:docs-hidden: {localStorage.getItem('devtools:docs-hidden') ?? 'null'}
  • +
  • devtools:show-deprecated: {localStorage.getItem('devtools:show-deprecated') ?? 'null'}
  • +
  • devtools:show-experimental: {localStorage.getItem('devtools:show-experimental') ?? 'null'}
  • +
  • devtools:unlocked: {localStorage.getItem('devtools:unlocked') ?? 'null'}
  • +
+
+
+
+ + + + + + Deprecated / Experimental + + Show hidden options in Settings + + +
+
+ +

+ skipRedirectToWallet +

+
+ +
+
+
+ +

+ Configuration Export +

+
+ +
+
+
+
+
+ ) +} diff --git a/apps/tonconnect-demo/src/components/tabs/SettingsTab.tsx b/apps/tonconnect-demo/src/components/tabs/SettingsTab.tsx new file mode 100644 index 000000000..0f6fbbe78 --- /dev/null +++ b/apps/tonconnect-demo/src/components/tabs/SettingsTab.tsx @@ -0,0 +1,512 @@ +import { useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { useSettingsContext } from "@/context/SettingsContext" +import { useDevToolsContext } from "@/context/DevToolsContext" +import { NetworkPicker } from "@/components/NetworkPicker" +import { FieldLabel } from "@/components/shared/FieldLabel" +import { JsonViewer } from "@/components/shared/JsonViewer" +import { TabsList, TabsTrigger } from "@/components/ui/tabs" +import { RotateCcw, AlertTriangle, Copy } from "lucide-react" +import { toast } from "sonner" +import type { ThemeOption, LanguageOption, ColorsConfig, FeaturesMode, ExportFormat } from "@/hooks/useSettings" + +function ColorInput({ + label, + value, + onChange +}: { + label: string + value: string + onChange: (value: string) => void +}) { + return ( +
+ +
+ onChange(e.target.value)} + className="h-8 w-8 cursor-pointer rounded border border-input bg-transparent" + /> + onChange(e.target.value)} + className="w-24 font-mono text-xs" + /> +
+
+ ) +} + +function ColorsCard({ + title, + description, + colors, + onUpdate +}: { + title: string + description: string + colors: ColorsConfig + onUpdate: (key: keyof ColorsConfig, value: string) => void +}) { + return ( + + + {title} + {description} + + +
+

Constants

+ onUpdate("constantBlack", v)} /> + onUpdate("constantWhite", v)} /> +
+
+

Connect Button

+ onUpdate("connectButtonBg", v)} /> + onUpdate("connectButtonFg", v)} /> +
+
+

General

+ onUpdate("accent", v)} /> + onUpdate("telegramButton", v)} /> +
+
+

Icons

+ onUpdate("iconPrimary", v)} /> + onUpdate("iconSecondary", v)} /> + onUpdate("iconTertiary", v)} /> + onUpdate("iconSuccess", v)} /> + onUpdate("iconError", v)} /> +
+
+

Background

+ onUpdate("backgroundPrimary", v)} /> + onUpdate("backgroundSecondary", v)} /> + onUpdate("backgroundSegment", v)} /> + onUpdate("backgroundTint", v)} /> + onUpdate("backgroundQr", v)} /> +
+
+

Text

+ onUpdate("textPrimary", v)} /> + onUpdate("textSecondary", v)} /> +
+
+
+ ) +} + +export function SettingsTab() { + const { + manifestUrl, setManifestUrl, + buildConfiguration, + formatConfiguration, + language, setLanguage, + theme, setTheme, + borderRadius, setBorderRadius, + darkColors, updateDarkColor, + lightColors, updateLightColor, + resetColors, + modalsBefore, setModalsBefore, + modalsSuccess, setModalsSuccess, + modalsError, setModalsError, + notificationsBefore, setNotificationsBefore, + notificationsSuccess, setNotificationsSuccess, + notificationsError, setNotificationsError, + returnStrategy, setReturnStrategy, + twaReturnUrl, setTwaReturnUrl, + skipRedirect, setSkipRedirect, + enableAndroidBackHandler, setEnableAndroidBackHandler, + featuresMode, setFeaturesMode, + minMessages, setMinMessages, + extraCurrencyRequired, setExtraCurrencyRequired, + signDataTypes, setSignDataTypes, + } = useSettingsContext() + + const { showDeprecated, showExperimental } = useDevToolsContext() + const [showFullConfig, setShowFullConfig] = useState(false) + const [exportFormat, setExportFormat] = useState("json") + const [includeDeprecatedInExport, setIncludeDeprecatedInExport] = useState(false) + + const handleSignDataTypeChange = (type: string, checked: boolean) => { + if (checked) { + setSignDataTypes([...signDataTypes, type]) + } else { + setSignDataTypes(signDataTypes.filter(t => t !== type)) + } + } + + return ( +
+ {/* Manifest URL */} + + + + Manifest URL + {""} + + Your dApp manifest for wallet identification + + + setManifestUrl(e.target.value)} + placeholder="https://your-app.com/tonconnect-manifest.json" + /> + + + + {/* Row 1: Connection Settings + Modals + Notifications */} +
+ + + Connection Settings + Network and wallet filtering + + + + +
+ + +
+ + {featuresMode !== "none" && ( +
+
+ + setMinMessages(e.target.value ? parseInt(e.target.value) : undefined)} + /> +
+
+ + +
+
+ +
+ {["text", "cell", "binary"].map(type => ( +
+ handleSignDataTypeChange(type, !!checked)} + /> + +
+ ))} +
+
+
+ )} +
+
+ + + + Modals + Show modal dialogs for actions + + +
+ + +
+
+ + +
+
+ + +
+
+
+ + + + Notifications + Show toast notifications + + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Row 2: UI Settings + Redirect Settings */} +
+ + + UI Settings + TonConnect UI appearance + + +
+ + +
+
+ + +
+
+ + +
+
+
+ + + + Redirect Settings + Configure wallet redirect behavior + + +
+ + +
+
+ + setTwaReturnUrl(e.target.value)} + placeholder="tg://resolve?domain=..." + /> +

+ Return URL for Telegram Web App connections +

+
+
+
+
+ + {/* Row 3: Android Settings */} + + + Android Settings + Android-specific behavior + + +
+
+ +

+ Use Android back button to close modals +

+
+ +
+
+
+ + {/* Row 4: Colors */} +
+
+

Colors

+ +
+
+ + +
+
+ + {/* Deprecated Options - only shown when enabled in DevTools */} + {showDeprecated && ( + + + + + Deprecated Options + + + These options are deprecated and may be removed in future SDK versions + + + +
+ + +

+ Deprecated: SDK now auto-detects for TWA connections. In TMA this is always forced to "never". +

+
+
+
+ )} + + {/* Configuration Export - Experimental */} + {showExperimental && ( + + + Configuration Export + + + {/* Combined controls: Tabs + Options */} +
+ + setExportFormat("json")} + data-state={exportFormat === "json" ? "active" : "inactive"} + > + JSON + + setExportFormat("react")} + data-state={exportFormat === "react" ? "active" : "inactive"} + > + React + + setExportFormat("vanilla")} + data-state={exportFormat === "vanilla" ? "active" : "inactive"} + > + Vanilla JS + + + +
+ + {showDeprecated && ( + + )} + +
+
+ + +
+
+ )} +
+ ) +} diff --git a/apps/tonconnect-demo/src/components/tabs/SignDataTab.tsx b/apps/tonconnect-demo/src/components/tabs/SignDataTab.tsx new file mode 100644 index 000000000..fd7fa047f --- /dev/null +++ b/apps/tonconnect-demo/src/components/tabs/SignDataTab.tsx @@ -0,0 +1,145 @@ +import { useRef, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { FormContainer } from "@/components/shared/FormContainer" +import { SignDataResultCard } from "@/components/shared/SignDataResultCard" +import { HowItWorksCard } from "@/components/shared/HowItWorksCard" +import { FieldLabel } from "@/components/shared/FieldLabel" +import { useSignData } from "@/hooks/useSignData" +import { useSettingsContext } from "@/context/SettingsContext" +import { validateSignDataJson } from "@/utils/validator" + +export function SignDataTab() { + const resultRef = useRef(null) + const { notificationsBefore, notificationsSuccess, notificationsError } = useSettingsContext() + const { + dataType, setDataType, + dataText, setDataText, + schema, setSchema, + requestJson, + sign, + setFromJson, + isConnected, + isSigning, + // Result + lastResult, + clearResult, + loadResultToForm, + // Verification + canVerify, + verify, + verificationResult, + isVerifying, + verifyOnServer, + serverVerificationResult, + isVerifyingOnServer, + } = useSignData(notificationsBefore, notificationsSuccess, notificationsError) + + // Scroll to result when it appears + useEffect(() => { + if (lastResult && resultRef.current) { + const rect = resultRef.current.getBoundingClientRect() + // Scroll if result is not fully visible + if (rect.top < 0 || rect.bottom > window.innerHeight) { + resultRef.current.scrollIntoView({ behavior: "smooth", block: "start" }) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- trigger only on new result id + }, [lastResult?.id]) + + const formContent = ( + <> +
+ Data Type +
+ + + +
+
+ + {dataType === "cell" && ( +
+ Schema (TL-B) + setSchema(e.target.value)} + placeholder="e.g. transfer#123abc amount:Coins" + /> +
+ )} + +
+ + {dataType === "text" ? "Text" : dataType === "binary" ? "Data (base64)" : "Cell (BOC)"} + +