diff --git a/apps/demo-wallet/e2e/demo-wallet/DemoWallet.ts b/apps/demo-wallet/e2e/demo-wallet/DemoWallet.ts index 48a30df62..c6e3adbbd 100644 --- a/apps/demo-wallet/e2e/demo-wallet/DemoWallet.ts +++ b/apps/demo-wallet/e2e/demo-wallet/DemoWallet.ts @@ -27,30 +27,26 @@ export class DemoWallet extends WalletApp { throw new Error('[importWallet] mnemonic is required setup WALLET_MNEMONIC'); } const app = await this.open(); + + // Welcome → "Add an existing wallet" → "Recovery phrase" + await app.getByTestId('welcome-add-existing').click(); + await app.getByTestId('add-wallet-import').click(); + // Setup password - await app.getByTestId('title').filter({ hasText: 'Setup Password', visible: true }); - await app.getByTestId('subtitle').filter({ hasText: 'Create Password', visible: true }); await app.getByTestId('password').fill(this.password); await app.getByTestId('password-confirm').fill(this.password); await app.getByTestId('password-submit').click(); - // Navigate to Import tab - await app.getByTestId('title').filter({ hasText: 'Setup Wallet' }).waitFor({ state: 'visible' }); - await app.getByTestId('tab-import').click(); - await app.getByTestId('subtitle').filter({ hasText: 'Import Wallet', visible: true }); - - // Select mainnet + // Import wallet screen: select mainnet, paste the phrase, continue await app.getByTestId('network-select-mainnet').click(); - - // Paste mnemonic using clipboard await app.evaluate(async (m) => { await navigator.clipboard.writeText(m); }, mnemonic); await app.getByTestId('paste-mnemonic').click(); - - // Import wallet await app.getByTestId('import-wallet-process').click(); - await app.getByTestId('title').filter({ hasText: 'TON Wallet' }).waitFor({ state: 'attached' }); + + // Wait for the dashboard (the settings button only exists there) + await app.getByTestId('wallet-menu').waitFor({ state: 'visible' }); // Disable auto-lock and hold-to-sign for e2e tests await app.getByTestId('wallet-menu').click(); @@ -64,6 +60,8 @@ export class DemoWallet extends WalletApp { async connectBy(url: string, shouldSkipConnect: boolean = false, confirm: boolean = true): Promise { const app = await this.open(); await delay(500); + // Open the "Connect to dApp" modal, then paste the TON Connect link. + await app.getByTestId('connect-dapp-button').click(); await app.getByTestId('tonconnect-url').fill(url); await app.getByTestId('tonconnect-process').click(); @@ -79,11 +77,11 @@ export class DemoWallet extends WalletApp { return; } - const modal = app.getByTestId('request').filter({ hasText: 'Connect Request' }); + const modal = app.getByTestId('connect-request'); await modal.waitFor({ state: 'visible' }); const chose = app.getByTestId(confirm ? 'connect-approve' : 'connect-reject'); - await chose.waitFor({ state: 'attached' }); + await chose.waitFor({ state: 'visible' }); await chose.click(); await modal.waitFor({ state: 'detached' }); await this.close(); @@ -91,10 +89,10 @@ export class DemoWallet extends WalletApp { async signData(confirm: boolean = true): Promise { const app = await this.open(); - const modal = app.getByTestId('request').filter({ hasText: 'Sign Data Request' }); + const modal = app.getByTestId('sign-data-request'); await modal.waitFor({ state: 'visible' }); const chose = app.getByTestId(confirm ? 'sign-data-approve' : 'sign-data-reject'); - await chose.waitFor({ state: 'attached' }); + await chose.waitFor({ state: 'visible' }); await chose.click(); await modal.waitFor({ state: 'detached' }); await this.close(); @@ -110,10 +108,10 @@ export class DemoWallet extends WalletApp { async accept(confirm: boolean = true): Promise { const app = await this.open(); - const modal = app.getByTestId('request').filter({ hasText: 'Transaction Request' }); + const modal = app.getByTestId('transaction-request'); await modal.waitFor({ state: 'visible' }); const chose = app.getByTestId(confirm ? 'send-transaction-approve' : 'send-transaction-reject'); - await chose.waitFor({ state: 'attached' }); + await chose.waitFor({ state: 'visible' }); await chose.click(); await modal.waitFor({ state: 'detached' }); await this.close(); @@ -133,19 +131,15 @@ export class DemoWallet extends WalletApp { await app.getByTestId('use-my-address').click(); // Fill in amount - await app.getByTestId('amount-input').fill(amount); + await app.getByTestId('send-amount-input').fill(amount); // Click send button await app.getByTestId('send-submit').click(); - // Wait for transaction request modal - const modal = app.getByTestId('request').filter({ hasText: 'Transaction Request' }); - await modal.waitFor({ state: 'visible' }); - - // Approve or reject + // Wait for the transaction request modal (anchored on its type-specific action button) const chose = app.getByTestId(confirm ? 'send-transaction-approve' : 'send-transaction-reject'); - await chose.waitFor({ state: 'attached' }); + await chose.waitFor({ state: 'visible' }); await chose.click(); - await modal.waitFor({ state: 'detached' }); + await chose.waitFor({ state: 'detached' }); } } diff --git a/apps/demo-wallet/e2e/localSendTransaction.spec.ts b/apps/demo-wallet/e2e/localSendTransaction.spec.ts index 2c8c2214b..b586aa408 100644 --- a/apps/demo-wallet/e2e/localSendTransaction.spec.ts +++ b/apps/demo-wallet/e2e/localSendTransaction.spec.ts @@ -47,9 +47,10 @@ test.describe('Local Send Transaction', () => { // Send TON locally to own address and approve await wallet.sendTonToSelf('0.001', true); - // Verify we're back on wallet page (transaction was processed) + // Verify we're back on the dashboard (transaction was processed). The settings + // button only exists on the wallet dashboard, so it's a stable anchor. const app = await wallet.open(); - await expect(app.getByTestId('title').filter({ hasText: 'TON Wallet' })).toBeVisible({ timeout: 10000 }); + await expect(app.getByTestId('wallet-menu')).toBeVisible({ timeout: 10000 }); await wallet.close(); }); diff --git a/apps/demo-wallet/e2e/pages/SetupPasswordPage.ts b/apps/demo-wallet/e2e/pages/SetupPasswordPage.ts index 465a9b60e..fade8e6fb 100644 --- a/apps/demo-wallet/e2e/pages/SetupPasswordPage.ts +++ b/apps/demo-wallet/e2e/pages/SetupPasswordPage.ts @@ -13,10 +13,6 @@ export class SetupPasswordPage { // Locators - get title() { - return this.page.getByTestId('title').filter({ hasText: 'Setup Password' }); - } - get subtitle() { return this.page.getByTestId('subtitle'); } @@ -34,17 +30,17 @@ export class SetupPasswordPage { } get helperText() { - return this.page.getByText('At least 4 characters'); + return this.page.getByText('Make sure to remember', { exact: false }); } get errorMessage() { - return this.page.locator('[data-testid="password-error"], .text-red-600'); + return this.page.locator('[data-testid="password-error"], .text-red-500'); } // Actions async waitForPage() { - await this.title.waitFor({ state: 'visible' }); + await this.passwordInput.waitFor({ state: 'visible' }); } async fillPassword(password: string) { diff --git a/apps/demo-wallet/e2e/pages/SetupWalletPage.ts b/apps/demo-wallet/e2e/pages/SetupWalletPage.ts index 7c3b402a2..20a69770b 100644 --- a/apps/demo-wallet/e2e/pages/SetupWalletPage.ts +++ b/apps/demo-wallet/e2e/pages/SetupWalletPage.ts @@ -8,14 +8,61 @@ import type { Page } from '@playwright/test'; +/** + * The screen reached right after setting a password for the "create" path — + * the redesigned "Recovery phrase" (create-wallet) screen. + */ export class SetupWalletPage { + /** Hold-to-continue gesture duration on the save-phrase confirmation modal (ms). */ + private static readonly HOLD_DURATION = 1500; + constructor(private readonly page: Page) {} - get title() { - return this.page.getByTestId('title').filter({ hasText: 'Setup Wallet' }); + get revealButton() { + return this.page.getByTestId('reveal-mnemonic'); + } + + get continueButton() { + return this.page.getByTestId('create-wallet-confirm'); + } + + /** The "Hold to continue" button inside the save-phrase confirmation modal. */ + get holdToContinue() { + return this.page.getByTestId('save-phrase-hold'); } async waitForPage() { - await this.title.waitFor({ state: 'visible' }); + await this.revealButton.waitFor({ state: 'visible' }); + } + + /** Open the save-phrase confirmation modal. */ + async openConfirm() { + await this.continueButton.click(); + await this.holdToContinue.waitFor({ state: 'visible' }); + } + + /** + * Press and hold the button for `ms`, then release. The gesture is wall-clock + * timed in the component, so the press must really last that long. + */ + private async pressHold(ms: number) { + await this.holdToContinue.hover(); + await this.page.mouse.down(); + await this.page.waitForTimeout(ms); + await this.page.mouse.up(); + } + + /** Open the modal and complete the hold-to-continue gesture to create the wallet. */ + async confirmAndCreate() { + await this.openConfirm(); + // Hold past the gesture duration plus the completion delay so onComplete fires. + await this.pressHold(SetupWalletPage.HOLD_DURATION + 700); + } + + /** A short tap on the hold button — not long enough to confirm. */ + async tapHold() { + await this.holdToContinue.hover(); + await this.page.mouse.down(); + await this.page.mouse.up(); } } diff --git a/apps/demo-wallet/e2e/pages/UnlockWalletPage.ts b/apps/demo-wallet/e2e/pages/UnlockWalletPage.ts deleted file mode 100644 index 4c9ee2591..000000000 --- a/apps/demo-wallet/e2e/pages/UnlockWalletPage.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import type { Page } from '@playwright/test'; - -export class UnlockWalletPage { - constructor(private readonly page: Page) {} - - get title() { - return this.page.getByTestId('title').filter({ hasText: 'Unlock Wallet' }); - } - - async waitForPage() { - await this.title.waitFor({ state: 'visible' }); - } -} diff --git a/apps/demo-wallet/e2e/pages/index.ts b/apps/demo-wallet/e2e/pages/index.ts index cf0b5a03b..591f702ec 100644 --- a/apps/demo-wallet/e2e/pages/index.ts +++ b/apps/demo-wallet/e2e/pages/index.ts @@ -8,4 +8,3 @@ export { SetupPasswordPage } from './SetupPasswordPage'; export { SetupWalletPage } from './SetupWalletPage'; -export { UnlockWalletPage } from './UnlockWalletPage'; diff --git a/apps/demo-wallet/e2e/ui-tests/importWallet.spec.ts b/apps/demo-wallet/e2e/ui-tests/importWallet.spec.ts index 34a7bdbc4..2d8c03145 100644 --- a/apps/demo-wallet/e2e/ui-tests/importWallet.spec.ts +++ b/apps/demo-wallet/e2e/ui-tests/importWallet.spec.ts @@ -7,6 +7,7 @@ */ import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; import type { NetworkType } from '@demo/wallet-core'; import { testWithUIFixture } from './UITestFixture'; @@ -39,102 +40,68 @@ const testMatrix: ImportWalletTestCase[] = [ { network: 'testnet', version: 'v5r1', interfaceType: 'signer' }, ]; +/** Welcome → "Add an existing wallet" → "Recovery phrase" → set a password → land on the import screen. */ +async function openImportScreen(page: Page): Promise { + await page.getByTestId('welcome-add-existing').click(); + await page.getByTestId('add-wallet-import').click(); + await page.getByTestId('password').fill(TEST_PASSWORD); + await page.getByTestId('password-confirm').fill(TEST_PASSWORD); + await page.getByTestId('password-submit').click(); + await page.getByTestId('paste-mnemonic').waitFor({ state: 'visible' }); +} + test.describe('Import Wallet Flow', () => { test.beforeEach(async ({ page }) => { if (!TEST_MNEMONIC) { test.skip(true, 'WALLET_MNEMONIC environment variable is required'); } - - // Setup password first - await page.getByTestId('title').filter({ hasText: 'Setup Password' }).waitFor({ state: 'visible' }); - await page.getByTestId('subtitle').filter({ hasText: 'Create Password' }).waitFor({ state: 'visible' }); - await page.getByTestId('password').fill(TEST_PASSWORD); - await page.getByTestId('password-confirm').fill(TEST_PASSWORD); - await page.getByTestId('password-submit').click(); - - // Wait for setup wallet page - Layout title is "Setup Wallet" - await page.getByTestId('title').filter({ hasText: 'Setup Wallet' }).waitFor({ state: 'visible' }); - - // Click on "Import" tab - await page.getByTestId('tab-import').click(); - await page.getByTestId('subtitle').filter({ hasText: 'Import Wallet' }).waitFor({ state: 'visible' }); + await openImportScreen(page); }); for (const testCase of testMatrix) { const testName = `Import wallet - ${testCase.network} / ${testCase.version} / ${testCase.interfaceType}`; test(testName, async ({ page }) => { - // Select network await page.getByTestId(`network-select-${testCase.network}`).click(); - - // Verify network button is enabled await expect(page.getByTestId(`network-select-${testCase.network}`)).toBeEnabled(); - // Select wallet version await page.getByTestId(`version-select-${testCase.version}`).click(); - - // Verify version button is enabled await expect(page.getByTestId(`version-select-${testCase.version}`)).toBeEnabled(); - // Select interface type await page.getByTestId(`interface-select-${testCase.interfaceType}`).click(); - - // Verify interface button is enabled await expect(page.getByTestId(`interface-select-${testCase.interfaceType}`)).toBeEnabled(); - // Paste mnemonic using the Paste button + // Paste the recovery phrase via the Paste button (reads the clipboard). await page.evaluate(async (mnemonic) => { await navigator.clipboard.writeText(mnemonic); }, TEST_MNEMONIC); - - // Click the Paste button await page.getByTestId('paste-mnemonic').click(); - // Wait for words to be filled - await page.waitForTimeout(500); - - // Click Import Wallet button await page.getByTestId('import-wallet-process').click(); - // Verify navigation to wallet dashboard - await page.getByTestId('title').filter({ hasText: 'TON Wallet' }).waitFor({ state: 'visible' }); + // The settings button only exists on the wallet dashboard. + await expect(page.getByTestId('wallet-menu')).toBeVisible(); }); } }); test.describe('Import Wallet - Validation', () => { test.beforeEach(async ({ page }) => { - // Setup password first - await page.getByTestId('title').filter({ hasText: 'Setup Password' }).waitFor({ state: 'visible' }); - await page.getByTestId('subtitle').filter({ hasText: 'Create Password' }).waitFor({ state: 'visible' }); - await page.getByTestId('password').fill(TEST_PASSWORD); - await page.getByTestId('password-confirm').fill(TEST_PASSWORD); - await page.getByTestId('password-submit').click(); - - // Wait for setup wallet page - Layout title is "Setup Wallet" - await page.getByTestId('title').filter({ hasText: 'Setup Wallet' }).waitFor({ state: 'visible' }); - - // Click on "Import" tab - await page.getByTestId('tab-import').click(); - await page.getByTestId('subtitle').filter({ hasText: 'Import Wallet' }).waitFor({ state: 'visible' }); + await openImportScreen(page); }); test('Import button is disabled with no mnemonic', async ({ page }) => { - // The Import Wallet button should be disabled without mnemonic await expect(page.getByTestId('import-wallet-process')).toBeDisabled(); }); test('Import button is disabled with less than 12 words', async ({ page }) => { - // Fill only 10 words const testWords = 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10'; await page.evaluate(async (mnemonic) => { await navigator.clipboard.writeText(mnemonic); }, testWords); await page.getByTestId('paste-mnemonic').click(); - await page.waitForTimeout(300); - // The Import Wallet button should be disabled await expect(page.getByTestId('import-wallet-process')).toBeDisabled(); }); @@ -143,21 +110,14 @@ test.describe('Import Wallet - Validation', () => { test.skip(true, 'WALLET_MNEMONIC environment variable is required'); } - // Paste mnemonic await page.evaluate(async (mnemonic) => { await navigator.clipboard.writeText(mnemonic); }, TEST_MNEMONIC); - await page.getByTestId('paste-mnemonic').click(); - await page.waitForTimeout(300); - // Click Clear button await page.getByTestId('clear-mnemonic').click(); - // Verify all inputs are empty (word count should be 0) await expect(page.getByTestId('word-count')).toHaveText('0/24 words'); - - // Import button should be disabled await expect(page.getByTestId('import-wallet-process')).toBeDisabled(); }); }); diff --git a/apps/demo-wallet/e2e/ui-tests/newWallet.spec.ts b/apps/demo-wallet/e2e/ui-tests/newWallet.spec.ts index cce119954..9b9ecdeef 100644 --- a/apps/demo-wallet/e2e/ui-tests/newWallet.spec.ts +++ b/apps/demo-wallet/e2e/ui-tests/newWallet.spec.ts @@ -10,117 +10,71 @@ import { expect } from '@playwright/test'; import { testWithUIFixture } from './UITestFixture'; import { TEST_PASSWORD } from '../constants'; +import { SetupWalletPage } from '../pages'; const test = testWithUIFixture(); test.describe('New Wallet Flow', () => { test.beforeEach(async ({ page }) => { - // Setup password first - Layout title is "Setup Password" - await page.getByTestId('title').filter({ hasText: 'Setup Password' }).waitFor({ state: 'visible' }); - await page.getByTestId('subtitle').filter({ hasText: 'Create Password' }).waitFor({ state: 'visible' }); + // Welcome → "Create a new wallet" → set a password → land on the Recovery phrase screen. + await page.getByTestId('welcome-create').click(); await page.getByTestId('password').fill(TEST_PASSWORD); await page.getByTestId('password-confirm').fill(TEST_PASSWORD); await page.getByTestId('password-submit').click(); - - // Wait for setup wallet page - Layout title is "Setup Wallet" - await page.getByTestId('title').filter({ hasText: 'Setup Wallet' }).waitFor({ state: 'visible' }); - - // Click on "New" tab (should be default, but ensure it's selected) - await page.getByTestId('tab-create').click(); - await page.getByTestId('subtitle').filter({ hasText: 'Create New Wallet' }).waitFor({ state: 'visible' }); + await page.getByTestId('reveal-mnemonic').waitFor({ state: 'visible' }); }); test('Create new wallet on Mainnet', async ({ page }) => { - // Mainnet should be selected by default and enabled - await expect(page.getByTestId('network-select-mainnet')).toBeEnabled(); + const setupWallet = new SetupWalletPage(page); - // Wait for mnemonic to be generated - await page.getByTestId('reveal-mnemonic').waitFor({ state: 'visible' }); + // Mainnet is selected by default. + await expect(page.getByTestId('network-select-mainnet')).toBeEnabled(); - // Click to reveal mnemonic await page.getByTestId('reveal-mnemonic').click(); - // Verify mnemonic grid is visible (24 words) await expect(page.getByTestId('mnemonic-grid')).toBeVisible(); await expect(page.getByTestId('mnemonic-word-1')).toBeVisible(); - // Check the "I have saved" checkbox - await page.getByTestId('saved-checkbox').check(); - - // Click Import Wallet button - await page.getByTestId('create-wallet-confirm').click(); + // Continue → "Have you saved it?" modal → hold-to-continue. + await setupWallet.confirmAndCreate(); - // Verify navigation to wallet dashboard - await page.getByTestId('title').filter({ hasText: 'TON Wallet' }).waitFor({ state: 'visible' }); + // The settings button only exists on the wallet dashboard. + await expect(page.getByTestId('wallet-menu')).toBeVisible(); }); test('Create new wallet on Testnet', async ({ page }) => { - // Switch to Testnet - await page.getByTestId('network-select-testnet').click(); + const setupWallet = new SetupWalletPage(page); - // Verify testnet button is enabled + await page.getByTestId('network-select-testnet').click(); await expect(page.getByTestId('network-select-testnet')).toBeEnabled(); - // Wait for mnemonic to be generated - await page.getByTestId('reveal-mnemonic').waitFor({ state: 'visible' }); - - // Click to reveal mnemonic await page.getByTestId('reveal-mnemonic').click(); - // Verify mnemonic grid is visible await expect(page.getByTestId('mnemonic-grid')).toBeVisible(); await expect(page.getByTestId('mnemonic-word-1')).toBeVisible(); - // Check the "I have saved" checkbox - await page.getByTestId('saved-checkbox').check(); - - // Click Import Wallet button - await page.getByTestId('create-wallet-confirm').click(); - - // Verify navigation to wallet dashboard - await page.getByTestId('title').filter({ hasText: 'TON Wallet' }).waitFor({ state: 'visible' }); - }); - - test('Generate new mnemonic phrase', async ({ page }) => { - // Wait for initial mnemonic generation - await page.getByTestId('reveal-mnemonic').waitFor({ state: 'visible' }); - - // Reveal the mnemonic - await page.getByTestId('reveal-mnemonic').click(); - - // Get the first word of initial mnemonic - const initialFirstWord = await page.getByTestId('mnemonic-word-1').textContent(); - - // Generate new phrase - await page.getByTestId('generate-new-phrase').click(); + await setupWallet.confirmAndCreate(); - // Wait for new mnemonic - await page.getByTestId('reveal-mnemonic').waitFor({ state: 'visible' }); - - // Reveal again - await page.getByTestId('reveal-mnemonic').click(); - - // Verify mnemonic changed (first word should likely be different) - // Note: There's a small chance they could be the same, but it's very unlikely - const newFirstWord = await page.getByTestId('mnemonic-word-1').textContent(); - expect(newFirstWord).toBeDefined(); - expect(initialFirstWord).toBeDefined(); + await expect(page.getByTestId('wallet-menu')).toBeVisible(); }); test('Cannot proceed without saving confirmation', async ({ page }) => { - // Wait for mnemonic generation - await page.getByTestId('reveal-mnemonic').waitFor({ state: 'visible' }); + const setupWallet = new SetupWalletPage(page); + + // Continue is disabled until the recovery phrase has been revealed. + await expect(setupWallet.continueButton).toBeDisabled(); - // Reveal mnemonic await page.getByTestId('reveal-mnemonic').click(); - // The Import Wallet button should be disabled without checking the save checkbox - await expect(page.getByTestId('create-wallet-confirm')).toBeDisabled(); + await expect(setupWallet.continueButton).toBeEnabled(); - // Check the save checkbox - await page.getByTestId('saved-checkbox').check(); + // Continue only opens the confirmation modal — it doesn't create the wallet. + await setupWallet.openConfirm(); + await expect(setupWallet.holdToContinue).toBeVisible(); - // Now the button should be enabled - await expect(page.getByTestId('create-wallet-confirm')).toBeEnabled(); + // A short tap is not enough; the gesture must be held to confirm. + await setupWallet.tapHold(); + await expect(page.getByTestId('wallet-menu')).toBeHidden(); + await expect(setupWallet.holdToContinue).toBeVisible(); }); }); diff --git a/apps/demo-wallet/e2e/ui-tests/setupPassword.spec.ts b/apps/demo-wallet/e2e/ui-tests/setupPassword.spec.ts index dd14cf207..b495d857e 100644 --- a/apps/demo-wallet/e2e/ui-tests/setupPassword.spec.ts +++ b/apps/demo-wallet/e2e/ui-tests/setupPassword.spec.ts @@ -9,14 +9,13 @@ import { expect } from '@playwright/test'; import { step } from 'allure-js-commons'; -import { SetupPasswordPage, SetupWalletPage, UnlockWalletPage } from '../pages'; +import { SetupPasswordPage, SetupWalletPage } from '../pages'; import { testWithUIFixture } from './UITestFixture'; import { TEST_PASSWORD, LONG_PASSWORD, XSS_PASSWORD_PAYLOAD } from '../constants'; const test = testWithUIFixture().extend<{ setupPassword: SetupPasswordPage; setupWallet: SetupWalletPage; - unlockWallet: UnlockWalletPage; }>({ setupPassword: async ({ page }, use) => { await use(new SetupPasswordPage(page)); @@ -24,20 +23,19 @@ const test = testWithUIFixture().extend<{ setupWallet: async ({ page }, use) => { await use(new SetupWalletPage(page)); }, - unlockWallet: async ({ page }, use) => { - await use(new UnlockWalletPage(page)); - }, }); test.describe('SetupPassword', () => { - test.beforeEach(async ({ setupPassword }) => { + test.beforeEach(async ({ page, setupPassword }) => { + // The redesign starts on Welcome; "Create a new wallet" leads to the password screen. + await page.getByTestId('welcome-create').click(); await setupPassword.waitForPage(); }); test.describe('display', () => { test('page renders correctly', async ({ setupPassword }) => { - await step('Verify subtitle contains "Create Password"', async () => { - await expect(setupPassword.subtitle).toHaveText('Create Password'); + await step('Verify subtitle contains "Create a password"', async () => { + await expect(setupPassword.subtitle).toHaveText('Create a password'); }); await step('Verify password input is visible', async () => { await expect(setupPassword.passwordInput).toBeVisible(); @@ -54,7 +52,7 @@ test.describe('SetupPassword', () => { }); test('helper text is visible', async ({ setupPassword }) => { - await step('Verify helper text contains "At least 4 characters"', async () => { + await step('Verify the "remember your password" helper is visible', async () => { await expect(setupPassword.helperText).toBeVisible(); }); }); @@ -104,17 +102,19 @@ test.describe('SetupPassword', () => { test.describe('validation errors', () => { test('error when password is less than 4 characters', async ({ setupPassword, page }) => { - await step('Submit password shorter than 4 characters', async () => { - await setupPassword.submit('ab'); + // Submit stays disabled for an invalid password, so the hint shows on input (no click). + await step('Type a password shorter than 4 characters', async () => { + await setupPassword.fillPassword('ab'); }); - await step('Verify error contains "Password must be at least 4 characters long"', async () => { - await expect(page.getByText('Password must be at least 4 characters long')).toBeVisible(); + await step('Verify error contains "Password must be at least 4 characters"', async () => { + await expect(page.getByText('Password must be at least 4 characters')).toBeVisible(); }); }); test('error when passwords do not match', async ({ setupPassword, page }) => { - await step('Submit mismatched passwords', async () => { - await setupPassword.submit(TEST_PASSWORD, 'diff'); + await step('Type mismatched passwords', async () => { + await setupPassword.fillPassword(TEST_PASSWORD); + await setupPassword.fillConfirm('diff'); }); await step('Verify error contains "Passwords do not match"', async () => { await expect(page.getByText('Passwords do not match')).toBeVisible(); @@ -122,8 +122,9 @@ test.describe('SetupPassword', () => { }); test('fields retain values after error', async ({ setupPassword }) => { - await step('Submit password shorter than 4 characters', async () => { - await setupPassword.submit('ab'); + await step('Type a password shorter than 4 characters', async () => { + await setupPassword.fillPassword('ab'); + await setupPassword.fillConfirm('ab'); }); await step('Verify password field retains its value', async () => { await expect(setupPassword.passwordInput).toHaveValue('ab'); @@ -135,11 +136,11 @@ test.describe('SetupPassword', () => { }); test.describe('positive', () => { - test('valid password redirects to /setup-wallet', async ({ setupPassword, setupWallet }) => { + test('valid password redirects to the recovery-phrase screen', async ({ setupPassword, setupWallet }) => { await step('Submit valid password', async () => { await setupPassword.submit(TEST_PASSWORD); }); - await step('Verify Setup Wallet page is displayed', async () => { + await step('Verify the recovery-phrase (create-wallet) screen is displayed', async () => { await setupWallet.waitForPage(); }); }); @@ -150,7 +151,7 @@ test.describe('SetupPassword', () => { await step('Submit valid password', async () => { await setupPassword.submit(TEST_PASSWORD); }); - await step('Wait for Setup Wallet page', async () => { + await step('Wait for the recovery-phrase screen', async () => { await setupWallet.waitForPage(); }); @@ -173,7 +174,7 @@ test.describe('SetupPassword', () => { await step('Submit 500-character password', async () => { await setupPassword.submit(LONG_PASSWORD); }); - await step('Verify Setup Wallet page is displayed', async () => { + await step('Verify the recovery-phrase screen is displayed', async () => { await setupWallet.waitForPage(); }); }); @@ -185,7 +186,7 @@ test.describe('SetupPassword', () => { await step('Paste and submit password', async () => { await setupPassword.submitByPasting(TEST_PASSWORD); }); - await step('Verify Setup Wallet page is displayed', async () => { + await step('Verify the recovery-phrase screen is displayed', async () => { await setupWallet.waitForPage(); }); }); @@ -203,7 +204,7 @@ test.describe('SetupPassword', () => { await step('Submit XSS payload as password', async () => { await setupPassword.submit(XSS_PASSWORD_PAYLOAD); }); - await step('Verify Setup Wallet page is displayed', async () => { + await step('Verify the recovery-phrase screen is displayed', async () => { await setupWallet.waitForPage(); }); await step('Verify no XSS dialog was triggered', async () => { diff --git a/apps/demo-wallet/e2e/utils.ts b/apps/demo-wallet/e2e/utils.ts index 53b7c81a8..11fc67435 100644 --- a/apps/demo-wallet/e2e/utils.ts +++ b/apps/demo-wallet/e2e/utils.ts @@ -6,7 +6,7 @@ * */ -import { createComponentLogger } from '../src/utils/logger'; +import { createComponentLogger } from '../src/core/lib/logger'; const log = createComponentLogger('Allure'); diff --git a/apps/demo-wallet/package.json b/apps/demo-wallet/package.json index 9f47952c9..b90d6bf72 100644 --- a/apps/demo-wallet/package.json +++ b/apps/demo-wallet/package.json @@ -24,10 +24,15 @@ "dependencies": { "@demo/v4ledger-adapter": "workspace:*", "@demo/wallet-core": "workspace:*", + "@fontsource-variable/inter": "^5.2.8", "@ledgerhq/hw-transport-webhid": "6.34.0", "@ledgerhq/hw-transport-webusb": "6.33.0", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-select": "2.2.6", "@scure/bip39": "^2.2.0", "@tailwindcss/vite": "^4.3.0", + "@telegram-apps/sdk": "^3.11.8", "@ton/core": "catalog:", "@ton/crypto": "catalog:", "@ton/walletkit": "workspace:*", @@ -41,6 +46,7 @@ "immer": "^10.2.0", "lucide-react": "^0.562.0", "next-themes": "^0.4.6", + "qr-code-styling": "^1.9.2", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "^7.15.1", @@ -49,6 +55,7 @@ "tailwindcss": "^4.3.0", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", "zustand": "^5.0.13" }, "devDependencies": { diff --git a/apps/demo-wallet/public/gram.svg b/apps/demo-wallet/public/gram.svg new file mode 100644 index 000000000..6a79055bc --- /dev/null +++ b/apps/demo-wallet/public/gram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo-wallet/public/walletkit.svg b/apps/demo-wallet/public/walletkit.svg new file mode 100644 index 000000000..910f552a8 --- /dev/null +++ b/apps/demo-wallet/public/walletkit.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/demo-wallet/src/App.css b/apps/demo-wallet/src/App.css index f611305f8..1d8be7bdb 100644 --- a/apps/demo-wallet/src/App.css +++ b/apps/demo-wallet/src/App.css @@ -5,6 +5,20 @@ @custom-variant dark (&:is(.dark *)); +@theme { + --font-sans: 'Inter Variable', system-ui, Avenir, Helvetica, Arial, sans-serif; + --font-display: 'Inter Display', 'Inter Variable', system-ui, sans-serif; +} + +@font-face { + font-family: 'Inter Display'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url('@fontsource-variable/inter/files/inter-latin-opsz-normal.woff2') format('woff2-variations'); + font-variation-settings: 'opsz' 32; +} + #root { width: 100%; } @@ -159,4 +173,11 @@ body { @apply bg-background text-foreground; } + button:not(:disabled), + [role='button'], + a, + summary, + label[for] { + cursor: pointer; + } } diff --git a/apps/demo-wallet/src/App.tsx b/apps/demo-wallet/src/App.tsx index c795d41ba..50319dfa9 100644 --- a/apps/demo-wallet/src/App.tsx +++ b/apps/demo-wallet/src/App.tsx @@ -10,9 +10,8 @@ import TransportWebHID from '@ledgerhq/hw-transport-webhid'; import { WalletProvider } from '@demo/wallet-core'; import type { WalletKitConfig } from '@demo/wallet-core'; -import { AppRouter } from './components'; - -import { Toaster } from '@/components/ui/sonner'; +import { AppRouter } from '@/core/routing'; +import { Toaster } from '@/core/components/ui/sonner'; import { DISABLE_AUTO_EMULATION, DISABLE_HTTP_BRIDGE, @@ -23,9 +22,9 @@ import { ENV_TON_API_KEY_TESTNET, ENV_TON_API_KEY_TETRA, ENV_TON_API_PROVIDER, -} from '@/lib/env'; -import { isExtension } from '@/utils/isExtension'; -import type { SendMessageToExtensionContent, CreateExtensionStorageAdapter } from '@/lib/extensionPopup'; +} from '@/core/lib/env'; +import { isExtension } from '@/core/lib/is-extension'; +import type { SendMessageToExtensionContent, CreateExtensionStorageAdapter } from '@/core/lib/extensionPopup'; import './App.css'; import './storePatch'; @@ -34,7 +33,7 @@ let jsBridgeTransport: typeof SendMessageToExtensionContent | undefined; let storage: ReturnType | undefined; if (isExtension()) { - const { SendMessageToExtensionContent, CreateExtensionStorageAdapter } = await import('@/lib/extensionPopup'); + const { SendMessageToExtensionContent, CreateExtensionStorageAdapter } = await import('@/core/lib/extensionPopup'); jsBridgeTransport = SendMessageToExtensionContent; storage = CreateExtensionStorageAdapter(); } diff --git a/apps/demo-wallet/src/assets/react.svg b/apps/demo-wallet/src/assets/react.svg deleted file mode 100644 index 6c87de9bb..000000000 --- a/apps/demo-wallet/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/demo-wallet/src/components/ActionPreviewList.tsx b/apps/demo-wallet/src/components/ActionPreviewList.tsx deleted file mode 100644 index 586d002e1..000000000 --- a/apps/demo-wallet/src/components/ActionPreviewList.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { memo, useMemo } from 'react'; -import type { ToncenterEmulationResponse } from '@ton/walletkit'; -import { emulationEvent } from '@ton/walletkit'; - -interface ActionPreviewListProps { - emulationResult: ToncenterEmulationResponse; - walletAddress?: string; - title?: string; - className?: string; -} - -export const ActionPreviewList: React.FC = memo( - ({ emulationResult, walletAddress, title = 'Actions:', className }) => { - if (!walletAddress) { - return null; - } - const event = useMemo(() => emulationEvent(emulationResult, walletAddress), [emulationResult, walletAddress]); - - return ( -
- {title &&
{title}
} -
- {event.actions.map((a, idx) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sp = (a as any).simplePreview as - | { - name: string; - description: string; - value: string; - valueImage?: string; - accounts: Array<{ address: string; name?: string }>; - } - | undefined; - if (!sp) { - return ( -
- {a.type} -
- ); - } - return ( -
- {sp.valueImage ? ( - {sp.name} - ) : ( -
- {a.type.slice(0, 1)} -
- )} -
-
-
{sp.name}
-
{sp.value}
-
- {sp.description && ( -
{sp.description}
- )} - {Array.isArray(sp.accounts) && sp.accounts.length > 0 && ( -
- {sp.accounts.map((acc, i) => ( - - {acc.name ?? shortenAddress(acc.address)} - - ))} -
- )} -
-
- ); - })} -
-
- ); - }, -); - -function shortenAddress(addr?: string): string { - if (!addr) return ''; - const a = String(addr); - return a.length <= 12 ? a : `${a.slice(0, 6)}...${a.slice(-6)}`; -} diff --git a/apps/demo-wallet/src/components/Button.tsx b/apps/demo-wallet/src/components/Button.tsx deleted file mode 100644 index 98e8ffe3b..000000000 --- a/apps/demo-wallet/src/components/Button.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React from 'react'; - -import { LoaderCircle } from './LoaderCircle'; - -interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: 'primary' | 'secondary' | 'danger'; - size?: 'sm' | 'md' | 'lg'; - isLoading?: boolean; - children: React.ReactNode; -} - -export const Button: React.FC = ({ - variant = 'primary', - size = 'md', - isLoading = false, - children, - disabled, - className = '', - ...props -}) => { - const baseClasses = - 'font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center'; - - const variantClasses = { - primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', - secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500', - danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', - }; - - const sizeClasses = { - sm: 'px-3 py-2 text-sm', - md: 'px-4 py-2 text-base', - lg: 'px-6 py-3 text-lg', - }; - - return ( - - ); -}; diff --git a/apps/demo-wallet/src/components/ConnectRequestModal.tsx b/apps/demo-wallet/src/components/ConnectRequestModal.tsx deleted file mode 100644 index fa681efe9..000000000 --- a/apps/demo-wallet/src/components/ConnectRequestModal.tsx +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { useState, useMemo, useEffect } from 'react'; -import type { ConnectionRequestEvent, Wallet } from '@ton/walletkit'; -import { getNetworkType, getNetworkLabel } from '@demo/wallet-core'; -import type { SavedWallet } from '@demo/wallet-core'; -import { toast } from 'sonner'; - -import { Button } from './Button'; -import { DAppInfo } from './DAppInfo'; -import { WalletPreview } from './WalletPreview'; -import { createComponentLogger } from '../utils/logger'; - -// Create logger for connect request modal -const log = createComponentLogger('ConnectRequestModal'); - -interface ConnectRequestModalProps { - request: ConnectionRequestEvent; - availableWallets: Wallet[]; - savedWallets: SavedWallet[]; - currentWallet?: Wallet; - isOpen: boolean; - onApprove: (selectedWallet: Wallet) => void; - onReject: (reason?: string) => void; -} - -export const ConnectRequestModal: React.FC = ({ - request, - availableWallets, - savedWallets, - currentWallet, - isOpen, - onApprove, - onReject, -}) => { - const [selectedWallet, setSelectedWallet] = useState(currentWallet || null); - const [isLoading, setIsLoading] = useState(false); - const [showAllWallets, setShowAllWallets] = useState(false); - - // Auto-select current wallet or first available wallet if selectedWallet is null - useEffect(() => { - if (selectedWallet !== null) return; - - const intervalId = setInterval(() => { - const walletToSelect = currentWallet || availableWallets[0]; - if (walletToSelect) { - setSelectedWallet(walletToSelect); - } - }, 100); - - return () => clearInterval(intervalId); - }, [selectedWallet, availableWallets, currentWallet]); - - // Create a map of wallet IDs to SavedWallet data - const walletDataMap = useMemo(() => { - const map = new Map(); - savedWallets.forEach((savedWallet) => { - map.set(savedWallet.id, savedWallet); - }); - return map; - }, [savedWallets]); - - const handleApprove = async () => { - if (!selectedWallet) return; - - setIsLoading(true); - try { - await onApprove(selectedWallet); - } catch (error) { - log.error('Failed to approve connection:', error); - toast.error('Failed to approve connection', { - description: (error as Error)?.message, - }); - } finally { - setIsLoading(false); - } - }; - - const handleReject = () => { - onReject('User rejected the connection'); - }; - - const formatAddress = (address: string, length: number = 16): string => { - if (!address) return ''; - let halfLength = Math.floor(length / 2); - if (halfLength > 24) { - halfLength = 24; - } - let dots = '...'; - if (halfLength > 23) { - dots = ''; - } else if (halfLength > 22) { - dots = '.'; - } - return `${address.slice(0, halfLength)}${dots}${address.slice(-halfLength)}`; - }; - - const getWalletNetworkInfo = (wallet?: Wallet): { label: string; isTestnet: boolean } => { - if (!wallet) return { label: 'Unknown', isTestnet: false }; - const networkType = getNetworkType(wallet.getNetwork()); - return { - label: getNetworkLabel(networkType), - isTestnet: networkType !== 'mainnet', - }; - }; - - if (!isOpen) return null; - - return ( -
-
- {/* Scrollable content */} -
-
- {/* Header */} -
-

- Connect Request -

-

A dApp wants to connect to your wallet

- {selectedWallet && ( - - {getWalletNetworkInfo(selectedWallet).label} - - )} -
- - {/* dApp Information */} - - - {/* Requested Permissions */} - {(request.preview.permissions || []).length > 0 && ( -
-

Requested Permissions:

-
- {request.preview.permissions?.map((permission, index) => ( -
-
-
-
-
- {permission.title} -
-

- {permission.description} -

- {permission.name === 'ton_addr' && selectedWallet && ( -

- Your address:{' '} - {formatAddress(selectedWallet.getAddress(), 20)} -

- )} -
-
-
- ))} -
-
- )} - - {/* Wallet Selection */} - {availableWallets.length > 0 && ( -
-
-

- {availableWallets.length > 1 ? 'Connecting with:' : 'Wallet:'} -

- {availableWallets.length > 1 && !showAllWallets && ( - - )} -
- - {showAllWallets ? ( -
- {availableWallets.map((wallet, index) => { - const walletId = wallet.getWalletId(); - const savedWallet = walletDataMap.get(walletId); - const networkLabel = getNetworkType(wallet.getNetwork()); - - return ( - - ); - })} - -
- ) : ( - selectedWallet && ( -
- -
- ) - )} -
- )} - - {/* Warning */} -
-
-
- - - -
-
-

- Only connect to trusted applications. This will give the dApp access to your - wallet address and allow it to request transactions. -

-
-
-
-
-
- - {/* Action Buttons — always visible at bottom */} -
- - -
-
-
- ); -}; diff --git a/apps/demo-wallet/src/components/CreateWallet.tsx b/apps/demo-wallet/src/components/CreateWallet.tsx deleted file mode 100644 index d7381880f..000000000 --- a/apps/demo-wallet/src/components/CreateWallet.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { useState, useCallback, useEffect } from 'react'; -import { CreateTonMnemonic } from '@ton/walletkit'; -import type { NetworkType } from '@demo/wallet-core'; - -import { Button } from './Button'; -import { MnemonicGrid } from './MnemonicGrid'; -import { MnemonicSkeleton } from './MnemonicSkeleton'; -import { NetworkSelector } from './NetworkSelector'; - -interface CreateWalletProps { - onConfirm: (mnemonic: string[], network: NetworkType) => Promise; - isLoading: boolean; - error: string; -} - -export const CreateWallet: React.FC = ({ onConfirm, isLoading, error }) => { - const [mnemonic, setMnemonic] = useState([]); - const [showMnemonic, setShowMnemonic] = useState(false); - const [isSaved, setIsSaved] = useState(false); - const [isGenerating, setIsGenerating] = useState(false); - const [network, setNetwork] = useState('mainnet'); - const [generationError, setGenerationError] = useState(''); - - const generateMnemonic = useCallback(async () => { - setIsGenerating(true); - setGenerationError(''); - setShowMnemonic(false); - setIsSaved(false); - try { - const newMnemonic = await CreateTonMnemonic(); - setMnemonic(newMnemonic); - } catch (err) { - setGenerationError(err instanceof Error ? err.message : 'Failed to generate mnemonic'); - } finally { - setIsGenerating(false); - } - }, []); - - // Generate mnemonic on mount - useEffect(() => { - generateMnemonic(); - }, [generateMnemonic]); - - const handleConfirm = async () => { - await onConfirm(mnemonic, network); - }; - - return ( -
-
-

- Create New Wallet -

-

Write down these 24 words in order.

-
- -
-
- - -
-
- {isGenerating ? ( - - ) : mnemonic.length > 0 ? ( - - ) : ( - - )} -
- - {!showMnemonic && !isGenerating && mnemonic.length > 0 && ( -
- -
- )} -
- - {showMnemonic && mnemonic.length > 0 && ( -
- - - -
- )} - - - - {(generationError || error) && ( -
{generationError || error}
- )} -
-
-
- ); -}; diff --git a/apps/demo-wallet/src/components/DAppInfo.tsx b/apps/demo-wallet/src/components/DAppInfo.tsx deleted file mode 100644 index 36a8a936a..000000000 --- a/apps/demo-wallet/src/components/DAppInfo.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React from 'react'; - -interface DAppInfoProps { - /** dApp name */ - name?: string; - /** dApp description */ - description?: string; - /** dApp URL */ - url?: string; - /** dApp icon URL */ - iconUrl?: string; - /** Optional additional className */ - className?: string; -} - -export const DAppInfo: React.FC = ({ name, description, url, iconUrl, className = '' }) => { - let host; - try { - host = url ? new URL(url).host : undefined; - } catch (_error) { - host = url; - } - - return ( -
-
- {/* dApp Icon */} - {iconUrl ? ( - {name} { - // Hide image if it fails to load - e.currentTarget.style.display = 'none'; - }} - /> - ) : ( -
- - - -
- )} - -
-

{name || 'Unknown dApp'}

- {description &&

{description}

} - {host &&

{host}

} -
-
-
- ); -}; diff --git a/apps/demo-wallet/src/components/Input.tsx b/apps/demo-wallet/src/components/Input.tsx deleted file mode 100644 index 1c64eae75..000000000 --- a/apps/demo-wallet/src/components/Input.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React from 'react'; - -interface InputProps extends React.InputHTMLAttributes { - label?: string; - error?: string; - helperText?: string; -} - -export const Input: React.FC = ({ label, error, helperText, className = '', ...props }) => { - const inputClasses = ` - w-full px-3 py-2 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 - ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300'} - ${className} - text-black - `; - - return ( -
- {label && } - - {error &&

{error}

} - {helperText && !error &&

{helperText}

} -
- ); -}; diff --git a/apps/demo-wallet/src/components/JettonsCard.tsx b/apps/demo-wallet/src/components/JettonsCard.tsx deleted file mode 100644 index d140d1157..000000000 --- a/apps/demo-wallet/src/components/JettonsCard.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React from 'react'; -import { useJettons } from '@demo/wallet-core'; - -import { Button } from './Button'; -import { Card } from './Card'; -import { JettonRow } from './JettonRow'; -import { createComponentLogger } from '../utils/logger'; - -import { getJettonsName } from '@/utils/jetton'; - -const log = createComponentLogger('JettonsCard'); - -interface JettonsCardProps { - className?: string; - embedded?: boolean; -} - -export const JettonsCard: React.FC = ({ className = '', embedded = false }) => { - const { userJettons, error, loadUserJettons } = useJettons(); - - const formatAddress = (address: string): string => { - return `${address.slice(0, 4)}...${address.slice(-4)}`; - }; - - const topJettons = userJettons.slice(0, 3); - const totalJettons = userJettons.length; - - const errorContent = ( -
-
- - - -
-

Failed to load jettons

- -
- ); - - if (error) { - return embedded ? ( -
{errorContent}
- ) : ( - - {errorContent} - - ); - } - - const mainContent = - totalJettons === 0 ? ( -
-

No jettons yet

-
- ) : ( -
- {topJettons.map((jetton) => ( - log.info('Jetton clicked:', getJettonsName(jetton))} - inline - /> - ))} - {totalJettons > 3 &&

+{totalJettons - 3} more

} -
- ); - - const wrapper = (children: React.ReactNode) => - embedded ? ( -
{children}
- ) : ( - - {children} - - ); - - return wrapper(mainContent); -}; diff --git a/apps/demo-wallet/src/components/JettonsList.tsx b/apps/demo-wallet/src/components/JettonsList.tsx deleted file mode 100644 index 91de1db57..000000000 --- a/apps/demo-wallet/src/components/JettonsList.tsx +++ /dev/null @@ -1,361 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { useState } from 'react'; -import type { Jetton } from '@ton/walletkit'; -import { useJettons } from '@demo/wallet-core'; - -import { createComponentLogger } from '../utils/logger'; -import { Button } from './Button'; - -import { useFormatJetton, useFormattedJetton } from '@/hooks/useFormattedJetton'; - -// Create logger for jettons list -const log = createComponentLogger('JettonsList'); - -interface JettonsListProps { - className?: string; - maxItems?: number; - showRefreshButton?: boolean; -} - -export const JettonsList: React.FC = ({ - className = '', - maxItems = 10, - showRefreshButton = true, -}) => { - const { userJettons, isLoadingJettons, isRefreshing, error, refreshJettons } = useJettons(); - - const [selectedJetton, setSelectedJetton] = useState(null); - - const formatJetton = useFormatJetton(); - const selectedJettonInfo = useFormattedJetton(selectedJetton); - - const handleRefresh = async () => { - try { - await refreshJettons(); - } catch (err) { - log.error('Error refreshing jettons:', err); - } - }; - - const formatAddress = (address: string): string => { - return `${address.slice(0, 6)}...${address.slice(-6)}`; - }; - - const displayedJettons = userJettons.slice(0, maxItems); - - if (error) { - return ( -
-
-
- - - -
-
-

