diff --git a/src/components/NFTDisplay.tsx b/src/components/NFTDisplay.tsx index fa506e3..7fe7c7f 100644 --- a/src/components/NFTDisplay.tsx +++ b/src/components/NFTDisplay.tsx @@ -1,24 +1,283 @@ -// Placeholder component for displaying Commitment NFTs -// This will show NFT metadata, images, and details +"use client"; + +/* eslint-disable @next/next/no-img-element -- NFT metadata images can come from arbitrary external domains, so this component keeps plain img fallback behavior. */ + +import React, { useMemo, useState } from "react"; +import { + CalendarDays, + ExternalLink, + ImageOff, + ShieldCheck, + WalletCards, +} from "lucide-react"; interface NFTDisplayProps { - tokenId: string - metadata?: Record + tokenId: string; + metadata?: Record; + ownerAddress?: string; + contractAddress?: string; + mintDate?: string; + riskProfile?: string; + amount?: string; + asset?: string; + maturityDate?: string; + complianceScore?: number; + attestationHref?: string; +} + +interface DisplayMetadata { + imageUrl?: string; + name: string; + description?: string; + owner?: string; + contract?: string; + mintDate?: string; + riskProfile?: string; + amount?: string; + asset?: string; + maturityDate?: string; + complianceScore?: number; +} + +function readString( + source: Record | undefined, + keys: string[], +): string | undefined { + if (!source) return undefined; + + for (const key of keys) { + const value = source[key]; + if (typeof value === "string" && value.trim() !== "") { + return value.trim(); + } + } + + return undefined; +} + +function readNumber( + source: Record | undefined, + keys: string[], +): number | undefined { + if (!source) return undefined; + + for (const key of keys) { + const value = source[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.min(100, Math.round(value))); + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.max(0, Math.min(100, Math.round(parsed))); + } + } + } + + return undefined; +} + +function truncateMiddle(value: string): string { + if (value.length <= 18) return value; + return `${value.slice(0, 8)}...${value.slice(-6)}`; } -export default function NFTDisplay({ tokenId, metadata }: NFTDisplayProps) { +function formatFallbackSeed(tokenId: string): string { return ( -
- {/* TODO: Implement NFT display with: - - NFT image/visualization - - Metadata display - - Commitment parameters - - Health metrics - - Attestation history link - */} -

NFT Display component - Token ID: {tokenId}

