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
12 changes: 12 additions & 0 deletions src/components/address/address-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -209,6 +210,17 @@ export default function AddressView({ addresses }: { addresses: string }) {
cachedCount={progress.cachedCount}
isCacheEnabled={search.enableCache}
/>
{resolvedAddresses.map(
(addr) =>
addr.nfd && (
<NFDExpirationBanner
key={addr.address}
nfdName={addr.nfd}
timeExpires={addr.timeExpires}
expired={addr.expired}
/>
),
)}
<main className="mt-4">
<div className="mx-auto max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
<div className="flex flex-col gap-2 border-b border-gray-200 pb-5">
Expand Down
125 changes: 125 additions & 0 deletions src/components/address/nfd-expiration-banner.tsx
Original file line number Diff line number Diff line change
@@ -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<NFDExpirationStatus, "ok">,
{
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 (
<div
className={cn(
"relative w-full border-b px-4 py-3",
config.bgClass,
config.borderClass,
)}
role="alert"
>
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4">
<div className="flex items-center gap-3">
<AlertTriangle className={cn("h-5 w-5 shrink-0", config.iconClass)} />
<p className={cn("text-sm font-medium", config.textClass)}>
{message}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<a
href={renewUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
status === "expired" || status === "critical"
? "bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600"
: "bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-700 dark:hover:bg-amber-600",
)}
>
Renew NFD
<ExternalLink className="h-3.5 w-3.5" />
</a>
<button
onClick={() => setIsDismissed(true)}
className={cn(
"rounded-md p-1 transition-colors hover:bg-black/10 dark:hover:bg-white/10",
config.textClass,
)}
aria-label="Dismiss notification"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/heatmap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export interface DisplayMonth {
export type ResolvedAddress = {
nfd: string | null;
address: string;
timeExpires?: string | null;
expired?: boolean;
};
67 changes: 67 additions & 0 deletions src/hooks/queries/useNFD.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
75 changes: 69 additions & 6 deletions src/hooks/queries/useNFD.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
export async function resolveNFD(
nfd: string,
): Promise<NFDResolveResult | null> {
try {
const response = await fetch(
`https://api.nf.domains/nfd/${nfd.toLowerCase()}`,
Expand All @@ -24,10 +81,16 @@ export async function resolveNFD(nfd: string): Promise<string> {
}

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;
}
}

Expand Down
18 changes: 13 additions & 5 deletions src/hooks/useAlgorandAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedAddress | null> => {
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,
};
}),
);

Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useNFD.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ export {
useNFDResolve,
useNFDReverse,
useNFDReverseMultiple,
getExpirationStatus,
type NFDExpirationStatus,
type NFDExpirationInfo,
} from "@/hooks/queries/useNFD";