diff --git a/frontend/src/components/CreateProposalModal.test.tsx b/frontend/src/components/CreateProposalModal.test.tsx index 62f2f60..ee71729 100644 --- a/frontend/src/components/CreateProposalModal.test.tsx +++ b/frontend/src/components/CreateProposalModal.test.tsx @@ -116,12 +116,69 @@ describe("CreateProposalModal", () => { const submitBtn = screen.getByText("Submit Proposal"); fireEvent.click(submitBtn); + expect(screen.getByText("Preview Proposal")).toBeDefined(); + expect(createProposal).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText("Confirm & Submit")); + await waitFor(() => { expect(createProposal).toHaveBeenCalled(); expect(defaultProps.onSubmitted).toHaveBeenCalled(); }); }); + it("shows a preview with entered values before submitting", () => { + render(); + fillRequiredFields(); + + const deadlineInput = document.querySelector( + 'input[type="date"]' + ) as HTMLInputElement; + const expectedDeadline = new Date( + `${deadlineInput.value}T00:00:00` + ).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); + + fireEvent.click(screen.getByText("Submit Proposal")); + + expect(screen.getByText("Preview Proposal")).toBeDefined(); + expect(screen.getByText("GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4IQDNC")).toBeDefined(); + expect(screen.getByText("10 XLM")).toBeDefined(); + expect(screen.getByText("Test payment")).toBeDefined(); + expect(screen.getByText(expectedDeadline)).toBeDefined(); + expect(createProposal).not.toHaveBeenCalled(); + }); + + it("Back returns to the form with entered values preserved", () => { + render(); + fillRequiredFields(); + + fireEvent.click(screen.getByText("Submit Proposal")); + fireEvent.click(screen.getByText("Back")); + + expect(screen.queryByText("Preview Proposal")).toBeNull(); + expect(screen.getByPlaceholderText("G...")).toHaveValue( + "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4IQDNC" + ); + expect(screen.getByPlaceholderText("0.00")).toHaveValue(10); + expect(screen.getByPlaceholderText("What is this payment for?")).toHaveValue( + "Test payment" + ); + }); + + it("Close button works from the preview step", () => { + render(); + fillRequiredFields(); + + fireEvent.click(screen.getByText("Submit Proposal")); + fireEvent.click(screen.getByText("✕")); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + it("Connected wallet opens modal and shows Proposer field", () => { render(); expect(screen.getByText("Proposer")).toBeDefined(); diff --git a/frontend/src/components/CreateProposalModal.tsx b/frontend/src/components/CreateProposalModal.tsx index ea87a75..dba134b 100644 --- a/frontend/src/components/CreateProposalModal.tsx +++ b/frontend/src/components/CreateProposalModal.tsx @@ -18,11 +18,22 @@ type Props = { triggerRef?: RefObject; }; +type ProposalStep = "form" | "preview"; + function truncateAddress(address: string | null) { if (!address) return "Not connected"; return `${address.slice(0, 6)}…${address.slice(-4)}`; } +function formatDeadline(deadline: string) { + return new Date(`${deadline}T00:00:00`).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export function CreateProposalModal({ walletAddress, onClose, onSubmitted }: Props) { export function CreateProposalModal({ walletAddress, onClose, onSubmitted, triggerRef }: Props) { const defaultDeadline = () => { const d = new Date(); @@ -36,6 +47,7 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg const [token, setToken] = useState("XLM"); const [description, setDescription] = useState(""); const [deadline, setDeadline] = useState(defaultDeadline); + const [step, setStep] = useState("form"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -151,63 +163,78 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg } } - async function handleSubmit() { + function getValidatedProposal() { if (!walletAddress) { setError("Connect your wallet first."); - return; + return null; } if (!to.trim() || !amount.trim() || !description.trim()) { setError("Recipient, amount, and description are required."); - return; + return null; } if (!StrKey.isValidEd25519PublicKey(to.trim())) { setError("Enter a valid Stellar address"); - return; + return null; } const amountNum = parseFloat(amount); if (isNaN(amountNum) || amountNum <= 0) { setError("Enter a valid amount."); - return; + return null; } const tokenAddr = TOKEN_ADDRESSES[token]; if (!tokenAddr) { setError("Unknown token."); - return; + return null; } - const amountStroops = displayToStroops(amountNum); - - // Deadline validation const deadlineMs = new Date(deadline).getTime(); const nowMs = Date.now(); const todayMidnight = new Date(); todayMidnight.setHours(0, 0, 0, 0); if (deadlineMs <= todayMidnight.getTime()) { setError("Deadline must be in the future."); - return; + return null; } const maxMs = nowMs + 90 * 24 * 3600 * 1000; if (deadlineMs > maxMs) { setError("Deadline cannot be more than 90 days away."); - return; + return null; } - // Deadline: Unix seconds - const deadlineUnix = BigInt(Math.floor(deadlineMs / 1000)); + return { + recipient: to.trim(), + tokenAddr, + amountStroops: displayToStroops(amountNum), + description: description.trim(), + deadlineUnix: BigInt(Math.floor(deadlineMs / 1000)), + }; + } + + function handleSubmit() { + const proposal = getValidatedProposal(); + if (!proposal) return; + + setError(null); + setStep("preview"); + } + + async function handleConfirmSubmit() { + const proposal = getValidatedProposal(); + if (!proposal) return; setError(null); setSubmitting(true); try { await createProposal( - walletAddress, - to.trim(), - tokenAddr, - amountStroops, - description.trim(), - deadlineUnix + walletAddress as string, + proposal.recipient, + proposal.tokenAddr, + proposal.amountStroops, + proposal.description, + proposal.deadlineUnix ); onSubmitted(); onClose(); @@ -242,77 +269,40 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg
-
- -
- {truncateAddress(walletAddress)} -
-
- -
- - { - setTo(e.target.value); - setRecipientTouched(true); - }} - onBlur={() => setRecipientTouched(true)} - placeholder="G..." - className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-white text-sm font-mono placeholder-zinc-600 focus:ring-2 focus:ring-zinc-400 focus:outline-none focus:border-zinc-500" - /> - {recipientTouched && !StrKey.isValidEd25519PublicKey(to.trim()) && ( -

Enter a valid Stellar address

- )} -
- -
-
- - setAmount(e.target.value)} - placeholder="0.00" - type="number" - min="0" - step="any" - className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-white text-sm placeholder-zinc-600 focus:ring-2 focus:ring-zinc-400 focus:outline-none focus:border-zinc-500" - /> -
-
- -
- {(["XLM", "USDC", "EURC"] as const).map((symbol) => { - const active = token === symbol; - - return ( - - ); - })} + {step === "form" ? ( + <> +
+ +
+ {truncateAddress(walletAddress)} +
+ +
+ + { + setTo(e.target.value); + setRecipientTouched(true); + }} + onBlur={() => setRecipientTouched(true)} + placeholder="G..." + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-white text-sm font-mono placeholder-zinc-600 focus:ring-2 focus:ring-zinc-400 focus:outline-none focus:border-zinc-500" + /> + {recipientTouched && !StrKey.isValidEd25519PublicKey(to.trim()) && ( +

Enter a valid Stellar address

@@ -365,36 +355,177 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg No estimate yet )}
- -
- )} - {error && ( -

- {error} -

- )} +
+
+ + setAmount(e.target.value)} + placeholder="0.00" + type="number" + min="0" + step="any" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-white text-sm placeholder-zinc-600 focus:ring-2 focus:ring-zinc-400 focus:outline-none focus:border-zinc-500" + /> +
+
+ +
+ {(["XLM", "USDC", "EURC"] as const).map((symbol) => { + const active = token === symbol; + + return ( + + ); + })} +
+
+
-
- -
+
+ + setDescription(e.target.value)} + placeholder="What is this payment for?" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-white text-sm placeholder-zinc-600 focus:ring-2 focus:ring-zinc-400 focus:outline-none focus:border-zinc-500" + /> +
+ +
+ + setDeadline(e.target.value)} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-white text-sm focus:ring-2 focus:ring-zinc-400 focus:outline-none focus:border-zinc-500" + /> +
+ + {canCalculateFee && ( +
+
+ {feeLoading ? ( + Estimating fee… + ) : feeError ? ( + Could not estimate fee + ) : feeEstimate !== null ? ( + + Estimated fee: ~{feeEstimate.toFixed(7)} XLM + + ) : ( + No estimate yet + )} +
+ +
+ )} + + {error && ( +

+ {error} +

+ )} + +
+ +
+ + ) : ( + <> +
+

Preview Proposal

+
+
+
Recipient Address
+
+ {to.trim()} +
+
+
+
Amount
+
+ {amount.trim()} {token} +
+
+
+
Description
+
+ {description.trim()} +
+
+
+
Deadline
+
+ {formatDeadline(deadline)} +
+
+
+
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+ + )}