From 39cc27601a258ad8ebaa8ecd282c463182969e7e Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Mon, 23 Feb 2026 13:59:01 +0700 Subject: [PATCH 1/5] refactor(hooks): add useClickOutside hook to consolidate click-outside logic --- .../components/Transfer/TokenSelector.tsx | 15 ++-------- .../components/contact-book/ContactPicker.tsx | 18 ++---------- .../popovers/ContactGroupPopover.tsx | 18 +++--------- .../components/popovers/EditBatchPopover.tsx | 19 ++---------- .../components/popovers/TokenPillPopover.tsx | 16 ++-------- packages/nextjs/hooks/useClickOutside.ts | 29 +++++++++++++++++++ 6 files changed, 42 insertions(+), 73 deletions(-) create mode 100644 packages/nextjs/hooks/useClickOutside.ts diff --git a/packages/nextjs/components/Transfer/TokenSelector.tsx b/packages/nextjs/components/Transfer/TokenSelector.tsx index 842f8053..351123ae 100644 --- a/packages/nextjs/components/Transfer/TokenSelector.tsx +++ b/packages/nextjs/components/Transfer/TokenSelector.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from "react"; import Image from "next/image"; import { ResolvedToken } from "@polypay/shared"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; +import { useClickOutside } from "~~/hooks/useClickOutside"; interface TokenSelectorProps { selectedToken: ResolvedToken; @@ -31,19 +32,7 @@ export function TokenSelector({ selectedToken, onSelect, disabled = false }: Tok } }, [isOpen]); - // Close popover when clicking outside - useEffect(() => { - if (!isOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isOpen]); + useClickOutside(containerRef, () => setIsOpen(false), { isActive: isOpen }); const handleTokenSelect = (token: ResolvedToken) => { onSelect(token); diff --git a/packages/nextjs/components/contact-book/ContactPicker.tsx b/packages/nextjs/components/contact-book/ContactPicker.tsx index 8b274e1d..92162345 100644 --- a/packages/nextjs/components/contact-book/ContactPicker.tsx +++ b/packages/nextjs/components/contact-book/ContactPicker.tsx @@ -4,6 +4,7 @@ import { ContactBookUserIcon } from "../icons/ContactBookUserIcon"; import { Contact } from "@polypay/shared"; import { Search, X } from "lucide-react"; import { useContacts, useGroups } from "~~/hooks"; +import { useClickOutside } from "~~/hooks/useClickOutside"; interface ContactPickerProps { accountId: string | null; @@ -35,22 +36,7 @@ export function ContactPicker({ accountId, onSelect, disabled }: ContactPickerPr } }, [isOpen]); - // Close picker when clicking outside - useEffect(() => { - const handleClickOutside: (event: MouseEvent) => void = (event: MouseEvent) => { - if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - } - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isOpen]); + useClickOutside(pickerRef, () => setIsOpen(false), { isActive: isOpen }); // Filter contacts by search term const filteredContacts = contacts.filter( diff --git a/packages/nextjs/components/popovers/ContactGroupPopover.tsx b/packages/nextjs/components/popovers/ContactGroupPopover.tsx index 36eee91c..a7e248d1 100644 --- a/packages/nextjs/components/popovers/ContactGroupPopover.tsx +++ b/packages/nextjs/components/popovers/ContactGroupPopover.tsx @@ -1,10 +1,11 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import Image from "next/image"; import { Checkbox, SearchInput } from "../Common"; import { ContactGroup } from "@polypay/shared"; import { useContact, useGroups } from "~~/hooks/api/useContactBook"; +import { useClickOutside } from "~~/hooks/useClickOutside"; interface ContactGroupPopoverProps { contactId: string; @@ -26,7 +27,7 @@ export function ContactGroupPopover({ selectedGroup, selectedGroupIds, onSelect, - arrowSrc = "/batch/popover-arrow.svg", + arrowSrc = "/icons/arrows/popover-arrow.svg", arrowWidth = 28, arrowHeight = 28, popoverClassName, @@ -38,18 +39,7 @@ export function ContactGroupPopover({ const [searchTerm, setSearchTerm] = useState(""); const rootRef = useRef(null); - useEffect(() => { - if (!open) return; - - const handleClickOutside = (event: MouseEvent) => { - if (rootRef.current && !rootRef.current.contains(event.target as Node)) { - setOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [open]); + useClickOutside(rootRef, () => setOpen(false), { isActive: open }); const contactGroupIds = selectedGroupIds || contact?.groups?.map(entry => entry.group?.id).filter(Boolean) || []; const isLoading = isLoadingContact || isLoadingGroups; diff --git a/packages/nextjs/components/popovers/EditBatchPopover.tsx b/packages/nextjs/components/popovers/EditBatchPopover.tsx index 8d69a876..613b7ad2 100644 --- a/packages/nextjs/components/popovers/EditBatchPopover.tsx +++ b/packages/nextjs/components/popovers/EditBatchPopover.tsx @@ -9,6 +9,7 @@ import { ContactPicker } from "~~/components/contact-book/ContactPicker"; import { useContacts } from "~~/hooks"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; import { useZodForm } from "~~/hooks/form"; +import { useClickOutside } from "~~/hooks/useClickOutside"; import { editBatchSchema } from "~~/lib/form"; import { useAccountStore } from "~~/services/store"; import { formatAddress } from "~~/utils/format"; @@ -67,23 +68,7 @@ export default function EditBatchPopover({ item, isOpen, onClose, onSave, trigge } }, [isOpen, triggerRef]); - useEffect(() => { - if (!isOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - if ( - popoverRef.current && - !popoverRef.current.contains(event.target as Node) && - triggerRef.current && - !triggerRef.current.contains(event.target as Node) - ) { - onClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isOpen, onClose, triggerRef]); + useClickOutside(popoverRef, onClose, { isActive: isOpen, triggerRef }); const handleContactSelect = (selectedAddress: string, name: string, contactId: string) => { form.setValue("recipient", selectedAddress, { shouldValidate: true }); diff --git a/packages/nextjs/components/popovers/TokenPillPopover.tsx b/packages/nextjs/components/popovers/TokenPillPopover.tsx index d1eef1a9..490c7820 100644 --- a/packages/nextjs/components/popovers/TokenPillPopover.tsx +++ b/packages/nextjs/components/popovers/TokenPillPopover.tsx @@ -1,11 +1,12 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import Image from "next/image"; import { ResolvedToken } from "@polypay/shared"; import { useMetaMultiSigWallet, useTokenPrices } from "~~/hooks"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; import { useTokenBalances } from "~~/hooks/app/useTokenBalance"; +import { useClickOutside } from "~~/hooks/useClickOutside"; interface TokenPillPopoverProps { selectedToken: ResolvedToken; @@ -34,18 +35,7 @@ export function TokenPillPopover({ const [open, setOpen] = useState(false); const rootRef = useRef(null); - useEffect(() => { - if (!open) return; - - const handleClickOutside = (event: MouseEvent) => { - if (rootRef.current && !rootRef.current.contains(event.target as Node)) { - setOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [open]); + useClickOutside(rootRef, () => setOpen(false), { isActive: open }); const getTokenUsdValue = (token: ResolvedToken): string => { const balance = parseFloat(balances[token.address] || "0"); diff --git a/packages/nextjs/hooks/useClickOutside.ts b/packages/nextjs/hooks/useClickOutside.ts new file mode 100644 index 00000000..21447f0d --- /dev/null +++ b/packages/nextjs/hooks/useClickOutside.ts @@ -0,0 +1,29 @@ +import { RefObject, useEffect } from "react"; + +/** + * Subscribe to mousedown outside a container (and optionally a trigger) and call onClose. + * When isActive is false, the listener is not added. + */ +export function useClickOutside( + containerRef: RefObject, + onClose: () => void, + options?: { isActive?: boolean; triggerRef?: RefObject }, +): void { + const isActive = options?.isActive !== false; + const triggerRef = options?.triggerRef; + + useEffect(() => { + if (!isActive) return; + + const handler = (event: MouseEvent) => { + const target = event.target as Node; + if (!containerRef.current) return; + let isOutside = !containerRef.current.contains(target); + if (triggerRef?.current && triggerRef.current.contains(target)) isOutside = false; + if (isOutside) onClose(); + }; + + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [isActive, onClose, triggerRef]); +} From 7881c887682ead662c4cb7aba0d5eefe82f9b88f Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Mon, 23 Feb 2026 14:10:38 +0700 Subject: [PATCH 2/5] refactor(utils): use formatErrorMessage consistently for error handling --- packages/nextjs/components/Batch/BatchContainer.tsx | 2 +- packages/nextjs/components/Transfer/TransferContainer.tsx | 3 ++- packages/nextjs/components/modals/ClaimRewardModal.tsx | 3 ++- .../nextjs/components/modals/EditAccountModal/index.tsx | 3 ++- .../nextjs/hooks/app/transaction/useBatchTransaction.ts | 3 ++- .../nextjs/hooks/app/transaction/useSignerTransaction.ts | 7 ++++--- .../nextjs/hooks/app/transaction/useTransactionVote.ts | 7 ++++--- .../hooks/app/transaction/useTransferTransaction.ts | 3 ++- packages/nextjs/hooks/app/useAuth.ts | 3 ++- packages/nextjs/hooks/app/useAuthProof.ts | 3 ++- packages/nextjs/lib/form/utils.ts | 8 ++++---- packages/nextjs/services/api/apiClient.ts | 3 ++- 12 files changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/nextjs/components/Batch/BatchContainer.tsx b/packages/nextjs/components/Batch/BatchContainer.tsx index e0e607bc..c1fdacc4 100644 --- a/packages/nextjs/components/Batch/BatchContainer.tsx +++ b/packages/nextjs/components/Batch/BatchContainer.tsx @@ -397,7 +397,7 @@ export default function BatchContainer() { tokenSymbol: token.symbol, }; }), - [selectedBatchItems, getToken], + [selectedBatchItems, getToken, network], ); const handleCloseDrawer = useCallback(() => { diff --git a/packages/nextjs/components/Transfer/TransferContainer.tsx b/packages/nextjs/components/Transfer/TransferContainer.tsx index 0fbf8729..d00825c2 100644 --- a/packages/nextjs/components/Transfer/TransferContainer.tsx +++ b/packages/nextjs/components/Transfer/TransferContainer.tsx @@ -12,6 +12,7 @@ import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; import { useTokenBalances } from "~~/hooks/app/useTokenBalance"; import { useZodForm } from "~~/hooks/form"; import { TransferFormData, transferSchema } from "~~/lib/form"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { useAccountStore, useIdentityStore } from "~~/services/store"; import { notification } from "~~/utils/scaffold-eth"; @@ -120,7 +121,7 @@ export default function TransferContainer() { setSelectedContactId(null); } catch (error: any) { console.error("Add to batch error:", error); - notification.error(error.message || "Failed to add to batch"); + notification.error(formatErrorMessage(error, "Failed to add to batch")); } }; diff --git a/packages/nextjs/components/modals/ClaimRewardModal.tsx b/packages/nextjs/components/modals/ClaimRewardModal.tsx index ee626679..f146f69a 100644 --- a/packages/nextjs/components/modals/ClaimRewardModal.tsx +++ b/packages/nextjs/components/modals/ClaimRewardModal.tsx @@ -6,6 +6,7 @@ import ModalContainer from "./ModalContainer"; import { useWalletClient } from "wagmi"; import { Button } from "~~/components/ui/button"; import { useClaimRewards, useClaimSummary } from "~~/hooks/api/useClaim"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { ModalProps } from "~~/types/modal"; import { chain } from "~~/utils/network-config"; @@ -63,7 +64,7 @@ const ClaimRewardModal: React.FC = ({ isOpen, onClose, we setTxHash(result.txHash); setState("success"); } catch (error: any) { - setErrorMessage(error?.message || "Failed to claim rewards. Please try again."); + setErrorMessage(formatErrorMessage(error, "Failed to claim rewards. Please try again.")); setState("error"); } }; diff --git a/packages/nextjs/components/modals/EditAccountModal/index.tsx b/packages/nextjs/components/modals/EditAccountModal/index.tsx index 5a211b97..62a7f3e4 100644 --- a/packages/nextjs/components/modals/EditAccountModal/index.tsx +++ b/packages/nextjs/components/modals/EditAccountModal/index.tsx @@ -7,6 +7,7 @@ import SubmittingStep from "./SubmittingStep"; import { ActionMode, ExistingSigner, ModalStep, PendingSigner } from "./types"; import ModalContainer from "~~/components/modals/ModalContainer"; import { useAccount, useMetaMultiSigWallet, useSignerTransaction } from "~~/hooks"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { useIdentityStore, useSidebarStore } from "~~/services/store"; import { ModalProps } from "~~/types/modal"; import { notification } from "~~/utils/scaffold-eth"; @@ -146,7 +147,7 @@ const EditAccountModal: React.FC = ({ isOpen, onClose }) => { closeManageAccounts(); } catch (error: any) { console.error("Failed to submit proposal:", error); - notification.error(error.message || "Failed to submit proposal"); + notification.error(formatErrorMessage(error, "Failed to submit proposal")); setStep("confirm"); // Back to confirm on error setIsSubmitting(false); } diff --git a/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts b/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts index 26d0278e..47fcdc02 100644 --- a/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts @@ -4,6 +4,7 @@ import { useWalletClient } from "wagmi"; import { useMetaMultiSigWallet } from "~~/hooks"; import { useCreateTransaction, useReserveNonce } from "~~/hooks/api"; import { useGenerateProof } from "~~/hooks/app/useGenerateProof"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { useIdentityStore } from "~~/services/store"; import { notification } from "~~/utils/scaffold-eth"; @@ -100,7 +101,7 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { options?.onSuccess?.(); } catch (error: any) { console.error("Propose batch error:", error); - notification.error(error.message || "Failed to propose batch"); + notification.error(formatErrorMessage(error, "Failed to propose batch")); } finally { setIsLoading(false); setLoadingState(""); diff --git a/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts b/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts index 08bd83be..17a617cd 100644 --- a/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts @@ -3,6 +3,7 @@ import { SignerData, TxType, encodeAddSigners, encodeRemoveSigners, encodeUpdate import { useWalletClient } from "wagmi"; import { useGenerateProof, useMetaMultiSigWallet, useWalletCommitments, useWalletThreshold } from "~~/hooks"; import { useCreateTransaction, useReserveNonce } from "~~/hooks/api/useTransaction"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { notification } from "~~/utils/scaffold-eth"; interface UseSignerTransactionOptions { @@ -107,7 +108,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { options?.onSuccess?.(); } catch (error: any) { console.error("Failed to add signer:", error); - notification.error(error.message || "Failed to add signer"); + notification.error(formatErrorMessage(error, "Failed to add signer")); } finally { setIsLoading(false); setLoadingState(""); @@ -159,7 +160,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { options?.onSuccess?.(); } catch (error: any) { console.error("Failed to remove signer:", error); - notification.error(error.message || "Failed to remove signer"); + notification.error(formatErrorMessage(error, "Failed to remove signer")); } finally { setIsLoading(false); setLoadingState(""); @@ -188,7 +189,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { options?.onSuccess?.(); } catch (error: any) { console.error("Failed to update threshold:", error); - notification.error(error.message || "Failed to update threshold"); + notification.error(formatErrorMessage(error, "Failed to update threshold")); } finally { setIsLoading(false); setLoadingState(""); diff --git a/packages/nextjs/hooks/app/transaction/useTransactionVote.ts b/packages/nextjs/hooks/app/transaction/useTransactionVote.ts index 11aceb38..9475cc82 100644 --- a/packages/nextjs/hooks/app/transaction/useTransactionVote.ts +++ b/packages/nextjs/hooks/app/transaction/useTransactionVote.ts @@ -16,6 +16,7 @@ import { useWalletClient } from "wagmi"; import { accountKeys, useMetaMultiSigWallet, userKeys } from "~~/hooks"; import { useApproveTransaction, useDenyTransaction, useExecuteTransaction } from "~~/hooks/api/useTransaction"; import { useGenerateProof } from "~~/hooks/app/useGenerateProof"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { useIdentityStore } from "~~/services/store/useIdentityStore"; import { notification } from "~~/utils/scaffold-eth"; @@ -175,7 +176,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { options?.onSuccess?.(); } catch (error: any) { console.error("Approve error:", error); - notification.error(error.message || "Failed to approve"); + notification.error(formatErrorMessage(error, "Failed to approve")); } finally { setIsLoading(false); setLoadingState(""); @@ -207,7 +208,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { options?.onSuccess?.(); } catch (error: any) { console.error("Deny error:", error); - notification.error(error.message || "Failed to deny"); + notification.error(formatErrorMessage(error, "Failed to deny")); } finally { setIsLoading(false); setLoadingState(""); @@ -245,7 +246,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { options?.onSuccess?.(); } catch (error: any) { console.error("Execute error:", error); - notification.error(error.message || "Failed to execute"); + notification.error(formatErrorMessage(error, "Failed to execute")); } finally { setIsLoading(false); setLoadingState(""); diff --git a/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts b/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts index fc8e72a7..e16cebd6 100644 --- a/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts @@ -5,6 +5,7 @@ import { useWalletClient } from "wagmi"; import { useMetaMultiSigWallet } from "~~/hooks"; import { useCreateTransaction, useReserveNonce } from "~~/hooks/api/useTransaction"; import { useGenerateProof } from "~~/hooks/app/useGenerateProof"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { notification } from "~~/utils/scaffold-eth"; interface TransferParams { @@ -101,7 +102,7 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) options?.onSuccess?.(); } catch (error: any) { console.error("Transfer error:", error); - notification.error(error.message || "Failed to create transfer"); + notification.error(formatErrorMessage(error, "Failed to create transfer")); } finally { setIsLoading(false); setLoadingState(""); diff --git a/packages/nextjs/hooks/app/useAuth.ts b/packages/nextjs/hooks/app/useAuth.ts index c894c740..cb06b944 100644 --- a/packages/nextjs/hooks/app/useAuth.ts +++ b/packages/nextjs/hooks/app/useAuth.ts @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { userKeys } from "../api"; import { useAuthProof } from "./useAuthProof"; import { useQueryClient } from "@tanstack/react-query"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { authApi } from "~~/services/api"; import { useIdentityStore } from "~~/services/store"; @@ -52,7 +53,7 @@ export const useAuth = () => { return true; } catch (err: any) { - setError(err.message || "Login failed"); + setError(formatErrorMessage(err, "Login failed")); return false; } finally { setIsLoggingIn(false); diff --git a/packages/nextjs/hooks/app/useAuthProof.ts b/packages/nextjs/hooks/app/useAuthProof.ts index 733e2e3a..82e39f1f 100644 --- a/packages/nextjs/hooks/app/useAuthProof.ts +++ b/packages/nextjs/hooks/app/useAuthProof.ts @@ -1,5 +1,6 @@ import { useState } from "react"; import { useWalletClient } from "wagmi"; +import { formatErrorMessage } from "~~/lib/form/utils"; import { createCommitment, createSecret } from "~~/utils/multisig"; interface AuthProofResult { @@ -69,7 +70,7 @@ export const useAuthProof = () => { walletAddress, // For analytics only - NOT stored in database }; } catch (err: any) { - setError(err.message || "Failed to generate auth proof"); + setError(formatErrorMessage(err, "Failed to generate auth proof")); return null; } finally { setIsGenerating(false); diff --git a/packages/nextjs/lib/form/utils.ts b/packages/nextjs/lib/form/utils.ts index 7da2181f..3f3613f3 100644 --- a/packages/nextjs/lib/form/utils.ts +++ b/packages/nextjs/lib/form/utils.ts @@ -94,13 +94,13 @@ export function getDirtyFields(form: UseFormReturn): P /** * Format error message for display */ -export function formatErrorMessage(error: unknown): string { +export function formatErrorMessage(error: unknown, defaultMessage?: string): string { if (typeof error === "string") return error; - if (error instanceof Error) return error.message; - if (typeof error === "object" && error !== null && "message" in error) { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "object" && error !== null && "message" in error && error.message) { return String(error.message); } - return "An unknown error occurred"; + return defaultMessage ?? "An unknown error occurred"; } /** diff --git a/packages/nextjs/services/api/apiClient.ts b/packages/nextjs/services/api/apiClient.ts index 3bab29d1..e347f2fb 100644 --- a/packages/nextjs/services/api/apiClient.ts +++ b/packages/nextjs/services/api/apiClient.ts @@ -2,6 +2,7 @@ import { useIdentityStore } from "../store"; import { authApi } from "./authApi"; import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from "axios"; import { API_BASE_URL } from "~~/constants"; +import { formatErrorMessage } from "~~/lib/form/utils"; const AUTHORIZATION_HEADER = (accessToken: string) => `Bearer ${accessToken}`; const ZK_TIMEOUT = 600000; // 10 minutes for ZK proof generation + verification @@ -103,7 +104,7 @@ apiClient.interceptors.response.use( throw new Error("Network Error - Please check your connection"); } else { console.error("❌ Error:", error.message); - throw new Error(error.message || "An unexpected error occurred"); + throw new Error(formatErrorMessage(error, "An unexpected error occurred")); } }, ); From 3243ae0341648f524bc12c3d4373a054277f7096 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Mon, 23 Feb 2026 14:40:44 +0700 Subject: [PATCH 3/5] feat(tokens): add USDC token support --- packages/shared/src/constants/token.ts | 27 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/constants/token.ts b/packages/shared/src/constants/token.ts index f8e139dc..b593012c 100644 --- a/packages/shared/src/constants/token.ts +++ b/packages/shared/src/constants/token.ts @@ -45,14 +45,6 @@ export const ZEN_TOKEN: Token = { coingeckoId: "zencash", }; -// { -// address: "0x01c7AEb2A0428b4159c0E333712f40e127aF639E", // USDC Horizen testnet address -// symbol: "USDC", -// name: "USD Coin", -// decimals: 6, -// icon: "/token/usdc.svg", -// coingeckoId: "usd-coin", -// }, // { // address: "0x15d70535a71Dba52b572EbF746c7C2F5806ACd0e", // USDT Horizen testnet address // symbol: "USDT", @@ -70,8 +62,25 @@ export const ZEN_TOKEN: Token = { // coingeckoId: "dai", // }, +// USDC - USD Coin (Horizen testnet / mainnet) +export const USDC_TOKEN: Token = { + addresses: { + testnet: "0x01c7AEb2A0428b4159c0E333712f40e127aF639E", + mainnet: "0xDF7108f8B10F9b9eC1aba01CCa057268cbf86B6c", + }, + symbol: "USDC", + name: "USD Coin", + decimals: 6, + icon: "/token/usdc.svg", + coingeckoId: "usd-coin", +}; + // All supported tokens (base definitions) -export const SUPPORTED_TOKENS_BASE: Token[] = [NATIVE_ETH, ZEN_TOKEN]; +export const SUPPORTED_TOKENS_BASE: Token[] = [ + NATIVE_ETH, + ZEN_TOKEN, + USDC_TOKEN, +]; // Helper: Get all coingecko IDs export function getCoingeckoIds(): string[] { From f78d4901bbaa50263af2d8b58240fbf74d129337 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Mon, 23 Feb 2026 13:48:51 +0700 Subject: [PATCH 4/5] refactor(questApi): extract buildLeaderboardParams helper to reduce duplication --- .cursorignore | 6 +++ packages/nextjs/services/api/questApi.ts | 47 ++++++++++-------------- 2 files changed, 25 insertions(+), 28 deletions(-) create mode 100644 .cursorignore diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 00000000..d6fd9b0d --- /dev/null +++ b/.cursorignore @@ -0,0 +1,6 @@ +.env +.env.local +.env.production +*.pem +*.key +secrets/ diff --git a/packages/nextjs/services/api/questApi.ts b/packages/nextjs/services/api/questApi.ts index 4a235303..6fde1546 100644 --- a/packages/nextjs/services/api/questApi.ts +++ b/packages/nextjs/services/api/questApi.ts @@ -9,6 +9,21 @@ import type { UserPoints, } from "@polypay/shared"; +type LeaderboardParamsExtra = { limit?: number; cursor?: string }; + +function buildLeaderboardParams( + filter: LeaderboardFilter, + week?: number, + extra?: LeaderboardParamsExtra, +): URLSearchParams { + const params = new URLSearchParams(); + params.append("filter", filter); + if (filter === "weekly" && week != null) params.append("week", week.toString()); + if (extra?.limit != null) params.append("limit", extra.limit.toString()); + if (extra?.cursor) params.append("cursor", extra.cursor); + return params; +} + export const questApi = { /** * Get all active quests @@ -22,12 +37,7 @@ export const questApi = { * Get top 3 leaderboard entries */ getLeaderboardTop: async (filter: LeaderboardFilter = "all-time", week?: number): Promise => { - const params = new URLSearchParams(); - params.append("filter", filter); - if (filter === "weekly" && week) { - params.append("week", week.toString()); - } - + const params = buildLeaderboardParams(filter, week); const { data } = await apiClient.get( `${API_ENDPOINTS.quests.leaderboardTop}?${params.toString()}`, ); @@ -38,12 +48,7 @@ export const questApi = { * Get current user's leaderboard position */ getLeaderboardMe: async (filter: LeaderboardFilter = "all-time", week?: number): Promise => { - const params = new URLSearchParams(); - params.append("filter", filter); - if (filter === "weekly" && week) { - params.append("week", week.toString()); - } - + const params = buildLeaderboardParams(filter, week); const { data } = await apiClient.get( `${API_ENDPOINTS.quests.leaderboardMe}?${params.toString()}`, ); @@ -59,16 +64,7 @@ export const questApi = { limit: number = DEFAULT_PAGE_SIZE, cursor?: string, ): Promise> => { - const params = new URLSearchParams(); - params.append("filter", filter); - params.append("limit", limit.toString()); - if (filter === "weekly" && week) { - params.append("week", week.toString()); - } - if (cursor) { - params.append("cursor", cursor); - } - + const params = buildLeaderboardParams(filter, week, { limit, cursor }); const { data } = await apiClient.get>( `${API_ENDPOINTS.quests.leaderboard}?${params.toString()}`, ); @@ -79,12 +75,7 @@ export const questApi = { * Get current user's points and history */ getMyPoints: async (filter: LeaderboardFilter = "all-time", week?: number): Promise => { - const params = new URLSearchParams(); - params.append("filter", filter); - if (filter === "weekly" && week) { - params.append("week", week.toString()); - } - + const params = buildLeaderboardParams(filter, week); const { data } = await apiClient.get(`${API_ENDPOINTS.quests.myPoints}?${params.toString()}`); return data; }, From b45688365829f277d0a5465a7384a22cd24662b6 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Mon, 23 Feb 2026 16:47:02 +0700 Subject: [PATCH 5/5] Update example dates in AnalyticsReportDto for clarity --- packages/backend/src/admin/dto/analytics-report.dto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/admin/dto/analytics-report.dto.ts b/packages/backend/src/admin/dto/analytics-report.dto.ts index fa7a49de..3c0f2ec0 100644 --- a/packages/backend/src/admin/dto/analytics-report.dto.ts +++ b/packages/backend/src/admin/dto/analytics-report.dto.ts @@ -5,7 +5,7 @@ import { Transform } from 'class-transformer'; export class AnalyticsReportDto { @ApiPropertyOptional({ description: 'Start date (inclusive)', - example: '2026-01-01', + example: '2026-01-29', }) @IsOptional() @IsDateString() @@ -13,7 +13,7 @@ export class AnalyticsReportDto { @ApiPropertyOptional({ description: 'End date (inclusive)', - example: '2026-02-01', + example: '2027-01-29', }) @IsOptional() @IsDateString()