Skip to content
Open
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
64 changes: 64 additions & 0 deletions app/frontend/e2e/claim-receipt-fcp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { test, expect, type CDPSession } from '@playwright/test';

// Chrome DevTools' "Slow 3G" network preset — the standard baseline for
// judging first-contentful-paint on weak mobile networks (see issue #292).
const SLOW_3G = {
offline: false,
downloadThroughput: (500 * 1024) / 8, // 500 kb/s
uploadThroughput: (500 * 1024) / 8,
latency: 400, // ms
};

const FCP_BUDGET_MS = 1000;

async function throttleToSlow3G(client: CDPSession) {
await client.send('Network.enable');
await client.send('Network.emulateNetworkConditions', SLOW_3G);
// A mid-tier/low-end phone CPU is part of "weak mobile networks" in the
// field — 4x slowdown approximates a budget Android device.
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
}

async function getFirstContentfulPaint(page: import('@playwright/test').Page) {
return page.evaluate(() => {
const entry = performance
.getEntriesByType('paint')
.find((e) => e.name === 'first-contentful-paint');
return entry ? entry.startTime : null;
});
}

test.describe('claim-receipt SSR performance', () => {
test('renders first contentful paint under 1s on a throttled 3G profile', async ({
page,
}) => {
const client = await page.context().newCDPSession(page);
await throttleToSlow3G(client);

await page.goto('/en/claim-receipt?claimId=mock-claim-123', {
waitUntil: 'load',
});

// The receipt heading should already be present in the server-rendered
// HTML — if this fails, the page fell back to a client-side loading
// spinner instead of SSR-ing the claim data.
await expect(page.getByRole('heading', { name: 'Claim Receipt' })).toBeVisible();

const fcp = await getFirstContentfulPaint(page);
expect(fcp, 'first-contentful-paint entry should exist').not.toBeNull();
expect(fcp as number).toBeLessThan(FCP_BUDGET_MS);
});

test('server-renders the receipt body without a client-side loading state', async ({
page,
}) => {
// No throttling here — this is a functional check that the HTML
// Playwright receives on first navigation already contains the claim
// amount, rather than "Loading your receipt…" from a CSR fetch.
const response = await page.goto('/en/claim-receipt?claimId=mock-claim-123');
const html = await response?.text();

expect(html).not.toContain('Loading your receipt');
expect(html).toContain('Claim Receipt');
});
});
5 changes: 5 additions & 0 deletions app/frontend/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ const config: Config = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
// Playwright's own test runner owns everything under e2e/ (see
// playwright.config.ts's testDir) — Jest's default testMatch would
// otherwise pick up *.spec.ts files there too and fail, since Playwright
// tests can only run via `playwright test`, not `jest`.
testPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/e2e/'],
};

export default config;
2 changes: 2 additions & 0 deletions app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"start": "next start",
"lint": "eslint",
"test": "jest",
"test:e2e": "playwright test",
"type-check": "tsc --noEmit",
"generate:api": "openapi-typescript openapi.json -o src/lib/generated/api.ts"
},
Expand Down Expand Up @@ -37,6 +38,7 @@
"zustand": "^5.0.10"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
Expand Down
35 changes: 35 additions & 0 deletions app/frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { defineConfig, devices } from '@playwright/test';

const PORT = process.env.E2E_PORT ?? '3100';
const baseURL = `http://127.0.0.1:${PORT}`;

export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL,
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
// Uses the production build so numbers reflect a real SSR response,
// not the dev server's on-demand compilation.
command: 'npm run build && npm run start -- -p ' + PORT,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000',
NEXT_PUBLIC_STELLAR_NETWORK: process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet',
NEXT_PUBLIC_USE_MOCKS: process.env.NEXT_PUBLIC_USE_MOCKS ?? 'true',
},
},
});
13 changes: 13 additions & 0 deletions app/frontend/src/app/[locale]/claim-receipt/BackButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';
import { useRouter } from 'next/navigation';
export function BackButton() {
const router = useRouter();
return (
<button
onClick={() => router.back()}
className="text-blue-600 dark:text-blue-400 hover:underline mb-4 flex items-center gap-2"
>
← Back
</button>
);
}
189 changes: 64 additions & 125 deletions app/frontend/src/app/[locale]/claim-receipt/page.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,78 @@
'use client';

