From d4522125d5172ad23713dff58f9cd595941905ab Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Thu, 28 May 2026 19:35:02 +0100 Subject: [PATCH] feat: implement signature errors, bio count, sticky header, and confirmation labels - Add targeted error copy for rejected and failed wallet signature requests (#340) - Implement inline character counter for creator bio input with warning states (#342) - Add scroll-aware sticky collapse behavior to creator profile header (#343) - Implement human-readable block confirmation labels (Pending/Confirming/Confirmed) (#344) --- .../common/CreatorOnboardingForm.tsx | 2 + .../common/CreatorProfileHeader.tsx | 152 +++++++++++------- src/components/common/FormInput.tsx | 39 +++-- src/components/common/PendingTxModal.tsx | 34 ++++ src/pages/LandingPage.tsx | 5 +- src/utils/errorHandling.utils.ts | 55 +++++++ src/utils/transaction.utils.ts | 43 +++++ 7 files changed, 259 insertions(+), 71 deletions(-) create mode 100644 src/utils/errorHandling.utils.ts create mode 100644 src/utils/transaction.utils.ts diff --git a/src/components/common/CreatorOnboardingForm.tsx b/src/components/common/CreatorOnboardingForm.tsx index 77023c7..a7cb78f 100644 --- a/src/components/common/CreatorOnboardingForm.tsx +++ b/src/components/common/CreatorOnboardingForm.tsx @@ -109,6 +109,8 @@ export const CreatorOnboardingForm: React.FC< placeholder="Tell us about yourself..." touched={touched.bio} rows={4} + maxLength={200} + showCharacterCount={true} /> = ({ className, }) => { const [copied, setCopied] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 20); + }; + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, []); // Display-normalised handle; raw `handle` is preserved for any equality / // URL construction the caller might do via the prop. @@ -75,75 +85,99 @@ const CreatorProfileHeader: React.FC = ({ return (
-
-
- -
-
-
-

- {name} -

- {isVerified && ( -
- +
+
+ + + +
+
+ + {name} + + {isVerified && ( +
+ +
+ )} +
+ {!isScrolled ? ( +
+

+ {displayHandle || `@${handle}`} +

+
+ ) : ( +

+ {displayHandle || `@${handle}`} +

)}
-

+ +

+
- -
- -
); }; diff --git a/src/components/common/FormInput.tsx b/src/components/common/FormInput.tsx index a6ffe25..2c4826c 100644 --- a/src/components/common/FormInput.tsx +++ b/src/components/common/FormInput.tsx @@ -22,6 +22,7 @@ interface FormInputProps { suffix?: React.ReactNode; // Optional wrapper className for the input container wrapperClassName?: string; + showCharacterCount?: boolean; } export const FormInput: React.FC = ({ @@ -42,6 +43,7 @@ export const FormInput: React.FC = ({ prefix, suffix, wrapperClassName = '', + showCharacterCount = false, }) => { // Local display state is used only for number inputs so we can // show formatted (comma separated) values while keeping the @@ -198,16 +200,33 @@ export const FormInput: React.FC = ({ {renderInputWithElements()} - - {hasError && ( - - )} + +
+
+ {hasError && ( + + )} +
+ {showCharacterCount && maxLength && ( +
+ {String(value).length} / {maxLength} +
+ )} +
); }; diff --git a/src/components/common/PendingTxModal.tsx b/src/components/common/PendingTxModal.tsx index 9303914..a0e50ff 100644 --- a/src/components/common/PendingTxModal.tsx +++ b/src/components/common/PendingTxModal.tsx @@ -11,6 +11,9 @@ import { Button } from '@/components/ui/button'; import CircularSpinner from '@/components/common/CircularSpinnerProps'; import { cn } from '@/lib/utils'; import TransactionHashRow from '@/components/common/TransactionHashRow'; +import { getConfirmationStatus, CONFIRMATION_THRESHOLDS } from '@/utils/transaction.utils'; +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; export interface PendingTxModalProps { open: boolean; @@ -30,6 +33,8 @@ export interface PendingTxModalProps { label: string; onClick: () => void; }; + /** Optional block confirmation count */ + confirmations?: number; } const PendingTxModal: React.FC = ({ @@ -42,6 +47,7 @@ const PendingTxModal: React.FC = ({ explorerUrl, blockDismissal = false, action, + confirmations, }) => { const handleOpenChange = (next: boolean) => { if (!next && blockDismissal && isLoading) return; @@ -93,6 +99,34 @@ const PendingTxModal: React.FC = ({ {description} + + {confirmations !== undefined && ( +
+ + + + = CONFIRMATION_THRESHOLDS.CONFIRMED ? "success" : "secondary"} + className={cn( + "px-3 py-1 text-xs font-bold uppercase tracking-wider", + confirmations === 0 && "bg-slate-500/20 text-slate-400 border-slate-500/30", + confirmations > 0 && confirmations < CONFIRMATION_THRESHOLDS.CONFIRMED && "bg-amber-500/20 text-amber-300 border-amber-500/30", + confirmations >= CONFIRMATION_THRESHOLDS.CONFIRMED && "bg-emerald-500/20 text-emerald-400 border-emerald-500/30" + )} + > + {getConfirmationStatus(confirmations)} + + + +

{confirmations} block confirmations

+
+
+
+

+ {confirmations} confirmations +

+
+ )} {txHash && ( diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index c2f7453..41a8378 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -29,6 +29,7 @@ import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; import PrecisionModeToggle, { type PrecisionMode, @@ -478,9 +479,9 @@ function LandingPage() { : `Holdings refreshed: -${formatNumber(amount)} keys.` ); setTradeDialogOpen(false); - } catch { + } catch (error) { setFeaturedHoldings(previousHoldings); - showToast.error('Trade failed. Holdings have been restored.'); + showToast.error(getSignatureErrorMessage(error)); } finally { setTradeSubmitting(false); } diff --git a/src/utils/errorHandling.utils.ts b/src/utils/errorHandling.utils.ts new file mode 100644 index 0000000..fd254cb --- /dev/null +++ b/src/utils/errorHandling.utils.ts @@ -0,0 +1,55 @@ +/** + * Wallet and Signature error handling utilities + */ + +/** + * Standard wallet error types and their corresponding user-friendly messages + */ +export const WALLET_ERROR_COPY = { + SIGNATURE_REJECTED: "Signature request was declined. Please try again when you're ready to confirm.", + SIGNATURE_FAILED: "The signature request failed. Please ensure your wallet is unlocked and try again.", + GENERIC_TRANSACTION_FAILED: "Transaction failed. Please check your balance or connection and try again.", +}; + +interface WalletError { + message?: string; + code?: number | string; +} + +/** + * Determines if an error is a user rejection of a signature or transaction + * + * @param error - The error object to check + * @returns boolean + */ +export function isUserRejection(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + + const err = error as WalletError; + // Check common error codes and names across different wallets/libraries + const errorMessage = (err.message || '').toLowerCase(); + const errorCode = err.code; + + return ( + errorCode === 4001 || // EIP-1193 userRejectedRequest + errorCode === 'ACTION_REJECTED' || // ethers.js + errorMessage.includes('user rejected') || + errorMessage.includes('declined') || + errorMessage.includes('cancelled') + ); +} + +/** + * Gets a human-readable error message for a wallet signature failure + * + * @param error - The error object + * @returns Targeted error message + */ +export function getSignatureErrorMessage(error: unknown): string { + if (isUserRejection(error)) { + return WALLET_ERROR_COPY.SIGNATURE_REJECTED; + } + + // For other failures that aren't explicit rejections + return WALLET_ERROR_COPY.SIGNATURE_FAILED; +} diff --git a/src/utils/transaction.utils.ts b/src/utils/transaction.utils.ts new file mode 100644 index 0000000..3d998be --- /dev/null +++ b/src/utils/transaction.utils.ts @@ -0,0 +1,43 @@ +/** + * Transaction confirmation status thresholds + */ +export const CONFIRMATION_THRESHOLDS = { + PENDING: 0, + CONFIRMING_START: 1, + CONFIRMED: 6, +} as const; + +export type ConfirmationStatus = 'Pending' | 'Confirming' | 'Confirmed'; + +/** + * Maps a raw block confirmation count to a human-readable status label + * + * @param confirmations - The number of block confirmations + * @returns Human-readable confirmation status + */ +export function getConfirmationStatus(confirmations: number): ConfirmationStatus { + if (confirmations <= CONFIRMATION_THRESHOLDS.PENDING) { + return 'Pending'; + } + if (confirmations < CONFIRMATION_THRESHOLDS.CONFIRMED) { + return 'Confirming'; + } + return 'Confirmed'; +} + +/** + * Maps a confirmation status to a UI tone/color + * + * @param status - The confirmation status + * @returns UI tone string + */ +export function getConfirmationTone(status: ConfirmationStatus): 'neutral' | 'warning' | 'success' { + switch (status) { + case 'Pending': + return 'neutral'; + case 'Confirming': + return 'warning'; + case 'Confirmed': + return 'success'; + } +}