1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 20:14:25 -04:00

Add domain pricing feature with Porkbun API and integrate into DomainUnregisteredState component

This commit is contained in:
2025-10-12 17:31:31 -04:00
parent d8604824de
commit 41461533a3
10 changed files with 282 additions and 35 deletions

View File

@@ -61,3 +61,18 @@ export const DiscordIcon: React.FC<IconProps> = ({ className }) => (
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0190 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z" />
</svg>
);
export const PorkbunIcon: React.FC<IconProps> = ({ className }) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
role="img"
aria-label="Porkbun"
>
<path
fill="#ef7878"
d="M12 0A11.97 11.97 0 0 0 .018 11.982C.018 18.612 5.37 24 12 24s11.982-5.353 11.982-11.982C23.982 5.388 18.63 0 12 0M5.832 5.885c1.064.248 2.092.638 3.014 1.135c-1.1.531-1.987 1.382-2.66 2.375a3.4 3.4 0 0 1-.674-2.057c0-.532.107-.992.32-1.453m12.336 0c.213.425.32.921.32 1.453c0 .78-.248 1.49-.674 2.057c-.673-.993-1.596-1.844-2.66-2.375a10 10 0 0 1 3.014-1.135m-6.072.81a6.39 6.39 0 0 1 6.32 6.457v3.829a1.18 1.18 0 0 1-1.17 1.17a1.18 1.18 0 0 1-1.17-1.17v-.958H7.852v.957a1.18 1.18 0 0 1-1.17 1.17a1.18 1.18 0 0 1-1.17-1.17v-3.65c0-3.51 2.73-6.489 6.24-6.63q.173-.007.344-.005m1.5 3.8a.94.94 0 0 0-.922.921c0 .248.07.424.213.602c.141.212.353.354.566.46c-.142.071-.319.143-.496.143c-.213 0-.39.176-.39.389s.177.39.39.39h.178a1.56 1.56 0 0 0 1.383-.851c.39-.142.709-.39.921-.744c.071-.107.034-.249-.037-.213c-.106-.036-.212-.034-.283.072a1.04 1.04 0 0 1-.426.354v-.143c0-.39-.14-.709-.353-.992a.88.88 0 0 0-.744-.389m0 .53c.212 0 .353.141.388.354v.178c0 .177-.034.354-.105.496a1.06 1.06 0 0 1-.604-.426c-.035-.071-.07-.14-.07-.211c0-.24.206-.39.39-.39"
/>
</svg>
);

View File

