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 (
+
+
+
+
+
+ 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";