From 2cf5489b34c1580a200672ef41ef6e47bd8feb74 Mon Sep 17 00:00:00 2001 From: gellu Date: Thu, 18 Jun 2026 01:07:34 +0100 Subject: [PATCH] feat: global toast notification system (#38) - Add success, info, warning variants to toast.tsx - Update use-toast.ts with duration support and auto-dismiss - Migrate group-actions.tsx to use toasts with Stellar Explorer links - Migrate flexible-form, rotational-form, target-form to toasts - Remove inline error/success divs from audited components --- .../components/create-group/flexible-form.tsx | 22 +-- .../create-group/rotational-form.tsx | 23 +-- .../components/create-group/target-form.tsx | 26 ++- frontend/components/group/group-actions.tsx | 153 ++++++++++++------ frontend/components/ui/toast.tsx | 29 ++-- frontend/hooks/use-toast.ts | 19 ++- 6 files changed, 159 insertions(+), 113 deletions(-) diff --git a/frontend/components/create-group/flexible-form.tsx b/frontend/components/create-group/flexible-form.tsx index 170e243..79d4583 100644 --- a/frontend/components/create-group/flexible-form.tsx +++ b/frontend/components/create-group/flexible-form.tsx @@ -6,10 +6,11 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" -import { Plus, X, Loader2, AlertCircle } from "lucide-react" +import { Plus, X, Loader2, ExternalLink } from "lucide-react" import { useRouter } from "next/navigation" import { useStellar } from "@/components/web3-provider" import { useDeployPool, useInitializePool, useRegisterPool } from "@/hooks/useJointSaveContracts" +import { useToast } from "@/hooks/use-toast" function isValidStellarAddress(addr: string) { return /^G[A-Z2-7]{55}$/.test(addr) @@ -22,8 +23,8 @@ export function FlexibleForm() { const router = useRouter() const { address } = useStellar() const [members, setMembers] = useState([""]) - const [error, setError] = useState("") const [step, setStep] = useState<"idle" | "deploying" | "initializing" | "registering" | "saving">("idle") + const { toast } = useToast() const [formData, setFormData] = useState({ name: "", description: "", minimumDeposit: "", enableYield: false, withdrawalFee: "1", }) @@ -42,11 +43,10 @@ export function FlexibleForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - setError("") - if (!address) return setError("Please connect your wallet first") - if (validMembers.length < 2) return setError("Need at least 2 valid Stellar addresses (you + 1 other)") - if (!formData.name) return setError("Please enter a group name") - if (!formData.minimumDeposit) return setError("Please enter a minimum deposit") + if (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (validMembers.length < 2) return toast({ variant: "destructive", title: "Invalid members", description: "Need at least 2 valid Stellar addresses (you + 1 other)" }) + if (!formData.name) return toast({ variant: "destructive", title: "Missing name", description: "Please enter a group name" }) + if (!formData.minimumDeposit) return toast({ variant: "destructive", title: "Missing deposit", description: "Please enter a minimum deposit" }) try { setStep("deploying") @@ -94,7 +94,7 @@ export function FlexibleForm() { const pool = await res.json() router.push(`/dashboard/group/${pool.id}`) } catch (err: any) { - setError(err.message || "Failed to create group") + toast({ variant: "destructive", title: "Failed to create group", description: err.message || "Failed to create group" }) setStep("idle") } } @@ -109,12 +109,6 @@ export function FlexibleForm() { return (
- {error && ( -
- -

{error}

-
- )} {isCreating && (
diff --git a/frontend/components/create-group/rotational-form.tsx b/frontend/components/create-group/rotational-form.tsx index 7fc2cdb..3789f4f 100644 --- a/frontend/components/create-group/rotational-form.tsx +++ b/frontend/components/create-group/rotational-form.tsx @@ -7,10 +7,11 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Plus, X, Loader2, AlertCircle, CheckCircle2 } from "lucide-react" +import { Plus, X, Loader2, ExternalLink } from "lucide-react" import { useRouter } from "next/navigation" import { useStellar } from "@/components/web3-provider" import { useDeployPool, useInitializePool, useRegisterPool } from "@/hooks/useJointSaveContracts" +import { useToast } from "@/hooks/use-toast" function isValidStellarAddress(addr: string) { return /^G[A-Z2-7]{55}$/.test(addr) @@ -32,8 +33,8 @@ export function RotationalForm() { const { address } = useStellar() // Creator is always the first member (read-only), others are editable const [members, setMembers] = useState([""]) - const [error, setError] = useState("") const [step, setStep] = useState<"idle" | "deploying" | "initializing" | "registering" | "saving">("idle") + const { toast } = useToast() const [formData, setFormData] = useState({ name: "", description: "", @@ -58,11 +59,10 @@ export function RotationalForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - setError("") - if (!address) return setError("Please connect your wallet first") - if (validMembers.length < 2) return setError("Need at least 2 valid Stellar addresses (you + 1 other)") - if (!formData.name) return setError("Please enter a group name") - if (!formData.contributionAmount) return setError("Please enter a contribution amount") + if (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (validMembers.length < 2) return toast({ variant: "destructive", title: "Invalid members", description: "Need at least 2 valid Stellar addresses (you + 1 other)" }) + if (!formData.name) return toast({ variant: "destructive", title: "Missing name", description: "Please enter a group name" }) + if (!formData.contributionAmount) return toast({ variant: "destructive", title: "Missing amount", description: "Please enter a contribution amount" }) try { // 1. Deploy contract instance from WASM hash @@ -111,7 +111,7 @@ export function RotationalForm() { const pool = await res.json() router.push(`/dashboard/group/${pool.id}`) } catch (err: any) { - setError(err.message || "Failed to create group") + toast({ variant: "destructive", title: "Failed to create group", description: err.message || "Failed to create group" }) setStep("idle") } } @@ -126,13 +126,6 @@ export function RotationalForm() { return ( - {error && ( -
- -

{error}

-
- )} - {isCreating && (
diff --git a/frontend/components/create-group/target-form.tsx b/frontend/components/create-group/target-form.tsx index 1ef40c7..6d14f73 100644 --- a/frontend/components/create-group/target-form.tsx +++ b/frontend/components/create-group/target-form.tsx @@ -6,10 +6,11 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" -import { Plus, X, Loader2, AlertCircle } from "lucide-react" +import { Plus, X, Loader2, ExternalLink } from "lucide-react" import { useRouter } from "next/navigation" import { useStellar } from "@/components/web3-provider" import { useDeployPool, useInitializePool, useRegisterPool, getRpc } from "@/hooks/useJointSaveContracts" +import { useToast } from "@/hooks/use-toast" function isValidStellarAddress(addr: string) { return /^G[A-Z2-7]{55}$/.test(addr) @@ -31,8 +32,8 @@ export function TargetForm() { const router = useRouter() const { address } = useStellar() const [members, setMembers] = useState([""]) - const [error, setError] = useState("") const [step, setStep] = useState<"idle" | "deploying" | "initializing" | "registering" | "saving">("idle") + const { toast } = useToast() const [formData, setFormData] = useState({ name: "", description: "", targetAmount: "", deadline: "" }) const { deploy } = useDeployPool() @@ -49,13 +50,12 @@ export function TargetForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - setError("") - if (!address) return setError("Please connect your wallet first") - if (validMembers.length < 2) return setError("Need at least 2 valid Stellar addresses (you + 1 other)") - if (!formData.name) return setError("Please enter a group name") - if (!formData.targetAmount) return setError("Please enter a target amount") - if (!formData.deadline) return setError("Please select a deadline") - if (new Date(formData.deadline) <= new Date()) return setError("Deadline must be in the future") + if (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (validMembers.length < 2) return toast({ variant: "destructive", title: "Invalid members", description: "Need at least 2 valid Stellar addresses (you + 1 other)" }) + if (!formData.name) return toast({ variant: "destructive", title: "Missing name", description: "Please enter a group name" }) + if (!formData.targetAmount) return toast({ variant: "destructive", title: "Missing target", description: "Please enter a target amount" }) + if (!formData.deadline) return toast({ variant: "destructive", title: "Missing deadline", description: "Please select a deadline" }) + if (new Date(formData.deadline) <= new Date()) return toast({ variant: "destructive", title: "Invalid deadline", description: "Deadline must be in the future" }) try { setStep("deploying") @@ -99,7 +99,7 @@ export function TargetForm() { const pool = await res.json() router.push(`/dashboard/group/${pool.id}`) } catch (err: any) { - setError(err.message || "Failed to create group") + toast({ variant: "destructive", title: "Failed to create group", description: err.message || "Failed to create group" }) setStep("idle") } } @@ -119,12 +119,6 @@ export function TargetForm() { return ( - {error && ( -
- -

{error}

-
- )} {isCreating && (
diff --git a/frontend/components/group/group-actions.tsx b/frontend/components/group/group-actions.tsx index 94edcbe..dccc9e5 100644 --- a/frontend/components/group/group-actions.tsx +++ b/frontend/components/group/group-actions.tsx @@ -5,7 +5,7 @@ import { Card } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Loader2, ArrowUpRight, ArrowDownLeft, AlertCircle, CheckCircle2, ShieldOff, ShieldCheck } from "lucide-react" +import { Loader2, ArrowUpRight, ArrowDownLeft, ShieldOff, ShieldCheck, ExternalLink } from "lucide-react" import { useStellar } from "@/components/web3-provider" import { useRotationalDeposit, useTriggerPayout, @@ -14,6 +14,7 @@ import { usePausePool, useUnpausePool, } from "@/hooks/useJointSaveContracts" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { useToast } from "@/hooks/use-toast" interface GroupActionsProps { groupId: string @@ -43,8 +44,7 @@ export function GroupActions({ groupId, poolAddress, poolType, isPaused = false, const isAdmin = !!address && !!poolAdmin && address.toUpperCase() === poolAdmin.toUpperCase() const [depositAmount, setDepositAmount] = useState("") const [withdrawAmount, setWithdrawAmount] = useState("") - const [error, setError] = useState("") - const [successMsg, setSuccessMsg] = useState("") + const { toast } = useToast() const rotationalDeposit = useRotationalDeposit(poolAddress) const triggerPayout = useTriggerPayout(poolAddress) @@ -59,10 +59,9 @@ export function GroupActions({ groupId, poolAddress, poolType, isPaused = false, const isPending = !poolAddress || poolAddress === "pending_deployment" const handleDeposit = async () => { - setError(""); setSuccessMsg("") - if (!address) return setError("Please connect your wallet first") - if (isPending) return setError("Contract not yet deployed.") - if (isPaused) return setError("Pool is paused. Deposits are disabled.") + if (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (isPending) return toast({ variant: "destructive", title: "Contract not deployed", description: "Contract not yet deployed." }) + if (isPaused) return toast({ variant: "destructive", title: "Pool paused", description: "Pool is paused. Deposits are disabled." }) try { let txHash: string | undefined if (poolType === "rotational") txHash = await rotationalDeposit.deposit() @@ -71,17 +70,27 @@ export function GroupActions({ groupId, poolAddress, poolType, isPaused = false, if (txHash) { await logActivity(groupId, "deposit", address, depositAmount || null, txHash) - setSuccessMsg("Deposit successful!") + toast({ + variant: "success", + title: "Deposit successful!", + description: `${depositAmount || "0"} XLM deposited into the pool.`, + action: , + }) setDepositAmount("") } - } catch (e: any) { setError(e.message || "Transaction failed") } + } catch (e: any) { + toast({ variant: "destructive", title: "Transaction failed", description: e.message || "Transaction failed" }) + } } const handleWithdraw = async () => { - setError(""); setSuccessMsg("") - 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 (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (isPending) return toast({ variant: "destructive", title: "Contract not deployed", description: "Contract not yet deployed." }) + if (isPaused) return toast({ variant: "destructive", title: "Pool paused", description: "Pool is paused. Withdrawals are disabled." }) try { let txHash: string | undefined if (poolType === "target") txHash = await targetWithdraw.withdraw() @@ -89,59 +98,114 @@ export function GroupActions({ groupId, poolAddress, poolType, isPaused = false, if (txHash) { await logActivity(groupId, "withdraw", address, withdrawAmount || null, txHash) - setSuccessMsg("Withdrawal successful!") + toast({ + variant: "success", + title: "Withdrawal successful!", + description: `${withdrawAmount || "0"} XLM withdrawn from the pool.`, + action: , + }) setWithdrawAmount("") } - } catch (e: any) { setError(e.message || "Transaction failed") } + } catch (e: any) { + toast({ variant: "destructive", title: "Transaction failed", description: e.message || "Transaction failed" }) + } } const handleTriggerPayout = async () => { - setError(""); setSuccessMsg("") - 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.") + if (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (isPending) return toast({ variant: "destructive", title: "Contract not deployed", description: "Contract not yet deployed." }) + if (isPaused) return toast({ variant: "destructive", title: "Pool paused", description: "Pool is paused. Payouts are disabled." }) try { const txHash = await triggerPayout.trigger() if (txHash) { await logActivity(groupId, "payout", address, null, txHash) - setSuccessMsg("Payout triggered!") + toast({ + variant: "success", + title: "Payout triggered!", + description: "You earned a relayer fee for triggering the payout.", + action: , + }) } - } catch (e: any) { setError(e.message || "Transaction failed") } + } catch (e: any) { + toast({ variant: "destructive", title: "Transaction failed", description: e.message || "Transaction failed" }) + } } const handleRefund = async () => { - setError(""); setSuccessMsg("") - if (!address) return setError("Please connect your wallet first") - if (isPending) return setError("Contract not yet deployed.") + if (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (isPending) return toast({ variant: "destructive", title: "Contract not deployed", description: "Contract not yet deployed." }) try { const txHash = await targetRefund.refund() if (txHash) { await logActivity(groupId, "refund", address, null, txHash) - setSuccessMsg("Refund initiated!") + toast({ + variant: "success", + title: "Refund initiated!", + description: "Your refund request has been submitted.", + action: , + }) } - } catch (e: any) { setError(e.message || "Transaction failed") } + } catch (e: any) { + toast({ variant: "destructive", title: "Transaction failed", description: e.message || "Transaction failed" }) + } } const handlePause = async () => { - setError(""); setSuccessMsg("") - if (!address) return setError("Please connect your wallet first") - if (isPending) return setError("Contract not yet deployed.") + if (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (isPending) return toast({ variant: "destructive", title: "Contract not deployed", description: "Contract not yet deployed." }) try { - await pausePool.pause() - setSuccessMsg("Pool paused successfully.") + const txHash = await pausePool.pause() + toast({ + variant: "success", + title: "Pool paused successfully.", + description: "All transactions are now disabled.", + ...(txHash ? { + action: , + } : {}), + }) onPauseChange?.() - } catch (e: any) { setError(e.message || "Transaction failed") } + } catch (e: any) { + toast({ variant: "destructive", title: "Transaction failed", description: e.message || "Transaction failed" }) + } } const handleUnpause = async () => { - setError(""); setSuccessMsg("") - if (!address) return setError("Please connect your wallet first") - if (isPending) return setError("Contract not yet deployed.") + if (!address) return toast({ variant: "destructive", title: "Wallet not connected", description: "Please connect your wallet first" }) + if (isPending) return toast({ variant: "destructive", title: "Contract not deployed", description: "Contract not yet deployed." }) try { - await unpausePool.unpause() - setSuccessMsg("Pool unpaused successfully.") + const txHash = await unpausePool.unpause() + toast({ + variant: "success", + title: "Pool unpaused successfully.", + description: "Transactions are now enabled.", + ...(txHash ? { + action: , + } : {}), + }) onPauseChange?.() - } catch (e: any) { setError(e.message || "Transaction failed") } + } catch (e: any) { + toast({ variant: "destructive", title: "Transaction failed", description: e.message || "Transaction failed" }) + } } const isDepositLoading = @@ -159,19 +223,6 @@ export function GroupActions({ groupId, poolAddress, poolType, isPaused = false,

Quick Actions

- {error && ( -
- -

{error}

-
- )} - - {successMsg && ( -
- -

{successMsg}

-
- )} {isPaused && (
diff --git a/frontend/components/ui/toast.tsx b/frontend/components/ui/toast.tsx index e596e62..f9fabf7 100644 --- a/frontend/components/ui/toast.tsx +++ b/frontend/components/ui/toast.tsx @@ -24,21 +24,24 @@ const ToastViewport = React.forwardRef< )) ToastViewport.displayName = ToastPrimitives.Viewport.displayName -const toastVariants = cva( - 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border bg-foreground text-background p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', - { - variants: { - variant: { - default: 'border bg-background text-foreground', - destructive: - 'destructive group border-destructive bg-destructive text-destructive-foreground', + const toastVariants = cva( + 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', + { + variants: { + variant: { + default: 'border bg-background text-foreground', + destructive: + 'destructive group border-destructive bg-destructive text-destructive-foreground', + success: 'border-l-4 border-l-green-500 bg-green-50 text-green-900 dark:bg-green-950/80 dark:text-green-100 dark:border-green-400', + info: 'border-l-4 border-l-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950/80 dark:text-blue-100 dark:border-blue-400', + warning: 'border-l-4 border-l-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950/80 dark:text-yellow-100 dark:border-yellow-400', + }, + }, + defaultVariants: { + variant: 'default', }, }, - defaultVariants: { - variant: 'default', - }, - }, -) + ) const Toast = React.forwardRef< React.ElementRef, diff --git a/frontend/hooks/use-toast.ts b/frontend/hooks/use-toast.ts index 8932bc5..ff28678 100644 --- a/frontend/hooks/use-toast.ts +++ b/frontend/hooks/use-toast.ts @@ -5,8 +5,11 @@ import * as React from 'react' import type { ToastActionElement, ToastProps } from '@/components/ui/toast' -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 +const TOAST_LIMIT = 5 +const TOAST_REMOVE_DELAY = 100000 + +const DEFAULT_DURATION = 6000 +const ERROR_DURATION = 0 // manual dismiss type ToasterToast = ToastProps & { id: string @@ -137,10 +140,11 @@ function dispatch(action: Action) { }) } -type Toast = Omit +type ToastInput = Omit -function toast({ ...props }: Toast) { +function toast({ ...props }: ToastInput) { const id = genId() + const duration = props.variant === 'destructive' ? ERROR_DURATION : (props.duration ?? DEFAULT_DURATION) const update = (props: ToasterToast) => dispatch({ @@ -155,12 +159,17 @@ function toast({ ...props }: Toast) { ...props, id, open: true, + duration, onOpenChange: (open) => { if (!open) dismiss() }, }, }) + if (duration > 0) { + setTimeout(() => dismiss(), duration) + } + return { id: id, dismiss, @@ -188,4 +197,6 @@ function useToast() { } } +export type { ToastInput } + export { useToast, toast }