@@ -0,0 +1,109 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { PorkbunIcon } from "@/components/brand-icons";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTRPC } from "@/lib/trpc/client";
import { cn } from "@/lib/utils";
export function DomainPricingCTASkeleton({
className,
}: {
className?: string;
}) {
return (
<div className={cn("flex flex-col items-center justify-center", className)}>
<Skeleton className="mt-1 h-3 w-20" aria-hidden />
<Skeleton className="mt-3 h-8 w-48" aria-hidden />
<Skeleton className="mt-7 mb-1 h-3 w-64" aria-hidden />
</div>
);
}
export function DomainPricingCTA({
domain,
className,
}: {
domain: string;
className?: string;
}) {
const trpc = useTRPC();
const { data, isLoading } = useQuery(
trpc.domain.pricing.queryOptions(
{ domain },
{
enabled: !!domain,
staleTime: 7 * 24 * 60 * 60 * 1000,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
},
),
);
if (isLoading) {
return <DomainPricingCTASkeleton className={className} />;
}
const priceString = data?.price ?? null;
if (!priceString) return null;
const price = ((value: string) => {
const amount = Number.parseFloat(value);
if (!Number.isFinite(amount)) return null;
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(amount);
} catch {
return `$${amount.toFixed(2)}`;
}
})(priceString);
if (!price) return null;
return (
<div className={cn("flex flex-col items-center justify-center", className)}>
<p className="mb-2 text-[13px] text-muted-foreground">until now?</p>
<Button variant="outline" asChild>
<a
href={`https://porkbun.com/checkout/search?q=${domain}`}
target="_blank"
rel="noopener"
aria-label="Register this domain"
className="flex items-center gap-2"
>
<span className="rounded-full bg-white">
<PorkbunIcon className="size-5" />
</span>
<span>
<span className="text-foreground/85">
.{domain.split(".").slice(1).join(".")} from
</span>{" "}
<span className="font-semibold">{price}</span>
<span className="text-muted-foreground text-xs">/year</span>
</span>
</a>
</Button>
<p className="mt-6 text-muted-foreground text-xs">
This is not an affiliate link, but it{" "}
<a
href="https://jarv.is/contact"
target="_blank"
rel="noopener"
className="text-foreground/85 underline underline-offset-2 hover:text-foreground/60"
>
might be
</a>{" "}
in the future!
</p>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { ShoppingCart } from "lucide-react";
import { Button } from "@/components/ui/button";
"use client";
import { DomainPricingCTA } from "@/components/domain/domain-pricing-cta";
import { NONPUBLIC_TLDS } from "@/lib/constants";
interface DomainUnregisteredStateProps {
@@ -13,6 +14,7 @@ export function DomainUnregisteredState({
const isNonPublicTld = NONPUBLIC_TLDS.some((suffix) =>
lower.endsWith(suffix),
);
return (
<div
className="relative overflow-hidden rounded-3xl border border-black/10 bg-background/60 p-8 text-center shadow-2xl shadow-black/10 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60 dark:border-white/10"
@@ -22,41 +24,19 @@ export function DomainUnregisteredState({
aria-hidden
className="-inset-x-16 -top-16 pointer-events-none absolute h-40 accent-glow opacity-40 blur-3xl"
/>
<h2 className="font-semibold text-2xl tracking-tight sm:text-3xl">
{domain}
</h2>
<p className="mt-2 text-muted-foreground text-sm sm:text-base">
appears to be unregistered.
appears to be unregistered
</p>
{!isNonPublicTld && (
<>
<div className="mt-5 flex justify-center">
<Button size="lg" variant="outline" asChild>
<a
href={`https://porkbun.com/checkout/search?q=${domain}`}
target="_blank"
rel="noopener"
aria-label="Register this domain"
>
<ShoppingCart />
Until now?
</a>
</Button>
</div>
<p className="mt-6 text-muted-foreground text-xs">
This is not an affiliate link, but it{" "}
<a
href="https://jarv.is/contact"
target="_blank"
rel="noopener"
className="text-foreground/85 underline underline-offset-2 hover:text-foreground/60"
>
might be
</a>{" "}
in the future.
</p>
</>
)}
{!isNonPublicTld ? (
// CTA component fetches Porkbun pricing and conditionally renders
<DomainPricingCTA domain={domain} className="mt-4.5" />
) : null}
</div>
);
}

View File

@@ -3,8 +3,8 @@ import { Redis } from "@upstash/redis";
// Uses KV_REST_API_URL and KV_REST_API_TOKEN set by Vercel integration
export const redis = Redis.fromEnv();
export function ns(n: string, id: string): string {
return `${n}:${id}`;
export function ns(n: string, id?: string): string {
return `${n}${id ? `:${id}` : ""}`;
}
function sleep(ms: number): Promise<void> {

View File

@@ -0,0 +1,8 @@
import z from "zod";
export const PricingSchema = z.object({
tld: z.string().nullable(),
price: z.string().nullable(),
});
export type Pricing = z.infer<typeof PricingSchema>;

View File

@@ -2,6 +2,7 @@ export * from "./domain/certificates";
export * from "./domain/dns";
export * from "./domain/hosting";
export * from "./domain/http";
export * from "./domain/pricing";
export * from "./domain/registration";
export * from "./domain/seo";
export * from "./internal/export";

View File

@@ -6,6 +6,7 @@ import {
DnsResolveResultSchema,
HostingSchema,
HttpHeadersSchema,
PricingSchema,
RegistrationSchema,
SeoResponseSchema,
} from "@/lib/schemas";
@@ -14,6 +15,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 { getRegistration } from "@/server/services/registration";
import { getOrCreateScreenshotBlobUrl } from "@/server/services/screenshot";
import { getSeo } from "@/server/services/seo";
@@ -32,6 +34,10 @@ export const domainRouter = createTRPCRouter({
.input(domainInput)
.output(RegistrationSchema)
.query(({ input }) => getRegistration(input.domain)),
pricing: loggedProcedure
.input(domainInput)
.output(PricingSchema)
.query(({ input }) => getPricingForTld(input.domain)),
dns: loggedProcedure
.input(domainInput)
.output(DnsResolveResultSchema)

View File

@@ -0,0 +1,63 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import { getPricingForTld } from "./pricing";
// Mock upstream fetch
const mockResponse = {
status: "SUCCESS",
pricing: {
com: { registration: "9.99" },
dev: { registration: "12.00" },
},
};
describe("getPricingForTld", () => {
afterEach(() => {
vi.restoreAllMocks();
globalThis.__redisTestHelper.reset();
});
it("returns null price for invalid tld input", async () => {
const res = await getPricingForTld("localhost");
// "localhost" -> tld becomes "" (no dot), handled as null
expect(res.tld).toBeNull();
expect(res.price).toBeNull();
});
it("fetches upstream and caches full payload on miss", async () => {
// Arrange: mock fetch
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
} as unknown as Response);
// Act
const res = await getPricingForTld("example.com");
// Assert
expect(res.tld).toBe("com");
expect(res.price).toBe("9.99");
expect(globalThis.__redisTestHelper.store.has("pricing")).toBe(true);
});
it("uses cached payload on subsequent calls without fetching again", async () => {
// Seed cache
globalThis.__redisTestHelper.store.set("pricing", mockResponse);
const fetchSpy = vi.spyOn(global, "fetch");
const res = await getPricingForTld("something.dev");
expect(res.tld).toBe("dev");
expect(res.price).toBe("12.00");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("handles upstream error gracefully", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: false,
status: 500,
} as unknown as Response);
const res = await getPricingForTld("example.com");
expect(res.tld).toBe("com");
expect(res.price).toBeNull();
});
});

View File

@@ -0,0 +1,65 @@
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
import type { Pricing } from "@/lib/schemas";
type DomainPricingResponse = {
status: string;
pricing?: Record<
string,
{ registration?: string; renewal?: string; transfer?: string }
>;
};
/**
* Fetch Porkbun pricing once and cache the full response for 7 days.
* Individual TLD lookups read from the cached payload.
*/
export async function getPricingForTld(domain: string): Promise<Pricing> {
const tld = domain.split(".").slice(1).join(".").toLowerCase();
if (!tld) return { tld: null, price: null };
const resultKey = ns("pricing");
const lockKey = ns("pricing-lock");
let payload = await redis.get<DomainPricingResponse>(resultKey);
if (!payload) {
const lock = await acquireLockOrWaitForResult<DomainPricingResponse>({
lockKey,
resultKey,
lockTtl: 30,
pollIntervalMs: 250,
maxWaitMs: 20_000,
});
if (lock.acquired) {
try {
const res = await fetch(
// Does not require authentication!
// https://porkbun.com/api/json/v3/documentation#Domain%20Pricing
"https://api.porkbun.com/api/json/v3/pricing/get",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
},
);
if (res.ok) {
payload = (await res.json()) as DomainPricingResponse;
await redis.set(resultKey, payload, { ex: 7 * 24 * 60 * 60 });
console.info("[pricing] fetched and cached full payload");
} else {
console.error("[pricing] upstream error", { status: res.status });
}
} catch (err) {
console.error("[pricing] fetch error", {
error: (err as Error)?.message,
});
}
} else {
payload = lock.cachedResult;
}
}
const price = payload?.pricing?.[tld]?.registration ?? null;
return { tld, price };
}

View File

@@ -15,7 +15,7 @@ const __redisImpl = vi.hoisted(() => {
const store = new Map<string, unknown>();
// simple sorted-set implementation: key -> Map(member -> score)
const zsets = new Map<string, Map<string, number>>();
const ns = (n: string, id: string) => `${n}:${id}`;
const ns = (n: string, id?: string) => `${n}${id ? `:${id}` : ""}`;
const get = vi.fn(async (key: string) =>
store.has(key) ? store.get(key) : null,