1
mirror of https://github.com/jakejarvis/domainstack.io.git synced 2025-12-02 19:33:48 -05:00

feat: add Dynadot as a domain pricing provider with API integration and update environment configuration

This commit is contained in:
2025-11-24 15:56:42 -05:00
parent cb31479a25
commit cb5e4af1b5
7 changed files with 107 additions and 13 deletions

View File

@@ -26,3 +26,7 @@ BLOB_SIGNING_SECRET=
# Optional: override user agent sent with upstream requests # Optional: override user agent sent with upstream requests
EXTERNAL_USER_AGENT= 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=

View File

@@ -95,3 +95,19 @@ export const CloudflareIcon: React.FC<IconProps> = ({ className }) => (
/> />
</svg> </svg>
); );
export const DynadotIcon: React.FC<IconProps> = ({ className }) => (
<svg
viewBox="0 0 24 24"
fill="none"
className={className}
role="img"
aria-label="Dynadot"
>
<rect fill="#031242" height="24" width="24" />
<path
d="M19.688 5.66008C18.4702 3.22123 15.3989 2.30539 12.9803 3.56192L5.82059 7.28598L7.42456 10.3674L5.80709 11.2074C3.41209 12.4521 2.39506 15.4695 3.65665 17.856C4.52864 19.5038 6.222 20.4483 7.96934 20.4483C8.72494 20.4483 9.49235 20.2713 10.2092 19.9002L17.4667 16.1239L15.8678 13.0458L17.5763 12.1569C19.9342 10.9308 20.8703 8.02978 19.688 5.66176V5.66008ZM9.37598 18.2929C7.6337 19.1986 5.41074 18.2608 4.95704 16.1863C4.90138 15.9383 4.88283 15.682 4.89801 15.429C4.97053 14.2989 5.59458 13.3527 6.55089 12.8586L8.25944 11.9697L8.96613 13.3291C9.7133 14.7644 11.4843 15.3244 12.9212 14.5789L14.2638 13.8807L15.0312 15.3564L9.37766 18.2945L9.37598 18.2929ZM11.9177 10.332C12.4692 10.4366 12.9128 10.8802 13.0191 11.43C13.2063 12.4167 12.3579 13.265 11.3712 13.0795C10.8197 12.9749 10.3761 12.5297 10.2699 11.9782C10.0827 10.9915 10.9327 10.1448 11.9177 10.3303V10.332ZM16.6689 10.5884L15.0329 11.4401L14.3262 10.0824C13.5773 8.64539 11.8064 8.08543 10.3711 8.83261L9.02685 9.53087L8.25944 8.05507L13.913 5.11698C15.3972 4.3462 17.2272 4.91122 18.0199 6.3752C18.8345 7.88135 18.1869 9.79904 16.6672 10.5901L16.6689 10.5884Z"
fill="white"
/>
</svg>
);

View File

@@ -23,6 +23,7 @@ export function DomainPricingCTASkeleton({
<Skeleton className="mt-1 h-3 w-20" aria-hidden /> <Skeleton className="mt-1 h-3 w-20" aria-hidden />
<Skeleton className="mt-3 h-8 w-48" aria-hidden /> <Skeleton className="mt-3 h-8 w-48" aria-hidden />
<Skeleton className="mt-2 h-8 w-48" aria-hidden /> <Skeleton className="mt-2 h-8 w-48" aria-hidden />
<Skeleton className="mt-2 h-8 w-48" aria-hidden />
<Skeleton className="mt-7 mb-1 h-3 w-64" aria-hidden /> <Skeleton className="mt-7 mb-1 h-3 w-64" aria-hidden />
</div> </div>
); );

View File