Error loading jettons

-

{error}

-
- -
-
-
-
- ); - } - - return ( -
-
-

Your Jettons

- {showRefreshButton && ( - - )} -
- {userJettons.length === 0 ? ( -
-
- Loading jettons... -
- ) : displayedJettons.length === 0 ? ( -
-
- - - -
-

No jettons found

-

- Your jetton tokens will appear here when you receive them -

-
- ) : ( -
- {displayedJettons.map((jetton) => { - const jettonInfo = formatJetton(jetton); - - return ( -
setSelectedJetton(jetton)} - > -
-
- {jettonInfo.image ? ( - {jettonInfo.name} { - // Fallback to initials if image fails to load - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - const parent = target.parentElement; - if (parent) { - parent.innerHTML = jettonInfo.symbol?.slice(0, 2) || ''; - parent.className += ' text-xs font-bold text-gray-600'; - } - }} - /> - ) : ( - - {jettonInfo.symbol?.slice(0, 2)} - - )} -
-
-

- {jettonInfo.name || jettonInfo.symbol} -

-

- {jettonInfo.symbol} • {formatAddress(jetton.address)} -

- {/*{jetton.verification?.verified && (*/} - {/*
*/} - {/* */} - {/* */} - {/* */} - {/* Verified*/} - {/*
*/} - {/*)}*/} -
-
-
-

{jettonInfo.balance}

-

{jettonInfo.symbol}

- {/*{jetton.usdValue &&

≈ ${jetton.usdValue}

}*/} -
-
- ); - })} - - {userJettons.length > maxItems && ( -
-

- Showing {maxItems} of {userJettons.length} jettons -

-
- )} -
- )} - {/* Jetton Details Modal */} - {selectedJettonInfo && ( -
-
-
-
-
-
- {selectedJettonInfo.image ? ( - {selectedJettonInfo.name} - ) : ( - - {selectedJettonInfo.symbol?.slice(0, 2)} - - )} -
-
-

- {selectedJettonInfo.name || selectedJettonInfo.symbol} -

-

{selectedJettonInfo.symbol}

-
-
- -
- -
-
- -

{selectedJettonInfo.balance}

- {/*{selectedJetton.usdValue && (*/} - {/*

≈ ${selectedJetton.usdValue} USD

*/} - {/*)}*/} -
- -
- -

- {selectedJettonInfo.address} -

-
- -
- -

- {selectedJettonInfo.walletAddress} -

-
- - {selectedJettonInfo.description && ( -
- -

{selectedJettonInfo.description}

-
- )} - -
-
- -

{selectedJettonInfo.decimals}

-
- {/*{selectedJetton.totalSupply && (*/} - {/*
*/} - {/* */} - {/*

*/} - {/* {formatJettonAmount(*/} - {/* selectedJetton.totalSupply,*/} - {/* selectedJetton.decimals,*/} - {/* )}*/} - {/*

*/} - {/*
*/} - {/*)}*/} -
- - {/*<>{selectedJetton.verification && (*/} - {/*
*/} - {/* */} - {/*
*/} - {/* {selectedJetton.verification.verified ? (*/} - {/* <>*/} - {/* */} - {/* */} - {/* */} - {/* */} - {/* Verified ({selectedJetton.verification.source})*/} - {/* */} - {/* */} - {/* ) : (*/} - {/* <>*/} - {/* */} - {/* */} - {/* */} - {/* Not verified*/} - {/* */} - {/* )}*/} - {/*
*/} - {/* {selectedJetton.verification.warnings &&*/} - {/* selectedJetton.verification.warnings.length > 0 && (*/} - {/*
*/} - {/*
    */} - {/* {selectedJetton.verification.warnings.map((warning, index) => (*/} - {/*
  • • {warning}
  • */} - {/* ))}*/} - {/*
*/} - {/*
*/} - {/* )}*/} - {/*
*/} - {/*)}*/} -
- -
- - -
-
-
-
- )} -
- ); -}; diff --git a/apps/demo-wallet/src/components/Layout.tsx b/apps/demo-wallet/src/components/Layout.tsx deleted file mode 100644 index 8fc99b049..000000000 --- a/apps/demo-wallet/src/components/Layout.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React from 'react'; - -import { SettingsDropdown } from './SettingsDropdown'; -// import { StreamingStatus } from './StreamingStatus'; - -interface LayoutProps { - children: React.ReactNode; - title?: string; - showLogout?: boolean; - headerAction?: React.ReactNode; - onBack?: () => void; -} - -export const Layout: React.FC = ({ - children, - title = 'TON Wallet', - showLogout = false, - headerAction, - onBack, -}) => { - return ( -
-
-
- {onBack && ( - - )} -

- {title} -

-
- {headerAction} - {showLogout && ( - <> - {/* */} - - - )} -
-
-
- -
{children}
-
- ); -}; diff --git a/apps/demo-wallet/src/components/LedgerSetup.tsx b/apps/demo-wallet/src/components/LedgerSetup.tsx deleted file mode 100644 index a78082f7b..000000000 --- a/apps/demo-wallet/src/components/LedgerSetup.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { useState } from 'react'; -import { useAuth } from '@demo/wallet-core'; -import type { NetworkType } from '@demo/wallet-core'; - -import { Button } from './Button'; -import { NetworkSelector } from './NetworkSelector'; - -interface LedgerSetupProps { - onConnect: (network: NetworkType) => Promise; - onBack: () => void; - isLoading: boolean; - error: string; -} - -export const LedgerSetup: React.FC = ({ onConnect, onBack, isLoading, error }) => { - const [network, setNetwork] = useState('mainnet'); - const { ledgerAccountNumber, setLedgerAccountNumber } = useAuth(); - - const handleConnect = async () => { - await onConnect(network); - }; - - return ( -
-
-

Connect Ledger

-

Connect your Ledger hardware wallet.

-
- -
-
-
- - -
- Account - setLedgerAccountNumber(parseInt(e.target.value, 10) || 0)} - /> -
-
- -
-

Before you continue:

-
    -
  • Connect Ledger via USB
  • -
  • Unlock with PIN
  • -
  • Open TON app
  • -
-
- -
- - -
- - {error &&
{error}
} -
-
-
- ); -}; diff --git a/apps/demo-wallet/src/components/Modal.tsx b/apps/demo-wallet/src/components/Modal.tsx deleted file mode 100644 index be51cbbd4..000000000 --- a/apps/demo-wallet/src/components/Modal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React from 'react'; -import { createPortal } from 'react-dom'; - -interface ModalProps { - isOpen: boolean; - onClose: () => void; - children: React.ReactNode; - className?: string; -} - -export const ModalContainer: React.FC = ({ isOpen, onClose, children, className = '' }) => { - if (!isOpen) return null; - - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - - return createPortal( -
-
e.stopPropagation()} - > - {children} -
-
, - document.body, - ); -}; - -interface ModalHeaderProps { - children: React.ReactNode; - onClose?: () => void; - className?: string; -} - -export const ModalHeader: React.FC = ({ children, onClose, className = '' }) => { - return ( -
-
-
{children}
- {onClose && ( - - )} -
-
- ); -}; - -interface ModalTitleProps { - children: React.ReactNode; - className?: string; -} - -export const ModalTitle: React.FC = ({ children, className = '' }) => { - return

{children}

; -}; - -interface ModalBodyProps { - children: React.ReactNode; - className?: string; -} - -export const ModalBody: React.FC = ({ children, className = '' }) => { - return
{children}
; -}; - -interface ModalFooterProps { - children: React.ReactNode; - className?: string; -} - -export const ModalFooter: React.FC = ({ children, className = '' }) => { - return
{children}
; -}; - -export const Modal = { - Container: ModalContainer, - Header: ModalHeader, - Title: ModalTitle, - Body: ModalBody, - Footer: ModalFooter, -}; diff --git a/apps/demo-wallet/src/components/NftsCard.tsx b/apps/demo-wallet/src/components/NftsCard.tsx deleted file mode 100644 index f3e609386..000000000 --- a/apps/demo-wallet/src/components/NftsCard.tsx +++ /dev/null @@ -1,335 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { useState } from 'react'; -import type { NFT } from '@ton/walletkit'; -import { useNfts } from '@demo/wallet-core'; - -import { Button } from './Button'; -import { Card } from './Card'; - -interface NftsCardProps { - className?: string; -} - -export const NftsCard: React.FC = ({ className = '' }) => { - const { userNfts, isLoadingNfts, error, loadUserNfts, formatNftIndex } = useNfts(); - const [selectedNft, setSelectedNft] = useState(null); - - const formatAddress = (address: string): string => { - return `${address.slice(0, 4)}...${address.slice(-4)}`; - }; - - const getNftImage = (nft: NFT): string => { - if (!nft?.info?.image) { - return ''; - } - - return ( - nft.info.image.url || - nft.info.image.data || - nft.info.image.mediumUrl || - nft.info.image.largeUrl || - nft.info.image.smallUrl || - '' - ); - }; - - const getNftName = (nft: NFT): string => { - if (nft.info?.name) { - return nft.info.name; - } - - if (nft.index) { - return `NFT ${formatNftIndex(nft.index)}`; - } - - return ''; - }; - - const getNftDescription = (nft: NFT): string | null => { - if (nft.info?.description) { - return nft.info.description; - } - return null; - }; - - const getCollectionName = (item: NFT): string => { - if (item.collection && item.collection.name) { - return item.collection.name; - } - - return 'Unknown Collection'; - }; - - // Show top 3 NFTs - const topNfts = userNfts.slice(0, 3); - const totalNfts = userNfts.length; - - if (error) { - return ( - -
-
- - - -
-

Failed to load NFTs

- -
-
- ); - } - - return ( - - {isLoadingNfts ? ( -
-
- Loading NFTs... -
- ) : totalNfts === 0 ? ( -
-
- - - -
-

No NFTs yet

-

Your NFT collection will appear here

-
- ) : ( -
- {/* Summary */} -
-

- {totalNfts} {totalNfts === 1 ? 'NFT' : 'NFTs'} -

-

Digital collectibles

-
- - {/* Top NFTs */} -
- {topNfts.map((nft) => ( -
setSelectedNft(nft)} - > -
- {getNftImage(nft) ? ( - {getNftName(nft)} { - // Fallback to placeholder if image fails to load - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - const parent = target.parentElement; - if (parent) { - parent.innerHTML = ` - - - - `; - } - }} - /> - ) : ( - - - - )} -
-
-

{getNftName(nft)}

-

- {getCollectionName(nft) || formatAddress(nft.address)} -

- {nft.isOnSale && ( -
- - On Sale - -
- )} -
-
- - - -
-
- ))} -
- - {totalNfts > 3 && ( -
-

Showing 3 of {totalNfts} NFTs

-
- )} -
- )} - - {/* NFT Details Modal */} - {selectedNft && ( -
-
-
-
-

- {getNftName(selectedNft)} -

- -
- -
- {/* NFT Image */} -
- {getNftImage(selectedNft) ? ( - {getNftName(selectedNft)} - ) : ( - - - - )} -
- - {/* Description */} - {getNftDescription(selectedNft) && ( -
- -

{getNftDescription(selectedNft)}

-
- )} - - {/* Collection */} - {selectedNft.collection && ( -
- -

{getCollectionName(selectedNft)}

- {getNftDescription(selectedNft) && ( -

- {getNftDescription(selectedNft)} -

- )} -
- )} - - {/* Details */} -
-
- -

- {selectedNft?.index ? formatNftIndex(selectedNft?.index) : '-'} -

-
-
- -

- {selectedNft.isOnSale ? 'On Sale' : 'Not for Sale'} -

-
-
- -
- -

{selectedNft.address}

-
- - {selectedNft.ownerAddress && ( -
- -

- {selectedNft.ownerAddress} -

-
- )} -
- -
- - -
-
-
-
- )} -
- ); -}; diff --git a/apps/demo-wallet/src/components/RecentTransactions.tsx b/apps/demo-wallet/src/components/RecentTransactions.tsx deleted file mode 100644 index d848260dc..000000000 --- a/apps/demo-wallet/src/components/RecentTransactions.tsx +++ /dev/null @@ -1,498 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; -import { motion } from 'framer-motion'; -import { useShallow } from 'zustand/react/shallow'; -import { useWalletStore } from '@demo/wallet-core'; -import { Base64ToHex } from '@ton/walletkit'; -import type { Event, Action } from '@ton/walletkit'; - -import { formatTonForDisplay, getTonviewerTxUrl, sameAddress } from '../utils'; -import { TraceRow } from './TraceRow'; -import { TransactionErrorState, TransactionLoadingState, TransactionEmptyState, ActionCard } from './transactions'; - -interface RecentTransactionsProps { - embedded?: boolean; -} - -/** - * Recent Transactions component - * Displays a list of recent blockchain transactions for the current wallet - */ -export const RecentTransactions: React.FC = memo(({ embedded = false }) => { - const { events, loadEvents, address, hasNextEvents, pendingTransactions, network } = useWalletStore( - useShallow((state) => { - const activeWallet = state.walletManagement.savedWallets.find( - (w) => w.id === state.walletManagement.activeWalletId, - ); - return { - events: state.walletManagement.events, - loadEvents: state.loadEvents, - address: state.walletManagement.address, - hasNextEvents: state.walletManagement.hasNextEvents, - pendingTransactions: state.walletManagement.pendingTransactions, - network: activeWallet?.network || 'testnet', - }; - }), - ); - const [isInitialLoading, setIsInitialLoading] = useState(true); - const [isPaginating, setIsPaginating] = useState(false); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(0); - const [limit] = useState(10); - const seenKeysRef = useRef>(new Set()); - const hasShownListRef = useRef(false); - - useEffect(() => { - seenKeysRef.current = new Set(); - hasShownListRef.current = false; - }, [address]); - - // Load events when component mounts, address changes, or page changes - useEffect(() => { - const fetchEvents = async () => { - if (!address) return; - - // Determine if this is initial load or pagination - const isInitial = currentPage === 0 && eventItems.length === 0; - - if (isInitial) { - setIsInitialLoading(true); - } else { - setIsPaginating(true); - } - - setError(null); - try { - const offset = currentPage * limit; - await loadEvents(limit, offset); - } catch (_err) { - setError('Failed to load events'); - } finally { - setIsInitialLoading(false); - setIsPaginating(false); - } - }; - - fetchEvents(); - }, [address, loadEvents, currentPage, limit]); - - const handleRefresh = async () => { - if (!address) return; - - setIsPaginating(true); - setError(null); - try { - const offset = currentPage * limit; - await loadEvents(limit, offset); - } catch (_err) { - setError('Failed to refresh events'); - } finally { - setIsPaginating(false); - } - }; - - const handleNextPage = () => { - if (hasNextEvents && !isPaginating) { - setCurrentPage((prev) => prev + 1); - } - }; - - const handlePreviousPage = () => { - if (currentPage > 0 && !isPaginating) { - setCurrentPage((prev) => prev - 1); - } - }; - - const eventItems = useMemo(() => (events || []) as Event[], [events]); - - const { confirmedTraceIds, confirmedExternalHashes } = useMemo(() => { - const traceIds = new Set(); - const extHashes = new Set(); - for (const ev of eventItems) { - if (ev.eventId) traceIds.add(ev.eventId); - if (ev.traceExternalHash) extHashes.add(Base64ToHex(ev.traceExternalHash)); - } - return { confirmedTraceIds: traceIds, confirmedExternalHashes: extHashes }; - }, [eventItems]); - - // Merge pending + events, sort by timestamp desc. Remove pending when we have matching event (confirmed). - const mergedItems = useMemo(() => { - const filteredPending = pendingTransactions.filter((p) => { - if (p.traceId && confirmedTraceIds.has(p.traceId)) return false; - if (p.externalHash && confirmedExternalHashes.has(p.externalHash)) return false; - return true; - }); - // Dedupe pending by trace_id - const seenByTraceId = new Set(); - const dedupedPending = filteredPending.filter((p) => { - if (seenByTraceId.has(p.traceId)) return false; - seenByTraceId.add(p.traceId); - return true; - }); - const pendingAsItems = dedupedPending.map((p) => ({ - type: 'pending' as const, - traceId: p.traceId, - externalHash: p.externalHash, - timestamp: p.preview?.timestamp ?? Math.floor(Date.now() / 1000), - data: p, - })); - const eventAsItems = eventItems.map((ev) => { - return { - type: 'event' as const, - traceId: ev.eventId, - externalHash: ev.traceExternalHash ? Base64ToHex(ev.traceExternalHash) : undefined, - timestamp: ev.timestamp, - data: ev, - }; - }); - const combined = [...pendingAsItems, ...eventAsItems]; - combined.sort((a, b) => b.timestamp - a.timestamp); - - return combined; - }, [pendingTransactions, eventItems, confirmedTraceIds, confirmedExternalHashes]); - - // Reset seenKeys when first showing list so items animate (works with React Strict Mode double-mount) - if (!isInitialLoading && mergedItems.length > 0 && !hasShownListRef.current) { - hasShownListRef.current = true; - seenKeysRef.current = new Set(); - } - - const header = !embedded && ( -
-

Recent Transactions

- -
- ); - - const content = ( -
- {error ? ( - - ) : isInitialLoading ? ( - - ) : mergedItems.length === 0 && currentPage === 0 ? ( - - ) : mergedItems.length === 0 && currentPage > 0 ? ( -
-

No transactions on this page

-
- ) : ( - <> - {/* Loading overlay during pagination */} - {isPaginating && ( -
-
- - - - - Loading... -
-
- )} - -
- {mergedItems.map((item) => { - const itemKey = item.type === 'pending' ? `pending-${item.data.traceId}` : item.traceId; - const isNew = !seenKeysRef.current.has(itemKey); - if (isNew) seenKeysRef.current.add(itemKey); - - const layoutTransition = { type: 'tween' as const, duration: 0.275 }; - const motionProps = { - layout: true, - initial: isNew ? { opacity: 0, y: -20 } : false, - animate: { opacity: 1, y: 0 }, - transition: layoutTransition, - }; - - if (item.type === 'pending') { - const p = item.data; - const preview = p.preview; - const isPending = p.finality !== 'confirmed' && p.finality !== 'finalized'; - - const finality = p.finality ?? 'pending'; - const pendingExplorerHash = p.externalHash ?? p.traceId; - if (p.action) { - return ( - - - - ); - } - - const amountFormatted = preview ? formatTonForDisplay(preview.amount) : '0'; - const description = preview - ? preview.type === 'send' - ? `Sent ${amountFormatted} TON` - : preview.type === 'receive' - ? `Received ${amountFormatted} TON` - : `Transfer ${amountFormatted} TON` - : 'Processing'; - const value = preview ? `${amountFormatted} TON` : '0 TON'; - - const pendingAction = { - type: 'TonTransfer', - id: p.traceId, - status: 'success' as const, - simplePreview: { - name: 'Ton Transfer', - description, - value, - accounts: - preview && address - ? preview.type === 'send' - ? [{ address, isScam: false, isWallet: true }] - : [{ address: preview.address, isScam: false, isWallet: false }] - : [], - }, - baseTransactions: [] as string[], - TonTransfer: { - sender: { - address: preview?.type === 'send' ? address || '' : preview?.address || '', - isScam: false, - isWallet: true, - }, - recipient: { - address: preview?.type === 'send' ? preview?.address || '' : address || '', - isScam: false, - isWallet: false, - }, - amount: BigInt(preview?.amount || 0), - }, - } as Action; - - return ( - - - - ); - } - - const ev = item.data; - const traceId = item.traceId; - const externalHash = item.externalHash ?? traceId; - - if (!ev.actions || ev.actions.length === 0) { - const rowDebugId = `event-row-${traceId.slice(0, 12)}`; - return ( - -
- - {rowDebugId} - - -
-
- ); - } - - const myAddr = address || ''; - const withMe = ev.actions.filter((a: Action) => - a.simplePreview?.accounts?.some((acc) => sameAddress(acc.address, myAddr)), - ); - const preferSender = (a: Action) => { - if (a.type === 'TonTransfer' && 'TonTransfer' in a) - return sameAddress(a.TonTransfer.sender.address, myAddr); - if (a.type === 'JettonTransfer' && 'JettonTransfer' in a) - return sameAddress(a.JettonTransfer.sender.address, myAddr); - if (a.type === 'NftItemTransfer' && 'NftItemTransfer' in a) - return sameAddress(a.NftItemTransfer.sender.address, myAddr); - return ( - a.simplePreview?.accounts?.[0] && - sameAddress(a.simplePreview.accounts[0].address, myAddr) - ); - }; - const relevantAction = withMe.find(preferSender) || withMe[0] || ev.actions[0]; - - return ( - - - - ); - })} -
- - )} -
- ); - - const pagination = !error && !isInitialLoading && (currentPage > 0 || (eventItems?.length ?? 0) > 0) && ( -
- {currentPage > 0 ? ( - - ) : ( -
- )} - -
Page {currentPage + 1}
- - {hasNextEvents ? ( - - ) : ( -
- )} -
- ); - - if (embedded) { - return ( -
- {header} - {content} - {pagination} -
- ); - } - - return ( -
- {header} - {content} - {pagination} -
- ); -}); - -RecentTransactions.displayName = 'RecentTransactions'; diff --git a/apps/demo-wallet/src/components/RequestModal.tsx b/apps/demo-wallet/src/components/RequestModal.tsx deleted file mode 100644 index 0045cccca..000000000 --- a/apps/demo-wallet/src/components/RequestModal.tsx +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { useEffect, useMemo, useState } from 'react'; -import type { SendTransactionRequestEvent, SignMessageRequestEvent, TransactionEmulatedPreview } from '@ton/walletkit'; -import { useAuth, useWalletKit, useWalletStore } from '@demo/wallet-core'; -import type { SavedWallet } from '@demo/wallet-core'; -import { toast } from 'sonner'; - -import { Button } from './Button'; -import { HoldToSignButton } from './HoldToSignButton'; -import { JettonFlow } from './JettonFlow'; -import { SuccessCard } from './SuccessCard'; -import { createComponentLogger } from '../utils/logger'; - -type RequestEvent = SendTransactionRequestEvent | SignMessageRequestEvent; -type WarningTone = 'red' | 'yellow'; - -interface RequestModalProps { - request: RequestEvent; - savedWallets: SavedWallet[]; - isOpen: boolean; - title: string; - subtitle: string; - details?: React.ReactNode; - warning: { tone: WarningTone; message: React.ReactNode }; - approveLabel: string; - successMessage: string; - testIds: { request: string; approve: string; reject: string }; - onApprove: () => Promise; - onReject: () => void; - loggerName: string; - previewMode: 'send' | 'sign'; -} - -const WARNING_CLASSES: Record = { - red: { - container: 'bg-red-50 border-red-200', - icon: 'text-red-400', - text: 'text-red-800', - }, - yellow: { - container: 'bg-yellow-50 border-yellow-200', - icon: 'text-yellow-400', - text: 'text-yellow-800', - }, -}; - -export const RequestModal: React.FC = ({ - request, - savedWallets, - isOpen, - title, - subtitle, - details, - warning, - approveLabel, - successMessage, - testIds, - onApprove, - onReject, - loggerName, - previewMode, -}) => { - const walletKit = useWalletKit(); - const isAuthenticated = useWalletStore((state) => state.walletManagement.isAuthenticated); - const { holdToSign } = useAuth(); - const [isLoading, setIsLoading] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - const [isExpired, setIsExpired] = useState(false); - const [localPreview, setLocalPreview] = useState(undefined); - - const log = useMemo(() => createComponentLogger(loggerName), [loggerName]); - - const currentWallet = useMemo(() => { - if (!request.walletAddress) return null; - return savedWallets.find((wallet) => wallet.kitWalletId === request.walletId) || null; - }, [savedWallets, request.walletAddress, request.walletId]); - - useEffect(() => { - const checkExpiration = () => { - const validUntil = request.request?.validUntil; - if (validUntil) { - const now = Math.floor(Date.now() / 1000); - setIsExpired(validUntil < now); - } else { - setIsExpired(false); - } - }; - checkExpiration(); - const interval = setInterval(checkExpiration, 1000); - return () => clearInterval(interval); - }, [request.request?.validUntil]); - - useEffect(() => { - if (!isAuthenticated) return; - async function updatePreview() { - if (request.preview.data) return; - await walletKit?.ensureInitialized(); - const preview = await walletKit - ?.getWallet(request.walletId ?? '') - ?.getTransactionPreview(request.request, { mode: previewMode }); - setLocalPreview(preview); - } - updatePreview(); - }, [request.walletId, request.request, request.preview, walletKit, isAuthenticated, previewMode]); - - const preview = useMemo(() => localPreview ?? request.preview.data, [request, localPreview]); - - useEffect(() => { - if (!isOpen) { - setShowSuccess(false); - setIsLoading(false); - setIsExpired(false); - } - }, [isOpen]); - - const handleApprove = async () => { - setIsLoading(true); - try { - await onApprove(); - setShowSuccess(true); - } catch (error) { - log.error(`Failed to approve ${loggerName}:`, error); - toast.error('Failed to approve request', { - description: (error as Error)?.message, - }); - } finally { - setIsLoading(false); - } - }; - - if (!isOpen) return null; - if (showSuccess) return ; - - const warningClasses = WARNING_CLASSES[warning.tone]; - - let dAppHost: string | undefined; - try { - dAppHost = request.dAppInfo?.url ? new URL(request.dAppInfo.url).host : undefined; - } catch (_e) { - dAppHost = request.dAppInfo?.url; - } - - return ( -
-
- {/* Scrollable content */} -
-
-
-

- {title} -

-

{subtitle}

-
- - {/* Combined dApp + Wallet block */} -
- {/* dApp row */} -
- {request.dAppInfo?.iconUrl ? ( - {request.dAppInfo.name} { - e.currentTarget.style.display = 'none'; - }} - /> - ) : ( -
- - - -
- )} -
-

- {request.dAppInfo?.name || 'Unknown dApp'} -

- {dAppHost &&

{dAppHost}

} -
-
- - {/* Wallet row */} - {currentWallet && ( - <> -
-
-
- - - -
-
-

- {currentWallet.name} -

-

- {currentWallet.address.slice(0, 6)}...{currentWallet.address.slice(-6)} -

-
-
- - )} -
- - {isExpired ? ( -
-
-
- - - -
-
-

Transaction Expired

-

- This transaction request has expired and can no longer be signed. Please - reject it and request a new transaction from the dApp. -

-
-
-
- ) : ( - <> - {details} - - {preview && preview.result === 'success' && ( -
-
- {preview.moneyFlow?.outputs === '0' && - preview.moneyFlow?.inputs === '0' && - preview.moneyFlow?.ourTransfers.length === 0 ? ( -
-

- This transaction doesn't involve any token transfers -

-
- ) : ( - - )} -
-
- )} - - {preview && (preview.result === 'failure' || preview.error) && ( -
-

- Error: {preview.error?.message} -

-
- )} - -
-
-
- - - -
-
-

{warning.message}

-
-
-
- - )} -
-
- - {/* Action buttons — always visible at bottom */} -
- - {!isExpired && - (holdToSign ? ( - - ) : ( - - ))} -
-
-
- ); -}; diff --git a/apps/demo-wallet/src/components/SettingsDropdown.tsx b/apps/demo-wallet/src/components/SettingsDropdown.tsx deleted file mode 100644 index 2a806fe36..000000000 --- a/apps/demo-wallet/src/components/SettingsDropdown.tsx +++ /dev/null @@ -1,395 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAuth, useWallet } from '@demo/wallet-core'; - -import { MnemonicDisplay } from './MnemonicDisplay'; -import { createComponentLogger } from '../utils/logger'; - -// Create logger for settings dropdown component -const log = createComponentLogger('SettingsDropdown'); - -export const SettingsDropdown: React.FC = () => { - const navigate = useNavigate(); - const { - lock, - reset, - persistPassword, - setPersistPassword, - holdToSign, - setHoldToSign, - showFastSend, - setShowFastSend, - } = useAuth(); - const { getDecryptedMnemonic } = useWallet(); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [showMnemonicModal, setShowMnemonicModal] = useState(false); - const [mnemonic, setMnemonic] = useState([]); - const [isLoadingMnemonic, setIsLoadingMnemonic] = useState(false); - const [mnemonicError, setMnemonicError] = useState(''); - const dropdownRef = useRef(null); - - const handleLockWallet = () => { - lock(); - setIsDropdownOpen(false); - }; - - const handleDeleteWallet = () => { - if (window.confirm('Are you sure you want to delete your wallet? This action cannot be undone.')) { - reset(); - setIsDropdownOpen(false); - } - }; - - const handleCreateNewWallet = () => { - setIsDropdownOpen(false); - navigate('/setup-wallet'); - }; - - const handleViewRecoveryPhrase = async () => { - setIsDropdownOpen(false); - setIsLoadingMnemonic(true); - setMnemonicError(''); - - try { - const decryptedMnemonic = await getDecryptedMnemonic(); - if (decryptedMnemonic) { - setMnemonic(decryptedMnemonic); - setShowMnemonicModal(true); - } else { - setMnemonicError('Unable to retrieve recovery phrase. Please ensure you are logged in.'); - } - } catch (error) { - setMnemonicError('Failed to decrypt recovery phrase. Please try again.'); - log.error('Error retrieving mnemonic:', error); - } finally { - setIsLoadingMnemonic(false); - } - }; - - const handleCloseMnemonicModal = () => { - setShowMnemonicModal(false); - setMnemonic([]); - setMnemonicError(''); - }; - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - // Close modal with Escape key - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && showMnemonicModal) { - handleCloseMnemonicModal(); - } - }; - - if (showMnemonicModal) { - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - } - }, [showMnemonicModal]); - - return ( - <> -
- - - {isDropdownOpen && ( -
-
- {/* Wallet Actions */} -
-

Wallet Actions

-
- - - - -
-
- - {/* Settings Section */} -
-

Settings

-
- {/* Auto-Lock */} -
-
- -

- Lock wallet on app reload (more secure) -

-
- -
- - {persistPassword && ( -
-
-
- - - -
-
-

- Security Notice: Auto-lock is disabled. Your - password is stored locally and the wallet stays unlocked. Only - use for development. -

-
-
-
- )} - - {/* Hold to Sign */} -
-
- -

- Require holding button for 3 seconds to approve transactions -

-
- -
- - {/* Show fast send */} -
-
- -

- Show "Send Fast" button on send screen (1 nano, no - confirmation) -

-
- -
- - {!holdToSign && ( -
-
-
- - - -
-
-

- Security Notice: Disabling hold-to-sign makes - it easier to accidentally approve transactions. Only use for - testing. -

-
-
-
- )} -
-
-
-
- )} -
- - {/* Recovery Phrase Modal */} - {showMnemonicModal && ( -
-
-
-
-

- Your Recovery Phrase -

- -
- -
- {/* Mnemonic Display */} - {mnemonic.length > 0 && ( - - )} - - {/* Error Display */} - {mnemonicError && ( -
- {mnemonicError} -
- )} - - {/* Close Button */} -
- -
-
-
-
-
- )} - - ); -}; diff --git a/apps/demo-wallet/src/components/SignDataRequestModal.tsx b/apps/demo-wallet/src/components/SignDataRequestModal.tsx deleted file mode 100644 index 4326da165..000000000 --- a/apps/demo-wallet/src/components/SignDataRequestModal.tsx +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { useMemo, useState, useEffect } from 'react'; -import type { SignDataRequestEvent } from '@ton/walletkit'; -import type { SavedWallet } from '@demo/wallet-core'; -import { useAuth } from '@demo/wallet-core'; - -import { Button } from './Button'; -import { Card } from './Card'; -import { DAppInfo } from './DAppInfo'; -import { WalletPreview } from './WalletPreview'; -import { HoldToSignButton } from './HoldToSignButton'; -import { createComponentLogger } from '../utils/logger'; - -// Create logger for sign data request modal -const log = createComponentLogger('SignDataRequestModal'); - -interface SignDataRequestModalProps { - request: SignDataRequestEvent; - savedWallets: SavedWallet[]; - isOpen: boolean; - onApprove: () => void; - onReject: (reason?: string) => void; -} - -export const SignDataRequestModal: React.FC = ({ - request, - savedWallets, - isOpen, - onApprove, - onReject, -}) => { - const [isLoading, setIsLoading] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); - const { holdToSign } = useAuth(); - - // Find the wallet being used for this sign data request - const currentWallet = useMemo(() => { - if (!request.walletAddress) return null; - return savedWallets.find((wallet) => wallet.kitWalletId === request.walletId) || null; - }, [savedWallets, request.walletAddress]); - - // Reset success state when modal closes/opens - useEffect(() => { - if (!isOpen) { - setShowSuccess(false); - setIsLoading(false); - } - }, [isOpen]); - - const handleApprove = async () => { - setIsLoading(true); - try { - // First, perform the actual signing operation - await onApprove(); - - // If successful, show success animation - setIsLoading(false); - setShowSuccess(true); - - // The parent will handle closing the modal after it sees the request is completed - // But we keep showing the success state for visual feedback - } catch (error) { - log.error('Failed to approve sign data request:', error); - setIsLoading(false); - } - }; - - const handleReject = () => { - onReject('User rejected the sign data request'); - }; - - const renderDataPreview = () => { - const { preview } = request; - - switch (preview.data.type) { - case 'text': - return ( -
-