import React, { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import React from 'react';
import { AlertCircle } from 'lucide-react';
import { ClaimReceipt, ClaimReceiptData } from '@/components/ClaimReceipt';
import { AlertCircle, Loader2 } from 'lucide-react';
import { LiveRegion } from '@/components/LiveRegion';
import { getStatusTransitionMessage } from '@/lib/status-messages';

export default function ClaimReceiptPage() {
const router = useRouter();
const searchParams = useSearchParams();
const claimId = searchParams.get('claimId');

const [claim, setClaim] = useState<ClaimReceiptData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const previousClaimRef = React.useRef<ClaimReceiptData | null>(null);
const [announcement, setAnnouncement] = useState('');
import { apiClient } from '@/lib/api-client';
import { BackButton } from './BackButton';

// This page is backed by /api/v1/claims/{id}/receipt, which is per-recipient
// data guarded by the backend's HttpCacheInterceptor (private, must-revalidate —
// see #32). It must be rendered fresh on every request rather than statically
// optimized, so the server always re-validates against that header instead of
// serving a stale Next.js Data/Full Route Cache entry.
export const dynamic = 'force-dynamic';

interface PageProps {
searchParams: Promise<{ claimId?: string }>;
}

useEffect(() => {
if (previousClaimRef.current && claim && previousClaimRef.current.status !== claim.status) {
setAnnouncement(getStatusTransitionMessage('Claim', previousClaimRef.current.status, claim.status));
}
previousClaimRef.current = claim;
}, [claim]);
function ErrorState({ message }: { message: string }) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 flex gap-4">
<AlertCircle className="text-red-600 dark:text-red-400 flex-shrink-0" size={24} />
<div>
<h2 className="font-semibold text-red-900 dark:text-red-100 mb-1">
Error
</h2>
<p className="text-red-800 dark:text-red-200">{message}</p>
</div>
</div>
);
}

