Skip to content
Closed
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
124 changes: 68 additions & 56 deletions frontend/app/hooks/useInactivityTimer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Hook for managing inactivity countdown timer
* Provides client-side active timer based on blockchain last-ping
*/

import { useState, useEffect, useCallback, useRef } from "react";
import { plansAPI } from "@/app/lib/api/plans";

Expand All @@ -12,7 +12,7 @@ export interface InactivityTimerState {
seconds: number;
lastPingTimestamp: number;
isClaimable: boolean;
isSoonWarning: boolean; // True when <= 24 hours
isSoonWarning: boolean;
}

interface UseInactivityTimerOptions {
Expand All @@ -22,39 +22,33 @@ interface UseInactivityTimerOptions {
warningThresholdHours?: number;
}

/**
* Calculate time remaining from timestamps
*/
function calculateTimeRemaining(
lastPingTimestamp: number,
inactivityPeriodDays: number
): {
days: number;
hours: number;
minutes: number;
seconds: number;
totalSeconds: number;
isClaimable: boolean;
} {
const claimableAt = lastPingTimestamp + inactivityPeriodDays * 24 * 60 * 60 * 1000;
const now = Date.now();
const remainingMs = Math.max(0, claimableAt - now);
) {
const claimableAt =
lastPingTimestamp + inactivityPeriodDays * 24 * 60 * 60 * 1000;

const remainingMs = Math.max(0, claimableAt - Date.now());
const isClaimable = remainingMs === 0;

const totalSeconds = Math.floor(remainingMs / 1000);
const days = Math.floor(totalSeconds / (24 * 60 * 60));
const hours = Math.floor((totalSeconds % (24 * 60 * 60)) / (60 * 60));
const minutes = Math.floor((totalSeconds % (60 * 60)) / 60);
const seconds = totalSeconds % 60;

return { days, hours, minutes, seconds, totalSeconds, isClaimable };
return {
days: Math.floor(totalSeconds / (24 * 60 * 60)),
hours: Math.floor((totalSeconds % (24 * 60 * 60)) / (60 * 60)),
minutes: Math.floor((totalSeconds % (60 * 60)) / 60),
seconds: totalSeconds % 60,
totalSeconds,
isClaimable,
};
}

export function useInactivityTimer(options: UseInactivityTimerOptions) {
const {
planId,
enabled = true,
pollIntervalMs = 5000, // Poll every 5 seconds for status
pollIntervalMs = 5000,
warningThresholdHours = 24,
} = options;

Expand All @@ -75,22 +69,21 @@ export function useInactivityTimer(options: UseInactivityTimerOptions) {
const tickIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);

// Fetch initial inactivity status
const fetchInactivityStatus = useCallback(async () => {
if (!enabled) return;

try {
setLoading(true);

const status = await plansAPI.getInactivityStatus(planId);

setInactivityPeriodDays(status.inactivity_period_days);
setError(null);

// Update timer state with fetched data
const remaining = calculateTimeRemaining(
status.last_ping_timestamp,
status.inactivity_period_days
);
const isSoonWarning = remaining.totalSeconds < warningThresholdHours * 60 * 60;

setTimerState({
days: remaining.days,
Expand All @@ -99,40 +92,52 @@ export function useInactivityTimer(options: UseInactivityTimerOptions) {
seconds: remaining.seconds,
lastPingTimestamp: status.last_ping_timestamp,
isClaimable: status.is_claimable || remaining.isClaimable,
isSoonWarning,
isSoonWarning:
remaining.totalSeconds <
warningThresholdHours * 60 * 60,
});
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to fetch inactivity status"));
setError(
err instanceof Error
? err
: new Error("Failed to fetch inactivity status")
);
} finally {
setLoading(false);
}
}, [planId, enabled, warningThresholdHours]);

// Update timer every second (client-side tick)
// Initial fetch + polling (FIXED)
useEffect(() => {
if (!enabled || !inactivityPeriodDays) return;
if (!enabled) return;

const init = async () => {
await fetchInactivityStatus();
};

// Start with immediate fetch
fetchInactivityStatus();
init();

// Set up polling for fresh data from blockchain
pollIntervalRef.current = setInterval(fetchInactivityStatus, pollIntervalMs);
pollIntervalRef.current = setInterval(() => {
fetchInactivityStatus();
}, pollIntervalMs);

return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [enabled, inactivityPeriodDays, pollIntervalMs, fetchInactivityStatus]);
}, [enabled, pollIntervalMs, fetchInactivityStatus]);

// Client-side tick (every second) for smooth countdown
// Local countdown ticker
useEffect(() => {
if (!enabled || !inactivityPeriodDays) return;
if (!enabled || inactivityPeriodDays === null) return;

const tick = () => {
setTimerState((prev) => {
const remaining = calculateTimeRemaining(prev.lastPingTimestamp, inactivityPeriodDays);
const isSoonWarning = remaining.totalSeconds < warningThresholdHours * 60 * 60;
const remaining = calculateTimeRemaining(
prev.lastPingTimestamp,
inactivityPeriodDays
);

return {
days: remaining.days,
Expand All @@ -141,7 +146,9 @@ export function useInactivityTimer(options: UseInactivityTimerOptions) {
seconds: remaining.seconds,
lastPingTimestamp: prev.lastPingTimestamp,
isClaimable: remaining.isClaimable,
isSoonWarning,
isSoonWarning:
remaining.totalSeconds <
warningThresholdHours * 60 * 60,
};
});
};
Expand All @@ -155,22 +162,27 @@ export function useInactivityTimer(options: UseInactivityTimerOptions) {
};
}, [enabled, inactivityPeriodDays, warningThresholdHours]);

const ping = useCallback(
async (signedTransaction?: string) => {
try {
setError(null);
const updated = await plansAPI.pingKeepAlive(planId, signedTransaction);
// Refetch status to get updated timestamp
await fetchInactivityStatus();
return updated;
} catch (err) {
const error = err instanceof Error ? err : new Error("Keep-alive ping failed");
setError(error);
throw error;
}
},
[planId, fetchInactivityStatus]
);
const ping = useCallback(async (signedTransaction?: string) => {
try {
setError(null);

const updated = await plansAPI.pingKeepAlive(
planId,
signedTransaction
);

await fetchInactivityStatus();
return updated;
} catch (err) {
const error =
err instanceof Error
? err
: new Error("Keep-alive ping failed");

setError(error);
throw error;
}
}, [planId, fetchInactivityStatus]);

const refetch = useCallback(async () => {
await fetchInactivityStatus();
Expand All @@ -183,4 +195,4 @@ export function useInactivityTimer(options: UseInactivityTimerOptions) {
ping,
refetch,
};
}
}
3 changes: 2 additions & 1 deletion frontend/app/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 10_000,
retryOnStatuses: [408, 429, 500, 502, 503, 504],
// Only retry on transient network and gateway errors.
retryOnStatuses: [408, 429, 502, 503, 504],
};

export interface ApiResponse<T> {
Expand Down
23 changes: 23 additions & 0 deletions frontend/app/lib/api/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,34 @@ export interface Plan {
updated_at: string;
}

export interface FiatAnchorInfo {
currency: string;
anchor_provider: string;
country: string;
bank_name?: string;
iban?: string;
routing_number?: string;
account_number?: string;
accept_fees: boolean;
}

export interface FiatAnchorInfo {
currency: string;
anchor_provider: string;
country: string;
bank_name?: string;
iban?: string;
routing_number?: string;
account_number?: string;
accept_fees: boolean;
}

export interface Beneficiary {
id?: string;
wallet_address: string;
name: string;
allocation_percentage: number;
fiat_anchor_info?: string;
}

export interface CreatePlanRequest {
Expand Down
43 changes: 31 additions & 12 deletions frontend/components/WalletModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@

import { useWallet } from "../context/WalletContext";
import { motion, AnimatePresence } from "framer-motion";
import { Loader2, Check } from "lucide-react";
import { Check } from "lucide-react";
import React from "react";
import UserIcon from "./userIcon";

export function WalletModal() {
const { isModalOpen, closeModal, supportedWallets, connect, isConnecting } =
useWallet();

// If we wanted to "select" first before connecting, we'd need local state.
// If we wanted to "select" first before connecting, we would need local state.
// But standard flow is click -> connect.
// However, the screenshot shows "Connect Wallet" button at the bottom.
// This implies: Select a wallet (radio) -> Click specific Connect button.
// However, the screenshot shows a "Connect Wallet" button at the bottom.
// This implies: Select a wallet (radio) -> Click the specific Connect button.
// I will implement that flow.

const [activeSelection, setActiveSelection] = React.useState<string | null>(
Expand All @@ -40,19 +40,21 @@ export function WalletModal() {
className="fixed inset-0 z-40 bg-transparent"
onClick={closeModal}
/>
<div className=" bg-[#161E22CC]">

<div className="bg-[#161E22CC]">
<motion.div
initial={{ opacity: 0, y: -10, x: 20 }}
animate={{ opacity: 1, y: 0, x: 0 }}
exit={{ opacity: 0, y: -10, x: 20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="fixed top-24 right-6 z-50 w-120 rounded-4xl bg-[#161E22CC] p-8 shadow-2xl"
className="fixed top-24 right-6 z-50 w-120 rounded-4xl bg-[#161E22CC] p-8 shadow-2xl"
>
<div className="border border-[#2A3338] bg-[#161E22] rounded-4xl p-[32px] flex flex-col items-center">
<div className="text-center mb-2">
<h2 className="text-2xl font-medium text-white">
Connect Wallet
</h2>

<p className="mt-2 text-[#92A5A8] text-sm">
Connect your wallet to get started with InheritX
</p>
Expand All @@ -61,16 +63,29 @@ export function WalletModal() {
<div className="flex flex-col gap-3 mt-8 mb-8">
{supportedWallets.map((wallet) => {
const isSelected = activeSelection === wallet.id;

return (
<div key={wallet.id} className="group flex items-center gap-4">
<div
key={wallet.id}
className="group flex items-center gap-4"
>
<div className="w-1.5 h-8 group-hover:bg-[#1C252A] flex items-center justify-center transition-colors" />

<button
onClick={() => setActiveSelection(wallet.id)}
className={`flex group-hover:bg-[#1C252A] items-center gap-4 w-full p-4 rounded-e-2xl transition-all border ${isSelected ? "bg-[#1a2333] border-[#33C5E0]/30" : "bg-transparent border-transparent hover:bg-[#1a2333]"}`}
className={`flex group-hover:bg-[#1C252A] items-center gap-4 w-full p-4 rounded-e-2xl transition-all border ${
isSelected
? "bg-[#1a2333] border-[#33C5E0]/30"
: "bg-transparent border-transparent hover:bg-[#1a2333]"
}`}
>
{/* Radio Circle */}
<div
className={`w-6 h-6 rounded-full border flex items-center justify-center transition-colors ${isSelected ? "border-[#33C5E0] bg-[#33C5E0]" : "border-[#2d3b4f] bg-white"}`}
className={`w-6 h-6 rounded-full border flex items-center justify-center transition-colors ${
isSelected
? "border-[#33C5E0] bg-[#33C5E0]"
: "border-[#2d3b4f] bg-white"
}`}
>
{isSelected && (
<Check
Expand Down Expand Up @@ -112,9 +127,13 @@ export function WalletModal() {
<button
onClick={handleConnectClick}
disabled={!activeSelection || isConnecting}
className={`w-full py-4 rounded-full font-medium text-white transition-all flex items-center justify-center gap-2 ${activeSelection ? "bg-[#1C252A] hover:bg-[#1C252A]" : "bg-[#1C252A] cursor-not-allowed text-gray-500"}`}
className={`w-full py-4 rounded-full font-medium text-white transition-all flex items-center justify-center gap-2 ${
activeSelection
? "bg-[#1C252A] hover:bg-[#1C252A]"
: "bg-[#1C252A] cursor-not-allowed text-gray-500"
}`}
>
<UserIcon />
<UserIcon />
<span>Connect Wallet</span>
</button>
</div>
Expand All @@ -124,4 +143,4 @@ export function WalletModal() {
)}
</AnimatePresence>
);
}
}
Loading
Loading