Text Message

-

{preview.data.value.content}

-
- ); - case 'binary': - return ( -
-

Binary Data

-
-

Content: {preview.data.value.content}

-
-
- ); - case 'cell': - return ( -
- {/*

TON Cell Data

*/} -
-
-

Content

-

- {preview.data.value.content} -

-
- {preview.data.value.schema && ( -
-

Schema

-

- {preview.data.value.schema} -

-
- )} - {/*

Content: {preview.content}

*/} - {/* {preview.schema &&

Schema: {preview.schema}

} */} - {preview.data.value.parsed && ( -
-

Parsed Data:

-
-                                        {JSON.stringify(preview.data.value.parsed, null, 2)}
-                                    
-
- )} -
-
- ); - default: - return ( -
- {/*

Data to Sign

*/} -

Unknown data format

-
- ); - } - }; - - if (!isOpen) return null; - - // Success state view - if (showSuccess) { - return ( -
- -
- {/* Success Content */} -
- {/* Success Icon */} -
-
- - - -
-
- - {/* Success Message */} -
-

Success!

-

Data signed successfully

-
-
-
-
- ); - } - - // Normal request view - return ( -
-
- -
- {/* Header */} -
-

- Sign Data Request -

-

A dApp wants you to sign data with your wallet

-
- - {/* dApp Information */} - - - {/* Wallet Information */} - {currentWallet && ( -
- -
- )} - - {/* Data Preview */} -
-

Data to Sign

- {renderDataPreview()} -
- - {/* Warning */} -
-
-
- - - -
-
-

- Warning: Only sign data if you trust the requesting dApp and - understand what you're signing. Signing data can have security implications. -

-
-
-
- - {/* Action Buttons */} -
- - {holdToSign ? ( - - ) : ( - - )} -
-
-
-
-
- ); -}; diff --git a/apps/demo-wallet/src/components/TraceRow.tsx b/apps/demo-wallet/src/components/TraceRow.tsx deleted file mode 100644 index f1050210d..000000000 --- a/apps/demo-wallet/src/components/TraceRow.tsx +++ /dev/null @@ -1,585 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { memo, useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { useShallow } from 'zustand/react/shallow'; -import type { ToncenterTraceItem } from '@ton/walletkit'; -import { useWalletKit, useWalletStore, getChainNetwork } from '@demo/wallet-core'; - -import { getTonviewerTxUrl } from '@/utils'; -import { log } from '@/utils/logger'; - -// Local type definitions for transaction data -interface TransactionMessage { - hash: string; - source?: string | null; - destination: string; - value?: string | null; - opcode?: string | null; -} - -interface TransactionData { - hash: string; - account: string; - total_fees?: string; - in_msg?: TransactionMessage | null; - out_msgs: TransactionMessage[]; - description: { - aborted?: boolean; - compute_ph?: { - success?: boolean; - }; - }; - emulated?: boolean; -} - -interface PendingPreview { - type: 'send' | 'receive' | 'contract'; - amount: string; - address: string; - timestamp: number; -} - -interface TraceRowProps { - traceId: string; - externalHash?: string; - isPending?: boolean; - pendingPreview?: PendingPreview; -} - -export const TraceRow: React.FC = memo( - ({ traceId, externalHash, isPending = false, pendingPreview }) => { - const walletKit = useWalletKit(); - const { savedWallets, activeWalletId } = useWalletStore( - useShallow((state) => ({ - savedWallets: state.walletManagement.savedWallets, - activeWalletId: state.walletManagement.activeWalletId, - })), - ); - const [trace, setTrace] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isExpanded, setIsExpanded] = useState(false); - - // Get the active wallet's network - const activeWallet = savedWallets.find((w) => w.id === activeWalletId); - const walletNetwork = activeWallet?.network || 'testnet'; - const chainNetwork = getChainNetwork(walletNetwork); - - const formatTonAmount = (amount: string): string => { - const tonAmount = parseFloat(amount || '0') / 1000000000; // Convert nanoTON to TON - return tonAmount.toFixed(4); - }; - - const formatAddress = (addr: string): string => { - if (!addr) return ''; - return `${addr.slice(0, 6)}...${addr.slice(-6)}`; - }; - - const formatTimestamp = (timestamp: number): string => { - return new Date(timestamp * 1000).toLocaleString(); - }; - - const formatMessageType = (msg: TransactionMessage | null): string => { - if (!msg) return 'Internal'; - if (!msg.opcode || msg.opcode === '0x00000000') return 'Transfer'; - return `Op: ${msg.opcode.slice(0, 8)}`; - }; - - const renderTransactionPreview = (tx: TransactionData, index: number) => { - const hasIncoming = tx.in_msg && tx.in_msg.value && parseFloat(tx.in_msg.value) > 0; - const hasOutgoing = tx.out_msgs && tx.out_msgs.length > 0; - - return ( -
-
-
- #{index + 1} - - {tx.hash.slice(0, 8)}...{tx.hash.slice(-4)} - - {formatAddress(tx.account)} -
-
Fee: {formatTonAmount(tx.total_fees || '0')} TON
-
- - {/* Incoming message */} - {hasIncoming && ( -
-
- - - - In: +{formatTonAmount(tx.in_msg!.value!)} TON - from {formatAddress(tx.in_msg!.source || '')} - ({formatMessageType(tx.in_msg || null)}) -
-
- )} - - {/* Outgoing messages */} - {hasOutgoing && ( -
- {tx.out_msgs.slice(0, 3).map((msg: TransactionMessage, msgIndex: number) => ( -
- - - - Out: -{formatTonAmount(msg.value || '0')} TON - to {formatAddress(msg.destination)} - ({formatMessageType(msg)}) -
- ))} - {tx.out_msgs.length > 3 && ( -
- +{tx.out_msgs.length - 3} more messages -
- )} -
- )} - - {/* Status indicator */} -
- - {tx.description.aborted || !tx.description.compute_ph?.success ? 'Failed' : 'Success'} - - {tx.emulated && ( - Emulated - )} -
-
- ); - }; - - // Analyze trace to determine transaction type and amount - const analyzeTrace = (traceData: ToncenterTraceItem) => { - if (!traceData.transactions || Object.keys(traceData.transactions).length === 0) { - return { - type: 'unknown' as const, - amount: '0', - address: '', - timestamp: traceData.start_utime, - status: 'failed' as const, - }; - } - - // Get all transactions in the trace - const transactions = Object.values(traceData.transactions); - const mainTransaction = transactions[0]; // Usually the first transaction is the main one - - let type: 'send' | 'receive' | 'contract' = 'receive'; - let amount = '0'; - let address = ''; - let status: 'confirmed' | 'failed' = 'confirmed'; - - // Check if any transaction failed - const hasFailedTx = transactions.some( - (tx) => tx.description.aborted || !tx.description.compute_ph?.success, - ); - - if (hasFailedTx) { - status = 'failed'; - } - - // Analyze the main transaction - if (mainTransaction) { - // Check incoming message - if (mainTransaction.in_msg && mainTransaction.in_msg.value) { - amount = mainTransaction.in_msg.value; - address = mainTransaction.in_msg.source || ''; - type = 'receive'; - } - - // Check outgoing messages - if there are any, it's likely a send transaction - if (mainTransaction.out_msgs && mainTransaction.out_msgs.length > 0) { - const mainOutMsg = mainTransaction.out_msgs[0]; - if (mainOutMsg.value) { - amount = mainOutMsg.value; - address = mainOutMsg.destination; - type = 'send'; - } - } - - // If there are multiple transactions or complex interactions, mark as contract - if (transactions.length > 1 || (traceData?.actions?.length ?? 0) > 0) { - type = 'contract'; - } - } - - return { - type, - amount, - address, - timestamp: traceData.start_utime, - status, - }; - }; - - useEffect(() => { - const fetchTrace = async () => { - if (isPending) { - setIsLoading(false); - return; - } - - try { - setIsLoading(true); - setError(null); - - if (!walletKit) { - setIsLoading(false); - return; - } - - while (!walletKit?.isReady()) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - const apiClient = walletKit.getApiClient(chainNetwork); - let response; - - try { - if (traceId && !response) { - // Fetch by trace ID for completed traces - response = await apiClient.getTrace({ - traceId: [traceId], - }); - } - - if (externalHash && !response) { - // Fetch by external message hash for pending traces - response = await apiClient.getPendingTrace({ - externalMessageHash: [externalHash], - }); - } - - if (externalHash && !response) { - // Fetch by trace ID for completed traces - response = await apiClient.getTrace({ - traceId: [externalHash], - }); - } - } catch (error) { - log.error('Error fetching trace', { error }); - } - - if (!response) { - setError('Trace not found'); - return; - } - - if (response.traces && response.traces.length > 0) { - setTrace(response.traces[0]); - } else { - setError('Trace not found'); - } - } catch (_err) { - // Failed to fetch trace - log.error('trace fetch error', _err); - setError('Failed to load trace data'); - } finally { - setIsLoading(false); - } - }; - - if (!walletKit) { - return; - } - - void fetchTrace(); - }, [traceId, externalHash, isPending, walletKit]); - - if (isPending && pendingPreview) { - const { type, amount, address, timestamp } = pendingPreview; - const label = type === 'send' ? 'Sent' : type === 'receive' ? 'Received' : 'Contract Interaction'; - const amountStr = amount !== '0' ? `${formatTonAmount(amount)} TON` : 'Contract'; - const sign = type === 'send' ? '-' : type === 'receive' ? '+' : ''; - const amountColor = - type === 'send' ? 'text-red-600' : type === 'receive' ? 'text-green-600' : 'text-blue-600'; - - return ( -
- -
-
-
-
-
-

{label}

-

- {address ? formatAddress(address) : 'Multiple addresses'} -

-

{formatTimestamp(timestamp)}

-
-
-
-

- {sign} - {amountStr} -

-

Pending

-
- -
- ); - } - - if (isPending) { - return ( -
-
-
-
-
-
-
-

Processing

-

Pending confirmation

-
-
-

Pending

-
-
- ); - } - - if (isLoading) { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
- ); - } - - if (error || !trace) { - return ( - -
-
- - - -
-
-

Error Loading Trace

-

{error}

-
-
-
- ); - } - - const traceInfo = analyzeTrace(trace); - const transactionCount = Object.keys(trace.transactions).length; - const actionCount = trace.actions?.length ?? 0; - - const linkTo = getTonviewerTxUrl(walletNetwork, externalHash ?? traceId); - - const orderedTransactions = trace.transactions_order - ? trace.transactions_order.map((hash) => trace.transactions[hash] as TransactionData) - : (Object.values(trace.transactions) as TransactionData[]); - - return ( -
- {/* Main trace header - clickable for navigation */} - -
-
- {traceInfo.status === 'failed' ? ( - - - - ) : traceInfo.type === 'send' ? ( - - - - ) : traceInfo.type === 'receive' ? ( - - - - ) : ( - - - - )} -
-
-

- {traceInfo.status === 'failed' - ? 'Failed Transaction' - : traceInfo.type === 'send' - ? 'Sent' - : traceInfo.type === 'receive' - ? 'Received' - : 'Contract Interaction'} - {transactionCount > 1 && ` (${transactionCount} txs)`} - {actionCount > 0 && ` • ${actionCount} actions`} -

-

- {traceInfo.address ? formatAddress(traceInfo.address) : 'Multiple addresses'} -

-

{formatTimestamp(traceInfo.timestamp)}

-
-
-
-
-

- {traceInfo.type === 'send' ? '-' : traceInfo.type === 'receive' ? '+' : ''} - {traceInfo.amount !== '0' ? `${formatTonAmount(traceInfo.amount)} TON` : 'Contract'} -

-

- {traceInfo.status} -

-
-
-
- - {/* Expand/Collapse button for transaction previews */} - {transactionCount > 1 && ( -
- -
- )} - - {/* Transaction previews */} - {isExpanded && transactionCount > 1 && ( -
-
Transaction Details:
- {orderedTransactions.map((tx, index) => renderTransactionPreview(tx, index))} -
- )} -
- ); - }, -); diff --git a/apps/demo-wallet/src/components/index.ts b/apps/demo-wallet/src/components/index.ts deleted file mode 100644 index 757c26d4f..000000000 --- a/apps/demo-wallet/src/components/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -export { AnimatedBalance } from './AnimatedBalance'; -export { AppRouter } from './AppRouter'; -export { Button } from './Button'; -export { Card } from './Card'; -export { ConnectRequestModal } from './ConnectRequestModal'; -export { CreateWallet } from './CreateWallet'; -export { DAppInfo } from './DAppInfo'; -export { DisconnectNotifications } from './DisconnectNotifications'; -export { ImportWallet } from './ImportWallet'; -export { Input } from './Input'; -export { LedgerSetup } from './LedgerSetup'; -export { JettonsCard } from './JettonsCard'; -export { JettonRow } from './JettonRow'; -export { JettonsList } from './JettonsList'; -export { Modal } from './Modal'; -export { NftsCard } from './NftsCard'; -export { Layout } from './Layout'; -export { MnemonicDisplay } from './MnemonicDisplay'; -export { NetworkSelector } from './NetworkSelector'; -export { SettingsDropdown } from './SettingsDropdown'; -export { StreamingStatus } from './StreamingStatus'; -export { ProtectedRoute } from './ProtectedRoute'; -export { RecentTransactions } from './RecentTransactions'; -export { SignDataRequestModal } from './SignDataRequestModal'; -export { SignMessageRequestModal } from './SignMessageRequestModal'; -export { TraceRow } from './TraceRow'; -export { TransactionRequestModal } from './TransactionRequestModal'; -export { WalletPreview } from './WalletPreview'; -export { WalletSwitcher } from './WalletSwitcher'; - -// Transaction components -export * from './transactions'; diff --git a/apps/demo-wallet/src/components/staking/StakingInfo.tsx b/apps/demo-wallet/src/components/staking/StakingInfo.tsx deleted file mode 100644 index 614e05557..000000000 --- a/apps/demo-wallet/src/components/staking/StakingInfo.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import type { FC } from 'react'; -import { useStaking } from '@demo/wallet-core'; - -import { Card } from '../Card'; - -export const StakingInfo: FC = () => { - const { stakedBalance, providerInfo } = useStaking(); - - return ( -
- -
-
-

Balance

-

- {stakedBalance?.stakedBalance ? stakedBalance?.stakedBalance : '0.00'} tsTON -