useEffect(() => {
if (!claimId) {
setError('Claim ID not provided');
setLoading(false);
return;
async function loadClaim(
claimId: string,
): Promise<{ claim: ClaimReceiptData | null; error: string | null }> {
try {
const { data, error, response } = await apiClient.GET(
'/api/v1/claims/{id}/receipt',
{
params: { path: { id: claimId } },
// Never let the Next.js server-side data cache serve a stale receipt —
// the backend's own Cache-Control header (private, must-revalidate)
// governs revalidation once it reaches the browser/CDN.
cache: 'no-store',
} as Parameters<typeof apiClient.GET>[1],
);

if (error || !data) {
if (response?.status === 404) {
return { claim: null, error: 'Claim receipt not found' };
}
return { claim: null, error: 'Failed to load claim receipt' };
}

const loadClaim = async () => {
try {
setLoading(true);
// TODO: Replace with actual API call
// const response = await fetch(`/api/claims/${claimId}/receipt`);
// if (!response.ok) throw new Error('Failed to load receipt');
// const data: ClaimReceiptData = await response.json();

// Mock data for now
const data: ClaimReceiptData = {
claimId,
packageId: 'pkg-' + Math.random().toString(36).substr(2, 9),
status: 'disbursed',
amount: 150.5,
tokenAddress:
'GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN',
transactionHash:
'439d564ab3b4df9d1d1f057bb081f9a26be4cd8cf9d564ab3b4df9d1d1f057bb',
contractAddress:
'CDA4BEYKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN',
timestamp: new Date().toISOString(),
};

setClaim(data);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to load claim receipt',
);
} finally {
setLoading(false);
}
return { claim: data as ClaimReceiptData, error: null };
} catch (err) {
return {
claim: null,
error: err instanceof Error ? err.message : 'Failed to load claim receipt',
};
}
}

void loadClaim();
}, [claimId]);

const handleShare = async () => {
if (!claim) return;
export default async function ClaimReceiptPage({ searchParams }: PageProps) {
const { claimId } = await searchParams;

try {
// Try Web Share API first
if (navigator.share) {
await navigator.share({
title: 'Claim Receipt',
text: `Claim ${claim.claimId} - ${claim.status}`,
});
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Share failed:', err);
}
}
};
const { claim, error } = claimId
? await loadClaim(claimId)
: { claim: null, error: 'Claim ID not provided' };

return (
<main className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 py-8 px-4">
<LiveRegion message={announcement} />
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="text-blue-600 dark:text-blue-400 hover:underline mb-4 flex items-center gap-2"
>
<span aria-hidden="true">←</span> Back
</button>
<BackButton />
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
Claim Receipt
</h1>
Expand All @@ -108,47 +81,13 @@ export default function ClaimReceiptPage() {
</p>
</div>

{/* Loading State */}
{loading && (
<div
role="status"
className="bg-white dark:bg-slate-800 rounded-lg shadow p-8 text-center"
>
<Loader2
aria-hidden="true"
className="inline-block animate-spin text-blue-600 dark:text-blue-400 mb-4"
size={32}
/>
<p className="text-slate-600 dark:text-slate-400">
Loading your receipt…
</p>
</div>
)}

{/* Error State */}
{error && (
<div
role="alert"
className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 flex gap-4"
>
<AlertCircle
aria-hidden="true"
className="text-red-600 dark:text-red-400 flex-shrink-0"
size={24}
/>
<div>
<h2 className="font-semibold text-red-900 dark:text-red-100 mb-1">
Error
</h2>
<p className="text-red-800 dark:text-red-200">{error}</p>
</div>
</div>
)}
{error && <ErrorState message={error} />}

{/* Receipt Card */}
{!loading && claim && (
{!error && claim && (
<div className="space-y-4">
<ClaimReceipt claim={claim} onShare={handleShare} />
<ClaimReceipt claim={claim} />

{/* Additional Information */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 border border-slate-200 dark:border-slate-700">
Expand Down
10 changes: 9 additions & 1 deletion app/frontend/src/components/ClaimReceipt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
export interface ClaimReceiptData {
claimId: string;
packageId: string;
status: 'requested' | 'verified' | 'approved' | 'disbursed' | 'archived';
status:
| 'requested'
| 'verified'
| 'approved'
| 'disbursed'
| 'archived'
| 'cancelled';
amount: number;
tokenAddress?: string;
transactionHash?: string;
Expand All @@ -29,7 +35,7 @@
onShare,
compact = false,
}) => {
const { theme } = useTheme();

Check warning on line 38 in app/frontend/src/components/ClaimReceipt.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

'theme' is assigned a value but never used
const [copied, setCopied] = React.useState(false);
const [sharing, setSharing] = React.useState(false);

Expand All @@ -39,6 +45,7 @@
approved: 'bg-green-50 border-green-200 text-green-900',
disbursed: 'bg-emerald-50 border-emerald-200 text-emerald-900',
archived: 'bg-gray-50 border-gray-200 text-gray-900',
cancelled: 'bg-red-50 border-red-200 text-red-900',
};

const statusBadgeColors = {
Expand All @@ -47,6 +54,7 @@
approved: 'bg-green-100 text-green-800',
disbursed: 'bg-emerald-100 text-emerald-800',
archived: 'bg-gray-100 text-gray-800',
cancelled: 'bg-red-100 text-red-800',
};

const formattedDate = useMemo(() => {
Expand Down
Loading
Loading