diff --git a/.gitignore b/.gitignore index c30764c..793d482 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ reference/ *.local package-lock.json +playwright-report/ +test-results/ diff --git a/e2e/payment-link.spec.ts b/e2e/payment-link.spec.ts new file mode 100644 index 0000000..b63306c --- /dev/null +++ b/e2e/payment-link.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Stellar Payment Link', () => { + test.beforeEach(async ({ context }) => { + // Mock Freighter API so the app thinks a wallet is installed and connected + await context.addInitScript(() => { + (window as any).freighter = { + isConnected: () => Promise.resolve({ isConnected: true }), + getAddress: () => Promise.resolve({ address: 'GATTESTADDRESSYOURSFREIGHTER123456789' }), + requestAccess: () => Promise.resolve(), + }; + (window as any).freighterApi = (window as any).freighter; // Some versions use freighterApi + }); + }); + + test('should generate a link, pre-fill the send form, and disable inputs', async ({ page, context }) => { + // 1. Go to Receive page + await page.goto('/receive'); + await page.locator('select').selectOption('stellar'); + + // 2. Open generated link in a new context + const testUrl = '/pay?to=st:xlm:test_meta_address&amount=15.5&memo=TestMemo&exp=' + (Math.floor(Date.now() / 1000) + 3600); + + const newPage = await context.newPage(); + await newPage.goto(testUrl); + + // Switch to Stellar network + await newPage.locator('select').selectOption('stellar'); + + // Click Connect Freighter + await newPage.click('text=Connect Freighter'); + + // 3. Verify Send page pre-filled inputs + const recipientInput = newPage.locator('input[placeholder="st:xlm:..."]'); + await expect(recipientInput).toHaveValue('st:xlm:test_meta_address'); + await expect(recipientInput).toBeDisabled(); + + const amountInput = newPage.locator('input[placeholder="0.0"]'); + await expect(amountInput).toHaveValue('15.5'); + await expect(amountInput).toBeDisabled(); + + const memoInput = newPage.locator('input[placeholder="e.g. Coffee"]'); + await expect(memoInput).toHaveValue('TestMemo'); + await expect(memoInput).toBeDisabled(); + }); + + test('should show expiration error and disable submit for expired link', async ({ page }) => { + const expiredExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const testUrl = `/pay?to=st:xlm:test_meta_address&amount=10&exp=${expiredExp}`; + + await page.goto(testUrl); + + // Switch to Stellar network + await page.locator('select').selectOption('stellar'); + + // Click Connect Freighter + await page.click('text=Connect Freighter'); + + // Check for error message + const errorText = page.locator('text=This payment link has expired'); + await expect(errorText).toBeVisible(); + + // The recipient and amount should still be pre-filled and disabled + const recipientInput = page.locator('input[placeholder="st:xlm:..."]'); + await expect(recipientInput).toHaveValue('st:xlm:test_meta_address'); + await expect(recipientInput).toBeDisabled(); + + // Verify submit button is disabled + const submitButton = page.locator('button:has-text("Send Privately")'); + // Button might also say "Confirm in wallet..." but the text says "Send Privately" initially + await expect(submitButton).toBeDisabled(); + }); +}); diff --git a/package.json b/package.json index 70b9dc4..4517a4d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@wraith-protocol/sdk": "^1.4.5", "bs58": "^6.0.0", "buffer": "^6.0.3", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.0", @@ -40,6 +41,7 @@ "devDependencies": { "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "@playwright/test": "^1.60.0", "@storybook/addon-a11y": "^8", "@storybook/addon-essentials": "^8", "@storybook/addon-interactions": "^8", @@ -48,6 +50,7 @@ "@storybook/test": "^8.6.18", "@storybook/test-runner": "^0.19.1", "@types/jest-image-snapshot": "^6.4.1", + "@types/node": "^25.9.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.5.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a6d3579 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/App.tsx b/src/App.tsx index c163cfe..6f2e398 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ export function App() { } /> } /> + } /> } /> diff --git a/src/components/StellarPaymentLink.tsx b/src/components/StellarPaymentLink.tsx new file mode 100644 index 0000000..6d428fd --- /dev/null +++ b/src/components/StellarPaymentLink.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { QRCodeSVG } from 'qrcode.react'; +import { CopyButton } from '@/components/CopyButton'; + +export interface StellarPaymentLinkProps { + metaAddress: string; +} + +export function StellarPaymentLink({ metaAddress }: StellarPaymentLinkProps) { + const [showForm, setShowForm] = useState(false); + const [amount, setAmount] = useState(''); + const [memo, setMemo] = useState(''); + const [expiresIn, setExpiresIn] = useState('24h'); + const [generatedUrl, setGeneratedUrl] = useState(null); + + const handleGenerate = () => { + const url = new URL(window.location.origin + '/pay'); + url.searchParams.set('to', metaAddress); + if (amount) url.searchParams.set('amount', amount); + if (memo) url.searchParams.set('memo', memo); + + let expSecs = 0; + const nowSecs = Math.floor(Date.now() / 1000); + if (expiresIn === '1h') expSecs = nowSecs + 3600; + else if (expiresIn === '24h') expSecs = nowSecs + 86400; + else if (expiresIn === '7d') expSecs = nowSecs + 7 * 86400; + + if (expSecs > 0) { + url.searchParams.set('exp', expSecs.toString()); + } + + setGeneratedUrl(url.toString()); + }; + + return ( +
+
+ + Receive Payment + + +
+ + {showForm && ( +
+
+ + setAmount(e.target.value)} + placeholder="0.0" + className="h-10 border border-outline-variant bg-surface px-3 font-mono text-sm text-primary placeholder:text-outline focus:border-primary" + /> +
+
+ + setMemo(e.target.value)} + placeholder="e.g. Coffee" + maxLength={28} + className="h-10 border border-outline-variant bg-surface px-3 font-mono text-sm text-primary placeholder:text-outline focus:border-primary" + /> +
+
+ + +
+ +
+ )} + + {showForm && generatedUrl && ( +
+
+ +
+
+ + {generatedUrl} + + +
+
+ )} +
+ ); +} diff --git a/src/components/StellarReceiveView.tsx b/src/components/StellarReceiveView.tsx index 0f79ac6..b9ec73b 100644 --- a/src/components/StellarReceiveView.tsx +++ b/src/components/StellarReceiveView.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { stellarTxUrl } from '@/lib/explorer'; import { CopyButton } from '@/components/CopyButton'; +import { StellarPaymentLink } from '@/components/StellarPaymentLink'; export interface StellarReceiveViewProps { isConnected: boolean; @@ -134,6 +135,8 @@ export function StellarReceiveView({ )} + +
+ {!paramTo && !isExpired && ( + + )}

XLM @@ -142,6 +160,22 @@ export function StellarSendView({

+
+ + onMemoChange?.(e.target.value)} + placeholder="e.g. Coffee" + maxLength={28} + disabled={paramMemo || isExpired} + className="h-12 w-full border border-outline-variant bg-surface px-4 font-mono text-sm text-primary placeholder:text-outline focus:border-primary disabled:opacity-50" + /> +
+