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: 38 additions & 13 deletions frontend/src/components/AgreementIdGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ interface AgreementIdGeneratorProps {
onGenerate?: (id: string) => void
}

/** Detects canvas element support (unavailable during SSR or in very old browsers). */
function isCanvasSupported(): boolean {
return typeof document !== 'undefined' && typeof HTMLCanvasElement !== 'undefined'
}

export function AgreementIdGenerator({ onGenerate }: AgreementIdGeneratorProps) {
const toast = useToast()
const [agreementId, setAgreementId] = useState<string>('')
const [qrDataUrl, setQrDataUrl] = useState<string>('')
const [qrUnavailable, setQrUnavailable] = useState(false)
const [showQR, setShowQR] = useState(false)
const [copied, setCopied] = useState(false)
const [shared, setShared] = useState(false)
Expand All @@ -22,20 +28,27 @@ export function AgreementIdGenerator({ onGenerate }: AgreementIdGeneratorProps)
setIsGenerating(true)
const newId = generateAgreementId()
setAgreementId(newId)
setQrDataUrl('')
setQrUnavailable(false)

// Generate QR code locally via the qrcode library
try {
const url = await QRCode.toDataURL(newId, {
width: 400,
margin: 2,
color: {
dark: '#1a2332',
light: '#ffffff',
},
})
setQrDataUrl(url)
} catch {
// Local QR generation failed; QR display will be skipped
// Generate QR code locally via the qrcode library, when canvas is available
if (isCanvasSupported()) {
try {
const url = await QRCode.toDataURL(newId, {
width: 400,
margin: 2,
color: {
dark: '#1a2332',
light: '#ffffff',
},
})
setQrDataUrl(url)
} catch {
// Local QR generation failed; fall back to the text-only display
setQrUnavailable(true)
}
} else {
setQrUnavailable(true)
}

setShowQR(true)
Expand Down Expand Up @@ -165,6 +178,18 @@ export function AgreementIdGenerator({ onGenerate }: AgreementIdGeneratorProps)
</div>
)}

{/* Text-only fallback when the QR code cannot be rendered (no canvas support) */}
{showQR && qrUnavailable && (
<div className="p-4 bg-navy-700 dark:bg-navy-700 light:bg-gray-100 rounded-lg border border-navy-600 dark:border-navy-600 light:border-gray-300 text-center space-y-2 transition-all duration-300">
<p className="text-xs text-gray-400 dark:text-gray-400 light:text-gray-600">
QR code isn't available in this browser. Use the ID below instead.
</p>
<p className="font-mono text-sm text-cyan-300 break-all leading-relaxed">
{agreementId.match(/.{1,8}/g)?.join(' ') ?? agreementId}
</p>
</div>
)}

{/* Action buttons */}
<div className="flex gap-2">
<button
Expand Down
57 changes: 44 additions & 13 deletions frontend/src/context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,16 @@ export function WalletProvider({ children }: { children: ReactNode }) {
// While connected, poll for the active Freighter account and network so
// switching accounts or networks in the extension (which fires no event
// Freighter exposes) is reflected here instead of silently going stale.
// While disconnected, poll much less aggressively just to notice the
// extension becoming available. Nothing is polled while unavailable,
// detecting, or connecting — those phases resolve on their own.
useEffect(() => {
if (phase !== 'connected') return;
const baseInterval = POLL_INTERVAL_MS[status];
if (!baseInterval) return;

let mounted = true;
let failureCount = 0;
let intervalId: ReturnType<typeof setInterval>;

const intervalId = setInterval(() => {
void (async () => {
Expand All @@ -197,26 +203,51 @@ export function WalletProvider({ children }: { children: ReactNode }) {
fetchNetworkPassphraseWithRetry(),
]);

if (!mounted) return;
async function runPoll() {
try {
if (status === 'connected') {
const [address, passphrase] = await Promise.all([getPublicKey(), getNetworkPassphrase()]);

if (!address) {
writeIntent(false);
setPublicKey(null);
setNetworkPassphrase(null);
setPhase('idle');
return;
if (!mounted) return;

if (!address) {
writeIntent(false);
setPublicKey(null);
setNetworkPassphrase(null);
setPhase('idle');
return;
}

setPublicKey((prev) => (prev !== address ? address : prev));
setNetworkPassphrase((prev) => (prev !== passphrase ? passphrase : prev));
} else {
const found = await isFreighterInstalled();
if (!mounted) return;
setInstalled(found);
}

if (failureCount > 0) {
failureCount = 0;
reschedule(baseInterval);
}
} catch {
if (!mounted) return;
failureCount += 1;
const backoffMs = Math.min(
baseInterval * BACKOFF_MULTIPLIER ** failureCount,
MAX_POLL_INTERVAL_MS,
);
reschedule(backoffMs);
}
}

setPublicKey((prev) => (prev !== address ? address : prev));
setNetworkPassphrase((prev) => (prev !== passphrase ? passphrase : prev));
})();
}, ACCOUNT_POLL_INTERVAL_MS);
intervalId = setInterval(runPoll, baseInterval);

return () => {
mounted = false;
clearInterval(intervalId);
};
}, [phase]);
}, [status]);

const connect = useCallback(async () => {
setError(null);
Expand Down
40 changes: 33 additions & 7 deletions frontend/src/hooks/useContractStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,23 +123,29 @@ export function useContractStats(): UseContractStatsResult {
const [lastUpdated, setLastUpdated] = useState<string | null>(null)

const statsRef = useRef<ContractStats | null>(null)
// Bumped on every fetch kickoff so a request superseded by a newer one
// (e.g. the immediate refetch on tab-visible) can recognize it's stale
// and skip applying its result, even if it resolves after the newer one.
const requestIdRef = useRef(0)

const fetchStats = useCallback(async (signal: AbortSignal) => {
const requestId = ++requestIdRef.current

try {
const [agreements, milestonesLocked] = await Promise.all([
fetchEventCount('created', signal),
fetchEventCount('locked', signal),
])

if (signal.aborted) return
if (signal.aborted || requestId !== requestIdRef.current) return

const next: ContractStats = { agreements, milestonesLocked }
statsRef.current = next
setStats(next)
setStatus('ok')
setLastUpdated(new Date().toISOString())
} catch (err) {
if (signal.aborted) return
if (signal.aborted || requestId !== requestIdRef.current) return

console.error('[useContractStats] Fetch failed:', err)

Expand All @@ -160,16 +166,36 @@ export function useContractStats(): UseContractStatsResult {
warnIfPlaceholder('RPC_URL', RPC_URL)

const controller = new AbortController()
let interval: ReturnType<typeof setInterval> | null = null

fetchStats(controller.signal)
const startInterval = () => {
if (interval !== null) return
interval = setInterval(() => fetchStats(controller.signal), POLL_INTERVAL_MS)
}

const stopInterval = () => {
if (interval === null) return
clearInterval(interval)
interval = null
}

const interval = setInterval(() => {
fetchStats(controller.signal)
}, POLL_INTERVAL_MS)
const handleVisibilityChange = () => {
if (document.hidden) {
stopInterval()
} else {
fetchStats(controller.signal)
startInterval()
}
}

fetchStats(controller.signal)
startInterval()
document.addEventListener('visibilitychange', handleVisibilityChange)

return () => {
controller.abort()
clearInterval(interval)
stopInterval()
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [fetchStats])

Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/useCountUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function useCountUp(
return () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
}
}, [targetValue, duration, minThreshold])
Expand Down
Loading