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
26 changes: 22 additions & 4 deletions .github/workflows/frontend-visual-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const nextConfig = {
reactStrictMode: true,
compress: true,
poweredByHeader: false,
allowedDevOrigins: ["127.0.0.1"],
images: {
remotePatterns: [
{
Expand Down
4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 36 additions & 8 deletions frontend/src/app/(public)/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<span
aria-hidden="true"
className="inline-flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-white/10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logo} alt={name ?? asset} className="h-8 w-8 object-contain" />
</span>
);
}

if (a === "XLM" || a === "NATIVE") {
return (
<span
Expand Down Expand Up @@ -345,12 +367,14 @@ export default function PaymentPage() {

const { activeProvider } = useWallet();
const {
isProcessing,
status: txStatus,
error: paymentError,
processPayment,
processPathPayment,
} = usePayment(activeProvider);
isProcessing,
status: txStatus,
error: paymentError,
processPayment,
processPathPayment,
} = usePayment(activeProvider);

const { assets: assetMetadata } = useAssetMetadata();

// ── Fetch payment details ──────────────────────────────────────────────────
useEffect(() => {
Expand Down Expand Up @@ -537,7 +561,11 @@ export default function PaymentPage() {
<div className="rounded-3xl border border-white/10 bg-white/5 shadow-2xl backdrop-blur">
{/* Amount hero */}
<div className="flex flex-col items-center gap-3 border-b border-white/10 px-8 py-10">
<AssetBadge asset={payment.asset} />
<AssetBadge
asset={payment.asset}
logo={assetMetadata.find((a) => a.code === payment.asset.toUpperCase())?.logo}
name={assetMetadata.find((a) => a.code === payment.asset.toUpperCase())?.name}
/>
<div className="flex items-baseline gap-2">
<span className="text-5xl font-bold tracking-tight text-white">
{payment.amount.toLocaleString(locale, {
Expand Down
114 changes: 114 additions & 0 deletions frontend/src/app/api/asset-metadata/route.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<AssetMetadataResponse>({
assets: [xlm, usdc],
cached_at: new Date().toISOString(),
});
}
31 changes: 29 additions & 2 deletions frontend/src/components/PaymentDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<span
aria-hidden="true"
className="inline-flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-white/10 shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={logo} alt={name ?? asset} className="h-6 w-6 object-contain" />
</span>
);
}

if (a === "XLM" || a === "NATIVE") {
return (
<span
Expand Down Expand Up @@ -181,6 +203,7 @@ export default function PaymentDetailModal({
const sheetRef = useRef<HTMLDivElement>(null);
const { activeProvider } = useWallet();
const walletReady = !!activeProvider;
const { assets: assetMetadata } = useAssetMetadata();

const { isProcessing, error: paymentError, processPayment } = usePayment(activeProvider);

Expand Down Expand Up @@ -418,7 +441,11 @@ export default function PaymentDetailModal({
<div className="p-6 space-y-6">
{/* ── Amount hero ── */}
<div className="flex flex-col items-center gap-3 text-center">
<AssetBadge asset={payment.asset} />
<AssetBadge
asset={payment.asset}
logo={assetMetadata.find((a) => a.code === payment.asset.toUpperCase())?.logo}
name={assetMetadata.find((a) => a.code === payment.asset.toUpperCase())?.name}
/>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold tracking-tight text-white">
{payment.amount.toLocaleString(undefined, {
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/lib/useAssetMetadata.ts
Original file line number Diff line number Diff line change
@@ -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<AssetMetadata[]>(DEFAULT_ASSETS);

useEffect(() => {
let cancelled = false;

fetch("/api/asset-metadata")
.then((res) => {
if (!res.ok) return;
return res.json() as Promise<AssetMetadataResponse>;
})
.then((data) => {
if (!cancelled && data) setAssets(data.assets);
})
.catch(() => {

});

return () => {
cancelled = true;
};
}, []);

return { assets };
}
4 changes: 4 additions & 0 deletions frontend/test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading