diff --git a/README.md b/README.md index 5f1a2e0..f80bf8c 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. +# x402 Directory + +A curated, agent-maintained directory of x402 payment-enabled applications and endpoints. + +## Listing detail page + +This implementation adds a static listing detail experience with: + +- Listing name and full description +- Endpoint URL +- Category +- Pricing +- Verification status +- Last checked date +- Maintainer metadata +- Link to try the endpoint +- Direct links with `?listing=` +- A custom directory thumbnail at `public/thumbnail.svg` + +## Local validation + +```bash +npm test +``` diff --git a/index.html b/index.html new file mode 100644 index 0000000..e611007 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + x402 Directory + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..02b3e38 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "x402-directory", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/*.test.mjs" + } +} diff --git a/public/thumbnail.svg b/public/thumbnail.svg new file mode 100644 index 0000000..c6749f6 --- /dev/null +++ b/public/thumbnail.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/listing-utils.js b/src/listing-utils.js new file mode 100644 index 0000000..cb3ad19 --- /dev/null +++ b/src/listing-utils.js @@ -0,0 +1,38 @@ +export function getListingBySlug(listings, slug) { + return listings.find((listing) => listing.slug === slug) ?? null; +} + +export function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +export function formatPricing(listing) { + const { amount, currency, unit } = listing.pricing; + return `${amount.toFixed(2)} ${currency} / ${unit}`; +} + +export function getVerificationLabel(listing) { + switch (listing.verificationStatus) { + case 'verified': + return 'Verified x402 endpoint'; + case 'failed': + return 'Needs recheck'; + default: + return 'Verification pending'; + } +} + +export function getVerificationClass(listing) { + switch (listing.verificationStatus) { + case 'verified': + return 'status-pill status-pill--verified'; + case 'failed': + return 'status-pill status-pill--failed'; + default: + return 'status-pill status-pill--pending'; + } +} diff --git a/src/listings.js b/src/listings.js new file mode 100644 index 0000000..ff0a571 --- /dev/null +++ b/src/listings.js @@ -0,0 +1,41 @@ +export const listings = [ + { + slug: 'weather-meter', + name: 'Weather Meter', + url: 'https://weather.example/x402', + tryUrl: 'https://weather.example/demo', + description: + 'A small x402 endpoint for current weather summaries by city. Useful for testing wallet-gated API calls and pay-per-request UX.', + category: 'Data APIs', + pricing: { amount: 0.02, currency: 'USDC', unit: 'request' }, + verificationStatus: 'verified', + lastChecked: '2026-05-28', + maintainer: 'Weather Meter Labs' + }, + { + slug: 'receipt-lens', + name: 'Receipt Lens', + url: 'https://receiptlens.example/pay', + tryUrl: 'https://receiptlens.example', + description: + 'Transforms uploaded receipt images into structured JSON after x402 payment. Listed here as a reference flow for AI-agent commerce demos.', + category: 'AI tools', + pricing: { amount: 0.15, currency: 'USDC', unit: 'image' }, + verificationStatus: 'pending', + lastChecked: '2026-05-26', + maintainer: 'Receipt Lens' + }, + { + slug: 'dataset-postage', + name: 'Dataset Postage', + url: 'https://datasetpostage.example/x402/download', + tryUrl: 'https://datasetpostage.example/catalog', + description: + 'A pay-per-download endpoint for small public-domain CSV bundles. Good for testing listing detail pages with richer pricing and verification metadata.', + category: 'Datasets', + pricing: { amount: 0.50, currency: 'USDC', unit: 'download' }, + verificationStatus: 'verified', + lastChecked: '2026-05-29', + maintainer: 'Open Data Desk' + } +]; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..d74df0b --- /dev/null +++ b/src/main.js @@ -0,0 +1,102 @@ +import { + escapeHtml, + formatPricing, + getListingBySlug, + getVerificationClass, + getVerificationLabel +} from './listing-utils.js'; +import { listings } from './listings.js'; + +const defaultListing = listings[0]; +const params = new URLSearchParams(window.location.search); +const initialSlug = params.get('listing') ?? window.location.hash.replace('#', '') ?? defaultListing.slug; + +function renderListingTabs(selectedSlug) { + return listings + .map((listing) => { + const activeClass = listing.slug === selectedSlug ? ' listing-tab--active' : ''; + return ` + + `; + }) + .join(''); +} + +function renderListingDetail(slug) { + const listing = getListingBySlug(listings, slug) ?? defaultListing; + const app = document.querySelector('#app'); + + app.innerHTML = ` +
+ x402 Directory thumbnail +
+

x402 Directory

+

Endpoint detail pages for pay-per-request services.

+

+ Each listing exposes the endpoint URL, category, pricing, verification state, last checked date, + and a direct link to try the service. +

+
+
+ + + +
+
+
+
+

${escapeHtml(listing.category)}

+

${escapeHtml(listing.name)}

+

${escapeHtml(listing.description)}

