You've already forked domainstack.io
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:
@@ -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=
|
||||
|
||||
@@ -95,3 +95,19 @@ export const CloudflareIcon: React.FC<IconProps> = ({ className }) => (
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ export function DomainPricingCTASkeleton({
|
||||
<Skeleton className="mt-1 h-3 w-20" 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-7 mb-1 h-3 w-64" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string, PricingProviderInfo> = {
|
||||
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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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<Pricing> {
|
||||
export async function getPricing(domain: string): Promise<Pricing> {
|
||||
const input = (domain ?? "").trim().toLowerCase();
|
||||
// Ignore single-label hosts like "localhost" or invalid inputs
|
||||
if (!input.includes(".")) return { tld: null, providers: [] };
|
||||
|
||||
Reference in New Issue
Block a user