Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions frontend/src/components/MilestoneBuilder.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'

export interface MilestoneInput {
amount: string
Expand All @@ -8,10 +8,54 @@ export interface MilestoneInput {
interface MilestoneBuilderProps {
milestones: MilestoneInput[]
onChange: (milestones: MilestoneInput[]) => void
onValidationChange?: (isValid: boolean) => void
}

export default function MilestoneBuilder({ milestones, onChange }: MilestoneBuilderProps) {
export default function MilestoneBuilder({ milestones, onChange, onValidationChange }: MilestoneBuilderProps) {
const [errors, setErrors] = useState<Record<number, string>>({})
const [duplicates, setDuplicates] = useState<Set<number>>(new Set())

// Check for duplicate amounts and invalid values
const validationStatus = useMemo(() => {
const amountMap = new Map<string, number[]>()
const newDuplicates = new Set<number>()
let hasErrors = false

milestones.forEach((milestone, index) => {
const numValue = parseFloat(milestone.amount)

// Check for individual validation errors
if (milestone.amount && (isNaN(numValue) || numValue <= 0)) {
hasErrors = true
}

// Track amounts for duplicate detection
if (milestone.amount && !isNaN(numValue) && numValue > 0) {
const key = numValue.toString()
if (!amountMap.has(key)) {
amountMap.set(key, [])
}
amountMap.get(key)!.push(index)
}
})

// Mark duplicates
amountMap.forEach((indices) => {
if (indices.length > 1) {
indices.forEach(index => newDuplicates.add(index))
hasErrors = true
}
})

setDuplicates(newDuplicates)
const isValid = !hasErrors && milestones.length > 0 && milestones.every(m => m.amount.trim() !== '')

if (onValidationChange) {
onValidationChange(isValid)
}

return { isValid, hasErrors }
}, [milestones, onValidationChange])

const addMilestone = () => {
onChange([...milestones, { amount: '', description: '' }])
Expand Down Expand Up @@ -141,6 +185,9 @@ export default function MilestoneBuilder({ milestones, onChange }: MilestoneBuil
{errors[index] && (
<p className="mt-1 text-xs text-red-400">{errors[index]}</p>
)}
{duplicates.has(index) && !errors[index] && (
<p className="mt-1 text-xs text-amber-400">⚠️ This amount is duplicated in another milestone</p>
)}
</div>

<div>
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/hooks/useAgreementEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ export function useAgreementEvents(agreementId: string | null) {
async (signal: AbortSignal): Promise<boolean> => {
if (!agreementId) return true // treat no-id as "success" (no-op)

setIsLoading(true)
setError(null)
// Guard state updates for unmounted components
if (!unmountedRef.current) {
setIsLoading(true)
setError(null)
}

// Combine the caller's signal with a per-request timeout signal.
const timeoutId = setTimeout(() => {
Expand Down
55 changes: 46 additions & 9 deletions frontend/src/hooks/useCountUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,52 @@ export function useCountUp(
): number {
const { duration = 500, minThreshold = 5 } = options

const [displayValue, setDisplayValue] = useState(targetValue)
const previousValueRef = useRef(targetValue)
// Validate and clamp target value
const validatedTarget = validateAndClampTarget(targetValue)

const [displayValue, setDisplayValue] = useState(validatedTarget)
const previousValueRef = useRef(validatedTarget)
const animationFrameRef = useRef<number | null>(null)
const startTimeRef = useRef<number | null>(null)
const iterationCountRef = useRef(0)

useEffect(() => {
const startValue = previousValueRef.current
const change = Math.abs(targetValue - startValue)
const change = Math.abs(validatedTarget - startValue)

if (change === 0) return

if (change < minThreshold) {
setDisplayValue(targetValue)
previousValueRef.current = targetValue
setDisplayValue(validatedTarget)
previousValueRef.current = validatedTarget
return
}

const startTime = performance.now()
startTimeRef.current = startTime
iterationCountRef.current = 0

const animate = (currentTime: number) => {
// Safeguard against infinite animation loops (max 1000 iterations)
iterationCountRef.current += 1
if (iterationCountRef.current > 1000) {
setDisplayValue(validatedTarget)
previousValueRef.current = validatedTarget
animationFrameRef.current = null
return
}

const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)

const current = startValue + (targetValue - startValue) * progress
const current = startValue + (validatedTarget - startValue) * progress
setDisplayValue(Math.round(current))

if (progress < 1) {
animationFrameRef.current = requestAnimationFrame(animate)
} else {
setDisplayValue(targetValue)
previousValueRef.current = targetValue
setDisplayValue(validatedTarget)
previousValueRef.current = validatedTarget
animationFrameRef.current = null
}
}
Expand All @@ -55,7 +69,30 @@ export function useCountUp(
animationFrameRef.current = null
}
}
}, [targetValue, duration, minThreshold])
}, [validatedTarget, duration, minThreshold])

return displayValue
}

/**
* Validates and clamps the target value for count-up animation.
*
* Ensures:
* - Input is a finite number (rejects NaN and Infinity)
* - Value is within reasonable range [0, 1e9]
* - Returns a safe default (0) for invalid inputs
*
* @param value - The target value to validate
* @returns A valid, finite number within the safe range
*/
function validateAndClampTarget(value: number): number {
// Check if value is a finite number
if (!Number.isFinite(value)) {
return 0
}

// Clamp to safe range [0, 1e9]
const MIN_VALUE = 0
const MAX_VALUE = 1e9
return Math.max(MIN_VALUE, Math.min(MAX_VALUE, value))
}
3 changes: 2 additions & 1 deletion frontend/src/hooks/useStellarStatus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { RPC_URL } from '../lib/config'
import { createAbortSignalWithTimeout } from '../lib/abort'

type NetworkStatus = 'online' | 'degraded' | 'offline' | 'checking'

Expand Down Expand Up @@ -28,7 +29,7 @@ export function useStellarStatus(intervalMs = 60000): StellarStatus {
method: 'getHealth',
params: [],
}),
signal: AbortSignal.timeout(5000),
signal: createAbortSignalWithTimeout(5000),
})

const latency = Date.now() - start
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/lib/abort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Creates an AbortSignal that automatically aborts after the specified timeout.
* Provides a fallback for browsers that don't support AbortSignal.timeout().
*
* @param timeoutMs - The timeout in milliseconds
* @returns An AbortSignal that aborts after the specified timeout
*/
export function createAbortSignalWithTimeout(timeoutMs: number): AbortSignal {
// Check if AbortSignal.timeout is supported (Chrome 103+, Safari 15.4+, etc.)
if (typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal) {
try {
return AbortSignal.timeout(timeoutMs)
} catch {
// Fallback if native implementation fails
return createAbortSignalFallback(timeoutMs)
}
}

// Fallback for older browsers
return createAbortSignalFallback(timeoutMs)
}

/**
* Fallback implementation using AbortController and setTimeout.
* Works on all browsers that support AbortController.
*/
function createAbortSignalFallback(timeoutMs: number): AbortSignal {
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, timeoutMs)

// Store the timeoutId on the signal so it can be cleaned up if needed
const signal = controller.signal as any
signal._timeoutId = timeoutId

return signal
}