+
+
+ {t(`status.${dispute.status}`)}
+
+ {t(`types.${dispute.dispute_type}`)}
+ {isActive && !isExpired && (
+
+ {t("expiresIn", { time: formatRemaining(dispute.expires_at) })}
+
+ )}
+
+
+
{dispute.description}
+
+ {(dispute.target_address || dispute.evidence_urls.length > 0) && (
+
+ {dispute.target_address && (
+
+ {t("against")}:{" "}
+ {shorten(dispute.target_address)}
+
+ )}
+ {dispute.evidence_urls.map((url) => (
+
+
+ {url}
+
+ ))}
+
+ )}
+
+ {(dispute.status === "resolved_upheld" || dispute.status === "resolved_dismissed") &&
+ dispute.resolution && (
+
+
{t("resolutionNote")}
+
{dispute.resolution}
+
+ )}
+
+
+
+
+ {t("voteCounts", { for: dispute.votes_for, against: dispute.votes_against, needed })}
+ {isActive && !isExpired && (
+ ({t("votesCast", { count: totalVotes })})
+ )}
+
+
+
+ {canVote && (
+ <>
+
+
+ >
+ )}
+ {isAdmin && isActive && (
+
+ )}
+
+
+
+
+
+ )
+}
+
+function formatRemaining(expiresAtIso: string): string {
+ const diffMs = new Date(expiresAtIso).getTime() - Date.now()
+ if (diffMs <= 0) return "0m"
+ const minutes = Math.floor(diffMs / 60_000)
+ if (minutes < 60) return `${minutes}m`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`
+ return `${Math.floor(hours / 24)}d ${String(hours % 24).padStart(2, "0")}h`
+}
+
+function shorten(address: string): string {
+ return address.length > 12 ? `${address.slice(0, 6)}…${address.slice(-4)}` : address
+}
diff --git a/frontend/components/disputes/disputes-panel.tsx b/frontend/components/disputes/disputes-panel.tsx
new file mode 100644
index 0000000..cf22e85
--- /dev/null
+++ b/frontend/components/disputes/disputes-panel.tsx
@@ -0,0 +1,105 @@
+"use client"
+
+/**
+ * DisputesPanel — pool disputes feed (issue #208).
+ * Composition root: loads disputes via useDisputes, renders the file-dispute
+ * dialog (members only) and the dispute cards. Admin actions appear for the
+ * pool creator. Rendered full-width below the group grid in GroupClient.
+ */
+
+import { useMemo } from "react"
+import { useTranslations } from "next-intl"
+import { Gavel } from "lucide-react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Skeleton } from "@/components/ui/skeleton"
+import { useDisputes } from "@/hooks/useDisputes"
+import { useStellar } from "@/components/web3-provider"
+import { DisputeCard } from "./dispute-card"
+import { FileDisputeDialog } from "./file-dispute-dialog"
+
+interface DisputesPanelProps {
+ poolId: string
+ memberAddresses: string[]
+ /** Pool creator address — gets the admin resolve buttons. */
+ poolAdmin: string | null
+}
+
+export function DisputesPanel({ poolId, memberAddresses, poolAdmin }: DisputesPanelProps) {
+ const t = useTranslations("group.disputes")
+ const { address } = useStellar()
+
+ const { disputes, loading, error, refresh, fileDispute, voteOnDispute, resolveDispute } =
+ useDisputes(poolId)
+
+ const isAdmin = useMemo(
+ () => !!address && !!poolAdmin && address.toLowerCase() === poolAdmin.toLowerCase(),
+ [address, poolAdmin]
+ )
+ const isMember = useMemo(
+ () => !!address && memberAddresses.some((m) => m.toLowerCase() === address.toLowerCase()),
+ [address, memberAddresses]
+ )
+
+ return (
+