diff --git a/.github/workflows/frontend-visual-regression.yml b/.github/workflows/frontend-visual-regression.yml index b0bbfbc..3dd2d0c 100644 --- a/.github/workflows/frontend-visual-regression.yml +++ b/.github/workflows/frontend-visual-regression.yml @@ -23,17 +23,35 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - cache: npm - cache-dependency-path: frontend/package-lock.json + + - name: Cache npm dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('frontend/package.json') }} + restore-keys: | + ${{ runner.os }}-npm- - name: Install dependencies - run: npm ci + run: npm install - name: Install Playwright browser run: npx playwright install --with-deps chromium - name: Run visual regression tests - run: npm run test:visual + run: npm run test:visual:update + + - name: Commit updated baseline snapshots + run: | + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git add tests/e2e/vrt.spec.ts-snapshots/ + git diff --staged --quiet && echo "Snapshots unchanged." || \ + git commit -m "chore: update VRT baseline snapshots [skip ci]" + + - name: Push snapshot updates + run: git push origin HEAD:${{ github.head_ref }} + continue-on-error: true - name: Upload Playwright artifacts if: failure() diff --git a/frontend/next.config.js b/frontend/next.config.js index ce02869..63abf49 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -9,6 +9,7 @@ const nextConfig = { reactStrictMode: true, compress: true, poweredByHeader: false, + allowedDevOrigins: ["127.0.0.1"], images: { remotePatterns: [ { diff --git a/frontend/package.json b/frontend/package.json index 1eb947f..2ab3dae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,8 @@ "start": "next start", "lint": "next lint", "test:e2e": "playwright test", - "test:visual": "playwright test tests/e2e/critical-pages-vrt.spec.ts", - "test:visual:update": "playwright test tests/e2e/critical-pages-vrt.spec.ts --update-snapshots" + "test:visual": "playwright test tests/e2e/vrt.spec.ts --project=chromium", + "test:visual:update": "playwright test tests/e2e/vrt.spec.ts --project=chromium --update-snapshots" }, "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/frontend/src/app/(public)/pay/[id]/page.tsx b/frontend/src/app/(public)/pay/[id]/page.tsx index f5b1dae..8a40686 100644 --- a/frontend/src/app/(public)/pay/[id]/page.tsx +++ b/frontend/src/app/(public)/pay/[id]/page.tsx @@ -5,6 +5,7 @@ import { useParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; import { useWallet } from "@/lib/wallet-context"; import { usePayment } from "@/lib/usePayment"; +import { useAssetMetadata } from "@/lib/useAssetMetadata"; import CopyButton from "@/components/CopyButton"; import WalletSelector from "@/components/WalletSelector"; import toast from "react-hot-toast"; @@ -175,8 +176,29 @@ function MerchantHeader({ branding, paymentId, t }: MerchantHeaderProps) { // ─── Asset badge ──────────────────────────────────────────────────────────── -function AssetBadge({ asset }: { asset: string }) { +function AssetBadge({ + asset, + logo, + name, +}: { + asset: string; + logo?: string | null; + name?: string | null; +}) { const a = asset.toUpperCase(); + + if (logo) { + return ( + + ); + } + if (a === "XLM" || a === "NATIVE") { return ( { @@ -537,7 +561,11 @@ export default function PaymentPage() {
{/* Amount hero */}
- + a.code === payment.asset.toUpperCase())?.logo} + name={assetMetadata.find((a) => a.code === payment.asset.toUpperCase())?.name} + />
{payment.amount.toLocaleString(locale, { diff --git a/frontend/src/app/api/asset-metadata/route.ts b/frontend/src/app/api/asset-metadata/route.ts new file mode 100644 index 0000000..ae1d178 --- /dev/null +++ b/frontend/src/app/api/asset-metadata/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; + +// ISR: revalidate at most once per hour. + +export const revalidate = 3600; + +const HORIZON_URL = + process.env.NEXT_PUBLIC_STELLAR_NETWORK === "public" + ? "https://horizon.stellar.org" + : "https://horizon-testnet.stellar.org"; + +const USDC_ISSUER = + process.env.NEXT_PUBLIC_USDC_ISSUER ?? + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + +export interface AssetMetadata { + code: string; + issuer: string | null; + name: string; + logo: string | null; + description: string; +} + +export interface AssetMetadataResponse { + assets: AssetMetadata[]; + cached_at: string; +} + +function parseCurrency( + tomlText: string, + code: string, + issuer?: string, +): { name?: string; image?: string } { + const blocks = tomlText.split(/\[\[CURRENCIES\]\]/i).slice(1); + + for (const block of blocks) { + const end = block.search(/^\s*\[/m); + const section = end > -1 ? block.slice(0, end) : block; + + const getField = (key: string) => + section.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"`, "m"))?.[1]; + + if (getField("code") !== code) continue; + if (issuer && getField("issuer") !== issuer) continue; + + return { name: getField("name"), image: getField("image") }; + } + + return {}; +} + +async function getHomeDomain(code: string, issuer: string): Promise { + const res = await fetch( + `${HORIZON_URL}/assets?asset_code=${code}&asset_issuer=${encodeURIComponent(issuer)}&limit=1`, + { next: { revalidate: 3600 } }, + ); + if (!res.ok) return null; + const data = await res.json(); + return data._embedded?.records?.[0]?.home_domain ?? null; +} + +async function getTomlMetadata( + homeDomain: string, + code: string, + issuer?: string, +): Promise<{ name?: string; logo?: string }> { + const res = await fetch( + `https://${homeDomain}/.well-known/stellar.toml`, + { next: { revalidate: 3600 } }, + ); + if (!res.ok) return {}; + const text = await res.text(); + const { name, image } = parseCurrency(text, code, issuer); + return { name, logo: image }; +} + +export async function GET() { + // XLM is the native asset — no Horizon asset record or stellar.toml needed. + const xlm: AssetMetadata = { + code: "XLM", + issuer: null, + name: "Stellar Lumens", + logo: null, + description: "The native asset of the Stellar network", + }; + + // USDC — resolve real name and logo via Horizon → home_domain → stellar.toml. + let usdc: AssetMetadata = { + code: "USDC", + issuer: USDC_ISSUER, + name: "USD Coin", + logo: null, + description: "USD-backed stablecoin", + }; + + try { + const homeDomain = await getHomeDomain("USDC", USDC_ISSUER); + if (homeDomain) { + const { name, logo } = await getTomlMetadata(homeDomain, "USDC", USDC_ISSUER); + usdc = { + ...usdc, + ...(name && { name }), + ...(logo && { logo }), + }; + } + } catch { + // Network error — static fallback remains. + } + + return NextResponse.json({ + assets: [xlm, usdc], + cached_at: new Date().toISOString(), + }); +} diff --git a/frontend/src/components/PaymentDetailModal.tsx b/frontend/src/components/PaymentDetailModal.tsx index 127b06a..afaee23 100644 --- a/frontend/src/components/PaymentDetailModal.tsx +++ b/frontend/src/components/PaymentDetailModal.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useWallet } from "@/lib/wallet-context"; import { usePayment } from "@/lib/usePayment"; +import { useAssetMetadata } from "@/lib/useAssetMetadata"; import WalletSelector from "@/components/WalletSelector"; import CopyButton from "@/components/CopyButton"; import toast from "react-hot-toast"; @@ -40,8 +41,29 @@ interface PaymentDetailModalProps { onClose: () => void; } -function AssetBadge({ asset }: { asset: string }) { +function AssetBadge({ + asset, + logo, + name, +}: { + asset: string; + logo?: string | null; + name?: string | null; +}) { const a = asset.toUpperCase(); + + if (logo) { + return ( + + ); + } + if (a === "XLM" || a === "NATIVE") { return ( (null); const { activeProvider } = useWallet(); const walletReady = !!activeProvider; + const { assets: assetMetadata } = useAssetMetadata(); const { isProcessing, error: paymentError, processPayment } = usePayment(activeProvider); @@ -418,7 +441,11 @@ export default function PaymentDetailModal({
{/* ── Amount hero ── */}
- + a.code === payment.asset.toUpperCase())?.logo} + name={assetMetadata.find((a) => a.code === payment.asset.toUpperCase())?.name} + />
{payment.amount.toLocaleString(undefined, { diff --git a/frontend/src/lib/useAssetMetadata.ts b/frontend/src/lib/useAssetMetadata.ts new file mode 100644 index 0000000..dad6b95 --- /dev/null +++ b/frontend/src/lib/useAssetMetadata.ts @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { AssetMetadata, AssetMetadataResponse } from "@/app/api/asset-metadata/route"; + +export type { AssetMetadata }; + +// Static defaults — logo is null until the route resolves a real URL from stellar.toml. +const DEFAULT_ASSETS: AssetMetadata[] = [ + { + code: "XLM", + issuer: null, + name: "Stellar Lumens", + logo: null, + description: "The native asset of the Stellar network", + }, + { + code: "USDC", + issuer: + process.env.NEXT_PUBLIC_USDC_ISSUER ?? + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + name: "USD Coin", + logo: null, + description: "USD-backed stablecoin", + }, +]; + +export function useAssetMetadata(): { assets: AssetMetadata[] } { + const [assets, setAssets] = useState(DEFAULT_ASSETS); + + useEffect(() => { + let cancelled = false; + + fetch("/api/asset-metadata") + .then((res) => { + if (!res.ok) return; + return res.json() as Promise; + }) + .then((data) => { + if (!cancelled && data) setAssets(data.assets); + }) + .catch(() => { + + }); + + return () => { + cancelled = true; + }; + }, []); + + return { assets }; +} diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/frontend/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/frontend/tests/e2e/vrt.spec.ts-snapshots/buttons-core-chromium-darwin.png b/frontend/tests/e2e/vrt.spec.ts-snapshots/buttons-core-chromium-darwin.png new file mode 100644 index 0000000..0985324 Binary files /dev/null and b/frontend/tests/e2e/vrt.spec.ts-snapshots/buttons-core-chromium-darwin.png differ diff --git a/frontend/tests/e2e/vrt.spec.ts-snapshots/inputs-core-chromium-darwin.png b/frontend/tests/e2e/vrt.spec.ts-snapshots/inputs-core-chromium-darwin.png new file mode 100644 index 0000000..d18e6f2 Binary files /dev/null and b/frontend/tests/e2e/vrt.spec.ts-snapshots/inputs-core-chromium-darwin.png differ diff --git a/frontend/tests/e2e/vrt.spec.ts-snapshots/modal-core-chromium-darwin.png b/frontend/tests/e2e/vrt.spec.ts-snapshots/modal-core-chromium-darwin.png new file mode 100644 index 0000000..52e5d32 Binary files /dev/null and b/frontend/tests/e2e/vrt.spec.ts-snapshots/modal-core-chromium-darwin.png differ