From 66d99cd9b6b719a638799e7ff79067a03c65026a Mon Sep 17 00:00:00 2001 From: Ebenezer199914 Date: Sun, 28 Jun 2026 04:23:07 +0000 Subject: [PATCH] feat: add Duplicate Pool action (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix group-actions.tsx: remove duplicate imports and merge two conflicting partial component definitions into one clean export - Fix group/[id]/page.tsx: remove duplicate Pool interface fields and stale creatorAddress prop on GroupActions - Add prefill prop to TargetForm and FlexibleForm: pre-fills name, description, amount, and members from query-string params - group-details.tsx already has the 'Start New Cycle / Duplicate Pool' button and create/[type]/page.tsx already parses the ?duplicate=1 query params — wired end-to-end now that forms accept the prop - Add e2e spec: duplicate-pool.spec.ts covers all three pool types and verifies the pending-pool hide behaviour Closes #91 --- frontend/app/dashboard/group/[id]/page.tsx | 8 +- .../components/create-group/flexible-form.tsx | 18 +- .../components/create-group/target-form.tsx | 19 +- frontend/components/group/group-actions.tsx | 596 +++++++----------- frontend/e2e/duplicate-pool.spec.ts | 102 +++ 5 files changed, 343 insertions(+), 400 deletions(-) create mode 100644 frontend/e2e/duplicate-pool.spec.ts diff --git a/frontend/app/dashboard/group/[id]/page.tsx b/frontend/app/dashboard/group/[id]/page.tsx index 20248cb..d708f8b 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -15,17 +15,12 @@ import { useStellar } from "@/components/web3-provider"; import { useRecentPools } from "@/hooks/useRecentPools"; interface Pool { - id: string - name: string - type: 'rotational' | 'target' | 'flexible' - contract_address: string - token_address: string - creator_address: string id: string; name: string; type: "rotational" | "target" | "flexible"; contract_address: string; token_address: string; + creator_address: string; } const isPendingAddress = (addr: string) => @@ -123,7 +118,6 @@ export default function GroupPage({ poolAddress={pool.contract_address} poolType={pool.type} tokenAddress={pool.token_address} - creatorAddress={pool.creator_address} isPaused={isPaused} poolAdmin={poolAdmin} onPauseChange={refreshPoolState} diff --git a/frontend/components/create-group/flexible-form.tsx b/frontend/components/create-group/flexible-form.tsx index d9a4faf..38ea270 100644 --- a/frontend/components/create-group/flexible-form.tsx +++ b/frontend/components/create-group/flexible-form.tsx @@ -20,6 +20,7 @@ import { validatePositiveAmount, validateWithdrawalFee, } from "@/lib/form-validation" +import type { DuplicatePrefill } from "@/app/dashboard/create/[type]/page" function isValidStellarAddress(addr: string) { return /^G[A-Z2-7]{55}$/.test(addr) @@ -30,16 +31,25 @@ const TREASURY = process.env.NEXT_PUBLIC_FACTORY_CONTRACT_ID || "" type FieldErrors = Partial> type Touched = Partial> -export function FlexibleForm() { +export function FlexibleForm({ prefill }: { prefill?: DuplicatePrefill }) { const router = useRouter() const { address } = useStellar() const [token, setToken] = useState({ address: "native", symbol: "XLM", decimals: 7 }) - const [members, setMembers] = useState([""]) - const [memberErrors, setMemberErrors] = useState([""]) + const initialMembers = prefill?.members?.filter((m: string) => m !== address) ?? [""] + const [members, setMembers] = useState( + initialMembers.length > 0 ? initialMembers : [""] + ) + const [memberErrors, setMemberErrors] = useState( + initialMembers.length > 0 ? initialMembers.map(() => "") : [""] + ) const [error, setError] = useState("") const [step, setStep] = useState<"idle" | "deploying" | "initializing" | "registering" | "saving">("idle") const [formData, setFormData] = useState({ - name: "", description: "", minimumDeposit: "", enableYield: false, withdrawalFee: "1", + name: prefill?.name || "", + description: prefill?.description || "", + minimumDeposit: prefill?.amount || "", + enableYield: false, + withdrawalFee: "1", }) const [fieldErrors, setFieldErrors] = useState({}) const [touched, setTouched] = useState({}) diff --git a/frontend/components/create-group/target-form.tsx b/frontend/components/create-group/target-form.tsx index d31318d..67d4361 100644 --- a/frontend/components/create-group/target-form.tsx +++ b/frontend/components/create-group/target-form.tsx @@ -20,6 +20,7 @@ import { validatePositiveAmount, validateDeadline, } from "@/lib/form-validation" +import type { DuplicatePrefill } from "@/app/dashboard/create/[type]/page" function isValidStellarAddress(addr: string) { return /^G[A-Z2-7]{55}$/.test(addr) @@ -38,15 +39,25 @@ async function dateToLedger(date: Date): Promise { type FieldErrors = Partial> type Touched = Partial> -export function TargetForm() { +export function TargetForm({ prefill }: { prefill?: DuplicatePrefill }) { const router = useRouter() const { address } = useStellar() const [token, setToken] = useState({ address: "native", symbol: "XLM", decimals: 7 }) - const [members, setMembers] = useState([""]) - const [memberErrors, setMemberErrors] = useState([""]) + const initialMembers = prefill?.members?.filter((m: string) => m !== address) ?? [""] + const [members, setMembers] = useState( + initialMembers.length > 0 ? initialMembers : [""] + ) + const [memberErrors, setMemberErrors] = useState( + initialMembers.length > 0 ? initialMembers.map(() => "") : [""] + ) const [error, setError] = useState("") const [step, setStep] = useState<"idle" | "deploying" | "initializing" | "registering" | "saving">("idle") - const [formData, setFormData] = useState({ name: "", description: "", targetAmount: "", deadline: "" }) + const [formData, setFormData] = useState({ + name: prefill?.name || "", + description: prefill?.description || "", + targetAmount: prefill?.amount || "", + deadline: "", + }) const [fieldErrors, setFieldErrors] = useState({}) const [touched, setTouched] = useState({}) const errorRef = useRef(null) diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 5b97301..0e17a59 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -36,18 +36,6 @@ import { } from "@/hooks/useJointSaveContracts"; import { validateStellarAddress } from "@/lib/form-validation"; import { - useRotationalDeposit, useTriggerPayout, - useTargetContribute, useTargetWithdraw, useTargetRefund, - useFlexibleDeposit, useFlexibleWithdraw, -} from "@/hooks/useJointSaveContracts" -import { ExportPdfButton } from "@/components/group/export-pdf-button" - -interface GroupActionsProps { - groupId: string - poolAddress: string - poolType: "rotational" | "target" | "flexible" - tokenAddress: string - creatorAddress?: string Tooltip, TooltipContent, TooltipTrigger, @@ -99,12 +87,6 @@ async function logActivity( } catch {} } -export function GroupActions({ groupId, poolAddress, poolType, creatorAddress }: GroupActionsProps) { - const { address } = useStellar() - const [depositAmount, setDepositAmount] = useState("") - const [withdrawAmount, setWithdrawAmount] = useState("") - const [error, setError] = useState("") - const [successMsg, setSuccessMsg] = useState("") export function GroupActions({ groupId, poolAddress, @@ -123,19 +105,16 @@ export function GroupActions({ const [error, setError] = useState(""); const [successMsg, setSuccessMsg] = useState(""); - // Pool metadata from Supabase const [poolData, setPoolData] = useState(null); const [members, setMembers] = useState([]); const [newMember, setNewMember] = useState(""); const [memberToRemove, setMemberToRemove] = useState(null); const isPending = !poolAddress || poolAddress === "pending_deployment"; - // Token display metadata (persisted on the pool row; defaults to native XLM) const tokenSymbol: string = poolData?.token_symbol ?? "XLM"; const tokenDecimals: number = poolData?.token_decimals ?? 7; const toBaseUnits = (amount: number) => BigInt(Math.round(amount * 10 ** tokenDecimals)); - // Modal Preview states const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [previewLoading, setPreviewLoading] = useState(false); const [isConfirmLoading, setIsConfirmLoading] = useState(false); @@ -179,11 +158,9 @@ export function GroupActions({ const { optimisticState, registerOptimistic, updateTxHash, markFailed } = useOptimisticTransactions(poolAddress); - // Watch for confirmation/failure from optimistic state useEffect(() => { const { pendingTx } = optimisticState; if (!pendingTx) return; - if (pendingTx.status === "confirmed") { toastManager.success( `${pendingTx.type.charAt(0).toUpperCase() + pendingTx.type.slice(1)} confirmed ✓`, @@ -209,22 +186,13 @@ export function GroupActions({ ? toBaseUnits(parseFloat(depositAmount)) : undefined; registerOptimistic("deposit", address, amount); - let txHash: string | undefined; if (poolType === "rotational") txHash = await rotationalDeposit.deposit(); - else if (poolType === "target") - txHash = await targetContribute.contribute(); + else if (poolType === "target") txHash = await targetContribute.contribute(); else txHash = await flexibleDeposit.deposit(); - if (txHash) { updateTxHash(txHash); - await logActivity( - groupId, - "deposit", - address, - depositAmount || null, - txHash, - ); + await logActivity(groupId, "deposit", address, depositAmount || null, txHash); setSuccessMsg("Deposit submitted (confirming on-chain)…"); } } catch (e: any) { @@ -240,9 +208,7 @@ export function GroupActions({ if (!address) return setError("Please connect your wallet first"); if (isPending) return setError("Contract not yet deployed."); if (isPaused) return setError("Pool is paused. Withdrawals are disabled."); - if (poolType === "target") { - // Direct withdrawal since target pool exit has no fee parameters stored/previewed try { registerOptimistic("withdraw", address); const txHash = await targetWithdraw.withdraw(); @@ -257,15 +223,12 @@ export function GroupActions({ markFailed(msg); } } else { - // Flexible withdrawal preview const amount = parseFloat(withdrawAmount); if (isNaN(amount) || amount <= 0) return setError("Please enter a valid withdrawal amount"); - const feePercent = poolData?.withdrawal_fee ?? 0; const feeAmount = amount * (feePercent / 100); const netAmount = amount - feeAmount; - setPreviewData({ type: "withdraw", amount, @@ -284,13 +247,7 @@ export function GroupActions({ const txHash = await flexibleWithdraw.withdraw(); if (txHash) { updateTxHash(txHash); - await logActivity( - groupId, - "withdraw", - address, - withdrawAmount || null, - txHash, - ); + await logActivity(groupId, "withdraw", address, withdrawAmount || null, txHash); setSuccessMsg("Withdrawal submitted (confirming on-chain)…"); setWithdrawAmount(""); } @@ -306,36 +263,24 @@ export function GroupActions({ if (!address) return setError("Please connect your wallet first"); if (isPending) return setError("Contract not yet deployed."); if (isPaused) return setError("Pool is paused. Payouts are disabled."); - setPreviewLoading(true); try { - // Fetch rotational pool state on-chain const state = await fetchRotationalState(poolAddress); - if (state.treasuryFeeBps === null || state.relayerFeeBps === null) { toastManager.error("Unable to load pool fee configuration. Please try again."); return; } - - const treasuryFeeBps = state.treasuryFeeBps; - const relayerFeeBps = state.relayerFeeBps; - const depositCount = state.depositCount; - const currentRound = state.currentRound; - const members = state.members; + const { treasuryFeeBps, relayerFeeBps, depositCount, currentRound, members } = state; const beneficiary = members[currentRound] || "Unknown beneficiary"; - const treasuryPercent = treasuryFeeBps / 100; const relayerPercent = relayerFeeBps / 100; - const contribution = parseFloat(poolData?.contribution_amount ?? "0"); const totalCollected = contribution * depositCount; const treasuryCut = totalCollected * (treasuryFeeBps / 10000); const relayerCut = totalCollected * (relayerFeeBps / 10000); const payoutAmount = totalCollected - treasuryCut - relayerCut; - const shortAddress = (addr: string) => addr.length > 12 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; - setPreviewData({ type: "payout", amount: totalCollected, @@ -356,10 +301,7 @@ export function GroupActions({ }, { label: "Net Recipient Payout", value: `${payoutAmount.toFixed(2)} ${tokenSymbol}` }, { label: "Beneficiary Address", value: shortAddress(beneficiary) }, - { - label: "Your Relayer Reward (expected)", - value: `${relayerCut.toFixed(2)} ${tokenSymbol}`, - }, + { label: "Your Relayer Reward (expected)", value: `${relayerCut.toFixed(2)} ${tokenSymbol}` }, ], onConfirm: async () => { registerOptimistic("trigger_payout", address); @@ -373,8 +315,7 @@ export function GroupActions({ }); setIsPreviewOpen(true); } catch (e: any) { - const msg = e.message || "Failed to load payout details"; - setError(msg); + setError(e.message || "Failed to load payout details"); } finally { setPreviewLoading(false); } @@ -430,10 +371,8 @@ export function GroupActions({ if (!address) return setError("Please connect your wallet first"); if (!isAdmin) return setError("Only the pool admin can manage members."); if (isPending) return setError("Contract not yet deployed."); - const validation = validateStellarAddress(newMember.trim().toUpperCase()); if (!validation.valid) return setError(validation.message); - try { const txHash = await addPoolMember.addMember(newMember.trim().toUpperCase()); if (txHash) { @@ -454,7 +393,6 @@ export function GroupActions({ if (!isAdmin) return setError("Only the pool admin can manage members."); if (isPending) return setError("Contract not yet deployed."); if (!memberToRemove) return; - try { const txHash = await removePoolMember.removeMember(memberToRemove); if (txHash) { @@ -493,7 +431,6 @@ export function GroupActions({ const formatAddress = (addr: string) => addr.length > 18 ? `${addr.slice(0, 8)}...${addr.slice(-8)}` : addr; - // Helper to render pending badge const renderPendingBadge = () => { const { pendingTx } = optimisticState; if (pendingTx && pendingTx.status === "pending") { @@ -514,7 +451,6 @@ export function GroupActions({ ⚠️ Pool is paused — all transactions are disabled. )} - {isPending && !isPaused && (
Contract pending deployment. @@ -526,24 +462,20 @@ export function GroupActions({ Quick Actions {renderPendingBadge()} - {/* Skeleton while pool metadata is loading */} {!poolData && !isPending && (
- {/* deposit field + button */}
- {/* withdraw field + button */}
- {/* admin controls */}
@@ -560,7 +492,6 @@ export function GroupActions({

{error}

)} - {successMsg && (
@@ -568,330 +499,248 @@ export function GroupActions({
)} - {/* Actual form — shown once pool metadata resolves (or contract is pending) */} {(poolData || isPending) && ( -
- {/* Deposit / Contribute */} -
- - {!isRotational && ( - setDepositAmount(e.target.value)} - disabled={isDepositLoading || actionsDisabled} - /> - )} -

- {isRotational && - "Deposit the fixed pool amount. Same for all members."} - {isTarget && "Contribute any amount toward the target goal."} - {isFlexible && - "Deposit any amount (must meet minimum). Withdraw anytime."} -

- -
- - {/* Withdraw */} - {!isRotational && ( -
-