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({
)}
+
+
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"
+ />
+
+