From 38c5e0c29472fa9af0e9357cc3d7565ac68609f3 Mon Sep 17 00:00:00 2001 From: Gwani-28 Date: Sun, 31 May 2026 16:41:07 +0900 Subject: [PATCH] Build submit listing form --- .gitignore | 10 + README.md | 22 + app/globals.css | 357 +++++++++++++++ app/layout.tsx | 36 ++ app/page.tsx | 18 + app/submit/page.tsx | 334 ++++++++++++++ next-env.d.ts | 6 + next.config.js | 4 + package-lock.json | 1036 +++++++++++++++++++++++++++++++++++++++++++ package.json | 25 ++ tsconfig.json | 36 ++ 11 files changed, 1884 insertions(+) create mode 100644 .gitignore create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/submit/page.tsx create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e78ee5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +out/ +dist/ +.env* +!.env.example +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.tsbuildinfo diff --git a/README.md b/README.md index 5f1a2e0..cfb0b6c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,24 @@ # x402-directory A curated, agent-maintained directory of x402 payment-enabled applications and endpoints. Browse, search, and discover services that accept x402 micropayments. Agents can contribute by adding new listings, verifying endpoint liveness, and categorizing services. + +## Development + +Install dependencies and run the app: + +```bash +npm install +npm run dev +``` + +Validate the project before submitting changes: + +```bash +npm run typecheck +npm run build +``` + +## Submit Listing Form + +The `/submit` page provides a client-side form for adding a new x402 endpoint. +It captures the endpoint name, URL, description, category, and pricing details, +then validates required fields before storing a local review preview. diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..ee0d360 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,357 @@ +:root { + --bg: #f7f8fa; + --panel: #ffffff; + --panel-soft: #eef3f8; + --text: #111827; + --muted: #64748b; + --border: #d8e0e8; + --brand: #0f766e; + --brand-dark: #0b5f59; + --danger: #b91c1c; + --success: #166534; + --focus: #2563eb; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +a { + color: inherit; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.shell { + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; +} + +.topbar { + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(8px); +} + +.topbar-inner { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; +} + +.brand-mark { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--brand); + color: white; + font-size: 14px; + letter-spacing: 0; +} + +.nav { + display: flex; + gap: 8px; +} + +.nav a { + padding: 8px 10px; + border-radius: 6px; + color: var(--muted); + text-decoration: none; +} + +.nav a:hover { + color: var(--text); + background: var(--panel-soft); +} + +.content { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 32px 0 56px; +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 24px; + align-items: start; +} + +.section-title { + margin: 0 0 8px; + font-size: 28px; + line-height: 1.2; + letter-spacing: 0; +} + +.section-copy { + margin: 0 0 24px; + color: var(--muted); + max-width: 760px; + line-height: 1.6; +} + +.panel { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); +} + +.form-panel { + padding: 24px; +} + +.form-grid { + display: grid; + gap: 18px; +} + +.field { + display: grid; + gap: 8px; +} + +.field-row { + display: grid; + grid-template-columns: 1fr 220px; + gap: 14px; +} + +.label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +label { + font-weight: 650; +} + +.hint { + color: var(--muted); + font-size: 13px; +} + +.input, +.select, +.textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--border); + border-radius: 6px; + padding: 11px 12px; + background: white; + color: var(--text); + outline: none; +} + +.textarea { + min-height: 128px; + resize: vertical; + line-height: 1.5; +} + +.input:focus, +.select:focus, +.textarea:focus { + border-color: var(--focus); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); +} + +.input[aria-invalid="true"], +.select[aria-invalid="true"], +.textarea[aria-invalid="true"] { + border-color: var(--danger); +} + +.error { + color: var(--danger); + font-size: 13px; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 4px; +} + +.button { + border: 1px solid transparent; + border-radius: 6px; + padding: 11px 16px; + cursor: pointer; + font-weight: 650; +} + +.button-primary { + background: var(--brand); + color: white; +} + +.button-primary:hover { + background: var(--brand-dark); +} + +.button-secondary { + background: white; + border-color: var(--border); + color: var(--text); +} + +.button-secondary:hover { + background: var(--panel-soft); +} + +.aside { + display: grid; + gap: 16px; +} + +.aside-card { + padding: 18px; +} + +.aside-card h2, +.aside-card h3 { + margin: 0 0 10px; + font-size: 16px; +} + +.aside-card p, +.aside-card li { + color: var(--muted); + line-height: 1.55; +} + +.aside-card p { + margin: 0; +} + +.aside-card ul { + padding-left: 18px; + margin: 0; +} + +.preview { + display: grid; + gap: 12px; + margin-top: 18px; + border-top: 1px solid var(--border); + padding-top: 18px; +} + +.preview-item { + display: grid; + grid-template-columns: 110px 1fr; + gap: 10px; + color: var(--muted); +} + +.preview-item strong { + color: var(--text); +} + +.success { + border: 1px solid #bbf7d0; + border-radius: 8px; + padding: 14px; + background: #f0fdf4; + color: var(--success); +} + +.empty-state { + padding: 28px; + display: grid; + gap: 12px; +} + +.empty-state p { + color: var(--muted); + line-height: 1.6; + margin: 0; +} + +.link-button { + width: fit-content; + border-radius: 6px; + background: var(--brand); + color: white; + padding: 10px 14px; + text-decoration: none; + font-weight: 650; +} + +@media (max-width: 860px) { + .workspace { + grid-template-columns: 1fr; + } + + .field-row { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .topbar-inner { + width: min(100% - 24px, 1120px); + } + + .content { + width: min(100% - 24px, 1120px); + padding-top: 24px; + } + + .form-panel { + padding: 18px; + } + + .section-title { + font-size: 23px; + } + + .actions { + flex-direction: column-reverse; + } + + .button { + width: 100%; + } + + .preview-item { + grid-template-columns: 1fr; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..d123580 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next" +import Link from "next/link" +import "./globals.css" + +export const metadata: Metadata = { + title: "x402 Directory", + description: "A directory for x402 payment-enabled endpoints", +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + +
+
+
+ + x4 + x402 Directory + + +
+
+
{children}
+
+ + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..3e52a9a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,18 @@ +import Link from "next/link" + +export default function HomePage() { + return ( +
+
+

x402 endpoint directory

+

+ Add payment-enabled endpoints with enough detail for agents and + developers to evaluate the service before integrating it. +

+
+ + Submit listing + +
+ ) +} diff --git a/app/submit/page.tsx b/app/submit/page.tsx new file mode 100644 index 0000000..04b336c --- /dev/null +++ b/app/submit/page.tsx @@ -0,0 +1,334 @@ +"use client" + +import { FormEvent, useMemo, useState } from "react" + +type ListingForm = { + name: string + url: string + description: string + category: string + pricing: string +} + +type ListingErrors = Partial> + +const categories = [ + "AI agent service", + "Data API", + "Developer tool", + "Media generation", + "Payments", + "Research", + "Other", +] + +const initialForm: ListingForm = { + name: "", + url: "", + description: "", + category: "", + pricing: "", +} + +function validateUrl(value: string) { + try { + const parsed = new URL(value) + return parsed.protocol === "https:" || parsed.protocol === "http:" + } catch { + return false + } +} + +function validate(form: ListingForm): ListingErrors { + const errors: ListingErrors = {} + + if (form.name.trim().length < 3) { + errors.name = "Use a clear listing name with at least 3 characters." + } + + if (!validateUrl(form.url.trim())) { + errors.url = "Enter a valid http or https endpoint URL." + } + + if (form.description.trim().length < 24) { + errors.description = + "Describe what the endpoint does in at least 24 characters." + } + + if (!form.category) { + errors.category = "Choose the closest endpoint category." + } + + if (form.pricing.trim().length < 3) { + errors.pricing = "Add pricing details, for example: 0.01 USDC per call." + } + + return errors +} + +function readStoredListings() { + try { + return JSON.parse( + window.localStorage.getItem("x402-directory-submissions") ?? "[]", + ) + } catch { + return [] + } +} + +export default function SubmitListingPage() { + const [form, setForm] = useState(initialForm) + const [touched, setTouched] = useState>>({}) + const [submitted, setSubmitted] = useState(null) + + const errors = useMemo(() => validate(form), [form]) + + function updateField(key: K, value: ListingForm[K]) { + setForm((current) => ({ ...current, [key]: value })) + } + + function visibleError(key: keyof ListingForm) { + return touched[key] ? errors[key] : undefined + } + + function handleSubmit(event: FormEvent) { + event.preventDefault() + setTouched({ + name: true, + url: true, + description: true, + category: true, + pricing: true, + }) + + const nextErrors = validate(form) + if (Object.keys(nextErrors).length > 0) { + return + } + + const cleanListing = { + name: form.name.trim(), + url: form.url.trim(), + description: form.description.trim(), + category: form.category, + pricing: form.pricing.trim(), + } + + const listings = readStoredListings() + window.localStorage.setItem( + "x402-directory-submissions", + JSON.stringify([cleanListing, ...listings].slice(0, 10)), + ) + + setSubmitted(cleanListing) + } + + function resetForm() { + setForm(initialForm) + setTouched({}) + setSubmitted(null) + } + + return ( +
+
+

Submit an x402 endpoint

+

+ Publish a payment-enabled service with the endpoint URL, category, and + pricing details needed for review. The form validates required fields + before accepting a local submission. +

+ +
+
+
+
+ + Required +
+ setTouched((current) => ({ ...current, name: true }))} + onChange={(event) => updateField("name", event.target.value)} + aria-invalid={Boolean(visibleError("name"))} + aria-describedby={visibleError("name") ? "name-error" : undefined} + placeholder="Agent Weather API" + /> + {visibleError("name") ? ( + + {visibleError("name")} + + ) : null} +
+ +
+
+ + Required +
+ + {visibleError("category") ? ( + + {visibleError("category")} + + ) : null} +
+
+ +
+
+ + http or https +
+ setTouched((current) => ({ ...current, url: true }))} + onChange={(event) => updateField("url", event.target.value)} + aria-invalid={Boolean(visibleError("url"))} + aria-describedby={visibleError("url") ? "url-error" : undefined} + placeholder="https://api.example.com/x402/weather" + inputMode="url" + /> + {visibleError("url") ? ( + + {visibleError("url")} + + ) : null} +
+ +
+
+ + {form.description.length}/500 +
+