diff --git a/src/components/dashboard-page.tsx b/src/components/dashboard-page.tsx index 6fc7e4d..0dfa3e4 100644 --- a/src/components/dashboard-page.tsx +++ b/src/components/dashboard-page.tsx @@ -1,17 +1,29 @@ import { useAppKitAccount, useAppKitNetwork } from "@reown/appkit/react"; +import { useAccount, useConnect, useDisconnect } from "wagmi"; +import { injected } from "wagmi/connectors"; import { ICONS } from "./iconography.tsx"; import { PoolDisplay } from "./pool-display.tsx"; +import { BaseError } from "viem"; +import { useStatusMessageState, useStatusMessageDispatch } from "../context/status-message.tsx"; +import { useToast } from "../ui/toast"; import { ConnectWalletButton } from "./connect-wallet.tsx"; import { supportedChains } from "../wallet/config.ts"; -import { useStatusMessageState } from "../context/status-message.tsx"; const LogoSpan = () => {ICONS.DAO_LOGO}; export function DashboardPage() { const { isConnected } = useAppKitAccount(); const { chainId } = useAppKitNetwork(); + const { address, chain, status } = useAccount(); + const { connect } = useConnect(); + const { disconnect } = useDisconnect(); const { successMessage, errorMessage } = useStatusMessageState(); + const dispatch = useStatusMessageDispatch(); + const setErrorMessage = (message: string) => dispatch({ type: "setError", message }); + const clearMessages = () => dispatch({ type: "clear" }); + const { toast } = useToast(); + const isWalletInstalled = typeof window !== "undefined" && !!(window as { ethereum?: unknown }).ethereum; const isUnsupportedChain = isConnected && chainId && !supportedChains.some((c) => c.id === chainId); return ( @@ -26,7 +38,38 @@ export function DashboardPage() { - + {/* Header Buttons/Controls (Directly under #header) */} + {isConnected && address ? ( + <> + + + ) : ( + + )} {/* Status Displays */} diff --git a/src/components/pool-display.tsx b/src/components/pool-display.tsx index f84a785..84d9e33 100644 --- a/src/components/pool-display.tsx +++ b/src/components/pool-display.tsx @@ -1,8 +1,13 @@ import { useAppKitAccount } from "@reown/appkit/react"; -import { useReadContract } from "wagmi"; +import { useReadContract, useWriteContract, usePublicClient } from "wagmi"; +import { parseUnits, BaseError } from "viem"; import { stakingContract } from "../constants/contracts"; +import erc20Abi from "../abis/erc20"; import { useErc20Token } from "../hooks/erc20Token"; import { useState } from "react"; +import { waitForTransactionReceipt } from "viem/actions"; +import { useStatusMessageState, useStatusMessageDispatch } from "../context/status-message.tsx"; +import { useToast } from "../ui/toast"; import { Button } from "./button"; import { useStaking } from "../hooks/useStaking"; import { safeFormatUnits, safeParseUnits, calculatePoolRewardPerDay, calculateUserPoolShare, calculateUserRewardPerDay } from "../utils/pool"; @@ -43,6 +48,12 @@ type WriteAction = (typeof WRITE_ACTION)[keyof typeof WRITE_ACTION]; export function PoolDisplay({ poolId = 0n }: PoolDisplayProps) { const { address, isConnected } = useAppKitAccount(); + const publicClient = usePublicClient(); + const dispatch = useStatusMessageDispatch(); + const setErrorMessage = (message: string) => dispatch({ type: "setError", message }); + const setSuccessMessage = (message: string) => dispatch({ type: "setSuccess", message }); + const clearMessages = () => dispatch({ type: "clear" }); + const { toast } = useToast(); const [stakeAmount, setStakeAmount] = useState(""); const [unstakeAmount, setUnstakeAmount] = useState(""); const [currentWriteAction, setCurrentWriteAction] = useState(WRITE_ACTION.NONE); @@ -84,6 +95,47 @@ export function PoolDisplay({ poolId = 0n }: PoolDisplayProps) { poolInfo.error && poolInfo.fetchStatus === "idle", ]; + const onSuccess = async (hash: `0x${string}`) => { + if (!publicClient) return; + try { + const receipt = await waitForTransactionReceipt(publicClient, { hash }); + if (receipt.status === "success") { + toast("Transaction confirmed", "success"); + setSuccessMessage("Transaction confirmed"); + } else { + toast("Transaction reverted", "error", 0); + setErrorMessage("Transaction reverted"); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Transaction failed"; + toast(message, "error", 0); + setErrorMessage(message); + } + staking.refetchAll(); + }; + + const onError = (error: unknown) => { + let message: string; + if (error instanceof BaseError) { + message = error.shortMessage; + } else if (error instanceof Error) { + message = error.message; + } else { + message = "An unknown error occurred"; + } + toast(message, "error", 0); + setErrorMessage(message); + }; + + if ( + stakingSettings.error || + lpTokenInfo.error || + rewardTokenInfo.error || + poolInfo.error || + (isConnected && (staking.data.pendingRewards?.error || staking.data.userInfo?.error || staking.data.allowance?.error || staking.data.balance?.error)) + ) { + return
Loading failed...
; + } const hasRealError = publicDataErrors.some((err) => err); const isLoadingPublicData = stakingSettings.isLoading || lpTokenInfo.isLoading || rewardTokenInfo.isLoading || poolInfo.isLoading; diff --git a/src/css/toast.css b/src/css/toast.css new file mode 100644 index 0000000..df78345 --- /dev/null +++ b/src/css/toast.css @@ -0,0 +1,110 @@ +/** + * Toast System Styles + * Minimal toast styles aligned with existing CSS design system + */ + +#toast-portal { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 400px; + pointer-events: none; +} + +#toast-portal > * { + pointer-events: auto; +} + +.toast { + padding: 12px 16px; + border-radius: 4px; + font-size: 14px; + font-family: var(--font-family, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", sans-serif); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + animation: toast-enter 0.3s ease-out; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: opacity 0.2s ease-out; + color: #ffffff; +} + +.toast:hover { + opacity: 0.9; +} + +.toast:focus { + outline: 2px solid var(--primary, #0066cc); + outline-offset: 2px; +} + +.toast:focus:not(:focus-visible) { + outline: none; +} + +.toast.info { + background-color: var(--primary, #0066cc); + border-left: 3px solid #004d99; +} + +.toast.success { + background-color: var(--success, #00aa55); + border-left: 3px solid #008844; +} + +.toast.error { + background-color: var(--error, #ff4444); + border-left: 3px solid #cc2222; +} + +.toast-icon { + font-size: 16px; + flex-shrink: 0; +} + +.toast-message { + flex: 1; + word-break: break-word; + line-height: 1.4; +} + +@keyframes toast-enter { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes toast-exit { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +.toast.exiting { + animation: toast-exit 0.2s ease-in forwards; +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + #toast-portal { + bottom: 16px; + right: 16px; + left: 16px; + max-width: none; + } +} diff --git a/src/main.tsx b/src/main.tsx index 7d77f96..8e1685a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,26 @@ import { WagmiProvider } from "wagmi"; import App from "./App.tsx"; import { grid } from "./the-grid"; import { StatusMessageProvider } from "./context/status-message.tsx"; +import { ToastProvider, ToastContainer } from "./ui/toast"; +import "./css/toast.css"; + +// Configure wagmi +const supportedChains: [Chain, ...Chain[]] = isLocalNode ? [mainnet, anvil] : [mainnet]; + +// Dynamically create transports for all supported chains +const transports = supportedChains.reduce((acc, chain) => { + acc[chain.id] = http(isLocalNode ? RPC_URL : `${RPC_URL}/${chain.id}`, { batch: true }); + return acc; +}, {} as Record>); + +export const config = createConfig({ + chains: supportedChains, + connectors: [injected()], + transports: transports, + batch: { + multicall: false, + }, +}); import { wagmiAdapter } from "./wallet/config"; const queryClient = new QueryClient(); @@ -24,9 +44,12 @@ createRoot(rootElement).render( - - - + + + + + + diff --git a/src/ui/toast/ToastContainer.tsx b/src/ui/toast/ToastContainer.tsx new file mode 100644 index 0000000..c332aa6 --- /dev/null +++ b/src/ui/toast/ToastContainer.tsx @@ -0,0 +1,63 @@ +/** + * Toast Container + * Portal-based container for rendering toasts at document body level + */ + +import { createPortal } from "react-dom"; +import { useToast } from "./ToastProvider"; +import type { ToastVariant } from "./types"; + +const ICONS: Record = { + info: "ℹ", + success: "✓", + error: "✕", +}; + +interface ToastItemProps { + toast: { + id: string; + message: string; + variant: ToastVariant; + }; + onDismiss: (id: string) => void; +} + +function ToastItem({ toast, onDismiss }: ToastItemProps) { + return ( +
onDismiss(toast.id)} + role={toast.variant === "error" ? "alert" : "status"} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onDismiss(toast.id); + } + }} + > + + {toast.message} +
+ ); +} + +export function ToastContainer() { + const { toasts, dismiss } = useToast(); + + // Don't render anything during SSR + if (typeof document === "undefined") { + return null; + } + + return createPortal( +
+ {toasts.map((toast) => ( + + ))} +
, + document.body + ); +} diff --git a/src/ui/toast/ToastProvider.tsx b/src/ui/toast/ToastProvider.tsx new file mode 100644 index 0000000..164d727 --- /dev/null +++ b/src/ui/toast/ToastProvider.tsx @@ -0,0 +1,119 @@ +/** + * Toast Provider + * React Context provider for toast system with deduplication and auto-dismiss + */ + +import { createContext, useContext, useReducer, useCallback, useEffect, type ReactNode } from "react"; +import type { Toast, ToastContextValue, ToastVariant, ToastAction } from "./types"; +import { TOAST_CONFIG } from "./constants"; + +interface ToastState { + toasts: Toast[]; + lastToast: { message: string; timestamp: number } | null; +} + + +const ToastContext = createContext(null); + +export function toastReducer(state: ToastState, action: ToastAction): ToastState { + switch (action.type) { + case "ADD": { + // Prevent duplicate toasts within dedup window + if (state.lastToast && state.lastToast.message === action.payload.message) { + const timeSinceLast = Date.now() - state.lastToast.timestamp; + if (timeSinceLast < TOAST_CONFIG.dedupWindow) { + return state; + } + } + + const newState = { + toasts: [...state.toasts, action.payload].slice(-TOAST_CONFIG.maxVisible), + lastToast: { message: action.payload.message, timestamp: action.payload.timestamp }, + }; + return newState; + } + case "DISMISS": { + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.payload), + }; + } + case "CLEAR": { + return { toasts: [], lastToast: null }; + } + default: + return state; + } +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(toastReducer, { toasts: [], lastToast: null }); + + const toast = useCallback((message: string, variant: ToastVariant = "info", duration?: number): string | null => { + // Check for deduplication before creating a new toast + if (state.lastToast && state.lastToast.message === message) { + const timeSinceLast = Date.now() - state.lastToast.timestamp; + if (timeSinceLast < TOAST_CONFIG.dedupWindow) { + // Find and return the existing toast's id + const existingToast = state.toasts.find(t => t.message === message); + return existingToast?.id ?? null; + } + } + + const id = crypto.randomUUID(); + const newToast: Toast = { + id, + message, + variant, + duration: duration ?? TOAST_CONFIG.defaultDuration, + timestamp: Date.now(), + }; + dispatch({ type: "ADD", payload: newToast }); + return id; + }, [state.toasts, state.lastToast]); + + const dismiss = useCallback((id: string) => { + dispatch({ type: "DISMISS", payload: id }); + }, []); + + const clear = useCallback(() => { + dispatch({ type: "CLEAR" }); + }, []); + + // Auto-dismiss non-persistent toasts + useEffect(() => { + const timers: ReturnType[] = []; + + state.toasts.forEach((t) => { + if (t.duration > 0) { + const elapsed = Date.now() - t.timestamp; + const remaining = Math.max(t.duration - elapsed, 0); + const timer = setTimeout(() => { + dismiss(t.id); + }, remaining); + timers.push(timer); + } + }); + + return () => { + timers.forEach(clearTimeout); + }; + }, [state.toasts, dismiss]); + + const contextValue: ToastContextValue = { + toasts: state.toasts, + toast, + dismiss, + clear, + }; + + return {children}; +} + +export function useToast(): ToastContextValue { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +} diff --git a/src/ui/toast/constants.ts b/src/ui/toast/constants.ts new file mode 100644 index 0000000..d484a11 --- /dev/null +++ b/src/ui/toast/constants.ts @@ -0,0 +1,18 @@ +/** + * Toast System Constants + * Configuration for toast behavior + */ + +import type { ToastConfig } from "./types"; + +export const TOAST_CONFIG: ToastConfig = { + defaultDuration: 5000, // 5 seconds + dedupWindow: 2000, // 2 seconds - prevent rapid duplicate toasts + maxVisible: 3, // Maximum visible toasts at once +}; + +export const TOAST_VARIANTS = { + info: "info", + success: "success", + error: "error", +} as const; diff --git a/src/ui/toast/index.ts b/src/ui/toast/index.ts new file mode 100644 index 0000000..b270a52 --- /dev/null +++ b/src/ui/toast/index.ts @@ -0,0 +1,8 @@ +/** + * Toast System + * Minimal toast system for error/success handling + */ + +export { ToastProvider, useToast } from "./ToastProvider"; +export { ToastContainer } from "./ToastContainer"; +export * from "./types"; diff --git a/src/ui/toast/types.ts b/src/ui/toast/types.ts new file mode 100644 index 0000000..52ca609 --- /dev/null +++ b/src/ui/toast/types.ts @@ -0,0 +1,32 @@ +/** + * Toast System Types + * Minimal toast types for error/success handling + */ + +export type ToastVariant = "info" | "success" | "error"; + +export interface Toast { + id: string; + message: string; + variant: ToastVariant; + duration: number; // 0 = persistent + timestamp: number; +} + +export interface ToastConfig { + defaultDuration: number; // 5000ms + dedupWindow: number; // 2000ms + maxVisible: number; // 3 toasts +} + +export interface ToastContextValue { + toasts: ReadonlyArray; + toast: (message: string, variant?: ToastVariant, duration?: number) => string | null; + dismiss: (id: string) => void; + clear: () => void; +} + +export type ToastAction = + | { type: "ADD"; payload: Toast } + | { type: "DISMISS"; payload: string } + | { type: "CLEAR" }; diff --git a/tests/toast.test.ts b/tests/toast.test.ts new file mode 100644 index 0000000..d19e415 --- /dev/null +++ b/tests/toast.test.ts @@ -0,0 +1,222 @@ +/** + * Toast System Unit Tests + * Tests for toast logic and reducer behavior + */ + +import { describe, it, expect } from "bun:test"; +import { toastReducer } from "../src/ui/toast/ToastProvider"; +import { TOAST_CONFIG } from "../src/ui/toast/constants"; +import type { Toast, ToastVariant } from "../src/ui/toast/types"; + +// Test state interface matching the reducer +interface TestToastState { + toasts: Toast[]; + lastToast: { message: string; timestamp: number } | null; +} + +describe("toast reducer logic", () => { + it("should add a new toast", () => { + const initialState: TestToastState = { toasts: [], lastToast: null }; + const action = { + type: "ADD" as const, + payload: { + id: "test-1", + message: "Test message", + variant: "success" as ToastVariant, + duration: 5000, + timestamp: Date.now(), + }, + }; + + const newState = toastReducer(initialState, action); + + expect(newState.toasts).toHaveLength(1); + expect(newState.toasts[0].message).toBe("Test message"); + expect(newState.lastToast?.message).toBe("Test message"); + }); + + it("should filter toasts by id on dismiss", () => { + const state: TestToastState = { + toasts: [ + { id: "1", message: "Toast 1", variant: "info" as ToastVariant, duration: 5000, timestamp: Date.now() }, + { id: "2", message: "Toast 2", variant: "success" as ToastVariant, duration: 5000, timestamp: Date.now() }, + ], + lastToast: null, + }; + const action = { type: "DISMISS" as const, payload: "1" }; + + const newState = toastReducer(state, action); + + expect(newState.toasts).toHaveLength(1); + expect(newState.toasts[0].id).toBe("2"); + }); + + it("should clear all toasts", () => { + const state: TestToastState = { + toasts: [{ id: "1", message: "Toast", variant: "info" as ToastVariant, duration: 5000, timestamp: Date.now() }], + lastToast: { message: "Toast", timestamp: Date.now() }, + }; + const action = { type: "CLEAR" as const }; + + const newState = toastReducer(state, action); + + expect(newState.toasts).toHaveLength(0); + expect(newState.lastToast).toBeNull(); + }); + + it("should limit toasts to max visible", () => { + const initialState: TestToastState = { toasts: [], lastToast: null }; + const now = Date.now(); + + let state = initialState; + for (let i = 1; i <= 5; i++) { + const action = { + type: "ADD" as const, + payload: { id: String(i), message: `Toast ${i}`, variant: "info" as ToastVariant, duration: 5000, timestamp: now + i }, + }; + state = toastReducer(state, action); + } + + expect(state.toasts).toHaveLength(TOAST_CONFIG.maxVisible); + expect(state.toasts[0]?.id).toBe("3"); + }); + + it("should deduplicate within window when message matches", () => { + const now = Date.now(); + const state: TestToastState = { + toasts: [{ id: "1", message: "Same message", variant: "info" as ToastVariant, duration: 5000, timestamp: now - 1000 }], + lastToast: { message: "Same message", timestamp: now - 1000 }, + }; + const action = { + type: "ADD" as const, + payload: { id: "2", message: "Same message", variant: "info" as ToastVariant, duration: 5000, timestamp: now }, + }; + + const newState = toastReducer(state, action); + + // Should not add duplicate + expect(newState.toasts).toHaveLength(1); + expect(newState.toasts[0]?.id).toBe("1"); + }); + + it("should allow different messages within window", () => { + const now = Date.now(); + const state: TestToastState = { + toasts: [{ id: "1", message: "Message 1", variant: "info" as ToastVariant, duration: 5000, timestamp: now - 1000 }], + lastToast: { message: "Message 1", timestamp: now - 1000 }, + }; + const action = { + type: "ADD" as const, + payload: { id: "2", message: "Message 2", variant: "info" as ToastVariant, duration: 5000, timestamp: now }, + }; + + const newState = toastReducer(state, action); + + // Should add different message + expect(newState.toasts).toHaveLength(2); + expect(newState.toasts[1]?.message).toBe("Message 2"); + }); + + it("should allow same message outside dedup window", () => { + const now = Date.now(); + const state: TestToastState = { + toasts: [{ id: "1", message: "Same message", variant: "info" as ToastVariant, duration: 5000, timestamp: now - 3000 }], + lastToast: { message: "Same message", timestamp: now - 3000 }, + }; + const action = { + type: "ADD" as const, + payload: { id: "2", message: "Same message", variant: "info" as ToastVariant, duration: 5000, timestamp: now }, + }; + + const newState = toastReducer(state, action); + + // Should add since outside dedup window + expect(newState.toasts).toHaveLength(2); + }); +}); + +describe("toast configuration", () => { + it("should have correct default duration", () => { + expect(TOAST_CONFIG.defaultDuration).toBe(5000); + }); + + it("should have correct dedup window", () => { + expect(TOAST_CONFIG.dedupWindow).toBe(2000); + }); + + it("should have correct max visible", () => { + expect(TOAST_CONFIG.maxVisible).toBe(3); + }); +}); + +describe("toast variants", () => { + it("should accept info variant", () => { + const toast: Toast = { + id: "1", + message: "Test", + variant: "info", + duration: 5000, + timestamp: Date.now(), + }; + expect(toast.variant).toBe("info"); + }); + + it("should accept success variant", () => { + const toast: Toast = { + id: "1", + message: "Test", + variant: "success", + duration: 5000, + timestamp: Date.now(), + }; + expect(toast.variant).toBe("success"); + }); + + it("should accept error variant", () => { + const toast: Toast = { + id: "1", + message: "Test", + variant: "error", + duration: 5000, + timestamp: Date.now(), + }; + expect(toast.variant).toBe("error"); + }); +}); + +describe("toast id generation", () => { + it("should generate unique ids", () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(crypto.randomUUID()); + } + expect(ids.size).toBe(100); + }); + + it("should generate string ids", () => { + const id = crypto.randomUUID(); + expect(typeof id).toBe("string"); + expect(id.length).toBe(36); // UUID format + }); +}); + +describe("toast message formatting", () => { + it("should handle base error messages", () => { + const formatError = (error: { shortMessage?: string; message?: string }) => { + return error.shortMessage || error.message || "An unknown error occurred"; + }; + + expect(formatError({ shortMessage: "User rejected request" })).toBe("User rejected request"); + expect(formatError({ message: "Connection timeout" })).toBe("Connection timeout"); + expect(formatError({})).toBe("An unknown error occurred"); + }); + + it("should handle generic errors", () => { + const formatError = (error: Error | unknown) => { + return error instanceof Error ? error.message : "An unknown error occurred"; + }; + + expect(formatError(new Error("Custom error"))).toBe("Custom error"); + expect(formatError(null)).toBe("An unknown error occurred"); + }); +});