From f1581977e0b7acc52463145f821f27fed7ee39f6 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Thu, 16 Jul 2026 10:12:58 +0300 Subject: [PATCH 01/14] Refactor ClaimReceiptPage and add BackButton component Refactored ClaimReceiptPage to include BackButton component and removed unused state and effect hooks. --- .../src/app/[locale]/claim-receipt/page.tsx | 179 +----------------- 1 file changed, 8 insertions(+), 171 deletions(-) diff --git a/app/frontend/src/app/[locale]/claim-receipt/page.tsx b/app/frontend/src/app/[locale]/claim-receipt/page.tsx index b7f054be..8a8164eb 100644 --- a/app/frontend/src/app/[locale]/claim-receipt/page.tsx +++ b/app/frontend/src/app/[locale]/claim-receipt/page.tsx @@ -1,178 +1,15 @@ 'use client'; -import React, { useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ClaimReceipt, ClaimReceiptData } from '@/components/ClaimReceipt'; -import { AlertCircle, Loader2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; -export default function ClaimReceiptPage() { +export function BackButton() { const router = useRouter(); - const searchParams = useSearchParams(); - const claimId = searchParams.get('claimId'); - - const [claim, setClaim] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (!claimId) { - setError('Claim ID not provided'); - setLoading(false); - return; - } - - 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); - } - }; - - void loadClaim(); - }, [claimId]); - - const handleShare = async () => { - if (!claim) return; - - 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); - } - } - }; - return ( -
-
- {/* Header */} -
- -

- Claim Receipt -

-

- View and share your claim proof -

-
- - {/* Loading State */} - {loading && ( -
- -

- Loading your receipt… -

-
- )} - - {/* Error State */} - {error && ( -
- -
-

- Error -

-

{error}

-
-
- )} - - {/* Receipt Card */} - {!loading && claim && ( -
- - - {/* Additional Information */} -
-

- What is this receipt? -

-
    -
  • - - - This receipt proves that your claim has been processed and - completed on the ChainForge platform. - -
  • -
  • - - - You can share this receipt with other parties as proof of - the transaction. - -
  • -
  • - - - Keep this receipt for your records. The data cannot be - altered after generation. - -
  • -
  • - - - You can download, copy, or share this receipt using the - buttons above. - -
  • -
-
- - {/* Support Information */} -
-

- Need help? -

-

- If you have questions about your claim or receipt, please - contact our support team at hello@chainforge.dev -

-
-
- )} -
-
+ ); } From cf5437718069537bccde625d88019deec5c708e6 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Thu, 16 Jul 2026 10:28:44 +0300 Subject: [PATCH 02/14] Update ClaimReceipt.tsx --- app/frontend/src/components/ClaimReceipt.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/frontend/src/components/ClaimReceipt.tsx b/app/frontend/src/components/ClaimReceipt.tsx index 70856148..4b5b5844 100644 --- a/app/frontend/src/components/ClaimReceipt.tsx +++ b/app/frontend/src/components/ClaimReceipt.tsx @@ -9,7 +9,13 @@ import { buildExplorerUrl } from '../lib/explorer'; 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; @@ -39,6 +45,7 @@ export const ClaimReceipt: React.FC = ({ 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 = { @@ -47,6 +54,7 @@ export const ClaimReceipt: React.FC = ({ 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(() => { @@ -167,7 +175,7 @@ ${claim.transactionHash ? `Transaction Hash: ${claim.transactionHash}` : ''}`.tr {claim.tokenAddress && (

TOKEN ADDRESS

-

CONTRACT ADDRESS

-

TRANSACTION HASH

-
Date: Thu, 16 Jul 2026 10:29:58 +0300 Subject: [PATCH 03/14] Add support for dynamic claim receipt endpoints --- app/frontend/src/lib/mock-api/client.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/frontend/src/lib/mock-api/client.ts b/app/frontend/src/lib/mock-api/client.ts index 44f33c04..58594934 100644 --- a/app/frontend/src/lib/mock-api/client.ts +++ b/app/frontend/src/lib/mock-api/client.ts @@ -38,6 +38,16 @@ export async function fetchClient( await new Promise((resolve) => setTimeout(resolve, 500)); return handlers['/campaigns/:id'](urlString, init); } + + // Support dynamic claim receipt endpoints like /api/v1/claims/:id/receipt + if ( + /^\/api\/v1\/claims\/[^/]+\/receipt$/.test(pathWithoutQuery) && + handlers['/api/v1/claims/:id/receipt'] + ) { + console.log(`[Mock API] Intercepting dynamic claim receipt request to: ${urlString}`); + await new Promise((resolve) => setTimeout(resolve, 500)); + return handlers['/api/v1/claims/:id/receipt'](urlString, init); + } } // Fallback to real fetch From 6a8a44cac8b5054a27d525fc2d943a28a7a54857 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Thu, 16 Jul 2026 10:30:45 +0300 Subject: [PATCH 04/14] Add claim receipt handler to mock API --- app/frontend/src/lib/mock-api/handlers.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/frontend/src/lib/mock-api/handlers.ts b/app/frontend/src/lib/mock-api/handlers.ts index 857a460f..8480582b 100644 --- a/app/frontend/src/lib/mock-api/handlers.ts +++ b/app/frontend/src/lib/mock-api/handlers.ts @@ -368,6 +368,28 @@ const recipientsImportConfirmHandler: MockHandler = async (_url, options) => { }); }; +const claimReceiptHandler: MockHandler = async (url) => { + const match = url.match(/\/api\/v1\/claims\/([^/]+)\/receipt/); + const claimId = match?.[1] ?? 'unknown-claim'; + + if (claimId === 'not-found') { + return new Response(null, { status: 404 }); + } + + return new Response( + JSON.stringify({ + claimId, + packageId: 'AID-001', + status: 'disbursed', + amount: 150.5, + tokenAddress: 'GATEMHCCKCY67ZUCKTROYN24ZYT5GK4EQZ5LKG3FZTSZ3NYNEJBBENSN', + timestamp: new Date().toISOString(), + recipientRef: 'recipient-ref-mock', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +}; + export const handlers: Record = { '/health': healthHandler, '/aid-packages': aidPackagesHandler, @@ -387,4 +409,5 @@ export const handlers: Record = { } return new Response(JSON.stringify({ success: false, message: 'Method not implemented in mock' }), { status: 405, headers: { 'Content-Type': 'application/json' } }); }, + '/api/v1/claims/:id/receipt': claimReceiptHandler, }; From 8944af05c3e890ef3231b7dc02e7da8e9ccd73bb Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Thu, 16 Jul 2026 10:31:22 +0300 Subject: [PATCH 05/14] Update package.json to add and remove dependencies --- app/frontend/package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/frontend/package.json b/app/frontend/package.json index 37b34b2e..8559bc3c 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -8,12 +8,12 @@ "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" }, "dependencies": { "@heroicons/react": "^2.2.0", - "openapi-fetch": "^0.13.5", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -29,6 +29,7 @@ "next": "^16.2.1", "next-intl": "^4.9.1", "next-themes": "^0.4.6", + "openapi-fetch": "^0.13.5", "papaparse": "^5.5.3", "react": "19.2.3", "react-dom": "19.2.3", @@ -37,8 +38,8 @@ "zustand": "^5.0.10" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", - "openapi-typescript": "^7.6.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/jest": "^30.0.0", @@ -51,6 +52,7 @@ "eslint-config-next": "^16.2.1", "jest": "^30.4.2", "jest-environment-jsdom": "^30.0.0", + "openapi-typescript": "^7.6.1", "tailwindcss": "^4", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", From a5b4d66fc0648e34a7d1fb2bd53e22e7159d64ed Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Thu, 16 Jul 2026 10:37:20 +0300 Subject: [PATCH 06/14] Create playwright.config.ts --- app/frontend/playwright.config.ts | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 app/frontend/playwright.config.ts diff --git a/app/frontend/playwright.config.ts b/app/frontend/playwright.config.ts new file mode 100644 index 00000000..9ca4fc5b --- /dev/null +++ b/app/frontend/playwright.config.ts @@ -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', + }, + }, +}); From befe772f1e5cae39dd9683b47ed2294e2ffa3ab5 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Thu, 16 Jul 2026 10:38:48 +0300 Subject: [PATCH 07/14] Create claim-receipt-fcp.spec.ts --- app/frontend/e2e/claim-receipt-fcp.spec.ts | 64 ++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 app/frontend/e2e/claim-receipt-fcp.spec.ts diff --git a/app/frontend/e2e/claim-receipt-fcp.spec.ts b/app/frontend/e2e/claim-receipt-fcp.spec.ts new file mode 100644 index 00000000..81c5313a --- /dev/null +++ b/app/frontend/e2e/claim-receipt-fcp.spec.ts @@ -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'); + }); +}); From 8ea0a64a293a9f68660c088fc8f6d9a84973e4d3 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Mon, 20 Jul 2026 21:07:32 +0300 Subject: [PATCH 08/14] Update ClaimReceipt.tsx From d658a838f52087d9894c20d21e027a3728a6e552 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Mon, 20 Jul 2026 21:29:10 +0300 Subject: [PATCH 09/14] Update page.tsx --- .../src/app/[locale]/claim-receipt/page.tsx | 404 ++++++++++-------- 1 file changed, 220 insertions(+), 184 deletions(-) diff --git a/app/frontend/src/app/[locale]/claim-receipt/page.tsx b/app/frontend/src/app/[locale]/claim-receipt/page.tsx index 26d70df3..4b5b5844 100644 --- a/app/frontend/src/app/[locale]/claim-receipt/page.tsx +++ b/app/frontend/src/app/[locale]/claim-receipt/page.tsx @@ -1,213 +1,249 @@ 'use client'; -import { useRouter } from 'next/navigation'; -import React, { useEffect, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ClaimReceipt, ClaimReceiptData } from '@/components/ClaimReceipt'; -import { AlertCircle, Loader2 } from 'lucide-react'; -import { LiveRegion } from '@/components/LiveRegion'; -import { getStatusTransitionMessage } from '@/lib/status-messages'; +import React, { useMemo } from 'react'; +import { Share2, Download, Copy, Check, ExternalLink } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { format } from 'date-fns'; +import { buildExplorerUrl } from '../lib/explorer'; + +export interface ClaimReceiptData { + claimId: string; + packageId: string; + status: + | 'requested' + | 'verified' + | 'approved' + | 'disbursed' + | 'archived' + | 'cancelled'; + amount: number; + tokenAddress?: string; + transactionHash?: string; + contractAddress?: string; + timestamp: string; + recipientRef?: string; +} -export function BackButton() { - const router = useRouter(); - return ( - - const searchParams = useSearchParams(); - const claimId = searchParams.get('claimId'); +interface ClaimReceiptProps { + claim: ClaimReceiptData; + onShare?: () => Promise; + compact?: boolean; +} - const [claim, setClaim] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const previousClaimRef = React.useRef(null); - const [announcement, setAnnouncement] = useState(''); +export const ClaimReceipt: React.FC = ({ + claim, + onShare, + compact = false, +}) => { + const { theme } = useTheme(); + const [copied, setCopied] = React.useState(false); + const [sharing, setSharing] = React.useState(false); + + const statusColors = { + requested: 'bg-yellow-50 border-yellow-200 text-yellow-900', + verified: 'bg-blue-50 border-blue-200 text-blue-900', + 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', + }; - useEffect(() => { - if (previousClaimRef.current && claim && previousClaimRef.current.status !== claim.status) { - setAnnouncement(getStatusTransitionMessage('Claim', previousClaimRef.current.status, claim.status)); - } - previousClaimRef.current = claim; - }, [claim]); + const statusBadgeColors = { + requested: 'bg-yellow-100 text-yellow-800', + verified: 'bg-blue-100 text-blue-800', + 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', + }; - useEffect(() => { - if (!claimId) { - setError('Claim ID not provided'); - setLoading(false); - return; + const formattedDate = useMemo(() => { + try { + return format(new Date(claim.timestamp), 'MMM dd, yyyy • HH:mm:ss'); + } catch { + return claim.timestamp; } + }, [claim.timestamp]); + + const receiptText = useMemo(() => { + return `Claim Receipt +Claim ID: ${claim.claimId} +Package ID: ${claim.packageId} +Status: ${claim.status.toUpperCase()} +Amount: ${claim.amount} tokens +Date: ${formattedDate} +${claim.tokenAddress ? `Token Address: ${claim.tokenAddress}` : ''} +${claim.contractAddress ? `Contract Address: ${claim.contractAddress}` : ''} +${claim.transactionHash ? `Transaction Hash: ${claim.transactionHash}` : ''}`.trim(); + }, [claim, formattedDate]); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(receiptText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy receipt', err); + } + }; - const loadClaim = async () => { + const handleShare = async () => { + if (onShare) { + setSharing(true); 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); + await onShare(); } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to load claim receipt', - ); + console.error('Failed to share receipt', err); } finally { - setLoading(false); + setSharing(false); } - }; - - void loadClaim(); - }, [claimId]); - - const handleShare = async () => { - if (!claim) return; - - try { - // Try Web Share API first - if (navigator.share) { + } else if (navigator.share) { + setSharing(true); + try { await navigator.share({ title: 'Claim Receipt', - text: `Claim ${claim.claimId} - ${claim.status}`, + text: receiptText, }); - } - } catch (err) { - if (err instanceof Error && err.name !== 'AbortError') { - console.error('Share failed:', err); + } catch (err) { + if (err instanceof Error && err.name !== 'AbortError') { + console.error('Share failed:', err); + } + } finally { + setSharing(false); } } }; - return ( -
- -
- {/* Header */} -
- -

- Claim Receipt -

-

- View and share your claim proof -

+ const handleDownload = () => { + const element = document.createElement('a'); + const file = new Blob([receiptText], { type: 'text/plain' }); + element.href = URL.createObjectURL(file); + element.download = `claim-receipt-${claim.claimId}.txt`; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }; + + if (compact) { + return ( +
+
+
+

{claim.packageId}

+

{formattedDate}

+
+ + {claim.status} +
+
+ ); + } - {/* Loading State */} - {loading && ( -
-
+ {/* Header */} +
+

Claim Receipt

+

Proof of claim completion

+
+ + {/* Details Grid */} +
+
+

CLAIM ID

+

{claim.claimId}

+
+
+

PACKAGE ID

+

{claim.packageId}

+
+
+

STATUS

+ + {claim.status} + +
+
+

AMOUNT

+

{claim.amount} tokens

+
+
+

TIMESTAMP

+

{formattedDate}

+
+ {claim.tokenAddress && ( +
)} - - {/* Error State */} - {error && ( -
-
+ + {/* Action Buttons */} +
+ + + +
+
); -} +}; From 032edcd14bf4a7fed1a48d9a5b019f37a78a6491 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Mon, 20 Jul 2026 21:41:23 +0300 Subject: [PATCH 10/14] Update page.tsx --- .../src/app/[locale]/claim-receipt/page.tsx | 355 ++++++------------ 1 file changed, 125 insertions(+), 230 deletions(-) diff --git a/app/frontend/src/app/[locale]/claim-receipt/page.tsx b/app/frontend/src/app/[locale]/claim-receipt/page.tsx index 4b5b5844..8a12be44 100644 --- a/app/frontend/src/app/[locale]/claim-receipt/page.tsx +++ b/app/frontend/src/app/[locale]/claim-receipt/page.tsx @@ -1,249 +1,144 @@ -'use client'; +import React from 'react'; +import { AlertCircle } from 'lucide-react'; +import { ClaimReceipt, ClaimReceiptData } from '@/components/ClaimReceipt'; +import { apiClient } from '@/lib/api-client'; +import { BackButton } from './BackButton'; -import React, { useMemo } from 'react'; -import { Share2, Download, Copy, Check, ExternalLink } from 'lucide-react'; -import { useTheme } from 'next-themes'; -import { format } from 'date-fns'; -import { buildExplorerUrl } from '../lib/explorer'; +// 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'; -export interface ClaimReceiptData { - claimId: string; - packageId: string; - status: - | 'requested' - | 'verified' - | 'approved' - | 'disbursed' - | 'archived' - | 'cancelled'; - amount: number; - tokenAddress?: string; - transactionHash?: string; - contractAddress?: string; - timestamp: string; - recipientRef?: string; +interface PageProps { + searchParams: Promise<{ claimId?: string }>; } -interface ClaimReceiptProps { - claim: ClaimReceiptData; - onShare?: () => Promise; - compact?: boolean; +function ErrorState({ message }: { message: string }) { + return ( +
+ +
+

+ Error +

+

{message}

+
+
+ ); } -export const ClaimReceipt: React.FC = ({ - claim, - onShare, - compact = false, -}) => { - const { theme } = useTheme(); - const [copied, setCopied] = React.useState(false); - const [sharing, setSharing] = React.useState(false); - - const statusColors = { - requested: 'bg-yellow-50 border-yellow-200 text-yellow-900', - verified: 'bg-blue-50 border-blue-200 text-blue-900', - 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 = { - requested: 'bg-yellow-100 text-yellow-800', - verified: 'bg-blue-100 text-blue-800', - 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', - }; +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[1], + ); - const formattedDate = useMemo(() => { - try { - return format(new Date(claim.timestamp), 'MMM dd, yyyy • HH:mm:ss'); - } catch { - return claim.timestamp; + if (error || !data) { + if (response?.status === 404) { + return { claim: null, error: 'Claim receipt not found' }; + } + return { claim: null, error: 'Failed to load claim receipt' }; } - }, [claim.timestamp]); - const receiptText = useMemo(() => { - return `Claim Receipt -Claim ID: ${claim.claimId} -Package ID: ${claim.packageId} -Status: ${claim.status.toUpperCase()} -Amount: ${claim.amount} tokens -Date: ${formattedDate} -${claim.tokenAddress ? `Token Address: ${claim.tokenAddress}` : ''} -${claim.contractAddress ? `Contract Address: ${claim.contractAddress}` : ''} -${claim.transactionHash ? `Transaction Hash: ${claim.transactionHash}` : ''}`.trim(); - }, [claim, formattedDate]); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(receiptText); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy receipt', err); - } - }; + return { claim: data as ClaimReceiptData, error: null }; + } catch (err) { + return { + claim: null, + error: err instanceof Error ? err.message : 'Failed to load claim receipt', + }; + } +} - const handleShare = async () => { - if (onShare) { - setSharing(true); - try { - await onShare(); - } catch (err) { - console.error('Failed to share receipt', err); - } finally { - setSharing(false); - } - } else if (navigator.share) { - setSharing(true); - try { - await navigator.share({ - title: 'Claim Receipt', - text: receiptText, - }); - } catch (err) { - if (err instanceof Error && err.name !== 'AbortError') { - console.error('Share failed:', err); - } - } finally { - setSharing(false); - } - } - }; +export default async function ClaimReceiptPage({ searchParams }: PageProps) { + const { claimId } = await searchParams; - const handleDownload = () => { - const element = document.createElement('a'); - const file = new Blob([receiptText], { type: 'text/plain' }); - element.href = URL.createObjectURL(file); - element.download = `claim-receipt-${claim.claimId}.txt`; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); - }; + const { claim, error } = claimId + ? await loadClaim(claimId) + : { claim: null, error: 'Claim ID not provided' }; - if (compact) { - return ( -
-
-
-

{claim.packageId}

-

{formattedDate}

-
- - {claim.status} - + return ( +
+
+ {/* Header */} +
+ +

+ Claim Receipt +

+

+ View and share your claim proof +

-
- ); - } - return ( -
- {/* Header */} -
-

Claim Receipt

-

Proof of claim completion

-
+ {/* Error State */} + {error && } - {/* Details Grid */} -
-
-

CLAIM ID

-

{claim.claimId}

-
-
-

PACKAGE ID

-

{claim.packageId}

-
-
-

STATUS

- - {claim.status} - -
-
-

AMOUNT

-

{claim.amount} tokens

-
-
-

TIMESTAMP

-

{formattedDate}

-
- {claim.tokenAddress && ( -
-

TOKEN ADDRESS

- - href={buildExplorerUrl('address', claim.tokenAddress)} - target="_blank" - rel="noopener noreferrer" - className="font-mono text-xs break-all text-blue-600 hover:underline dark:text-blue-400 flex items-center gap-1 inline-flex" - > - {claim.tokenAddress} - - -
- )} - {claim.contractAddress && ( -
-

CONTRACT ADDRESS

- - href={buildExplorerUrl('contract', claim.contractAddress)} - target="_blank" - rel="noopener noreferrer" - className="font-mono text-xs break-all text-blue-600 hover:underline dark:text-blue-400 flex items-center gap-1 inline-flex" - > - {claim.contractAddress} - - -
- )} - {claim.transactionHash && ( -
-

TRANSACTION HASH

- - href={buildExplorerUrl('tx', claim.transactionHash)} - target="_blank" - rel="noopener noreferrer" - className="font-mono text-xs break-all text-blue-600 hover:underline dark:text-blue-400 flex items-center gap-1 inline-flex" - > - {claim.transactionHash} - - + {/* Receipt Card */} + {!error && claim && ( +
+ + + {/* Additional Information */} +
+

+ What is this receipt? +

+
    +
  • + + + This receipt proves that your claim has been processed and + completed on the ChainForge platform. + +
  • +
  • + + + You can share this receipt with other parties as proof of + the transaction. + +
  • +
  • + + + Keep this receipt for your records. The data cannot be + altered after generation. + +
  • +
  • + + + You can download, copy, or share this receipt using the + buttons above. + +
  • +
+
+ + {/* Support Information */} +
+

+ Need help? +

+

+ If you have questions about your claim or receipt, please + contact our support team at hello@chainforge.dev +

+
)}
- - {/* Action Buttons */} -
- - - -
-
+
); -}; +} From cc9b6d4e3981fd01c07883ecb5345e4e7e4db4c8 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Mon, 20 Jul 2026 21:55:03 +0300 Subject: [PATCH 11/14] Update ClaimReceipt.tsx --- app/frontend/src/components/ClaimReceipt.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/frontend/src/components/ClaimReceipt.tsx b/app/frontend/src/components/ClaimReceipt.tsx index 4b5b5844..b77d51e9 100644 --- a/app/frontend/src/components/ClaimReceipt.tsx +++ b/app/frontend/src/components/ClaimReceipt.tsx @@ -175,7 +175,7 @@ ${claim.transactionHash ? `Transaction Hash: ${claim.transactionHash}` : ''}`.tr {claim.tokenAddress && (

TOKEN ADDRESS

- +

CONTRACT ADDRESS

- +

TRANSACTION HASH

- +
Date: Tue, 21 Jul 2026 08:30:11 +0300 Subject: [PATCH 12/14] Update accessibility.spec.tsx --- app/frontend/test/accessibility.spec.tsx | 25 +++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/app/frontend/test/accessibility.spec.tsx b/app/frontend/test/accessibility.spec.tsx index 520db1bd..7107ba82 100644 --- a/app/frontend/test/accessibility.spec.tsx +++ b/app/frontend/test/accessibility.spec.tsx @@ -10,6 +10,22 @@ import Home from '@/app/[locale]/page'; import CampaignsPage from '@/app/[locale]/campaigns/page'; import ClaimReceiptPage from '@/app/[locale]/claim-receipt/page'; +jest.mock('@/lib/api-client', () => ({ + apiClient: { + GET: jest.fn(async () => ({ + data: { + claimId: 'claim-123', + packageId: 'AID-001', + status: 'disbursed', + amount: 150.5, + timestamp: new Date().toISOString(), + }, + error: undefined, + response: { status: 200 }, + })), + }, +})); + expect.extend(toHaveNoViolations); // jsdom has no layout engine, so color-contrast cannot be computed here. @@ -186,8 +202,10 @@ describe('accessibility (axe, WCAG 2.2 AA automated subset)', () => { }); it('claim receipt page with claimId has no axe violations', async () => { - mockSearchParams.current = new URLSearchParams('claimId=claim-123'); - const { container } = renderWithProviders(); + const ui = await ClaimReceiptPage({ + searchParams: Promise.resolve({ claimId: 'claim-123' }), + }); + const { container } = renderWithProviders(ui); await waitFor(() => { expect(screen.getByText('What is this receipt?')).toBeInTheDocument(); @@ -197,7 +215,8 @@ describe('accessibility (axe, WCAG 2.2 AA automated subset)', () => { }); it('claim receipt page without claimId (error state) has no axe violations', async () => { - const { container } = renderWithProviders(); + const ui = await ClaimReceiptPage({ searchParams: Promise.resolve({}) }); + const { container } = renderWithProviders(ui); await waitFor(() => { expect(screen.getByText('Claim ID not provided')).toBeInTheDocument(); From 2c97a45adbb29df725484516afde30e5f82912a2 Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Tue, 21 Jul 2026 08:32:22 +0300 Subject: [PATCH 13/14] Create BackButton.tsx --- .../src/app/[locale]/claim-receipt/BackButton.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/frontend/src/app/[locale]/claim-receipt/BackButton.tsx diff --git a/app/frontend/src/app/[locale]/claim-receipt/BackButton.tsx b/app/frontend/src/app/[locale]/claim-receipt/BackButton.tsx new file mode 100644 index 00000000..f8fc8236 --- /dev/null +++ b/app/frontend/src/app/[locale]/claim-receipt/BackButton.tsx @@ -0,0 +1,13 @@ +'use client'; +import { useRouter } from 'next/navigation'; +export function BackButton() { + const router = useRouter(); + return ( + + ); +} From 00f5ae3dc0e24f570d051bba5de077b2440d86aa Mon Sep 17 00:00:00 2001 From: Miss Jojo Date: Tue, 21 Jul 2026 08:38:11 +0300 Subject: [PATCH 14/14] Update jest.config.ts --- app/frontend/jest.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/frontend/jest.config.ts b/app/frontend/jest.config.ts index 79d1967c..7e325e06 100644 --- a/app/frontend/jest.config.ts +++ b/app/frontend/jest.config.ts @@ -6,6 +6,11 @@ const config: Config = { moduleNameMapper: { '^@/(.*)$': '/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: ['/node_modules/', '/e2e/'], }; export default config;