-
-
-
- - -
-
- Provider - Tonstakers -
-
- APY - - {providerInfo?.apy ? `${providerInfo.apy.toFixed(2)}%` : '--'} - -
-
- Instant Unstake Available - - {providerInfo?.instantUnstakeAvailable - ? Number(providerInfo?.instantUnstakeAvailable).toFixed(4) - : '0.00'}{' '} - TON - -
-
-
-
- ); -}; diff --git a/apps/demo-wallet/src/components/staking/StakingInterface.tsx b/apps/demo-wallet/src/components/staking/StakingInterface.tsx deleted file mode 100644 index 1a215b44c..000000000 --- a/apps/demo-wallet/src/components/staking/StakingInterface.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import type { FC, ChangeEvent } from 'react'; -import { useState } from 'react'; -import { useStaking } from '@demo/wallet-core'; -import { useNavigate } from 'react-router-dom'; -import { UnstakeMode } from '@ton/walletkit'; - -import { Card } from '../Card'; -import { Button } from '../Button'; - -import { cn } from '@/lib/utils'; - -export const StakingInterface: FC = () => { - const { - amount, - currentQuote, - isLoadingQuote, - isStaking, - isUnstaking, - error, - unstakeMode, - setStakingAmount: setAmount, - setUnstakeMode, - getStakingQuote: getQuote, - stake, - unstake, - validateStakingInputs, - } = useStaking(); - - const [tab, setTab] = useState<'stake' | 'unstake'>('stake'); - - const navigate = useNavigate(); - - const handleAmountChange = (e: ChangeEvent) => { - setAmount(e.target.value); - }; - - const handleGetQuote = async () => { - await getQuote({ - amount, - direction: tab === 'stake' ? 'stake' : 'unstake', - }); - }; - - const handleAction = async () => { - if (!currentQuote) return; - - if (tab === 'stake') { - await stake({ quote: currentQuote }); - } else { - await unstake({ quote: currentQuote }); - } - - navigate('/wallet', { - state: { message: `${tab === 'stake' ? 'Staked' : 'Unstaked'} successfully!` }, - }); - }; - - const validationError = validateStakingInputs(); - const canGetQuote = !validationError && amount && parseFloat(amount) > 0; - - return ( - -
- - -
- -
-
- -
- -
- {tab === 'stake' ? 'TON' : 'tsTON'} -
-
- {validationError && amount !== '' &&

{validationError}

} -
- - {tab === 'unstake' && ( -
- -
- {[UnstakeMode.INSTANT, UnstakeMode.WHEN_AVAILABLE, UnstakeMode.ROUND_END].map((mode) => ( - - ))} -
-

- {unstakeMode === UnstakeMode.INSTANT && 'Receive TON immediately'} - {unstakeMode === UnstakeMode.WHEN_AVAILABLE && 'Immediate if liquid, or up to ~18h queue'} - {unstakeMode === UnstakeMode.ROUND_END && 'Wait for cycle end (~18h) for best rate'} -

-
- )} - - {currentQuote && ( -
-
- You will receive - - {currentQuote.amountOut} {tab === 'stake' ? 'tsTON' : 'TON'} - -
-
- )} - - {error && ( -
-

{error}

-
- )} - - {!currentQuote ? ( - - ) : ( -
- - -
- )} -
-
- ); -}; diff --git a/apps/demo-wallet/src/components/swap/SwapInterface.tsx b/apps/demo-wallet/src/components/swap/SwapInterface.tsx deleted file mode 100644 index 7840223f9..000000000 --- a/apps/demo-wallet/src/components/swap/SwapInterface.tsx +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import type { FC } from 'react'; -import { useState } from 'react'; -import { useSwap } from '@demo/wallet-core'; -import { useNavigate } from 'react-router-dom'; -import type { SwapToken } from '@ton/walletkit'; - -import { SwapSettings } from './SwapSettings'; -import { TokenInput } from './TokenInput'; -import { QuoteTimer } from './QuoteTimer'; -import { Button } from '../Button'; -import { Card } from '../Card'; - -import { cn } from '@/lib/utils'; - -function getPriceImpactColor(priceImpact: number): string { - if (priceImpact > 500) return 'text-destructive'; - if (priceImpact > 200) return 'text-yellow-600'; - return 'text-green-600'; -} - -interface SwapInterfaceProps { - className?: string; -} - -export const SwapInterface: FC = ({ className }) => { - const { - fromToken, - toToken, - amount, - isReverseSwap, - destinationAddress, - currentQuote, - isLoadingQuote, - isSwapping, - error, - slippageBps, - setFromToken, - setToToken, - setSwapAmount: setAmount, - setIsReverseSwap, - setDestinationAddress, - setSlippageBps, - swapTokens, - getSwapQuote: getQuote, - executeSwap, - } = useSwap(); - - const navigate = useNavigate(); - - const [showSlippageSettings, setShowSlippageSettings] = useState(false); - const [useCustomDestination, setUseCustomDestination] = useState(false); - - const getTokenSymbol = (token: SwapToken): string => { - if (token.symbol) return token.symbol; - if (token.address === 'ton') return 'TON'; - return 'Unknown'; - }; - - const fromSymbol = getTokenSymbol(fromToken); - const toSymbol = getTokenSymbol(toToken); - - const handleGetQuote = async () => { - await getQuote(); - }; - - const handleExecuteSwap = async () => { - await executeSwap(); - - navigate('/wallet', { - state: { message: `${fromSymbol} sent successfully!` }, - }); - }; - - const handleFromAmountChange = (val: string) => { - setAmount(val); - setIsReverseSwap(false); - }; - - const handleToAmountChange = (val: string) => { - setAmount(val); - setIsReverseSwap(true); - }; - - const fromAmount = !isReverseSwap ? amount : currentQuote ? currentQuote.fromAmount : ''; - - const toAmount = isReverseSwap ? amount : currentQuote ? currentQuote.toAmount : ''; - - const getSwapButtonText = () => { - if (!fromToken || !toToken) return 'Select tokens'; - const hasFromAmount = fromAmount && parseFloat(fromAmount) > 0; - const hasToAmount = toAmount && parseFloat(toAmount) > 0; - if (!hasFromAmount && !hasToAmount) return 'Enter amount'; - if (isLoadingQuote) return 'Getting quote...'; - if (error) return 'Error'; - if (!currentQuote) return 'Get Quote'; - return `Swap ${fromSymbol} for ${toSymbol}`; - }; - - const isSwapDisabled = !!error || isLoadingQuote || isSwapping; - - return ( - -
-

Swap

- -
- -
- - -
- -
- - - - {/* Destination Address */} -
- - - {useCustomDestination && ( -
- setDestinationAddress(e.target.value)} - placeholder="Enter recipient address (EQ...)" - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" - /> -

- Swapped tokens will be sent to this address instead of your wallet -

-
- )} -
- - {currentQuote && ( - <> - - -
- -
-
- Provider - {currentQuote.providerId} -
- -
- Minimum Received - - {Number(currentQuote.minReceived).toFixed(6)} {toSymbol} - -
- - {currentQuote.priceImpact && ( -
- Price Impact - - {(currentQuote.priceImpact / 100).toFixed(2)}% - -
- )} - -
- Slippage - {slippageBps / 100}% -
-
- - )} - - {error && ( -
- {error} -
- )} -
- -
- {!currentQuote && ( - - )} - - {currentQuote && ( - - )} -
- - ); -}; diff --git a/apps/demo-wallet/src/components/swap/SwapSettings.tsx b/apps/demo-wallet/src/components/swap/SwapSettings.tsx deleted file mode 100644 index 442d618ee..000000000 --- a/apps/demo-wallet/src/components/swap/SwapSettings.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import type { FC } from 'react'; -import { useState, useEffect } from 'react'; - -import { Button } from '../Button'; -import { Modal } from '../Modal'; - -interface SwapSettingsProps { - slippageBps: number; - setSlippageBps: (slippage: number) => void; - showSettings: boolean; - setShowSettings: (show: boolean) => void; -} - -export const SwapSettings: FC = ({ slippageBps, setSlippageBps }) => { - const [open, setOpen] = useState(false); - const [tempSlippageBps, setTempSlippageBps] = useState(slippageBps); - - const presetSlippages = [50, 100, 300, 500]; - - useEffect(() => { - setTempSlippageBps(slippageBps); - }, [slippageBps]); - - const handleSave = () => { - if (tempSlippageBps >= 10 && tempSlippageBps <= 5000) { - setSlippageBps(tempSlippageBps); - } - setOpen(false); - }; - - const handleCancel = () => { - setTempSlippageBps(slippageBps); - setOpen(false); - }; - - return ( - <> - - - - - Swap Settings - - -
- -
- {presetSlippages.map((preset) => ( - - ))} -
-

- Your transaction will revert if the price changes unfavorably by more than this percentage. -

-
-
- - -
- - -
-
-
- - ); -}; diff --git a/apps/demo-wallet/src/components/swap/TokenInput.tsx b/apps/demo-wallet/src/components/swap/TokenInput.tsx deleted file mode 100644 index 84f9dfff9..000000000 --- a/apps/demo-wallet/src/components/swap/TokenInput.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import type { FC } from 'react'; -import { useJettons, useWallet, formatUnits } from '@demo/wallet-core'; -import type { SwapToken } from '@ton/walletkit'; - -import { TokenSelector } from './TokenSelector'; -import { Button } from '../Button'; - -import { cn } from '@/lib/utils'; - -interface Props { - label: string; - token: SwapToken; - amount: string; - onTokenSelect: (token: SwapToken) => void; - onAmountChange: (amount: string) => void; - excludeToken?: SwapToken; - isOutput?: boolean; - className?: string; -} - -export const TokenInput: FC = ({ - label, - token, - amount, - onTokenSelect, - onAmountChange, - excludeToken, - isOutput = false, - className, -}) => { - const { balance } = useWallet(); - const { userJettons } = useJettons(); - - const getTokenBalance = (token: SwapToken): string => { - if (token.address === 'ton') { - return formatUnits(balance || '0', token.decimals); - } - - const jetton = userJettons.find((j) => j.address === token.address); - if (jetton && jetton.balance) { - return formatUnits(jetton.balance, token.decimals); - } - - return '0'; - }; - - const handleMaxClick = () => { - if (isOutput) return; - - if (token.address === 'ton') { - const currentBalance = parseFloat(formatUnits(balance || '0', token.decimals)); - const maxAmount = currentBalance - 0.1; - if (maxAmount > 0) { - onAmountChange(maxAmount.toString()); - } - } else { - const jetton = userJettons.find((j) => j.address === token.address); - if (jetton && jetton.balance) { - const balanceInUnits = formatUnits(jetton.balance, token.decimals); - onAmountChange(balanceInUnits); - } - } - }; - - const tokenBalance = getTokenBalance(token); - - return ( -
-
- {label} - - {token && ( -
-

Balance:

- - {parseFloat(tokenBalance).toFixed(6)} - - {!isOutput && parseFloat(tokenBalance) > 0 && ( - - )} -
- )} -
- -
-
- onAmountChange(e.target.value)} - placeholder="0" - type="text" - value={amount} - /> -
- - -
-
- ); -}; diff --git a/apps/demo-wallet/src/components/swap/TokenSelector.tsx b/apps/demo-wallet/src/components/swap/TokenSelector.tsx deleted file mode 100644 index 6a2ae5b7e..000000000 --- a/apps/demo-wallet/src/components/swap/TokenSelector.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import { useMemo } from 'react'; -import type { FC } from 'react'; -import { useJettons } from '@demo/wallet-core'; -import type { Jetton } from '@ton/walletkit'; -import type { SwapToken } from '@ton/walletkit'; - -import { cn } from '@/lib/utils'; -import { USDT_ADDRESS } from '@/constants/swap'; -import { getJettonsImage, getJettonsSymbol } from '@/utils/jetton'; -import { CircleLogo } from '@/components/CircleLogo'; - -interface TokenSelectorProps { - selectedToken: SwapToken; - onTokenSelect: (token: SwapToken) => void; - excludeToken?: SwapToken; - placeholder?: string; - className?: string; -} - -const getTokenSymbol = (token: SwapToken, jetton?: Jetton): string => { - if (token.symbol) return token.symbol; - if (token.address === 'ton') return 'TON'; - if (token.address === USDT_ADDRESS) return 'USDT'; - - if (jetton) { - const symbol = getJettonsSymbol(jetton); - return symbol || 'Unknown'; - } - - return 'Unknown'; -}; - -export const TokenSelector: FC = ({ - selectedToken, - // onTokenSelect, - // excludeToken, - placeholder = 'Select token', - className, -}) => { - const { userJettons } = useJettons(); - - // const [open, setOpen] = useState(false); - - // const handleTokenSelect = (tokenAddress: string) => { - // onTokenSelect(tokenAddress); - // setOpen(false); - // }; - - const selectedTokenInfo = useMemo(() => { - const symbol = getTokenSymbol(selectedToken); - - if (selectedToken.address !== 'ton') { - const jetton = userJettons.find((j) => j.address === selectedToken.address); - const icon = selectedToken.image ?? (jetton ? getJettonsImage(jetton) : undefined); - - return { symbol, icon }; - } - - return { - symbol, - icon: '/ton.png', - }; - }, [selectedToken, userJettons]); - - return ( - <> - - - {/* setOpen(false)}>*/} - {/* setOpen(false)}>*/} - {/* Select a token*/} - {/* */} - - {/* */} - {/* {availableTokens.map((token) => (*/} - {/* handleTokenSelect(token.address)}*/} - {/* >*/} - {/*
*/} - {/* {token.icon}*/} - {/*
*/} - {/*
*/} - {/*

{token.symbol}

*/} - {/*

{token.name}

*/} - {/*
*/} - {/* */} - {/* ))}*/} - {/*
*/} - {/*
*/} - - ); -}; diff --git a/apps/demo-wallet/src/components/transactions/ActionCard.tsx b/apps/demo-wallet/src/components/transactions/ActionCard.tsx deleted file mode 100644 index 6a31e2e75..000000000 --- a/apps/demo-wallet/src/components/transactions/ActionCard.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { memo, useMemo } from 'react'; -import type { Action } from '@ton/walletkit'; - -import { formatTonForDisplay, sameAddress } from '../../utils'; -import { TransactionCard } from './TransactionCard'; -import type { TxFinality } from './TransactionCard'; - -interface ActionCardProps { - action: Action; - myAddress: string; - timestamp: number; - traceLink?: string; - /** When true, renders as pending (spinner icon, "Pending" status) */ - isPending?: boolean; - /** Finality: pending, confirmed, finalized, or done (default from isPending) */ - finality?: TxFinality; - /** Debug ID for DOM inspection (data-debug-id) */ - debugId?: string; -} - -/** - * Wrapper that extracts Action data and renders TransactionCard - */ -const TON_TRANSFER_DESC = /^Transferring (.+) TON$/; -const TON_VALUE = /^(.+) TON$/; -const JETTON_TRANSFER_DESC = /^Transferring (.+)$/; - -function isOutgoingFromAction(action: Action, myAddress: string): boolean { - if (action.type === 'TonTransfer' && 'TonTransfer' in action) { - return sameAddress(action.TonTransfer?.sender?.address, myAddress); - } - if (action.type === 'JettonTransfer' && 'JettonTransfer' in action) { - return sameAddress(action.JettonTransfer?.sender?.address, myAddress); - } - if (action.type === 'NftItemTransfer' && 'NftItemTransfer' in action) { - return sameAddress(action.NftItemTransfer?.sender?.address, myAddress); - } - const accounts = action.simplePreview?.accounts; - return accounts != null && accounts.length > 0 && sameAddress(accounts[0].address, myAddress); -} - -export const ActionCard: React.FC = memo( - ({ action, myAddress, timestamp, traceLink, isPending = false, finality: finalityProp, debugId }) => { - const { simplePreview, status } = action; - const { description, value, valueImage } = simplePreview; - - const isOutgoing = isOutgoingFromAction(action, myAddress); - const txStatus = isPending ? 'pending' : status === 'failure' ? 'failure' : 'success'; - const finality: TxFinality = finalityProp ?? (isPending ? 'pending' : status === 'failure' ? 'done' : 'done'); - - const { description: displayDesc, value: displayValue } = useMemo(() => { - const descMatch = description?.match(TON_TRANSFER_DESC); - const valueMatch = value?.match(TON_VALUE); - if (descMatch && valueMatch && action.type === 'TonTransfer') { - const amount = formatTonForDisplay(valueMatch[1]); - const label = isOutgoing ? 'Sent' : 'Received'; - return { - description: `${label} ${amount} TON`, - value: `${amount} TON`, - }; - } - if (valueMatch && action.type === 'TonTransfer') { - const amount = formatTonForDisplay(valueMatch[1]); - return { description, value: `${amount} TON` }; - } - const jettonMatch = description?.match(JETTON_TRANSFER_DESC); - if (jettonMatch && action.type === 'JettonTransfer') { - const label = isOutgoing ? 'Sent' : 'Received'; - return { - description: `${label} ${jettonMatch[1]}`, - value: value ?? '', - }; - } - return { description, value }; - }, [description, value, action.type, isOutgoing]); - - return ( - - ); - }, -); diff --git a/apps/demo-wallet/src/components/transactions/TonTransferCard.tsx b/apps/demo-wallet/src/components/transactions/TonTransferCard.tsx deleted file mode 100644 index c07a6d049..000000000 --- a/apps/demo-wallet/src/components/transactions/TonTransferCard.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { memo } from 'react'; -import { Link } from 'react-router-dom'; -import type { TonTransferAction } from '@ton/walletkit'; - -import { formatAddress, formatTimestamp } from '../../utils'; -import { formatTon } from '../../utils/units'; - -interface TonTransferCardProps { - action: TonTransferAction; - myAddress: string; - eventId: string; - timestamp: number; - traceLink: string; -} - -/** - * Component for displaying a single TON transfer transaction - */ -export const TonTransferCard: React.FC = memo( - ({ action, myAddress, eventId, timestamp, traceLink }) => { - const isOutgoing = action.TonTransfer.sender.address === myAddress; - const amount = formatTon(action.TonTransfer.amount); - const otherAddress = isOutgoing ? action.TonTransfer.recipient.address : action.TonTransfer.sender.address; - - return ( - -
-
- {isOutgoing ? ( - - - - ) : ( - - - - )} -
-
-

{isOutgoing ? 'Sent TON' : 'Received TON'}

-

{formatAddress(otherAddress)}

- {action.TonTransfer.comment && ( -

{action.TonTransfer.comment}

- )} -
-
-
-

- {isOutgoing ? '-' : '+'} - {amount} TON -

-

{formatTimestamp(timestamp)}

-
- - ); - }, -); diff --git a/apps/demo-wallet/src/components/transactions/TransactionCard.tsx b/apps/demo-wallet/src/components/transactions/TransactionCard.tsx deleted file mode 100644 index 6b877dd08..000000000 --- a/apps/demo-wallet/src/components/transactions/TransactionCard.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React, { memo } from 'react'; -import { Link } from 'react-router-dom'; - -import { formatTimestamp } from '../../utils'; - -export type TxFinality = 'pending' | 'confirmed' | 'finalized' | 'invalidated' | 'done'; - -export interface TransactionCardProps { - description: string; - value: string; - valueImage?: string; - timestamp: number; - traceLink?: string; - status: 'pending' | 'success' | 'failure'; - /** Finality for status badge: pending, confirmed, finalized, or done (default: pending when status=pending, else done) */ - finality?: TxFinality; - isOutgoing?: boolean; - /** Debug ID for DOM inspection (data-debug-id) */ - debugId?: string; -} - -const StatusBadge: React.FC<{ finality: TxFinality; isFailed?: boolean }> = ({ finality, isFailed }) => { - const base = - 'absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full flex items-center justify-center ring-1 ring-white'; - if (isFailed) { - return ( -
- - - -
- ); - } - if (finality === 'pending') { - return ( -
-
-
- ); - } - if (finality === 'confirmed') { - return ( -
- - - - -
- ); - } - if (finality === 'finalized') { - return ( -
- - - -
- ); - } - if (finality === 'invalidated') { - return ( -
- - - -
- ); - } - // done - no badge - return null; -}; - -/** - * Unified card for pending and confirmed transactions. - * Main icon: always send/receive. Status badge: small icon in corner. - */ -export const TransactionCard: React.FC = memo( - ({ - description, - value, - valueImage, - timestamp, - traceLink, - status, - finality: finalityProp, - isOutgoing = false, - debugId, - }) => { - const isFailed = status === 'failure'; - const isPending = status === 'pending'; - - const finality: TxFinality = finalityProp ?? (isPending ? 'pending' : isFailed ? 'done' : 'done'); - - // Main icon: always send (up) or receive (down) based on direction - const mainIcon = (() => { - if (isFailed) { - return ( - - - - ); - } - if (isOutgoing) { - return ( - - - - ); - } - return ( - - - - ); - })(); - - const bgColor = isFailed ? 'bg-red-100' : isOutgoing ? 'bg-red-100' : 'bg-green-100'; - const valueColor = isFailed ? 'text-red-600' : isOutgoing ? 'text-red-600' : 'text-green-600'; - const valueWithSign = isFailed ? value : isOutgoing ? `-${value}` : `+${value}`; - - const statusText = - finality === 'pending' - ? 'Pending' - : finality === 'confirmed' - ? 'Confirmed' - : finality === 'finalized' - ? 'Finalized' - : finality === 'invalidated' - ? 'Invalidated' - : isFailed - ? 'Failed' - : formatTimestamp(timestamp); - - const statusColor = - finality === 'pending' - ? 'text-yellow-600' - : finality === 'confirmed' - ? 'text-blue-600' - : finality === 'finalized' - ? 'text-indigo-600' - : finality === 'invalidated' - ? 'text-red-600' - : isFailed - ? 'text-red-500' - : 'text-gray-400'; - - const inner = ( - <> - {/* Row 1: description + value */} -
-
-
- {mainIcon} - {(finality !== 'done' || isFailed) && ( - - )} -
-

{description}

-
-
- {valueImage && ( - { - e.currentTarget.style.display = 'none'; - }} - /> - )} -

{valueWithSign}

-
-
- {/* Row 2: timestamp */} -
-

{statusText}

- {debugId && ( - - {debugId} - - )} -
- - ); - - if (!traceLink) { - return ( -
- {inner} -
- ); - } - - const isExternal = /^https?:\/\//.test(traceLink); - const className = 'block py-2 hover:bg-gray-50/50 -mx-1 px-1 rounded transition-colors'; - - if (isExternal) { - return ( - - {inner} - - ); - } - - return ( - - {inner} - - ); - }, -); - -TransactionCard.displayName = 'TransactionCard'; diff --git a/apps/demo-wallet/src/components/transactions/TransactionStates.tsx b/apps/demo-wallet/src/components/transactions/TransactionStates.tsx deleted file mode 100644 index 3800b6a54..000000000 --- a/apps/demo-wallet/src/components/transactions/TransactionStates.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright (c) TonTech. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import React from 'react'; - -interface TransactionErrorStateProps { - error: string; - onRetry: () => void; -} - -/** - * Error state component for transaction list - */ -export const TransactionErrorState: React.FC = ({ error, onRetry }) => ( -
-
- - - -
-

{error}