- {metadata &&
{JSON.stringify(metadata, null, 2)}
} + tokenId + .replace(/[^a-zA-Z0-9]/g, "") + .slice(-3) + .toUpperCase() || "NFT" + ); +} + +function buildDisplayMetadata({ + tokenId, + metadata, + ownerAddress, + contractAddress, + mintDate, + riskProfile, + amount, + asset, + maturityDate, + complianceScore, +}: NFTDisplayProps): DisplayMetadata { + return { + imageUrl: readString(metadata, [ + "image", + "imageUrl", + "image_url", + "artworkUrl", + "artwork_url", + ]), + name: + readString(metadata, ["name", "title"]) ?? `Commitment NFT #${tokenId}`, + description: readString(metadata, ["description", "summary"]), + owner: + ownerAddress ?? + readString(metadata, ["owner", "ownerAddress", "owner_address"]), + contract: + contractAddress ?? + readString(metadata, ["contract", "contractAddress", "contract_address"]), + mintDate: + mintDate ?? + readString(metadata, ["mintDate", "mintedAt", "mint_date", "createdAt"]), + riskProfile: + riskProfile ?? + readString(metadata, ["riskProfile", "risk_profile", "risk"]), + amount: amount ?? readString(metadata, ["amount", "principal"]), + asset: asset ?? readString(metadata, ["asset", "currency"]), + maturityDate: + maturityDate ?? + readString(metadata, ["maturityDate", "maturity", "expiresAt"]), + complianceScore: + complianceScore ?? + readNumber(metadata, ["complianceScore", "compliance_score"]), + }; +} + +function MetadataRow({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ {label} + + {mono ? truncateMiddle(value) : value} +
- ) + ); } +export default function NFTDisplay(props: NFTDisplayProps) { + const { tokenId, attestationHref = "#attestation-history" } = props; + const [imageFailed, setImageFailed] = useState(false); + const display = useMemo(() => buildDisplayMetadata(props), [props]); + const shouldRenderImage = Boolean(display.imageUrl) && !imageFailed; + const commitmentAmount = + display.amount && display.asset + ? `${display.amount} ${display.asset}` + : (display.amount ?? display.asset); + + return ( +
+
+
+ {shouldRenderImage ? ( + {`${display.name} setImageFailed(true)} + src={display.imageUrl} + /> + ) : ( +
+
+ {display.imageUrl ? ( + + ) : ( + + {formatFallbackSeed(tokenId)} + + )} +
+
+

+ Commitment NFT +

+

+ #{truncateMiddle(tokenId)} +

+
+
+ )} +
+ +
+
+

+ Commitment NFT +

+

+ {display.name} +

+ {display.description && ( +

+ {display.description} +

+ )} +
+ +
+
+ +

Token

+

+ {truncateMiddle(tokenId)} +

+
+
+ +

Compliance

+

+ {typeof display.complianceScore === "number" + ? `${display.complianceScore}%` + : "Not scored"} +

+
+
+ +

Maturity

+

+ {display.maturityDate ?? "Not set"} +

+
+
+ +
+

NFT Metadata

+ + {display.owner && ( + + )} + {display.contract && ( + + )} + {display.mintDate && ( + + )} + {display.riskProfile && ( + + )} + {commitmentAmount && ( + + )} +
+ + + + View attestation history + +
+
+
+ ); +} diff --git a/src/components/__tests__/NFTDisplay.test.tsx b/src/components/__tests__/NFTDisplay.test.tsx new file mode 100644 index 0000000..7e6791f --- /dev/null +++ b/src/components/__tests__/NFTDisplay.test.tsx @@ -0,0 +1,121 @@ +/** + * @vitest-environment happy-dom + */ + +import React from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import NFTDisplay from "@/components/NFTDisplay"; + +describe("NFTDisplay", () => { + afterEach(() => { + cleanup(); + }); + + it("renders artwork, parsed metadata, commitment parameters, and attestation link", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { name: "Alpha Liquidity Commitment" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("img", { name: "Alpha Liquidity Commitment artwork" }), + ).toHaveAttribute("src", "https://example.test/nft.png"); + expect( + screen.getByText("A commitment NFT backed by XLM liquidity."), + ).toBeInTheDocument(); + expect(screen.getByText("GABCDEFG...456789")).toBeInTheDocument(); + expect(screen.getByText("CCONTRAC...567890")).toBeInTheDocument(); + expect(screen.getByText("2026-06-18")).toBeInTheDocument(); + expect(screen.getByText("Balanced")).toBeInTheDocument(); + expect(screen.getByText("50000 XLM")).toBeInTheDocument(); + expect(screen.getByText("94%")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /view attestation history/i }), + ).toHaveAttribute("href", "#history"); + }); + + it("keeps the component prop-driven and allows explicit props to override metadata values", () => { + render( + , + ); + + expect(screen.getByText("GPROPADD...000001")).toBeInTheDocument(); + expect(screen.queryByText("GMETADA...000001")).not.toBeInTheDocument(); + expect(screen.getByText("88%")).toBeInTheDocument(); + }); + + it("shows a branded fallback when metadata has no image", () => { + render( + , + ); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("PHA")).toBeInTheDocument(); + expect(screen.getByText("#CMT-591-ALPHA")).toBeInTheDocument(); + expect( + screen.queryByText(/NFT Display component/i), + ).not.toBeInTheDocument(); + }); + + it("switches to the visual fallback when the artwork fails to load", () => { + render( + , + ); + + fireEvent.error( + screen.getByRole("img", { name: "Broken Image NFT artwork" }), + ); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("#CMT-BROKEN-IMAGE")).toBeInTheDocument(); + }); + + it("handles missing metadata without rendering raw JSON", () => { + const { container } = render(); + + expect( + screen.getByRole("heading", { name: "Commitment NFT #SHORT" }), + ).toBeInTheDocument(); + expect(screen.getByText("Not scored")).toBeInTheDocument(); + expect(screen.getByText("Not set")).toBeInTheDocument(); + expect(container.querySelector("pre")).toBeNull(); + expect(screen.queryByText(/"tokenId"/i)).not.toBeInTheDocument(); + }); +});