@@ -1,4 +1,8 @@
import { CloudflareIcon, PorkbunIcon } from "@/components/brand-icons"; import {
CloudflareIcon,
DynadotIcon,
PorkbunIcon,
} from "@/components/brand-icons";
/** /**
* Provider configuration for domain pricing. * Provider configuration for domain pricing.
@@ -33,6 +37,12 @@ export const PRICING_PROVIDERS: Record<string, PricingProviderInfo> = {
url: (domain) => `https://domains.cloudflare.com/?domain=${domain}`, url: (domain) => `https://domains.cloudflare.com/?domain=${domain}`,
transparentIcon: true, transparentIcon: true,
}, },
dynadot: {
name: "Dynadot",
icon: DynadotIcon,
url: (domain) => `https://www.dynadot.com/domain/search?domain=${domain}`,
transparentIcon: false,
},
} as const; } as const;
/** /**

View File

@@ -16,7 +16,7 @@ import { resolveAll } from "@/server/services/dns";
import { getOrCreateFaviconBlobUrl } from "@/server/services/favicon"; import { getOrCreateFaviconBlobUrl } from "@/server/services/favicon";
import { probeHeaders } from "@/server/services/headers"; import { probeHeaders } from "@/server/services/headers";
import { detectHosting } from "@/server/services/hosting"; 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 { getRegistration } from "@/server/services/registration";
import { getOrCreateScreenshotBlobUrl } from "@/server/services/screenshot"; import { getOrCreateScreenshotBlobUrl } from "@/server/services/screenshot";
import { getSeo } from "@/server/services/seo"; import { getSeo } from "@/server/services/seo";
@@ -70,5 +70,5 @@ export const domainRouter = createTRPCRouter({
pricing: publicProcedure pricing: publicProcedure
.input(DomainInputSchema) .input(DomainInputSchema)
.output(PricingSchema) .output(PricingSchema)
.query(({ input }) => getPricingForTld(input.domain)), .query(({ input }) => getPricing(input.domain)),
}); });

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPricingForTld } from "./pricing"; import { getPricing } from "./pricing";
// Mock the logger // Mock the logger
vi.mock("@/lib/logger/server", () => ({ vi.mock("@/lib/logger/server", () => ({
@@ -15,14 +15,14 @@ describe("pricing service", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("getPricingForTld", () => { describe("getPricing", () => {
it("should return null for invalid domains", async () => { it("should return null for invalid domains", async () => {
const result = await getPricingForTld("localhost"); const result = await getPricing("localhost");
expect(result).toEqual({ tld: null, providers: [] }); expect(result).toEqual({ tld: null, providers: [] });
}); });
it("should return null for empty input", async () => { it("should return null for empty input", async () => {
const result = await getPricingForTld(""); const result = await getPricing("");
expect(result).toEqual({ tld: null, providers: [] }); expect(result).toEqual({ tld: null, providers: [] });
}); });
@@ -40,7 +40,7 @@ describe("pricing service", () => {
json: async () => mockPorkbunResponse, json: async () => mockPorkbunResponse,
}); });
const result = await getPricingForTld("example.com"); const result = await getPricing("example.com");
expect(result.tld).toBe("com"); expect(result.tld).toBe("com");
}); });
@@ -73,7 +73,7 @@ describe("pricing service", () => {
return Promise.reject(new Error("Unknown URL")); 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).toHaveLength(2);
expect(result.providers).toContainEqual({ expect(result.providers).toContainEqual({
provider: "porkbun", 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).toHaveLength(1);
expect(result.providers[0].provider).toBe("cloudflare"); expect(result.providers[0].provider).toBe("cloudflare");
}); });
@@ -112,7 +112,7 @@ describe("pricing service", () => {
json: async () => ({ pricing: {} }), json: async () => ({ pricing: {} }),
}); });
const result = await getPricingForTld("example.xyz"); const result = await getPricing("example.xyz");
expect(result.tld).toBe("xyz"); expect(result.tld).toBe("xyz");
expect(result.providers).toEqual([]); expect(result.providers).toEqual([]);
}); });

View File

@@ -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. * List of providers to check.
* All enabled providers are queried in parallel. * All enabled providers are queried in parallel.
*/ */
const providers: PricingProvider[] = [porkbunProvider, cloudflareProvider]; const providers: PricingProvider[] = [
porkbunProvider,
cloudflareProvider,
dynadotProvider,
];
// ============================================================================ // ============================================================================
// Public API // Public API
@@ -230,7 +293,7 @@ const providers: PricingProvider[] = [porkbunProvider, cloudflareProvider];
* Fetch domain pricing for the given domain's TLD from all providers. * Fetch domain pricing for the given domain's TLD from all providers.
* Returns pricing from all providers that have data for this TLD. * Returns pricing from all providers that have data for this TLD.
*/ */
export async function getPricingForTld(domain: string): Promise<Pricing> { export async function getPricing(domain: string): Promise<Pricing> {
const input = (domain ?? "").trim().toLowerCase(); const input = (domain ?? "").trim().toLowerCase();
// Ignore single-label hosts like "localhost" or invalid inputs // Ignore single-label hosts like "localhost" or invalid inputs
if (!input.includes(".")) return { tld: null, providers: [] }; if (!input.includes(".")) return { tld: null, providers: [] };