- -
-); - -/** - * Loading state component for transaction list - */ -export const TransactionLoadingState: React.FC = () => ( -
-
-

Loading transactions...

-
-); - -/** - * Empty state component for transaction list - */ -export const TransactionEmptyState: React.FC = () => ( -
-
- - - -
-

No activity yet

-

Your history will appear here

-
-); diff --git a/apps/demo-wallet/src/core/components/shared/amount-presets/amount-presets.tsx b/apps/demo-wallet/src/core/components/shared/amount-presets/amount-presets.tsx new file mode 100644 index 000000000..30197b12b --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/amount-presets/amount-presets.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { ComponentProps, FC } from 'react'; + +import { Button } from '@/core/components/ui/button'; +import { cn } from '@/core/lib/utils'; + +export interface AmountPreset { + label: string; + amount: string; + onSelect?: () => void; +} + +export interface AmountPresetsProps extends ComponentProps<'div'> { + presets: AmountPreset[]; + currencySymbol?: string; + onPresetSelect: (value: string) => void; +} + +/** Row of quick-amount buttons (e.g. 10% / 25% / 50% / MAX). Ported from appkit-react. */ +export const AmountPresets: FC = ({ + presets, + currencySymbol, + onPresetSelect, + className, + ...props +}) => ( +
+ {presets.map((preset) => ( + + ))} +
+); diff --git a/apps/demo-wallet/src/components/transactions/index.ts b/apps/demo-wallet/src/core/components/shared/amount-presets/index.ts similarity index 54% rename from apps/demo-wallet/src/components/transactions/index.ts rename to apps/demo-wallet/src/core/components/shared/amount-presets/index.ts index 50757e4e4..0f9c1611d 100644 --- a/apps/demo-wallet/src/components/transactions/index.ts +++ b/apps/demo-wallet/src/core/components/shared/amount-presets/index.ts @@ -6,7 +6,4 @@ * */ -export * from './TransactionStates'; -export * from './TonTransferCard'; -export * from './TransactionCard'; -export * from './ActionCard'; +export * from './amount-presets'; diff --git a/apps/demo-wallet/src/core/components/shared/centered-screen/centered-screen.tsx b/apps/demo-wallet/src/core/components/shared/centered-screen/centered-screen.tsx new file mode 100644 index 000000000..9bd097bd7 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/centered-screen/centered-screen.tsx @@ -0,0 +1,47 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { ChevronLeft } from 'lucide-react'; + +interface CenteredScreenProps { + /** Optional back button, pinned at the top. */ + onBack?: () => void; + /** Optional action area, pinned at the bottom. */ + footer?: React.ReactNode; + children: React.ReactNode; +} + +/** + * Full-screen onboarding layout: back button pinned top, actions pinned bottom, + * content vertically centered in between and scrollable when it doesn't fit. + */ +export const CenteredScreen: React.FC = ({ onBack, footer, children }) => ( +
+
+ {onBack && ( +
+ +
+ )} + +
+
{children}
+
+ + {footer &&
{footer}
} +
+
+); diff --git a/apps/demo-wallet/src/core/components/shared/centered-screen/index.ts b/apps/demo-wallet/src/core/components/shared/centered-screen/index.ts new file mode 100644 index 000000000..2219776c0 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/centered-screen/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { CenteredScreen } from './centered-screen'; diff --git a/apps/demo-wallet/src/core/components/shared/confirm-modal/confirm-modal.tsx b/apps/demo-wallet/src/core/components/shared/confirm-modal/confirm-modal.tsx new file mode 100644 index 000000000..178b66864 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/confirm-modal/confirm-modal.tsx @@ -0,0 +1,66 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +import { Modal } from '@/core/components/ui/modal'; + +interface ConfirmModalProps { + isOpen: boolean; + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + /** Style the confirm action as destructive (red). */ + danger?: boolean; + onConfirm: () => void; + onClose: () => void; +} + +/** Generic confirm/cancel modal (drawer on mobile, dialog on desktop). */ +export const ConfirmModal: React.FC = ({ + isOpen, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + danger = false, + onConfirm, + onClose, +}) => ( + !open && onClose()} className="px-2"> + + {title} + + + {description && ( + +

{description}

+
+ )} + + + + + +
+); diff --git a/apps/demo-wallet/src/core/components/shared/confirm-modal/index.ts b/apps/demo-wallet/src/core/components/shared/confirm-modal/index.ts new file mode 100644 index 000000000..139166f3a --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/confirm-modal/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { ConfirmModal } from './confirm-modal'; diff --git a/apps/demo-wallet/src/core/components/shared/new-layout/index.ts b/apps/demo-wallet/src/core/components/shared/new-layout/index.ts new file mode 100644 index 000000000..0468c2cc9 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/new-layout/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './new-layout'; diff --git a/apps/demo-wallet/src/core/components/shared/new-layout/new-layout.tsx b/apps/demo-wallet/src/core/components/shared/new-layout/new-layout.tsx new file mode 100644 index 000000000..e14762828 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/new-layout/new-layout.tsx @@ -0,0 +1,23 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +interface NewLayoutProps { + header?: React.ReactNode; + children: React.ReactNode; +} + +export const NewLayout: React.FC = ({ header, children }) => ( +
+
+ {header} +
{children}
+
+
+); diff --git a/apps/demo-wallet/src/core/components/shared/screen-header/index.ts b/apps/demo-wallet/src/core/components/shared/screen-header/index.ts new file mode 100644 index 000000000..7dddcbda7 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/screen-header/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './screen-header'; diff --git a/apps/demo-wallet/src/core/components/shared/screen-header/screen-header.tsx b/apps/demo-wallet/src/core/components/shared/screen-header/screen-header.tsx new file mode 100644 index 000000000..c118df0b3 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/screen-header/screen-header.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { ChevronLeft } from 'lucide-react'; + +interface ScreenHeaderProps { + title: string; + /** Back action; the back button is hidden when omitted. */ + onBack?: () => void; +} + +/** Page header for NewLayout: a round back button (same style as the modal close button) plus a title. */ +export const ScreenHeader: React.FC = ({ title, onBack }) => ( +
+ {onBack && ( + + )} +

{title}

+
+); diff --git a/apps/demo-wallet/src/core/components/shared/settings-button/index.ts b/apps/demo-wallet/src/core/components/shared/settings-button/index.ts new file mode 100644 index 000000000..c3ddee4b5 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/settings-button/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './settings-button'; diff --git a/apps/demo-wallet/src/core/components/shared/settings-button/settings-button.tsx b/apps/demo-wallet/src/core/components/shared/settings-button/settings-button.tsx new file mode 100644 index 000000000..735a07c21 --- /dev/null +++ b/apps/demo-wallet/src/core/components/shared/settings-button/settings-button.tsx @@ -0,0 +1,37 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { Settings2 } from 'lucide-react'; + +import { cn } from '@/core/lib/utils'; + +interface SettingsButtonProps { + onClick: () => void; + className?: string; + 'aria-label'?: string; +} + +/** Square gear button sized to sit next to a full-width `lg` action button (Swap / Staking). */ +export const SettingsButton: React.FC = ({ + onClick, + className, + 'aria-label': ariaLabel = 'Settings', +}) => ( + +); diff --git a/apps/demo-wallet/src/core/components/ui/amount-reversed/amount-reversed.tsx b/apps/demo-wallet/src/core/components/ui/amount-reversed/amount-reversed.tsx new file mode 100644 index 000000000..e549c1a08 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/amount-reversed/amount-reversed.tsx @@ -0,0 +1,60 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { ComponentProps, FC } from 'react'; + +import { cn } from '@/core/lib/utils'; +import { formatLargeValue } from '@/core/utils'; + +export interface AmountReversedProps extends ComponentProps<'div'> { + value: string; + ticker?: string; + symbol?: string; + decimals?: number; + errorMessage?: string; + isLoading?: boolean; +} + +/** Read-only secondary amount shown under the centered input (e.g. the fiat equivalent). */ +export const AmountReversed: FC = ({ + value, + ticker, + symbol, + decimals, + errorMessage, + isLoading, + className, + ...props +}) => { + const containerClass = cn( + 'flex w-full items-center justify-center gap-2 text-base font-semibold text-gray-500', + className, + ); + + if (errorMessage) { + return ( +
+ {errorMessage} +
+ ); + } + + return ( +
+ {isLoading ? ( + + ) : ( + + {symbol} + {value ? formatLargeValue(value, decimals) : '0'} + {ticker ? ` ${ticker}` : ''} + + )} +
+ ); +}; diff --git a/apps/demo-wallet/src/core/components/ui/amount-reversed/index.ts b/apps/demo-wallet/src/core/components/ui/amount-reversed/index.ts new file mode 100644 index 000000000..56736debd --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/amount-reversed/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './amount-reversed'; diff --git a/apps/demo-wallet/src/core/components/ui/button/button.tsx b/apps/demo-wallet/src/core/components/ui/button/button.tsx new file mode 100644 index 000000000..5a1abe919 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/button/button.tsx @@ -0,0 +1,87 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { forwardRef } from 'react'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { Loader2 } from 'lucide-react'; + +import { cn } from '@/core/lib/utils'; + +export type ButtonVariant = 'primary' | 'secondary' | 'gray' | 'danger' | 'ghost'; +export type ButtonSize = 'lg' | 'md' | 'sm' | 'icon'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + fullWidth?: boolean; + loading?: boolean; + /** Optional leading icon rendered before children. */ + icon?: ReactNode; +} + +const VARIANT_CLASS: Record = { + primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-100 disabled:text-gray-400', + secondary: 'bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60', + gray: 'bg-gray-100 text-gray-900 hover:bg-gray-200 disabled:opacity-50', + danger: 'bg-red-500 text-white hover:bg-red-600 disabled:opacity-60', + ghost: 'bg-transparent text-gray-400 hover:text-gray-600 disabled:opacity-50', +}; + +const SIZE_CLASS: Record = { + lg: 'px-5 py-3.5 text-base font-bold rounded-2xl', + md: 'px-4 py-2.5 text-sm font-semibold rounded-xl', + sm: 'px-4 py-2 text-sm font-semibold rounded-full', + icon: 'h-9 w-9 rounded-full', +}; + +/** + * Variant-based action button. Visual language is driven by `variant` + `size`; + * disabled/loading plumbing and icon rendering are handled here. + */ +export const Button = forwardRef( + ( + { + className, + variant = 'primary', + size = 'lg', + fullWidth = false, + loading = false, + disabled, + icon, + children, + type, + ...props + }, + ref, + ) => ( + + ), +); + +Button.displayName = 'Button'; diff --git a/apps/demo-wallet/src/core/components/ui/button/index.ts b/apps/demo-wallet/src/core/components/ui/button/index.ts new file mode 100644 index 000000000..af3535fe0 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/button/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { Button } from './button'; +export type { ButtonProps, ButtonVariant, ButtonSize } from './button'; diff --git a/apps/demo-wallet/src/components/Card.tsx b/apps/demo-wallet/src/core/components/ui/card/card.tsx similarity index 100% rename from apps/demo-wallet/src/components/Card.tsx rename to apps/demo-wallet/src/core/components/ui/card/card.tsx diff --git a/apps/demo-wallet/src/core/components/ui/card/index.ts b/apps/demo-wallet/src/core/components/ui/card/index.ts new file mode 100644 index 000000000..8bb13d3d8 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/card/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './card'; diff --git a/apps/demo-wallet/src/core/components/ui/centered-amount-input/centered-amount-input.tsx b/apps/demo-wallet/src/core/components/ui/centered-amount-input/centered-amount-input.tsx new file mode 100644 index 000000000..29f6f96bd --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/centered-amount-input/centered-amount-input.tsx @@ -0,0 +1,143 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import type { FC, ComponentProps } from 'react'; + +import { cn } from '@/core/lib/utils'; + +const MIN_FONT_SCALE = 0.5; +// Mirrors appkit-react's --ta-input-xl* tokens (amount 60px / ticker 40px, weight 700). +const INPUT_FONT = '60px'; +const INPUT_LINE_HEIGHT = '68px'; +const TICKER_FONT = '40px'; + +export interface CenteredAmountInputProps extends ComponentProps<'div'> { + value: string; + onValueChange: (value: string) => void; + ticker?: string; + symbol?: string; + placeholder?: string; + /** Base test id; the inner gets `${baseTestId}-input` (e.g. "send-amount" → "send-amount-input"). */ + baseTestId?: string; +} + +/** Big centered amount input whose font shrinks to fit the available width (ported from appkit-react). */ +export const CenteredAmountInput: FC = ({ + value, + onValueChange, + ticker, + symbol, + placeholder = '0', + className, + baseTestId, + ...props +}) => { + const wrapperRef = useRef(null); + const measureRowRef = useRef(null); + const mirrorRef = useRef(null); + const inputRef = useRef(null); + const [inputWidth, setInputWidth] = useState(undefined); + const [fontScale, setFontScale] = useState(1); + + const adjustSize = useCallback(() => { + const wrapper = wrapperRef.current; + const measureRow = measureRowRef.current; + const mirror = mirrorRef.current; + if (!wrapper || !measureRow || !mirror) return; + + const contentWidth = measureRow.offsetWidth; + const availableWidth = wrapper.clientWidth - 4; + + let scale = 1; + if (contentWidth > 0 && contentWidth > availableWidth) { + scale = Math.max(MIN_FONT_SCALE, availableWidth / contentWidth); + } + + setFontScale(scale); + setInputWidth(mirror.offsetWidth * scale + 4); + }, []); + + useLayoutEffect(adjustSize, [value, placeholder, symbol, ticker, adjustSize]); + + useLayoutEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + const observer = new ResizeObserver(adjustSize); + observer.observe(wrapper); + return () => observer.disconnect(); + }, [adjustSize]); + + const inputFontSize = fontScale < 1 ? `calc(${INPUT_FONT} * ${fontScale})` : INPUT_FONT; + const tickerFontSize = fontScale < 1 ? `calc(${TICKER_FONT} * ${fontScale})` : TICKER_FONT; + + return ( +
inputRef.current?.focus()} + {...props} + > + {/* Hidden row that measures the full content width at the base font. */} + + +
+ {symbol && ( + + {symbol} + + )} + onValueChange(e.target.value)} + style={{ + width: inputWidth ? `${inputWidth}px` : undefined, + fontSize: inputFontSize, + lineHeight: INPUT_LINE_HEIGHT, + boxSizing: 'content-box', + }} + /> + {ticker && ( + + {ticker} + + )} +
+ + {/* Hidden mirror that measures the input text width at the base font. */} + +
+ ); +}; diff --git a/apps/demo-wallet/src/core/components/ui/centered-amount-input/index.ts b/apps/demo-wallet/src/core/components/ui/centered-amount-input/index.ts new file mode 100644 index 000000000..f155b4018 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/centered-amount-input/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './centered-amount-input'; diff --git a/apps/demo-wallet/src/components/CircleLogo.tsx b/apps/demo-wallet/src/core/components/ui/circle-logo/circle-logo.tsx similarity index 97% rename from apps/demo-wallet/src/components/CircleLogo.tsx rename to apps/demo-wallet/src/core/components/ui/circle-logo/circle-logo.tsx index fb8c8c7ec..e6984f7f0 100644 --- a/apps/demo-wallet/src/components/CircleLogo.tsx +++ b/apps/demo-wallet/src/core/components/ui/circle-logo/circle-logo.tsx @@ -9,7 +9,7 @@ import type { ComponentProps } from 'react'; import { useState } from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@/core/lib/utils'; function CircleLogoContainer({ className, ...props }: ComponentProps<'div'>) { return ( diff --git a/apps/demo-wallet/src/core/components/ui/circle-logo/index.ts b/apps/demo-wallet/src/core/components/ui/circle-logo/index.ts new file mode 100644 index 000000000..f0cc01254 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/circle-logo/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './circle-logo'; diff --git a/apps/demo-wallet/src/core/components/ui/dialog/dialog.tsx b/apps/demo-wallet/src/core/components/ui/dialog/dialog.tsx new file mode 100644 index 000000000..6a608a9a2 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/dialog/dialog.tsx @@ -0,0 +1,98 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; + +import { cn } from '@/core/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/demo-wallet/src/core/components/ui/dialog/index.ts b/apps/demo-wallet/src/core/components/ui/dialog/index.ts new file mode 100644 index 000000000..5be87f197 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/dialog/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './dialog'; diff --git a/apps/demo-wallet/src/core/components/ui/drawer/drawer.tsx b/apps/demo-wallet/src/core/components/ui/drawer/drawer.tsx new file mode 100644 index 000000000..5e97f27ea --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/drawer/drawer.tsx @@ -0,0 +1,94 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import { Drawer as DrawerPrimitive } from 'vaul'; + +import { cn } from '@/core/lib/utils'; + +const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps) => ( + +); +Drawer.displayName = 'Drawer'; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + +)); +DrawerContent.displayName = 'DrawerContent'; + +const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = 'DrawerHeader'; + +const DrawerFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = 'DrawerFooter'; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/apps/demo-wallet/src/core/components/ui/drawer/index.ts b/apps/demo-wallet/src/core/components/ui/drawer/index.ts new file mode 100644 index 000000000..34faa783e --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/drawer/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './drawer'; diff --git a/apps/demo-wallet/src/core/components/ui/fallback-image/fallback-image.tsx b/apps/demo-wallet/src/core/components/ui/fallback-image/fallback-image.tsx new file mode 100644 index 000000000..2894f11e7 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/fallback-image/fallback-image.tsx @@ -0,0 +1,83 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useLayoutEffect, useRef, useState } from 'react'; + +type ImageStatus = 'idle' | 'loading' | 'loaded' | 'error'; + +const toList = (src: string | string[] | undefined): string[] => + (Array.isArray(src) ? src : src ? [src] : []).filter((url): url is string => Boolean(url)); + +const useImageStatus = (src: string | undefined): ImageStatus => { + const imageRef = useRef(null); + + const getImage = (): HTMLImageElement | null => { + if (typeof window === 'undefined') return null; + if (!imageRef.current) imageRef.current = new window.Image(); + return imageRef.current; + }; + + const resolve = (): ImageStatus => { + const image = getImage(); + if (!image || !src) return 'idle'; + if (image.src !== src) image.src = src; + return image.complete && image.naturalWidth > 0 ? 'loaded' : 'loading'; + }; + + const [status, setStatus] = useState(resolve); + + useLayoutEffect(() => { + setStatus(resolve()); + const image = getImage(); + if (!image) return; + const onLoad = () => setStatus('loaded'); + const onError = () => setStatus('error'); + image.addEventListener('load', onLoad); + image.addEventListener('error', onError); + return () => { + image.removeEventListener('load', onLoad); + image.removeEventListener('error', onError); + }; + }, [src]); + + return status; +}; + +interface FallbackImageProps extends Omit, 'src'> { + /** One or more candidate URLs, tried in order until one loads. */ + src: string | string[] | undefined; + /** Rendered while loading or when every candidate fails to load. */ + fallback?: React.ReactNode; +} + +/** + * `` that walks a list of candidate URLs, showing the first that loads and + * falling back to the next on error (404 / 403 / CSP / network). Renders + * `fallback` until one succeeds. + */ +export const FallbackImage: React.FC = ({ src, fallback = null, alt = '', ...props }) => { + const sources = toList(src); + const key = sources.join(' '); + + const [index, setIndex] = useState(0); + useLayoutEffect(() => setIndex(0), [key]); + + const current = sources[index]; + const status = useImageStatus(current); + + useLayoutEffect(() => { + if (status === 'error' && index < sources.length - 1) { + setIndex((value) => value + 1); + } + }, [status, index, sources.length]); + + if (status === 'loaded' && current) { + return {alt}; + } + return <>{fallback}; +}; diff --git a/apps/demo-wallet/src/core/components/ui/fallback-image/index.ts b/apps/demo-wallet/src/core/components/ui/fallback-image/index.ts new file mode 100644 index 000000000..3a1514afe --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/fallback-image/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './fallback-image'; diff --git a/apps/demo-wallet/src/components/HoldToSignButton.tsx b/apps/demo-wallet/src/core/components/ui/hold-to-sign-button/hold-to-sign-button.tsx similarity index 92% rename from apps/demo-wallet/src/components/HoldToSignButton.tsx rename to apps/demo-wallet/src/core/components/ui/hold-to-sign-button/hold-to-sign-button.tsx index 85bb3149d..8bfa2c75e 100644 --- a/apps/demo-wallet/src/components/HoldToSignButton.tsx +++ b/apps/demo-wallet/src/core/components/ui/hold-to-sign-button/hold-to-sign-button.tsx @@ -11,17 +11,25 @@ import React, { useRef, useState, useCallback, useEffect } from 'react'; interface HoldToSignButtonProps { onComplete: () => void; disabled?: boolean; - isLoading?: boolean; + loading?: boolean; holdDuration?: number; // Duration in milliseconds className?: string; + /** Label shown in the idle state (before holding). */ + idleLabel?: string; + /** Label shown once the hold completes. */ + completeLabel?: string; + testId?: string; } export const HoldToSignButton: React.FC = ({ onComplete, disabled = false, - isLoading = false, + loading = false, holdDuration = 3000, className = '', + idleLabel = 'Hold to Sign', + completeLabel = 'Signed!', + testId, }) => { const [isHolding, setIsHolding] = useState(false); const [progress, setProgress] = useState(0); @@ -43,7 +51,7 @@ export const HoldToSignButton: React.FC = ({ }, []); const handleHoldStart = useCallback(() => { - if (disabled || isLoading || isComplete) return; + if (disabled || loading || isComplete) return; setIsHolding(true); setShowRipples(true); @@ -74,7 +82,7 @@ export const HoldToSignButton: React.FC = ({ setProgress(0); }, 1000); }, holdDuration); - }, [disabled, isLoading, isComplete, holdDuration, onComplete, clearTimers]); + }, [disabled, loading, isComplete, holdDuration, onComplete, clearTimers]); const handleHoldEnd = useCallback(() => { if (isComplete) return; @@ -110,7 +118,7 @@ export const HoldToSignButton: React.FC = ({ const buttonClasses = ` relative flex-1 px-4 py-3 rounded-lg font-medium text-white overflow-hidden transition-all duration-300 select-none - ${disabled || isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} + ${disabled || loading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${isComplete ? 'bg-green-600' : isHolding ? 'bg-blue-700 scale-[0.98]' : 'bg-blue-600 hover:bg-blue-700'} ${className} `; @@ -118,13 +126,14 @@ export const HoldToSignButton: React.FC = ({ return ( + )} + {children} +
+ {onClose && ( + + )} +
+); + +export const ModalTitle: React.FC> = ({ className, ...props }) => { + const isMobile = useIsMobile(); + const Wrapper = isMobile ? DrawerTitle : DialogTitle; + return ; +}; + +export const ModalBody: React.FC> = ({ children, className, ...props }) => ( +
+ {children} +
+); + +export const ModalFooter: React.FC> = ({ children, className, ...props }) => ( +
+ {children} +
+); + +export const Modal = { + Container: ModalContainer, + Header: ModalHeader, + Title: ModalTitle, + Body: ModalBody, + Footer: ModalFooter, +}; diff --git a/apps/demo-wallet/src/core/components/ui/option-row/index.ts b/apps/demo-wallet/src/core/components/ui/option-row/index.ts new file mode 100644 index 000000000..691e920f0 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/option-row/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { OptionRow } from './option-row'; +export type { OptionRowProps } from './option-row'; diff --git a/apps/demo-wallet/src/core/components/ui/option-row/option-row.tsx b/apps/demo-wallet/src/core/components/ui/option-row/option-row.tsx new file mode 100644 index 000000000..7edbe5f72 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/option-row/option-row.tsx @@ -0,0 +1,50 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { ChevronRight, Loader2 } from 'lucide-react'; + +export interface OptionRowProps { + icon: React.ReactNode; + title: string; + subtitle: string; + loading?: boolean; + disabled?: boolean; + onClick?: () => void; + testId?: string; +} + +/** Selectable list row: leading icon, title/subtitle, trailing chevron (or spinner). */ +export const OptionRow: React.FC = ({ + icon, + title, + subtitle, + loading = false, + disabled = false, + onClick, + testId, +}) => ( + +); diff --git a/apps/demo-wallet/src/core/components/ui/popover/index.ts b/apps/demo-wallet/src/core/components/ui/popover/index.ts new file mode 100644 index 000000000..a93388445 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/popover/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './popover'; diff --git a/apps/demo-wallet/src/core/components/ui/popover/popover.tsx b/apps/demo-wallet/src/core/components/ui/popover/popover.tsx new file mode 100644 index 000000000..f1680f421 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/popover/popover.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; + +import { cn } from '@/core/lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverAnchor = PopoverPrimitive.Anchor; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/apps/demo-wallet/src/core/components/ui/segmented/index.ts b/apps/demo-wallet/src/core/components/ui/segmented/index.ts new file mode 100644 index 000000000..9fc13cf7d --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/segmented/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { Segmented } from './segmented'; +export type { SegmentedOption, SegmentedProps } from './segmented'; diff --git a/apps/demo-wallet/src/core/components/ui/segmented/segmented.tsx b/apps/demo-wallet/src/core/components/ui/segmented/segmented.tsx new file mode 100644 index 000000000..36d4a2eb8 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/segmented/segmented.tsx @@ -0,0 +1,45 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { cn } from '@/core/lib/utils'; + +export interface SegmentedOption { + value: T; + label: string; + testId?: string; +} + +export interface SegmentedProps { + value: T; + onChange: (value: T) => void; + options: SegmentedOption[]; + className?: string; +} + +/** Compact single-select segmented control (e.g. network / wallet version pickers). */ +export function Segmented({ value, onChange, options, className }: SegmentedProps) { + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ); +} diff --git a/apps/demo-wallet/src/core/components/ui/select/index.ts b/apps/demo-wallet/src/core/components/ui/select/index.ts new file mode 100644 index 000000000..184b41312 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/select/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './select'; diff --git a/apps/demo-wallet/src/core/components/ui/select/select.tsx b/apps/demo-wallet/src/core/components/ui/select/select.tsx new file mode 100644 index 000000000..1f5905646 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/select/select.tsx @@ -0,0 +1,149 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; + +import { cn } from '@/core/lib/utils'; + +function Select(props: React.ComponentProps) { + return ; +} + +function SelectGroup(props: React.ComponentProps) { + return ; +} + +function SelectValue(props: React.ComponentProps) { + return ; +} + +function SelectTrigger({ className, children, ...props }: React.ComponentProps) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = 'popper', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton(props: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton(props: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/demo-wallet/src/core/components/ui/sonner/index.ts b/apps/demo-wallet/src/core/components/ui/sonner/index.ts new file mode 100644 index 000000000..476d9ba73 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/sonner/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './sonner'; diff --git a/apps/demo-wallet/src/components/ui/sonner.tsx b/apps/demo-wallet/src/core/components/ui/sonner/sonner.tsx similarity index 100% rename from apps/demo-wallet/src/components/ui/sonner.tsx rename to apps/demo-wallet/src/core/components/ui/sonner/sonner.tsx diff --git a/apps/demo-wallet/src/core/components/ui/success-card/index.ts b/apps/demo-wallet/src/core/components/ui/success-card/index.ts new file mode 100644 index 000000000..118786ee6 --- /dev/null +++ b/apps/demo-wallet/src/core/components/ui/success-card/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './success-card'; diff --git a/apps/demo-wallet/src/components/SuccessCard.tsx b/apps/demo-wallet/src/core/components/ui/success-card/success-card.tsx similarity index 100% rename from apps/demo-wallet/src/components/SuccessCard.tsx rename to apps/demo-wallet/src/core/components/ui/success-card/success-card.tsx diff --git a/apps/demo-wallet/src/core/hooks/index.ts b/apps/demo-wallet/src/core/hooks/index.ts new file mode 100644 index 000000000..5cef44e31 --- /dev/null +++ b/apps/demo-wallet/src/core/hooks/index.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './use-count-up'; +export * from './use-media-query'; +export * from './use-paste-handler'; +export * from './use-ton-wallet'; +export * from './use-wallet-data-updater'; diff --git a/apps/demo-wallet/src/core/hooks/use-count-up.ts b/apps/demo-wallet/src/core/hooks/use-count-up.ts new file mode 100644 index 000000000..e1a0206b3 --- /dev/null +++ b/apps/demo-wallet/src/core/hooks/use-count-up.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useRef, useState } from 'react'; + +/** Ease-out cubic for smooth deceleration at the end. */ +const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3); + +/** + * Animate a number from its previous value to `target` (count-up / odometer + * roll). Returns the current animated value, updated each frame. + */ +export const useCountUp = (target: number, durationMs = 600): number => { + const [value, setValue] = useState(target); + const fromRef = useRef(target); + const rafRef = useRef(null); + + useEffect(() => { + const start = fromRef.current; + const end = target; + + if (Math.abs(start - end) < 1e-9) { + fromRef.current = end; + setValue(end); + return; + } + + const startTime = performance.now(); + const tick = (now: number) => { + const progress = Math.min((now - startTime) / durationMs, 1); + const current = start + (end - start) * easeOutCubic(progress); + fromRef.current = current; + setValue(current); + if (progress < 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + fromRef.current = end; + setValue(end); + } + }; + + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(tick); + + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, [target, durationMs]); + + return value; +}; diff --git a/apps/demo-wallet/src/core/hooks/use-media-query.ts b/apps/demo-wallet/src/core/hooks/use-media-query.ts new file mode 100644 index 000000000..c4a03f489 --- /dev/null +++ b/apps/demo-wallet/src/core/hooks/use-media-query.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useState } from 'react'; + +export const useMediaQuery = (query: string): boolean => { + const [matches, setMatches] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia(query).matches; + }); + + useEffect(() => { + if (typeof window === 'undefined') return; + const mql = window.matchMedia(query); + const handler = (event: MediaQueryListEvent) => setMatches(event.matches); + setMatches(mql.matches); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, [query]); + + return matches; +}; + +export const useIsDesktop = (): boolean => useMediaQuery('(min-width: 768px)'); + +export const useIsMobile = (): boolean => useMediaQuery('(max-width: 767px)'); diff --git a/apps/demo-wallet/src/hooks/usePasteHandler.ts b/apps/demo-wallet/src/core/hooks/use-paste-handler.ts similarity index 89% rename from apps/demo-wallet/src/hooks/usePasteHandler.ts rename to apps/demo-wallet/src/core/hooks/use-paste-handler.ts index 32fb23445..208e3f879 100644 --- a/apps/demo-wallet/src/hooks/usePasteHandler.ts +++ b/apps/demo-wallet/src/core/hooks/use-paste-handler.ts @@ -8,11 +8,13 @@ import { useEffect } from 'react'; -import { log } from '../utils/logger'; +import { log } from '@/core/lib/logger'; // Hook to listen for paste events and handle TON Connect URLs -export function usePasteHandler(handleTonConnectUrl: (url: string) => Promise) { +export function usePasteHandler(handleTonConnectUrl: (url: string) => Promise, isDisabled: boolean) { useEffect(() => { + if (isDisabled) return; + const handlePaste = async (event: ClipboardEvent) => { try { const pastedText = event.clipboardData?.getData('text'); @@ -36,5 +38,5 @@ export function usePasteHandler(handleTonConnectUrl: (url: string) => Promise document.removeEventListener('paste', handlePaste); - }, [handleTonConnectUrl]); + }, [handleTonConnectUrl, isDisabled]); } diff --git a/apps/demo-wallet/src/hooks/useTonWallet.ts b/apps/demo-wallet/src/core/hooks/use-ton-wallet.ts similarity index 98% rename from apps/demo-wallet/src/hooks/useTonWallet.ts rename to apps/demo-wallet/src/core/hooks/use-ton-wallet.ts index e3b625138..086c9a019 100644 --- a/apps/demo-wallet/src/hooks/useTonWallet.ts +++ b/apps/demo-wallet/src/core/hooks/use-ton-wallet.ts @@ -11,7 +11,7 @@ import { CreateTonMnemonic } from '@ton/walletkit'; import { useWallet, useAuth } from '@demo/wallet-core'; import type { NetworkType } from '@demo/wallet-core'; -import { createComponentLogger } from '../utils/logger'; +import { createComponentLogger } from '@/core/lib/logger'; // Create logger for TON wallet hook const log = createComponentLogger('useTonWallet'); diff --git a/apps/demo-wallet/src/hooks/useWalletDataUpdater.ts b/apps/demo-wallet/src/core/hooks/use-wallet-data-updater.ts similarity index 64% rename from apps/demo-wallet/src/hooks/useWalletDataUpdater.ts rename to apps/demo-wallet/src/core/hooks/use-wallet-data-updater.ts index 8ffcd770b..52b2b03a6 100644 --- a/apps/demo-wallet/src/hooks/useWalletDataUpdater.ts +++ b/apps/demo-wallet/src/core/hooks/use-wallet-data-updater.ts @@ -7,13 +7,14 @@ */ import { useEffect } from 'react'; -import { useAuth, useJettons, useNfts, useWallet } from '@demo/wallet-core'; +import { useAuth, useJettons, useNfts, useRates, useWallet } from '@demo/wallet-core'; export const useWalletDataUpdater = () => { const { address, updateBalance, hasWallet, currentWallet, loadAllWallets } = useWallet(); const { isUnlocked } = useAuth(); const { loadUserJettons, clearJettons } = useJettons(); const { loadUserNfts, clearNfts, refreshNfts } = useNfts(); + const { loadRates, clearRates } = useRates(); // Load wallets when hasWallet but currentWallet missing (e.g. refresh on /send before rehydration) useEffect(() => { @@ -28,22 +29,31 @@ export const useWalletDataUpdater = () => { clearNfts(); clearJettons(); - void Promise.allSettled([updateBalance(), loadUserJettons(), loadUserNfts()]); - }, [address, updateBalance, loadUserJettons, loadUserNfts, clearNfts, clearJettons]); + clearRates(); - // Periodic refresh: balances, jettons and NFTs sequentially to avoid overloading the backend + void (async () => { + await Promise.allSettled([updateBalance(), loadUserJettons(), loadUserNfts()]); + await loadRates(); + })(); + }, [address, updateBalance, loadUserJettons, loadUserNfts, loadRates, clearNfts, clearJettons, clearRates]); + + // Periodic refresh — sequential to avoid overloading the backend (ported from main): + // balance → jettons → rates → NFTs, chained via setTimeout, every 20s. Rates self-throttle + // to 60s (see loadRates), so re-requesting on each tick is cheap. useEffect(() => { if (!address) return; let cancelled = false; let timeout: ReturnType; - const refreshInterval = 20_000; + const refreshInterval = 30_000; const tick = async () => { await updateBalance().catch(() => {}); if (cancelled) return; await loadUserJettons().catch(() => {}); if (cancelled) return; + await loadRates().catch(() => {}); + if (cancelled) return; await refreshNfts().catch(() => {}); if (cancelled) return; timeout = setTimeout(() => void tick(), refreshInterval); @@ -55,5 +65,5 @@ export const useWalletDataUpdater = () => { cancelled = true; clearTimeout(timeout); }; - }, [address, updateBalance, loadUserJettons, refreshNfts]); + }, [address, updateBalance, loadUserJettons, loadRates, refreshNfts]); }; diff --git a/apps/demo-wallet/src/lib/constants.ts b/apps/demo-wallet/src/core/lib/constants.ts similarity index 100% rename from apps/demo-wallet/src/lib/constants.ts rename to apps/demo-wallet/src/core/lib/constants.ts diff --git a/apps/demo-wallet/src/lib/env.ts b/apps/demo-wallet/src/core/lib/env.ts similarity index 100% rename from apps/demo-wallet/src/lib/env.ts rename to apps/demo-wallet/src/core/lib/env.ts diff --git a/apps/demo-wallet/src/lib/extension.ts b/apps/demo-wallet/src/core/lib/extension.ts similarity index 94% rename from apps/demo-wallet/src/lib/extension.ts rename to apps/demo-wallet/src/core/lib/extension.ts index 515d06ebf..b85b9aefd 100644 --- a/apps/demo-wallet/src/lib/extension.ts +++ b/apps/demo-wallet/src/core/lib/extension.ts @@ -10,7 +10,7 @@ import type { sendMessage } from '@truecarry/webext-bridge/background'; import { JS_BRIDGE_MESSAGE_TO_CONTENT } from './constants'; -import { createComponentLogger } from '@/utils/logger'; +import { createComponentLogger } from '@/core/lib/logger'; const log = createComponentLogger('Extension'); diff --git a/apps/demo-wallet/src/lib/extensionBackground.ts b/apps/demo-wallet/src/core/lib/extensionBackground.ts similarity index 100% rename from apps/demo-wallet/src/lib/extensionBackground.ts rename to apps/demo-wallet/src/core/lib/extensionBackground.ts diff --git a/apps/demo-wallet/src/lib/extensionPopup.ts b/apps/demo-wallet/src/core/lib/extensionPopup.ts similarity index 100% rename from apps/demo-wallet/src/lib/extensionPopup.ts rename to apps/demo-wallet/src/core/lib/extensionPopup.ts diff --git a/apps/demo-wallet/src/utils/isExtension.ts b/apps/demo-wallet/src/core/lib/is-extension.ts similarity index 100% rename from apps/demo-wallet/src/utils/isExtension.ts rename to apps/demo-wallet/src/core/lib/is-extension.ts diff --git a/apps/demo-wallet/src/utils/logger.ts b/apps/demo-wallet/src/core/lib/logger.ts similarity index 97% rename from apps/demo-wallet/src/utils/logger.ts rename to apps/demo-wallet/src/core/lib/logger.ts index 972b47b03..0aea68ba1 100644 --- a/apps/demo-wallet/src/utils/logger.ts +++ b/apps/demo-wallet/src/core/lib/logger.ts @@ -148,17 +148,17 @@ export default log; Usage examples: // Basic logging -import log from './utils/logger'; +import log from '@/core/lib/logger'; log.info('User connected wallet'); log.error('Transaction failed', error); // Component-specific logging -import { createComponentLogger } from './utils/logger'; +import { createComponentLogger } from '@/core/lib/logger'; const componentLog = createComponentLogger('WalletDashboard'); componentLog.debug('Component mounted'); // Runtime log level control (useful for debugging) -import { setLogLevel, getLogLevel } from './utils/logger'; +import { setLogLevel, getLogLevel } from '@/core/lib/logger'; setLogLevel(5); // Enable debug logging // Log levels: diff --git a/apps/demo-wallet/src/lib/utils.ts b/apps/demo-wallet/src/core/lib/utils.ts similarity index 100% rename from apps/demo-wallet/src/lib/utils.ts rename to apps/demo-wallet/src/core/lib/utils.ts diff --git a/apps/demo-wallet/src/utils/walletManifest.ts b/apps/demo-wallet/src/core/lib/wallet-manifest.ts similarity index 97% rename from apps/demo-wallet/src/utils/walletManifest.ts rename to apps/demo-wallet/src/core/lib/wallet-manifest.ts index 30bf1948d..00850b812 100644 --- a/apps/demo-wallet/src/utils/walletManifest.ts +++ b/apps/demo-wallet/src/core/lib/wallet-manifest.ts @@ -8,7 +8,7 @@ import type { DeviceInfo, Feature, WalletInfo } from '@ton/walletkit'; -import { isExtension } from './isExtension'; +import { isExtension } from '@/core/lib/is-extension'; export function getTonConnectWalletManifest(): WalletInfo { return { diff --git a/apps/demo-wallet/src/components/AppRouter.tsx b/apps/demo-wallet/src/core/routing/app-router.tsx similarity index 68% rename from apps/demo-wallet/src/components/AppRouter.tsx rename to apps/demo-wallet/src/core/routing/app-router.tsx index 57020400a..61b4c905c 100644 --- a/apps/demo-wallet/src/components/AppRouter.tsx +++ b/apps/demo-wallet/src/core/routing/app-router.tsx @@ -10,23 +10,23 @@ import React from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { useWalletStore, useWallet } from '@demo/wallet-core'; -import { ProtectedRoute } from './ProtectedRoute'; -import { LoaderCircle } from './LoaderCircle'; -import { - SetupPassword, - UnlockWallet, - SetupWallet, - WalletDashboard, - SendTransaction, - TracePage, - TransactionDetail, - Swap, - Staking, - TonConnectRoute, -} from '../pages'; +import { ProtectedRoute } from './protected-route'; -import { useWalletDataUpdater } from '@/hooks/useWalletDataUpdater'; -import { Button } from '@/components/Button'; +import { LoaderCircle } from '@/core/components/ui/loader-circle'; +import { WalletDashboard } from '@/features/dashboard'; +import { AssetsScreen } from '@/features/assets'; +import { NftsScreen } from '@/features/nft'; +import { TonConnectRoute } from '@/features/ton-connect'; +import { HistoryScreen } from '@/features/transactions'; +import { Staking } from '@/features/staking'; +import { Swap } from '@/features/swap'; +import { SendTransaction } from '@/features/send'; +import { SetupPasswordScreen, UnlockScreen } from '@/features/auth'; +import { LedgerScreen } from '@/features/ledger'; +import { WelcomeScreen, CreateWalletScreen, ImportWalletScreen } from '@/features/wallet-setup'; +import { useWalletDataUpdater } from '@/core/hooks/use-wallet-data-updater'; +import { useReceivedToasts } from '@/features/notifications'; +import { Button } from '@/core/components/ui/button'; export const AppRouter: React.FC = () => { const isPasswordSet = useWalletStore((state) => state.auth.isPasswordSet); @@ -36,11 +36,12 @@ export const AppRouter: React.FC = () => { const { hasWallet } = useWallet(); useWalletDataUpdater(); + useReceivedToasts(); const getInitialRoute = () => { - if (!isPasswordSet) return '/setup-password'; + if (!isPasswordSet) return '/welcome'; if (!isUnlocked) return '/unlock'; - if (!hasWallet) return '/setup-wallet'; + if (!hasWallet) return '/welcome'; return '/wallet'; }; @@ -83,15 +84,32 @@ export const AppRouter: React.FC = () => { {/* Public routes */} - } /> - } /> + } /> + } /> + } /> {/* Protected routes - require authentication */} - + + + } + /> + + + + } + /> + + } /> @@ -106,42 +124,50 @@ export const AppRouter: React.FC = () => { } /> - + } /> - + } /> - + } /> - + } /> - + + + } + /> + + } /> diff --git a/apps/demo-wallet/src/core/routing/index.ts b/apps/demo-wallet/src/core/routing/index.ts new file mode 100644 index 000000000..e88f26813 --- /dev/null +++ b/apps/demo-wallet/src/core/routing/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './app-router'; +export * from './protected-route'; diff --git a/apps/demo-wallet/src/components/ProtectedRoute.tsx b/apps/demo-wallet/src/core/routing/protected-route.tsx similarity index 75% rename from apps/demo-wallet/src/components/ProtectedRoute.tsx rename to apps/demo-wallet/src/core/routing/protected-route.tsx index 2c04508eb..97684432a 100644 --- a/apps/demo-wallet/src/components/ProtectedRoute.tsx +++ b/apps/demo-wallet/src/core/routing/protected-route.tsx @@ -19,9 +19,9 @@ export const ProtectedRoute: React.FC = ({ children, requir const { isPasswordSet, isUnlocked } = useAuth(); const { hasWallet } = useWallet(); - // If no password is set, redirect to password setup + // If no password is set (brand new or after a reset), start from the welcome screen if (!isPasswordSet) { - return ; + return ; } // If password is set but wallet is locked, redirect to unlock @@ -29,9 +29,9 @@ export const ProtectedRoute: React.FC = ({ children, requir return ; } - // If wallet is required but doesn't exist, redirect to wallet setup + // If wallet is required but doesn't exist, send the user to the welcome screen if (requiresWallet && !hasWallet) { - return ; + return ; } return <>{children}; diff --git a/apps/demo-wallet/src/core/utils/format.ts b/apps/demo-wallet/src/core/utils/format.ts new file mode 100644 index 000000000..0a91946f9 --- /dev/null +++ b/apps/demo-wallet/src/core/utils/format.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { formatUnits } from '@ton/walletkit'; + +const usd = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const usdSmall = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8 }); + +/** Raw token units (nanotons / jetton base units) → a JS number in whole tokens. */ +export const toDecimal = (raw: bigint | string | undefined, decimals: number): number => { + if (raw === undefined || raw === '') return 0; + try { + return Number(formatUnits(raw, decimals)); + } catch { + return 0; + } +}; + +/** Per-unit price → `$`, with extra precision for sub-cent values. */ +export const formatRate = (value: number): string => `$${(value >= 0.01 ? usd : usdSmall).format(value)}`; diff --git a/apps/demo-wallet/src/utils/formatters.ts b/apps/demo-wallet/src/core/utils/formatters.ts similarity index 53% rename from apps/demo-wallet/src/utils/formatters.ts rename to apps/demo-wallet/src/core/utils/formatters.ts index 84585d826..30793b6a7 100644 --- a/apps/demo-wallet/src/utils/formatters.ts +++ b/apps/demo-wallet/src/core/utils/formatters.ts @@ -9,17 +9,17 @@ import { Address } from '@ton/core'; import { Base64ToHex } from '@ton/walletkit'; -export function normalizeAddress(address: string): string | null { +export function normalizeAddress(address: string, bounceable = false): string | null { try { - return Address.parse(address).toString(); + return Address.parse(address).toString({ bounceable }); } catch { return null; } } -export function shortenAddress(addr?: string, count = 6): string { +export function shortenAddress(addr?: string, count = 4, bounceable = false): string { if (!addr) return ''; - const normalized = normalizeAddress(addr) ?? addr; + const normalized = normalizeAddress(addr, bounceable) ?? addr; return normalized.length <= count * 2 ? normalized : `${normalized.slice(0, count)}...${normalized.slice(-count)}`; } @@ -44,32 +44,6 @@ export const formatTimestamp = (timestampSeconds: number): string => { return new Date(timestampSeconds * 1000).toLocaleString(); }; -/** - * Formats TON amount for consistent display (4 decimals). - * Accepts amount in nanoton (string) or formatted value like "0.001 TON". - * TODO - make better function for formatting amounts - */ -export const formatTonForDisplay = (amountOrValue: string): string => { - const num = - amountOrValue.includes('TON') || amountOrValue.includes('.') - ? parseFloat( - String(amountOrValue) - .replace(/\s*TON\s*$/i, '') - .trim(), - ) || 0 - : parseFloat(amountOrValue || '0') / 1e9; - return num.toFixed(4); -}; - -/** - * Formats a blockchain address to a shortened form (first 6 and last 6 characters) - * @param addr - Full blockchain address - * @returns Shortened address (e.g., "EQAbc...xyz123") - */ -export const formatAddress = (addr: string): string => { - return shortenAddress(addr); -}; - type ExplorerNetwork = 'mainnet' | 'testnet' | 'tetra'; function getTonviewerHost(network: ExplorerNetwork): string { @@ -93,3 +67,37 @@ function toHexHash(hash: string): string { export function getTonviewerTxUrl(network: ExplorerNetwork, hash: string): string { return `https://${getTonviewerHost(network)}/transaction/${toHexHash(hash)}`; } + +/** + * Formats a human-readable amount for compact display, mirroring the appkit-react + * widget formatter (`formatLargeValue` from `@ton/appkit`): abbreviates large values + * (M/B/T) and otherwise truncates to `decimals` fractional digits with locale + * thousands separators. Expects a decimal amount, not nanoton. + */ +export const formatLargeValue = (amount: string, decimals: number = 2, minimumFractionDigits: number = 0): string => { + const cleanAmount = amount.toString().replace(/\s/g, ''); + const intPart = cleanAmount.split('.')[0] || '0'; + + // > 100 000 000 000 000 => 100T + if (intPart.length > 12) { + return `${(Number(intPart.slice(0, -10)) / 100).toLocaleString('en-US')}T`; + } + // > 100 000 000 000 => 100B + if (intPart.length > 9) { + return `${(Number(intPart.slice(0, -7)) / 100).toLocaleString('en-US')}B`; + } + // > 10 000 000 => 10M + if (intPart.length > 6) { + return `${(Number(intPart.slice(0, -4)) / 100).toLocaleString('en-US')}M`; + } + + const value = parseFloat(cleanAmount); + if (isNaN(value)) { + return '0'; + } + + const factor = Math.pow(10, decimals); + const truncated = Math.floor(value * factor) / factor; + + return truncated.toLocaleString('en-US', { minimumFractionDigits, maximumFractionDigits: decimals }); +}; diff --git a/apps/demo-wallet/src/utils/index.ts b/apps/demo-wallet/src/core/utils/index.ts similarity index 69% rename from apps/demo-wallet/src/utils/index.ts rename to apps/demo-wallet/src/core/utils/index.ts index 664a2a940..cdf5226cb 100644 --- a/apps/demo-wallet/src/utils/index.ts +++ b/apps/demo-wallet/src/core/utils/index.ts @@ -6,6 +6,10 @@ * */ +export * from './format'; export * from './formatters'; export * from './payload'; +export * from './rates'; +export * from './telegram'; +export * from './token-image'; export * from './units'; diff --git a/apps/demo-wallet/src/utils/payload.ts b/apps/demo-wallet/src/core/utils/payload.ts similarity index 100% rename from apps/demo-wallet/src/utils/payload.ts rename to apps/demo-wallet/src/core/utils/payload.ts diff --git a/apps/demo-wallet/src/core/utils/rates.ts b/apps/demo-wallet/src/core/utils/rates.ts new file mode 100644 index 000000000..930c39eda --- /dev/null +++ b/apps/demo-wallet/src/core/utils/rates.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { compareAddress } from '@ton/walletkit'; +import type { RateEntry } from '@demo/wallet-core'; + +/** + * Look up a rate by address, tolerant of address format (EQ/UQ/raw). + * Tries a direct key hit first, then falls back to address-equality comparison. + */ +export function findRate(rates: Record, address: string): RateEntry | undefined { + const direct = rates[address]; + if (direct) return direct; + + for (const [key, entry] of Object.entries(rates)) { + if (compareAddress(key, address)) return entry; + } + return undefined; +} diff --git a/apps/demo-wallet/src/core/utils/telegram.ts b/apps/demo-wallet/src/core/utils/telegram.ts new file mode 100644 index 000000000..a5e3b8ec4 --- /dev/null +++ b/apps/demo-wallet/src/core/utils/telegram.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { init, openTelegramLink as openTgLink, retrieveLaunchParams } from '@telegram-apps/sdk'; + +// Initialize the SDK so its helpers can reach the native Telegram client. +try { + init(); +} catch { + /* not inside a Telegram Mini App */ +} + +/** Telegram user id from the Mini App launch params, or undefined outside Telegram. */ +export function getTelegramId(): number | undefined { + try { + return retrieveLaunchParams(true).tgWebAppData?.user?.id; + } catch { + return undefined; + } +} + +/** Open a t.me link inside Telegram, falling back to a new browser tab. */ +export function openTelegramLink(url: string): void { + if (openTgLink.isAvailable()) { + openTgLink(url); + return; + } + window.open(url, '_blank', 'noopener,noreferrer'); +} diff --git a/apps/demo-wallet/src/core/utils/token-image.ts b/apps/demo-wallet/src/core/utils/token-image.ts new file mode 100644 index 000000000..837ca5d15 --- /dev/null +++ b/apps/demo-wallet/src/core/utils/token-image.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { TokenImage } from '@ton/walletkit'; + +/** + * Flatten a wallet-kit {@link TokenImage} into candidate image URLs, best-first. + * The kit exposes discrete size fields; the UI consumes an ordered list and + * picks the first usable one. + */ +export const tokenImageUrls = (image: TokenImage | undefined): string[] => + image + ? [image.url, image.largeUrl, image.mediumUrl, image.smallUrl].filter((url): url is string => Boolean(url)) + : []; diff --git a/apps/demo-wallet/src/utils/units.ts b/apps/demo-wallet/src/core/utils/units.ts similarity index 98% rename from apps/demo-wallet/src/utils/units.ts rename to apps/demo-wallet/src/core/utils/units.ts index 4a589e88a..f057bb393 100644 --- a/apps/demo-wallet/src/utils/units.ts +++ b/apps/demo-wallet/src/core/utils/units.ts @@ -89,7 +89,7 @@ export function formatTon(value: bigint | string) { export function formatNanoTonAmount(value?: bigint | string) { try { - return `${formatTon(value ?? '0')} TON`; + return `${formatTon(value ?? '0')} GRAM`; } catch { return `${value ?? '0'} nanotons`; } diff --git a/apps/demo-wallet/src/extension.css b/apps/demo-wallet/src/extension.css index 555d87663..eb7e1f06e 100644 --- a/apps/demo-wallet/src/extension.css +++ b/apps/demo-wallet/src/extension.css @@ -1,7 +1,13 @@ -#root { +html, +body { + margin: 0; padding: 0; - min-width: 400px; - max-width: 400px; - border-radius: 32px; - overflow: hidden; + width: 400px; + height: 600px; +} + +#root { + width: 100%; + height: 100%; + overflow-y: auto; } diff --git a/apps/demo-wallet/src/extension/background_main.ts b/apps/demo-wallet/src/extension/background_main.ts index 70bbbeff8..6e45c0f54 100644 --- a/apps/demo-wallet/src/extension/background_main.ts +++ b/apps/demo-wallet/src/extension/background_main.ts @@ -17,11 +17,15 @@ import browser from 'webextension-polyfill'; import { onMessage } from '@truecarry/webext-bridge/background'; import { INJECT_CONTENT_SCRIPT, TONCONNECT_BRIDGE_REQUEST } from '@ton/walletkit/bridge'; -import { getTonConnectDeviceInfo, getTonConnectWalletManifest } from '../utils/walletManifest'; - -import { JS_BRIDGE_MESSAGE_TO_BACKGROUND } from '@/lib/constants'; -import { SendMessageToExtensionContentFromBackground } from '@/lib/extensionBackground'; -import { DISABLE_AUTO_POPUP, ENV_TON_API_KEY_MAINNET, ENV_TON_API_KEY_TESTNET, ENV_TON_API_KEY_TETRA } from '@/lib/env'; +import { getTonConnectDeviceInfo, getTonConnectWalletManifest } from '@/core/lib/wallet-manifest'; +import { JS_BRIDGE_MESSAGE_TO_BACKGROUND } from '@/core/lib/constants'; +import { SendMessageToExtensionContentFromBackground } from '@/core/lib/extensionBackground'; +import { + DISABLE_AUTO_POPUP, + ENV_TON_API_KEY_MAINNET, + ENV_TON_API_KEY_TESTNET, + ENV_TON_API_KEY_TETRA, +} from '@/core/lib/env'; // Initialize WalletKit and JSBridge let walletKit: TonWalletKit | null = null; diff --git a/apps/demo-wallet/src/extension/content.ts b/apps/demo-wallet/src/extension/content.ts index 22243ff89..31bd9da00 100644 --- a/apps/demo-wallet/src/extension/content.ts +++ b/apps/demo-wallet/src/extension/content.ts @@ -17,9 +17,12 @@ import { ExtensionTransport, injectBridgeCode } from '@ton/walletkit/bridge'; import type { MessageSender, MessageListener } from '@ton/walletkit/bridge'; import { onMessage, sendMessage, setNamespace } from '@truecarry/webext-bridge/window'; -import { getTonConnectDeviceInfo, getTonConnectWalletManifest } from '../utils/walletManifest'; - -import { JS_BRIDGE_MESSAGE_TO_BACKGROUND, JS_BRIDGE_MESSAGE_TO_CONTENT, JS_BRIDGE_NAMESPACE } from '@/lib/constants'; +import { getTonConnectDeviceInfo, getTonConnectWalletManifest } from '@/core/lib/wallet-manifest'; +import { + JS_BRIDGE_MESSAGE_TO_BACKGROUND, + JS_BRIDGE_MESSAGE_TO_CONTENT, + JS_BRIDGE_NAMESPACE, +} from '@/core/lib/constants'; try { setNamespace(JS_BRIDGE_NAMESPACE); diff --git a/apps/demo-wallet/src/extension/content_script.ts b/apps/demo-wallet/src/extension/content_script.ts index ecbfe754e..d2faf135c 100644 --- a/apps/demo-wallet/src/extension/content_script.ts +++ b/apps/demo-wallet/src/extension/content_script.ts @@ -8,7 +8,7 @@ import { allowWindowMessaging } from '@truecarry/webext-bridge/content-script'; -import { JS_BRIDGE_NAMESPACE } from '@/lib/constants'; +import { JS_BRIDGE_NAMESPACE } from '@/core/lib/constants'; async function startContentScript() { allowWindowMessaging(JS_BRIDGE_NAMESPACE); diff --git a/apps/demo-wallet/src/features/assets/components/asset-row/asset-row.tsx b/apps/demo-wallet/src/features/assets/components/asset-row/asset-row.tsx new file mode 100644 index 000000000..a6877f0f5 --- /dev/null +++ b/apps/demo-wallet/src/features/assets/components/asset-row/asset-row.tsx @@ -0,0 +1,78 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +import { FallbackImage } from '@/core/components/ui/fallback-image'; +import { useCountUp } from '@/core/hooks/use-count-up'; +import { formatLargeValue } from '@/core/utils'; + +/** View-model for a single balance row (TON or a jetton). */ +export interface AssetRowData { + id: string; + /** One or more candidate icon URLs, tried in order until one loads. */ + icon?: string | string[]; + fallbackText: string; + name: string; + symbol: string; + amount: number; + rateLabel?: string; + /** Fiat value to display on the right; omit to hide (asset has no rate). */ + fiat?: number; +} + +export const AssetRow: React.FC = ({ icon, fallbackText, name, symbol, amount, rateLabel, fiat }) => { + const animatedAmount = useCountUp(amount); + const animatedFiat = useCountUp(fiat ?? 0); + const hasFiat = fiat !== undefined; + + return ( +
+ + + {fallbackText} + + } + /> + +
+
{name}
+
+ {formatLargeValue(String(animatedAmount), 4)} {symbol} + {rateLabel && ` · ${rateLabel}`} +
+
+ {hasFiat && ( +
+
+ ${formatLargeValue(String(animatedFiat), 2, 2)} +
+
+ )} +
+ ); +}; + +export const AssetRowSkeleton: React.FC = () => ( +
+ +
+
+
+
+
+
+
+
+
+); diff --git a/apps/demo-wallet/src/features/assets/components/asset-row/index.ts b/apps/demo-wallet/src/features/assets/components/asset-row/index.ts new file mode 100644 index 000000000..eeae9c0ca --- /dev/null +++ b/apps/demo-wallet/src/features/assets/components/asset-row/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './asset-row'; diff --git a/apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx b/apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx new file mode 100644 index 000000000..339b04fa2 --- /dev/null +++ b/apps/demo-wallet/src/features/assets/components/assets-screen/assets-screen.tsx @@ -0,0 +1,43 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { AssetRow, AssetRowSkeleton } from '../asset-row'; +import { useAssetRows } from '../../hooks/use-asset-rows'; + +import { NewLayout } from '@/core/components/shared/new-layout'; +import { ScreenHeader } from '@/core/components/shared/screen-header'; + +/** Full assets page: every token on the active wallet's balance (TON + all jettons). */ +export const AssetsScreen: FC = () => { + const navigate = useNavigate(); + const { tonRow, jettonRows, assetsReady } = useAssetRows(); + + return ( + navigate('/wallet')} />}> +
+ {assetsReady && tonRow ? ( + <> + + {jettonRows.map((row) => ( + + ))} + + ) : ( + <> + + + + + )} +
+
+ ); +}; diff --git a/apps/demo-wallet/src/features/assets/components/assets-screen/index.ts b/apps/demo-wallet/src/features/assets/components/assets-screen/index.ts new file mode 100644 index 000000000..e8ccf6c69 --- /dev/null +++ b/apps/demo-wallet/src/features/assets/components/assets-screen/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './assets-screen'; diff --git a/apps/demo-wallet/src/features/assets/hooks/use-asset-rows.ts b/apps/demo-wallet/src/features/assets/hooks/use-asset-rows.ts new file mode 100644 index 000000000..afb9a461a --- /dev/null +++ b/apps/demo-wallet/src/features/assets/hooks/use-asset-rows.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; +import { useJettons, useRates, useWallet } from '@demo/wallet-core'; + +import type { AssetRowData } from '../components/asset-row'; + +import { getJettonsName, getJettonsSymbol } from '@/features/jettons'; +import { findRate, formatRate, toDecimal, tokenImageUrls } from '@/core/utils'; + +const GRAM_DECIMALS = 9; + +/** Candidate icon URLs (best-first), appending the inline base64 image as a last resort. */ +export const imageSources = (urls: string[] | undefined, dataBase64?: string): string[] => [ + ...(urls ?? []), + ...(dataBase64 ? [`data:image/png;base64,${dataBase64}`] : []), +]; + +interface AssetRows { + tonRow: AssetRowData | null; + /** All held jettons as rows, sorted by fiat value desc (verified first as a tiebreaker). */ + jettonRows: AssetRowData[]; + assetsReady: boolean; +} + +/** Builds the TON row + a row per held jetton. Shared by the dashboard preview and the full assets page. */ +export const useAssetRows = (): AssetRows => { + const { balance } = useWallet(); + const { userJettons, lastJettonsUpdate } = useJettons(); + const { entries: rates, lastUpdated: ratesUpdated } = useRates(); + + const assetsReady = balance !== undefined && lastJettonsUpdate > 0 && ratesUpdated > 0; + + const tonRow = useMemo(() => { + if (!assetsReady) return null; + const rateEntry = rates['GRAM']; + const amount = toDecimal(balance, GRAM_DECIMALS); + return { + id: 'TON', + icon: '/gram.svg', + fallbackText: 'GR', + name: 'Gram', + symbol: 'GRAM', + amount, + rateLabel: rateEntry ? formatRate(rateEntry.rate) : undefined, + fiat: rateEntry ? amount * rateEntry.rate : undefined, + }; + }, [assetsReady, balance, rates]); + + const jettonRows = useMemo(() => { + if (!assetsReady) return []; + return userJettons + .map((jetton) => { + const rateEntry = findRate(rates, jetton.address); + const decimals = jetton.decimalsNumber ?? 9; + const amount = toDecimal(jetton.balance, decimals); + const symbol = getJettonsSymbol(jetton) ?? ''; + return { + row: { + id: jetton.address, + icon: imageSources(tokenImageUrls(jetton.info?.image), jetton.info?.image?.data), + fallbackText: symbol.slice(0, 2).toUpperCase() || '??', + name: getJettonsName(jetton) ?? symbol, + symbol, + amount, + rateLabel: rateEntry ? formatRate(rateEntry.rate) : undefined, + fiat: rateEntry ? amount * rateEntry.rate : undefined, + } satisfies AssetRowData, + isVerified: jetton.isVerified, + }; + }) + .sort((a, b) => (b.row.fiat ?? 0) - (a.row.fiat ?? 0) || Number(b.isVerified) - Number(a.isVerified)) + .map((entry) => entry.row); + }, [assetsReady, userJettons, rates]); + + return { tonRow, jettonRows, assetsReady }; +}; diff --git a/apps/demo-wallet/src/features/assets/index.ts b/apps/demo-wallet/src/features/assets/index.ts new file mode 100644 index 000000000..634854f63 --- /dev/null +++ b/apps/demo-wallet/src/features/assets/index.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/asset-row'; +export * from './components/assets-screen'; +export * from './hooks/use-asset-rows'; diff --git a/apps/demo-wallet/src/features/auth/components/setup-password-screen/index.ts b/apps/demo-wallet/src/features/auth/components/setup-password-screen/index.ts new file mode 100644 index 000000000..2224ea840 --- /dev/null +++ b/apps/demo-wallet/src/features/auth/components/setup-password-screen/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { SetupPasswordScreen } from './setup-password-screen'; diff --git a/apps/demo-wallet/src/features/auth/components/setup-password-screen/setup-password-screen.tsx b/apps/demo-wallet/src/features/auth/components/setup-password-screen/setup-password-screen.tsx new file mode 100644 index 000000000..830641048 --- /dev/null +++ b/apps/demo-wallet/src/features/auth/components/setup-password-screen/setup-password-screen.tsx @@ -0,0 +1,129 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '@demo/wallet-core'; + +import { CenteredScreen } from '@/core/components/shared/centered-screen'; +import { Button } from '@/core/components/ui/button'; +import { WALLET_SETUP_ROUTE } from '@/features/wallet-setup'; +import type { WalletSetupMode } from '@/features/wallet-setup'; + +const MIN_LENGTH = 4; + +const INPUT_CLASS = + 'w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-base text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500'; + +export const SetupPasswordScreen: React.FC = () => { + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const navigate = useNavigate(); + const location = useLocation(); + const { setPassword: setStorePassword } = useAuth(); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const tooShort = password.length > 0 && password.length < MIN_LENGTH; + const mismatch = confirmPassword.length > 0 && confirmPassword !== password; + const canSubmit = password.length >= MIN_LENGTH && password === confirmPassword && !isLoading; + + const handleSubmit = async () => { + if (!canSubmit) return; + setError(''); + setIsLoading(true); + try { + await setStorePassword(password); + // Route by the chosen path — each has its own dedicated screen. + const tab = (location.state as { tab?: WalletSetupMode } | null)?.tab; + navigate(WALLET_SETUP_ROUTE[tab ?? 'create']); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + const footer = ( + + ); + + return ( + navigate(-1)} footer={footer}> +
+

