diff --git a/.gitignore b/.gitignore index 342e6b9..177cccd 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,14 @@ test-ledger/ # Local config (contains generated mint addresses) config.json +# Environment secrets +.env + +# Playwright output +apps/web/playwright-report/ +apps/web/test-results/ + +# TypeScript incremental build cache +apps/web/tsconfig.tsbuildinfo + +.vercel diff --git a/apps/web/e2e/helpers/wallet.ts b/apps/web/e2e/helpers/wallet.ts new file mode 100644 index 0000000..c47d016 --- /dev/null +++ b/apps/web/e2e/helpers/wallet.ts @@ -0,0 +1,164 @@ +import type { Page } from '@playwright/test'; + +/** + * Injects a mock Phantom wallet into the page using TweetNaCl for Ed25519 signing. + * + * Must be called after page.goto() but before clicking "Select Wallet". + * After calling this, call connectWallet() to trigger the adapter connect flow. + * + * Returns the wallet's base58 public key. + */ +export async function injectWallet(page: Page, walletKeyBase58: string): Promise { + await page.evaluate(key => { + (window as any)._walletKey = key; + }, walletKeyBase58); + + await page.evaluate( + () => + new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js'; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load TweetNaCl')); + document.head.appendChild(script); + }), + ); + + // Minimal Buffer polyfill — the Phantom wallet adapter uses Buffer.from() internally. + await page.evaluate(() => { + (window as any).Buffer = { + alloc: (size: number, fill = 0) => new Uint8Array(size).fill(fill), + concat: (bufs: Uint8Array[]) => { + const total = bufs.reduce((s, b) => s + b.length, 0); + const result = new Uint8Array(total); + let offset = 0; + for (const b of bufs) { + result.set(b, offset); + offset += b.length; + } + return result; + }, + from: (data: any) => { + if (data instanceof Uint8Array) return data; + if (Array.isArray(data)) return new Uint8Array(data); + return new Uint8Array(data); + }, + isBuffer: (obj: any) => obj instanceof Uint8Array, + }; + }); + + const pubkey = await page.evaluate((walletKey: string) => { + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + + function b58Decode(s: string): Uint8Array { + const bytes = [0]; + for (const c of s) { + const idx = ALPHABET.indexOf(c); + if (idx < 0) throw new Error('Invalid base58 char: ' + c); + let carry = idx; + for (let j = 0; j < bytes.length; j++) { + carry += bytes[j] * 58; + bytes[j] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + for (const c of s) { + if (c === '1') bytes.push(0); + else break; + } + return new Uint8Array(bytes.reverse()); + } + + function b58Encode(bytes: Uint8Array): string { + const digits = [0]; + for (let i = 0; i < bytes.length; i++) { + let carry = bytes[i]; + for (let j = 0; j < digits.length; j++) { + carry += digits[j] * 256; + digits[j] = carry % 58; + carry = Math.floor(carry / 58); + } + while (carry > 0) { + digits.push(carry % 58); + carry = Math.floor(carry / 58); + } + } + let result = ''; + for (let i = 0; i < bytes.length - 1 && bytes[i] === 0; i++) result += '1'; + return ( + result + + digits + .reverse() + .map(d => ALPHABET[d]) + .join('') + ); + } + + const nacl = (window as any).nacl; + const kp = nacl.sign.keyPair.fromSecretKey(b58Decode(walletKey)); + const pubkeyB58 = b58Encode(kp.publicKey); + + (window as any)._kp = kp; + (window as any)._pubkey = pubkeyB58; + + (window as any).solana = { + _events: {} as Record void)[]>, + connect: async () => ({ publicKey: (window as any).solana.publicKey }), + disconnect: async () => {}, + emit(event: string, ...args: any[]) { + (this._events[event] ?? []).forEach((h: any) => h(...args)); + }, + isConnected: true, + isPhantom: true, + off(event: string, handler: (...args: any[]) => void) { + if (this._events[event]) { + this._events[event] = this._events[event].filter((h: any) => h !== handler); + } + }, + on(event: string, handler: (...args: any[]) => void) { + if (!this._events[event]) this._events[event] = []; + this._events[event].push(handler); + }, + publicKey: { + toBase58: () => pubkeyB58, + toBytes: () => kp.publicKey, + toString: () => pubkeyB58, + }, + removeListener(event: string, handler: (...args: any[]) => void) { + this.off(event, handler); + }, + signAllTransactions: async (txs: any[]) => + await Promise.all(txs.map((tx: any) => (window as any).solana.signTransaction(tx))), + signMessage: async (msg: Uint8Array) => ({ + signature: new Uint8Array(nacl.sign.detached(msg, kp.secretKey)), + }), + signTransaction: async (tx: any) => { + const msgBytes = new Uint8Array(tx.message.serialize()); + const sig = nacl.sign.detached(msgBytes, kp.secretKey); + tx.signatures[0] = new Uint8Array(sig); + return tx; + }, + }; + + return pubkeyB58; + }, walletKeyBase58); + + return pubkey; +} + +/** + * Opens the wallet modal and selects "Phantom Detected". + * + * Must be called after injectWallet(). The adapter captures window.solana.signTransaction + * at connect time, so this must happen after injection — not before. + */ +export async function connectWallet(page: Page): Promise { + const connectBtn = page.getByRole('button', { name: /Select Wallet|Connect Wallet/ }); + await connectBtn.click(); + await page.getByRole('button', { name: /Phantom.*Detected/i }).click(); + await page.getByRole('button', { name: /Disconnect/i }).waitFor({ timeout: 8000 }); +} diff --git a/apps/web/e2e/multidelegator-ui.spec.ts b/apps/web/e2e/multidelegator-ui.spec.ts new file mode 100644 index 0000000..9a19213 --- /dev/null +++ b/apps/web/e2e/multidelegator-ui.spec.ts @@ -0,0 +1,305 @@ +/** + * E2E tests for the Multi-Delegator devnet UI. + * + * Tests run serially and share on-chain state across the suite. + * Required env vars in .env at repo root: + * PLAYRIGHT_WALLET — base58-encoded 64-byte secret key for the test wallet + * PLAYWRIGHT_TOKEN_MINT — Token-2022 devnet mint the wallet holds an ATA for + * + * Optional: + * APP_URL — defaults to http://localhost:3000 + * + * The test wallet must have devnet SOL for rent and a Token-2022 ATA for + * PLAYWRIGHT_TOKEN_MINT (balance can be zero for delegation tests; non-zero + * required for TransferFixed / TransferRecurring / TransferSubscription). + */ +import { expect, type Page, test } from '@playwright/test'; + +import { connectWallet, injectWallet } from './helpers/wallet'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const TOKEN_MINT = process.env.PLAYWRIGHT_TOKEN_MINT ?? ''; + +// ─── Shared state (populated by earlier tests) ─────────────────────────────── + +// One year from test start — used wherever the program requires a future expiry timestamp. +const ONE_YEAR_FROM_NOW = String(Math.floor(Date.now() / 1000) + 365 * 24 * 3600); +const NOW_TS = String(Math.floor(Date.now() / 1000)); +// Unique plan ID per test run so re-runs don't collide with existing on-chain accounts. +// Uses seconds since epoch mod 1_000_000 to stay within u64 range while being human-readable. +const PLAN_ID = String(Math.floor(Date.now() / 1000) % 1_000_000); + +let walletAddress = ''; +let delegationPda = ''; +let planPda = ''; +let subscriptionPda = ''; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Navigate to a panel via the sidebar. + * `navLabel` is the sidebar button text; `headingName` is the h2 on the panel. + * When they match, only one argument is needed. + */ +async function openPanel(page: Page, headingName: string, navLabel?: string): Promise { + await page.getByRole('button', { exact: true, name: navLabel ?? headingName }).click(); + await expect(page.getByRole('heading', { level: 2, name: headingName })).toBeVisible(); +} + +/** Click the nth Autofill button on the active panel. */ +async function autofill(page: Page, nth = 0): Promise { + await page.getByRole('button', { name: 'Autofill' }).nth(nth).click(); +} + +/** + * Clicks "Send Transaction" and waits for a new entry in Recent Transactions. + * Snapshots the count before clicking to avoid TOCTOU races on fast devnet confirms. + * Returns 'success' | 'failed'. + */ +async function sendAndWait(page: Page): Promise<'failed' | 'success'> { + const heading = page.getByRole('heading', { name: /Recent Transactions/ }); + + const beforeText = (await heading.textContent({ timeout: 500 }).catch(() => '')) ?? ''; + const beforeCount = parseInt(beforeText.match(/\d+/)?.[0] ?? '0'); + + await page.getByRole('button', { name: 'Send Transaction' }).click(); + + await expect(async () => { + const text = (await heading.textContent()) ?? ''; + const count = parseInt(text.match(/\d+/)?.[0] ?? '0'); + expect(count).toBeGreaterThan(beforeCount); + }).toPass({ intervals: [500, 1000, 2000], timeout: 45_000 }); + + if (await page.getByText('Success', { exact: true }).last().isVisible()) return 'success'; + return 'failed'; +} + +// ─── Suite setup ───────────────────────────────────────────────────────────── + +test.describe('Multi-Delegator UI', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + + test.beforeAll(async ({ browser }) => { + const walletKey = process.env.PLAYRIGHT_WALLET; + if (!walletKey) throw new Error('PLAYRIGHT_WALLET env var is not set'); + if (!TOKEN_MINT) throw new Error('PLAYWRIGHT_TOKEN_MINT env var is not set'); + + page = await browser.newPage(); + await page.goto('/'); + walletAddress = await injectWallet(page, walletKey); + await connectWallet(page); + }); + + test.afterAll(async () => { + await page.close(); + }); + + // ─── Init Multi-Delegate ───────────────────────────────────────────────── + + test('Init Multi-Delegate — succeeds and saves MultiDelegate PDA to QuickDefaults', async () => { + await openPanel(page, 'Init Multi-Delegate'); + await page.getByRole('textbox', { name: 'Token Mint' }).fill(TOKEN_MINT); + + expect(await sendAndWait(page)).toBe('success'); + + const defaultMultiDelegate = page.getByRole('combobox', { name: 'Default MultiDelegate' }); + await expect(defaultMultiDelegate).not.toHaveValue(''); + const saved = await defaultMultiDelegate.inputValue(); + expect(saved.length).toBeGreaterThanOrEqual(32); + expect(saved.length).toBeLessThanOrEqual(44); + + await expect(page.getByRole('combobox', { name: 'Default Mint' })).toHaveValue(TOKEN_MINT); + await expect(page.locator('text=1 saved').first()).toBeVisible(); + }); + + // ─── Create Fixed Delegation ───────────────────────────────────────────── + + test('Create Fixed Delegation — succeeds and saves Delegation PDA to QuickDefaults', async () => { + await openPanel(page, 'Create Fixed Delegation'); + await autofill(page, 0); // Mint + // Delegatee = connected wallet (delegator = delegatee for test simplicity) + await page.getByRole('textbox', { name: 'Delegatee' }).fill(walletAddress); + await page.getByRole('spinbutton', { name: 'Nonce' }).fill('0'); + await page.getByRole('spinbutton', { name: 'Amount' }).fill('1000000'); + await page.getByRole('spinbutton', { name: 'Expiry Timestamp' }).fill(ONE_YEAR_FROM_NOW); + + expect(await sendAndWait(page)).toBe('success'); + + const defaultDelegation = page.getByRole('combobox', { name: 'Default Delegation' }); + await expect(defaultDelegation).not.toHaveValue(''); + delegationPda = await defaultDelegation.inputValue(); + expect(delegationPda.length).toBeGreaterThanOrEqual(32); + expect(delegationPda.length).toBeLessThanOrEqual(44); + + await expect(page.getByRole('combobox', { name: 'Default Delegatee' })).toHaveValue(walletAddress); + }); + + // ─── Revoke Delegation ─────────────────────────────────────────────────── + + test('Revoke Delegation — succeeds for the fixed delegation', async () => { + await openPanel(page, 'Revoke Delegation'); + await autofill(page); // Delegation Account + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Create Recurring Delegation ───────────────────────────────────────── + + test('Create Recurring Delegation — succeeds and overwrites Delegation PDA in QuickDefaults', async () => { + await openPanel(page, 'Create Recurring Delegation'); + await autofill(page, 0); // Mint + await page.getByRole('textbox', { name: 'Delegatee' }).fill(walletAddress); + await page.getByRole('spinbutton', { name: 'Nonce' }).fill('0'); + await page.getByRole('spinbutton', { name: 'Amount Per Period' }).fill('500000'); + await page.getByRole('spinbutton', { name: 'Period Length (seconds)' }).fill('86400'); + await page.getByRole('spinbutton', { name: 'Expiry Timestamp' }).fill(ONE_YEAR_FROM_NOW); + await page.getByRole('spinbutton', { name: 'Start Timestamp' }).fill(NOW_TS); + + expect(await sendAndWait(page)).toBe('success'); + + const defaultDelegation = page.getByRole('combobox', { name: 'Default Delegation' }); + await expect(defaultDelegation).not.toHaveValue(''); + delegationPda = await defaultDelegation.inputValue(); + }); + + // ─── Revoke Recurring Delegation ───────────────────────────────────────── + + test('Revoke Delegation — succeeds for the recurring delegation', async () => { + await openPanel(page, 'Revoke Delegation'); + await autofill(page); // Delegation Account (now points to recurring) + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Close Multi-Delegate ──────────────────────────────────────────────── + + test('Close Multi-Delegate — succeeds once all delegations are revoked', async () => { + await openPanel(page, 'Close Multi-Delegate'); + await autofill(page); // Mint + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Create Plan ───────────────────────────────────────────────────────── + // Re-init multi-delegate before subscribe test + + test('Init Multi-Delegate (second) — re-initialises for subscription tests', async () => { + await openPanel(page, 'Init Multi-Delegate'); + await autofill(page); // Mint (from QuickDefaults) + + expect(await sendAndWait(page)).toBe('success'); + }); + + test('Create Plan — succeeds and saves Plan PDA to QuickDefaults', async () => { + await openPanel(page, 'Create Plan'); + await page.getByRole('spinbutton', { name: 'Plan ID' }).fill(PLAN_ID); + await autofill(page); // Mint + await page.getByRole('spinbutton', { name: 'Amount' }).fill('1000000'); + await page.getByRole('spinbutton', { name: 'Period Hours' }).fill('24'); + await page.getByRole('spinbutton', { name: 'End Timestamp' }).fill('0'); + + expect(await sendAndWait(page)).toBe('success'); + + const defaultPlan = page.getByRole('combobox', { name: 'Default Plan' }); + await expect(defaultPlan).not.toHaveValue(''); + planPda = await defaultPlan.inputValue(); + expect(planPda.length).toBeGreaterThanOrEqual(32); + expect(planPda.length).toBeLessThanOrEqual(44); + }); + + // ─── Update Plan ───────────────────────────────────────────────────────── + + test('Update Plan — succeeds setting metadata URI', async () => { + await openPanel(page, 'Update Plan'); + await autofill(page); // Plan PDA + await page.getByRole('textbox', { name: 'Metadata URI' }).fill('https://multidelegator.test/plan.json'); + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Subscribe ─────────────────────────────────────────────────────────── + + test('Subscribe — succeeds and saves Subscription PDA to QuickDefaults', async () => { + await openPanel(page, 'Subscribe'); + // Merchant = connected wallet (subscribed to own plan for test purposes) + await page.getByRole('textbox', { name: 'Merchant' }).fill(walletAddress); + await page.getByRole('spinbutton', { name: 'Plan ID' }).fill(PLAN_ID); + await autofill(page); // Token Mint + + expect(await sendAndWait(page)).toBe('success'); + + const defaultSubscription = page.getByRole('combobox', { name: 'Default Subscription' }); + await expect(defaultSubscription).not.toHaveValue(''); + subscriptionPda = await defaultSubscription.inputValue(); + expect(subscriptionPda.length).toBeGreaterThanOrEqual(32); + expect(subscriptionPda.length).toBeLessThanOrEqual(44); + }); + + // ─── Cancel Subscription ───────────────────────────────────────────────── + + test('Cancel Subscription — succeeds', async () => { + await openPanel(page, 'Cancel Subscription'); + await autofill(page, 0); // Plan PDA + await autofill(page, 1); // Subscription PDA + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── Update Plan to Sunset ─────────────────────────────────────────────── + + test('Update Plan — succeeds setting status to Sunset', async () => { + await openPanel(page, 'Update Plan'); + await autofill(page); // Plan PDA + await page.getByRole('combobox', { name: 'Status' }).selectOption('Sunset'); + // Sunset requires a non-zero future end timestamp. + await page.getByRole('spinbutton', { name: 'End Timestamp' }).fill(ONE_YEAR_FROM_NOW); + + expect(await sendAndWait(page)).toBe('success'); + }); + + // ─── UI components ─────────────────────────────────────────────────────── + + test.describe('UI components', () => { + test('RPC badge opens dropdown with network presets and custom URL input', async () => { + await page.getByRole('button', { name: /Devnet/ }).click(); + await expect(page.getByRole('button', { name: /Mainnet/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Testnet/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /Localhost/i })).toBeVisible(); + await expect(page.getByRole('textbox', { name: /my-rpc/i })).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + test('Program badge opens with editable program ID', async () => { + await page.getByRole('button', { name: /Default Program/ }).click(); + await expect(page.getByRole('button', { name: 'Set Program ID' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Use Default' })).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + test('QuickDefaults Clear Saved removes all saved values', async () => { + await expect(page.getByRole('combobox', { name: 'Default Mint' })).not.toHaveValue(''); + + await page.getByRole('button', { name: 'Clear Saved' }).click(); + + await expect(page.getByRole('combobox', { name: 'Default Mint' })).toHaveValue(''); + await expect(page.getByRole('combobox', { name: 'Default Plan' })).toHaveValue(''); + await expect(page.getByRole('combobox', { name: 'Default Subscription' })).toHaveValue(''); + await expect(page.locator('text=0 saved').first()).toBeVisible(); + }); + + test('RecentTransactions shows all transactions with View Explorer links', async () => { + const heading = page.getByRole('heading', { name: /Recent Transactions \(\d+\)/ }); + await expect(heading).toBeVisible(); + + const count = parseInt((await heading.textContent())!.match(/\d+/)![0]); + // Init×2, CreateFixed, Revoke×2, CreateRecurring, CloseMultiDelegate, + // CreatePlan, UpdatePlan×2, Subscribe, CancelSubscription + expect(count).toBeGreaterThanOrEqual(10); + + await expect(page.getByRole('button', { name: 'View Explorer' }).first()).toBeVisible(); + }); + }); +}); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8009df9..9a609e0 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,11 @@ import type { NextConfig } from 'next'; +import { resolve } from 'path'; const nextConfig: NextConfig = { - transpilePackages: ['@multidelegator/client'], + transpilePackages: ['@multidelegator/client'], + turbopack: { + root: resolve(process.cwd(), '../..'), + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 9b9a5bb..6a259fe 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@base-ui/react": "^1.1.0", @@ -14,6 +16,7 @@ "@solana-program/token": "^0.9.0", "@solana/design-system": "^1.0.0", "@solana/kit": "^5.3.0", + "@vercel/analytics": "^2.0.1", "@solana/wallet-adapter-react": "^0.15.39", "@solana/wallet-adapter-react-ui": "^0.9.39", "@solana/wallet-adapter-wallets": "^0.19.37", @@ -31,6 +34,8 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "tailwindcss": "^4.2.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "@playwright/test": "^1.50.0", + "dotenv": "^16.4.7" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..d45513f --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +export default defineConfig({ + projects: [ + { + name: 'chromium', + use: { channel: 'chromium' }, + }, + ], + reporter: [['list'], ['html', { open: 'never' }]], + retries: 0, + testDir: './e2e', + timeout: 60_000, + use: { + baseURL: process.env.APP_URL ?? 'http://localhost:3000', + headless: true, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + workers: 1, +}); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index e6612f0..55512b7 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { Analytics } from '@vercel/analytics/next'; import './globals.css'; import '@solana/wallet-adapter-react-ui/styles.css'; import { Providers } from '@/components/Providers'; @@ -13,6 +14,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + ); diff --git a/apps/web/src/components/QuickDefaults.tsx b/apps/web/src/components/QuickDefaults.tsx index c060076..ef2860f 100644 --- a/apps/web/src/components/QuickDefaults.tsx +++ b/apps/web/src/components/QuickDefaults.tsx @@ -36,10 +36,13 @@ function SavedField({ label, value, onChange, onSave, savedValues, datalistId, p export function QuickDefaults() { const { - defaultDelegatee, defaultMultiDelegate, defaultDelegation, defaultMint, defaultPlan, - delegatees, multiDelegates, delegations, mints, plans, - setDefaultDelegatee, setDefaultMultiDelegate, setDefaultDelegation, setDefaultMint, setDefaultPlan, - rememberDelegatee, rememberMultiDelegate, rememberDelegation, rememberMint, rememberPlan, + defaultDelegatee, defaultMultiDelegate, defaultDelegation, + defaultMint, defaultPlan, defaultSubscription, + delegatees, multiDelegates, delegations, mints, plans, subscriptions, + setDefaultDelegatee, setDefaultMultiDelegate, setDefaultDelegation, + setDefaultMint, setDefaultPlan, setDefaultSubscription, + rememberDelegatee, rememberMultiDelegate, rememberDelegation, + rememberMint, rememberPlan, rememberSubscription, clearSavedValues, } = useSavedValues(); @@ -50,16 +53,18 @@ export function QuickDefaults() {
- - - - - +
); diff --git a/apps/web/src/contexts/SavedValuesContext.tsx b/apps/web/src/contexts/SavedValuesContext.tsx index 18033d8..e78307b 100644 --- a/apps/web/src/contexts/SavedValuesContext.tsx +++ b/apps/web/src/contexts/SavedValuesContext.tsx @@ -11,11 +11,13 @@ interface SavedValuesState { defaultDelegation: string; defaultMint: string; defaultPlan: string; + defaultSubscription: string; delegatees: string[]; multiDelegates: string[]; delegations: string[]; mints: string[]; plans: string[]; + subscriptions: string[]; } const INITIAL_STATE: SavedValuesState = { @@ -24,11 +26,13 @@ const INITIAL_STATE: SavedValuesState = { defaultDelegation: '', defaultMint: '', defaultPlan: '', + defaultSubscription: '', delegatees: [], multiDelegates: [], delegations: [], mints: [], plans: [], + subscriptions: [], }; interface SavedValuesContextType extends SavedValuesState { @@ -37,11 +41,13 @@ interface SavedValuesContextType extends SavedValuesState { setDefaultDelegation: (v: string) => void; setDefaultMint: (v: string) => void; setDefaultPlan: (v: string) => void; + setDefaultSubscription: (v: string) => void; rememberDelegatee: (v: string) => void; rememberMultiDelegate: (v: string) => void; rememberDelegation: (v: string) => void; rememberMint: (v: string) => void; rememberPlan: (v: string) => void; + rememberSubscription: (v: string) => void; clearSavedValues: () => void; } @@ -70,11 +76,13 @@ function readFromStorage(): SavedValuesState { defaultDelegation: ss(p.defaultDelegation), defaultMint: ss(p.defaultMint), defaultPlan: ss(p.defaultPlan), + defaultSubscription: ss(p.defaultSubscription), delegatees: sa(p.delegatees), multiDelegates: sa(p.multiDelegates), delegations: sa(p.delegations), mints: sa(p.mints), plans: sa(p.plans), + subscriptions: sa(p.subscriptions), }; } catch { return INITIAL_STATE; @@ -93,6 +101,7 @@ export function SavedValuesProvider({ children }: { children: React.ReactNode }) const setDefaultDelegation = useCallback((v: string) => setState(s => ({ ...s, defaultDelegation: normalize(v) })), []); const setDefaultMint = useCallback((v: string) => setState(s => ({ ...s, defaultMint: normalize(v) })), []); const setDefaultPlan = useCallback((v: string) => setState(s => ({ ...s, defaultPlan: normalize(v) })), []); + const setDefaultSubscription = useCallback((v: string) => setState(s => ({ ...s, defaultSubscription: normalize(v) })), []); const rememberDelegatee = useCallback((v: string) => setState(s => { const n = normalize(v); if (!n) return s; @@ -114,16 +123,26 @@ export function SavedValuesProvider({ children }: { children: React.ReactNode }) const n = normalize(v); if (!n) return s; return { ...s, defaultPlan: n, plans: addUnique(s.plans, n) }; }), []); + const rememberSubscription = useCallback((v: string) => setState(s => { + const n = normalize(v); if (!n) return s; + return { ...s, defaultSubscription: n, subscriptions: addUnique(s.subscriptions, n) }; + }), []); const clearSavedValues = useCallback(() => setState(INITIAL_STATE), []); const ctx = useMemo(() => ({ ...state, - setDefaultDelegatee, setDefaultMultiDelegate, setDefaultDelegation, setDefaultMint, setDefaultPlan, - rememberDelegatee, rememberMultiDelegate, rememberDelegation, rememberMint, rememberPlan, + setDefaultDelegatee, setDefaultMultiDelegate, setDefaultDelegation, + setDefaultMint, setDefaultPlan, setDefaultSubscription, + rememberDelegatee, rememberMultiDelegate, rememberDelegation, + rememberMint, rememberPlan, rememberSubscription, clearSavedValues, - }), [state, setDefaultDelegatee, setDefaultMultiDelegate, setDefaultDelegation, setDefaultMint, setDefaultPlan, - rememberDelegatee, rememberMultiDelegate, rememberDelegation, rememberMint, rememberPlan, clearSavedValues]); + }), [state, + setDefaultDelegatee, setDefaultMultiDelegate, setDefaultDelegation, + setDefaultMint, setDefaultPlan, setDefaultSubscription, + rememberDelegatee, rememberMultiDelegate, rememberDelegation, + rememberMint, rememberPlan, rememberSubscription, + clearSavedValues]); return {children}; } diff --git a/apps/web/src/instructions/CancelSubscription.tsx b/apps/web/src/instructions/CancelSubscription.tsx index bf6f7ff..d295380 100644 --- a/apps/web/src/instructions/CancelSubscription.tsx +++ b/apps/web/src/instructions/CancelSubscription.tsx @@ -11,14 +11,15 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function CancelSubscription() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); - const { defaultPlan } = useSavedValues(); + const { send, sending, error, signature, reset } = useSendTx(); + const { defaultPlan, defaultSubscription } = useSavedValues(); - const [planPda, setPlanPda] = useState(defaultPlan); + const [planPda, setPlanPda] = useState(''); const [subscriptionPda, setSubscriptionPda] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -35,9 +36,13 @@ export function CancelSubscription() { } return ( -
- - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + + diff --git a/apps/web/src/instructions/CloseMultiDelegate.tsx b/apps/web/src/instructions/CloseMultiDelegate.tsx index c864177..7fff8ac 100644 --- a/apps/web/src/instructions/CloseMultiDelegate.tsx +++ b/apps/web/src/instructions/CloseMultiDelegate.tsx @@ -11,13 +11,14 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function CloseMultiDelegate() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); + const { send, sending, error, signature, reset } = useSendTx(); const { defaultMint } = useSavedValues(); - const [mint, setMint] = useState(defaultMint); + const [mint, setMint] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -29,8 +30,10 @@ export function CloseMultiDelegate() { } return ( -
- + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + diff --git a/apps/web/src/instructions/CreateFixedDelegation.tsx b/apps/web/src/instructions/CreateFixedDelegation.tsx index 04790ff..7fc6809 100644 --- a/apps/web/src/instructions/CreateFixedDelegation.tsx +++ b/apps/web/src/instructions/CreateFixedDelegation.tsx @@ -11,17 +11,18 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function CreateFixedDelegation() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); - const { defaultMint, defaultDelegatee } = useSavedValues(); + const { send, sending, error, signature, reset } = useSendTx(); + const { defaultMint, defaultDelegatee, rememberMint, rememberDelegatee, rememberDelegation } = useSavedValues(); - const [mint, setMint] = useState(defaultMint); - const [delegatee, setDelegatee] = useState(defaultDelegatee); + const [mint, setMint] = useState(''); + const [delegatee, setDelegatee] = useState(''); const [nonce, setNonce] = useState('0'); const [amount, setAmount] = useState(''); const [expiryTs, setExpiryTs] = useState('0'); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -35,19 +36,31 @@ export function CreateFixedDelegation() { programAddress: getProgramAddress(), }); - await send(instructions, { + const sig = await send(instructions, { action: 'CreateFixedDelegation', values: { mint: mint.trim(), delegatee: delegatee.trim(), delegationPda }, }); + if (sig) { + rememberMint(mint.trim()); + rememberDelegatee(delegatee.trim()); + rememberDelegation(delegationPda); + } } return ( -
- - - - - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + + + + + diff --git a/apps/web/src/instructions/CreatePlan.tsx b/apps/web/src/instructions/CreatePlan.tsx index 519703b..21e78ed 100644 --- a/apps/web/src/instructions/CreatePlan.tsx +++ b/apps/web/src/instructions/CreatePlan.tsx @@ -11,11 +11,11 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function CreatePlan() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); - const { defaultMint } = useSavedValues(); + const { send, sending, error, signature, reset } = useSendTx(); + const { defaultMint, rememberMint, rememberPlan } = useSavedValues(); const [planId, setPlanId] = useState('0'); - const [mint, setMint] = useState(defaultMint); + const [mint, setMint] = useState(''); const [amount, setAmount] = useState(''); const [periodHours, setPeriodHours] = useState(''); const [endTs, setEndTs] = useState('0'); @@ -29,6 +29,7 @@ export function CreatePlan() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -40,16 +41,26 @@ export function CreatePlan() { tokenProgram: TOKEN_2022_PROGRAM_ID, programAddress: getProgramAddress(), }); - await send(instructions, { action: 'CreatePlan', values: { mint: mint.trim(), planPda } }); + const sig = await send(instructions, { action: 'CreatePlan', values: { mint: mint.trim(), planPda } }); + if (sig) { + rememberMint(mint.trim()); + rememberPlan(planPda); + } } return ( -
- - - - - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + + + + + - - - - - - - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + + + + + + + diff --git a/apps/web/src/instructions/DeletePlan.tsx b/apps/web/src/instructions/DeletePlan.tsx index abd9d3a..ff5d8e1 100644 --- a/apps/web/src/instructions/DeletePlan.tsx +++ b/apps/web/src/instructions/DeletePlan.tsx @@ -11,13 +11,14 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function DeletePlan() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); + const { send, sending, error, signature, reset } = useSendTx(); const { defaultPlan } = useSavedValues(); - const [planPda, setPlanPda] = useState(defaultPlan); + const [planPda, setPlanPda] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -29,8 +30,10 @@ export function DeletePlan() { } return ( -
- + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + diff --git a/apps/web/src/instructions/InitMultiDelegate.tsx b/apps/web/src/instructions/InitMultiDelegate.tsx index c79266d..9809f5a 100644 --- a/apps/web/src/instructions/InitMultiDelegate.tsx +++ b/apps/web/src/instructions/InitMultiDelegate.tsx @@ -11,15 +11,16 @@ import { useSendTx } from '@/hooks/useSendTx'; import { FormField, SendButton, TxResultDisplay } from './shared'; export function InitMultiDelegate() { - const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); - const { defaultMint } = useSavedValues(); + const { createSigner, account } = useWallet(); + const { send, sending, error, signature, reset } = useSendTx(); + const { defaultMint, rememberMint, rememberMultiDelegate } = useSavedValues(); - const [mint, setMint] = useState(defaultMint); + const [mint, setMint] = useState(''); const [userAta, setUserAta] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -39,17 +40,23 @@ export function InitMultiDelegate() { tokenProgram, programAddress: getProgramAddress(), }); - await send(instructions, { + const sig = await send(instructions, { action: 'InitMultiDelegate', values: { mint: mintAddress, multiDelegate: multiDelegatePda }, }); + if (sig) { + rememberMint(mintAddress); + rememberMultiDelegate(multiDelegatePda); + } } return ( -
- + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + diff --git a/apps/web/src/instructions/RevokeDelegation.tsx b/apps/web/src/instructions/RevokeDelegation.tsx index a0ecb1a..e77d805 100644 --- a/apps/web/src/instructions/RevokeDelegation.tsx +++ b/apps/web/src/instructions/RevokeDelegation.tsx @@ -11,13 +11,14 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function RevokeDelegation() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); + const { send, sending, error, signature, reset } = useSendTx(); const { defaultDelegation } = useSavedValues(); - const [delegationAccount, setDelegationAccount] = useState(defaultDelegation); + const [delegationAccount, setDelegationAccount] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -34,8 +35,9 @@ export function RevokeDelegation() { } return ( - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> diff --git a/apps/web/src/instructions/Subscribe.tsx b/apps/web/src/instructions/Subscribe.tsx index a7f3fef..ea8fe26 100644 --- a/apps/web/src/instructions/Subscribe.tsx +++ b/apps/web/src/instructions/Subscribe.tsx @@ -11,15 +11,16 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function Subscribe() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); - const { defaultMint } = useSavedValues(); + const { send, sending, error, signature, reset } = useSendTx(); + const { defaultMint, rememberSubscription } = useSavedValues(); const [merchant, setMerchant] = useState(''); const [planId, setPlanId] = useState('0'); - const [tokenMint, setTokenMint] = useState(defaultMint); + const [tokenMint, setTokenMint] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -29,17 +30,22 @@ export function Subscribe() { programAddress: getProgramAddress(), }); - await send(instructions, { + const sig = await send(instructions, { action: 'Subscribe', values: { mint: tokenMint.trim(), subscriptionPda }, }); + if (sig) rememberSubscription(subscriptionPda); } return ( - - - - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + + + diff --git a/apps/web/src/instructions/TransferFixed.tsx b/apps/web/src/instructions/TransferFixed.tsx index 9962f01..0537ea0 100644 --- a/apps/web/src/instructions/TransferFixed.tsx +++ b/apps/web/src/instructions/TransferFixed.tsx @@ -12,17 +12,18 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function TransferFixed() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); + const { send, sending, error, signature, reset } = useSendTx(); const { defaultDelegation, defaultMint } = useSavedValues(); - const [delegationPda, setDelegationPda] = useState(defaultDelegation); + const [delegationPda, setDelegationPda] = useState(''); const [delegator, setDelegator] = useState(''); - const [tokenMint, setTokenMint] = useState(defaultMint); + const [tokenMint, setTokenMint] = useState(''); const [amount, setAmount] = useState(''); const [receiverAta, setReceiverAta] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -54,11 +55,17 @@ export function TransferFixed() { } return ( -
- - - - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + + + + diff --git a/apps/web/src/instructions/TransferRecurring.tsx b/apps/web/src/instructions/TransferRecurring.tsx index 864bc66..1ae4c6f 100644 --- a/apps/web/src/instructions/TransferRecurring.tsx +++ b/apps/web/src/instructions/TransferRecurring.tsx @@ -12,17 +12,18 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function TransferRecurring() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); + const { send, sending, error, signature, reset } = useSendTx(); const { defaultDelegation, defaultMint } = useSavedValues(); - const [delegationPda, setDelegationPda] = useState(defaultDelegation); + const [delegationPda, setDelegationPda] = useState(''); const [delegator, setDelegator] = useState(''); - const [tokenMint, setTokenMint] = useState(defaultMint); + const [tokenMint, setTokenMint] = useState(''); const [amount, setAmount] = useState(''); const [receiverAta, setReceiverAta] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -54,11 +55,17 @@ export function TransferRecurring() { } return ( - - - - - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + + + + diff --git a/apps/web/src/instructions/TransferSubscription.tsx b/apps/web/src/instructions/TransferSubscription.tsx index 90510f7..42e383f 100644 --- a/apps/web/src/instructions/TransferSubscription.tsx +++ b/apps/web/src/instructions/TransferSubscription.tsx @@ -12,18 +12,19 @@ import { FormField, SendButton, TxResultDisplay } from './shared'; export function TransferSubscription() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); - const { defaultPlan, defaultMint } = useSavedValues(); + const { send, sending, error, signature, reset } = useSendTx(); + const { defaultPlan, defaultMint, defaultSubscription } = useSavedValues(); const [subscriptionPda, setSubscriptionPda] = useState(''); - const [planPda, setPlanPda] = useState(defaultPlan); + const [planPda, setPlanPda] = useState(''); const [delegator, setDelegator] = useState(''); - const [tokenMint, setTokenMint] = useState(defaultMint); + const [tokenMint, setTokenMint] = useState(''); const [amount, setAmount] = useState(''); const [receiverAta, setReceiverAta] = useState(''); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -52,12 +53,20 @@ export function TransferSubscription() { } return ( - - - - - - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + + + + + diff --git a/apps/web/src/instructions/UpdatePlan.tsx b/apps/web/src/instructions/UpdatePlan.tsx index ece5483..2f277d2 100644 --- a/apps/web/src/instructions/UpdatePlan.tsx +++ b/apps/web/src/instructions/UpdatePlan.tsx @@ -11,10 +11,10 @@ import { FormField, SelectField, SendButton, TxResultDisplay } from './shared'; export function UpdatePlan() { const { createSigner } = useWallet(); - const { send, sending, error, signature } = useSendTx(); + const { send, sending, error, signature, reset } = useSendTx(); const { defaultPlan } = useSavedValues(); - const [planPda, setPlanPda] = useState(defaultPlan); + const [planPda, setPlanPda] = useState(''); const [statusKey, setStatusKey] = useState<'Active' | 'Sunset'>('Active'); const [endTs, setEndTs] = useState('0'); const [metadataUri, setMetadataUri] = useState(''); @@ -26,6 +26,7 @@ export function UpdatePlan() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + reset(); const signer = createSigner(); if (!signer) return; @@ -40,13 +41,17 @@ export function UpdatePlan() { } return ( - - + { void handleSubmit(e); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> + setStatusKey(v as 'Active' | 'Sunset')} options={[{ label: 'Active', value: 'Active' }, { label: 'Sunset', value: 'Sunset' }]} /> - - + + diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 0000000..ad33663 --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,4 @@ +{ + "framework": "nextjs", + "installCommand": "cd ../.. && pnpm install --frozen-lockfile" +} diff --git a/justfile b/justfile index bed27c5..a6b3a53 100644 --- a/justfile +++ b/justfile @@ -131,6 +131,10 @@ build-client: generate-client # Run all tests test: test-program test-client +# Run E2E tests against the dev UI (requires PLAYRIGHT_WALLET and PLAYWRIGHT_TOKEN_MINT in .env) +e2e-test: + pnpm --filter @multidelegator/web test:e2e + # Run Rust program tests test-program: cd {{program_dir}} && cargo test-sbf diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57ec0f1..5a6e677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@solana/web3.js': specifier: ^1.98.4 version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@vercel/analytics': + specifier: ^2.0.1 + version: 2.0.1(next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) bs58: specifier: ^6.0.0 version: 6.0.0 @@ -82,7 +85,7 @@ importers: version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: ^16.1.6 - version: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -90,6 +93,9 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) devDependencies: + '@playwright/test': + specifier: ^1.50.0 + version: 1.58.2 '@tailwindcss/postcss': specifier: ^4.2.1 version: 4.2.2 @@ -102,6 +108,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + dotenv: + specifier: ^16.4.7 + version: 16.6.1 tailwindcss: specifier: ^4.2.1 version: 4.2.2 @@ -1443,6 +1452,11 @@ packages: '@solana/web3.js': ^1.50.1 bs58: ^4.0.1 + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@project-serum/sol-wallet-adapter@0.2.6': resolution: {integrity: sha512-cpIb13aWPW8y4KzkZAPDgw+Kb+DXjCC6rZoH74MGm3I/6e/zKyGnfAuW5olb2zxonFqsYgnv7ev8MQnvSgJ3/g==} engines: {node: '>=10'} @@ -4517,6 +4531,35 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vercel/analytics@2.0.1': + resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + '@vitejs/plugin-react@5.1.4': resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5460,6 +5503,10 @@ packages: resolution: {integrity: sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==} engines: {node: '>=10'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + draggabilly@3.0.0: resolution: {integrity: sha512-aEs+B6prbMZQMxc9lgTpCBfyCUhRur/VFucHhIOvlvvdARTj7TcDmX/cdOUtqbjJJUh7+agyJXR5Z6IFe1MxwQ==} @@ -9421,6 +9468,10 @@ snapshots: '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) bs58: 6.0.0 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@project-serum/sol-wallet-adapter@0.2.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))': dependencies: '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -13530,6 +13581,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vercel/analytics@2.0.1(next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + optionalDependencies: + next: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 @@ -15048,6 +15104,8 @@ snapshots: domain-browser@4.22.0: {} + dotenv@16.6.1: {} + draggabilly@3.0.0: dependencies: get-size: 3.0.0 @@ -16457,7 +16515,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.0 '@swc/helpers': 0.5.15 @@ -16476,6 +16534,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.0 '@next/swc-win32-arm64-msvc': 16.2.0 '@next/swc-win32-x64-msvc': 16.2.0 + '@playwright/test': 1.58.2 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core'