From f73abaa2e7a2a86c386f536127825a839c32d2f1 Mon Sep 17 00:00:00 2001 From: "deno-deploy[bot]" <75045203+deno-deploy[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 07:55:11 +0000 Subject: [PATCH 1/3] [Deno Deploy] Add .github/workflows/deploy.yml --- .github/workflows/deploy.yml | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0e004e6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,44 @@ +name: Deploy +on: + push: + branches: main + pull_request: + branches: main + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + + permissions: + id-token: write # Needed for auth with Deno Deploy + contents: read # Needed to clone the repository + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Install Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install step + run: "bun install" + + - name: Build step + run: "bun run build" + + - name: Upload to Deno Deploy + uses: denoland/deployctl@v1 + with: + project: "stake-ubq-fi" + entrypoint: "https://deno.land/std@0.217.0/http/file_server.ts" + root: "dist" + + From 92bb29e04ff0b7f18aec351c3630996cedfb4397 Mon Sep 17 00:00:00 2001 From: 3scava1i3r Date: Sat, 7 Feb 2026 15:37:54 +0530 Subject: [PATCH 2/3] Implement toast system for error handling and status feedback --- src/components/dashboard-page.tsx | 12 +-- src/components/pool-display.tsx | 17 ++- src/css/toast.css | 110 ++++++++++++++++++++ src/main.tsx | 11 +- src/ui/toast/ToastContainer.tsx | 62 +++++++++++ src/ui/toast/ToastProvider.tsx | 111 ++++++++++++++++++++ src/ui/toast/constants.ts | 18 ++++ src/ui/toast/index.ts | 8 ++ src/ui/toast/types.ts | 27 +++++ tests/toast.test.ts | 166 ++++++++++++++++++++++++++++++ 10 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 src/css/toast.css create mode 100644 src/ui/toast/ToastContainer.tsx create mode 100644 src/ui/toast/ToastProvider.tsx create mode 100644 src/ui/toast/constants.ts create mode 100644 src/ui/toast/index.ts create mode 100644 src/ui/toast/types.ts create mode 100644 tests/toast.test.ts diff --git a/src/components/dashboard-page.tsx b/src/components/dashboard-page.tsx index 2aff183..dbc685d 100644 --- a/src/components/dashboard-page.tsx +++ b/src/components/dashboard-page.tsx @@ -3,6 +3,7 @@ import { ICONS } from "./iconography.tsx"; import { PoolDisplay } from "./pool-display.tsx"; import { BaseError } from "viem"; import { useStatusMessage } from "../context/status-message.tsx"; +import { useToast } from "../ui/toast"; const LogoSpan = () => {ICONS.DAO_LOGO}; @@ -13,8 +14,9 @@ export function DashboardPage() { const { disconnect } = useDisconnect(); const { successMessage, errorMessage, setErrorMessage, clearMessages } = useStatusMessage(); + const { toast } = useToast(); - const isWalletInstalled = typeof window !== "undefined" && !!window.ethereum; + const isWalletInstalled = typeof window !== "undefined" && !!(window as { ethereum?: unknown }).ethereum; return ( <> @@ -47,11 +49,9 @@ export function DashboardPage() { { connector: injected() }, { onError: (error) => { - if (error instanceof BaseError) { - setErrorMessage(error.shortMessage); - } else { - setErrorMessage(error.message); - } + const message = error instanceof BaseError ? error.shortMessage : error.message; + toast(message, "error", 0); + setErrorMessage(message); }, onSuccess: () => clearMessages(), } diff --git a/src/components/pool-display.tsx b/src/components/pool-display.tsx index 6122d47..885e8c4 100644 --- a/src/components/pool-display.tsx +++ b/src/components/pool-display.tsx @@ -6,6 +6,7 @@ import { useErc20Token } from "../hooks/erc20Token.ts"; import { useState } from "react"; import { waitForTransactionReceipt } from "viem/actions"; import { useStatusMessage } from "../context/status-message.tsx"; +import { useToast } from "../ui/toast"; import { Button } from "./button.tsx"; interface PoolDisplayProps { @@ -18,6 +19,7 @@ export function PoolDisplay({ poolId = 0n }: PoolDisplayProps) { const publicClient = usePublicClient(); const { setErrorMessage, setSuccessMessage, clearMessages } = useStatusMessage(); + const { toast } = useToast(); const [stakeAmount, setStakeAmount] = useState(""); const [unstakeAmount, setUnstakeAmount] = useState(""); @@ -82,24 +84,31 @@ export function PoolDisplay({ poolId = 0n }: PoolDisplayProps) { 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) { - setErrorMessage(error instanceof Error ? error.message : "Transaction failed"); + const message = error instanceof Error ? error.message : "Transaction failed"; + toast(message, "error", 0); + setErrorMessage(message); } refreshData(); }; const onError = (error: unknown) => { + let message: string; if (error instanceof BaseError) { - setErrorMessage(error.shortMessage); + message = error.shortMessage; } else if (error instanceof Error) { - setErrorMessage(error.message); + message = error.message; } else { - setErrorMessage("An unknown error occurred"); + message = "An unknown error occurred"; } + toast(message, "error", 0); + setErrorMessage(message); }; const claim = () => { diff --git a/src/css/toast.css b/src/css/toast.css new file mode 100644 index 0000000..2b32199 --- /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 var(--primary, #0066cc); +} + +.toast.success { + background-color: var(--success, #00aa55); + border-left: 3px solid var(--success, #00aa55); +} + +.toast.error { + background-color: var(--error, #ff4444); + border-left: 3px solid var(--error, #ff4444); +} + +.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 2aec4b7..a0b100a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,8 @@ import App from "./App.tsx"; import { grid } from "./the-grid"; import { isLocalNode, RPC_URL } from "./constants/config"; 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]; @@ -44,9 +46,12 @@ createRoot(rootElement).render( - - - + + + + + + diff --git a/src/ui/toast/ToastContainer.tsx b/src/ui/toast/ToastContainer.tsx new file mode 100644 index 0000000..77356fb --- /dev/null +++ b/src/ui/toast/ToastContainer.tsx @@ -0,0 +1,62 @@ +/** + * 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="alert" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + 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..09be9e0 --- /dev/null +++ b/src/ui/toast/ToastProvider.tsx @@ -0,0 +1,111 @@ +/** + * 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 } from "./types"; +import { TOAST_CONFIG } from "./constants"; + +interface ToastState { + toasts: Toast[]; + lastToast: { message: string; timestamp: number } | null; +} + +type ToastAction = + | { type: "ADD"; payload: Toast } + | { type: "DISMISS"; payload: string } + | { type: "CLEAR" }; + +const ToastContext = createContext(null); + +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 => { + 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; + }, []); + + 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 timer = setTimeout(() => { + dismiss(t.id); + }, t.duration); + 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..23b88e6 --- /dev/null +++ b/src/ui/toast/types.ts @@ -0,0 +1,27 @@ +/** + * 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; + dismiss: (id: string) => void; + clear: () => void; +} diff --git a/tests/toast.test.ts b/tests/toast.test.ts new file mode 100644 index 0000000..b3a34f3 --- /dev/null +++ b/tests/toast.test.ts @@ -0,0 +1,166 @@ +/** + * Toast System Unit Tests + * Tests for toast logic and reducer behavior + */ + +import { describe, it, expect } from "bun:test"; + +// Test the toast reducer logic directly +describe("toast reducer logic", () => { + it("should add a new toast", () => { + const initialState = { toasts: [], lastToast: null }; + const action = { + type: "ADD" as const, + payload: { + id: "test-1", + message: "Test message", + variant: "success" as const, + duration: 5000, + timestamp: Date.now(), + }, + }; + + // Simulate reducer + if (action.type === "ADD") { + const newState = { + toasts: [...initialState.toasts, action.payload], + lastToast: { message: action.payload.message, timestamp: action.payload.timestamp }, + }; + expect(newState.toasts).toHaveLength(1); + expect(newState.toasts[0].message).toBe("Test message"); + } + }); + + it("should filter toasts by id on dismiss", () => { + const state = { + toasts: [ + { id: "1", message: "Toast 1", variant: "info" as const, duration: 5000, timestamp: Date.now() }, + { id: "2", message: "Toast 2", variant: "success" as const, duration: 5000, timestamp: Date.now() }, + ], + lastToast: null, + }; + + const action = { type: "DISMISS" as const, payload: "1" }; + + if (action.type === "DISMISS") { + const newState = { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.payload), + }; + expect(newState.toasts).toHaveLength(1); + expect(newState.toasts[0].id).toBe("2"); + } + }); + + it("should clear all toasts", () => { + const action = { type: "CLEAR" as const }; + + if (action.type === "CLEAR") { + const newState = { toasts: [], lastToast: null }; + expect(newState.toasts).toHaveLength(0); + } + }); + + it("should limit toasts to max visible", () => { + const maxVisible = 3; + const toasts = [ + { id: "1", message: "Toast 1", variant: "info" as const, duration: 5000, timestamp: Date.now() }, + { id: "2", message: "Toast 2", variant: "info" as const, duration: 5000, timestamp: Date.now() }, + { id: "3", message: "Toast 3", variant: "info" as const, duration: 5000, timestamp: Date.now() }, + { id: "4", message: "Toast 4", variant: "info" as const, duration: 5000, timestamp: Date.now() }, + { id: "5", message: "Toast 5", variant: "info" as const, duration: 5000, timestamp: Date.now() }, + ]; + + const limitedToasts = toasts.slice(-maxVisible); + expect(limitedToasts).toHaveLength(3); + expect(limitedToasts[0].id).toBe("3"); + }); + + it("should deduplicate within window", () => { + const dedupWindow = 2000; + const now = Date.now(); + const lastToast = { message: "Same message", timestamp: now - 1000 }; + + const shouldDeduplicate = lastToast && now - lastToast.timestamp < dedupWindow; + expect(shouldDeduplicate).toBe(true); + }); + + it("should allow different messages", () => { + const dedupWindow = 2000; + const now = Date.now(); + const lastToast = { message: "Message 1", timestamp: now - 1000 }; + + const shouldDeduplicate = lastToast && lastToast.message === "Message 2" && now - lastToast.timestamp < dedupWindow; + expect(shouldDeduplicate).toBe(false); + }); +}); + +describe("toast configuration", () => { + it("should have correct default duration", () => { + const DEFAULT_DURATION = 5000; + expect(DEFAULT_DURATION).toBe(5000); + }); + + it("should have correct dedup window", () => { + const DEDUP_WINDOW = 2000; + expect(DEDUP_WINDOW).toBe(2000); + }); + + it("should have correct max visible", () => { + const MAX_VISIBLE = 3; + expect(MAX_VISIBLE).toBe(3); + }); +}); + +describe("toast variants", () => { + it("should accept info variant", () => { + const variant = "info"; + expect(variant).toBe("info"); + }); + + it("should accept success variant", () => { + const variant = "success"; + expect(variant).toBe("success"); + }); + + it("should accept error variant", () => { + const variant = "error"; + expect(variant).toBe("error"); + }); +}); + +describe("toast id generation", () => { + it("should generate unique ids", () => { + const generateId = () => crypto.randomUUID(); + const id1 = generateId(); + const id2 = generateId(); + expect(id1).not.toBe(id2); + }); + + 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"); + }); +}); From 16cbeb6ee4273c491de18f4994c71b90dc25a775 Mon Sep 17 00:00:00 2001 From: 3scava1i3r Date: Sun, 8 Feb 2026 15:25:06 +0530 Subject: [PATCH 3/3] fix: requested changes coderabbit --- .github/workflows/deploy.yml | 44 ------- src/components/dashboard-page.tsx | 18 +-- src/components/pool-display.tsx | 107 ++--------------- src/css/toast.css | 6 +- src/ui/toast/ToastContainer.tsx | 3 +- src/ui/toast/ToastProvider.tsx | 26 ++-- src/ui/toast/types.ts | 7 +- tests/toast.test.ts | 192 +++++++++++++++++++----------- 8 files changed, 175 insertions(+), 228 deletions(-) delete mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 0e004e6..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Deploy -on: - push: - branches: main - pull_request: - branches: main - -jobs: - deploy: - name: Deploy - runs-on: ubuntu-latest - - permissions: - id-token: write # Needed for auth with Deno Deploy - contents: read # Needed to clone the repository - - steps: - - name: Clone repository - uses: actions/checkout@v4 - - - name: Install Deno - uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: lts/* - - - name: Install step - run: "bun install" - - - name: Build step - run: "bun run build" - - - name: Upload to Deno Deploy - uses: denoland/deployctl@v1 - with: - project: "stake-ubq-fi" - entrypoint: "https://deno.land/std@0.217.0/http/file_server.ts" - root: "dist" - - diff --git a/src/components/dashboard-page.tsx b/src/components/dashboard-page.tsx index 0cca4b5..0dfa3e4 100644 --- a/src/components/dashboard-page.tsx +++ b/src/components/dashboard-page.tsx @@ -1,21 +1,26 @@ 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 { useStatusMessage } from "../context/status-message.tsx"; +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 { successMessage, errorMessage, setErrorMessage, clearMessages } = useStatusMessage(); + 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; @@ -46,7 +51,7 @@ export function DashboardPage() { ) : ( )} - {/* Status Displays */} diff --git a/src/components/pool-display.tsx b/src/components/pool-display.tsx index 5323ea9..84d9e33 100644 --- a/src/components/pool-display.tsx +++ b/src/components/pool-display.tsx @@ -1,12 +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 { useStatusMessage } from "../context/status-message.tsx"; +import { useStatusMessageState, useStatusMessageDispatch } from "../context/status-message.tsx"; import { useToast } from "../ui/toast"; -import { Button } from "./button.tsx"; import { Button } from "./button"; import { useStaking } from "../hooks/useStaking"; import { safeFormatUnits, safeParseUnits, calculatePoolRewardPerDay, calculateUserPoolShare, calculateUserRewardPerDay } from "../utils/pool"; @@ -43,12 +44,16 @@ const WRITE_ACTION = { NONE: "none", } as const; -const { setErrorMessage, setSuccessMessage, clearMessages } = useStatusMessage(); -const { toast } = useToast(); 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); @@ -106,7 +111,7 @@ export function PoolDisplay({ poolId = 0n }: PoolDisplayProps) { toast(message, "error", 0); setErrorMessage(message); } - refreshData(); + staking.refetchAll(); }; const onError = (error: unknown) => { @@ -122,100 +127,12 @@ export function PoolDisplay({ poolId = 0n }: PoolDisplayProps) { setErrorMessage(message); }; - const claim = () => { - clearMessages(); - setIsClaiming(true); - writeContract( - { - ...stakingContract, - functionName: "unstake", - args: [poolId, 0n], - }, - { - onSuccess: onSuccess, - onError: onError, - onSettled: () => { - setIsClaiming(false); - }, - } - ); - }; - - const stake = () => { - if (!lpTokenInfo.data) return; - clearMessages(); - setIsStaking(true); - writeContract( - { - ...stakingContract, - functionName: "stake", - args: [poolId, parseUnits(stakeAmount, lpTokenInfo.data.decimals)], - }, - { - onSuccess: onSuccess, - onError: onError, - onSettled: () => { - setIsStaking(false); - }, - } - ); - }; - - const unstake = () => { - if (!lpTokenInfo.data) return; - clearMessages(); - setIsUnstaking(true); - writeContract( - { - ...stakingContract, - functionName: "unstake", - args: [poolId, parseUnits(unstakeAmount, lpTokenInfo.data.decimals)], - }, - { - onSuccess: onSuccess, - onError: onError, - onSettled: () => { - setIsUnstaking(false); - }, - } - ); - }; - - const approveAllowance = () => { - if (!lpTokenAddress || !lpTokenInfo.data) return; - clearMessages(); - setIsApproving(true); - writeContract( - { - abi: erc20Abi, - address: lpTokenAddress, - functionName: "approve", - args: [stakingContract.address, parseUnits(stakeAmount, lpTokenInfo.data.decimals)], - }, - { - onSuccess: onSuccess, - onError: onError, - onSettled: () => { - setIsApproving(false); - }, - } - ); - }; - - const refreshData = () => { - poolInfo.refetch(); - userInfo.refetch(); - pendingRewards.refetch(); - allowance.refetch(); - balance.refetch(); - }; - if ( stakingSettings.error || lpTokenInfo.error || rewardTokenInfo.error || poolInfo.error || - (account.isConnected && (pendingRewards.error || userInfo.error || allowance.error || balance.error)) + (isConnected && (staking.data.pendingRewards?.error || staking.data.userInfo?.error || staking.data.allowance?.error || staking.data.balance?.error)) ) { return
Loading failed...
; } diff --git a/src/css/toast.css b/src/css/toast.css index 2b32199..df78345 100644 --- a/src/css/toast.css +++ b/src/css/toast.css @@ -49,17 +49,17 @@ .toast.info { background-color: var(--primary, #0066cc); - border-left: 3px solid var(--primary, #0066cc); + border-left: 3px solid #004d99; } .toast.success { background-color: var(--success, #00aa55); - border-left: 3px solid var(--success, #00aa55); + border-left: 3px solid #008844; } .toast.error { background-color: var(--error, #ff4444); - border-left: 3px solid var(--error, #ff4444); + border-left: 3px solid #cc2222; } .toast-icon { diff --git a/src/ui/toast/ToastContainer.tsx b/src/ui/toast/ToastContainer.tsx index 77356fb..c332aa6 100644 --- a/src/ui/toast/ToastContainer.tsx +++ b/src/ui/toast/ToastContainer.tsx @@ -27,10 +27,11 @@ function ToastItem({ toast, onDismiss }: ToastItemProps) {
onDismiss(toast.id)} - role="alert" + role={toast.variant === "error" ? "alert" : "status"} tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); onDismiss(toast.id); } }} diff --git a/src/ui/toast/ToastProvider.tsx b/src/ui/toast/ToastProvider.tsx index 09be9e0..164d727 100644 --- a/src/ui/toast/ToastProvider.tsx +++ b/src/ui/toast/ToastProvider.tsx @@ -4,7 +4,7 @@ */ import { createContext, useContext, useReducer, useCallback, useEffect, type ReactNode } from "react"; -import type { Toast, ToastContextValue, ToastVariant } from "./types"; +import type { Toast, ToastContextValue, ToastVariant, ToastAction } from "./types"; import { TOAST_CONFIG } from "./constants"; interface ToastState { @@ -12,14 +12,10 @@ interface ToastState { lastToast: { message: string; timestamp: number } | null; } -type ToastAction = - | { type: "ADD"; payload: Toast } - | { type: "DISMISS"; payload: string } - | { type: "CLEAR" }; const ToastContext = createContext(null); -function toastReducer(state: ToastState, action: ToastAction): ToastState { +export function toastReducer(state: ToastState, action: ToastAction): ToastState { switch (action.type) { case "ADD": { // Prevent duplicate toasts within dedup window @@ -53,7 +49,17 @@ function toastReducer(state: ToastState, action: ToastAction): ToastState { 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 => { + 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, @@ -64,7 +70,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { }; dispatch({ type: "ADD", payload: newToast }); return id; - }, []); + }, [state.toasts, state.lastToast]); const dismiss = useCallback((id: string) => { dispatch({ type: "DISMISS", payload: id }); @@ -80,9 +86,11 @@ export function ToastProvider({ children }: { children: ReactNode }) { 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); - }, t.duration); + }, remaining); timers.push(timer); } }); diff --git a/src/ui/toast/types.ts b/src/ui/toast/types.ts index 23b88e6..52ca609 100644 --- a/src/ui/toast/types.ts +++ b/src/ui/toast/types.ts @@ -21,7 +21,12 @@ export interface ToastConfig { export interface ToastContextValue { toasts: ReadonlyArray; - toast: (message: string, variant?: ToastVariant, duration?: number) => string; + 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 index b3a34f3..d19e415 100644 --- a/tests/toast.test.ts +++ b/tests/toast.test.ts @@ -4,137 +4,193 @@ */ 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; +} -// Test the toast reducer logic directly describe("toast reducer logic", () => { it("should add a new toast", () => { - const initialState = { toasts: [], lastToast: null }; + const initialState: TestToastState = { toasts: [], lastToast: null }; const action = { type: "ADD" as const, payload: { id: "test-1", message: "Test message", - variant: "success" as const, + variant: "success" as ToastVariant, duration: 5000, timestamp: Date.now(), }, }; - // Simulate reducer - if (action.type === "ADD") { - const newState = { - toasts: [...initialState.toasts, action.payload], - lastToast: { message: action.payload.message, timestamp: action.payload.timestamp }, - }; - expect(newState.toasts).toHaveLength(1); - expect(newState.toasts[0].message).toBe("Test message"); - } + 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 = { + const state: TestToastState = { toasts: [ - { id: "1", message: "Toast 1", variant: "info" as const, duration: 5000, timestamp: Date.now() }, - { id: "2", message: "Toast 2", variant: "success" as const, duration: 5000, timestamp: Date.now() }, + { 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" }; - if (action.type === "DISMISS") { - const newState = { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.payload), - }; - expect(newState.toasts).toHaveLength(1); - expect(newState.toasts[0].id).toBe("2"); - } + 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 }; - if (action.type === "CLEAR") { - const newState = { toasts: [], lastToast: null }; - expect(newState.toasts).toHaveLength(0); - } + const newState = toastReducer(state, action); + + expect(newState.toasts).toHaveLength(0); + expect(newState.lastToast).toBeNull(); }); it("should limit toasts to max visible", () => { - const maxVisible = 3; - const toasts = [ - { id: "1", message: "Toast 1", variant: "info" as const, duration: 5000, timestamp: Date.now() }, - { id: "2", message: "Toast 2", variant: "info" as const, duration: 5000, timestamp: Date.now() }, - { id: "3", message: "Toast 3", variant: "info" as const, duration: 5000, timestamp: Date.now() }, - { id: "4", message: "Toast 4", variant: "info" as const, duration: 5000, timestamp: Date.now() }, - { id: "5", message: "Toast 5", variant: "info" as const, duration: 5000, timestamp: Date.now() }, - ]; - - const limitedToasts = toasts.slice(-maxVisible); - expect(limitedToasts).toHaveLength(3); - expect(limitedToasts[0].id).toBe("3"); - }); - - it("should deduplicate within window", () => { - const dedupWindow = 2000; + const initialState: TestToastState = { toasts: [], lastToast: null }; const now = Date.now(); - const lastToast = { message: "Same message", timestamp: now - 1000 }; - const shouldDeduplicate = lastToast && now - lastToast.timestamp < dedupWindow; - expect(shouldDeduplicate).toBe(true); + 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 allow different messages", () => { - const dedupWindow = 2000; + it("should deduplicate within window when message matches", () => { const now = Date.now(); - const lastToast = { message: "Message 1", timestamp: now - 1000 }; + 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); - const shouldDeduplicate = lastToast && lastToast.message === "Message 2" && now - lastToast.timestamp < dedupWindow; - expect(shouldDeduplicate).toBe(false); + // 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", () => { - const DEFAULT_DURATION = 5000; - expect(DEFAULT_DURATION).toBe(5000); + expect(TOAST_CONFIG.defaultDuration).toBe(5000); }); it("should have correct dedup window", () => { - const DEDUP_WINDOW = 2000; - expect(DEDUP_WINDOW).toBe(2000); + expect(TOAST_CONFIG.dedupWindow).toBe(2000); }); it("should have correct max visible", () => { - const MAX_VISIBLE = 3; - expect(MAX_VISIBLE).toBe(3); + expect(TOAST_CONFIG.maxVisible).toBe(3); }); }); describe("toast variants", () => { it("should accept info variant", () => { - const variant = "info"; - expect(variant).toBe("info"); + 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 variant = "success"; - expect(variant).toBe("success"); + 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 variant = "error"; - expect(variant).toBe("error"); + 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 generateId = () => crypto.randomUUID(); - const id1 = generateId(); - const id2 = generateId(); - expect(id1).not.toBe(id2); + 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", () => {