diff --git a/.env.example b/.env.example index 3ebcf08..dfa128e 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,7 @@ BLOB_SIGNING_SECRET= # Optional: override user agent sent with upstream requests EXTERNAL_USER_AGENT= + +# Optional: Dynadot API key for domain pricing (provider will be skipped if not set) +# Generate at: https://www.dynadot.com/account/domain/setting/api.html +DYNADOT_API_KEY= diff --git a/components/brand-icons.tsx b/components/brand-icons.tsx index e0f805c..7d75502 100644 --- a/components/brand-icons.tsx +++ b/components/brand-icons.tsx @@ -95,3 +95,19 @@ export const CloudflareIcon: React.FC = ({ className }) => ( /> ); + +export const DynadotIcon: React.FC = ({ className }) => ( + + + + +); diff --git a/components/domain/domain-pricing-cta.tsx b/components/domain/domain-pricing-cta.tsx index f82b1a2..9b26e56 100644 --- a/components/domain/domain-pricing-cta.tsx +++ b/components/domain/domain-pricing-cta.tsx @@ -23,6 +23,7 @@ export function DomainPricingCTASkeleton({ + ); diff --git a/lib/constants/pricing-providers.ts b/lib/constants/pricing-providers.ts index b39bbe0..d2bf16c 100644 --- a/lib/constants/pricing-providers.ts +++ b/lib/constants/pricing-providers.ts @@ -1,4 +1,8 @@ -import { CloudflareIcon, PorkbunIcon } from "@/components/brand-icons"; +import { + CloudflareIcon, + DynadotIcon, + PorkbunIcon, +} from "@/components/brand-icons"; /** * Provider configuration for domain pricing. @@ -33,6 +37,12 @@ export const PRICING_PROVIDERS: Record = { url: (domain) => `https://domains.cloudflare.com/?domain=${domain}`, transparentIcon: true, }, + dynadot: { + name: "Dynadot", + icon: DynadotIcon, + url: (domain) => `https://www.dynadot.com/domain/search?domain=${domain}`, + transparentIcon: false, + }, } as const; /** diff --git a/server/routers/domain.ts b/server/routers/domain.ts index 2a19027..bbb5a6f 100644 --- a/server/routers/domain.ts +++ b/server/routers/domain.ts @@ -16,7 +16,7 @@ import { resolveAll } from "@/server/services/dns"; import { getOrCreateFaviconBlobUrl } from "@/server/services/favicon"; import { probeHeaders } from "@/server/services/headers"; import { detectHosting } from "@/server/services/hosting"; -import { getPricingForTld } from "@/server/services/pricing"; +import { getPricing } from "@/server/services/pricing"; import { getRegistration } from "@/server/services/registration"; import { getOrCreateScreenshotBlobUrl } from "@/server/services/screenshot"; import { getSeo } from "@/server/services/seo"; @@ -70,5 +70,5 @@ export const domainRouter = createTRPCRouter({ pricing: publicProcedure .input(DomainInputSchema) .output(PricingSchema) - .query(({ input }) => getPricingForTld(input.domain)), + .query(({ input }) => getPricing(input.domain)), }); diff --git a/server/services/pricing.test.ts b/server/services/pricing.test.ts index 65c2219..871988b 100644 --- a/server/services/pricing.test.ts +++ b/server/services/pricing.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getPricingForTld } from "./pricing"; +import { getPricing } from "./pricing"; // Mock the logger vi.mock("@/lib/logger/server", () => ({ @@ -15,14 +15,14 @@ describe("pricing service", () => { vi.clearAllMocks(); }); - describe("getPricingForTld", () => { + describe("getPricing", () => { it("should return null for invalid domains", async () => { - const result = await getPricingForTld("localhost"); + const result = await getPricing("localhost"); expect(result).toEqual({ tld: null, providers: [] }); }); it("should return null for empty input", async () => { - const result = await getPricingForTld(""); + const result = await getPricing(""); expect(result).toEqual({ tld: null, providers: [] }); }); @@ -40,7 +40,7 @@ describe("pricing service", () => { json: async () => mockPorkbunResponse, }); - const result = await getPricingForTld("example.com"); + const result = await getPricing("example.com"); expect(result.tld).toBe("com"); }); @@ -73,7 +73,7 @@ describe("pricing service", () => { return Promise.reject(new Error("Unknown URL")); }); - const result = await getPricingForTld("example.com"); + const result = await getPricing("example.com"); expect(result.providers).toHaveLength(2); expect(result.providers).toContainEqual({ provider: "porkbun", @@ -101,7 +101,7 @@ describe("pricing service", () => { }); }); - const result = await getPricingForTld("example.com"); + const result = await getPricing("example.com"); expect(result.providers).toHaveLength(1); expect(result.providers[0].provider).toBe("cloudflare"); }); @@ -112,7 +112,7 @@ describe("pricing service", () => { json: async () => ({ pricing: {} }), }); - const result = await getPricingForTld("example.xyz"); + const result = await getPricing("example.xyz"); expect(result.tld).toBe("xyz"); expect(result.providers).toEqual([]); }); diff --git a/server/services/pricing.ts b/server/services/pricing.ts index b764ab1..72e1fec 100644 --- a/server/services/pricing.ts +++ b/server/services/pricing.ts @@ -216,11 +216,74 @@ const cloudflareProvider = createPricingProvider("cloudflare", { }, }); +const dynadotProvider = createPricingProvider("dynadot", { + async fetchPricing(fetchWithTimeout) { + // Requires API key from environment + const apiKey = process.env.DYNADOT_API_KEY; + + if (!apiKey) { + logger.warn("DYNADOT_API_KEY not set", { provider: "dynadot" }); + throw new Error("Dynadot API key not configured"); + } + + // Build URL with required query parameters + // https://www.dynadot.com/domain/api-document#domain_get_tld_price + const url = new URL( + "https://api.dynadot.com/restful/v1/domains/get_tld_price", + ); + url.searchParams.set("currency", "USD"); + + const res = await fetchWithTimeout(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }); + + const data = await res.json(); + + // Check for API errors + if (data?.code !== 200) { + logger.error("dynadot api error", undefined, { + provider: "dynadot", + code: data?.code, + message: data?.message, + error: data?.error, + }); + throw new Error(`Dynadot API error: ${data?.message ?? "Unknown error"}`); + } + + // Dynadot returns: { code: 200, message: "success", data: { tldPriceList: [...] } } + const tldList = data?.data?.tldPriceList || []; + const pricing: RegistrarPricingResponse = {}; + + for (const item of tldList) { + // Dynadot includes the leading dot (e.g., ".com"), so we need to remove it + const tld = item.tld?.toLowerCase().replace(/^\./, ""); + // allYearsRegisterPrice is an array, get the first year price + const registrationPrice = item.allYearsRegisterPrice?.[0]; + + if (tld && registrationPrice !== undefined) { + pricing[tld] = { + registration: String(registrationPrice), + }; + } + } + + return pricing; + }, +}); + /** * List of providers to check. * All enabled providers are queried in parallel. */ -const providers: PricingProvider[] = [porkbunProvider, cloudflareProvider]; +const providers: PricingProvider[] = [ + porkbunProvider, + cloudflareProvider, + dynadotProvider, +]; // ============================================================================ // Public API @@ -230,7 +293,7 @@ const providers: PricingProvider[] = [porkbunProvider, cloudflareProvider]; * Fetch domain pricing for the given domain's TLD from all providers. * Returns pricing from all providers that have data for this TLD. */ -export async function getPricingForTld(domain: string): Promise { +export async function getPricing(domain: string): Promise { const input = (domain ?? "").trim().toLowerCase(); // Ignore single-label hosts like "localhost" or invalid inputs if (!input.includes(".")) return { tld: null, providers: [] };