+ Create a password +

+

Create a password to protect your wallet.

+ +
+ { + setPassword(e.target.value); + setError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleSubmit(); + }} + placeholder="Password" + autoComplete="new-password" + aria-label="Password" + className={INPUT_CLASS} + /> + { + setConfirmPassword(e.target.value); + setError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleSubmit(); + }} + placeholder="Confirm password" + autoComplete="new-password" + aria-label="Confirm password" + className={INPUT_CLASS} + /> +
+ + {(error || tooShort || mismatch) && ( +

+ {error || + (tooShort + ? `Password must be at least ${MIN_LENGTH} characters` + : 'Passwords do not match')} +

+ )} + +

+ Make sure to remember your password — it can’t be recovered if forgotten. +

+
+
+ ); +}; diff --git a/apps/demo-wallet/src/features/auth/components/unlock-screen/index.ts b/apps/demo-wallet/src/features/auth/components/unlock-screen/index.ts new file mode 100644 index 000000000..8c1ad4dba --- /dev/null +++ b/apps/demo-wallet/src/features/auth/components/unlock-screen/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { UnlockScreen } from './unlock-screen'; diff --git a/apps/demo-wallet/src/features/auth/components/unlock-screen/unlock-screen.tsx b/apps/demo-wallet/src/features/auth/components/unlock-screen/unlock-screen.tsx new file mode 100644 index 000000000..3ad355d73 --- /dev/null +++ b/apps/demo-wallet/src/features/auth/components/unlock-screen/unlock-screen.tsx @@ -0,0 +1,119 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth, useWallet } from '@demo/wallet-core'; + +import { CenteredScreen } from '@/core/components/shared/centered-screen'; +import { ConfirmModal } from '@/core/components/shared/confirm-modal'; +import { Button } from '@/core/components/ui/button'; + +const INPUT_CLASS = + 'w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-base text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500'; + +export const UnlockScreen: React.FC = () => { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isResetOpen, setIsResetOpen] = useState(false); + + const navigate = useNavigate(); + const { unlock, reset } = useAuth(); + const { loadAllWallets } = useWallet(); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSubmit = async () => { + if (!password || isLoading) return; + setError(''); + setIsLoading(true); + try { + const success = await unlock(password); + if (!success) { + throw new Error('Incorrect password'); + } + await loadAllWallets(); + navigate('/wallet'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to unlock wallet'); + } finally { + setIsLoading(false); + } + }; + + const handleReset = () => { + setIsResetOpen(false); + reset(); + navigate('/welcome'); + }; + + const footer = ( +
+ + +
+ ); + + return ( + +
+

+ Enter your password +

+

Enter your password to unlock your wallet.

+ +
+ { + setPassword(e.target.value); + setError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleSubmit(); + }} + placeholder="Password" + autoComplete="current-password" + aria-label="Password" + className={INPUT_CLASS} + /> +
+ + {error &&

{error}

} +
+ + setIsResetOpen(false)} + /> +
+ ); +}; diff --git a/apps/demo-wallet/src/features/auth/index.ts b/apps/demo-wallet/src/features/auth/index.ts new file mode 100644 index 000000000..cae3d28b4 --- /dev/null +++ b/apps/demo-wallet/src/features/auth/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/setup-password-screen'; +export * from './components/unlock-screen'; diff --git a/apps/demo-wallet/src/components/AnimatedBalance.tsx b/apps/demo-wallet/src/features/dashboard/components/animated-balance/animated-balance.tsx similarity index 98% rename from apps/demo-wallet/src/components/AnimatedBalance.tsx rename to apps/demo-wallet/src/features/dashboard/components/animated-balance/animated-balance.tsx index a95c13843..80fd84cf4 100644 --- a/apps/demo-wallet/src/components/AnimatedBalance.tsx +++ b/apps/demo-wallet/src/features/dashboard/components/animated-balance/animated-balance.tsx @@ -28,7 +28,7 @@ interface AnimatedBalanceProps { className?: string; } -export const AnimatedBalance: React.FC = ({ balance, suffix = ' TON', className }) => { +export const AnimatedBalance: React.FC = ({ balance, suffix = ' GRAM', className }) => { const targetValue = parseFloat(formatUnits(balance || '0', 9)); const [displayValue, setDisplayValue] = useState(() => balanceFormatter.format(targetValue)); const displayRef = useRef(targetValue); diff --git a/apps/demo-wallet/src/features/dashboard/components/animated-balance/index.ts b/apps/demo-wallet/src/features/dashboard/components/animated-balance/index.ts new file mode 100644 index 000000000..1e8c92f3e --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/animated-balance/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './animated-balance'; diff --git a/apps/demo-wallet/src/features/dashboard/components/balance-total/balance-total.tsx b/apps/demo-wallet/src/features/dashboard/components/balance-total/balance-total.tsx new file mode 100644 index 000000000..8c6a147ad --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/balance-total/balance-total.tsx @@ -0,0 +1,95 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useCallback, useMemo } from 'react'; +import { Copy } from 'lucide-react'; +import { toast } from 'sonner'; +import { useWallet, useJettons, useRates } from '@demo/wallet-core'; + +import { useCountUp } from '@/core/hooks/use-count-up'; +import { findRate, shortenAddress, toDecimal } from '@/core/utils'; + +const usdFormat = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +/** USD value split into integer and fraction parts (no `$`), for the styled total. */ +const formatUsdParts = (value: number): { intPart: string; fracPart: string } => { + const [intPart, fracPart = '00'] = usdFormat.format(value).split('.'); + return { intPart, fracPart }; +}; + +const GRAM_DECIMALS = 9; + +export const BalanceTotal: React.FC = () => { + const { address, balance } = useWallet(); + const { userJettons } = useJettons(); + const { entries: rates, lastUpdated: ratesUpdated } = useRates(); + + // Wait for both balance and real rates (not the bootstrap TON rate) before showing the total. + const ready = balance !== undefined && ratesUpdated > 0; + + const totalUsd = useMemo(() => { + if (!ready) return 0; + + let total = 0; + const tonRate = rates['GRAM']?.rate; + if (tonRate) { + total += toDecimal(balance, GRAM_DECIMALS) * tonRate; + } + for (const jetton of userJettons) { + const rate = findRate(rates, jetton.address)?.rate; + if (!rate) continue; + total += toDecimal(jetton.balance, jetton.decimalsNumber ?? 9) * rate; + } + return total; + }, [ready, rates, balance, userJettons]); + + const handleCopy = useCallback(async () => { + if (!address) return; + try { + await navigator.clipboard.writeText(address); + toast.success('Address copied'); + } catch { + toast.error('Failed to copy address'); + } + }, [address]); + + const animatedTotal = useCountUp(totalUsd); + const { intPart, fracPart } = formatUsdParts(animatedTotal); + + return ( +
+ {ready ? ( +
+ $ + {intPart} + . + {fracPart} +
+ ) : ( +
+ )} + + {address ? ( + + ) : ( +
+ )} +
+ ); +}; diff --git a/apps/demo-wallet/src/features/dashboard/components/balance-total/index.ts b/apps/demo-wallet/src/features/dashboard/components/balance-total/index.ts new file mode 100644 index 000000000..f9061d039 --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/balance-total/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './balance-total'; diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-action-button/dashboard-action-button.tsx b/apps/demo-wallet/src/features/dashboard/components/dashboard-action-button/dashboard-action-button.tsx new file mode 100644 index 000000000..a38f99677 --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-action-button/dashboard-action-button.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +interface DashboardActionButtonProps { + icon: React.ReactNode; + label: string; + onClick?: () => void; + 'aria-label'?: string; + testId?: string; +} + +export const DashboardActionButton: React.FC = ({ + icon, + label, + onClick, + 'aria-label': ariaLabel, + testId, +}) => ( + +); diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-action-button/index.ts b/apps/demo-wallet/src/features/dashboard/components/dashboard-action-button/index.ts new file mode 100644 index 000000000..378befbed --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-action-button/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './dashboard-action-button'; diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-actions/dashboard-actions.tsx b/apps/demo-wallet/src/features/dashboard/components/dashboard-actions/dashboard-actions.tsx new file mode 100644 index 000000000..be697eadd --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-actions/dashboard-actions.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { DashboardActionButton } from '../dashboard-action-button'; + +import { SwapIcon, SendIcon, StakeIcon } from '@/core/components/ui/icons'; + +export const DashboardActions: React.FC = () => { + const navigate = useNavigate(); + + return ( +
+ } + label="Send" + onClick={() => navigate('/send')} + testId="send-button" + /> + } + label="Swap" + onClick={() => navigate('/swap')} + testId="swap-button" + /> + } + label="Stake" + onClick={() => navigate('/staking')} + testId="stake-button" + /> +
+ ); +}; diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-actions/index.ts b/apps/demo-wallet/src/features/dashboard/components/dashboard-actions/index.ts new file mode 100644 index 000000000..5e0fc0f31 --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-actions/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './dashboard-actions'; diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/dashboard-assets.tsx b/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/dashboard-assets.tsx new file mode 100644 index 000000000..c004f49da --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/dashboard-assets.tsx @@ -0,0 +1,119 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { useJettons, useRates, useWallet, useWalletKit } from '@demo/wallet-core'; +import type { JettonInfo } from '@ton/walletkit'; + +import { AssetRow, AssetRowSkeleton, imageSources, useAssetRows } from '@/features/assets'; +import type { AssetRowData } from '@/features/assets'; +import { getJettonsName, getJettonsSymbol } from '@/features/jettons'; +import { findRate, formatRate, toDecimal, tokenImageUrls } from '@/core/utils'; + +const JETTON_SLOTS = 2; + +// Always-shown fallback jettons (used to pad the preview to 3 assets when the user +// holds fewer). Metadata is used only when the token isn't in the wallet. +const DEFAULT_JETTONS: { address: string; symbol: string; name: string }[] = [ + { address: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs', symbol: 'USDT', name: 'Tether USD' }, + { address: 'EQA1R_LuQCLHlMgOo1S4G7Y7W1cd0FrAkbA10Zq7rddKxi9k', symbol: 'XAUT', name: 'Tether Gold' }, +]; + +export const DashboardAssets: React.FC = () => { + const navigate = useNavigate(); + const { currentWallet, getActiveWallet } = useWallet(); + const { userJettons } = useJettons(); + const { entries: rates } = useRates(); + const walletKit = useWalletKit(); + const { tonRow, jettonRows, assetsReady } = useAssetRows(); + + const isMainnet = getActiveWallet()?.network === 'mainnet'; + + // Fetch metadata (name/icon) for the default tokens from the API — mainnet only. + const [defaultInfos, setDefaultInfos] = useState>({}); + useEffect(() => { + if (!isMainnet || !walletKit || !currentWallet) return; + const network = currentWallet.getNetwork(); + let cancelled = false; + void Promise.all( + DEFAULT_JETTONS.map((def) => walletKit.jettons.getJettonInfo(def.address, network).catch(() => null)), + ).then((infos) => { + if (cancelled) return; + const next: Record = {}; + infos.forEach((info, i) => { + if (info) next[DEFAULT_JETTONS[i].address] = info; + }); + setDefaultInfos(next); + }); + return () => { + cancelled = true; + }; + }, [isMainnet, walletKit, currentWallet]); + + // Preview: top JETTON_SLOTS held jettons, padded with default tokens (USDT/XAUT) on mainnet. + const selected = useMemo(() => { + const base = jettonRows.slice(0, JETTON_SLOTS); + if (!isMainnet || base.length >= JETTON_SLOTS) return base; + + const heldByAddress = new Map(userJettons.map((jetton) => [jetton.address, jetton])); + const present = new Set(base.map((row) => row.id)); + const padded = [...base]; + + for (const def of DEFAULT_JETTONS) { + if (padded.length >= JETTON_SLOTS) break; + if (present.has(def.address)) continue; + + const held = heldByAddress.get(def.address); + const info = defaultInfos[def.address]; + const decimals = held?.decimalsNumber ?? info?.decimals ?? 9; + const amount = held ? toDecimal(held.balance, decimals) : 0; + const rateEntry = findRate(rates, def.address); + padded.push({ + id: def.address, + icon: held + ? imageSources(tokenImageUrls(held.info?.image), held.info?.image?.data) + : imageSources(info?.image ? [info.image] : undefined, info?.image_data), + fallbackText: def.symbol.slice(0, 2).toUpperCase(), + name: (held && getJettonsName(held)) || info?.name || def.name, + symbol: (held && getJettonsSymbol(held)) || info?.symbol || def.symbol, + amount, + rateLabel: rateEntry ? formatRate(rateEntry.rate) : undefined, + fiat: rateEntry ? amount * rateEntry.rate : undefined, + }); + } + return padded; + }, [jettonRows, isMainnet, userJettons, rates, defaultInfos]); + + return ( +
+ + +
+ {tonRow ? : } + {assetsReady ? ( + selected.map((row) => ) + ) : ( + <> + + + + )} +
+
+ ); +}; diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/index.ts b/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/index.ts new file mode 100644 index 000000000..bc39b3435 --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-assets/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './dashboard-assets'; diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-header/dashboard-header.tsx b/apps/demo-wallet/src/features/dashboard/components/dashboard-header/dashboard-header.tsx new file mode 100644 index 000000000..3293f346c --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-header/dashboard-header.tsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { useTonConnect, useWallet } from '@demo/wallet-core'; + +import { WalletSelectorModal } from '@/features/wallets'; +import { SettingsDropdown } from '@/features/settings'; +import { ConnectDappModal } from '@/features/ton-connect'; +import { ScanIcon } from '@/core/components/ui/icons'; +import { usePasteHandler } from '@/core/hooks'; + +export const DashboardHeader: React.FC = () => { + const [isWalletSelectorOpen, setIsWalletSelectorOpen] = useState(false); + const [isConnectOpen, setIsConnectOpen] = useState(false); + + const { handleTonConnectUrl } = useTonConnect(); + const { savedWallets, activeWalletId } = useWallet(); + const activeWallet = savedWallets.find((w) => w.id === activeWalletId); + + usePasteHandler(handleTonConnectUrl, isConnectOpen); + + return ( +
+ + + + + + + setIsWalletSelectorOpen(false)} /> + setIsConnectOpen(false)} /> +
+ ); +}; diff --git a/apps/demo-wallet/src/features/dashboard/components/dashboard-header/index.ts b/apps/demo-wallet/src/features/dashboard/components/dashboard-header/index.ts new file mode 100644 index 000000000..93603facf --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/dashboard-header/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './dashboard-header'; diff --git a/apps/demo-wallet/src/features/dashboard/components/wallet-dashboard/index.ts b/apps/demo-wallet/src/features/dashboard/components/wallet-dashboard/index.ts new file mode 100644 index 000000000..c7a0e5696 --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/wallet-dashboard/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './wallet-dashboard'; diff --git a/apps/demo-wallet/src/features/dashboard/components/wallet-dashboard/wallet-dashboard.tsx b/apps/demo-wallet/src/features/dashboard/components/wallet-dashboard/wallet-dashboard.tsx new file mode 100644 index 000000000..0afbda0eb --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/components/wallet-dashboard/wallet-dashboard.tsx @@ -0,0 +1,98 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { + useWallet, + useTonConnect, + useTransactionRequests, + useSignDataRequests, + useSignMessageRequests, +} from '@demo/wallet-core'; + +import { DashboardHeader } from '../dashboard-header'; +import { BalanceTotal } from '../balance-total'; +import { DashboardActions } from '../dashboard-actions'; +import { DashboardAssets } from '../dashboard-assets'; + +import { + ConnectRequestModal, + TransactionRequestModal, + SignDataRequestModal, + SignMessageRequestModal, +} from '@/features/ton-connect'; +import { NewLayout } from '@/core/components/shared/new-layout'; +import { NftsCard } from '@/features/nft'; +import { TransactionHistory } from '@/features/transactions'; +import { useTonWallet } from '@/core/hooks'; + +export const WalletDashboard: React.FC = () => { + // Re-initialize the wallet when the dashboard mounts (gated behind the unlocked route), so + // WalletKit + currentWallet are restored when booting straight onto it — e.g. an extension + // popup reopen. Must NOT move to AppRouter: useTonWallet inits once and at the root it fires + // before the store rehydrates (isUnlocked=false), skipping loadAllWallets with no retry. + useTonWallet(); + + const { getAvailableWallets, savedWallets, getActiveWallet } = useWallet(); + const activeWallet = getActiveWallet(); + const { pendingConnectRequest, isConnectModalOpen, approveConnectRequest, rejectConnectRequest } = useTonConnect(); + const { pendingTransactionRequest, isTransactionModalOpen } = useTransactionRequests(); + const { pendingSignDataRequest, isSignDataModalOpen, approveSignDataRequest, rejectSignDataRequest } = + useSignDataRequests(); + const { pendingSignMessageRequest, isSignMessageModalOpen } = useSignMessageRequests(); + + return ( + }> +
+ + + + + +
+ + {pendingConnectRequest && ( + w.getWalletId() === activeWallet?.kitWalletId)} + isOpen={isConnectModalOpen} + onApprove={approveConnectRequest} + onReject={rejectConnectRequest} + /> + )} + + {pendingTransactionRequest && ( + + )} + + {pendingSignDataRequest && ( + + )} + + {pendingSignMessageRequest && ( + + )} +
+ ); +}; diff --git a/apps/demo-wallet/src/features/dashboard/index.ts b/apps/demo-wallet/src/features/dashboard/index.ts new file mode 100644 index 000000000..e3c0b0de1 --- /dev/null +++ b/apps/demo-wallet/src/features/dashboard/index.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/wallet-dashboard'; +export * from './components/animated-balance'; +export * from './components/balance-total'; +export * from './components/dashboard-action-button'; +export * from './components/dashboard-actions'; +export * from './components/dashboard-assets'; +export * from './components/dashboard-header'; diff --git a/apps/demo-wallet/src/features/jettons/components/jetton-flow/index.ts b/apps/demo-wallet/src/features/jettons/components/jetton-flow/index.ts new file mode 100644 index 000000000..4c6b99fa0 --- /dev/null +++ b/apps/demo-wallet/src/features/jettons/components/jetton-flow/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './jetton-flow'; diff --git a/apps/demo-wallet/src/components/JettonFlow.tsx b/apps/demo-wallet/src/features/jettons/components/jetton-flow/jetton-flow.tsx similarity index 83% rename from apps/demo-wallet/src/components/JettonFlow.tsx rename to apps/demo-wallet/src/features/jettons/components/jetton-flow/jetton-flow.tsx index b16a7785d..8675e3e69 100644 --- a/apps/demo-wallet/src/components/JettonFlow.tsx +++ b/apps/demo-wallet/src/features/jettons/components/jetton-flow/jetton-flow.tsx @@ -10,8 +10,9 @@ import { memo } from 'react'; import type { Address } from '@ton/core'; import type { TransactionTraceMoneyFlowItem } from '@ton/walletkit'; -import { resolveTokenAddress, TON_INFO, useJettonInfo } from '../hooks/useJettonInfo'; -import { formatUnits } from '../utils/units'; +import { resolveTokenAddress, GRAM_INFO, useJettonInfo } from '../../hooks/use-jetton-info'; + +import { formatUnits } from '@/core/utils/units'; export const JettonNameDisplay = memo(function JettonNameDisplay({ jettonAddress, @@ -46,10 +47,10 @@ export const JettonImage = memo(function JettonImage({ jettonAddress: Address | string | undefined; }) { const jettonInfo = useJettonInfo(resolveTokenAddress(jettonAddress)); - if (!jettonInfo?.image) { - return {TON_INFO.name}; + if (!jettonInfo?.images?.[0]) { + return {GRAM_INFO.name}; } - return {jettonInfo.name}; + return {jettonInfo.name}; }); const JettonFlowItem = memo(function JettonFlowItem({ @@ -75,9 +76,9 @@ const JettonFlowItem = memo(function JettonFlowItem({ export const JettonFlow = memo(function JettonFlow({ transfers }: { transfers: TransactionTraceMoneyFlowItem[] }) { return ( -
-
Money Flow:
-
+
+

Money flow

