From 4a810bc12369b90291c936d1d15852b6dc3272fc Mon Sep 17 00:00:00 2001 From: Pete Millspaugh Date: Tue, 5 Aug 2025 15:19:59 -0400 Subject: [PATCH 1/2] Implement SLC frontend for domain search from CSV --- package-lock.json | 34 ++++ package.json | 1 + src/app/api/domain-search/route.ts | 188 ++++++++++++++++++ src/app/api/domain-search/types.ts | 22 ++ src/app/domain-search/page.tsx | 5 + .../DomainSearch/DomainSearch.module.css | 16 ++ src/components/DomainSearch/DomainSearch.tsx | 31 +++ src/components/DomainSearch/Form.module.css | 47 +++++ src/components/DomainSearch/Form.tsx | 98 +++++++++ src/components/DomainSearch/Table.module.css | 29 +++ src/components/DomainSearch/Table.tsx | 92 +++++++++ src/components/DomainSearch/index.tsx | 1 + 12 files changed, 564 insertions(+) create mode 100644 src/app/api/domain-search/route.ts create mode 100644 src/app/api/domain-search/types.ts create mode 100644 src/app/domain-search/page.tsx create mode 100644 src/components/DomainSearch/DomainSearch.module.css create mode 100644 src/components/DomainSearch/DomainSearch.tsx create mode 100644 src/components/DomainSearch/Form.module.css create mode 100644 src/components/DomainSearch/Form.tsx create mode 100644 src/components/DomainSearch/Table.module.css create mode 100644 src/components/DomainSearch/Table.tsx create mode 100644 src/components/DomainSearch/index.tsx diff --git a/package-lock.json b/package-lock.json index 7e1e8d3..ca7b7a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@radix-ui/react-visually-hidden": "^1.2.3", + "@tanstack/react-table": "^8.21.3", "@vercel/analytics": "^1.5.0", "gray-matter": "^4.0.3", "marked": "^16.1.1", @@ -925,6 +926,39 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", diff --git a/package.json b/package.json index 73d985a..aab98e1 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@radix-ui/react-visually-hidden": "^1.2.3", + "@tanstack/react-table": "^8.21.3", "@vercel/analytics": "^1.5.0", "gray-matter": "^4.0.3", "marked": "^16.1.1", diff --git a/src/app/api/domain-search/route.ts b/src/app/api/domain-search/route.ts new file mode 100644 index 0000000..ae98a4a --- /dev/null +++ b/src/app/api/domain-search/route.ts @@ -0,0 +1,188 @@ +import { type NextRequest } from "next/server"; +import { type BulkSearchResponse } from "./types"; + +export async function POST(request: NextRequest) { + const formData = await request.formData(); + const file = formData.get("file") as File; + const tld = formData.get("tld") as string; + + const searchParams = request.nextUrl.searchParams; + const skipUsage = searchParams.get("skipUsage"); + + // Parse CSV file into array + const csvText = await file.text(); + const domains = csvText + .split("\n") + .map((line) => `${line.trim()}${tld}`) + .filter((line) => line.length > 0); // Remove empty lines + + // Check domain availability and aftermarket pricing + const url = new URL("https://instantdomainsearch.com/api/v1/bulk-check"); + url.searchParams.set("names", domains.join(",")); + url.searchParams.set( + "aftermarkets", + "brandbucket,dan,epik,godaddy,namejet,pool,sav,sedo,snapnames,venture" + ); + + const apiKey = process.env.INSTANT_DOMAIN_SEARCH_API_KEY; + if (!apiKey) { + return new Response("API key not configured", { status: 500 }); + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "X-Api-Key": apiKey, + }, + }); + + if (!response.ok) { + return new Response( + "Failed to fetch domain availability and aftermarket pricing", + { status: 500 } + ); + } + + const data: BulkSearchResponse = await response.json(); + + if (skipUsage) { + const domainResults = data.results.map((result) => { + const priceInCents = result.aftermarket?.current_price; + const priceInDollars = priceInCents ? priceInCents / 100 : null; + + return { + domain: result.domain, + available: result.availability === "available", + price: priceInDollars, + }; + }); + + return new Response(JSON.stringify(domainResults), { + headers: { + "Content-Type": "application/json", + "X-Response-Type": "immediate", + }, + }); + } + + // Stream the response because isDomainUsed takes up to 3 seconds per domain + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + for (const result of data.results) { + const priceInCents = result.aftermarket?.current_price; + const priceInDollars = priceInCents ? priceInCents / 100 : null; + const { used, notes } = await isDomainUsed(result.domain); + + const domainResult = { + domain: result.domain, + available: result.availability === "available", + price: priceInDollars, + used, + notes, + }; + + controller.enqueue(encoder.encode(JSON.stringify(domainResult) + "\n")); + } + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Response-Type": "streaming", + }, + }); +} + +async function isDomainUsed(domain: string) { + const TIMEOUT = 3000; + + try { + let response = await fetch(`https://${domain}`, { + method: "GET", + signal: AbortSignal.timeout(TIMEOUT), + }); + + // Many 301s just redirect to a www subdomain, so we handle that case + if (response.status === 301) { + response = await fetch(`https://www.${domain}`, { + method: "GET", + signal: AbortSignal.timeout(TIMEOUT), + }); + } + + // Check for HTTP status codes obviously indicating an unused domain + if (response.status === 404) { + return { used: false, notes: `404 not found for ${domain}` }; + } else if (response.status >= 300 && response.status < 400) { + return { + used: false, + notes: `${response.status} redirect for ${domain}`, + }; + } else if (response.status >= 500) { + return { + used: false, + notes: `${response.status} server error for ${domain}`, + }; + } + + // Pull title and meta description + const html = await response.text(); + const title = html.match(/(.*?)<\/title>/)?.[1] ?? ""; + const description = + html.match(/<meta name="description" content="(.*?)"/)?.[1] ?? ""; + const metadata = title.length + description.length; + + // If the site is missing a title and/or description, it's likely unused + if (!title && !description) { + return { + used: false, + notes: `${domain} is missing title and description - likely unused`, + }; + } else if ( + `${title}${description}`.includes("domain") && + `${title}${description}`.includes("sale") + ) { + // If the title or description mentions domain for sale, it's likely unused + return { + used: false, + notes: `${domain} mentions domain for sale - likely unused`, + }; + } else if (metadata > 20) { + // If the title and description are sufficiently long, it's likely a real website + return { + used: true, + notes: `${domain} is probably a real website based on metadata: ${title}; ${description}`, + }; + } + + // If the site has minimal content, it's likely not being used + if (html.length < 1000) { + return { + used: false, + notes: "Minimal content - likely not a real website", + }; + } + + // If we get here, it's probably a real website + return { used: true, notes: `Request to ${domain} succeeded` }; + } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { + return { + used: false, + notes: `Request to ${domain} timed out (${TIMEOUT}ms)`, + }; + } + + return { + used: false, + notes: `Request to ${domain} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } +} diff --git a/src/app/api/domain-search/types.ts b/src/app/api/domain-search/types.ts new file mode 100644 index 0000000..742da14 --- /dev/null +++ b/src/app/api/domain-search/types.ts @@ -0,0 +1,22 @@ +//https://instantdomainsearch.com/api/v1#model/aftermarketinfo +interface AftermarketInfo { + currency: string; + market: string; + current_price?: number; + lowest_bid?: number; + value?: number; +} + +// https://instantdomainsearch.com/api/v1#model/aftermarketdomain +interface AftermarketDomain { + availability: "available" | "taken" | "aftermarket" | "expiring" | "unknown"; + backlink: string; + domain: string; + tld: string; + aftermarket?: AftermarketInfo; +} + +// https://instantdomainsearch.com/api/v1#tag/domain-search/get/bulk-check +export interface BulkSearchResponse { + results: AftermarketDomain[]; +} diff --git a/src/app/domain-search/page.tsx b/src/app/domain-search/page.tsx new file mode 100644 index 0000000..c2f628a --- /dev/null +++ b/src/app/domain-search/page.tsx @@ -0,0 +1,5 @@ +import DomainSearch from "@/components/DomainSearch"; + +export default function DomainSearchPage() { + return <DomainSearch />; +} diff --git a/src/components/DomainSearch/DomainSearch.module.css b/src/components/DomainSearch/DomainSearch.module.css new file mode 100644 index 0000000..82cc779 --- /dev/null +++ b/src/components/DomainSearch/DomainSearch.module.css @@ -0,0 +1,16 @@ +.page { + display: grid; + grid-template-rows: auto 1fr auto; + min-height: 100vh; +} + +.main { + margin: auto; + padding: 24px; + max-width: 800px; +} + +.h1 { + font-family: var(--newsreader); + font-size: 3rem; +} diff --git a/src/components/DomainSearch/DomainSearch.tsx b/src/components/DomainSearch/DomainSearch.tsx new file mode 100644 index 0000000..18b4c5f --- /dev/null +++ b/src/components/DomainSearch/DomainSearch.tsx @@ -0,0 +1,31 @@ +import Footer from "@/components/Footer"; +import Header from "@/components/Header"; +import styles from "./DomainSearch.module.css"; +import { Form } from "./Form"; + +export interface Domain { + domain: string; + available: boolean; + price: number | null; + used: boolean | null; + notes: string | null; +} + +export default function DomainSearch() { + return ( + <div className={styles.page}> + <Header /> + + <main className={styles.main}> + <h1 className={styles.h1}>Domain search</h1> + <p> + Upload a spreadsheet of words and choose a TLD to check domain + availability, use, and aftermarket pricing. + </p> + <Form /> + </main> + + <Footer /> + </div> + ); +} diff --git a/src/components/DomainSearch/Form.module.css b/src/components/DomainSearch/Form.module.css new file mode 100644 index 0000000..f54bcf5 --- /dev/null +++ b/src/components/DomainSearch/Form.module.css @@ -0,0 +1,47 @@ +.form { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.form input[type="file"] { + margin-right: auto; +} + +.form input[type="file"]::file-selector-button { + background: #eee; + border: 2px solid black; + border-radius: 2px; + margin-right: 12px; + padding: 4px 8px; + cursor: pointer; +} + +.form input[type="file"]::file-selector-button:hover { + background: black; + color: white; +} + +.form input[type="text"] { + width: 80px; + padding-inline: 4px; + border: 2px solid black; + border-radius: 2px; +} + +.form button[type="submit"] { + background: black; + color: white; + border: 2px solid black; + border-radius: 2px; + padding: 2px 8px; + cursor: pointer; +} + +.form button[type="submit"]:hover { + background: #555; +} + +.h2 { + margin-top: 48px; +} diff --git a/src/components/DomainSearch/Form.tsx b/src/components/DomainSearch/Form.tsx new file mode 100644 index 0000000..c27f15d --- /dev/null +++ b/src/components/DomainSearch/Form.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { useState } from "react"; +import { Domain } from "./DomainSearch"; +import styles from "./Form.module.css"; +import { Table } from "./Table"; + +export function Form() { + const [domains, setDomains] = useState<Domain[]>([]); + + async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + + // Note: rm skipUsage to check whether domains serve real websites + const response = await fetch("/api/domain-search?skipUsage=true", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error("Failed to fetch domain search results"); + } + + // If the response is JSON (skipUsage=true), parse it + const responseType = response.headers.get("x-response-type"); + if (responseType === "immediate") { + const data = await response.json(); + setDomains(data); + return; + } + + // Stream the response otherwise + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("Failed to get reader"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + + // Process all complete lines except the last one (which might be incomplete) + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i].trim(); + if (line) { + try { + const domain = JSON.parse(line); + setDomains((prev) => [...prev, domain]); + } catch (error) { + console.error("Failed to parse JSON:", line, error); + } + } + } + + // Keep the last line in the buffer as it might be incomplete + buffer = lines[lines.length - 1]; + } + } + + return ( + <> + <form className={styles.form} onSubmit={handleSubmit}> + <VisuallyHidden> + <label htmlFor="file">Upload your spreadsheet</label> + </VisuallyHidden> + <input + type="file" + id="file" + name="file" + accept=".csv, .xlsx, .xls" + required + /> + + <VisuallyHidden> + <label htmlFor="tld">Top-Level Domain (TLD)</label> + </VisuallyHidden> + <input type="text" id="tld" name="tld" placeholder=".com" required /> + + <button type="submit">Search</button> + </form> + + {domains.length > 0 && ( + <> + <h2 className={styles.h2}>Results</h2> + <Table domains={domains} /> + </> + )} + </> + ); +} diff --git a/src/components/DomainSearch/Table.module.css b/src/components/DomainSearch/Table.module.css new file mode 100644 index 0000000..3265f31 --- /dev/null +++ b/src/components/DomainSearch/Table.module.css @@ -0,0 +1,29 @@ +.table { + width: 100%; + border-collapse: collapse; + border-radius: 2px; + overflow: hidden; + animation: fadeIn 250ms ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.table th { + padding: 4px 8px; + border: 2px solid black; + background: #eee; + text-align: left; + cursor: pointer; +} + +.table td { + padding: 4px 8px; + border: 2px solid black; +} diff --git a/src/components/DomainSearch/Table.tsx b/src/components/DomainSearch/Table.tsx new file mode 100644 index 0000000..13abbef --- /dev/null +++ b/src/components/DomainSearch/Table.tsx @@ -0,0 +1,92 @@ +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table"; +import { useMemo } from "react"; +import { Domain } from "./DomainSearch"; +import styles from "./Table.module.css"; + +export function Table({ domains }: { domains: Domain[] }) { + const columns = useMemo<ColumnDef<Domain>[]>( + () => [ + { + accessorKey: "domain", + header: "Domain", + }, + { + accessorKey: "available", + header: "Available?", + cell: ({ row }) => { + const available = row.original.available; + return available ? "Yes" : "No"; + }, + }, + { + accessorKey: "used", + header: "Used?", + cell: ({ row }) => { + const used = row.original.used; + if (used === undefined) return "--"; + return used ? "Yes" : "No"; + }, + }, + { + accessorKey: "price", + header: "Price", + cell: ({ row }) => { + const price = row.original.price; + if (price === null) return "--"; + + return Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + }).format(price); + }, + }, + ], + [] + ); + + const table = useReactTable({ + data: domains, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + <table className={styles.table}> + <thead> + {table.getHeaderGroups().map((headerGroup) => ( + <tr key={headerGroup.id}> + {headerGroup.headers.map((header) => ( + <th key={header.id} onClick={() => header.column.toggleSorting()}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + </th> + ))} + </tr> + ))} + </thead> + <tbody> + {table.getRowModel().rows.map((row) => ( + <tr key={row.id}> + {row.getVisibleCells().map((cell) => ( + <td key={cell.id}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </td> + ))} + </tr> + ))} + </tbody> + </table> + ); +} diff --git a/src/components/DomainSearch/index.tsx b/src/components/DomainSearch/index.tsx new file mode 100644 index 0000000..9cf92c9 --- /dev/null +++ b/src/components/DomainSearch/index.tsx @@ -0,0 +1 @@ +export { default } from "./DomainSearch"; From 634764d5d3a796c1f91d50ee04d6387865fd63e9 Mon Sep 17 00:00:00 2001 From: Pete Millspaugh <peterdgmillspaugh@gmail.com> Date: Fri, 8 Aug 2025 10:58:31 -0400 Subject: [PATCH 2/2] Add TODO for exporting csv --- src/components/DomainSearch/DomainSearch.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/DomainSearch/DomainSearch.tsx b/src/components/DomainSearch/DomainSearch.tsx index 18b4c5f..9ac0a75 100644 --- a/src/components/DomainSearch/DomainSearch.tsx +++ b/src/components/DomainSearch/DomainSearch.tsx @@ -11,6 +11,8 @@ export interface Domain { notes: string | null; } +// TODO: export table of results + export default function DomainSearch() { return ( <div className={styles.page}>