-
- {truncateMiddle(proposalIdHex)}
-
+
+
@@ -385,22 +375,8 @@ export default function ProposalDetailPage({
)}
Proposal
-
-
- {truncateMiddle(proposalIdHex)}
-
-
+
+
@@ -467,39 +443,13 @@ export default function ProposalDetailPage({
- Proposer
-
{proposer ? (
-
-
- {shortenAddress(proposer)}
-
-
-
+
) : (
Unknown
)}
-
- {copied ? "Proposer address copied to clipboard" : null}
-
diff --git a/apps/web/src/app/(app)/proposals/page.tsx b/apps/web/src/app/(app)/proposals/page.tsx
index 8a09dae..efa33cc 100644
--- a/apps/web/src/app/(app)/proposals/page.tsx
+++ b/apps/web/src/app/(app)/proposals/page.tsx
@@ -490,7 +490,6 @@ export default function ProposalsPage() {
: undefined
}
isRetryingState={isRetrying}
- onCopyId={() => void navigator.clipboard.writeText(id)}
/>
);
diff --git a/apps/web/src/components/CommunityCard.tsx b/apps/web/src/components/CommunityCard.tsx
index 13c5bc7..5619fed 100644
--- a/apps/web/src/components/CommunityCard.tsx
+++ b/apps/web/src/components/CommunityCard.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { CommunityAvatar } from "@/components/CommunityAvatar";
+import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier";
import type { Community } from "@/lib/community/types";
import { truncateMiddle } from "@/lib/truncate";
import { FreshnessNotice } from "@/components/ui/FreshnessNotice";
@@ -20,12 +21,15 @@ export function CommunityCard({ community }: { community: Community }) {
{name}
-
- {truncateMiddle(record.id, 10, 8)}
-
+
+
+
diff --git a/apps/web/src/components/CommunityDeploymentPanel.tsx b/apps/web/src/components/CommunityDeploymentPanel.tsx
index 22395d6..f86edf7 100644
--- a/apps/web/src/components/CommunityDeploymentPanel.tsx
+++ b/apps/web/src/components/CommunityDeploymentPanel.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TransactionLifecycleStatus } from "@/components/TransactionLifecycleStatus";
import { LiveStatus } from "@/components/ui/LiveStatus";
+import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier";
import { useWallet } from "@/context/WalletProvider";
import {
communityDeploymentRecoveryKey,
@@ -21,10 +22,7 @@ import type {
} from "@/lib/community/schema";
import { getE2EBridge } from "@/lib/e2eMock";
import { config } from "@/lib/stellar";
-import {
- buildStellarExplorerContractUrl,
- buildStellarExplorerTxUrl,
-} from "@/lib/stellarExplorer";
+import { buildStellarExplorerTxUrl } from "@/lib/stellarExplorer";
import type { TransactionLifecycleStage } from "@/lib/transactionLifecycle";
type Props = {
@@ -481,28 +479,15 @@ export function CommunityDeploymentPanel({
["Governor", expected.governorContract],
] as const
).map(([label, contractId]) => (
-
+
{label} contract
-
- {contractId}
-
-
-
-
- Open explorer
-
+
+
))}
diff --git a/apps/web/src/components/ProposalSummaryCard.tsx b/apps/web/src/components/ProposalSummaryCard.tsx
index ffa804e..4ce83ad 100644
--- a/apps/web/src/components/ProposalSummaryCard.tsx
+++ b/apps/web/src/components/ProposalSummaryCard.tsx
@@ -2,6 +2,7 @@
import Link from "next/link";
import type { ProposalSummary } from "@/lib/proposal/types";
+import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier";
import { truncateMiddle } from "@/lib/truncate";
export type ProposalSummaryCardStateStatus =
@@ -31,7 +32,6 @@ export type ProposalSummaryCardProps = {
showDescription?: boolean;
onRetryState?: () => void;
isRetryingState?: boolean;
- onCopyId?: () => void;
href?: string;
};
@@ -89,7 +89,6 @@ export function ProposalSummaryCard({
showDescription = false,
onRetryState,
isRetryingState = false,
- onCopyId,
href,
}: ProposalSummaryCardProps) {
const { proposalId } = summary;
@@ -140,17 +139,7 @@ export function ProposalSummaryCard({
{isRetryingState ? "Retrying…" : "Retry state"}
)}
- {onCopyId && (
-
- )}
+
);
diff --git a/apps/web/src/components/__tests__/ProposalSummaryCard.test.tsx b/apps/web/src/components/__tests__/ProposalSummaryCard.test.tsx
index ccb1883..c92069a 100644
--- a/apps/web/src/components/__tests__/ProposalSummaryCard.test.tsx
+++ b/apps/web/src/components/__tests__/ProposalSummaryCard.test.tsx
@@ -30,7 +30,6 @@ describe("ProposalSummaryCard", () => {
showDescription
stateStatus="ready"
stateLabel="Active"
- onCopyId={() => undefined}
/>,
);
@@ -126,6 +125,22 @@ describe("ProposalSummaryCard", () => {
expect(onRetryState).toHaveBeenCalledTimes(1);
});
+ it("copies the proposal id via the shared identifier control", async () => {
+ Object.assign(navigator, {
+ clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
+ });
+ render(
+
,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy Proposal ID" }));
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FULL_ID);
+ });
+
it("keeps long proposal IDs from breaking the layout", () => {
const { container } = render(
{
+ beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_STELLAR_NETWORK", "testnet");
+ Object.assign(navigator, {
+ clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it("truncates the value deterministically and exposes the full value via title", () => {
+ render();
+
+ const text = screen.getByTitle(CONTRACT_ID);
+ expect(text).toHaveTextContent(`${CONTRACT_ID.slice(0, 8)}…${CONTRACT_ID.slice(-6)}`);
+ });
+
+ it("does not change the copy button's accessible name after a successful copy", async () => {
+ render();
+
+ const button = screen.getByRole("button", { name: "Copy Governor contract" });
+ fireEvent.click(button);
+
+ await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(CONTRACT_ID));
+ expect(screen.getByRole("button", { name: "Copy Governor contract" })).toBe(button);
+ await waitFor(() =>
+ expect(screen.getByRole("status")).toHaveTextContent("Governor contract copied to clipboard"),
+ );
+ });
+
+ it("announces a failure without changing the accessible name", async () => {
+ Object.assign(navigator, {
+ clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) },
+ });
+
+ render();
+ const button = screen.getByRole("button", { name: "Copy Proposer" });
+ fireEvent.click(button);
+
+ await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Failed to copy Proposer"));
+ expect(screen.getByRole("button", { name: "Copy Proposer" })).toBe(button);
+ });
+
+ it("links contract ids to the contract explorer entity for the active network", () => {
+ render();
+
+ expect(screen.getByRole("link", { name: "Open Governor contract in explorer" })).toHaveAttribute(
+ "href",
+ `https://stellar.expert/explorer/public/contract/${CONTRACT_ID}`,
+ );
+ });
+
+ it("links account addresses to the account explorer entity", () => {
+ render();
+
+ expect(screen.getByRole("link", { name: "Open Proposer in explorer" })).toHaveAttribute(
+ "href",
+ `https://stellar.expert/explorer/testnet/account/${ACCOUNT_ID}`,
+ );
+ });
+
+ it("links transaction hashes to the tx explorer entity", () => {
+ render();
+
+ expect(screen.getByRole("link", { name: "Open Transaction in explorer" })).toHaveAttribute(
+ "href",
+ `https://stellar.expert/explorer/testnet/tx/${TX_HASH}`,
+ );
+ });
+
+ it("never renders an explorer link for opaque values like proposal ids", () => {
+ render();
+
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ });
+
+ it("never renders an explorer link when the value fails validation for its kind", () => {
+ render();
+
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/ui/OnChainIdentifier.tsx b/apps/web/src/components/ui/OnChainIdentifier.tsx
new file mode 100644
index 0000000..7f1789c
--- /dev/null
+++ b/apps/web/src/components/ui/OnChainIdentifier.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { truncateMiddle } from "@/lib/truncate";
+import {
+ buildStellarExplorerAccountUrl,
+ buildStellarExplorerContractUrl,
+ buildStellarExplorerTxUrl,
+ resolveStellarNetworkId,
+ type StellarNetworkId,
+} from "@/lib/stellarExplorer";
+import { LiveStatus } from "@/components/ui/LiveStatus";
+
+export type OnChainIdentifierKind = "contract" | "tx" | "account" | "opaque";
+
+export type OnChainIdentifierProps = {
+ /** Human-readable label used in aria-labels and copy/explorer feedback, e.g. "Governor contract". */
+ label: string;
+ /** Full, untruncated identifier value. */
+ value: string;
+ /** Which explorer lookup (if any) applies to this value. "opaque" never links out. */
+ kind: OnChainIdentifierKind;
+ /** Defaults to the app's configured network. */
+ network?: StellarNetworkId;
+ truncateStart?: number;
+ truncateEnd?: number;
+ className?: string;
+ /** Render only the copy/explorer controls, e.g. when the value is already shown elsewhere. */
+ hideValue?: boolean;
+};
+
+const EXPLORER_BUILDERS: Record<
+ Exclude,
+ (value: string, network: StellarNetworkId) => string | null
+> = {
+ contract: buildStellarExplorerContractUrl,
+ tx: buildStellarExplorerTxUrl,
+ account: buildStellarExplorerAccountUrl,
+};
+
+const COPY_FEEDBACK_MS = 2000;
+
+/**
+ * Deterministic display for a wallet address, contract id, transaction hash,
+ * or proposal id: truncated text with the full value in `title`, a copy
+ * button with keyboard access and a live-region announcement, and an
+ * explorer link when the kind/network combination resolves to one.
+ */
+export function OnChainIdentifier({
+ label,
+ value,
+ kind,
+ network,
+ truncateStart = 8,
+ truncateEnd = 6,
+ className,
+ hideValue = false,
+}: OnChainIdentifierProps) {
+ const [copyStatus, setCopyStatus] = useState<"idle" | "success" | "error">("idle");
+ const resetTimer = useRef | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (resetTimer.current) clearTimeout(resetTimer.current);
+ };
+ }, []);
+
+ const resolvedNetwork = network ?? resolveStellarNetworkId();
+ const explorerUrl = kind === "opaque" ? null : EXPLORER_BUILDERS[kind](value, resolvedNetwork);
+ const displayValue = truncateMiddle(value, truncateStart, truncateEnd);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopyStatus("success");
+ } catch {
+ setCopyStatus("error");
+ }
+ if (resetTimer.current) clearTimeout(resetTimer.current);
+ resetTimer.current = setTimeout(() => setCopyStatus("idle"), COPY_FEEDBACK_MS);
+ };
+
+ return (
+
+ {!hideValue && (
+
+ {displayValue}
+
+ )}
+
+ {explorerUrl && (
+
+ Explorer
+
+ )}
+
+ {copyStatus === "success"
+ ? `${label} copied to clipboard`
+ : copyStatus === "error"
+ ? `Failed to copy ${label}`
+ : ""}
+
+
+ );
+}
diff --git a/apps/web/src/lib/stellarExplorer.test.ts b/apps/web/src/lib/stellarExplorer.test.ts
index d222433..00e5284 100644
--- a/apps/web/src/lib/stellarExplorer.test.ts
+++ b/apps/web/src/lib/stellarExplorer.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ buildStellarExplorerAccountUrl,
buildStellarExplorerContractUrl,
buildStellarExplorerTxUrl,
resolveStellarNetworkId,
@@ -56,6 +57,26 @@ describe("buildStellarExplorerContractUrl", () => {
});
});
+describe("buildStellarExplorerAccountUrl", () => {
+ const accountId = `G${"A".repeat(55)}`;
+
+ it("builds network-specific account links", () => {
+ expect(buildStellarExplorerAccountUrl(accountId, "testnet")).toBe(
+ `https://stellar.expert/explorer/testnet/account/${accountId}`,
+ );
+ expect(buildStellarExplorerAccountUrl(accountId, "mainnet")).toBe(
+ `https://stellar.expert/explorer/public/account/${accountId}`,
+ );
+ });
+
+ it("returns null for missing or malformed account addresses", () => {
+ expect(buildStellarExplorerAccountUrl(null, "testnet")).toBeNull();
+ expect(buildStellarExplorerAccountUrl(undefined, "testnet")).toBeNull();
+ expect(buildStellarExplorerAccountUrl("", "testnet")).toBeNull();
+ expect(buildStellarExplorerAccountUrl("CNFT", "testnet")).toBeNull();
+ });
+});
+
describe("resolveStellarNetworkId", () => {
it("maps configured network values", () => {
expect(resolveStellarNetworkId("mainnet")).toBe("mainnet");
diff --git a/apps/web/src/lib/stellarExplorer.ts b/apps/web/src/lib/stellarExplorer.ts
index 7d9a7b8..e4c3736 100644
--- a/apps/web/src/lib/stellarExplorer.ts
+++ b/apps/web/src/lib/stellarExplorer.ts
@@ -2,6 +2,7 @@ export type StellarNetworkId = "testnet" | "mainnet";
const TX_HASH_PATTERN = /^[0-9a-fA-F]{64}$/;
const CONTRACT_ADDRESS_PATTERN = /^C[A-Z2-7]{55}$/;
+const ACCOUNT_ADDRESS_PATTERN = /^G[A-Z2-7]{55}$/;
/**
* Build a Stellar Expert explorer URL for a confirmed transaction hash.
@@ -35,3 +36,16 @@ export function buildStellarExplorerContractUrl(
const explorerNetwork = network === "mainnet" ? "public" : "testnet";
return `https://stellar.expert/explorer/${explorerNetwork}/contract/${contractId}`;
}
+
+/**
+ * Build a Stellar Expert explorer URL for a G... account address.
+ * Returns null when the address is missing or invalid so callers can skip the link.
+ */
+export function buildStellarExplorerAccountUrl(
+ address: string | null | undefined,
+ network: StellarNetworkId = "testnet",
+): string | null {
+ if (!address || !ACCOUNT_ADDRESS_PATTERN.test(address)) return null;
+ const explorerNetwork = network === "mainnet" ? "public" : "testnet";
+ return `https://stellar.expert/explorer/${explorerNetwork}/account/${address}`;
+}