+
{transfers?.length > 0 ? transfers.map((transfer) => transfer.assetType === 'jetton' ? ( diff --git a/apps/demo-wallet/src/features/jettons/components/jetton-row/index.ts b/apps/demo-wallet/src/features/jettons/components/jetton-row/index.ts new file mode 100644 index 000000000..bf18bdeb8 --- /dev/null +++ b/apps/demo-wallet/src/features/jettons/components/jetton-row/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './jetton-row'; diff --git a/apps/demo-wallet/src/components/JettonRow.tsx b/apps/demo-wallet/src/features/jettons/components/jetton-row/jetton-row.tsx similarity index 98% rename from apps/demo-wallet/src/components/JettonRow.tsx rename to apps/demo-wallet/src/features/jettons/components/jetton-row/jetton-row.tsx index 1512aac33..344b1e10c 100644 --- a/apps/demo-wallet/src/components/JettonRow.tsx +++ b/apps/demo-wallet/src/features/jettons/components/jetton-row/jetton-row.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type { Jetton } from '@ton/walletkit'; -import { useFormattedJetton } from '@/hooks/useFormattedJetton'; +import { useFormattedJetton } from '@/features/jettons'; interface JettonRowProps { jetton: Jetton; diff --git a/apps/demo-wallet/src/hooks/useFormattedJetton.ts b/apps/demo-wallet/src/features/jettons/hooks/use-formatted-jetton.ts similarity index 93% rename from apps/demo-wallet/src/hooks/useFormattedJetton.ts rename to apps/demo-wallet/src/features/jettons/hooks/use-formatted-jetton.ts index 42c3f01d7..b485a8628 100644 --- a/apps/demo-wallet/src/hooks/useFormattedJetton.ts +++ b/apps/demo-wallet/src/features/jettons/hooks/use-formatted-jetton.ts @@ -10,7 +10,7 @@ import { useCallback, useMemo } from 'react'; import type { Jetton } from '@ton/walletkit'; import { useJettons } from '@demo/wallet-core'; -import { getFormattedJettonInfo } from '@/utils/jetton'; +import { getFormattedJettonInfo } from '../utils/jetton'; export const useFormatJetton = () => { const { formatJettonAmount } = useJettons(); diff --git a/apps/demo-wallet/src/hooks/useJettonInfo.ts b/apps/demo-wallet/src/features/jettons/hooks/use-jetton-info.ts similarity index 75% rename from apps/demo-wallet/src/hooks/useJettonInfo.ts rename to apps/demo-wallet/src/features/jettons/hooks/use-jetton-info.ts index e2e8c284d..c6a8777ed 100644 --- a/apps/demo-wallet/src/hooks/useJettonInfo.ts +++ b/apps/demo-wallet/src/features/jettons/hooks/use-jetton-info.ts @@ -12,17 +12,19 @@ import type { JettonInfo } from '@ton/walletkit'; import { getChainNetwork, useWalletKit, useWalletStore } from '@demo/wallet-core'; import type { NetworkType } from '@demo/wallet-core'; -import { normalizeAddress } from '../utils/formatters'; +import { normalizeAddress } from '@/core/utils/formatters'; -export type TokenInfo = Partial> & { +export type TokenInfo = Partial> & { decimals?: number; + /** Candidate icon URLs, best-first. Derived from the kit's single `image`. */ + images?: string[]; }; -export const TON_INFO: TokenInfo = { - name: 'TON', - symbol: 'TON', +export const GRAM_INFO: TokenInfo = { + name: 'Gram', + symbol: 'GRAM', decimals: 9, - image: '/ton.png', + images: ['/gram.svg'], }; export function useActiveWalletNetwork(): NetworkType { @@ -52,10 +54,15 @@ export function useJettonInfo(tokenAddress: Address | string | null | undefined) return; } + if (typeof tokenAddress === 'string' && tokenAddress.toUpperCase() === 'TON') { + setTokenInfo(GRAM_INFO); + return; + } + async function updateTokenInfo() { if (!tokenAddress) return; const info = await walletKit?.jettons?.getJettonInfo(tokenAddress.toString(), chainNetwork); - setTokenInfo(info ?? null); + setTokenInfo(info ? { ...info, images: info.image ? [info.image] : undefined } : null); } updateTokenInfo(); diff --git a/apps/demo-wallet/src/features/jettons/index.ts b/apps/demo-wallet/src/features/jettons/index.ts new file mode 100644 index 000000000..407b6aae6 --- /dev/null +++ b/apps/demo-wallet/src/features/jettons/index.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/jetton-row'; +export * from './components/jetton-flow'; +export * from './hooks/use-jetton-info'; +export * from './hooks/use-formatted-jetton'; +export * from './utils/jetton'; diff --git a/apps/demo-wallet/src/utils/jetton.ts b/apps/demo-wallet/src/features/jettons/utils/jetton.ts similarity index 89% rename from apps/demo-wallet/src/utils/jetton.ts rename to apps/demo-wallet/src/features/jettons/utils/jetton.ts index 6f4f89a41..4e63287be 100644 --- a/apps/demo-wallet/src/utils/jetton.ts +++ b/apps/demo-wallet/src/features/jettons/utils/jetton.ts @@ -8,6 +8,8 @@ import type { Jetton } from '@ton/walletkit'; +import { tokenImageUrls } from '@/core/utils'; + export const getJettonsSymbol = (jetton: Jetton): string | undefined => { if (!jetton?.info?.symbol) { return; @@ -30,14 +32,7 @@ export const getJettonsImage = (jetton: Jetton): string | undefined => { } const img = jetton.info.image; - return ( - img.url || - (img.data ? `data:image/png;base64,${img.data}` : undefined) || - img.mediumUrl || - img.largeUrl || - img.smallUrl || - '' - ); + return tokenImageUrls(img)[0] || (img.data ? `data:image/png;base64,${img.data}` : undefined) || ''; }; export const getFormattedJettonInfo = diff --git a/apps/demo-wallet/src/features/ledger/components/ledger-screen/index.ts b/apps/demo-wallet/src/features/ledger/components/ledger-screen/index.ts new file mode 100644 index 000000000..73092c9c7 --- /dev/null +++ b/apps/demo-wallet/src/features/ledger/components/ledger-screen/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export { LedgerScreen } from './ledger-screen'; diff --git a/apps/demo-wallet/src/features/ledger/components/ledger-screen/ledger-screen.tsx b/apps/demo-wallet/src/features/ledger/components/ledger-screen/ledger-screen.tsx new file mode 100644 index 000000000..09456eb98 --- /dev/null +++ b/apps/demo-wallet/src/features/ledger/components/ledger-screen/ledger-screen.tsx @@ -0,0 +1,100 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Minus, Plus } from 'lucide-react'; +import { useAuth } from '@demo/wallet-core'; +import type { NetworkType } from '@demo/wallet-core'; + +import { CenteredScreen } from '@/core/components/shared/centered-screen'; +import { Button } from '@/core/components/ui/button'; +import { NetworkSelector } from '@/features/wallets'; +import { useTonWallet } from '@/core/hooks'; + +/** Dedicated screen for connecting a Ledger hardware wallet. */ +export const LedgerScreen: React.FC = () => { + const navigate = useNavigate(); + const { createLedgerWallet } = useTonWallet(); + const { ledgerAccountNumber, setLedgerAccountNumber, setUseWalletInterfaceType } = useAuth(); + + const [network, setNetwork] = useState('mainnet'); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const handleConnect = async () => { + setError(''); + setIsLoading(true); + try { + setUseWalletInterfaceType('ledger'); + await createLedgerWallet(network); + navigate('/wallet'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to connect Ledger'); + } finally { + setIsLoading(false); + } + }; + + const footer = ( + + ); + + return ( + navigate(-1)} footer={footer}> +
+
+

Connect Ledger

+

Connect your Ledger hardware wallet to continue.

+
+ +
+ +
+ Account +
+ + + {ledgerAccountNumber || 0} + + +
+
+
+ +
+

Before you continue:

+
    +
  • Connect Ledger via USB
  • +
  • Unlock it with your PIN
  • +
  • Open the TON app
  • +
+
+ + {error &&

{error}

} +
+
+ ); +}; diff --git a/apps/demo-wallet/src/features/ledger/index.ts b/apps/demo-wallet/src/features/ledger/index.ts new file mode 100644 index 000000000..1b1afec1b --- /dev/null +++ b/apps/demo-wallet/src/features/ledger/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/ledger-screen'; diff --git a/apps/demo-wallet/src/features/nft/components/nft-screen/index.ts b/apps/demo-wallet/src/features/nft/components/nft-screen/index.ts new file mode 100644 index 000000000..f5ed64dc3 --- /dev/null +++ b/apps/demo-wallet/src/features/nft/components/nft-screen/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './nft-screen'; diff --git a/apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx b/apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx new file mode 100644 index 000000000..eb18bed9c --- /dev/null +++ b/apps/demo-wallet/src/features/nft/components/nft-screen/nft-screen.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useNfts } from '@demo/wallet-core'; + +import { NftTile } from '../nft-tile'; + +import { NewLayout } from '@/core/components/shared/new-layout'; +import { ScreenHeader } from '@/core/components/shared/screen-header'; + +/** Full NFTs page: every NFT held by the active wallet, as a grid. */ +export const NftsScreen: FC = () => { + const navigate = useNavigate(); + const { userNfts, formatNftIndex } = useNfts(); + + return ( + navigate('/wallet')} />}> + {userNfts.length === 0 ? ( +

No NFTs yet

+ ) : ( +
+ {userNfts.map((nft) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/apps/demo-wallet/src/features/nft/components/nft-tile/index.ts b/apps/demo-wallet/src/features/nft/components/nft-tile/index.ts new file mode 100644 index 000000000..cdbc48053 --- /dev/null +++ b/apps/demo-wallet/src/features/nft/components/nft-tile/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './nft-tile'; diff --git a/apps/demo-wallet/src/features/nft/components/nft-tile/nft-tile.tsx b/apps/demo-wallet/src/features/nft/components/nft-tile/nft-tile.tsx new file mode 100644 index 000000000..fc16749e6 --- /dev/null +++ b/apps/demo-wallet/src/features/nft/components/nft-tile/nft-tile.tsx @@ -0,0 +1,55 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import type { NFT } from '@ton/walletkit'; + +import { FallbackImage } from '@/core/components/ui/fallback-image'; +import { tokenImageUrls } from '@/core/utils'; + +const getNftImageSources = (nft: NFT): string[] => { + const img = nft.info?.image; + if (!img) return []; + return [...tokenImageUrls(img), ...(img.data ? [`data:image/png;base64,${img.data}`] : [])]; +}; + +const getNftName = (nft: NFT, formatNftIndex: (index: string) => string): string => { + if (nft.info?.name) return nft.info.name; + if (nft.index) return `NFT ${formatNftIndex(nft.index)}`; + return 'NFT'; +}; + +interface NftTileProps { + nft: NFT; + formatNftIndex: (index: string) => string; +} + +/** + * NFT card (image + name + index). Width follows the container — wrapped for the + * horizontal-scroll preview on the dashboard, gridded on the full NFTs page. + */ +export const NftTile: React.FC = ({ nft, formatNftIndex }) => { + const name = getNftName(nft, formatNftIndex); + const indexLabel = nft.index ? formatNftIndex(nft.index) : null; + + return ( +
+
+ +
+
+
{name}
+ {indexLabel && ( +
+ {indexLabel.length > 10 ? `${indexLabel.slice(0, 6)}…` : indexLabel} +
+ )} +
+
+ ); +}; diff --git a/apps/demo-wallet/src/features/nft/components/nfts-card/index.ts b/apps/demo-wallet/src/features/nft/components/nfts-card/index.ts new file mode 100644 index 000000000..1708339a7 --- /dev/null +++ b/apps/demo-wallet/src/features/nft/components/nfts-card/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './nfts-card'; diff --git a/apps/demo-wallet/src/features/nft/components/nfts-card/nfts-card.tsx b/apps/demo-wallet/src/features/nft/components/nfts-card/nfts-card.tsx new file mode 100644 index 000000000..18fb21fef --- /dev/null +++ b/apps/demo-wallet/src/features/nft/components/nfts-card/nfts-card.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { ChevronRight } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { useNfts } from '@demo/wallet-core'; + +import { NftTile } from '../nft-tile'; + +/** Dashboard NFTs preview: a horizontal-scroll strip; renders nothing when the wallet has no NFTs. */ +export const NftsCard: React.FC = () => { + const navigate = useNavigate(); + const { userNfts, formatNftIndex } = useNfts(); + + if (userNfts.length === 0) { + return null; + } + + return ( +
+ + +
+ {userNfts.map((nft) => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/apps/demo-wallet/src/features/nft/index.ts b/apps/demo-wallet/src/features/nft/index.ts new file mode 100644 index 000000000..bb51a9e3b --- /dev/null +++ b/apps/demo-wallet/src/features/nft/index.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/nfts-card'; +export * from './components/nft-tile'; +export * from './components/nft-screen'; diff --git a/apps/demo-wallet/src/components/DisconnectNotifications.tsx b/apps/demo-wallet/src/features/notifications/components/disconnect-notifications/disconnect-notifications.tsx similarity index 97% rename from apps/demo-wallet/src/components/DisconnectNotifications.tsx rename to apps/demo-wallet/src/features/notifications/components/disconnect-notifications/disconnect-notifications.tsx index 6caa17241..5ed4d0c9d 100644 --- a/apps/demo-wallet/src/components/DisconnectNotifications.tsx +++ b/apps/demo-wallet/src/features/notifications/components/disconnect-notifications/disconnect-notifications.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useDisconnectEvents } from '@demo/wallet-core'; -import { Button } from './Button'; +import { Button } from '@/core/components/ui/button'; interface DisconnectNotificationsProps { className?: string; diff --git a/apps/demo-wallet/src/features/notifications/components/disconnect-notifications/index.ts b/apps/demo-wallet/src/features/notifications/components/disconnect-notifications/index.ts new file mode 100644 index 000000000..6ad51bd0f --- /dev/null +++ b/apps/demo-wallet/src/features/notifications/components/disconnect-notifications/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './disconnect-notifications'; diff --git a/apps/demo-wallet/src/features/notifications/components/streaming-status/index.ts b/apps/demo-wallet/src/features/notifications/components/streaming-status/index.ts new file mode 100644 index 000000000..6e6ceab14 --- /dev/null +++ b/apps/demo-wallet/src/features/notifications/components/streaming-status/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './streaming-status'; diff --git a/apps/demo-wallet/src/components/StreamingStatus.tsx b/apps/demo-wallet/src/features/notifications/components/streaming-status/streaming-status.tsx similarity index 100% rename from apps/demo-wallet/src/components/StreamingStatus.tsx rename to apps/demo-wallet/src/features/notifications/components/streaming-status/streaming-status.tsx diff --git a/apps/demo-wallet/src/features/notifications/hooks/use-received-toasts.tsx b/apps/demo-wallet/src/features/notifications/hooks/use-received-toasts.tsx new file mode 100644 index 000000000..22ffb7ce8 --- /dev/null +++ b/apps/demo-wallet/src/features/notifications/hooks/use-received-toasts.tsx @@ -0,0 +1,110 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useEffect, useRef } from 'react'; +import { ArrowDownLeft } from 'lucide-react'; +import { toast } from 'sonner'; +import { useJettons, useRates, useWallet } from '@demo/wallet-core'; +import type { RateEntry } from '@demo/wallet-core'; + +import { getJettonsSymbol } from '@/features/jettons'; +import { formatLargeValue, toDecimal } from '@/core/utils'; + +const GRAM_KEY = 'GRAM'; +const GRAM_DECIMALS = 9; + +const safeBigInt = (value: string | undefined): bigint | null => { + if (!value) return null; + try { + return BigInt(value); + } catch { + return null; + } +}; + +const ReceivedIcon = ( + + + +); + +const toastReceived = (amount: number, symbol: string, rate?: number): void => { + const fiat = rate ? ` ($${formatLargeValue(String(amount * rate), 2, 2)})` : ''; + toast(`You received ${formatLargeValue(String(amount), 4)} ${symbol}${fiat}`.trim(), { icon: ReceivedIcon }); +}; + +/** + * Shows a "You received …" toast whenever an asset balance grows. Tracks the + * previous raw balance per asset; the first observation after a load (or wallet + * switch) just seeds the baseline without toasting. Sends/decreases are ignored. + */ +export const useReceivedToasts = (): void => { + const { address, balance } = useWallet(); + const { userJettons, lastJettonsUpdate } = useJettons(); + const { entries: rates } = useRates(); + + const balancesRef = useRef>(new Map()); + const tonSeededRef = useRef(false); + const jettonsSeededRef = useRef(false); + + // Always read the latest rates without making them an effect dependency. + const ratesRef = useRef>(rates); + ratesRef.current = rates; + + // New wallet → reset the baseline so cross-wallet diffs never toast. + useEffect(() => { + balancesRef.current.clear(); + tonSeededRef.current = false; + jettonsSeededRef.current = false; + }, [address]); + + // TON balance. + useEffect(() => { + const next = safeBigInt(balance); + if (next === null) return; + + const prev = balancesRef.current.get(GRAM_KEY); + balancesRef.current.set(GRAM_KEY, next); + + if (!tonSeededRef.current) { + tonSeededRef.current = true; + return; + } + if (prev !== undefined && next > prev) { + toastReceived(toDecimal(next - prev, GRAM_DECIMALS), 'GRAM', ratesRef.current[GRAM_KEY]?.rate); + } + }, [balance]); + + // Jettons. + useEffect(() => { + if (lastJettonsUpdate === 0) return; // not loaded yet — avoid false baseline + + if (!jettonsSeededRef.current) { + for (const jetton of userJettons) { + const value = safeBigInt(jetton.balance); + if (value !== null) balancesRef.current.set(jetton.address, value); + } + jettonsSeededRef.current = true; + return; + } + + for (const jetton of userJettons) { + const next = safeBigInt(jetton.balance); + if (next === null) continue; + + const prev = balancesRef.current.get(jetton.address) ?? 0n; + balancesRef.current.set(jetton.address, next); + + if (next > prev) { + const decimals = jetton.decimalsNumber ?? 9; + const symbol = getJettonsSymbol(jetton) ?? ''; + toastReceived(toDecimal(next - prev, decimals), symbol, ratesRef.current[jetton.address]?.rate); + } + } + }, [userJettons, lastJettonsUpdate]); +}; diff --git a/apps/demo-wallet/src/features/notifications/index.ts b/apps/demo-wallet/src/features/notifications/index.ts new file mode 100644 index 000000000..735d6221c --- /dev/null +++ b/apps/demo-wallet/src/features/notifications/index.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/streaming-status'; +export * from './components/disconnect-notifications'; +export * from './hooks/use-received-toasts'; diff --git a/apps/demo-wallet/src/features/send/components/amount-field/amount-field.tsx b/apps/demo-wallet/src/features/send/components/amount-field/amount-field.tsx new file mode 100644 index 000000000..f36cafd4c --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/amount-field/amount-field.tsx @@ -0,0 +1,60 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +import type { TokenOption } from '../../types'; + +import { CenteredAmountInput } from '@/core/components/ui/centered-amount-input'; +import { AmountReversed } from '@/core/components/ui/amount-reversed'; +import { AmountPresets } from '@/core/components/shared/amount-presets'; + +/** Clamp a numeric amount to a clean decimal string (no trailing zeros, no exponent). */ +const toAmountString = (value: number, decimals: number): string => { + if (!Number.isFinite(value) || value <= 0) return '0'; + const fixed = value.toFixed(Math.min(decimals, 9)); + return fixed.includes('.') ? fixed.replace(/\.?0+$/, '') : fixed; +}; + +interface AmountFieldProps { + value: string; + onChange: (value: string) => void; + token: TokenOption; +} + +/** Centered amount input with a fiat sub-line and percentage presets. */ +export const AmountField: React.FC = ({ value, onChange, token }) => { + const amountNumber = parseFloat(value) || 0; + const fiatValue = token.rate !== undefined ? String(amountNumber * token.rate) : undefined; + const presets = [ + { label: '10%', amount: toAmountString(token.balance * 0.1, token.decimals) }, + { label: '25%', amount: toAmountString(token.balance * 0.25, token.decimals) }, + { label: '50%', amount: toAmountString(token.balance * 0.5, token.decimals) }, + { label: 'MAX', amount: toAmountString(token.maxSendable, token.decimals) }, + ]; + + const handleAmountChange = (raw: string) => { + // Keep digits and a single decimal separator (the input is free-form text). + onChange(raw.replace(/[^0-9.]/g, '').replace(/(\..*)\./g, '$1')); + }; + + return ( +
+
+ + {fiatValue !== undefined && } +
+ +
+ ); +}; diff --git a/apps/demo-wallet/src/features/send/components/amount-field/index.ts b/apps/demo-wallet/src/features/send/components/amount-field/index.ts new file mode 100644 index 000000000..1dca13fbd --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/amount-field/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './amount-field'; diff --git a/apps/demo-wallet/src/features/send/components/gasless-options/gasless-options.tsx b/apps/demo-wallet/src/features/send/components/gasless-options/gasless-options.tsx new file mode 100644 index 000000000..79854be0e --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/gasless-options/gasless-options.tsx @@ -0,0 +1,91 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +import type { UseGaslessJettonSendResult } from '../../hooks/use-gasless-jetton-send'; + +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/core/components/ui/select'; +import { useJettonInfo } from '@/features/jettons'; + +/** Resolves a fee-asset's ticker, falling back to a short address. */ +const useFeeAssetLabel = (address: string): string => { + const info = useJettonInfo(address); + return info?.symbol || `${address.slice(0, 4)}…${address.slice(-4)}`; +}; + +const FeeAssetLabel: React.FC<{ address: string }> = ({ address }) => <>{useFeeAssetLabel(address)}; + +const FeeAssetOption: React.FC<{ address: string }> = ({ address }) => ( + + {useFeeAssetLabel(address)} + +); + +interface GaslessOptionsProps { + gasless: UseGaslessJettonSendResult; +} + +/** Optional gasless block: the toggle, a fee-asset picker, and the resolved gas fee. */ +export const GaslessOptions: React.FC = ({ gasless }) => { + if (!gasless.canUse) return null; + + return ( +
+ + + {gasless.effective && ( + <> +
+ Fee asset + +
+
+ Gas fee + + {gasless.error ? '—' : gasless.isQuoting ? 'Calculating…' : (gasless.feeFormatted ?? '—')} + +
+ {gasless.error && ( +

+ {gasless.error} +

+ )} + + )} +
+ ); +}; diff --git a/apps/demo-wallet/src/features/send/components/gasless-options/index.ts b/apps/demo-wallet/src/features/send/components/gasless-options/index.ts new file mode 100644 index 000000000..b9c408bf2 --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/gasless-options/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './gasless-options'; diff --git a/apps/demo-wallet/src/features/send/components/recipient-field/index.ts b/apps/demo-wallet/src/features/send/components/recipient-field/index.ts new file mode 100644 index 000000000..de9b27f8b --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/recipient-field/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './recipient-field'; diff --git a/apps/demo-wallet/src/features/send/components/recipient-field/recipient-field.tsx b/apps/demo-wallet/src/features/send/components/recipient-field/recipient-field.tsx new file mode 100644 index 000000000..5f2e32675 --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/recipient-field/recipient-field.tsx @@ -0,0 +1,47 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +import { Input } from '@/core/components/ui/input'; + +interface RecipientFieldProps { + value: string; + onChange: (value: string) => void; + error?: string; + /** When provided, renders a "Use my address" shortcut in the header. */ + onUseMyAddress?: () => void; +} + +/** Recipient address field with an optional "Use my address" shortcut and inline validation. */ +export const RecipientField: React.FC = ({ value, onChange, error, onUseMyAddress }) => ( + + + Recipient + {onUseMyAddress && ( + + )} + + + onChange(e.target.value)} + placeholder="EQ…" + data-testid="recipient-input" + /> + + {error && {error}} + +); diff --git a/apps/demo-wallet/src/features/send/components/send-transaction/index.ts b/apps/demo-wallet/src/features/send/components/send-transaction/index.ts new file mode 100644 index 000000000..bc4df5554 --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/send-transaction/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './send-transaction'; diff --git a/apps/demo-wallet/src/features/send/components/send-transaction/send-transaction.tsx b/apps/demo-wallet/src/features/send/components/send-transaction/send-transaction.tsx new file mode 100644 index 000000000..122141ad3 --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/send-transaction/send-transaction.tsx @@ -0,0 +1,239 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { isValidAddress } from '@ton/walletkit'; +import type { TONTransferRequest } from '@ton/walletkit'; +import { useAuth, useJettons, useWallet, useWalletKit, getTransactionExplorerUrls } from '@demo/wallet-core'; +import { toast } from 'sonner'; + +import { useSendToken } from '../../hooks/use-send-token'; +import { useSendTokens } from '../../hooks/use-send-tokens'; +import { TokenSelectButton } from '../token-select-button'; +import { TokenSelectModal } from '../token-select-modal'; +import { AmountField } from '../amount-field'; +import { RecipientField } from '../recipient-field'; +import { GaslessOptions } from '../gasless-options'; +import type { TokenOption } from '../../types'; + +import { Button } from '@/core/components/ui/button'; +import { NewLayout } from '@/core/components/shared/new-layout'; +import { ScreenHeader } from '@/core/components/shared/screen-header'; +import { createComponentLogger } from '@/core/lib/logger'; + +const log = createComponentLogger('SendTransaction'); + +export const SendTransaction: React.FC = () => { + const navigate = useNavigate(); + const walletKit = useWalletKit(); + const { currentWallet, address, savedWallets, activeWalletId } = useWallet(); + const { showFastSend } = useAuth(); + const { loadUserJettons } = useJettons(); + const network = savedWallets.find((w) => w.id === activeWalletId)?.network ?? 'testnet'; + + const [selectedId, setSelectedId] = useState('TON'); + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showTokenModal, setShowTokenModal] = useState(false); + + const options = useSendTokens(); + const selected = options.find((option) => option.id === selectedId) ?? options[0]; + + const sender = useSendToken({ + wallet: currentWallet, + walletKit, + tokenType: selected.token.type, + jetton: selected.token.data, + recipient, + amount, + }); + const gasless = sender.gasless; + const effectiveGasless = gasless.effective; + + useEffect(() => { + loadUserJettons(); + }, [loadUserJettons]); + + // Success toast with explorer links — for flows that return a broadcast hash + // immediately (gasless send, fast send). + const notifySent = (normalizedHash: string) => { + const { tonScan, tonViewer } = getTransactionExplorerUrls(normalizedHash, network); + toast.success('Transaction is sent to the network', { + description: ( + + + TonScan + + + TonViewer + + + ), + }); + }; + + const handleSelectToken = (option: TokenOption) => { + setSelectedId(option.id); + setAmount(''); + setError(''); + setShowTokenModal(false); + }; + + const handleSendToSelf = () => { + if (address) setRecipient(address); + }; + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + if (!isValidAddress(recipient)) { + throw new Error('Invalid recipient address'); + } + + const inputAmount = parseFloat(amount); + if (!(inputAmount > 0)) { + throw new Error('Amount must be greater than 0'); + } + if (inputAmount > selected.balance) { + throw new Error('Insufficient balance'); + } + + // Build + submit, dispatching gasless vs regular inside the hook. Gasless + // relays immediately and returns a hash → toast; the regular flow goes + // through the preview queue. + const result = await sender.send(); + if (result?.normalizedHash) { + notifySent(result.normalizedHash); + navigate('/wallet'); + } else { + navigate('/wallet', { state: { message: `${selected.symbol} sent successfully!` } }); + } + } catch (err) { + log.error('Send transaction error:', err); + setError(err instanceof Error ? err.message : 'Failed to send transaction'); + } finally { + setIsLoading(false); + } + }; + + const handleFastSend = async () => { + if (!currentWallet) return; + const recipientAddress = recipient.trim() || address; + if (!recipientAddress) return; + if (!isValidAddress(recipientAddress)) { + setError('Invalid recipient address'); + return; + } + setError(''); + setIsLoading(true); + try { + let result; + if (selected.token.type === 'TON') { + const params: TONTransferRequest = { recipientAddress, transferAmount: '1000000' }; + const tx = await currentWallet.createTransferTonTransaction(params); + result = await currentWallet.sendTransaction(tx); + } else if (selected.token.data) { + const tx = await currentWallet.createTransferJettonTransaction({ + recipientAddress, + jettonAddress: selected.token.data.address, + transferAmount: '1', + }); + result = await currentWallet.sendTransaction(tx); + } + if (result?.normalizedHash) { + notifySent(result.normalizedHash); + } + } catch (err) { + log.error('Fast send error:', err); + setError(err instanceof Error ? err.message : 'Failed to send'); + } finally { + setIsLoading(false); + } + }; + + const recipientError = recipient.length > 0 && !isValidAddress(recipient) ? 'Invalid address' : ''; + const isSendDisabled = sender.isDisabled || Boolean(recipientError); + const isSendFastDisabled = !currentWallet || !address; + + return ( + navigate('/wallet')} />}> + {!currentWallet ? ( +
+

Loading wallet…

+ +
+ ) : ( +
+ setShowTokenModal(true)} /> + + + + + + + + {error &&

{error}

} + +
+ + {showFastSend && !effectiveGasless && ( + + )} +
+ + )} + + +
+ ); +}; diff --git a/apps/demo-wallet/src/features/send/components/token-select-button/index.ts b/apps/demo-wallet/src/features/send/components/token-select-button/index.ts new file mode 100644 index 000000000..282ebce05 --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/token-select-button/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './token-select-button'; diff --git a/apps/demo-wallet/src/features/send/components/token-select-button/token-select-button.tsx b/apps/demo-wallet/src/features/send/components/token-select-button/token-select-button.tsx new file mode 100644 index 000000000..1a0921eee --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/token-select-button/token-select-button.tsx @@ -0,0 +1,53 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { ChevronDown } from 'lucide-react'; + +import type { TokenOption } from '../../types'; + +import { FallbackImage } from '@/core/components/ui/fallback-image'; +import { formatLargeValue } from '@/core/utils'; + +interface TokenSelectButtonProps { + token: TokenOption; + onClick: () => void; +} + +/** Send token row: icon + symbol + chevron on the left, balance on the right. Opens the picker. */ +export const TokenSelectButton: React.FC = ({ token, onClick }) => ( + +); diff --git a/apps/demo-wallet/src/features/send/components/token-select-modal/index.ts b/apps/demo-wallet/src/features/send/components/token-select-modal/index.ts new file mode 100644 index 000000000..c2e4c614e --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/token-select-modal/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './token-select-modal'; diff --git a/apps/demo-wallet/src/features/send/components/token-select-modal/token-select-modal.tsx b/apps/demo-wallet/src/features/send/components/token-select-modal/token-select-modal.tsx new file mode 100644 index 000000000..23a971384 --- /dev/null +++ b/apps/demo-wallet/src/features/send/components/token-select-modal/token-select-modal.tsx @@ -0,0 +1,63 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +import type { TokenOption } from '../../types'; + +import { Modal } from '@/core/components/ui/modal'; +import { AssetRow } from '@/features/assets'; +import { cn } from '@/core/lib/utils'; +import { formatRate } from '@/core/utils'; + +interface TokenSelectModalProps { + isOpened: boolean; + onOpenChange: (value: boolean) => void; + options: TokenOption[]; + selectedId: string; + onSelect: (option: TokenOption) => void; +} + +/** Drawer/dialog token picker, reusing the assets-page row for visual parity. */ +export const TokenSelectModal: React.FC = ({ + isOpened, + onOpenChange, + options, + selectedId, + onSelect, +}) => ( + + onOpenChange(false)}> + Select token + + + {options.map((option) => ( + + ))} + + +); diff --git a/apps/demo-wallet/src/hooks/useGaslessJettonSend.ts b/apps/demo-wallet/src/features/send/hooks/use-gasless-jetton-send.ts similarity index 84% rename from apps/demo-wallet/src/hooks/useGaslessJettonSend.ts rename to apps/demo-wallet/src/features/send/hooks/use-gasless-jetton-send.ts index baa891b08..22e017804 100644 --- a/apps/demo-wallet/src/hooks/useGaslessJettonSend.ts +++ b/apps/demo-wallet/src/features/send/hooks/use-gasless-jetton-send.ts @@ -11,8 +11,8 @@ import { isValidAddress, hasSignMessageSupport } from '@ton/walletkit'; import type { Jetton, Wallet, GaslessSupportedAsset, SendTransactionResponse } from '@ton/walletkit'; import { useGasless } from '@demo/wallet-core'; -import { useJettonInfo } from '@/hooks/useJettonInfo'; -import { formatUnits, parseUnits } from '@/utils/units'; +import { useJettonInfo } from '@/features/jettons'; +import { formatUnits, parseUnits } from '@/core/utils/units'; const QUOTE_DEBOUNCE_MS = 400; @@ -82,33 +82,44 @@ export const useGaslessJettonSend = ({ return () => gasless.clearGasless(); }, [gasless.clearGasless]); + const jettonAddress = jetton?.address; + const jettonDecimals = jetton?.decimalsNumber; + // Re-quote (debounced) as the inputs change. The prior quote is invalidated // synchronously first, so a stale quote can't be sent during the debounce - // window (the Send button gates on `hasQuote`). + // window (the Send button gates on `hasQuote`). The store sequences the actual + // requests, so a slow earlier response can't overwrite a newer quote. useEffect(() => { if (!effective) return; gasless.clearGaslessQuote(); const inputAmount = parseFloat(amount); - const decimals = jetton?.decimalsNumber; if ( - !jetton || + !jettonAddress || !recipient || !isValidAddress(recipient) || !(inputAmount > 0) || !gasless.feeAsset || - !decimals + jettonDecimals == null ) { return; } - const transferAmount = parseUnits(amount, decimals).toString(); - const jettonAddress = jetton.address; + const transferAmount = parseUnits(amount, jettonDecimals).toString(); const id = setTimeout(() => { gasless.getGaslessQuote({ recipientAddress: recipient, jettonAddress, transferAmount }); }, QUOTE_DEBOUNCE_MS); return () => clearTimeout(id); - }, [effective, recipient, amount, gasless.feeAsset, jetton, gasless.clearGaslessQuote, gasless.getGaslessQuote]); + }, [ + effective, + recipient, + amount, + gasless.feeAsset, + jettonAddress, + jettonDecimals, + gasless.clearGaslessQuote, + gasless.getGaslessQuote, + ]); return { canUse, diff --git a/apps/demo-wallet/src/hooks/useSendToken.ts b/apps/demo-wallet/src/features/send/hooks/use-send-token.ts similarity index 90% rename from apps/demo-wallet/src/hooks/useSendToken.ts rename to apps/demo-wallet/src/features/send/hooks/use-send-token.ts index d7a71144a..ee9d55dfc 100644 --- a/apps/demo-wallet/src/hooks/useSendToken.ts +++ b/apps/demo-wallet/src/features/send/hooks/use-send-token.ts @@ -10,11 +10,12 @@ import { useCallback } from 'react'; import { toast } from 'sonner'; import type { ITonWalletKit, Jetton, SendTransactionResponse, Wallet } from '@ton/walletkit'; -import { parseUnits } from '@/utils/units'; -import { useGaslessJettonSend } from '@/hooks/useGaslessJettonSend'; -import type { UseGaslessJettonSendResult } from '@/hooks/useGaslessJettonSend'; +import { useGaslessJettonSend } from './use-gasless-jetton-send'; +import type { UseGaslessJettonSendResult } from './use-gasless-jetton-send'; -const TON_DECIMALS = 9; +import { parseUnits } from '@/core/utils/units'; + +const GRAM_DECIMALS = 9; interface UseSendTokenParams { wallet: Wallet | null | undefined; @@ -79,7 +80,7 @@ export const useSendToken = ({ if (tokenType === 'TON') { const tx = await wallet.createTransferTonTransaction({ recipientAddress: recipient, - transferAmount: parseUnits(amount, TON_DECIMALS).toString(), + transferAmount: parseUnits(amount, GRAM_DECIMALS).toString(), }); await walletKit.handleNewTransaction(wallet, tx); return undefined; @@ -87,7 +88,7 @@ export const useSendToken = ({ if (jetton) { const decimals = jetton.decimalsNumber; - if (!decimals) throw new Error('Jetton decimals not found'); + if (decimals == null) throw new Error('Jetton decimals not found'); const tx = await wallet.createTransferJettonTransaction({ recipientAddress: recipient, diff --git a/apps/demo-wallet/src/features/send/hooks/use-send-tokens.ts b/apps/demo-wallet/src/features/send/hooks/use-send-tokens.ts new file mode 100644 index 000000000..9dc6d69b1 --- /dev/null +++ b/apps/demo-wallet/src/features/send/hooks/use-send-tokens.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; +import { useJettons, useRates, useWallet } from '@demo/wallet-core'; + +import type { TokenOption } from '../types'; + +import { getJettonsImage, getJettonsName, getJettonsSymbol } from '@/features/jettons'; +import { findRate, toDecimal } from '@/core/utils'; + +const GRAM_DECIMALS = 9; +/** Kept aside on a MAX TON send so the transfer still has gas to pay for itself. */ +const TON_GAS_RESERVE = 0.01; + +/** Builds the selectable send assets: TON first, then every held jetton. */ +export const useSendTokens = (): TokenOption[] => { + const { balance } = useWallet(); + const { userJettons } = useJettons(); + const { entries: rates } = useRates(); + + return useMemo(() => { + const tonAmount = toDecimal(balance, GRAM_DECIMALS); + const tonOption: TokenOption = { + token: { type: 'TON' }, + id: 'TON', + icon: '/gram.svg', + fallbackText: 'GR', + name: 'Gram', + symbol: 'GRAM', + decimals: GRAM_DECIMALS, + balance: tonAmount, + maxSendable: Math.max(0, tonAmount - TON_GAS_RESERVE), + rate: rates['GRAM']?.rate, + }; + + const jettonOptions = userJettons.map((jetton): TokenOption => { + const decimals = jetton.decimalsNumber ?? GRAM_DECIMALS; + const amount = toDecimal(jetton.balance, decimals); + const symbol = getJettonsSymbol(jetton) ?? ''; + return { + token: { type: 'JETTON', data: jetton }, + id: jetton.address, + icon: getJettonsImage(jetton), + fallbackText: symbol.slice(0, 2).toUpperCase() || '??', + name: getJettonsName(jetton) ?? symbol, + symbol, + decimals, + balance: amount, + maxSendable: amount, + rate: findRate(rates, jetton.address)?.rate, + }; + }); + + return [tonOption, ...jettonOptions]; + }, [balance, userJettons, rates]); +}; diff --git a/apps/demo-wallet/src/features/send/index.ts b/apps/demo-wallet/src/features/send/index.ts new file mode 100644 index 000000000..a1dc1adb5 --- /dev/null +++ b/apps/demo-wallet/src/features/send/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/send-transaction'; +export * from './components/token-select-button'; +export * from './components/amount-field'; +export * from './components/recipient-field'; +export * from './components/gasless-options'; +export * from './components/token-select-modal'; +export * from './hooks/use-send-token'; +export * from './hooks/use-send-tokens'; +export * from './hooks/use-gasless-jetton-send'; +export * from './types'; diff --git a/apps/demo-wallet/src/features/send/types.ts b/apps/demo-wallet/src/features/send/types.ts new file mode 100644 index 000000000..984f5add0 --- /dev/null +++ b/apps/demo-wallet/src/features/send/types.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { Jetton } from '@ton/walletkit'; + +/** The asset the user is sending — TON, or a specific jetton. */ +export interface SelectedToken { + type: 'TON' | 'JETTON'; + data?: Jetton; +} + +/** View-model for one selectable send asset (TON or a held jetton). */ +export interface TokenOption { + token: SelectedToken; + /** `'TON'` or the jetton address — also the selection key. */ + id: string; + icon?: string; + fallbackText: string; + name: string; + symbol: string; + decimals: number; + /** Held balance in whole tokens. */ + balance: number; + /** Largest sendable amount (balance minus the gas reserve for TON). */ + maxSendable: number; + /** USD price per token; omitted when there's no rate. */ + rate?: number; +} diff --git a/apps/demo-wallet/src/features/settings/components/settings-dropdown/index.ts b/apps/demo-wallet/src/features/settings/components/settings-dropdown/index.ts new file mode 100644 index 000000000..41d03d226 --- /dev/null +++ b/apps/demo-wallet/src/features/settings/components/settings-dropdown/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './settings-dropdown'; diff --git a/apps/demo-wallet/src/features/settings/components/settings-dropdown/settings-dropdown.tsx b/apps/demo-wallet/src/features/settings/components/settings-dropdown/settings-dropdown.tsx new file mode 100644 index 000000000..bdf059974 --- /dev/null +++ b/apps/demo-wallet/src/features/settings/components/settings-dropdown/settings-dropdown.tsx @@ -0,0 +1,223 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ChevronRight, KeyRound, Lock, Plus, Trash2 } from 'lucide-react'; +import { useAuth, useWallet } from '@demo/wallet-core'; + +import { ToggleRow } from '../toggle-row'; + +import { MnemonicDisplay } from '@/features/wallets'; +import { createComponentLogger } from '@/core/lib/logger'; +import { Modal } from '@/core/components/ui/modal'; +import { SettingsIcon } from '@/core/components/ui/icons'; +import { CreateWalletModal, WALLET_SETUP_ROUTE } from '@/features/wallet-setup'; +import type { CreateWalletMode } from '@/features/wallet-setup'; + +const log = createComponentLogger('SettingsDropdown'); + +interface ActionRowProps { + icon: React.ReactNode; + label: string; + onClick: () => void; + danger?: boolean; + disabled?: boolean; +} + +const ActionRow: React.FC = ({ icon, label, onClick, danger = false, disabled = false }) => ( + +); + +export const SettingsDropdown: React.FC = () => { + const navigate = useNavigate(); + const { + lock, + reset, + persistPassword, + setPersistPassword, + holdToSign, + setHoldToSign, + showFastSend, + setShowFastSend, + } = useAuth(); + const { getDecryptedMnemonic } = useWallet(); + + const [panel, setPanel] = useState<'menu' | 'create' | 'mnemonic' | null>(null); + const [mnemonic, setMnemonic] = useState([]); + const [isLoadingMnemonic, setIsLoadingMnemonic] = useState(false); + const [mnemonicError, setMnemonicError] = useState(''); + + const handleLockWallet = () => { + setPanel(null); + lock(); + }; + + const handleDeleteWallet = () => { + if (window.confirm('Are you sure you want to delete your wallet? This action cannot be undone.')) { + setPanel(null); + reset(); + } + }; + + const handleCreateNewWallet = () => setPanel('create'); + + const handleSelectCreateMode = (mode: CreateWalletMode) => { + setPanel(null); + navigate(WALLET_SETUP_ROUTE[mode]); + }; + + const handleViewRecoveryPhrase = async () => { + setIsLoadingMnemonic(true); + setMnemonicError(''); + + try { + const decryptedMnemonic = await getDecryptedMnemonic(); + if (decryptedMnemonic) { + setMnemonic(decryptedMnemonic); + setPanel('mnemonic'); + } else { + setMnemonicError('Unable to retrieve recovery phrase. Please ensure you are logged in.'); + } + } catch (error) { + setMnemonicError('Failed to decrypt recovery phrase. Please try again.'); + log.error('Error retrieving mnemonic:', error); + } finally { + setIsLoadingMnemonic(false); + } + }; + + const handleCloseMnemonicModal = () => { + setPanel(null); + setMnemonic([]); + setMnemonicError(''); + }; + + return ( + <> + + + !open && setPanel(null)} + className="px-2" + > + setPanel(null)}> + Settings + + + +
+ setPersistPassword(!checked)} + info={ + <> + Security notice: when auto-lock is off, your password is stored + locally and the wallet stays unlocked. Only use for development. + + } + /> + + Security notice: disabling hold-to-sign makes it easier to + accidentally approve transactions. Only use for testing. + + } + /> + +
+ +
+ } + label="Create New Wallet" + onClick={handleCreateNewWallet} + /> + } + label={isLoadingMnemonic ? 'Loading…' : 'View Recovery Phrase'} + onClick={handleViewRecoveryPhrase} + disabled={isLoadingMnemonic} + /> + } label="Lock Wallet" onClick={handleLockWallet} /> + } + label="Delete Wallet" + onClick={handleDeleteWallet} + danger + /> +
+ + {mnemonicError && ( +

{mnemonicError}

+ )} +
+
+ + setPanel(null)} + onSelect={handleSelectCreateMode} + /> + + !open && handleCloseMnemonicModal()} + className="px-2" + > + + Recovery Phrase + + + {mnemonic.length > 0 && ( + + )} + + + + ); +}; diff --git a/apps/demo-wallet/src/features/settings/components/toggle-row/index.ts b/apps/demo-wallet/src/features/settings/components/toggle-row/index.ts new file mode 100644 index 000000000..ec1e962be --- /dev/null +++ b/apps/demo-wallet/src/features/settings/components/toggle-row/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './toggle-row'; diff --git a/apps/demo-wallet/src/features/settings/components/toggle-row/toggle-row.tsx b/apps/demo-wallet/src/features/settings/components/toggle-row/toggle-row.tsx new file mode 100644 index 000000000..3f150da90 --- /dev/null +++ b/apps/demo-wallet/src/features/settings/components/toggle-row/toggle-row.tsx @@ -0,0 +1,60 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import { Info } from 'lucide-react'; + +import { Popover, PopoverContent, PopoverTrigger } from '@/core/components/ui/popover'; + +const InfoPopover: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( + + + + + + {children} + + +); + +interface ToggleRowProps { + testId: string; + label: string; + description: string; + checked: boolean; + onChange: (checked: boolean) => void; + info?: React.ReactNode; +} + +export const ToggleRow: React.FC = ({ testId, label, description, checked, onChange, info }) => ( +
+
+
+ {label} + {info && {info}} +
+

{description}

+
+ +
+); diff --git a/apps/demo-wallet/src/features/settings/index.ts b/apps/demo-wallet/src/features/settings/index.ts new file mode 100644 index 000000000..cfaf8da61 --- /dev/null +++ b/apps/demo-wallet/src/features/settings/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/settings-dropdown'; +export * from './components/toggle-row'; diff --git a/apps/demo-wallet/src/features/staking/components/staking-info/index.ts b/apps/demo-wallet/src/features/staking/components/staking-info/index.ts new file mode 100644 index 000000000..b71974a6c --- /dev/null +++ b/apps/demo-wallet/src/features/staking/components/staking-info/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './staking-info'; diff --git a/apps/demo-wallet/src/features/staking/components/staking-info/staking-info.tsx b/apps/demo-wallet/src/features/staking/components/staking-info/staking-info.tsx new file mode 100644 index 000000000..2de312165 --- /dev/null +++ b/apps/demo-wallet/src/features/staking/components/staking-info/staking-info.tsx @@ -0,0 +1,59 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import { useStaking } from '@demo/wallet-core'; + +import { useStakingProviders } from '../../hooks/use-staking-providers'; + +import { formatLargeValue } from '@/core/utils'; + +interface StakingInfoProps { + /** Ticker received from the current quote (stake → tsTON, unstake → GRAM). */ + receiveTicker?: string; +} + +/** Read-only pool summary: APY, provider, instant-unstake liquidity and the quoted output. */ +export const StakingInfo: FC = ({ receiveTicker }) => { + const { providerInfo, providerId, currentQuote } = useStaking(); + const providers = useStakingProviders(); + const providerName = providers.find((provider) => provider.id === providerId)?.name ?? 'Tonstakers'; + + return ( +
+
+ APY + + {providerInfo?.apy ? `${providerInfo.apy.toFixed(2)}%` : '—'} + +
+
+ Provider + {providerName} +
+
+ Instant unstake available + + {providerInfo?.instantUnstakeAvailable + ? formatLargeValue(String(providerInfo.instantUnstakeAvailable), 4) + : '0'}{' '} + GRAM + +
+ {currentQuote && ( +
+ You will receive + + {formatLargeValue(String(currentQuote.amountOut), 4)} + {receiveTicker ? ` ${receiveTicker}` : ''} + +
+ )} +
+ ); +}; diff --git a/apps/demo-wallet/src/features/staking/components/staking-interface/index.ts b/apps/demo-wallet/src/features/staking/components/staking-interface/index.ts new file mode 100644 index 000000000..bbf1fff19 --- /dev/null +++ b/apps/demo-wallet/src/features/staking/components/staking-interface/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './staking-interface'; diff --git a/apps/demo-wallet/src/features/staking/components/staking-interface/staking-interface.tsx b/apps/demo-wallet/src/features/staking/components/staking-interface/staking-interface.tsx new file mode 100644 index 000000000..a64121bbb --- /dev/null +++ b/apps/demo-wallet/src/features/staking/components/staking-interface/staking-interface.tsx @@ -0,0 +1,250 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useState } from 'react'; +import type { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useStaking, useWallet } from '@demo/wallet-core'; +import { UnstakeMode } from '@ton/walletkit'; + +import { StakingSettings } from '../staking-settings'; +import { StakingInfo } from '../staking-info'; + +import { Button } from '@/core/components/ui/button'; +import { CenteredAmountInput } from '@/core/components/ui/centered-amount-input'; +import { cn } from '@/core/lib/utils'; +import { formatLargeValue } from '@/core/utils'; +import { formatUnits } from '@/core/utils/units'; + +const GRAM_DECIMALS = 9; +/** + * GRAM kept aside on a stake so the transaction still has gas (and the account stays + * funded). Mirrors the staking widget's 1.2 TON `feeReserveNanos` in appkit-react. + */ +const STAKE_GAS_RESERVE = 1.2; +const STAKE_TICKER = 'GRAM'; +const STAKED_TICKER = 'tsTON'; + +const UNSTAKE_MODES = [ + { mode: UnstakeMode.INSTANT, label: 'Instant', hint: 'Receive GRAM immediately' }, + { mode: UnstakeMode.WHEN_AVAILABLE, label: 'When available', hint: 'Immediate if liquid, or up to ~18h queue' }, + { mode: UnstakeMode.ROUND_END, label: 'Round end', hint: 'Wait for cycle end (~18h) for best rate' }, +]; + +export const StakingInterface: FC = () => { + const navigate = useNavigate(); + const { balance } = useWallet(); + const { + amount, + currentQuote, + isLoadingQuote, + isStaking, + isUnstaking, + error, + unstakeMode, + stakedBalance, + providerId, + setStakingAmount: setAmount, + setUnstakeMode, + setStakingProviderId, + getStakingQuote: getQuote, + stake, + unstake, + validateStakingInputs, + } = useStaking(); + + const [tab, setTab] = useState<'stake' | 'unstake'>('stake'); + const isStake = tab === 'stake'; + + const availableGram = formatUnits(balance || '0', GRAM_DECIMALS); + const stakedTs = stakedBalance?.stakedBalance ?? '0'; + + const handleTab = (next: 'stake' | 'unstake') => { + setTab(next); + setAmount(''); + }; + + const handleAmountChange = (raw: string) => { + // Keep digits and a single decimal separator (the input is free-form text). + setAmount(raw.replace(/[^0-9.]/g, '').replace(/(\..*)\./g, '$1')); + }; + + const handleMax = () => { + if (isStake) { + const max = parseFloat(availableGram) - STAKE_GAS_RESERVE; + if (max > 0) setAmount(String(max)); + } else if (parseFloat(stakedTs) > 0) { + setAmount(stakedTs); + } + }; + + const handlePreview = async () => { + await getQuote({ amount, direction: isStake ? 'stake' : 'unstake' }); + }; + + const handleAction = async () => { + if (!currentQuote) return; + const ok = isStake ? await stake({ quote: currentQuote }) : await unstake({ quote: currentQuote }); + if (ok) navigate('/wallet', { state: { message: `${isStake ? 'Staked' : 'Unstaked'} successfully!` } }); + }; + + const amountNumber = parseFloat(amount) || 0; + // Max GRAM that can be staked while keeping the gas reserve (the threshold). + const stakeMax = Math.max(0, parseFloat(availableGram) - STAKE_GAS_RESERVE); + // Direction-aware balance guard: a stake spends GRAM (and must leave gas), an unstake + // spends staked tsTON. + let balanceError = ''; + if (amountNumber > 0) { + if (isStake) { + if (amountNumber > parseFloat(availableGram)) { + balanceError = 'Insufficient balance'; + } else if (amountNumber > stakeMax) { + balanceError = `Keep ~${STAKE_GAS_RESERVE} GRAM for network fees`; + } + } else if (amountNumber > parseFloat(stakedTs)) { + balanceError = 'Not enough staked'; + } + } + const validationError = validateStakingInputs(); + const canPreview = Boolean(!validationError && !balanceError && amountNumber > 0); + const isSending = isStaking || isUnstaking; + // Receive side (shown once a quote exists): stake → tsTON, unstake → GRAM. + const receiveTicker = isStake ? STAKED_TICKER : STAKE_TICKER; + const activeHint = UNSTAKE_MODES.find((m) => m.mode === unstakeMode)?.hint; + + return ( +
+ {/* Stake / Unstake tabs */} +
+ {(['stake', 'unstake'] as const).map((value) => ( + + ))} +
+ + {/* Amount */} +
+ +
+ + {/* Balances + Max */} +
+
+ Available + + + {formatLargeValue(availableGram, 4)} GRAM + + {isStake && parseFloat(availableGram) > 0 && ( + + )} + +
+
+ Staked + + + {formatLargeValue(stakedTs, 4)} tsTON + + {!isStake && parseFloat(stakedTs) > 0 && ( + + )} + +
+
+ + {/* Unstake method */} + {!isStake && ( +
+ Unstake method +
+ {UNSTAKE_MODES.map(({ mode, label }) => ( + + ))} +
+ {activeHint &&

{activeHint}

} +
+ )} + + {(balanceError || error) && ( +

{balanceError || error}

+ )} + + {/* Actions */} +
+ {!currentQuote ? ( + + ) : ( + <> + + + + )} + +
+ + +
+ ); +}; diff --git a/apps/demo-wallet/src/features/staking/components/staking-screen/index.ts b/apps/demo-wallet/src/features/staking/components/staking-screen/index.ts new file mode 100644 index 000000000..529082a48 --- /dev/null +++ b/apps/demo-wallet/src/features/staking/components/staking-screen/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './staking-screen'; diff --git a/apps/demo-wallet/src/features/staking/components/staking-screen/staking-screen.tsx b/apps/demo-wallet/src/features/staking/components/staking-screen/staking-screen.tsx new file mode 100644 index 000000000..7de54bda0 --- /dev/null +++ b/apps/demo-wallet/src/features/staking/components/staking-screen/staking-screen.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useStaking, useWallet } from '@demo/wallet-core'; + +import { StakingInterface } from '../staking-interface'; + +import { NewLayout } from '@/core/components/shared/new-layout'; +import { ScreenHeader } from '@/core/components/shared/screen-header'; + +export const Staking: FC = () => { + const navigate = useNavigate(); + const { address } = useWallet(); + const { clearStaking, loadStakingData } = useStaking(); + + useEffect(() => { + if (address) { + loadStakingData(address); + } + return () => clearStaking(); + }, [address, loadStakingData, clearStaking]); + + return ( + navigate('/wallet')} />}> + + + ); +}; diff --git a/apps/demo-wallet/src/features/staking/components/staking-settings/index.ts b/apps/demo-wallet/src/features/staking/components/staking-settings/index.ts new file mode 100644 index 000000000..de30eb59c --- /dev/null +++ b/apps/demo-wallet/src/features/staking/components/staking-settings/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './staking-settings'; diff --git a/apps/demo-wallet/src/features/staking/components/staking-settings/staking-settings.tsx b/apps/demo-wallet/src/features/staking/components/staking-settings/staking-settings.tsx new file mode 100644 index 000000000..af7021b21 --- /dev/null +++ b/apps/demo-wallet/src/features/staking/components/staking-settings/staking-settings.tsx @@ -0,0 +1,81 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useEffect, useState } from 'react'; + +import { useStakingProviders } from '../../hooks/use-staking-providers'; + +import { Button } from '@/core/components/ui/button'; +import { Modal } from '@/core/components/ui/modal'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/core/components/ui/select'; +import { SettingsButton } from '@/core/components/shared/settings-button'; + +interface StakingSettingsProps { + providerId: string; + setProviderId: (providerId: string) => void; +} + +/** Gear button that opens a modal to choose the staking provider. */ +export const StakingSettings: React.FC = ({ providerId, setProviderId }) => { + const providers = useStakingProviders(); + + const [open, setOpen] = useState(false); + const [tempProviderId, setTempProviderId] = useState(providerId); + + useEffect(() => { + if (open) setTempProviderId(providerId); + }, [open, providerId]); + + const handleSave = () => { + if (tempProviderId !== providerId) { + setProviderId(tempProviderId); + } + setOpen(false); + }; + + const selectedProviderName = providers.find((provider) => provider.id === tempProviderId)?.name; + + return ( + <> + setOpen(true)} aria-label="Staking settings" /> + + + setOpen(false)}> + Staking settings + + +
+ Provider + +
+
+ + + + +
+ + ); +}; diff --git a/apps/demo-wallet/src/features/staking/hooks/use-staking-providers.ts b/apps/demo-wallet/src/features/staking/hooks/use-staking-providers.ts new file mode 100644 index 000000000..0746eb702 --- /dev/null +++ b/apps/demo-wallet/src/features/staking/hooks/use-staking-providers.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; +import { useWalletKit } from '@demo/wallet-core'; + +export interface StakingProviderOption { + id: string; + name: string; +} + +/** Registered staking providers (id + display name) read from the kit. */ +export const useStakingProviders = (): StakingProviderOption[] => { + const walletKit = useWalletKit(); + + return useMemo(() => { + if (!walletKit) return []; + return walletKit.staking.getProviders().map((provider) => ({ + id: provider.providerId, + name: provider.getStakingProviderMetadata().name, + })); + }, [walletKit]); +}; diff --git a/apps/demo-wallet/src/features/staking/index.ts b/apps/demo-wallet/src/features/staking/index.ts new file mode 100644 index 000000000..892b92221 --- /dev/null +++ b/apps/demo-wallet/src/features/staking/index.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/staking-screen'; +export * from './components/staking-info'; +export * from './components/staking-interface'; +export * from './components/staking-settings'; +export * from './hooks/use-staking-providers'; diff --git a/apps/demo-wallet/src/features/swap/components/quote-timer/index.ts b/apps/demo-wallet/src/features/swap/components/quote-timer/index.ts new file mode 100644 index 000000000..5f3b1905b --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/quote-timer/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './quote-timer'; diff --git a/apps/demo-wallet/src/components/swap/QuoteTimer.tsx b/apps/demo-wallet/src/features/swap/components/quote-timer/quote-timer.tsx similarity index 74% rename from apps/demo-wallet/src/components/swap/QuoteTimer.tsx rename to apps/demo-wallet/src/features/swap/components/quote-timer/quote-timer.tsx index b7c764cfe..7389479b2 100644 --- a/apps/demo-wallet/src/components/swap/QuoteTimer.tsx +++ b/apps/demo-wallet/src/features/swap/components/quote-timer/quote-timer.tsx @@ -9,15 +9,15 @@ import type { FC } from 'react'; import { useEffect, useState } from 'react'; -import { Button } from '../Button'; +import { Button } from '@/core/components/ui/button'; interface QuoteTimerProps { expiresAt?: number; // Unix timestamp in seconds onRefresh: () => void; - isLoading?: boolean; + loading?: boolean; } -export const QuoteTimer: FC = ({ expiresAt, onRefresh, isLoading = false }) => { +export const QuoteTimer: FC = ({ expiresAt, onRefresh, loading = false }) => { const [timeLeft, setTimeLeft] = useState(0); useEffect(() => { @@ -43,17 +43,15 @@ export const QuoteTimer: FC = ({ expiresAt, onRefresh, isLoadin const seconds = totalSeconds % 60; const isExpired = !expiresAt || timeLeft === 0; + if (!expiresAt) { + return null; + } + if (isExpired) { return ( -
- Quote expired -
@@ -61,8 +59,8 @@ export const QuoteTimer: FC = ({ expiresAt, onRefresh, isLoadin } return ( -
- +
+ Quote valid for{' '} {minutes > 0 && `${minutes}m `} diff --git a/apps/demo-wallet/src/features/swap/components/swap-field/index.ts b/apps/demo-wallet/src/features/swap/components/swap-field/index.ts new file mode 100644 index 000000000..cd1af50f3 --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-field/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './swap-field'; diff --git a/apps/demo-wallet/src/features/swap/components/swap-field/swap-field.tsx b/apps/demo-wallet/src/features/swap/components/swap-field/swap-field.tsx new file mode 100644 index 000000000..1de30d3c8 --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-field/swap-field.tsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; + +import { FallbackImage } from '@/core/components/ui/fallback-image'; +import { formatLargeValue } from '@/core/utils'; + +interface SwapFieldProps { + label: string; + symbol: string; + icon?: string; + amount: string; + /** Held balance as a human-readable decimal string. */ + balance: string; + onAmountChange: (value: string) => void; + onMax?: () => void; +} + +/** One side of the swap (From / To): amount on the left, a token pill on the right. */ +export const SwapField: React.FC = ({ + label, + symbol, + icon, + amount, + balance, + onAmountChange, + onMax, +}) => ( +
+
+ {label} + + Balance: {formatLargeValue(balance, 4)} + {onMax && parseFloat(balance) > 0 && ( + + )} + +
+ +
+ onAmountChange(e.target.value)} + /> + + + + + {symbol.slice(0, 2).toUpperCase()} + + } + /> + + {symbol} + +
+
+); diff --git a/apps/demo-wallet/src/features/swap/components/swap-info/index.ts b/apps/demo-wallet/src/features/swap/components/swap-info/index.ts new file mode 100644 index 000000000..706f1854b --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-info/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './swap-info'; diff --git a/apps/demo-wallet/src/features/swap/components/swap-info/swap-info.tsx b/apps/demo-wallet/src/features/swap/components/swap-info/swap-info.tsx new file mode 100644 index 000000000..bd9751a8a --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-info/swap-info.tsx @@ -0,0 +1,62 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React from 'react'; +import type { SwapQuote } from '@ton/walletkit'; + +import { useSwapProviders } from '../../hooks/use-swap-providers'; + +import { cn } from '@/core/lib/utils'; + +function priceImpactColor(priceImpact: number): string { + if (priceImpact > 500) return 'text-red-500'; + if (priceImpact > 200) return 'text-yellow-600'; + return 'text-green-600'; +} + +const InfoRow: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+ {label} + {children} +
+); + +interface SwapInfoProps { + quote: SwapQuote; + toSymbol: string; + slippageBps: number; +} + +/** Read-only summary of the current quote (provider, minimum received, price impact, slippage). */ +export const SwapInfo: React.FC = ({ quote, toSymbol, slippageBps }) => { + const providers = useSwapProviders(); + const providerName = providers.find((provider) => provider.id === quote.providerId)?.name ?? quote.providerId; + + return ( +
+ + {providerName} + + + + {Number(quote.minReceived).toFixed(6)} {toSymbol} + + + {quote.priceImpact ? ( + + + {(quote.priceImpact / 100).toFixed(2)}% + + + ) : null} + + {slippageBps / 100}% + +
+ ); +}; diff --git a/apps/demo-wallet/src/features/swap/components/swap-interface/index.ts b/apps/demo-wallet/src/features/swap/components/swap-interface/index.ts new file mode 100644 index 000000000..5168317bd --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-interface/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './swap-interface'; diff --git a/apps/demo-wallet/src/features/swap/components/swap-interface/swap-interface.tsx b/apps/demo-wallet/src/features/swap/components/swap-interface/swap-interface.tsx new file mode 100644 index 000000000..8fc62c2f9 --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-interface/swap-interface.tsx @@ -0,0 +1,227 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useState } from 'react'; +import type { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowDownUp } from 'lucide-react'; +import { useJettons, useSwap, useWallet } from '@demo/wallet-core'; +import type { SwapToken } from '@ton/walletkit'; + +import { SwapField } from '../swap-field'; +import { SwapInfo } from '../swap-info'; +import { SwapSettings } from '../swap-settings'; +import { QuoteTimer } from '../quote-timer'; + +import { Button } from '@/core/components/ui/button'; +import { Input } from '@/core/components/ui/input'; +import { getJettonsImage, getJettonsSymbol } from '@/features/jettons'; +import { cn } from '@/core/lib/utils'; +import { formatUnits } from '@/core/utils/units'; + +/** Reserved on a MAX TON swap, so the transaction still has gas to pay for itself. */ +const TON_GAS_RESERVE = 0.1; + +interface TokenView { + symbol: string; + icon?: string; + balance: string; +} + +interface SwapInterfaceProps { + className?: string; +} + +export const SwapInterface: FC = ({ className }) => { + const navigate = useNavigate(); + const { balance } = useWallet(); + const { userJettons } = useJettons(); + const { + fromToken, + toToken, + amount, + isReverseSwap, + destinationAddress, + currentQuote, + isLoadingQuote, + isSwapping, + error, + slippageBps, + providerId, + setSwapAmount: setAmount, + setIsReverseSwap, + setDestinationAddress, + setSlippageBps, + setSwapProviderId, + swapTokens, + getSwapQuote, + executeSwap, + } = useSwap(); + + const [useCustomDestination, setUseCustomDestination] = useState(false); + + const getTokenView = (token: SwapToken): TokenView => { + if (token.address === 'ton') { + return { + symbol: token.symbol || 'GRAM', + icon: '/gram.svg', + balance: formatUnits(balance || '0', token.decimals), + }; + } + const jetton = userJettons.find((j) => j.address === token.address); + return { + symbol: token.symbol || (jetton ? getJettonsSymbol(jetton) : undefined) || 'Token', + icon: token.image ?? (jetton ? getJettonsImage(jetton) : undefined), + balance: jetton?.balance ? formatUnits(jetton.balance, token.decimals) : '0', + }; + }; + + const fromView = getTokenView(fromToken); + const toView = getTokenView(toToken); + + const fromAmount = !isReverseSwap ? amount : currentQuote ? currentQuote.fromAmount : ''; + const toAmount = isReverseSwap ? amount : currentQuote ? currentQuote.toAmount : ''; + + const handleFromAmountChange = (value: string) => { + setAmount(value); + setIsReverseSwap(false); + }; + + const handleToAmountChange = (value: string) => { + setAmount(value); + setIsReverseSwap(true); + }; + + const handleMaxFrom = () => { + const currentBalance = parseFloat(fromView.balance); + if (!(currentBalance > 0)) return; + if (fromToken.address === 'ton') { + const maxAmount = currentBalance - TON_GAS_RESERVE; + if (maxAmount > 0) handleFromAmountChange(maxAmount.toString()); + } else { + handleFromAmountChange(fromView.balance); + } + }; + + const handleExecuteSwap = async () => { + const ok = await executeSwap(); + if (ok) navigate('/wallet', { state: { message: `${fromView.symbol} sent successfully!` } }); + }; + + const getSwapButtonText = (): string => { + const hasFromAmount = fromAmount && parseFloat(fromAmount) > 0; + const hasToAmount = toAmount && parseFloat(toAmount) > 0; + if (!hasFromAmount && !hasToAmount) return 'Enter amount'; + if (error) return 'Error'; + return `Swap ${fromView.symbol} for ${toView.symbol}`; + }; + + const isSwapDisabled = Boolean(error) || isLoadingQuote || isSwapping; + + return ( +
+
+
+ + +
+ + +
+ + {/* Optional custom recipient */} +
+ + + {useCustomDestination && ( + + + setDestinationAddress(e.target.value)} + placeholder="Recipient address (EQ…)" + /> + + Swapped tokens will be sent here instead of your wallet. + + )} +
+ + {currentQuote && ( + <> + + + + )} + + {error &&

{error}

} + +
+ {!currentQuote ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/apps/demo-wallet/src/features/swap/components/swap-screen/index.ts b/apps/demo-wallet/src/features/swap/components/swap-screen/index.ts new file mode 100644 index 000000000..6f9491a5a --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-screen/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './swap-screen'; diff --git a/apps/demo-wallet/src/features/swap/components/swap-screen/swap-screen.tsx b/apps/demo-wallet/src/features/swap/components/swap-screen/swap-screen.tsx new file mode 100644 index 000000000..56362d244 --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-screen/swap-screen.tsx @@ -0,0 +1,36 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { FC } from 'react'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSwap } from '@demo/wallet-core'; + +import { SwapInterface } from '../swap-interface'; +import { USDT_ADDRESS } from '../../constants/swap'; + +import { NewLayout } from '@/core/components/shared/new-layout'; +import { ScreenHeader } from '@/core/components/shared/screen-header'; + +export const Swap: FC = () => { + const navigate = useNavigate(); + const { setFromToken, setToToken, clearSwap } = useSwap(); + + useEffect(() => { + setFromToken({ address: 'ton', decimals: 9, symbol: 'GRAM' }); + setToToken({ address: USDT_ADDRESS, decimals: 6, symbol: 'USDT' }); + + return () => clearSwap(); + }, []); + + return ( + navigate('/wallet')} />}> + + + ); +}; diff --git a/apps/demo-wallet/src/features/swap/components/swap-settings/index.ts b/apps/demo-wallet/src/features/swap/components/swap-settings/index.ts new file mode 100644 index 000000000..1c008cbf0 --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-settings/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './swap-settings'; diff --git a/apps/demo-wallet/src/features/swap/components/swap-settings/swap-settings.tsx b/apps/demo-wallet/src/features/swap/components/swap-settings/swap-settings.tsx new file mode 100644 index 000000000..878797af2 --- /dev/null +++ b/apps/demo-wallet/src/features/swap/components/swap-settings/swap-settings.tsx @@ -0,0 +1,128 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useEffect, useState } from 'react'; + +import { useSwapProviders } from '../../hooks/use-swap-providers'; + +import { Button } from '@/core/components/ui/button'; +import { Modal } from '@/core/components/ui/modal'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/core/components/ui/select'; +import { SettingsButton } from '@/core/components/shared/settings-button'; +import { cn } from '@/core/lib/utils'; + +const PRESET_SLIPPAGES = [50, 100, 300, 500]; + +interface SwapSettingsProps { + slippageBps: number; + setSlippageBps: (slippage: number) => void; + providerId: string; + setProviderId: (providerId: string) => void; +} + +/** Gear button that opens a modal to choose the slippage tolerance and swap provider. */ +export const SwapSettings: React.FC = ({ + slippageBps, + setSlippageBps, + providerId, + setProviderId, +}) => { + const providers = useSwapProviders(); + + const [open, setOpen] = useState(false); + const [tempSlippageBps, setTempSlippageBps] = useState(slippageBps); + const [tempProviderId, setTempProviderId] = useState(providerId); + + useEffect(() => { + if (open) { + setTempSlippageBps(slippageBps); + setTempProviderId(providerId); + } + }, [open, slippageBps, providerId]); + + const handleSave = () => { + if (tempSlippageBps >= 10 && tempSlippageBps <= 5000) { + setSlippageBps(tempSlippageBps); + } + if (tempProviderId !== providerId) { + setProviderId(tempProviderId); + } + setOpen(false); + }; + + const selectedProviderName = providers.find((provider) => provider.id === tempProviderId)?.name; + + return ( + <> + setOpen(true)} aria-label="Swap settings" /> + + + setOpen(false)}> + Swap settings + + +
+ Slippage tolerance +
+ {PRESET_SLIPPAGES.map((preset) => ( + + ))} +
+

