From da28b993f906a8f626e5afbb80f0dc5176373644 Mon Sep 17 00:00:00 2001 From: cryptomalgo <205295302+cryptomalgo@users.noreply.github.com> Date: Wed, 31 Dec 2025 09:40:41 +0100 Subject: [PATCH] Display a banner when an NFD is about to expire --- src/components/address/address-view.tsx | 12 ++ .../address/nfd-expiration-banner.tsx | 125 ++++++++++++++++++ src/components/heatmap/types.ts | 2 + src/hooks/queries/useNFD.test.ts | 67 ++++++++++ src/hooks/queries/useNFD.ts | 75 ++++++++++- src/hooks/useAlgorandAddress.ts | 18 ++- src/hooks/useNFD.ts | 3 + 7 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 src/components/address/nfd-expiration-banner.tsx create mode 100644 src/hooks/queries/useNFD.test.ts diff --git a/src/components/address/address-view.tsx b/src/components/address/address-view.tsx index 976f8a7..8ffa6c1 100644 --- a/src/components/address/address-view.tsx +++ b/src/components/address/address-view.tsx @@ -13,6 +13,7 @@ import { useNavigate } from "@tanstack/react-router"; import { Skeleton } from "@/components/ui/skeleton"; import { displayAlgoAddress } from "@/lib/utils.ts"; import CopyButton from "@/components/copy-to-clipboard"; +import { NFDExpirationBanner } from "./nfd-expiration-banner"; // Lazy load ALL heavy components for better performance const Heatmap = lazy(() => import("@/components/heatmap/heatmap")); @@ -209,6 +210,17 @@ export default function AddressView({ addresses }: { addresses: string }) { cachedCount={progress.cachedCount} isCacheEnabled={search.enableCache} /> + {resolvedAddresses.map( + (addr) => + addr.nfd && ( + + ), + )}
diff --git a/src/components/address/nfd-expiration-banner.tsx b/src/components/address/nfd-expiration-banner.tsx new file mode 100644 index 0000000..c6986d6 --- /dev/null +++ b/src/components/address/nfd-expiration-banner.tsx @@ -0,0 +1,125 @@ +import { AlertTriangle, ExternalLink, X } from "lucide-react"; +import { useState } from "react"; +import { + getExpirationStatus, + type NFDExpirationStatus, +} from "@/hooks/queries/useNFD"; +import { cn } from "@/lib/utils"; + +interface NFDExpirationBannerProps { + nfdName: string; + timeExpires: string | null | undefined; + expired: boolean | undefined; + warningDays?: number; + criticalDays?: number; +} + +const statusConfig: Record< + Exclude, + { + bgClass: string; + borderClass: string; + textClass: string; + iconClass: string; + getMessage: (days: number | null, name: string) => string; + } +> = { + warning: { + bgClass: "bg-amber-50 dark:bg-amber-950/50", + borderClass: "border-amber-300 dark:border-amber-700", + textClass: "text-amber-800 dark:text-amber-200", + iconClass: "text-amber-500 dark:text-amber-400", + getMessage: (days, name) => + `Your NFD "${name}" expires in ${days} days. Renew it to keep your domain active.`, + }, + critical: { + bgClass: "bg-red-50 dark:bg-red-950/50", + borderClass: "border-red-300 dark:border-red-700", + textClass: "text-red-800 dark:text-red-200", + iconClass: "text-red-500 dark:text-red-400", + getMessage: (days, name) => + days === 1 + ? `Your NFD "${name}" expires tomorrow! Renew now to avoid losing your domain.` + : `Your NFD "${name}" expires in ${days} days! Renew now to avoid losing your domain.`, + }, + expired: { + bgClass: "bg-red-100 dark:bg-red-950/70", + borderClass: "border-red-400 dark:border-red-600", + textClass: "text-red-900 dark:text-red-100", + iconClass: "text-red-600 dark:text-red-400", + getMessage: (_, name) => + `Your NFD "${name}" has expired! Renew immediately to reclaim your domain.`, + }, +}; + +export function NFDExpirationBanner({ + nfdName, + timeExpires, + expired, + warningDays, + criticalDays, +}: NFDExpirationBannerProps) { + const [isDismissed, setIsDismissed] = useState(false); + + const { status, daysUntilExpiration } = getExpirationStatus( + timeExpires, + expired, + warningDays, + criticalDays, + ); + + if (status === "ok" || isDismissed) { + return null; + } + + const config = statusConfig[status]; + const message = config.getMessage(daysUntilExpiration, nfdName); + + const renewUrl = `https://app.nf.domains/name/${encodeURIComponent(nfdName)}`; + + return ( +
+
+
+ +

+ {message} +

+
+
+ + Renew NFD + + + +
+
+
+ ); +} diff --git a/src/components/heatmap/types.ts b/src/components/heatmap/types.ts index dba475a..1c2c8a6 100644 --- a/src/components/heatmap/types.ts +++ b/src/components/heatmap/types.ts @@ -12,4 +12,6 @@ export interface DisplayMonth { export type ResolvedAddress = { nfd: string | null; address: string; + timeExpires?: string | null; + expired?: boolean; }; diff --git a/src/hooks/queries/useNFD.test.ts b/src/hooks/queries/useNFD.test.ts new file mode 100644 index 0000000..3ab7deb --- /dev/null +++ b/src/hooks/queries/useNFD.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getExpirationStatus } from "./useNFD"; + +describe("getExpirationStatus", () => { + beforeEach(() => { + // Mock current date to Dec 31, 2025 + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-12-31T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns 'expired' when the expired flag is true", () => { + const result = getExpirationStatus("2026-03-03T21:39:44Z", true); + expect(result.status).toBe("expired"); + expect(result.daysUntilExpiration).toBeNull(); + }); + + it("returns 'ok' when timeExpires is null", () => { + const result = getExpirationStatus(null, false); + expect(result.status).toBe("ok"); + expect(result.daysUntilExpiration).toBeNull(); + }); + + it("returns 'expired' when expiration date is in the past", () => { + const result = getExpirationStatus("2025-12-30T12:00:00Z", false); + expect(result.status).toBe("expired"); + expect(result.daysUntilExpiration).toBe(0); + }); + + it("returns 'critical' when within critical days threshold", () => { + // 5 days from now + const result = getExpirationStatus("2026-01-05T12:00:00Z", false, 30, 7); + expect(result.status).toBe("critical"); + expect(result.daysUntilExpiration).toBe(5); + }); + + it("returns 'warning' when within warning days but outside critical days", () => { + // March 3, 2026 is ~62 days from Dec 31, 2025 + const result = getExpirationStatus("2026-03-03T21:39:44Z", false, 90, 7); + expect(result.status).toBe("warning"); + expect(result.daysUntilExpiration).toBe(63); // ceil of ~62.4 days + }); + + it("returns 'ok' when outside warning days threshold", () => { + // March 3, 2026 is ~62 days from Dec 31, 2025 + const result = getExpirationStatus("2026-03-03T21:39:44Z", false, 30, 7); + expect(result.status).toBe("ok"); + expect(result.daysUntilExpiration).toBe(63); + }); + + it("uses default thresholds (60 warning, 15 critical) when not specified", () => { + // Jan 20, 2026 is 20 days from Dec 31, 2025 + // With default warningDays=60 and criticalDays=15, this should be 'warning' + const result = getExpirationStatus("2026-01-20T12:00:00Z", false); + expect(result.status).toBe("warning"); + expect(result.daysUntilExpiration).toBe(20); + }); + + it("returns 'critical' for tomorrow", () => { + const result = getExpirationStatus("2026-01-01T12:00:00Z", false, 30, 7); + expect(result.status).toBe("critical"); + expect(result.daysUntilExpiration).toBe(1); + }); +}); diff --git a/src/hooks/queries/useNFD.ts b/src/hooks/queries/useNFD.ts index 6871bcf..2c48ff2 100644 --- a/src/hooks/queries/useNFD.ts +++ b/src/hooks/queries/useNFD.ts @@ -1,19 +1,76 @@ import { useQuery } from "@tanstack/react-query"; interface NFDRecord { - depositAccount: string; + depositAccount?: string; name: string; owner: string; + timeExpires?: string; + expired?: boolean; + state?: string; +} + +export type NFDExpirationStatus = "ok" | "warning" | "critical" | "expired"; + +export interface NFDExpirationInfo { + name: string; + status: NFDExpirationStatus; + expiresAt: Date | null; + daysUntilExpiration: number | null; +} + +/** + * Calculates the expiration status based on time remaining + */ +export function getExpirationStatus( + timeExpires: string | null | undefined, + expired: boolean | undefined, + warningDays = 60, + criticalDays = 15, +): { status: NFDExpirationStatus; daysUntilExpiration: number | null } { + if (expired) { + return { status: "expired", daysUntilExpiration: null }; + } + + if (!timeExpires) { + return { status: "ok", daysUntilExpiration: null }; + } + + const expirationDate = new Date(timeExpires); + const now = new Date(); + const diffMs = expirationDate.getTime() - now.getTime(); + const daysUntilExpiration = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (daysUntilExpiration <= 0) { + return { status: "expired", daysUntilExpiration: 0 }; + } + + if (daysUntilExpiration <= criticalDays) { + return { status: "critical", daysUntilExpiration }; + } + + if (daysUntilExpiration <= warningDays) { + return { status: "warning", daysUntilExpiration }; + } + + return { status: "ok", daysUntilExpiration }; +} + +export interface NFDResolveResult { + address: string; + timeExpires: string | null; + expired: boolean; } // Private API calls - not exported /** - * Resolves an NFD name to its Algorand address + * Resolves an NFD name to its Algorand address and expiration info * @param nfd - The NFD name (e.g., "silvio.algo") - * @returns The Algorand address associated with the NFD + * @returns The resolved address and expiration info */ -export async function resolveNFD(nfd: string): Promise { +export async function resolveNFD( + nfd: string, +): Promise { try { const response = await fetch( `https://api.nf.domains/nfd/${nfd.toLowerCase()}`, @@ -24,10 +81,16 @@ export async function resolveNFD(nfd: string): Promise { } const data: NFDRecord = await response.json(); - return data.depositAccount; + // For expired NFDs, depositAccount may not be present, fall back to owner + const address = data.depositAccount || data.owner; + return { + address, + timeExpires: data.timeExpires ?? null, + expired: data.expired ?? data.state === "expired", + }; } catch (error) { console.error("Error resolving NFD:", error); - return ""; + return null; } } diff --git a/src/hooks/useAlgorandAddress.ts b/src/hooks/useAlgorandAddress.ts index 08a74ab..bce0e52 100644 --- a/src/hooks/useAlgorandAddress.ts +++ b/src/hooks/useAlgorandAddress.ts @@ -15,19 +15,27 @@ export const useAlgorandAddresses = (addresses: string[]) => { try { // Use Promise.all to wait for all the async operations to complete const resolved = await Promise.all( - addresses.map(async (address) => { + addresses.map(async (address): Promise => { if (address.toLowerCase().endsWith(".algo")) { - const resolvedAddr = await resolveNFD(address); + const result = await resolveNFD(address); // If NFD resolution fails (expired, not found, etc.), skip it - if (!resolvedAddr || resolvedAddr.length !== 58) { + if (!result || !result.address || result.address.length !== 58) { toast.error( `NFD "${address}" could not be resolved. It may be expired or not found.`, ); return null; } - return { address: resolvedAddr, nfd: address }; + return { + address: result.address, + nfd: address, + timeExpires: result.timeExpires, + expired: result.expired, + }; } - return { address, nfd: null }; + return { + address, + nfd: null, + }; }), ); diff --git a/src/hooks/useNFD.ts b/src/hooks/useNFD.ts index 7ef2a2e..c4f15fc 100644 --- a/src/hooks/useNFD.ts +++ b/src/hooks/useNFD.ts @@ -4,4 +4,7 @@ export { useNFDResolve, useNFDReverse, useNFDReverseMultiple, + getExpirationStatus, + type NFDExpirationStatus, + type NFDExpirationInfo, } from "@/hooks/queries/useNFD";