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
7 changes: 5 additions & 2 deletions apps/web/src/__tests__/CommunityDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,14 @@ describe("CommunityDetailPage", () => {
`/communities/${COMMUNITY_ID}/proposals`,
);
expect(
screen.getAllByRole("button", { name: /Copy full .* address/ }),
screen.getAllByRole("button", { name: /^Copy .* contract$/ }),
).toHaveLength(2);
expect(
screen.getAllByRole("link", { name: /on Stellar Expert/ }),
screen.getAllByRole("link", { name: /Open .* contract in explorer/ }),
).toHaveLength(2);
expect(
screen.getByRole("button", { name: "Copy Community owner" }),
).toBeInTheDocument();
});

it("renders malformed and unknown IDs as clear not-found states", async () => {
Expand Down
65 changes: 16 additions & 49 deletions apps/web/src/app/(app)/communities/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,67 +8,37 @@ import { AsyncState } from "@/components/ui/AsyncState";
import { ErrorState } from "@/components/ui/ErrorState";
import { FreshnessNotice } from "@/components/ui/FreshnessNotice";
import { LiveStatus } from "@/components/ui/LiveStatus";
import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier";
import { Skeleton } from "@/components/ui/Skeleton";
import { useCommunityRegistry } from "@/lib/community/CommunityRegistryProvider";
import type {
CommunityDetailResult,
CommunityRegistryRecord,
} from "@/lib/community/types";
import {
buildStellarExplorerContractUrl,
resolveStellarNetworkId,
} from "@/lib/stellarExplorer";
import { truncateMiddle } from "@/lib/truncate";

function ContractAddress({
label,
record,
contractId,
onCopy,
}: {
label: string;
record: CommunityRegistryRecord;
contractId: string;
onCopy: (label: string, contractId: string) => void;
}) {
const explorerUrl = buildStellarExplorerContractUrl(
contractId,
resolveStellarNetworkId(),
);

return (
<div className="min-w-0 rounded-lg border border-slate-800 bg-[#0b0f19] p-4">
<dt className="text-xs font-medium uppercase tracking-wide text-slate-500">
{label}
</dt>
<dd className="mt-2 min-w-0">
<span
className="block break-all font-mono text-sm text-slate-200"
title={contractId}
>
{truncateMiddle(contractId, 12, 10)}
</span>
<span className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => onCopy(label, contractId)}
className="min-h-11 rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800"
aria-label={`Copy full ${label.toLowerCase()} address`}
>
Copy address
</button>
{explorerUrl && (
<a
href={explorerUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center rounded-lg border border-slate-700 px-3 py-2 text-sm text-slate-200 hover:bg-slate-800"
aria-label={`View ${label.toLowerCase()} on Stellar Expert`}
>
Open explorer
</a>
)}
</span>
<OnChainIdentifier
label={label}
value={contractId}
kind="contract"
truncateStart={12}
truncateEnd={10}
/>
</dd>
<span className="sr-only">Community {record.id}</span>
</div>
Expand Down Expand Up @@ -115,10 +85,6 @@ export default function CommunityDetailPage() {
}
}

function copyAddress(label: string, address: string) {
void copyValue(`${label} address`, address);
}

async function shareCommunity(name: string, id: string) {
setCopyStatus("");
const canonicalUrl = `${window.location.origin}/communities/${id}`;
Expand Down Expand Up @@ -328,13 +294,11 @@ export default function CommunityDetailPage() {
label="NFT contract"
record={record}
contractId={record.nftContract}
onCopy={copyAddress}
/>
<ContractAddress
label="Governor contract"
record={record}
contractId={record.governorContract}
onCopy={copyAddress}
/>
</dl>
</section>
Expand Down Expand Up @@ -409,11 +373,14 @@ export default function CommunityDetailPage() {
</div>
<div className="min-w-0">
<dt className="text-slate-500">Community owner</dt>
<dd
title={record.communityOwner}
className="mt-1 break-all font-mono text-slate-200"
>
{truncateMiddle(record.communityOwner, 10, 8)}
<dd className="mt-1 min-w-0 text-slate-200">
<OnChainIdentifier
label="Community owner"
value={record.communityOwner}
kind="account"
truncateStart={10}
truncateEnd={8}
/>
</dd>
</div>
<div className="min-w-0">
Expand Down
62 changes: 6 additions & 56 deletions apps/web/src/app/(app)/proposals/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,8 @@ import { useTransactionLifecycle } from "@/hooks/useTransactionLifecycle";
import { TransactionLifecycleDisplay } from "@/components/TransactionLifecycleDisplay";
import { truncateMiddle } from "@/lib/truncate";
import { LiveStatus } from "@/components/ui/LiveStatus";
import { OnChainIdentifier } from "@/components/ui/OnChainIdentifier";
import type { Community } from "@/lib/community/types";

function shortenAddress(addr: string): string {
if (addr.length <= 12) return addr;
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}
import { fetchVoteTotals, type VoteTotals } from "@/lib/voteAggregation";
import { fmt, pct } from "@/lib/voteDisplay";

Expand Down Expand Up @@ -64,7 +60,6 @@ export default function ProposalDetailPage({
const [reason, setReason] = useState("Support");
const [status, setStatus] = useState<string | null>(null);
const [proposer, setProposer] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [totals, setTotals] = useState<VoteTotals | null>(null);
const [quorum, setQuorum] = useState<bigint | null>(null);
const [totalsError, setTotalsError] = useState<string | null>(null);
Expand Down Expand Up @@ -340,13 +335,8 @@ export default function ProposalDetailPage({
<div className="mx-auto max-w-3xl px-4 py-10">
<LiveStatus className="sr-only">Loading proposal…</LiveStatus>
<h1 className="text-2xl font-bold text-slate-100">Proposal</h1>
<div className="mt-2 flex items-center gap-2">
<p
className="truncate font-mono text-sm text-slate-400"
title={proposalIdHex}
>
{truncateMiddle(proposalIdHex)}
</p>
<div className="mt-2">
<OnChainIdentifier label="Proposal ID" value={proposalIdHex} kind="opaque" />
</div>
<div className="mt-6 grid gap-3 rounded-xl border border-slate-800 bg-[#151b2b] p-5 text-sm sm:grid-cols-2">
<div>
Expand Down Expand Up @@ -385,22 +375,8 @@ export default function ProposalDetailPage({
</nav>
)}
<h1 className="text-2xl font-bold text-slate-100">Proposal</h1>
<div className="mt-2 flex items-center gap-2">
<p
className="truncate font-mono text-sm text-slate-400"
title={proposalIdHex}
>
{truncateMiddle(proposalIdHex)}
</p>
<button
type="button"
onClick={() => navigator.clipboard.writeText(proposalIdHex)}
className="shrink-0 rounded px-2 py-0.5 text-xs text-slate-500 transition hover:bg-slate-800 hover:text-slate-300"
title="Copy proposal ID"
aria-label={`Copy proposal ID ${proposalIdHex}`}
>
Copy
</button>
<div className="mt-2">
<OnChainIdentifier label="Proposal ID" value={proposalIdHex} kind="opaque" />
</div>

<dl className="mt-6 grid gap-3 rounded-xl border border-slate-800 bg-[#151b2b] p-5 text-sm sm:grid-cols-2">
Expand Down Expand Up @@ -467,39 +443,13 @@ export default function ProposalDetailPage({
<dt className="text-slate-500">Proposer</dt>
<dd className="mt-1">
{proposer ? (
<span className="inline-flex items-center gap-2">
<span
className="font-mono text-sm text-slate-200"
title={proposer}
>
{shortenAddress(proposer)}
</span>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(proposer);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API unavailable
}
}}
aria-label={`Copy full proposer address ${proposer}`}
className="rounded-md border border-slate-700 bg-slate-800 px-2 py-0.5 text-xs text-slate-400 transition hover:border-slate-600 hover:text-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
>
{copied ? "Copied!" : "Copy"}
</button>
</span>
<OnChainIdentifier label="Proposer" value={proposer} kind="account" />
) : (
<span className="text-slate-600">Unknown</span>
)}
</dd>
</div>
</dl>
<div aria-live="polite" className="sr-only">
{copied ? "Proposer address copied to clipboard" : null}
</div>


<section className="mt-6 rounded-xl border border-slate-800 bg-[#151b2b] p-5">
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/app/(app)/proposals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,6 @@ export default function ProposalsPage() {
: undefined
}
isRetryingState={isRetrying}
onCopyId={() => void navigator.clipboard.writeText(id)}
/>
</li>
);
Expand Down
16 changes: 10 additions & 6 deletions apps/web/src/components/CommunityCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,12 +21,15 @@ export function CommunityCard({ community }: { community: Community }) {
<h2 className="break-words text-lg font-semibold text-slate-100 [overflow-wrap:anywhere]">
{name}
</h2>
<p
className="mt-0.5 break-all font-mono text-xs text-slate-500"
title={record.id}
>
{truncateMiddle(record.id, 10, 8)}
</p>
<div className="mt-0.5 text-xs text-slate-500">
<OnChainIdentifier
label="Community ID"
value={record.id}
kind="opaque"
truncateStart={10}
truncateEnd={8}
/>
</div>
</div>
</div>

Expand Down
35 changes: 10 additions & 25 deletions apps/web/src/components/CommunityDeploymentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -481,28 +479,15 @@ export function CommunityDeploymentPanel({
["Governor", expected.governorContract],
] as const
).map(([label, contractId]) => (
<div key={label} className="rounded-lg bg-slate-950/50 p-3">
<div key={label} className="min-w-0 rounded-lg bg-slate-950/50 p-3">
<p className="text-xs text-slate-400">{label} contract</p>
<p className="mt-1 break-all font-mono text-xs text-slate-100">
{contractId}
</p>
<div className="mt-2 flex flex-wrap gap-3">
<button
type="button"
onClick={() => void navigator.clipboard.writeText(contractId)}
className="min-h-11 text-sm text-indigo-300"
aria-label={`Copy full ${label} contract address`}
>
Copy address
</button>
<a
href={buildStellarExplorerContractUrl(contractId, network) ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center text-sm text-indigo-300"
>
Open explorer
</a>
<div className="mt-1">
<OnChainIdentifier
label={`${label} contract`}
value={contractId}
kind="contract"
network={network}
/>
</div>
</div>
))}
Expand Down
15 changes: 2 additions & 13 deletions apps/web/src/components/ProposalSummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -31,7 +32,6 @@ export type ProposalSummaryCardProps = {
showDescription?: boolean;
onRetryState?: () => void;
isRetryingState?: boolean;
onCopyId?: () => void;
href?: string;
};

Expand Down Expand Up @@ -89,7 +89,6 @@ export function ProposalSummaryCard({
showDescription = false,
onRetryState,
isRetryingState = false,
onCopyId,
href,
}: ProposalSummaryCardProps) {
const { proposalId } = summary;
Expand Down Expand Up @@ -140,17 +139,7 @@ export function ProposalSummaryCard({
{isRetryingState ? "Retrying…" : "Retry state"}
</button>
)}
{onCopyId && (
<button
type="button"
onClick={onCopyId}
className="rounded px-2 py-1 text-xs text-slate-400 transition hover:bg-slate-700 hover:text-slate-200"
title="Copy proposal ID"
aria-label={`Copy proposal ID ${proposalId}`}
>
Copy
</button>
)}
<OnChainIdentifier label="Proposal ID" value={proposalId} kind="opaque" hideValue />
</div>
</div>
);
Expand Down
Loading