+ Your transaction will revert if the price changes unfavorably by more than this percentage. +

+
+ + {providers.length > 0 && ( +
+ Provider + +
+ )} +
+ + + + +
+ + ); +}; diff --git a/apps/demo-wallet/src/constants/swap.ts b/apps/demo-wallet/src/features/swap/constants/swap.ts similarity index 100% rename from apps/demo-wallet/src/constants/swap.ts rename to apps/demo-wallet/src/features/swap/constants/swap.ts diff --git a/apps/demo-wallet/src/features/swap/hooks/use-swap-providers.ts b/apps/demo-wallet/src/features/swap/hooks/use-swap-providers.ts new file mode 100644 index 000000000..b2eecd64a --- /dev/null +++ b/apps/demo-wallet/src/features/swap/hooks/use-swap-providers.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useMemo } from 'react'; +import { useWalletKit } from '@demo/wallet-core'; + +export interface SwapProviderOption { + id: string; + name: string; + logo?: string; + url?: string; +} + +/** Registered swap providers (id + display metadata) read from the kit. */ +export const useSwapProviders = (): SwapProviderOption[] => { + const walletKit = useWalletKit(); + + return useMemo(() => { + if (!walletKit) return []; + return walletKit.swap.getProviders().map((provider) => ({ + id: provider.providerId, + ...provider.getMetadata(), + })); + }, [walletKit]); +}; diff --git a/apps/demo-wallet/src/features/swap/index.ts b/apps/demo-wallet/src/features/swap/index.ts new file mode 100644 index 000000000..0044e3193 --- /dev/null +++ b/apps/demo-wallet/src/features/swap/index.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export * from './components/swap-screen'; +export * from './components/swap-interface'; +export * from './components/swap-settings'; +export * from './components/swap-field'; +export * from './components/swap-info'; +export * from './components/quote-timer'; +export * from './hooks/use-swap-providers'; diff --git a/apps/demo-wallet/src/features/ton-connect/components/connect-dapp-modal/connect-dapp-modal.tsx b/apps/demo-wallet/src/features/ton-connect/components/connect-dapp-modal/connect-dapp-modal.tsx new file mode 100644 index 000000000..2b354975c --- /dev/null +++ b/apps/demo-wallet/src/features/ton-connect/components/connect-dapp-modal/connect-dapp-modal.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import React, { useCallback, useState } from 'react'; +import { useTonConnect } from '@demo/wallet-core'; + +import { Button } from '@/core/components/ui/button'; +import { Modal } from '@/core/components/ui/modal'; + +interface ConnectDappModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const ConnectDappModal: React.FC = ({ isOpen, onClose }) => { + const { handleTonConnectUrl } = useTonConnect(); + const [url, setUrl] = useState(''); + const [isConnecting, setIsConnecting] = useState(false); + + const handleConnect = useCallback(async () => { + const trimmed = url.trim(); + if (!trimmed) return; + + setIsConnecting(true); + try { + await handleTonConnectUrl(trimmed); + setUrl(''); + onClose(); + } catch { + // connect modal / error state handled by the store + } finally { + setIsConnecting(false); + } + }, [url, handleTonConnectUrl, onClose]); + + return ( + !open && onClose()} className="px-2"> + + Connect to dApp + + + + +