Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ dist/
reference/
*.local
package-lock.json
playwright-report/
test-results/
73 changes: 73 additions & 0 deletions e2e/payment-link.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function App() {
<Routes>
<Route path="/send" element={<Send />} />
<Route path="/receive" element={<Receive />} />
<Route path="/pay" element={<Send />} />
<Route path="*" element={<Navigate to="/send" replace />} />
</Routes>
</main>
Expand Down
115 changes: 115 additions & 0 deletions src/components/StellarPaymentLink.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="border border-outline-variant bg-surface-container p-5">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
Receive Payment
</span>
<button
onClick={() => setShowForm(!showForm)}
className="font-mono text-[10px] font-semibold uppercase tracking-widest text-primary transition-colors hover:brightness-110"
>
{showForm ? 'Close' : 'Generate Payment Link'}
</button>
</div>

{showForm && (
<div className="mt-4 flex flex-col gap-4 border-t border-outline-variant/30 pt-4">
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Amount (optional)
</label>
<input
type="text"
value={amount}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Memo (optional)
</label>
<input
type="text"
value={memo}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Expires In
</label>
<select
value={expiresIn}
onChange={(e) => setExpiresIn(e.target.value)}
className="h-10 border border-outline-variant bg-surface px-3 font-mono text-sm text-primary focus:border-primary"
>
<option value="1h">1 hour</option>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="never">Never</option>
</select>
</div>
<button
onClick={handleGenerate}
className="mt-2 h-10 w-full bg-primary font-heading text-[11px] font-semibold uppercase tracking-widest text-surface transition-colors hover:brightness-110"
>
Generate Link
</button>
</div>
)}

{showForm && generatedUrl && (
<div className="mt-6 flex flex-col items-center gap-4 border-t border-outline-variant/30 pt-6">
<div className="rounded-lg bg-white p-3">
<QRCodeSVG value={generatedUrl} size={160} />
</div>
<div className="flex w-full items-center gap-2 rounded bg-surface p-2 border border-outline-variant">
<code className="block flex-1 truncate font-mono text-[10px] text-primary">
{generatedUrl}
</code>
<CopyButton text={generatedUrl} />
</div>
</div>
)}
</div>
);
}
3 changes: 3 additions & 0 deletions src/components/StellarReceiveView.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -134,6 +135,8 @@ export function StellarReceiveView({
)}
</div>

<StellarPaymentLink metaAddress={metaAddress} />

<div className="flex items-center justify-between">
<button
onClick={onScan}
Expand Down
Loading