From efb965ec556e64cbd9a582462b4f2f5c4a928c9e Mon Sep 17 00:00:00 2001 From: James Akolo Date: Tue, 28 Jul 2026 18:55:21 +0100 Subject: [PATCH 1/4] feat: add equal distribution one-click button for split builder (#494) - Add 'Split equally' button to distribute amounts evenly among recipients - Implement undo functionality to restore previous distribution - Button disabled when fewer than 2 recipients - Undo button appears after split and clears previous state - Add calculateEqualSplit utility to split calculator - Accessible via aria-label with WCAG compliance --- src/app/invoice/new/page.tsx | 102 +++++++++++++++++++++++++------- src/hooks/useEqualSplitUndo.ts | 48 +++++++++++++++ src/hooks/useSplitCalculator.ts | 9 +++ 3 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 src/hooks/useEqualSplitUndo.ts diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx index 259d6e7..c5f938e 100644 --- a/src/app/invoice/new/page.tsx +++ b/src/app/invoice/new/page.tsx @@ -219,6 +219,39 @@ function NewInvoiceForm() { const { toasts, addToast } = useToasts(); + const handleSplitEqually = () => { + if (recipients.length <= 1) return; + + setPreviousRecipientsForUndo([...recipients]); + + const { perRecipient, remainder } = (() => { + const pp = Math.floor(100 / recipients.length); + const rem = 100 - (pp * recipients.length); + return { perRecipient: pp, remainder: rem }; + })(); + + const newRecipients = recipients.map((r, index) => ({ + ...r, + amount: (100 / recipients.length).toFixed(7), + })); + + if (remainder > 0 && newRecipients.length > 0) { + const firstAmount = parseFloat(newRecipients[0].amount) + (remainder / recipients.length); + newRecipients[0].amount = firstAmount.toFixed(7); + } + + setRecipients(newRecipients); + addToast("Split distributed equally", "success"); + }; + + const handleUndoSplit = () => { + if (previousRecipientsForUndo) { + setRecipients(previousRecipientsForUndo); + setPreviousRecipientsForUndo(null); + addToast("Distribution undone", "success"); + } + }; + const handleImported = (data: ImportedTxData) => { setImportedTx(data); setRetroError(null); @@ -298,6 +331,7 @@ function NewInvoiceForm() { const [loading, setLoading] = useState(false); const [autofilled, setAutofilled] = useState(false); const [stepErrors, setStepErrors] = useState>({}); + const [previousRecipientsForUndo, setPreviousRecipientsForUndo] = useState(null); useEffect(() => { if (fromId || sessionStorage.getItem("invoiceTemplate") || searchParams.get("address")) return; @@ -694,28 +728,54 @@ function NewInvoiceForm() {

Recipients

{!cloneSourceId && ( -
- - -
+ > + + + + + {!equalSplit && ( +
+ + {previousRecipientsForUndo && ( + + )} +
+ )} + )} {equalSplit && !cloneSourceId && ( diff --git a/src/hooks/useEqualSplitUndo.ts b/src/hooks/useEqualSplitUndo.ts new file mode 100644 index 0000000..4b5ee77 --- /dev/null +++ b/src/hooks/useEqualSplitUndo.ts @@ -0,0 +1,48 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +interface RecipientRow { + address: string; + amount: string; +} + +export function useEqualSplitUndo(recipients: RecipientRow[]) { + const [previousState, setPreviousState] = useState(null); + + const splitEqually = useCallback( + (totalAmount: number) => { + if (recipients.length <= 1) return false; + + setPreviousState([...recipients]); + + const perRecipient = Math.floor(100 / recipients.length); + const remainder = 100 - (perRecipient * recipients.length); + const amountPerRecipient = totalAmount / recipients.length; + + const newRecipients = recipients.map((r, index) => ({ + ...r, + amount: (amountPerRecipient).toFixed(7), + })); + + // Assign remainder to first recipient + if (remainder > 0 && newRecipients.length > 0) { + const firstAmount = parseFloat(newRecipients[0].amount) + (totalAmount * remainder / 100); + newRecipients[0].amount = firstAmount.toFixed(7); + } + + return newRecipients; + }, + [recipients] + ); + + const undo = useCallback(() => { + const state = previousState; + setPreviousState(null); + return state; + }, [previousState]); + + const hasUndo = previousState !== null; + + return { splitEqually, undo, hasUndo }; +} diff --git a/src/hooks/useSplitCalculator.ts b/src/hooks/useSplitCalculator.ts index f04082b..a2e145b 100644 --- a/src/hooks/useSplitCalculator.ts +++ b/src/hooks/useSplitCalculator.ts @@ -158,3 +158,12 @@ export function defaultRecipientLine(address = ''): RecipientLine { fixedFeeXLM: 0, }; } + +export function calculateEqualSplit(recipientCount: number): { perRecipient: number; remainder: number } { + if (recipientCount <= 0) { + return { perRecipient: 0, remainder: 0 }; + } + const perRecipient = Math.floor(100 / recipientCount); + const remainder = 100 - (perRecipient * recipientCount); + return { perRecipient, remainder }; +} From 1f7034bebc1beb39f4a499b51ffea64c6484c41a Mon Sep 17 00:00:00 2001 From: James Akolo Date: Tue, 28 Jul 2026 18:56:36 +0100 Subject: [PATCH 2/4] feat: add inline field-level error messages with accessible markup (#495) - Create FormField component for consistent error display - Show errors on field blur and during typing after first blur - Use aria-describedby and aria-invalid for accessibility - Display red border and error icon for invalid fields - Add field validation for token address and deadline - Errors clear immediately when field becomes valid - Improve UX by showing specific validation feedback inline --- src/app/invoice/new/page.tsx | 193 +++++++++++++++++++++----------- src/components/ui/FormField.tsx | 72 ++++++++++++ 2 files changed, 201 insertions(+), 64 deletions(-) create mode 100644 src/components/ui/FormField.tsx diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx index c5f938e..9085327 100644 --- a/src/app/invoice/new/page.tsx +++ b/src/app/invoice/new/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import dynamic from "next/dynamic"; import Stepper, { type Step } from "@/components/ui/Stepper"; +import FormField from "@/components/ui/FormField"; import { splitClient } from "@/lib/stellar"; import { getFreighterPublicKey } from "@/lib/freighter"; import { deadlineFromDays, parseAmount, formatAmount } from "@stellar-split/sdk"; @@ -252,6 +253,41 @@ function NewInvoiceForm() { } }; + const markFieldTouched = (fieldName: string) => { + setTouchedFields((prev) => new Set([...prev, fieldName])); + }; + + const validateField = (fieldName: string, value: unknown): string | null => { + if (fieldName === 'token') { + const tokenValue = value as string; + if (!tokenValue || !tokenValue.startsWith('C')) { + return 'Valid token contract address (starting with C) is required'; + } + } else if (fieldName === 'deadlineDays') { + const days = value as number; + if (days < 1 || days > 365) { + return 'Deadline must be between 1 and 365 days'; + } + } else if (fieldName === 'cloneDeadline') { + const deadlineError = validateDeadline(value as string); + return deadlineError; + } + return null; + }; + + const handleFieldBlur = (fieldName: string, value: unknown) => { + markFieldTouched(fieldName); + const error = validateField(fieldName, value); + setFieldErrors((prev) => ({ ...prev, [fieldName]: error })); + }; + + const handleFieldChange = (fieldName: string, value: unknown) => { + if (touchedFields.has(fieldName)) { + const error = validateField(fieldName, value); + setFieldErrors((prev) => ({ ...prev, [fieldName]: error })); + } + }; + const handleImported = (data: ImportedTxData) => { setImportedTx(data); setRetroError(null); @@ -332,6 +368,8 @@ function NewInvoiceForm() { const [autofilled, setAutofilled] = useState(false); const [stepErrors, setStepErrors] = useState>({}); const [previousRecipientsForUndo, setPreviousRecipientsForUndo] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + const [touchedFields, setTouchedFields] = useState>(new Set()); useEffect(() => { if (fromId || sessionStorage.getItem("invoiceTemplate") || searchParams.get("address")) return; @@ -606,78 +644,91 @@ function NewInvoiceForm() { /> )} -
- - + + setToken(e.target.value)} - required + onChange={(e) => { + setToken(e.target.value); + handleFieldChange('token', e.target.value); + }} + onBlur={(e) => handleFieldBlur('token', e.target.value)} placeholder="C..." - className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500" + className={`w-full min-h-11 bg-gray-800 border rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ + touchedFields.has('token') && fieldErrors['token'] + ? 'border-red-500' + : 'border-gray-700' + }`} /> - -
+ + {cloneSourceId ? ( -
- + { setCloneDeadlineIso(e.target.value); - const err = validateDeadline(e.target.value); - setDeadlineError(err); - setStepErrors((prev) => ({ ...prev, [0]: err })); + handleFieldChange('cloneDeadline', e.target.value); }} - required + onBlur={(e) => handleFieldBlur('cloneDeadline', e.target.value)} className={`w-full min-h-11 bg-gray-800 border rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ - deadlineError ? "border-red-500" : "border-gray-700" + touchedFields.has('cloneDeadline') && fieldErrors['cloneDeadline'] + ? 'border-red-500' + : 'border-gray-700' }`} - aria-describedby={deadlineError ? "clone-deadline-error" : undefined} - aria-invalid={!!deadlineError} /> - {deadlineError && ( - - )} -
+ ) : ( -
- + setDeadlineDays(Number(e.target.value))} - required - className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> - sum + parseFloat(r.amount || "0"), 0) - .toString() - } - recipientCount={recipients.filter((r) => r.address).length} - onUseSuggestion={(days: number) => setDeadlineDays(days)} + onChange={(e) => { + setDeadlineDays(Number(e.target.value)); + handleFieldChange('deadlineDays', Number(e.target.value)); + }} + onBlur={(e) => handleFieldBlur('deadlineDays', Number(e.target.value))} + className={`w-full min-h-11 bg-gray-800 border rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ + touchedFields.has('deadlineDays') && fieldErrors['deadlineDays'] + ? 'border-red-500' + : 'border-gray-700' + }`} /> -
+ + )} + + {!cloneSourceId && ( + sum + parseFloat(r.amount || "0"), 0) + .toString() + } + recipientCount={recipients.filter((r) => r.address).length} + onUseSuggestion={(days: number) => setDeadlineDays(days)} + /> )} {!cloneSourceId && ( @@ -779,27 +830,41 @@ function NewInvoiceForm() { )} {equalSplit && !cloneSourceId && ( -
- - + setTotalAmount(e.target.value)} + label={t("invoiceNew.totalAmount")} + error={touchedFields.has('totalAmount') ? fieldErrors['totalAmount'] : null} required - className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500" - /> + > + { + setTotalAmount(e.target.value); + if (touchedFields.has('totalAmount')) { + handleFieldChange('totalAmount', e.target.value); + } + }} + onBlur={(e) => { + handleFieldBlur('totalAmount', e.target.value); + }} + className={`w-full min-h-11 bg-gray-800 border rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ + touchedFields.has('totalAmount') && fieldErrors['totalAmount'] + ? 'border-red-500' + : 'border-gray-700' + }`} + /> + {perRecipientAmount && ( -

+

{perRecipientAmount} {t("invoiceNew.perRecipient")}

)} -
+ )}
diff --git a/src/components/ui/FormField.tsx b/src/components/ui/FormField.tsx new file mode 100644 index 0000000..fbaa51a --- /dev/null +++ b/src/components/ui/FormField.tsx @@ -0,0 +1,72 @@ +'use client'; + +import React from 'react'; + +interface FormFieldProps { + id: string; + label: string; + error?: string | null; + children: React.ReactNode; + helperText?: string; + required?: boolean; +} + +export default function FormField({ + id, + label, + error, + children, + helperText, + required = false, +}: FormFieldProps) { + const errorId = error ? `${id}-error` : undefined; + + return ( +
+ + +
+ {React.cloneElement(children as React.ReactElement, { + id, + 'aria-describedby': errorId || helperText ? `${id}-hint` : undefined, + 'aria-invalid': !!error, + })} + + {error && ( +
+ + + +
+ )} +
+ + {error && ( + + )} + + {helperText && !error && ( +

+ {helperText} +

+ )} +
+ ); +} From 2398c410baec6ccf280ac2848f3491463647a885 Mon Sep 17 00:00:00 2001 From: James Akolo Date: Tue, 28 Jul 2026 18:57:56 +0100 Subject: [PATCH 3/4] feat: add invoice draft discard confirmation dialog (#496) - Create useUnsavedChanges hook to track form dirty state - Create UnsavedChangesModal with accessible dialog markup - Intercept navigation attempts and show confirmation - Trigger native beforeunload dialog on browser tab close - Only show modal after user interacts with form - 'Discard' proceeds with navigation and clears draft - 'Keep editing' returns focus to form without navigation - Add back button to header with protection --- src/app/invoice/new/page.tsx | 73 +++++++++++++++++- src/components/ui/UnsavedChangesModal.tsx | 93 +++++++++++++++++++++++ src/hooks/useUnsavedChanges.ts | 65 ++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/UnsavedChangesModal.tsx create mode 100644 src/hooks/useUnsavedChanges.ts diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx index 9085327..4805aea 100644 --- a/src/app/invoice/new/page.tsx +++ b/src/app/invoice/new/page.tsx @@ -6,6 +6,7 @@ import { useRouter, useSearchParams, usePathname } from "next/navigation"; import dynamic from "next/dynamic"; import Stepper, { type Step } from "@/components/ui/Stepper"; import FormField from "@/components/ui/FormField"; +import UnsavedChangesModal from "@/components/ui/UnsavedChangesModal"; import { splitClient } from "@/lib/stellar"; import { getFreighterPublicKey } from "@/lib/freighter"; import { deadlineFromDays, parseAmount, formatAmount } from "@stellar-split/sdk"; @@ -155,6 +156,11 @@ function NewInvoiceForm() { const [draftId, setDraftId] = useState(null); const [recoveredDraft, setRecoveredDraft] = useState(null); + const isDraftDirty = () => { + if (!hasUserInteracted) return false; + return recipients.length > 0 || deadlineDays !== 7 || token !== (process.env.NEXT_PUBLIC_USDC_ADDRESS ?? "") || tags.length > 0; + }; + useEffect(() => { getFreighterPublicKey() .then((pk) => setDraftUserId(pk)) @@ -162,6 +168,18 @@ function NewInvoiceForm() { setDraftId(crypto.randomUUID()); }, []); + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isDraftDirty()) { + e.preventDefault(); + e.returnValue = ''; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [hasUserInteracted, recipients, deadlineDays, token, tags]); + useEffect(() => { if (!draftUserId || fromId || searchParams.get("template") || searchParams.get("address")) return; listDraftsForUser(draftUserId) @@ -174,6 +192,20 @@ function NewInvoiceForm() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [draftUserId]); + useEffect(() => { + if (hasUserInteracted) return; + const initialRecipients = [{ address: "", amount: "" }]; + const initialToken = process.env.NEXT_PUBLIC_USDC_ADDRESS ?? ""; + const initialDeadline = 7; + + if (JSON.stringify(recipients) !== JSON.stringify(initialRecipients) || + token !== initialToken || + deadlineDays !== initialDeadline || + tags.length > 0) { + setHasUserInteracted(true); + } + }, [recipients, token, deadlineDays, tags, hasUserInteracted]); + const draftSnapshot = { recipients, token, @@ -277,11 +309,13 @@ function NewInvoiceForm() { const handleFieldBlur = (fieldName: string, value: unknown) => { markFieldTouched(fieldName); + setHasUserInteracted(true); const error = validateField(fieldName, value); setFieldErrors((prev) => ({ ...prev, [fieldName]: error })); }; const handleFieldChange = (fieldName: string, value: unknown) => { + setHasUserInteracted(true); if (touchedFields.has(fieldName)) { const error = validateField(fieldName, value); setFieldErrors((prev) => ({ ...prev, [fieldName]: error })); @@ -370,6 +404,9 @@ function NewInvoiceForm() { const [previousRecipientsForUndo, setPreviousRecipientsForUndo] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); const [touchedFields, setTouchedFields] = useState>(new Set()); + const [showUnsavedModal, setShowUnsavedModal] = useState(false); + const [pendingNavigationHref, setPendingNavigationHref] = useState(null); + const [hasUserInteracted, setHasUserInteracted] = useState(false); useEffect(() => { if (fromId || sessionStorage.getItem("invoiceTemplate") || searchParams.get("address")) return; @@ -520,7 +557,18 @@ function NewInvoiceForm() { }; const handleBack = () => { - goToStep(Math.max(step - 1, 0)); + if (step > 0) { + goToStep(Math.max(step - 1, 0)); + } + }; + + const handleProtectedNavigation = (href: string) => { + if (isDraftDirty()) { + setPendingNavigationHref(href); + setShowUnsavedModal(true); + } else { + router.push(href); + } }; const payloadForApi = () => { @@ -1040,6 +1088,14 @@ function NewInvoiceForm() { )}
+

Create Invoice

{draftOffline && ( )} + + { + setShowUnsavedModal(false); + discardDraft(); + if (pendingNavigationHref) { + router.push(pendingNavigationHref); + } + }} + onKeepEditing={() => { + setShowUnsavedModal(false); + setPendingNavigationHref(null); + }} + /> ); } diff --git a/src/components/ui/UnsavedChangesModal.tsx b/src/components/ui/UnsavedChangesModal.tsx new file mode 100644 index 0000000..da23fe1 --- /dev/null +++ b/src/components/ui/UnsavedChangesModal.tsx @@ -0,0 +1,93 @@ +'use client'; + +import React, { useEffect, useRef } from 'react'; + +interface UnsavedChangesModalProps { + isOpen: boolean; + onDiscard: () => void; + onKeepEditing: () => void; + title?: string; + message?: string; +} + +export default function UnsavedChangesModal({ + isOpen, + onDiscard, + onKeepEditing, + title = 'Discard changes?', + message = 'You have unsaved changes. If you leave now, your work will be lost.', +}: UnsavedChangesModalProps) { + const focusTrapRef = useRef(null); + const discardButtonRef = useRef(null); + + useEffect(() => { + if (isOpen) { + const trapElement = focusTrapRef.current; + const focusableElements = trapElement?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements && focusableElements.length > 0) { + (focusableElements[focusableElements.length - 1] as HTMLElement).focus(); + } + } + }, [isOpen]); + + if (!isOpen) return null; + + return ( + <> +
Total - - {equalSplit ? totalAmount : total.toFixed(7)} USDC - + + + {equalSplit ? totalAmount : total.toFixed(7)} USDC + +
diff --git a/src/components/ui/FeeTooltip.tsx b/src/components/ui/FeeTooltip.tsx new file mode 100644 index 0000000..fd03f48 --- /dev/null +++ b/src/components/ui/FeeTooltip.tsx @@ -0,0 +1,156 @@ +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; + +interface FeeBreakdown { + baseFee: { stroops: number; xlm: number }; + resourceFee: { stroops: number; xlm: number }; + total: { stroops: number; xlm: number }; +} + +interface FeeTooltipProps { + feeBreakdown: FeeBreakdown | null; + isLoading?: boolean; + error?: string | null; + onRefresh?: () => void; + children: React.ReactElement; +} + +export default function FeeTooltip({ + feeBreakdown, + isLoading = false, + error = null, + onRefresh, + children, +}: FeeTooltipProps) { + const [isOpen, setIsOpen] = useState(false); + const tooltipRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + tooltipRef.current && + !tooltipRef.current.contains(event.target as Node) && + triggerRef.current && + !triggerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + } + }, [isOpen]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsOpen(!isOpen); + } + }; + + return ( +
+
setIsOpen(!isOpen)} + onKeyDown={handleKeyDown} + aria-label="Show fee breakdown" + aria-expanded={isOpen} + className="cursor-help" + > + {children} +
+ + {isOpen && ( +
+
+

Fee Breakdown

+ {onRefresh && ( + + )} +
+ + {isLoading ? ( +
Loading fee data...
+ ) : error ? ( +
{error}
+ ) : feeBreakdown ? ( + + + + + + + + + + + + + + + +
Base Fee + {feeBreakdown.baseFee.stroops} stroops +
Resource Fee + {feeBreakdown.resourceFee.stroops} stroops +
Total Fee +
+ + {feeBreakdown.total.stroops} stroops + + + {feeBreakdown.total.xlm.toFixed(7)} XLM + +
+
+ ) : ( +
Fee breakdown unavailable
+ )} +
+ )} +
+ ); +} diff --git a/src/hooks/useNetworkFeeBreakdown.ts b/src/hooks/useNetworkFeeBreakdown.ts new file mode 100644 index 0000000..400fd2a --- /dev/null +++ b/src/hooks/useNetworkFeeBreakdown.ts @@ -0,0 +1,62 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +interface FeeBreakdown { + baseFee: { stroops: number; xlm: number }; + resourceFee: { stroops: number; xlm: number }; + total: { stroops: number; xlm: number }; +} + +const STROOPS_PER_XLM = 10_000_000; + +export function useNetworkFeeBreakdown() { + const [feeBreakdown, setFeeBreakdown] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchFeeStats = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/fees'); + if (!response.ok) { + throw new Error('Failed to fetch fee stats'); + } + + const data = await response.json(); + + const baseFeeStroops = parseInt(data.baseFee || '100', 10); + const resourceFeeStroops = parseInt(data.resourceFee || '0', 10); + const totalStroops = baseFeeStroops + resourceFeeStroops; + + setFeeBreakdown({ + baseFee: { + stroops: baseFeeStroops, + xlm: baseFeeStroops / STROOPS_PER_XLM, + }, + resourceFee: { + stroops: resourceFeeStroops, + xlm: resourceFeeStroops / STROOPS_PER_XLM, + }, + total: { + stroops: totalStroops, + xlm: totalStroops / STROOPS_PER_XLM, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to fetch fee breakdown'; + setError(message); + setFeeBreakdown(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchFeeStats(); + }, [fetchFeeStats]); + + return { feeBreakdown, isLoading, error, refetch: fetchFeeStats }; +}