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:
@@ -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>
|
||||
);
|
||||
|
109
components/domain/domain-pricing-cta.tsx
Normal file
109
components/domain/domain-pricing-cta.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
@@ -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> {
|
||||
|
8
lib/schemas/domain/pricing.ts
Normal file
8
lib/schemas/domain/pricing.ts
Normal 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>;
|
@@ -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";
|
||||
|
@@ -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)
|
||||
|
63
server/services/pricing.test.ts
Normal file
63
server/services/pricing.test.ts
Normal 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();
|
||||
});
|
||||
});
|
65
server/services/pricing.ts
Normal file
65
server/services/pricing.ts
Normal 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 };
|
||||
}
|
@@ -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,
|
||||
|
Reference in New Issue
Block a user