+
+ ${escapeHtml(getVerificationLabel(listing))} +
+ + + + Try this endpoint +
+ + +
+ `; + + window.history.replaceState({}, '', `?listing=${encodeURIComponent(listing.slug)}`); + + document.querySelectorAll('[data-listing-slug]').forEach((button) => { + button.addEventListener('click', () => renderListingDetail(button.dataset.listingSlug)); + }); +} + +renderListingDetail(initialSlug); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..a4f9bcf --- /dev/null +++ b/src/styles.css @@ -0,0 +1,259 @@ +:root { + color-scheme: light; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --bg: #07111f; + --card: #ffffff; + --ink: #101828; + --muted: #667085; + --line: #d0d8e6; + --brand: #16a34a; + --brand-2: #06b6d4; + --warning: #b54708; + --shadow: 0 28px 80px rgb(0 0 0 / 0.26); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 12% 0%, rgb(22 163 74 / 0.42), transparent 30rem), + radial-gradient(circle at 88% 8%, rgb(6 182 212 / 0.32), transparent 34rem), + linear-gradient(135deg, #07111f, #0f172a 62%, #052e2b); + color: white; +} + +button, +code { + font: inherit; +} + +#app { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 40px 0; +} + +.hero { + display: grid; + grid-template-columns: 112px 1fr; + gap: 24px; + align-items: center; + margin-bottom: 28px; +} + +.thumbnail { + width: 112px; + height: 112px; + border-radius: 30px; + box-shadow: 0 18px 54px rgb(6 182 212 / 0.3); +} + +.eyebrow { + margin: 0 0 8px; + color: #67e8f9; + font-size: 0.78rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2, +h3, +p, +dl, +dd { + margin-top: 0; +} + +h1 { + max-width: 840px; + margin-bottom: 12px; + font-size: clamp(2.2rem, 6vw, 5rem); + line-height: 0.94; + letter-spacing: -0.06em; +} + +.hero-copy { + max-width: 760px; + color: #cbd5e1; + font-size: 1.08rem; + line-height: 1.7; +} + +.listing-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.listing-tab { + display: grid; + gap: 4px; + border: 1px solid rgb(255 255 255 / 0.16); + border-radius: 20px; + background: rgb(255 255 255 / 0.08); + color: white; + cursor: pointer; + padding: 16px; + text-align: left; + transition: + border-color 160ms ease, + transform 160ms ease, + background 160ms ease; +} + +.listing-tab:hover, +.listing-tab--active { + border-color: #67e8f9; + background: rgb(255 255 255 / 0.16); + transform: translateY(-1px); +} + +.listing-tab span { + font-weight: 900; +} + +.listing-tab small { + color: #cbd5e1; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 290px; + gap: 18px; + align-items: start; +} + +.detail-card, +.summary-card { + color: var(--ink); + border-radius: 32px; + background: rgb(255 255 255 / 0.95); + box-shadow: var(--shadow); +} + +.detail-card { + padding: 28px; +} + +.summary-card { + padding: 24px; + position: sticky; + top: 20px; +} + +.summary-card ul { + padding-left: 20px; + color: var(--muted); + line-height: 1.7; +} + +.detail-heading { + display: grid; + grid-template-columns: 1fr max-content; + gap: 18px; + align-items: start; + margin-bottom: 24px; +} + +.detail-heading h2 { + margin-bottom: 12px; + font-size: clamp(2rem, 4vw, 3.6rem); + letter-spacing: -0.05em; +} + +.detail-heading p { + max-width: 680px; + color: var(--muted); + line-height: 1.7; +} + +.status-pill { + border-radius: 999px; + font-weight: 900; + padding: 10px 14px; + white-space: nowrap; +} + +.status-pill--verified { + background: #dcfce7; + color: #166534; +} + +.status-pill--pending { + background: #ffedd5; + color: var(--warning); +} + +.status-pill--failed { + background: #fee2e2; + color: #b42318; +} + +.metadata-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 24px; +} + +.metadata-grid div { + min-width: 0; + border: 1px solid var(--line); + border-radius: 22px; + padding: 18px; +} + +dt { + margin-bottom: 8px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +dd { + overflow-wrap: anywhere; + font-weight: 800; +} + +a { + color: #0f766e; +} + +.try-link { + display: inline-flex; + border-radius: 999px; + background: linear-gradient(135deg, var(--brand), var(--brand-2)); + color: white; + font-weight: 900; + padding: 14px 18px; + text-decoration: none; +} + +@media (max-width: 820px) { + #app { + width: min(100% - 20px, 640px); + padding: 24px 0; + } + + .hero, + .detail-grid, + .detail-heading, + .metadata-grid, + .listing-tabs { + grid-template-columns: 1fr; + } + + .summary-card { + position: static; + } +} diff --git a/tests/listing-detail.test.mjs b/tests/listing-detail.test.mjs new file mode 100644 index 0000000..6a87981 --- /dev/null +++ b/tests/listing-detail.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getListingBySlug, formatPricing, getVerificationLabel } from '../src/listing-utils.js'; + +const listings = [ + { + slug: 'weather-meter', + name: 'Weather Meter', + pricing: { amount: 0.02, currency: 'USDC', unit: 'request' }, + verificationStatus: 'verified' + } +]; + +test('finds a listing detail record by slug', () => { + assert.equal(getListingBySlug(listings, 'weather-meter')?.name, 'Weather Meter'); + assert.equal(getListingBySlug(listings, 'missing'), null); +}); + +test('formats x402 endpoint pricing for the detail page', () => { + assert.equal(formatPricing(listings[0]), '0.02 USDC / request'); +}); + +test('renders a human verification label', () => { + assert.equal(getVerificationLabel({ verificationStatus: 'verified' }), 'Verified x402 endpoint'); + assert.equal(getVerificationLabel({ verificationStatus: 'pending' }), 'Verification pending'); +});