diff --git a/src/app/api/fees/route.ts b/src/app/api/fees/route.ts new file mode 100644 index 0000000..5a9c55f --- /dev/null +++ b/src/app/api/fees/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const horizonUrl = process.env.NEXT_PUBLIC_HORIZON_URL || 'https://horizon.stellar.org'; + + const response = await fetch(`${horizonUrl}/fee_stats`, { + headers: { + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: 'Failed to fetch fee stats from Horizon' }, + { status: response.status } + ); + } + + const data = await response.json(); + + return NextResponse.json({ + baseFee: data.base_fee?.toString() || '100', + resourceFee: data.soroban_resource_fee?.toString() || '0', + ledgerCapacityUsage: data.ledger_capacity_usage?.toString() || '0', + }); + } catch (error) { + console.error('Error fetching fee stats:', error); + return NextResponse.json( + { error: 'Failed to fetch fee data' }, + { status: 500 } + ); + } +} diff --git a/src/app/invoice/new/page.tsx b/src/app/invoice/new/page.tsx index 5127f56..8110ed4 100644 --- a/src/app/invoice/new/page.tsx +++ b/src/app/invoice/new/page.tsx @@ -5,6 +5,10 @@ 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 UnsavedChangesModal from "@/components/ui/UnsavedChangesModal"; +import FeeTooltip from "@/components/ui/FeeTooltip"; +import { useNetworkFeeBreakdown } from "@/hooks/useNetworkFeeBreakdown"; import { splitClient } from "@/lib/stellar"; import { getFreighterPublicKey } from "@/lib/freighter"; import { deadlineFromDays, parseAmount, formatAmount } from "@stellar-split/sdk"; @@ -161,6 +165,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)) @@ -168,6 +177,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) @@ -180,6 +201,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, @@ -226,6 +261,76 @@ 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 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); + 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 })); + } + }; + const handleImported = (data: ImportedTxData) => { setImportedTx(data); setRetroError(null); @@ -306,6 +411,13 @@ function NewInvoiceForm() { const [loading, setLoading] = useState(false); const [autofilled, setAutofilled] = useState(false); const [stepErrors, setStepErrors] = useState>({}); + 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); + const { feeBreakdown, isLoading: feeLoading, error: feeError, refetch: refetchFees } = useNetworkFeeBreakdown(); // Denomination toggle state (XLM / USDC) type Denomination = "XLM" | "USDC"; @@ -476,7 +588,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 = () => { @@ -600,13 +723,14 @@ function NewInvoiceForm() { /> )} -
- - + + setToken(e.target.value)} @@ -616,47 +740,46 @@ function NewInvoiceForm() { }} required 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 && ( - - )} -
+ ) : ( -
- + r.address).length} onUseSuggestion={(days: number) => setDeadlineDays(days)} /> -
+ + )} + + {!cloneSourceId && ( + sum + parseFloat(r.amount || "0"), 0) + .toString() + } + recipientCount={recipients.filter((r) => r.address).length} + onUseSuggestion={(days: number) => setDeadlineDays(days)} + /> )} {!cloneSourceId && ( @@ -732,36 +869,59 @@ function NewInvoiceForm() {

Recipients

{!cloneSourceId && ( -
- - -
+ > + + + + + {!equalSplit && ( +
+ + {previousRecipientsForUndo && ( + + )} +
+ )} + )} {equalSplit && !cloneSourceId && ( -
- - + {perRecipientAmount && ( -

+

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

)} -
+ )}
@@ -930,9 +1090,16 @@ function NewInvoiceForm() {
Total - - {equalSplit ? totalAmount : total.toFixed(7)} USDC - + + + {equalSplit ? totalAmount : total.toFixed(7)} USDC + +
@@ -993,6 +1160,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/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/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} +

+ )} +
+ ); +} 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 ( + <> +