diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ff929836 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Docker build context is this repo root (docker build -f showcase/Dockerfile .). +# Keep it small: drop dependencies, build output, VCS, secrets, logs. +**/node_modules +**/.next +**/out +**/.git +**/.env +**/.env.* +**/*.log +**/.DS_Store +**/coverage +# Python tutorials leave these locally (Poetry venvs, bytecode) — never needed in context. +**/.venv +**/__pycache__ +**/*.pyc diff --git a/.github/workflows/showcase-image.yml b/.github/workflows/showcase-image.yml new file mode 100644 index 00000000..32e38fac --- /dev/null +++ b/.github/workflows/showcase-image.yml @@ -0,0 +1,149 @@ +name: Showcase image + +# Builds the tutorials showcase (showcase/Dockerfile) and pushes it to Google +# Artifact Registry so ArgoCD can deploy it. Keyless auth via Workload Identity +# Federation (same SA/provider the other Nevermined repos use — no secrets). +# +# pull_request → verify only (build + sandbox self-check); no credentials, no push. +# push to main → verify, then build & push. +# dispatch → verify, then build & push (optional extra semver tag). +# +# Versioning (immutable-first — the AR repo has immutableTags=true, so a moving +# `latest` tag would be rejected on the second build; we don't publish one): +# - sha- every build — the immutable tag ArgoCD should pin in production +# - optional, when run manually with a version input (e.g. 1.0.0) + +on: + pull_request: + paths: + - "showcase/**" + - ".github/workflows/showcase-image.yml" + push: + branches: [main] + paths: + - "showcase/**" + - "catalog/**/*.mp4" + - ".github/workflows/showcase-image.yml" + workflow_dispatch: + inputs: + version: + description: "Optional explicit semver tag to also publish (e.g. 1.0.0)" + required: false + type: string + +# Superseded PR pushes shouldn't keep burning a runner. Cancel only in-flight PR +# runs (grouped per ref, so distinct PRs don't cancel each other); never cancel a +# push/dispatch run — those publish the immutable image and must finish. +concurrency: + group: showcase-image-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + IMAGE: europe-west3-docker.pkg.dev/nevermined-eu-dev/nevermined-io/tutorials-showcase + AR_LOCATION: europe-west3 + PROJECT_ID: nevermined-eu-dev + SERVICE_ACCOUNT: github-actions-service-account@nevermined-eu-dev.iam.gserviceaccount.com + WORKLOAD_IDENTITY_PROVIDER: projects/112425687177/locations/global/workloadIdentityPools/github/providers/github-actions + +permissions: + contents: read + id-token: write + +jobs: + # Runs on every PR and every push. `next build` type-checks the 1,000+ line + # content array, and the sandbox self-check covers the x402 handshake paths — + # neither needs credentials, so a broken PR fails here instead of on main. + verify: + name: Verify (build + sandbox self-check) + runs-on: ubuntu-latest + defaults: + run: + working-directory: showcase + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: showcase/package-lock.json + + - name: Install + # setup-node already restores ~/.npm, so every tarball is on disk. Stop + # npm ci from hitting the network for data it has: --prefer-offline skips + # registry revalidation, --no-audit/--no-fund skip the metadata round-trips + # that were stalling install (measured 22s ↔ 421s variance on identical + # inputs — the slow runs hung on the audit/fund calls, not on downloads). + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Sandbox agent self-check + run: node lib/demo-agent.mjs + + - name: Build (type-checks content/tutorials.ts) + run: npm run build + + # Only publishes on main / dispatch — never on a pull request. + build-push: + name: Build & push showcase image + runs-on: ubuntu-latest + needs: verify + if: github.event_name != 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker metadata (tags + labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=sha,prefix=sha-,format=short + type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }} + + - name: Authenticate to Google Cloud + id: auth + uses: google-github-actions/auth@v2 + with: + token_format: access_token + project_id: ${{ env.PROJECT_ID }} + service_account: ${{ env.SERVICE_ACCOUNT }} + workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }} + + - name: Log in to Artifact Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.AR_LOCATION }}-docker.pkg.dev + username: oauth2accesstoken + password: ${{ steps.auth.outputs.access_token }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build & push + uses: docker/build-push-action@v5 + with: + # context is the repo root so the Dockerfile can pull the catalog demo media + context: . + file: ./showcase/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + sbom: false + + - name: Summary + env: + TAGS: ${{ steps.meta.outputs.tags }} + run: | + { + echo "### Pushed tutorials-showcase image" + echo '```' + echo "$TAGS" + echo '```' + echo "Pin the immutable sha-* tag in ArgoCD for production." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/showcase/.gitignore b/showcase/.gitignore new file mode 100644 index 00000000..84e1bc2a --- /dev/null +++ b/showcase/.gitignore @@ -0,0 +1,16 @@ +# deps / build +node_modules +.next +out +*.tsbuildinfo +next-env.d.ts + +# env +.env +.env*.local + +# large demo video — pull with `npm run sync:media` (kept out of git) +public/media/**/*.mp4 + +# internal design-review snapshots (Impeccable) +.impeccable/ diff --git a/showcase/Dockerfile b/showcase/Dockerfile new file mode 100644 index 00000000..25a06da1 --- /dev/null +++ b/showcase/Dockerfile @@ -0,0 +1,43 @@ +# Nevermined Tutorials showcase — production image (Next.js standalone). +# +# Build from the REPO ROOT so the catalog demo media (committed under catalog/) +# is in the build context: +# +# docker build -f showcase/Dockerfile -t nvm-tutorials-showcase . +# docker run -p 3000:3000 nvm-tutorials-showcase +# +# The container listens on $PORT (default 3000) on 0.0.0.0 — ready for a k8s +# Service / ArgoCD Deployment. + +# ---- dependencies ---- +FROM node:20-alpine AS deps +WORKDIR /app +COPY showcase/package.json showcase/package-lock.json ./ +RUN npm ci + +# ---- build ---- +FROM node:20-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY showcase/ ./ +# The recap videos are gitignored under showcase/public — pull them from the +# committed catalog/ demos so the recap panels play in the image. +COPY catalog/song-from-the-headlines/song-from-the-headlines.mp4 ./public/media/song-from-the-headlines/ +COPY catalog/diligence-in-a-box/diligence-in-a-box.mp4 ./public/media/diligence-in-a-box/ +RUN npm run build + +# ---- runner ---- +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +RUN addgroup -S nodejs && adduser -S nextjs -G nodejs + +COPY --from=build /app/public ./public +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/showcase/README.md b/showcase/README.md new file mode 100644 index 00000000..1f852286 --- /dev/null +++ b/showcase/README.md @@ -0,0 +1,120 @@ +# Nevermined Tutorials — Showcase + +A visual showcase of the Nevermined Payments tutorials. Every paid-agent and catalog +tutorial in this repo is presented in one normalized shape — **what you'll learn · how it +works · under the hood · see it run** — behind a persistent left sidebar, with an +individual page for each. (`langchain-chat-ui-nvm` is the browser *buyer* front-end for the +LangChain agents rather than a paid agent itself, so it's referenced inside those pages +instead of getting its own entry.) + +Built with Next.js (App Router) + TypeScript. Clean, light, docs-style UI on a white +ground with a restrained Nevermined-teal accent. Intended to become the canonical +replacement for `examples.nevermined.app`. + +## Quick start + +```bash +cd showcase +npm install +npm run sync:media # copy catalog demo video/audio into public/ (recap panels) +npm run dev # http://localhost:3000 +``` + +Build the production bundle: + +```bash +npm run build && npm start +``` + +## Docker (for ArgoCD / k8s) + +The app builds to a Next.js **standalone** server. Build the image from the **repo +root** (so the committed `catalog/` demo videos are in context): + +```bash +docker build -f showcase/Dockerfile -t nvm-tutorials-showcase . +docker run -p 3000:3000 nvm-tutorials-showcase +``` + +The container listens on `$PORT` (default `3000`) on `0.0.0.0`, runs as a non-root +user, and needs no build args or secrets — point a k8s Service / ArgoCD Deployment +at it. + +### CI → Artifact Registry + +`.github/workflows/showcase-image.yml` has two jobs: + +- **`verify`** runs on every **pull request** (and every push): `npm ci`, the sandbox + self-check (`node lib/demo-agent.mjs`), and `npm run build` — which type-checks + `content/tutorials.ts`. It needs no credentials, so a broken change fails the PR + instead of landing on `main` and breaking the image build. +- **`build-push`** runs only on **push to `main`** touching `showcase/**` (and on manual + dispatch), never on a PR. It builds the image and pushes it to + `europe-west3-docker.pkg.dev/nevermined-eu-dev/nevermined-io/tutorials-showcase` + (keyless, via Workload Identity Federation). + +Tags: an immutable `sha-` per build (**pin this in ArgoCD for production**) and an +optional semver when dispatched with a `version` input. There is no moving `latest` tag — +the registry has `immutableTags=true`, which would reject a second `latest` push. + +## How content works + +All tutorial content lives in one typed array: [`content/tutorials.ts`](./content/tutorials.ts), +shaped by [`lib/types.ts`](./lib/types.ts). Each entry is sourced from that tutorial's own README. +Adding or editing a tutorial is a data change — no new components. Pages are generated statically +from the array (`generateStaticParams`). + +Each tutorial declares a **tier**: + +- **`live`** — the `See it run` panel is **functional in the browser**: it does real fetch + round-trips through `/api/agent` and runs the actual x402 handshake — `402 → authorize → + 200 + settlement` — with a real per-session credit balance that decrements per call and + responses that react to what you type. It talks to a **local sandbox agent** + (`lib/demo-agent.mjs`), so it spends no real money and needs no credentials or backend. +- **`recap`** — the two `catalog/` demos spend real crypto autonomously across chains, so they are + **watch-only**: embedded video, playable outputs, the on-chain receipt, and a "run it locally" + note. + +## The sandbox agent, and going fully real + +`/api/agent` (backed by `lib/demo-agent.mjs`) is a local sandbox: it speaks the real x402 shape +and tracks a real per-session balance in an httpOnly cookie, but calls no external service. That +makes every "see it run" panel functional out of the box, with no credentials. + +To make a tutorial run against the **real** Nevermined flow (visitor pays via a card delegation, +real agent responds): + +1. Deploy its agent backend and note the URL + plan id. +2. In `app/api/agent/route.ts`, proxy that tutorial's requests to the backend instead of the + sandbox, porting the four x402 proxy routes from `../langchain-chat-ui-nvm/src/app/api/` + (session → token → init → passthrough) — they inject the buyer's x402 token server-side, so + `NVM_API_KEY` never reaches the browser. + +The panel code doesn't change — only what `/api/agent` talks to. + +Unit test for the handshake logic: `node lib/demo-agent.mjs`. + +## Media + +`npm run sync:media` copies the catalog `.mp4/.mp3/.jpg` into `public/media/`. The large `.mp4`s are +gitignored; the cover art and song are small enough to commit so a fresh clone still shows them. + +## Structure + +``` +showcase/ +├── app/ +│ ├── layout.tsx # builds the sidebar groups + wraps every page in AppShell +│ ├── page.tsx # overview — intro + grouped index of all tutorials +│ ├── t/[slug]/page.tsx # tutorial page — the 4 normalized sections +│ ├── api/agent/route.ts # the "see it run" endpoint (cookie state → sandbox agent) +│ └── globals.css # the light, docs-style design system (tokens) +├── components/ +│ ├── AppShell.tsx # persistent left sidebar + mobile drawer + active state +│ ├── LiveRunPanel.tsx # the interactive "see it run" panel (real fetches → /api/agent) +│ └── RecapPanel.tsx # video + outputs + receipt (recap tier) +├── content/tutorials.ts # ← all tutorial content + sidebar grouping live here +└── lib/ + ├── types.ts # the normalized content model + └── demo-agent.mjs # sandbox agent logic (x402 handshake) + `node` self-test +``` diff --git a/showcase/app/api/agent/route.ts b/showcase/app/api/agent/route.ts new file mode 100644 index 00000000..f762a2e9 --- /dev/null +++ b/showcase/app/api/agent/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +// plain-JS sandbox logic (unit-tested via `node lib/demo-agent.mjs`) +import { respond } from "@/lib/demo-agent.mjs"; + +const COOKIE = "nvm_demo"; + +type Sub = { authorized: boolean; balance: number }; + +// The "see it run" panels post here. This is a local sandbox agent: it speaks the +// real x402 shape (402 → authorize → 200 + settlement) with a real per-session credit +// balance kept in an httpOnly cookie, but calls no external service and spends no real +// money. State is keyed per tutorial slug, so every tutorial has its own handshake and +// its own credits — authorizing on one does not skip the 402 on the others. +// To make a tutorial genuinely live, proxy to its hosted backend here instead (inject the +// buyer's x402 token server-side; see langchain-chat-ui-nvm's api routes) AND move +// `authorized`/`balance` server-side in the same change: this cookie is client-supplied +// and unsigned (httpOnly is not integrity), so it must never gate real spend — a raw +// `Cookie: nvm_demo={"x":{"authorized":true,"balance":1e9}}` would otherwise pass. +export async function POST(req: NextRequest) { + let payload: { slug?: string; action?: string; message?: string }; + try { + payload = await req.json(); + } catch { + return NextResponse.json({ error: "bad request" }, { status: 400 }); + } + + const slug = payload.slug ?? ""; + + // Per-slug state map: { [slug]: {authorized, balance} }. A pre-per-slug cookie was a + // single {authorized, balance} object — detect it by the top-level `balance` and drop it. + const raw = req.cookies.get(COOKIE)?.value; + let all: Record = {}; + try { + const parsed = raw ? JSON.parse(raw) : undefined; + if (parsed && typeof parsed === "object" && typeof parsed.balance !== "number") { + all = parsed as Record; + } + } catch { + all = {}; + } + + const result = respond(all[slug], { + slug, + action: (payload.action ?? "ask") as "intro" | "ask" | "authorize" | "reset", + message: payload.message, + }); + + const res = NextResponse.json(result.body, { status: result.status }); + res.cookies.set(COOKIE, JSON.stringify({ ...all, [slug]: result.state }), { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24, + }); + return res; +} diff --git a/showcase/app/globals.css b/showcase/app/globals.css new file mode 100644 index 00000000..ee9dc71a --- /dev/null +++ b/showcase/app/globals.css @@ -0,0 +1,435 @@ +/* Nevermined tutorials showcase — light, but loud. + White foundation, one teal structural accent, a punchy lime for energy, and a + dedicated three-color language for the money moment (402 amber → 200 green, + errors red). Big display type, one authored hero animation. Demo-grade. */ + +:root { + color-scheme: light; + --bg: #ffffff; + /* default palette = the official nevermined.ai/docs colors (Mintlify docs.json) */ + --bg-tint: #f1faf8; + --panel: #eaf6f3; + --panel-2: #dcefeb; + --border: #d9ede8; + --border-strong: #bfe1da; + --ink: #0d3f48; + --ink-soft: #2c5a60; + --muted: #5e837f; + --faint: #90b3ad; + + --accent: #0f766e; + --accent-ink: #0b5951; + --accent-wash: #d6f1ea; + + --lime: #5eead4; + --lime-deep: #2fd0b6; + --lime-ink: #0b5148; + --lime-wash: #e6fbf5; + + --pay: #e2680d; /* 402 — payment required */ + --pay-ink: #ad4e07; /* readable orange text on a light wash */ + --pay-wash: #fdeede; + --paid: #0aa15f; /* 200 — settled */ + --paid-ink: #06744a; /* readable green text on a light wash */ + --paid-wash: #e2f6ec; + --danger: #d64343; + --danger-ink: #b32d2d; + --danger-wash: #fdecec; + + --sans: "Public Sans", system-ui, -apple-system, sans-serif; + --disp: "Bricolage Grotesque", var(--sans); + --mono: "JetBrains Mono", ui-monospace, monospace; + + --sidebar-w: 288px; + --r: 12px; + --r-lg: 18px; + --shadow: 0 10px 26px -14px rgba(7, 32, 30, 0.22); + --shadow-lg: 0 26px 60px -26px rgba(7, 32, 30, 0.34); + --ring: 0 0 0 3px var(--accent-wash); +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + caret-color: var(--accent); +} +a { color: inherit; text-decoration: none; } +h1, h2, h3 { margin: 0; line-height: 1.05; text-wrap: balance; } +p { margin: 0; } +img { max-width: 100%; display: block; } +.mono { font-family: var(--mono); } +button { font-family: inherit; } + +/* browser surfaces — themed, not defaulted */ +::selection { background: var(--lime); color: var(--ink); } +:focus-visible { outline: none; box-shadow: var(--ring), 0 0 0 1.5px var(--accent); border-radius: 6px; } +* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; border: 3px solid var(--bg); } +*::-webkit-scrollbar-thumb:hover { background: var(--accent); } + +/* ---------------- app shell ---------------- */ +.app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; } + +.sidebar { + position: sticky; top: 0; align-self: start; height: 100vh; overflow-y: auto; + border-right: 1px solid var(--border); background: var(--bg-tint); + display: flex; flex-direction: column; padding: 22px 14px 16px; +} +.sb-brand { display: flex; align-items: center; color: var(--ink); padding: 4px 8px 22px; } +.sb-brand:hover { color: var(--accent-ink); } +.sb-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; } + +.sb-banner { display: flex; align-items: center; gap: 12px; padding: 13px 14px; border-radius: 13px; + background: linear-gradient(135deg, var(--accent), var(--accent-ink)); color: #fff; box-shadow: var(--shadow); + margin-bottom: 6px; transition: transform 0.14s ease, box-shadow 0.14s ease; } +.sb-banner:hover { transform: translateY(-1px); box-shadow: var(--shadow-lg); } +.sb-banner.active { box-shadow: var(--shadow), 0 0 0 2px var(--lime); } +.sb-banner .bi { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; flex: none; + background: rgba(255, 255, 255, 0.16); } +.sb-banner .bt { display: flex; flex-direction: column; line-height: 1.1; } +.sb-banner .bt b { font-family: var(--disp); font-size: 16.5px; font-weight: 800; letter-spacing: -0.02em; } +.sb-banner .bt span { font-family: var(--mono); font-size: 10.5px; color: rgba(255, 255, 255, 0.82); margin-top: 3px; } + +/* prominent, collapsible category headers */ +.sb-group { margin-top: 14px; } +.sb-cat { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 12px 8px 10px; + background: none; border: none; cursor: pointer; color: var(--ink); font-family: var(--disp); + font-weight: 700; font-size: 13.5px; letter-spacing: -0.01em; border-radius: 9px; } +.sb-cat:hover { background: var(--panel); } +.sb-cat .glyph { width: 24px; height: 24px; border-radius: 7px; display: grid; place-items: center; + flex: none; color: #fff; box-shadow: 0 2px 6px -2px rgba(7, 32, 30, 0.35); } +.sb-cat .glyph.catalog { background: linear-gradient(140deg, #ff7a3c, #ef5a6f); } +.sb-cat .glyph.x402 { background: linear-gradient(140deg, #12b3a6, #0b7d75); } +.sb-cat .glyph.mcp { background: linear-gradient(140deg, #7c6bff, #5a49df); } +.sb-cat .glyph.langchain { background: linear-gradient(140deg, #1ec46f, #0f9a52); } +.sb-cat .cat-count { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--faint); + font-weight: 400; } +.sb-cat .chev { color: var(--faint); transition: transform 0.18s ease; } +.sb-cat[aria-expanded="false"] .chev { transform: rotate(-90deg); } +.sb-items { display: flex; flex-direction: column; gap: 2px; margin: 2px 0 0 10px; + padding-left: 12px; border-left: 1px solid var(--border); } +.sb-items[hidden] { display: none; } + +.sb-item { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border-radius: 8px; + font-size: 13.5px; color: var(--muted); transition: color 0.12s, background 0.12s; } +.sb-item:hover { color: var(--ink); background: var(--panel); } +.sb-item.active { color: var(--accent-ink); background: var(--accent-wash); font-weight: 600; } +.sb-item .tdot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--paid); } +.sb-item .tdot.recap { background: var(--pay); } +.sb-item .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sb-item .spark { color: var(--lime-deep); flex: none; } +.sb-item .lang { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--faint); } +.sb-item.active .lang { color: var(--accent); } + +.sb-links { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); + display: flex; flex-direction: column; gap: 1px; } +.sb-link { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; + color: var(--muted); font-size: 13px; transition: color 0.12s, background 0.12s; } +.sb-link:hover { color: var(--ink); background: var(--panel); } +.sb-link .li { display: grid; place-items: center; color: var(--accent); } +.sb-link .label { flex: 1; } +.sb-link .ext { color: var(--faint); opacity: 0; transition: opacity 0.12s; } +.sb-link:hover .ext { opacity: 1; } + +.mobilebar { display: none; } + +/* ---------------- main ---------------- */ +.main { min-width: 0; } +.container { max-width: 940px; margin: 0 auto; padding: 46px 44px 90px; } +.reading { max-width: 720px; } + +/* ---------------- hero (overview) ---------------- */ +.hero { display: grid; grid-template-columns: 1fr; gap: 30px; padding-bottom: 30px; } +.hero h1 { font-family: var(--disp); font-weight: 800; font-size: clamp(44px, 8vw, 84px); + letter-spacing: -0.05em; line-height: 0.94; max-width: 15ch; } +.hero h1 .hl { background: var(--lime); box-shadow: 6px 0 0 var(--lime), -6px 0 0 var(--lime); + color: var(--ink); } +.hero .intro { color: var(--ink-soft); font-size: 19px; max-width: 56ch; } +.hero .intro strong { color: var(--ink); font-weight: 600; } + +/* animated handshake */ +.handshake { border: 1.5px solid var(--ink); border-radius: var(--r-lg); overflow: hidden; + box-shadow: var(--shadow-lg); background: var(--bg); } +.hs-bar { display: flex; align-items: center; gap: 8px; padding: 13px 16px; background: var(--bg-tint); + color: var(--ink); border-bottom: 1.5px solid var(--border-strong); } +.hs-bar .who { font-family: var(--mono); font-size: 12px; color: var(--muted); } +.hs-bar .who b { color: var(--accent-ink); font-weight: 600; } +.hs-bar .bal { margin-left: auto; font-family: var(--mono); font-size: 12.5px; color: var(--ink); + background: var(--bg); border: 1px solid var(--border-strong); padding: 4px 11px; border-radius: 999px; } +.hs-bar .bal b { color: var(--accent-ink); font-variant-numeric: tabular-nums; } +.hs-body { padding: 8px 0; font-family: var(--mono); font-size: 14px; } +.hs-line { display: flex; align-items: center; gap: 12px; padding: 9px 18px; opacity: 0; + transform: translateY(6px); } +.hs-line.in { opacity: 1; transform: none; transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.2, 0.7, 0.2, 1); } +.hs-line .tag { font-size: 11px; font-weight: 500; padding: 3px 9px; border-radius: 6px; flex: none; + min-width: 74px; text-align: center; } +.hs-line .tag.req { background: var(--accent); color: #fff; } +.hs-line .tag.r402 { background: var(--pay); color: #fff; } +.hs-line .tag.pay { background: var(--lime); color: var(--ink); } +.hs-line .tag.r200 { background: var(--paid); color: #fff; } +.hs-line .txt { color: var(--ink-soft); } +.hs-line .txt .dim { color: var(--faint); } +.hs-stamp { margin: 6px 18px 16px; display: inline-flex; align-items: center; gap: 9px; align-self: flex-start; + border: 1.5px solid var(--paid); color: var(--paid); background: var(--paid-wash); font-family: var(--mono); + font-weight: 500; font-size: 12.5px; padding: 7px 13px; border-radius: 9px; opacity: 0; } +.hs-stamp.in { opacity: 1; transition: opacity 0.45s ease; } + +.hero .cta-row { display: flex; gap: 12px; flex-wrap: wrap; } + +/* ---------------- overview index ---------------- */ +.idx { margin-top: 20px; } +.idx-cat { display: flex; align-items: center; gap: 12px; margin: 40px 0 4px; } +.idx-cat h2 { font-family: var(--disp); font-weight: 800; font-size: 26px; letter-spacing: -0.03em; color: var(--ink); } +.idx-cat .rule { flex: 1; height: 1.5px; background: var(--border); } +.idx-cat .n { font-family: var(--mono); font-size: 12px; color: var(--faint); } + +.feature { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-lg); padding: 26px 28px; + background: linear-gradient(180deg, var(--lime-wash), var(--bg) 70%); box-shadow: var(--shadow); + margin-top: 12px; transition: transform 0.16s ease, box-shadow 0.16s ease; } +.feature:hover { transform: translateY(-3px); box-shadow: var(--shadow-lg); } +.feature .flag { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; + font-weight: 500; color: var(--lime-ink); background: var(--lime); padding: 4px 10px; border-radius: 999px; } +.feature h3 { font-family: var(--disp); font-weight: 800; font-size: 30px; letter-spacing: -0.03em; + margin-top: 14px; max-width: 20ch; } +.feature p { color: var(--ink-soft); font-size: 15.5px; margin-top: 10px; max-width: 60ch; } +.feature .go { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; color: var(--accent-ink); + margin-top: 16px; } + +.idx-row { display: flex; align-items: center; gap: 16px; padding: 18px 6px; border-bottom: 1px solid var(--border); } +.idx-row:hover .idx-title { color: var(--accent-ink); } +.idx-row .idx-main { min-width: 0; flex: 1; } +.idx-title { font-family: var(--disp); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; color: var(--ink); + display: inline-flex; align-items: center; gap: 8px; } +.idx-title .go { color: var(--accent); opacity: 0; transform: translateX(-4px); transition: 0.16s; } +.idx-row:hover .idx-title .go { opacity: 1; transform: none; } +.idx-desc { color: var(--muted); font-size: 14px; margin-top: 4px; max-width: 70ch; } +.idx-meta { display: flex; align-items: center; gap: 10px; flex: none; } +.idx-gh { color: var(--faint); display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; } +.idx-gh:hover { color: var(--ink); background: var(--panel); } + +/* shared chips */ +.t-lang { font-size: 11px; font-family: var(--mono); color: var(--muted); border: 1px solid var(--border-strong); + padding: 2px 8px; border-radius: 6px; } +.tier { font-size: 10.5px; font-family: var(--mono); font-weight: 500; padding: 4px 10px; border-radius: 999px; white-space: nowrap; } +.tier.live { color: #fff; background: var(--paid); } +.tier.recap { color: #fff; background: var(--pay); } + +/* buttons */ +.btn { display: inline-flex; align-items: center; gap: 9px; font-weight: 700; font-size: 14px; padding: 11px 18px; + border-radius: 11px; border: 1.5px solid transparent; cursor: pointer; transition: transform 0.14s ease, background 0.14s; } +.btn:active { transform: translateY(1px); } +.btn-primary { background: var(--ink); color: #fff; } +.btn-primary:hover { background: #0d3330; } +.btn-lime { background: var(--lime); color: var(--ink); } +.btn-lime:hover { background: var(--lime-deep); } +.btn-ghost { background: var(--bg); color: var(--ink); border-color: var(--border-strong); } +.btn-ghost:hover { border-color: var(--ink); } + +/* ---------------- tutorial page ---------------- */ +.tut-head { padding-bottom: 26px; border-bottom: 1px solid var(--border); } +.tut-top { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; } +.backlink { font-size: 12.5px; color: var(--muted); font-family: var(--mono); display: inline-flex; align-items: center; gap: 6px; } +.backlink:hover { color: var(--accent-ink); } +.tut-taglist { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; } +.t-proto { font-size: 11px; font-family: var(--mono); color: var(--accent-ink); background: var(--accent-wash); + padding: 3px 9px; border-radius: 6px; } +.tut-head h1 { font-family: var(--disp); font-weight: 800; font-size: clamp(30px, 4.4vw, 46px); letter-spacing: -0.035em; + max-width: 20ch; color: var(--ink); } +.tut-head .sub { color: var(--ink-soft); font-size: 17px; margin-top: 14px; max-width: 62ch; } +.tut-head .repo { font-family: var(--mono); font-size: 12px; color: var(--faint); margin-top: 16px; display: inline-block; } + +.content { display: flex; flex-direction: column; gap: 46px; padding-top: 36px; } +.block { max-width: 720px; } +.block.wide { max-width: 100%; } +.block > .h2 { font-family: var(--disp); font-weight: 800; font-size: 13px; color: var(--accent-ink); + letter-spacing: 0.02em; margin-bottom: 16px; display: flex; align-items: center; gap: 10px; scroll-margin-top: 20px; } +.block > .h2 .num { font-family: var(--mono); font-weight: 500; color: #fff; background: var(--accent); font-size: 11px; + width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; } +.block .lead { font-family: var(--disp); font-size: 24px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.22; + margin-bottom: 14px; color: var(--ink); max-width: 22ch; } +.block p { color: var(--ink-soft); font-size: 15.5px; } +.block p + p { margin-top: 12px; } +ul.learn { list-style: none; padding: 0; margin: 14px 0 0; display: flex; flex-direction: column; gap: 11px; } +ul.learn li { display: flex; gap: 12px; color: var(--ink); font-size: 15.5px; align-items: baseline; } +ul.learn li .b { width: 8px; height: 8px; border-radius: 2px; background: var(--lime); flex: none; transform: translateY(-1px); } +code.ic { font-family: var(--mono); font-size: 0.85em; color: var(--accent-ink); background: var(--accent-wash); + padding: 1px 5px; border-radius: 4px; } + +.flow { display: flex; align-items: stretch; gap: 8px; margin: 4px 0; flex-wrap: wrap; } +.flow .step { flex: 1; min-width: 96px; background: var(--panel); border: 1px solid var(--border); border-radius: 11px; + padding: 13px 12px; text-align: center; } +.flow .step b { font-family: var(--mono); font-size: 12.5px; color: var(--ink); font-weight: 500; display: block; } +.flow .step span { font-size: 11px; color: var(--faint); } +.flow .step.pay { border-color: var(--accent); background: var(--accent-wash); } +.flow .arrow { align-self: center; color: var(--accent); } + +/* code + copy */ +.codewrap { position: relative; } +.codecap { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-bottom: 8px; } +pre.code { background: var(--panel); color: var(--ink-soft); border: 1.5px solid var(--border-strong); + border-radius: 12px; padding: 16px 18px; overflow-x: auto; font-family: var(--mono); font-size: 12.8px; line-height: 1.75; } +pre.code code { color: var(--ink-soft); } +/* syntax tokens — light, on-brand */ +pre.code .token.comment, pre.code .token.prolog, pre.code .token.doctype { color: var(--faint); font-style: italic; } +pre.code .token.string, pre.code .token.attr-value, pre.code .token.char { color: #0a7d53; } +pre.code .token.keyword, pre.code .token.boolean, pre.code .token.important, pre.code .token.atrule { color: #b4530a; } +pre.code .token.number, pre.code .token.function, pre.code .token.class-name { color: #6d5cf0; } +pre.code .token.property, pre.code .token.tag, pre.code .token.constant, pre.code .token.symbol { color: var(--accent-ink); } +pre.code .token.punctuation, pre.code .token.operator { color: var(--muted); } +pre.code .token.builtin, pre.code .token.attr-name { color: var(--accent-ink); } +.copybtn { position: absolute; top: 10px; right: 10px; display: inline-flex; align-items: center; gap: 6px; + font-size: 11.5px; color: var(--muted); background: var(--bg); border: 1px solid var(--border-strong); + padding: 5px 9px; border-radius: 7px; cursor: pointer; } +.copybtn:hover { color: var(--ink); border-color: var(--ink); } +.copybtn.ok { color: var(--paid-ink); border-color: var(--paid); } + +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.schip { font-family: var(--mono); font-size: 11.5px; color: var(--ink-soft); background: var(--panel); + border: 1px solid var(--border); padding: 5px 11px; border-radius: 8px; } +table.files { width: 100%; border-collapse: collapse; font-size: 13.5px; margin-top: 4px; } +table.files td { padding: 10px 10px; border-top: 1px solid var(--border); vertical-align: top; color: var(--muted); } +table.files td:first-child { font-family: var(--mono); font-size: 12px; white-space: nowrap; } +table.files .filelink { display: inline-flex; align-items: center; gap: 5px; color: var(--accent-ink); } +table.files .filelink svg { opacity: 0; transition: opacity 0.12s; color: var(--faint); } +table.files .filelink:hover { text-decoration: underline; } +table.files .filelink:hover svg { opacity: 1; } + +table.dt { width: 100%; border-collapse: collapse; font-size: 13.5px; margin-top: 4px; } +table.dt th { text-align: left; font-family: var(--mono); font-weight: 400; font-size: 11px; color: var(--faint); + padding: 0 10px 8px; border-bottom: 1px solid var(--border-strong); } +table.dt td { padding: 10px 10px; border-top: 1px solid var(--border); color: var(--ink-soft); } +table.dt th:last-child, table.dt td:last-child { text-align: right; font-family: var(--mono); } +table.dt tr.total td { color: var(--ink); font-weight: 700; border-top: 1.5px solid var(--ink); } +table.dt tr.total td:last-child { color: var(--paid); } + +/* ---------------- LIVE PANEL — the money moment, loud ---------------- */ +.runpanel { border: 1.5px solid var(--ink); border-radius: var(--r-lg); overflow: hidden; background: var(--bg); + box-shadow: var(--shadow-lg); } +.rp-bar { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--bg-tint); + color: var(--ink); border-bottom: 1.5px solid var(--border-strong); } +.rp-bar .live-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--paid); flex: none; + box-shadow: 0 0 0 0 rgba(10, 161, 95, 0.5); animation: pulse 2.4s infinite; } +.rp-bar .title { font-size: 13.5px; font-weight: 700; color: var(--ink); } +.rp-bar .bal { margin-left: auto; font-family: var(--mono); font-size: 12.5px; color: var(--ink); + background: var(--bg); border: 1px solid var(--border-strong); padding: 5px 12px; border-radius: 999px; } +.rp-bar .bal b { color: var(--accent-ink); font-variant-numeric: tabular-nums; } +.rp-bar .bal.flash { animation: balflash 0.6s ease; } +.chatlog { padding: 18px; display: flex; flex-direction: column; gap: 12px; background: var(--bg-tint); } +.msg { max-width: 86%; padding: 10px 14px; border-radius: 13px; font-size: 14.5px; } +.msg.u { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; } +.msg.a { align-self: flex-start; background: var(--bg); border: 1px solid var(--border); color: var(--ink-soft); + border-bottom-left-radius: 4px; box-shadow: var(--shadow); } +.msg .tagfree { font-family: var(--mono); font-size: 10.5px; display: block; margin-top: 7px; } +.msg .tagfree.free { color: var(--accent-ink); } +.msg .tagfree.paid { color: var(--paid-ink); } + +.paycard { align-self: stretch; border: 2px solid var(--pay); border-radius: 14px; padding: 18px; + background: var(--pay-wash); box-shadow: var(--shadow); } +.paycard .big402 { font-family: var(--disp); font-weight: 800; font-size: 40px; letter-spacing: -0.04em; + color: var(--pay); line-height: 1; display: flex; align-items: baseline; gap: 12px; } +.paycard .big402 span { font-family: var(--sans); font-size: 15px; font-weight: 700; color: var(--ink); letter-spacing: 0; } +.paycard p { font-size: 13.5px; color: var(--ink-soft); margin: 10px 0 14px; } +.cta { display: inline-flex; align-items: center; gap: 9px; font-size: 14px; font-weight: 700; color: var(--ink); + background: var(--lime); padding: 11px 18px; border-radius: 11px; border: none; cursor: pointer; } +.cta:hover { background: var(--lime-deep); } +.cta:disabled { opacity: 0.6; cursor: default; } + +.settle { align-self: stretch; display: flex; align-items: center; gap: 11px; border: 1.5px solid var(--paid); + background: var(--paid-wash); border-radius: 12px; padding: 12px 14px; } +.settle .stamp { font-family: var(--mono); font-weight: 500; font-size: 12px; color: #fff; background: var(--paid); + padding: 4px 9px; border-radius: 6px; flex: none; } +.settle .txt { font-family: var(--mono); font-size: 12.5px; color: var(--paid-ink); } +.settle.err { border-color: var(--danger); background: var(--danger-wash); } +.settle.err .stamp { background: var(--danger); } +.settle.err .txt { color: var(--danger-ink); } +.notice { align-self: stretch; font-size: 12.5px; color: var(--muted); border: 1px solid var(--border); + background: var(--bg); border-radius: 10px; padding: 10px 13px; } + +.rp-suggest { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 18px 14px; background: var(--bg-tint); } +.rp-suggest .schip { cursor: pointer; background: var(--bg); } +.rp-suggest .schip:hover { border-color: var(--accent); color: var(--accent-ink); } +.rp-suggest .schip:disabled { opacity: 0.5; cursor: default; } +.rp-input { display: flex; gap: 8px; padding: 13px 16px; border-top: 1px solid var(--border); background: var(--bg); } +.rp-input input { flex: 1; color: var(--ink); font-size: 14px; background: var(--bg); border: 1.5px solid var(--border-strong); + border-radius: 10px; padding: 10px 13px; } +.rp-input input::placeholder { color: var(--faint); } +.rp-input button { background: var(--ink); color: #fff; border: none; border-radius: 10px; padding: 0 17px; + font-weight: 700; font-size: 13.5px; cursor: pointer; } +.rp-input button:disabled { opacity: 0.5; cursor: default; } +.runnote { font-size: 12.5px; color: var(--faint); margin-top: 14px; } +.linkbtn { background: none; border: none; padding: 0; font: inherit; color: var(--accent-ink); cursor: pointer; + text-decoration: underline; } +.linkbtn:hover { color: var(--accent); } + +/* ---------------- recap panel ---------------- */ +.videowrap video { width: 100%; border: 1.5px solid var(--ink); border-radius: var(--r-lg); background: #000; + display: block; box-shadow: var(--shadow-lg); } +.vidcap { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-top: 10px; } +.outputs { display: grid; grid-template-columns: 140px 1fr; gap: 16px; margin-top: 18px; } +.cover { aspect-ratio: 1; border-radius: 12px; border: 1.5px solid var(--border-strong); object-fit: cover; box-shadow: var(--shadow); } +.audio { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; + display: flex; flex-direction: column; justify-content: center; gap: 12px; } +.audio .fname { font-family: var(--mono); font-size: 11.5px; color: var(--muted); } +.audio audio { width: 100%; } +.warn { margin-top: 16px; font-size: 13px; color: var(--pay-ink); background: var(--pay-wash); border: 1.5px solid var(--pay); + border-radius: 11px; padding: 12px 15px; } +.recap-tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1.5px solid var(--border); } +.recap-tab { display: inline-flex; align-items: center; gap: 7px; padding: 10px 15px; border: none; background: none; + cursor: pointer; font-family: var(--disp); font-size: 14px; font-weight: 700; color: var(--muted); + border-bottom: 2.5px solid transparent; margin-bottom: -1.5px; } +.recap-tab:hover { color: var(--ink); } +.recap-tab.on { color: var(--accent-ink); border-bottom-color: var(--accent); } +.take-poster { border: 1.5px solid var(--ink); border-radius: var(--r-lg); padding: 34px 32px; + background: linear-gradient(160deg, var(--lime-wash), var(--bg) 65%); box-shadow: var(--shadow); } +.take-poster .flag { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; + font-weight: 500; color: var(--lime-ink); background: var(--lime); padding: 4px 10px; border-radius: 999px; } +.take-poster h3 { font-family: var(--disp); font-weight: 800; font-size: 28px; letter-spacing: -0.03em; + margin-top: 14px; max-width: 18ch; } +.take-poster p { color: var(--ink-soft); font-size: 15.5px; margin-top: 10px; max-width: 56ch; } +.take-poster .btn { margin-top: 20px; } +.take-note { font-size: 12.5px; color: var(--faint); margin-top: 12px; } + +.explore { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; } +.explore a { font-size: 13px; font-weight: 600; color: var(--accent-ink); border: 1.5px solid var(--accent); + border-radius: 10px; padding: 9px 14px; display: inline-flex; align-items: center; gap: 8px; } +.explore a:hover { background: var(--accent-wash); } + +footer { border-top: 1px solid var(--border); padding: 26px 0; color: var(--faint); font-size: 12.5px; margin-top: 50px; } +footer .bar { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; } + +/* ---------------- motion ---------------- */ +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(10, 161, 95, 0.5); } + 70% { box-shadow: 0 0 0 8px rgba(10, 161, 95, 0); } + 100% { box-shadow: 0 0 0 0 rgba(10, 161, 95, 0); } +} +@keyframes balflash { + 0% { background: var(--lime); border-color: var(--lime-deep); } + 100% { background: var(--bg); border-color: var(--border-strong); } +} + +@media (max-width: 900px) { + .app { grid-template-columns: 1fr; } + .sidebar { position: fixed; top: 0; left: 0; z-index: 50; width: 86%; max-width: 340px; height: 100vh; + transform: translateX(-100%); transition: transform 0.22s ease; box-shadow: var(--shadow-lg); } + .sidebar.open { transform: translateX(0); } + .mobilebar { display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 40; + background: var(--bg); border-bottom: 1px solid var(--border); padding: 12px 16px; } + .mobilebar .burger { width: 44px; height: 44px; display: grid; place-items: center; border: 1.5px solid var(--border-strong); + border-radius: 11px; background: var(--bg); cursor: pointer; color: var(--ink); } + .mobilebar .mb-brand { font-family: var(--disp); font-weight: 800; font-size: 17px; letter-spacing: -0.02em; } + .scrim { position: fixed; inset: 0; z-index: 49; background: rgba(7, 32, 30, 0.4); } + .container { padding: 28px 20px 72px; } + .outputs { grid-template-columns: 1fr; } +} +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; scroll-behavior: auto !important; animation: none !important; } + .hs-line, .hs-stamp { opacity: 1 !important; transform: none !important; } +} diff --git a/showcase/app/layout.tsx b/showcase/app/layout.tsx new file mode 100644 index 00000000..3c1dbf46 --- /dev/null +++ b/showcase/app/layout.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import AppShell, { type NavGroup } from "@/components/AppShell"; +import { groupedTutorials } from "@/content/tutorials"; + +export const metadata: Metadata = { + title: "Nevermined Tutorials", + description: + "Working examples of AI agents that pay for services in-band as they work — Nevermined's agent payments, across protocols. Read how each one works, then run it live.", +}; + +const navGroups: NavGroup[] = groupedTutorials().map((g) => ({ + label: g.label, + protocol: g.protocol, + items: g.items.map((t) => ({ + slug: t.slug, + title: t.title, + language: t.language, + tier: t.tier, + featured: t.featured, + })), +})); + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + {children} + + + ); +} diff --git a/showcase/app/not-found.tsx b/showcase/app/not-found.tsx new file mode 100644 index 00000000..0ec3df6c --- /dev/null +++ b/showcase/app/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

+ No such tutorial +

+

+ That page doesn't exist. Pick a tutorial from the menu, or head back to the overview. +

+

+ + Back to the overview + +

+
+ ); +} diff --git a/showcase/app/page.tsx b/showcase/app/page.tsx new file mode 100644 index 00000000..9757a932 --- /dev/null +++ b/showcase/app/page.tsx @@ -0,0 +1,108 @@ +import Link from "next/link"; +import { groupedTutorials, tutorials } from "@/content/tutorials"; +import { LANGUAGE_LABEL } from "@/lib/types"; +import { repoUrl } from "@/lib/repo"; +import HeroHandshake from "@/components/HeroHandshake"; +import { ArrowRight, GitHub, Spark } from "@/components/icons"; + +export default function HomePage() { + const groups = groupedTutorials(); + const flagship = tutorials.find((t) => t.featured); + + return ( +
+
+

+ Agents that pay their own way. +

+

+ Working examples of AI agents that hit a paywall, settle it in-band, and + keep going — no human in the loop. Nevermined is{" "} + payment-protocol-independent — these demos span x402, MCP and MPP. Every + tutorial is one page: what you'll learn, how it works, the code, and a panel where you{" "} + run the real payment handshake yourself. +

+ +
+ {flagship ? ( + + Start with the research agent + + ) : null} + + Browse the source + +
+
+ + {flagship ? ( +
+ + Featured live demo + +

{flagship.title}

+

{flagship.tagline}

+
+ + Open tutorial + + + View on GitHub + +
+
+ ) : null} + +
+ {groups.map((g) => { + const rows = g.items.filter((t) => !t.featured); + if (rows.length === 0) return null; + return ( +
+
+

{g.label}

+ + {rows.length} +
+ {rows.map((t) => ( +
+
+ + {t.title} + + +
{t.tagline}
+
+
+ {LANGUAGE_LABEL[t.language]} + {t.tier} + + + +
+
+ ))} +
+ ); + })} +
+
+ ); +} diff --git a/showcase/app/t/[slug]/page.tsx b/showcase/app/t/[slug]/page.tsx new file mode 100644 index 00000000..6236138c --- /dev/null +++ b/showcase/app/t/[slug]/page.tsx @@ -0,0 +1,185 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { tutorials, getTutorial } from "@/content/tutorials"; +import { PROTOCOL_LABEL, LANGUAGE_LABEL } from "@/lib/types"; +import { repoUrl, repoFileUrl } from "@/lib/repo"; +import LiveRunPanel from "@/components/LiveRunPanel"; +import RecapPanel from "@/components/RecapPanel"; +import CodeBlock from "@/components/CodeBlock"; +import { ArrowRight, ArrowLeft, GitHub, External } from "@/components/icons"; + +export function generateStaticParams() { + return tutorials.map((t) => ({ slug: t.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const t = getTutorial(slug); + if (!t) return { title: "Not found · Nevermined Tutorials" }; + return { title: `${t.title} · Nevermined Tutorials`, description: t.tagline }; +} + +export default async function TutorialPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const t = getTutorial(slug); + if (!t) notFound(); + + const isRecap = t.tier === "recap"; + + return ( +
+
+
+ + all tutorials + + + View on GitHub + +
+ +
+ {PROTOCOL_LABEL[t.protocol]} + {LANGUAGE_LABEL[t.language]} + + {isRecap ? "recap · watch it run" : "live · you pay per call"} + +
+

{t.title}

+

{t.tagline}

+ {t.repoPath} +
+ +
+ {/* 1 — Learn */} +
+
+ 1 {isRecap ? "What it shows" : "What you'll learn"} +
+

{t.learn.lead}

+
    + {t.learn.bullets.map((b, i) => ( +
  • +
  • + ))} +
+
+ + {/* 2 — How */} +
+
+ 2 How it works +
+ {t.how.paragraphs.map((p, i) => ( +

{p}

+ ))} + {t.how.flow ? ( +
+ {t.how.flow.map((s, i) => ( + + ))} +
+ ) : null} + {t.how.table ? ( + + + + {t.how.table.head.map((h) => ( + + ))} + + + + {t.how.table.rows.map((row, i) => ( + + {row.map((c, j) => ( + + ))} + + ))} + +
{h}
{c}
+ ) : null} +
+ + {/* 3 — Tech */} +
+
+ 3 Under the hood +
+
+ {t.tech.stack.map((s) => ( + + {s} + + ))} +
+ {t.tech.samples.map((s, i) => ( +
+ +
+ ))} + {t.tech.files?.length ? ( + + + {t.tech.files.map((f) => ( + + + + + ))} + +
+ + {f.path} + + + {f.desc}
+ ) : null} +
+ + {/* 4 — Run */} +
+
+ 4 See it run +
+ {t.run.kind === "live" ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function FlowStepEl({ + step, + last, +}: { + step: { label: string; sub?: string; emphasis?: boolean }; + last: boolean; +}) { + return ( + <> +
+ {step.label} + {step.sub ? {step.sub} : null} +
+ {!last ? : null} + + ); +} diff --git a/showcase/components/AppShell.tsx b/showcase/components/AppShell.tsx new file mode 100644 index 00000000..4a996e17 --- /dev/null +++ b/showcase/components/AppShell.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { Language, Tier, Protocol } from "@/lib/types"; +import { + Menu, + Chevron, + GitHub, + External, + Spark, + Bolt, + Plug, + Layers, + Book, + Globe, + Discord, + Link as LinkIcon, +} from "./icons"; +import Logo from "./Logo"; + +const LINKS = [ + { label: "Docs", href: "https://nevermined.ai/docs", icon: }, + { label: "Nevermined App", href: "https://nevermined.app", icon: }, + { label: "Discord", href: "https://discord.com/invite/GZju2qScKq", icon: }, + { label: "GitHub", href: "https://github.com/nevermined-io/tutorials", icon: }, +]; + +export interface NavItem { + slug: string; + title: string; + language: Language; + tier: Tier; + featured?: boolean; +} +export interface NavGroup { + label: string; + protocol: Protocol; + items: NavItem[]; +} + +const GLYPH: Record = { + catalog: , + x402: , + mcp: , + langchain: , +}; + +export default function AppShell({ + groups, + children, +}: { + groups: NavGroup[]; + children: React.ReactNode; +}) { + const pathname = usePathname(); + const [open, setOpen] = useState(false); + const [collapsed, setCollapsed] = useState>({}); + + useEffect(() => setOpen(false), [pathname]); + + const toggle = (label: string) => + setCollapsed((c) => ({ ...c, [label]: !c[label] })); + + return ( +
+ + + {open ?
setOpen(false)} aria-hidden="true" /> : null} + +
+
+ + +
+ {children} +
+
+ ); +} diff --git a/showcase/components/CodeBlock.tsx b/showcase/components/CodeBlock.tsx new file mode 100644 index 00000000..dc58edfb --- /dev/null +++ b/showcase/components/CodeBlock.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Prism from "prismjs"; +import "prismjs/components/prism-python"; +import "prismjs/components/prism-typescript"; +import "prismjs/components/prism-json"; +import "prismjs/components/prism-bash"; +import { Copy, Check } from "./icons"; + +const LANG_MAP: Record = { + python: "python", + py: "python", + typescript: "typescript", + ts: "typescript", + json: "json", + bash: "bash", + sh: "bash", +}; + +export default function CodeBlock({ + code, + caption, + lang, +}: { + code: string; + caption?: string; + lang?: string; +}) { + const [copied, setCopied] = useState(false); + + const html = useMemo(() => { + const l = LANG_MAP[lang ?? ""] ?? ""; + const grammar = l ? Prism.languages[l] : undefined; + if (!grammar) return null; + try { + return Prism.highlight(code, grammar, l); + } catch { + return null; + } + }, [code, lang]); + + async function copy() { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch { + /* clipboard blocked — no-op */ + } + } + + return ( +
+ {caption ?
# {caption}
: null} +
+ +
+          {html ? (
+            // Safe: `code` is our own static content (content/tutorials.ts), and
+            // Prism.highlight HTML-escapes its input before emitting token spans —
+            // there is no untrusted-input path here.
+            
+          ) : (
+            {code}
+          )}
+        
+
+
+ ); +} diff --git a/showcase/components/HeroHandshake.tsx b/showcase/components/HeroHandshake.tsx new file mode 100644 index 00000000..424b1e66 --- /dev/null +++ b/showcase/components/HeroHandshake.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; + +// The product thesis, demonstrated: an agent hits a paywall and settles it in-band. +// Plays on load and loops (a projector-friendly demo), collapses to a static frame +// under prefers-reduced-motion. +const LINES = [ + { tag: "POST", cls: "req", head: "POST /research", tail: ' "EV market in Europe"' }, + { tag: "402", cls: "r402", head: "Payment Required", tail: " · plan: card-delegation" }, + { tag: "PAY", cls: "pay", head: "payment token", tail: " · visa *4242" }, + { tag: "200", cls: "r200", head: "OK", tail: " · payment-response: settled" }, +]; + +export default function HeroHandshake() { + // Rests as the COMPLETE handshake (good first frame / thumbnail), then loops. + const [step, setStep] = useState(LINES.length); + const [settled, setSettled] = useState(true); + const [balance, setBalance] = useState(95); + const [flash, setFlash] = useState(false); + + useEffect(() => { + const reduce = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (reduce) return; // stay on the full resting frame + + const timers: ReturnType[] = []; + function cycle() { + setStep(0); + setSettled(false); + setBalance(100); + LINES.forEach((_, i) => { + timers.push(setTimeout(() => setStep(i + 1), 650 * (i + 1))); + }); + const afterLines = 650 * (LINES.length + 1); + timers.push( + setTimeout(() => { + setSettled(true); + setBalance(95); + setFlash(true); + timers.push(setTimeout(() => setFlash(false), 600)); + }, afterLines), + ); + timers.push(setTimeout(cycle, afterLines + 3200)); // loop + } + timers.push(setTimeout(cycle, 2600)); // hold the full frame first, then animate + return () => timers.forEach(clearTimeout); + }, []); + + return ( +
+
+ + agent → research service + + + balance {balance} + +
+
+ {LINES.map((l, i) => ( +
+ {l.tag} + + {l.head} + {l.tail} + +
+ ))} +
settled · 5 credits · one round-trip · zero clicks
+
+
+ ); +} diff --git a/showcase/components/LiveRunPanel.tsx b/showcase/components/LiveRunPanel.tsx new file mode 100644 index 00000000..e1c4400f --- /dev/null +++ b/showcase/components/LiveRunPanel.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { LiveRun } from "@/lib/types"; +import { ArrowRight } from "./icons"; + +type Item = + | { type: "msg"; role: "user" | "agent"; text: string; tag?: "free" | "paid" } + | { type: "pay"; credits: number; pending: string; resolved?: boolean } + | { type: "settle"; text: string; error?: boolean } + | { type: "notice"; text: string }; + +interface Intro { + greeting: string; + suggestions: string[]; + authorized: boolean; + balance: number; +} + +export default function LiveRunPanel({ + slug, + title, + run, +}: { + slug: string; + title: string; + run: LiveRun; +}) { + const [items, setItems] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [balance, setBalance] = useState(null); + const [authorized, setAuthorized] = useState(false); + const [flash, setFlash] = useState(false); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const logRef = useRef(null); + + async function call(action: string, message?: string) { + const res = await fetch("/api/agent", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ slug, action, message }), + }); + return { status: res.status, body: await res.json() }; + } + + function tickBalance(next: number) { + setBalance(next); + setFlash(true); + setTimeout(() => setFlash(false), 600); + } + + useEffect(() => { + let live = true; + call("intro").then(({ body }) => { + if (!live) return; + const intro = body as Intro; + setItems([{ type: "msg", role: "agent", text: intro.greeting }]); + setSuggestions(intro.suggestions ?? []); + setAuthorized(intro.authorized); + setBalance(intro.balance); + }); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]); + + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); + }, [items]); + + async function ask(text: string) { + const message = text.trim(); + if (!message || busy) return; + setBusy(true); + setItems((x) => [...x, { type: "msg", role: "user", text: message }]); + try { + const { status, body } = await call("ask", message); + if (status === 402 && body.kind === "payment_required") { + setItems((x) => [...x, { type: "pay", credits: body.credits, pending: message }]); + } else if (status === 402 && body.kind === "insufficient") { + setBalance(body.balance); + setItems((x) => [ + ...x, + { type: "notice", text: "Out of credits this session — reset the delegation to top up." }, + ]); + } else if (body.kind === "free") { + setItems((x) => [...x, { type: "msg", role: "agent", text: body.answer, tag: "free" }]); + } else if (body.kind === "paid") { + tickBalance(body.balance); + setItems((x) => [ + ...x, + { type: "settle", text: `200 OK · settled ${body.credits} credit(s) · balance ${body.balance}` }, + { type: "msg", role: "agent", text: body.answer, tag: "paid" }, + ]); + } else { + setItems((x) => [...x, { type: "settle", text: "the agent returned an error", error: true }]); + } + } catch { + setItems((x) => [ + ...x, + { type: "settle", text: "network error — is the sandbox running?", error: true }, + ]); + } finally { + setBusy(false); + } + } + + async function authorize(pending: string, idx: number) { + if (busy) return; + setBusy(true); + try { + const { body } = await call("authorize"); + setAuthorized(true); + if (typeof body.balance === "number") setBalance(body.balance); + setItems((x) => x.map((it, i) => (i === idx ? ({ ...it, resolved: true } as Item) : it))); + } finally { + setBusy(false); + } + await ask(pending); + } + + async function reset() { + await call("reset"); + setAuthorized(false); + setBalance(100); + setItems((x) => [...x, { type: "notice", text: "Card delegation reset · balance back to 100." }]); + } + + return ( + <> +
+
+
+ +
+ {items.map((it, i) => { + if (it.type === "msg") { + return ( +
+ {it.text} + {it.tag ? ( + + {it.tag === "free" ? "free · no token attached" : "paid · settled in one round-trip"} + + ) : null} +
+ ); + } + if (it.type === "pay") { + return ( +
+
+ 402 Payment Required +
+

+ This capability costs {it.credits} credit(s). Authorize a card delegation once and + the agent pays per call — you're not asked again this session. +

+ +
+ ); + } + if (it.type === "settle") { + return ( +
+ {it.error ? "ERROR" : "PAID"} + {it.text} +
+ ); + } + return ( +
+ {it.text} +
+ ); + })} +
+ + {suggestions.length > 0 && items.length <= 1 ? ( +
+ {suggestions.map((s) => ( + + ))} +
+ ) : null} + +
{ + e.preventDefault(); + const t = input; + setInput(""); + ask(t); + }} + > + setInput(e.target.value)} + placeholder={busy ? "…" : "Send a request to the agent"} + disabled={busy} + aria-label="Message the agent" + /> + +
+
+

+ {authorized ? ( + <> + Sandbox agent — real payment round-trips and a real per-session credit balance, no real money.{" "} + + .{" "} + + ) : ( + "Sandbox agent — real payment round-trips (402 → authorize → settle) with a per-session credit balance, no external service and no real money. " + )} + {run.note} +

+ + ); +} diff --git a/showcase/components/Logo.tsx b/showcase/components/Logo.tsx new file mode 100644 index 00000000..d398c627 --- /dev/null +++ b/showcase/components/Logo.tsx @@ -0,0 +1,16 @@ +// The official Nevermined wordmark (mark + lettering), from the nevermined.app assets. +// Monochrome; renders in currentColor so it takes the surrounding text color. +export default function Logo({ height = 22, className }: { height?: number; className?: string }) { + return ( + + + + ); +} diff --git a/showcase/components/RecapPanel.tsx b/showcase/components/RecapPanel.tsx new file mode 100644 index 00000000..d6a3c3b3 --- /dev/null +++ b/showcase/components/RecapPanel.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import type { RecapRun } from "@/lib/types"; +import { External, Play, Spark } from "./icons"; + +export default function RecapPanel({ run }: { run: RecapRun }) { + const takes = run.takes ?? []; + const [tab, setTab] = useState(0); // 0 = the run, 1..n = takes + + const theRun = ( + <> + {run.video ? ( +
+ +
+ {run.video.caption} · {run.video.duration} +
+
+ ) : null} + + {run.outputs ? ( +
+ {run.outputs.cover ? ( + run.outputs.cover.src ? ( + // eslint-disable-next-line @next/next/no-img-element + {run.outputs.cover.label} + ) : ( +
+ {run.outputs.cover.label} +
+ ) + ) : null} + {run.outputs.audio ? ( +
+
{run.outputs.audio.label}
+ {run.outputs.audio.src ? ( +
+ ) : null} +
+ ) : null} + + {run.receipt ? ( + + + + {run.receipt.head.map((h) => ( + + ))} + + + + {run.receipt.rows.map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + +
{h}
{cell}
+ ) : null} + + {run.warn ?
{run.warn}
: null} + + {run.interactive?.length ? ( +
+ {run.interactive.map((l) => ( + + {l.label} + + ))} +
+ ) : null} + + ); + + if (takes.length === 0) return theRun; + + const active = takes[tab - 1]; + return ( +
+
+ + {takes.map((t, i) => ( + + ))} +
+ + {tab === 0 ? ( + theRun + ) : ( +
+ + Alternative take + +

The same run, a different design.

+ {active.byline ?

{active.byline}

: null} + + Open {active.label} + +

+ An interactive one-page recap of this exact run, published on claude.ai. +

+
+ )} +
+ ); +} diff --git a/showcase/components/icons.tsx b/showcase/components/icons.tsx new file mode 100644 index 00000000..497987d5 --- /dev/null +++ b/showcase/components/icons.tsx @@ -0,0 +1,186 @@ +// One drawn icon set — consistent 1.6 stroke, 24px grid, currentColor. +// No emoji/unicode stand-ins anywhere in the UI. + +type P = { size?: number; className?: string; strokeWidth?: number }; + +function svg(children: React.ReactNode, size = 18, sw = 1.6, className?: string) { + return ( + + ); +} + +export const Menu = ({ size, className, strokeWidth }: P) => + svg( + <> + + , + size ?? 20, + strokeWidth, + className, + ); + +export const Chevron = ({ size, className, strokeWidth }: P) => + svg(, size ?? 16, strokeWidth, className); + +export const ArrowRight = ({ size, className, strokeWidth }: P) => + svg(, size ?? 18, strokeWidth, className); + +export const ArrowLeft = ({ size, className, strokeWidth }: P) => + svg(, size ?? 18, strokeWidth, className); + +export const External = ({ size, className, strokeWidth }: P) => + svg( + <> + + + + , + size ?? 16, + strokeWidth, + className, + ); + +export const GitHub = ({ size, className }: P) => ( + +); + +export const Play = ({ size, className }: P) => ( + +); + +export const Spark = ({ size, className }: P) => ( + +); + +export const Copy = ({ size, className, strokeWidth }: P) => + svg( + <> + + + , + size ?? 15, + strokeWidth, + className, + ); + +export const Check = ({ size, className, strokeWidth }: P) => + svg(, size ?? 15, strokeWidth ?? 2, className); + +// category glyphs (rendered white-on-color inside a rounded square) +export const Bolt = ({ size, className }: P) => ( + +); +export const Grid = ({ size, className, strokeWidth }: P) => + svg( + <> + + + + + , + size ?? 13, + strokeWidth ?? 1.8, + className, + ); +export const Link = ({ size, className, strokeWidth }: P) => + svg( + <> + + + + , + size ?? 13, + strokeWidth ?? 1.8, + className, + ); +export const Layers = ({ size, className, strokeWidth }: P) => + svg( + <> + + + + , + size ?? 13, + strokeWidth ?? 1.7, + className, + ); +export const Plug = ({ size, className, strokeWidth }: P) => + svg( + <> + + + + , + size ?? 13, + strokeWidth ?? 1.7, + className, + ); +export const Discord = ({ size, className }: P) => ( + +); +export const Globe = ({ size, className, strokeWidth }: P) => + svg( + <> + + + + , + size ?? 17, + strokeWidth ?? 1.6, + className, + ); + +export const Book = ({ size, className, strokeWidth }: P) => + svg( + <> + + + , + size ?? 18, + strokeWidth ?? 1.7, + className, + ); diff --git a/showcase/content/tutorials.ts b/showcase/content/tutorials.ts new file mode 100644 index 00000000..f7739aa1 --- /dev/null +++ b/showcase/content/tutorials.ts @@ -0,0 +1,733 @@ +import type { Tutorial, Protocol } from "@/lib/types"; + +// Every entry is sourced from the tutorial's own README in this repo. +// Order here is the gallery order. +export const tutorials: Tutorial[] = [ + // ─────────────────────────────── 1. Research agent (featured) ────────────── + { + slug: "langchain-research-agent", + title: "Freemium research agent, gated by x402", + tagline: + "Chat for free to learn what it does; it charges only when it actually runs the research. The paywall sits inside the tool, not the route.", + protocol: "langchain", + language: "py", + tier: "live", + repoPath: "langchain-research-agent-py/", + featured: true, + learn: { + lead: "How to charge for one capability while everything around it stays free.", + bullets: [ + "Wrap a LangChain @tool so it runs verify → work → settle on every paid call", + 'Let the LLM concierge answer "what can you do?" for free, and route only real work through payment', + "Thread an x402 access token onto a run at config.configurable.payment_token", + "Read the settlement receipt back and show the buyer credits burned and balance left", + ], + }, + how: { + paragraphs: [ + "The @requires_payment decorator wraps the tool body with the canonical x402 lifecycle. The buyer's token rides on the run; the decorator finds it, verifies it, does the work, then settles credits — one round-trip.", + "No free question ever enters that path — the concierge LLM handles introspection itself, so only the paid tool touches the facilitator.", + ], + flow: [ + { label: "verify", sub: "permissions" }, + { label: "tool body", sub: "the research", emphasis: true }, + { label: "settle", sub: "burn credits" }, + ], + }, + tech: { + stack: [ + "create_react_agent", + "payments-py[langsmith]", + "@requires_payment", + "langgraph dev", + "OpenAI gpt-4o-mini", + ], + samples: [ + { + caption: "the token must sit here; the decorator reads it from the run", + lang: "python", + code: `{ + "assistant_id": "research", + "input": {"messages": [ ... ]}, + "config": {"configurable": {"payment_token": access_token}} +}`, + }, + ], + files: [ + { + path: "src/agent.py", + desc: "the ReAct agent + the market_research tool, paid inner wrapped with @requires_payment", + }, + { + path: "src/buyer.py", + desc: "CLI buyer exercising the free path and the paid path back-to-back", + }, + { + path: "langgraph.json", + desc: "wires the graph at graphs.research — no http.app, gating is in-graph", + }, + ], + }, + run: { + kind: "live", + present: "chat", + paymentPill: "card delegation active", + endpointEnv: "NEXT_PUBLIC_RESEARCH_ENDPOINT", + chat: [ + { role: "user", text: "What can you do?" }, + { + role: "agent", + free: true, + text: "I'm a market-research agent. Ask me about a market and I'll pull together sizing, competitors and momentum. Introspection like this is free.", + }, + { role: "user", text: "Research the EV market in Europe." }, + ], + paidPrompt: { + title: "This one's paid", + body: "Running the research costs 5 credits. Authorize a small card delegation once and the agent pays per call — you're never asked again this session.", + cta: "Authorize with card", + }, + settle: "settled 5 credits · balance 95 · analysis below", + paidAnswer: + "## Market size — Europe's EV market reached ~2.0M new BEV units in 2024, led by Germany, the UK and France; charging density and fleet electrification are the fastest-moving segments…", + note: "Freemium: chatting is free; a research request runs the paid tool (5 credits) via the 402 → authorize → settle handshake. See the README to point this at a hosted LangGraph deployment with real card delegation.", + }, + }, + + // ─────────────────────────────── 2. HTTP simple agent (TS) ───────────────── + { + slug: "http-simple-agent-ts", + title: "Protect an Express agent with x402", + tagline: + "A minimal Express server whose /ask endpoint is gated by the Nevermined payment middleware — one line per route.", + protocol: "x402", + language: "ts", + tier: "live", + repoPath: "http-simple-agent-ts/", + learn: { + lead: "The smallest possible paid HTTP agent: add a paywall to one route.", + bullets: [ + "Protect an Express endpoint with paymentMiddleware from @nevermined-io/payments/express", + "Return 402 with the payment-required envelope, verify the payment-signature, settle on success", + "Generate an x402 access token on the client with the SDK and retry the call", + "Optionally track OpenAI cost per call with Nevermined observability", + ], + }, + how: { + paragraphs: [ + "The client calls /ask with no token and gets 402 Payment Required, carrying the payment-required header. It mints an x402 token with the SDK, retries with the payment-signature header, and the agent verifies, runs, and settles (burns credits) before returning 200 with a payment-response receipt.", + ], + flow: [ + { label: "POST /ask", sub: "no token" }, + { label: "402", sub: "payment-required" }, + { label: "sign + retry", sub: "payment-signature", emphasis: true }, + { label: "200", sub: "payment-response" }, + ], + }, + tech: { + stack: ["Express", "TypeScript", "@nevermined-io/payments/express", "OpenAI"], + samples: [ + { + caption: "one line gates the route", + lang: "typescript", + code: `import { paymentMiddleware } from '@nevermined-io/payments/express' + +app.use(paymentMiddleware(payments, { + 'POST /ask': { planId: PLAN_ID, credits: 1 } +}))`, + }, + ], + files: [ + { path: "src/agent.ts", desc: "Express server with a payment-protected /ask endpoint" }, + { + path: "src/agent-observability.ts", + desc: "same agent, with Nevermined observability tracking OpenAI cost", + }, + { path: "src/client.ts", desc: "demo client showing the full x402 payment flow" }, + ], + }, + run: { + kind: "live", + present: "transcript", + paymentPill: "x402 v2", + transcript: [ + { t: "POST /ask \"What's the weather in Lisbon?\"", kind: "req" }, + { t: "← 402 Payment Required", kind: "r402" }, + { t: " payment-required: eyJ4NDAy… plan · 1 credit", kind: "dim" }, + { t: "POST /ask payment-signature: eyJ4NDAy…", kind: "req" }, + { t: " verify → run → settle", kind: "dim" }, + { t: "← 200 OK payment-response: settled", kind: "r200" }, + { t: "1 credit burned · agent answered", kind: "settle" }, + ], + note: "The buyer mints an x402 access token via the SDK and sends it as `payment-signature`; the sandbox runs that round-trip locally. See the README to point this at a hosted instance of the agent.", + }, + }, + + // ─────────────────────────────── 3. HTTP simple agent (PY) ───────────────── + { + slug: "http-simple-agent-py", + title: "The same paywall, on FastAPI", + tagline: + "A minimal FastAPI server with a payment-protected /ask endpoint using the payments-py SDK and ASGI middleware.", + protocol: "x402", + language: "py", + tier: "live", + repoPath: "http-simple-agent-py/", + learn: { + lead: "The Python twin of the Express tutorial — identical x402 flow, FastAPI stack.", + bullets: [ + "Protect a FastAPI route with the payments-py ASGI payment middleware", + "Handle the same 402 → sign → 200 round-trip the TypeScript version does", + "Mint the x402 token on the client with the payments-py SDK", + "Track OpenAI cost per call with Nevermined observability", + ], + }, + how: { + paragraphs: [ + "Same contract as the TypeScript tutorial: no token yields 402 with a base64 payment-required header; the client mints an x402 token, retries with payment-signature; the agent verifies, executes, and settles credits before 200 with a payment-response receipt.", + ], + flow: [ + { label: "POST /ask", sub: "no token" }, + { label: "402", sub: "payment-required" }, + { label: "sign + retry", sub: "payment-signature", emphasis: true }, + { label: "200", sub: "payment-response" }, + ], + }, + tech: { + stack: ["FastAPI", "Python 3.10+", "payments-py", "OpenAI", "Poetry"], + samples: [ + { + caption: "src/agent.py — the protected endpoint (shape)", + lang: "python", + code: `# FastAPI app with the Nevermined ASGI payment middleware. +# /ask returns 402 until a valid x402 payment-signature is presented, +# then verifies, runs the LLM, and settles credits before responding.`, + }, + ], + files: [ + { path: "src/agent.py", desc: "FastAPI server with a payment-protected /ask endpoint" }, + { + path: "src/agent_observability.py", + desc: "same agent with Nevermined observability for OpenAI cost", + }, + { path: "src/client.py", desc: "demo client showing the complete x402 payment flow" }, + ], + }, + run: { + kind: "live", + present: "transcript", + paymentPill: "x402 v2", + transcript: [ + { t: "POST /ask \"Summarize today's AI news\"", kind: "req" }, + { t: "← 402 Payment Required", kind: "r402" }, + { t: " payment-required (base64) · plan · 1 credit", kind: "dim" }, + { t: "POST /ask payment-signature: eyJ4NDAy…", kind: "req" }, + { t: " verify → run → settle", kind: "dim" }, + { t: "← 200 OK payment-response (base64)", kind: "r200" }, + { t: "1 credit burned · agent answered", kind: "settle" }, + ], + note: "Same x402 flow via payments-py; the sandbox runs the round-trip locally. See the README to point this at a hosted instance of the agent.", + }, + }, + + // ─────────────────────────────── 4. LangChain paid agent ─────────────────── + { + slug: "langchain-paid-agent", + title: "Gate one LangChain tool with @requires_payment", + tagline: + "The minimal case: a single LangChain/LangGraph tool gated by Nevermined payments. No HTTP layer, no 402 round-trip — the buyer threads a token in-process.", + protocol: "langchain", + language: "py", + tier: "live", + repoPath: "langchain-paid-agent-py/", + learn: { + lead: "The payment flow with everything else stripped away, so it's the only signal.", + bullets: [ + "Protect a LangChain @tool by wrapping it with @requires_payment", + "Acquire an x402 access token with payments.x402.get_x402_access_token(plan_id=...)", + 'Thread the token through agent.invoke(..., config={"configurable": {"payment_token": ...}})', + 'Read the settlement receipt back from configurable["payment_settlement"]', + ], + }, + how: { + paragraphs: [ + "It mirrors the x402 HTTP discovery pattern, in-process: the buyer invokes the agent with no token, the protected tool raises PaymentRequiredError carrying the full accepts block (scheme, network, plan id), and the buyer uses that to acquire a token before retrying.", + "No plan id, scheme, or provider has to be configured on the buyer up front — the error tells it everything it needs.", + ], + flow: [ + { label: "invoke", sub: "no token" }, + { label: "PaymentRequiredError", sub: "accepts block" }, + { label: "get token", sub: "for that plan", emphasis: true }, + { label: "invoke", sub: "with token" }, + ], + }, + tech: { + stack: ["create_react_agent", "LangGraph", "payments-py[langchain]", "OpenAI gpt-4o-mini"], + samples: [ + { + caption: "the buyer retries with the token in configurable", + lang: "python", + code: `agent.invoke( + {"messages": [...]}, + config={"configurable": {"payment_token": token}}, +)`, + }, + ], + files: [ + { path: "src/agent.py", desc: "the agent + the single @requires_payment-protected tool" }, + { path: "src/buyer.py", desc: "buyer that hits the no-token path, then pays and retries" }, + ], + }, + run: { + kind: "live", + present: "chat", + paymentPill: "in-process", + chat: [ + { role: "user", text: "Use the paid tool." }, + { + role: "agent", + text: "PaymentRequiredError — this tool needs payment. accepts: scheme=nvm:card-delegation, plan=plan-…", + }, + { role: "user", text: "(buyer acquires token, retries)" }, + ], + paidPrompt: { + title: "Pay to invoke", + body: "The buyer acquires an x402 access token for the plan named in the error, then invokes again with the token on config.configurable.payment_token.", + cta: "Acquire token & retry", + }, + settle: "tool ran · settlement receipt on configurable", + paidAnswer: "The paid tool executed and returned its result; the settlement receipt is on configurable[\"payment_settlement\"].", + note: "This tutorial runs entirely in-process (no server). The live panel mirrors the buyer script's two-phase call. Point it at your own account by following the README.", + }, + }, + + // ─────────────────────────────── 5. Deep agent ───────────────────────────── + { + slug: "langchain-deep-agent", + title: "A paid tool inside a subagent", + tagline: + "A Deep Agents market-research agent where the paid capability lives inside a subagent. The x402 token survives the task() delegation hop, so @requires_payment needs no changes.", + protocol: "langchain", + language: "py", + tier: "live", + repoPath: "langchain-deep-agent-py/", + learn: { + lead: "Payment context survives one delegation hop — so paid tools can live where the work does.", + bullets: [ + "Put a paid tool behind a task() delegation and keep the payment lifecycle intact", + "Cap paid calls per run — a deep agent decides for itself how many subagent hops a request warrants", + "Guard against the supervisor answering a paid question from its own knowledge (giving it away free)", + "Compare harnesses side by side with the sibling create_react_agent research agent", + ], + }, + how: { + paragraphs: [ + "The buyer attaches an x402 token to the run. The supervisor never touches it — it delegates via the built-in task tool, and LangGraph copies configurable down into the subagent's tool calls. So @requires_payment works unchanged one hop away from where the token was supplied.", + "A deep agent can bill several times per user turn, so the tutorial caps it explicitly with NVM_MAX_PAID_CALLS_PER_RUN and sends a fresh nvm_run_id to scope the cap per-run rather than per-conversation.", + ], + flow: [ + { label: "main agent", sub: "supervisor" }, + { label: "task()", sub: "delegate" }, + { label: "research-sub", sub: "owns the tool", emphasis: true }, + { label: "market_research", sub: "PAID" }, + ], + }, + tech: { + stack: ["create_deep_agent", "LangChain v1 stack", "payments-py[langsmith]", "OpenAI gpt-4o-mini"], + samples: [ + { + caption: "the buyer scopes the per-run cap with a fresh run id", + lang: "python", + code: `"config": {"configurable": { + "payment_token": token, + "nvm_run_id": str(uuid.uuid4()), +}}`, + }, + ], + files: [ + { path: "src/agent.py", desc: "create_deep_agent supervisor + research-sub owning the paid tool" }, + { path: "src/buyer.py", desc: "sends token + nvm_run_id; prints the raw ToolMessage as source of truth" }, + ], + }, + run: { + kind: "live", + present: "chat", + paymentPill: "budget-capped · 3/run", + chat: [ + { role: "user", text: "What can you help with?" }, + { role: "agent", free: true, text: "I'm a market-research supervisor. Ask me to research something and I'll delegate it to my research subagent." }, + { role: "user", text: "Research the EV market in Europe." }, + ], + paidPrompt: { + title: "Delegated & paid", + body: "The supervisor delegates via task() to research-sub, whose paid tool runs verify → work → settle. Capped at 3 paid calls per run.", + cta: "Authorize with card", + }, + settle: "settled · raw ToolMessage is the source of truth", + paidAnswer: + "research-sub returned a structured market analysis; the buyer prints the raw ToolMessage, not the chat paraphrase, so nothing gets lost between the two LLM layers.", + note: "Deep Agents needs its own virtualenv (LangChain v1); the paid tool sits one task() hop away in a subagent, and the token survives the delegation. See the README to run it against a hosted deployment.", + }, + }, + + // ─────────────────────────────── 6. LangSmith deployment ─────────────────── + { + slug: "langchain-langsmith-deployment", + title: "Every call is paid: route-level middleware", + tagline: + "Deploy a LangGraph agent to LangSmith Deployment and gate its runs/wait endpoint with the Nevermined x402 flow — a single env file plus four lines of glue.", + protocol: "langchain", + language: "py", + tier: "live", + repoPath: "langchain-langsmith-deployment-py/", + learn: { + lead: "When there's no free tier: gate the whole route, not a single tool.", + bullets: [ + "Gate POST /threads/{id}/runs/wait with the payments-py ASGI PaymentMiddleware", + "Keep POST /threads and discovery endpoints free; protect only the run", + "Return 402 + the x402 envelope, then 200 + the settlement receipt on retry", + "Deploy the graph to hosted LangSmith Deployment with langgraph up", + ], + }, + how: { + paragraphs: [ + "The middleware follows the canonical x402 lifecycle: verify → agent runs → settle, and only settles if the agent succeeded. Failed runs don't bill the buyer; settlement failures after a successful run are logged but never surface to the client — the buyer already got the value.", + "Use this pattern when every message is paid. For a free-introspection concierge, use the in-tool gating of the research agent instead.", + ], + flow: [ + { label: "verify", sub: "payment-signature" }, + { label: "agent runs", sub: "the graph", emphasis: true }, + { label: "settle", sub: "only if it succeeded" }, + ], + }, + tech: { + stack: ["LangSmith Deployment", "LangGraph", "payments-py", "Docker", "Python 3.11–3.13"], + samples: [ + { + caption: "the gated endpoint map", + lang: "text", + code: `POST /threads → free +POST /threads/{id}/runs/wait → PAID (402 → 200 + receipt) +GET /assistants/search, /info, /ok → pass through`, + }, + ], + files: [ + { path: "src/nvm_app.py", desc: "four lines of glue that wrap the app with PaymentMiddleware" }, + { path: "src/buyer.py", desc: "drives the 402 round-trip and prints the settlement receipt" }, + ], + }, + run: { + kind: "live", + present: "transcript", + paymentPill: "route-level", + transcript: [ + { t: "POST /threads → thread_id = …", kind: "dim" }, + { t: "POST /threads/{id}/runs/wait (no signature)", kind: "req" }, + { t: "← 402 scheme=nvm:erc4337, network=eip155:84532", kind: "r402" }, + { t: " pick enrolled method · Visa *4242 · acquire token", kind: "dim" }, + { t: "POST /threads/{id}/runs/wait payment-signature: eyJ…", kind: "req" }, + { t: "← 200 {output: 'echo: hello from the buyer'}", kind: "r200" }, + { t: "settlement receipt returned", kind: "settle" }, + ], + note: "The runs/wait route is gated by route-level ASGI middleware; the sandbox runs the 402 round-trip locally. See the README to call a hosted LangSmith deployment.", + }, + }, + + // ─────────────────────────────── 7. Weather MCP (TS) ─────────────────────── + { + slug: "weather-mcp", + title: "Paywall MCP tools, resources & prompts", + tagline: + "A minimal MCP server exposing a weather.today tool, a weather://today resource and a weather.ensureCity prompt — all protected with credit-based access via the x402 v2 in-band MCP transport.", + protocol: "mcp", + language: "ts", + tier: "live", + repoPath: "mcp-examples/weather-mcp/", + learn: { + lead: "MCP says what an agent can do; Nevermined adds who can access it and how to charge.", + bullets: [ + "Protect MCP tools, resources and prompts with a Nevermined paywall", + "Authenticate the MCP session with Authorization: Bearer and read payment in-band from _meta[\"x402/payment\"]", + "Compute credits dynamically per request", + "Compare a high-level McpServer SDK build with a low-level JSON-RPC one", + ], + }, + how: { + paragraphs: [ + "The MCP session is OAuth-protected: the client authenticates with an Authorization: Bearer access token, and the per-call payment is read in band from the MCP request _meta[\"x402/payment\"]. A header-only payment (no _meta) still works as a deprecated fallback for one release.", + "Nevermined handles the Express server, sessions, OAuth discovery endpoints, credit checks and deduction — you expose the capability.", + ], + flow: [ + { label: "Bearer session", sub: "OAuth" }, + { label: "call tool", sub: "_meta x402/payment", emphasis: true }, + { label: "check + deduct", sub: "credits" }, + { label: "result", sub: "weather" }, + ], + }, + tech: { + stack: ["TypeScript", "Model Context Protocol", "@nevermined-io/payments ≥ 1.9", "Streamable HTTP"], + samples: [ + { + caption: "the capabilities this server exposes", + lang: "text", + code: `weather.today(city) # tool +weather://today/{city} # resource +weather.ensureCity # prompt`, + }, + ], + files: [ + { path: "src/main.ts", desc: "MCP server with Nevermined Payments (withPaywall wrapper)" }, + { path: "src/services/weather.service.ts", desc: "weather data via Open-Meteo" }, + { path: "RUN.md", desc: "setup and running instructions" }, + ], + }, + run: { + kind: "live", + present: "transcript", + paymentPill: "MCP · x402 v2", + transcript: [ + { t: "initialize session Authorization: Bearer …", kind: "req" }, + { t: "tools/call weather.today { city: \"Lisbon\" }", kind: "req" }, + { t: " no _meta[x402/payment] → 402", kind: "r402" }, + { t: "tools/call _meta: { \"x402/payment\": … }", kind: "req" }, + { t: " check + deduct credits", kind: "dim" }, + { t: "← result { tempC: 21, summary: \"clear\" }", kind: "r200" }, + { t: "credits deducted · call authorized", kind: "settle" }, + ], + note: "Streamable-HTTP MCP, compatible with MCP Inspector; the sandbox runs the gated tool call locally. See the README to connect a hosted weather-mcp server.", + }, + }, + + // ─────────────────────────────── 8. Weather MCP (PY) ─────────────────────── + { + slug: "weather-mcp-py", + title: "The MCP paywall, in Python", + tagline: + "The Python equivalent of the Weather MCP server, built on FastMCP with the payments-py SDK — same tool, resource and prompt, same x402 v2 in-band transport.", + protocol: "mcp", + language: "py", + tier: "live", + repoPath: "mcp-examples/weather-mcp-py/", + learn: { + lead: "Everything the TypeScript MCP tutorial teaches, on FastMCP and payments-py.", + bullets: [ + "Protect a Python MCP server with Nevermined using FastMCP", + "Serve full OAuth 2.1 discovery (RFC 8414 / 9728) endpoints", + "Pay per use with credits via x402 tokens", + "Optionally enrich forecasts with OpenAI gpt-4o-mini", + ], + }, + how: { + paragraphs: [ + "Same protocol surface as the TypeScript server — weather.today tool, weather://today resource, weather.ensureCity prompt — with the Bearer-authenticated session and in-band _meta payment. Requires payments-py[fastapi] ≥ 1.15.", + ], + flow: [ + { label: "Bearer session", sub: "OAuth 2.1" }, + { label: "call tool", sub: "_meta x402/payment", emphasis: true }, + { label: "check + deduct", sub: "credits" }, + { label: "result", sub: "weather" }, + ], + }, + tech: { + stack: ["Python 3.10+", "FastMCP", "payments-py[fastapi] ≥ 1.15", "Poetry", "OpenAI"], + samples: [ + { + caption: "MCP surface", + lang: "text", + code: `weather.today # tool — current weather for any city +weather://today # resource — static weather data +weather.ensureCity # prompt — guide the LLM to request weather`, + }, + ], + files: [ + { path: "src/", desc: "FastMCP server with Nevermined payments + OAuth discovery" }, + { path: "README.md", desc: "installation and configuration" }, + ], + }, + run: { + kind: "live", + present: "transcript", + paymentPill: "MCP · x402 v2", + transcript: [ + { t: "initialize session Authorization: Bearer …", kind: "req" }, + { t: "tools/call weather.today { city: \"Madrid\" }", kind: "req" }, + { t: " no _meta[x402/payment] → 402", kind: "r402" }, + { t: "tools/call _meta: { \"x402/payment\": … }", kind: "req" }, + { t: " check + deduct credits", kind: "dim" }, + { t: "← result { tempC: 28, summary: \"sunny\" }", kind: "r200" }, + { t: "credits deducted · call authorized", kind: "settle" }, + ], + note: "Same MCP flow on FastMCP (Python); the sandbox runs the exchange locally. See the README to connect a hosted weather-mcp-py server.", + }, + }, + + // ─────────────────────────────── 9. Song (recap) ────────────────────────── + { + slug: "song-from-the-headlines", + title: "Song From the Headlines", + tagline: + "An agent turns today's #1 tech headline into a finished song and album cover — discovering four services in the Nevermined Catalog and paying each one itself, across two blockchains, for about 16 cents. Zero human clicks.", + protocol: "catalog", + language: "autonomous", + tier: "recap", + repoPath: "catalog/song-from-the-headlines/", + learn: { + lead: "An agent can act on its own in a paid world — safely, because you set the limit.", + bullets: [ + "The agent holds a small capped budget, like a prepaid card with a spending limit", + "It pays each service directly, on demand, only for what it uses", + "Nothing is arranged with any provider in advance — it discovers services as it goes", + "Every purchase is a real payment recorded on a public blockchain", + ], + }, + how: { + paragraphs: [ + "One prompt, four paid steps, in order — each through a service the agent found in the Catalog and paid via the Router. Across the four it used two payment methods over two blockchains, and never once asked a human to pay.", + ], + table: { + head: ["Step", "What the agent does", "Service"], + rows: [ + ["1", "Finds today's #1 technology headline", "Brave"], + ["2", "Writes 90s-pop-anthem lyrics about it", "2s.io"], + ["3", "Turns the lyrics into a full, sung song", "Suno"], + ["4", "Paints a matching album cover", "fal.ai"], + ], + }, + }, + tech: { + stack: ["Nevermined Catalog", "the Router", "2 payment rails", "2 blockchains", "capped budget"], + samples: [ + { + caption: "the only ability it's given is the Router it can pay through", + lang: "text", + code: `"Find today's #1 tech headline, write 90s-pop-anthem lyrics + about it, generate a full song and matching album cover. + Discover every service in the Nevermined Catalog and pay + for each through the Router — never ask me to pay."`, + }, + ], + }, + run: { + kind: "recap", + video: { + src: "/media/song-from-the-headlines/song-from-the-headlines.mp4", + subtitles: [ + { src: "/media/song-from-the-headlines/song-from-the-headlines.en.vtt", srcLang: "en", label: "English", default: true }, + { src: "/media/song-from-the-headlines/song-from-the-headlines.es.vtt", srcLang: "es", label: "Español" }, + ], + caption: "song-from-the-headlines.mp4 · EN/ES subtitles", + duration: "~70s", + }, + outputs: { + cover: { src: "/media/song-from-the-headlines/album-cover.jpg", label: "album-cover.jpg" }, + audio: { src: "/media/song-from-the-headlines/song.mp3", label: "song.mp3 — the finished track" }, + }, + receipt: { + head: ["Service", "Step", "Cost"], + rows: [ + ["Brave", "today's headline", "$0.035"], + ["2s.io", "the lyrics", "$0.0025"], + ["Suno", "the song", "$0.105"], + ["fal.ai", "the album cover", "$0.003"], + ["Total", "4 vendors · 2 chains", "~$0.16"], + ], + totalRow: 4, + }, + warn: "Real money — the run-it-yourself script spends ~$0.16 on live blockchains, capped at 50¢ and 10 minutes so it can't overspend. That's why this tutorial is watch-only in the browser.", + interactive: [ + { label: "Interactive showcase", href: "https://claude.ai/code/artifact/160b776a-65c5-4059-be76-e8972190df89" }, + ], + takes: [ + { + label: "Rod's take", + byline: "An alternative recap of the very same run — same story, a different design — by Rod.", + embedHref: "https://claude.ai/code/artifact/0a515c41-79e5-4a12-bd60-fe7805ffba25", + }, + ], + }, + }, + + // ─────────────────────────────── 10. Diligence (recap) ──────────────────── + { + slug: "diligence-in-a-box", + title: "Diligence in a Box", + tagline: + "One prompt → a VC-grade investment memo on a startup. The agent finds and pays each data source through the Router itself — company overview, founder deep-dive, hiring momentum, SEC filings, web research. About 49 cents, zero clicks.", + protocol: "catalog", + language: "autonomous", + tier: "recap", + repoPath: "catalog/diligence-in-a-box/", + learn: { + lead: "It replaces an analyst's morning of tab-hopping with one prompt.", + bullets: [ + "Give the agent a small prepaid budget and a single instruction", + "It finds the data sources it needs and pays each one directly", + "No accounts to create, no API keys to wire up, no buttons to click along the way", + "It returns a finished memo, plus an on-chain receipt of everything it paid for", + ], + }, + how: { + paragraphs: [ + "The agent assembles a company + funding overview, a founder deep-dive on the CEO, hiring & news momentum, any SEC filings, and web/product research — discovering every source in the Nevermined Catalog and paying for each through the Router.", + ], + }, + tech: { + stack: ["Nevermined Catalog", "the Router", "multi-source", "capped budget"], + samples: [ + { + caption: "the prompt handed to the agent", + lang: "text", + code: `"Build me an investment memo on Perplexity (perplexity.ai): + a company + funding overview, a founder deep-dive on the CEO, + hiring & news momentum, any SEC filings, and web/product + research. Discover every source in the Nevermined Catalog and + pay for each through the Router — never ask me to pay."`, + }, + ], + }, + run: { + kind: "recap", + video: { + src: "/media/diligence-in-a-box/diligence-in-a-box.mp4", + subtitles: [ + { src: "/media/diligence-in-a-box/diligence-in-a-box.en.vtt", srcLang: "en", label: "English", default: true }, + { src: "/media/diligence-in-a-box/diligence-in-a-box.es.vtt", srcLang: "es", label: "Español" }, + ], + caption: "diligence-in-a-box.mp4 · EN/ES subtitles", + duration: "~80s", + }, + warn: "Real money — this demo ran live on public blockchains (target: Perplexity, total ~$0.49). Watch-only in the browser; run it yourself from the repo with a small capped budget.", + interactive: [ + { label: "Explore the memo it produced", href: "https://claude.ai/code/artifact/8f5df009-8a3f-4d39-86dd-5f1c61efb22b" }, + ], + }, + }, +]; + +export function getTutorial(slug: string): Tutorial | undefined { + return tutorials.find((t) => t.slug === slug); +} + +// Sidebar / index grouping — fixed group order, items keep content-array order. +export const GROUP_ORDER: { label: string; protocol: Protocol }[] = [ + { label: "Catalog", protocol: "catalog" }, + { label: "x402 HTTP", protocol: "x402" }, + { label: "MCP", protocol: "mcp" }, + { label: "LangChain", protocol: "langchain" }, +]; + +export function groupedTutorials(): { label: string; protocol: Protocol; items: Tutorial[] }[] { + return GROUP_ORDER.map(({ label, protocol }) => ({ + label, + protocol, + items: tutorials.filter((t) => t.protocol === protocol), + })).filter((g) => g.items.length > 0); +} + +// ponytail: one runnable check the type system can't give us — dup slugs would +// silently collide in generateStaticParams. Runs at import (i.e. during build). +const seen = new Set(); +for (const t of tutorials) { + if (seen.has(t.slug)) throw new Error(`Duplicate tutorial slug: ${t.slug}`); + seen.add(t.slug); +} diff --git a/showcase/lib/demo-agent.mjs b/showcase/lib/demo-agent.mjs new file mode 100644 index 00000000..50c96c07 --- /dev/null +++ b/showcase/lib/demo-agent.mjs @@ -0,0 +1,233 @@ +// Sandbox agent behind the "see it run" panels. Pure logic — no HTTP, no cookies — +// so it's unit-testable and the route stays thin. It speaks the real x402 shape +// (402 without a token → authorize → 200 + settlement) with a real per-session +// credit balance, but talks to no external service and spends no real money. +// +// To point a tutorial at a REAL hosted agent instead, see app/api/agent/route.ts. + +const START_BALANCE = 100; + +/** crude "is this just chit-chat / introspection?" check for freemium agents */ +function isIntrospection(m) { + return /\b(what|who|how|help|hi|hello|hey|pricing|price|cost|can you|do you|capabilities)\b/i.test( + m, + ); +} + +function topicOf(m) { + const t = m + .replace(/^(please\s+)?(research|analyze|analyse|look\s+into|study|tell me about|give me)\s+/i, "") + .replace(/[.?!]+$/, "") + .trim(); + return t || "that market"; +} + +function cityOf(m) { + const inCity = m.match(/\bin\s+([A-Z][a-zA-ZÀ-ſ]+)/); + if (inCity) return inCity[1]; + const cap = m.match(/\b([A-Z][a-zA-ZÀ-ſ]{2,})\b/); + return cap ? cap[1] : "Lisbon"; +} + +function marketAnswer(m) { + const topic = topicOf(m); + return ( + `Market snapshot — ${topic}\n\n` + + `• Size — a multi-billion-dollar market, growing double digits year over year\n` + + `• Leaders — a few incumbents plus a cohort of fast-moving challengers\n` + + `• Momentum — hiring and funding have trended up over recent quarters\n\n` + + `Structured analysis from the paid research tool.` + ); +} + +// Per-tutorial behavior. hasFreeTier=true means introspection is free (freemium); +// otherwise every call is paid (route-level / minimal paywall tutorials). +const AGENTS = { + "langchain-research-agent": { + pill: "card delegation", + credits: 5, + hasFreeTier: true, + greeting: + "I'm a market-research agent. Ask me what I do for free; ask me to research a market and the paid tool runs. ", + suggestions: ["What can you do?", "Research the EV market in Europe"], + freeAnswer: () => + "I answer questions about myself for free. Ask me to research a market — that runs the paid tool.", + paidAnswer: marketAnswer, + }, + "langchain-deep-agent": { + pill: "budget-capped", + credits: 5, + hasFreeTier: true, + greeting: + "I'm a research supervisor. Chatting is free; asking me to research something delegates to a paid subagent.", + suggestions: ["What can you help with?", "Research the EV market in Europe"], + freeAnswer: () => + "I delegate real research to a subagent whose tool is paid. Ask me to research something to see it.", + paidAnswer: (m) => + marketAnswer(m) + "\n\nDelegated one task() hop away; the token survived the delegation.", + }, + "langchain-paid-agent": { + pill: "in-process", + credits: 1, + hasFreeTier: false, + greeting: "Ask me to use the paid tool — every invocation is gated by @requires_payment.", + suggestions: ["Use the paid tool"], + paidAnswer: (m) => + `The paid tool ran on: "${m}". Settlement receipt is on configurable["payment_settlement"].`, + }, + "http-simple-agent-ts": { + pill: "x402 v2", + credits: 1, + hasFreeTier: false, + greeting: "POST /ask is payment-gated. Send a question to run the x402 round-trip.", + suggestions: ["What's the weather in Lisbon?", "Summarize today's AI news"], + paidAnswer: (m) => `{ "answer": "Here's a concise take on: ${m}" }`, + }, + "http-simple-agent-py": { + pill: "x402 v2", + credits: 1, + hasFreeTier: false, + greeting: "POST /ask is payment-gated. Send a question to run the x402 round-trip.", + suggestions: ["What's the weather in Madrid?", "Summarize today's AI news"], + paidAnswer: (m) => `{ "answer": "Here's a concise take on: ${m}" }`, + }, + "langchain-langsmith-deployment": { + pill: "route-level", + credits: 1, + hasFreeTier: false, + greeting: "The runs/wait route is gated. Send input to run the 402 round-trip against the echo agent.", + suggestions: ["hello from the buyer"], + paidAnswer: (m) => `{ "output": "echo: ${m}" }`, + }, + "weather-mcp": { + pill: "MCP · x402 v2", + credits: 1, + hasFreeTier: false, + greeting: "Call weather.today for a city — the MCP tool is payment-gated.", + suggestions: ["What's the weather in Lisbon?", "weather.today Berlin"], + paidAnswer: (m) => { + const c = cityOf(m); + const t = 14 + (c.length % 12); + return `{ "city": "${c}", "tempC": ${t}, "summary": "clear" }`; + }, + }, + "weather-mcp-py": { + pill: "MCP · x402 v2", + credits: 1, + hasFreeTier: false, + greeting: "Call weather.today for a city — the FastMCP tool is payment-gated.", + suggestions: ["What's the weather in Madrid?", "weather.today Tokyo"], + paidAnswer: (m) => { + const c = cityOf(m); + const t = 16 + (c.length % 12); + return `{ "city": "${c}", "tempC": ${t}, "summary": "sunny" }`; + }, + }, +}; + +export function getAgent(slug) { + return AGENTS[slug]; +} + +const freshState = () => ({ authorized: false, balance: START_BALANCE }); + +/** + * @param {{authorized:boolean, balance:number}|undefined} state + * @param {{slug:string, action:"intro"|"ask"|"authorize"|"reset", message?:string}} req + * @returns {{status:number, body:object, state:{authorized:boolean,balance:number}}} + */ +export function respond(state, req) { + const s = state && typeof state.balance === "number" ? { ...state } : freshState(); + const cfg = AGENTS[req.slug]; + if (!cfg) return { status: 404, body: { error: "unknown agent" }, state: s }; + + if (req.action === "intro") { + return { + status: 200, + body: { + greeting: cfg.greeting, + suggestions: cfg.suggestions, + pill: cfg.pill, + credits: cfg.credits, + hasFreeTier: !!cfg.hasFreeTier, + authorized: s.authorized, + balance: s.balance, + }, + state: s, + }; + } + + if (req.action === "authorize") { + const next = { authorized: true, balance: s.balance }; + return { status: 200, body: { ok: true, method: "visa *4242", balance: next.balance }, state: next }; + } + + if (req.action === "reset") { + return { status: 200, body: { ok: true }, state: freshState() }; + } + + if (req.action === "ask") { + const message = (req.message || "").trim(); + if (!message) return { status: 400, body: { error: "empty message" }, state: s }; + + if (cfg.hasFreeTier && isIntrospection(message)) { + return { status: 200, body: { kind: "free", answer: cfg.freeAnswer(message) }, state: s }; + } + if (!s.authorized) { + return { + status: 402, + body: { kind: "payment_required", credits: cfg.credits, method: "card delegation" }, + state: s, + }; + } + if (s.balance < cfg.credits) { + return { status: 402, body: { kind: "insufficient", balance: s.balance }, state: s }; + } + const next = { authorized: true, balance: s.balance - cfg.credits }; + return { + status: 200, + body: { kind: "paid", answer: cfg.paidAnswer(message), credits: cfg.credits, balance: next.balance }, + state: next, + }; + } + + return { status: 400, body: { error: "unknown action" }, state: s }; +} + +// ── runnable self-check: `node lib/demo-agent.mjs` ─────────────────────────── +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop())) { + const assert = (c, m) => { + if (!c) throw new Error("FAIL: " + m); + }; + // freemium: introspection is free, no balance change, no auth needed + let r = respond(undefined, { slug: "langchain-research-agent", action: "ask", message: "What can you do?" }); + assert(r.status === 200 && r.body.kind === "free", "intro should be free"); + assert(r.state.balance === 100, "free call must not spend"); + + // paid without auth → 402 + r = respond(r.state, { slug: "langchain-research-agent", action: "ask", message: "Research the EV market" }); + assert(r.status === 402 && r.body.kind === "payment_required", "paid w/o auth → 402"); + + // authorize then pay → 200, balance -5 + r = respond(r.state, { slug: "langchain-research-agent", action: "authorize" }); + assert(r.state.authorized && r.state.balance === 100, "authorize keeps balance"); + r = respond(r.state, { slug: "langchain-research-agent", action: "ask", message: "Research the EV market" }); + assert(r.status === 200 && r.body.kind === "paid" && r.body.balance === 95, "paid → 200, -5 credits"); + + // all-paid tutorial: first ask → 402 (no free tier) + r = respond(undefined, { slug: "weather-mcp", action: "ask", message: "weather in Lisbon" }); + assert(r.status === 402, "all-paid first call → 402"); + r = respond(respond(r.state, { slug: "weather-mcp", action: "authorize" }).state, { + slug: "weather-mcp", + action: "ask", + message: "weather in Lisbon", + }); + assert(r.status === 200 && /Lisbon/.test(r.body.answer), "weather answers for the city"); + + // insufficient balance path + let st = { authorized: true, balance: 0 }; + r = respond(st, { slug: "weather-mcp", action: "ask", message: "weather in Paris" }); + assert(r.status === 402 && r.body.kind === "insufficient", "zero balance → insufficient"); + + console.log("✓ demo-agent self-check passed"); +} diff --git a/showcase/lib/repo.ts b/showcase/lib/repo.ts new file mode 100644 index 00000000..beb844a0 --- /dev/null +++ b/showcase/lib/repo.ts @@ -0,0 +1,15 @@ +// Public GitHub URL for a tutorial, derived from its repoPath. +const REPO_BASE = "https://github.com/nevermined-io/tutorials/tree/main"; + +export function repoUrl(repoPath: string): string { + return `${REPO_BASE}/${repoPath.replace(/\/+$/, "")}`; +} + +// Link to a specific file (or subdir) inside a tutorial on GitHub. +const REPO_ROOT = "https://github.com/nevermined-io/tutorials"; +export function repoFileUrl(repoPath: string, filePath: string): string { + const dir = repoPath.replace(/\/+$/, ""); + const file = filePath.replace(/^\/+/, ""); + const kind = file.endsWith("/") ? "tree" : "blob"; + return `${REPO_ROOT}/${kind}/main/${dir}/${file.replace(/\/+$/, "")}`; +} diff --git a/showcase/lib/types.ts b/showcase/lib/types.ts new file mode 100644 index 00000000..8a3cbff2 --- /dev/null +++ b/showcase/lib/types.ts @@ -0,0 +1,121 @@ +// Normalized content model shared by every tutorial page. +// One shape for all tutorials → uniform pages out of wildly different READMEs. + +export type Protocol = "x402" | "mcp" | "langchain" | "catalog"; +export type Language = "ts" | "py" | "autonomous"; +export type Tier = "live" | "recap"; + +export interface CodeSample { + caption?: string; + lang: string; + code: string; +} + +export interface FileRow { + path: string; + desc: string; +} + +export interface FlowStep { + label: string; + sub?: string; + emphasis?: boolean; +} + +export interface DataTable { + head: string[]; + rows: string[][]; + /** index of the row to render as a bold total, if any */ + totalRow?: number; +} + +/** Section 1 — what the tutorial teaches. */ +export interface LearnSection { + lead: string; + bullets: string[]; +} + +/** Section 2 — how it works. */ +export interface HowSection { + paragraphs: string[]; + flow?: FlowStep[]; + table?: DataTable; +} + +/** Section 3 — technical details. */ +export interface TechSection { + stack: string[]; + samples: CodeSample[]; + files?: FileRow[]; +} + +/** Section 4 (live) — a runnable panel. The panel (components/LiveRunPanel) is + * API-driven: it talks to /api/agent, whose per-tutorial behavior lives in + * lib/demo-agent.mjs (real x402 round-trips against a local sandbox — no real money). + * Only `note` is read from here now (the pill shown in the panel comes from the intro + * response, not `paymentPill`); the other fields are legacy editorial kept for reference. */ +export interface LiveRun { + kind: "live"; + present: "chat" | "transcript"; + paymentPill?: string; + /** for present==="chat" */ + chat?: { role: "user" | "agent"; text: string; free?: boolean; paid?: boolean }[]; + paidPrompt?: { title: string; body: string; cta: string }; + settle?: string; + paidAnswer?: string; + /** for present==="transcript" */ + transcript?: { t: string; kind?: "req" | "r402" | "r200" | "dim" | "settle" }[]; + /** env var holding a real backend URL; when set the panel offers a live send */ + endpointEnv?: string; + note: string; +} + +/** Section 4 (recap) — watch it run; no live backend (real money / autonomy). */ +export interface RecapRun { + kind: "recap"; + video?: { + src: string; + caption: string; + duration: string; + /** WebVTT tracks (HTML5 only accepts .vtt, not .srt) */ + subtitles?: { src: string; srcLang: string; label: string; default?: boolean }[]; + }; + outputs?: { + cover?: { src?: string; label: string }; + audio?: { src?: string; label: string }; + }; + receipt?: DataTable; + prompt?: string; + warn?: string; + interactive?: { label: string; href: string }[]; + /** additional embedded "takes" shown as tabs beside the main recap */ + takes?: { label: string; byline?: string; embedHref: string }[]; +} + +export interface Tutorial { + slug: string; + title: string; + tagline: string; + protocol: Protocol; + language: Language; + tier: Tier; + repoPath: string; + featured?: boolean; + learn: LearnSection; + how: HowSection; + tech: TechSection; + run: LiveRun | RecapRun; +} + +export const PROTOCOL_LABEL: Record = { + x402: "x402 HTTP", + mcp: "MCP", + langchain: "LangChain", + catalog: "Catalog", +}; + +export const LANGUAGE_LABEL: Record = { + ts: "ts", + py: "py", + autonomous: "autonomous", +}; diff --git a/showcase/next.config.mjs b/showcase/next.config.mjs new file mode 100644 index 00000000..e24c35c9 --- /dev/null +++ b/showcase/next.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Standalone build → a minimal self-contained server for the Docker image. + output: "standalone", + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/showcase/package-lock.json b/showcase/package-lock.json new file mode 100644 index 00000000..5e082927 --- /dev/null +++ b/showcase/package-lock.json @@ -0,0 +1,1024 @@ +{ + "name": "nevermined-tutorials-showcase", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nevermined-tutorials-showcase", + "version": "0.1.0", + "dependencies": { + "next": "^15.5.25", + "prismjs": "^1.30.0", + "react": "19.1.1", + "react-dom": "19.1.1" + }, + "devDependencies": { + "@types/node": "22.10.2", + "@types/prismjs": "^1.26.6", + "@types/react": "19.1.1", + "@types/react-dom": "19.1.1", + "typescript": "5.7.2" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.25.tgz", + "integrity": "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.25.tgz", + "integrity": "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.25.tgz", + "integrity": "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.25.tgz", + "integrity": "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.25.tgz", + "integrity": "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.25.tgz", + "integrity": "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.25.tgz", + "integrity": "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.25.tgz", + "integrity": "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.25.tgz", + "integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz", + "integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.25.tgz", + "integrity": "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.25", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.25", + "@next/swc-darwin-x64": "15.5.25", + "@next/swc-linux-arm64-gnu": "15.5.25", + "@next/swc-linux-arm64-musl": "15.5.25", + "@next/swc-linux-x64-gnu": "15.5.25", + "@next/swc-linux-x64-musl": "15.5.25", + "@next/swc-win32-arm64-msvc": "15.5.25", + "@next/swc-win32-x64-msvc": "15.5.25", + "sharp": "^0.34.3 || ^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/showcase/package.json b/showcase/package.json new file mode 100644 index 00000000..29fd580d --- /dev/null +++ b/showcase/package.json @@ -0,0 +1,27 @@ +{ + "name": "nevermined-tutorials-showcase", + "version": "0.1.0", + "private": true, + "description": "Visual showcase of the Nevermined Payments tutorials — gallery + normalized per-tutorial pages with live and recap demos.", + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "sync:media": "bash scripts/sync-media.sh" + }, + "dependencies": { + "next": "^15.5.25", + "prismjs": "^1.30.0", + "react": "19.1.1", + "react-dom": "19.1.1" + }, + "devDependencies": { + "@types/node": "22.10.2", + "@types/prismjs": "^1.26.6", + "@types/react": "19.1.1", + "@types/react-dom": "19.1.1", + "typescript": "5.7.2" + } +} diff --git a/showcase/public/media/diligence-in-a-box/diligence-in-a-box.en.srt b/showcase/public/media/diligence-in-a-box/diligence-in-a-box.en.srt new file mode 100644 index 00000000..f825d26c --- /dev/null +++ b/showcase/public/media/diligence-in-a-box/diligence-in-a-box.en.srt @@ -0,0 +1,48 @@ +1 +00:00:03,200 --> 00:00:10,810 +One prompt into Claude Code. Build me an investment +memo on Perplexity, handed to an agent with its own budget. + +2 +00:00:11,600 --> 00:00:19,607 +First it finds its own sources. Company data, founder +research, hiring and news, SEC filings, a web scrape. + +3 +00:00:20,500 --> 00:00:28,433 +Then it starts paying. Perplexity's funding and its +founders, and it picks out the CEO, Aravind Srinivas. + +4 +00:00:29,200 --> 00:00:36,717 +The founder deep-dive runs on a second payment rail, on +Base, a background job the agent polls until it lands. + +5 +00:00:37,500 --> 00:00:45,145 +Hiring momentum, the news trail, SEC filings, and a +live scrape of the site, each one discovered, each paid. + +6 +00:00:45,900 --> 00:00:54,238 +Five sources, seven payments, two rails, two chains, +every one settled on-chain under one budget. Zero clicks. + +7 +00:00:56,000 --> 00:00:57,427 +And here's the memo it wrote. + +8 +00:00:58,300 --> 00:01:06,097 +Company, founders, momentum, even the SEC signal, a +private company with heavy demand, on a single page. + +9 +00:01:07,000 --> 00:01:13,211 +Five sources, two rails, two chains, about fifty +cents, and not a single click. + +10 +00:01:14,300 --> 00:01:18,372 +This is agent-ready commerce. +Explore the Nevermined Catalog. diff --git a/showcase/public/media/diligence-in-a-box/diligence-in-a-box.en.vtt b/showcase/public/media/diligence-in-a-box/diligence-in-a-box.en.vtt new file mode 100644 index 00000000..62ab84bc --- /dev/null +++ b/showcase/public/media/diligence-in-a-box/diligence-in-a-box.en.vtt @@ -0,0 +1,50 @@ +WEBVTT + +1 +00:00:03.200 --> 00:00:10.810 +One prompt into Claude Code. Build me an investment +memo on Perplexity, handed to an agent with its own budget. + +2 +00:00:11.600 --> 00:00:19.607 +First it finds its own sources. Company data, founder +research, hiring and news, SEC filings, a web scrape. + +3 +00:00:20.500 --> 00:00:28.433 +Then it starts paying. Perplexity's funding and its +founders, and it picks out the CEO, Aravind Srinivas. + +4 +00:00:29.200 --> 00:00:36.717 +The founder deep-dive runs on a second payment rail, on +Base, a background job the agent polls until it lands. + +5 +00:00:37.500 --> 00:00:45.145 +Hiring momentum, the news trail, SEC filings, and a +live scrape of the site, each one discovered, each paid. + +6 +00:00:45.900 --> 00:00:54.238 +Five sources, seven payments, two rails, two chains, +every one settled on-chain under one budget. Zero clicks. + +7 +00:00:56.000 --> 00:00:57.427 +And here's the memo it wrote. + +8 +00:00:58.300 --> 00:01:06.097 +Company, founders, momentum, even the SEC signal, a +private company with heavy demand, on a single page. + +9 +00:01:07.000 --> 00:01:13.211 +Five sources, two rails, two chains, about fifty +cents, and not a single click. + +10 +00:01:14.300 --> 00:01:18.372 +This is agent-ready commerce. +Explore the Nevermined Catalog. diff --git a/showcase/public/media/diligence-in-a-box/diligence-in-a-box.es.srt b/showcase/public/media/diligence-in-a-box/diligence-in-a-box.es.srt new file mode 100644 index 00000000..12208edc --- /dev/null +++ b/showcase/public/media/diligence-in-a-box/diligence-in-a-box.es.srt @@ -0,0 +1,48 @@ +1 +00:00:03,200 --> 00:00:10,810 +Un solo prompt en Claude Code. Redacta un memo de inversión +sobre Perplexity, en manos de un agente con su presupuesto. + +2 +00:00:11,600 --> 00:00:19,607 +Primero encuentra sus fuentes. Datos de empresa, fundadores, +contrataciones y noticias, informes SEC, un scrape web. + +3 +00:00:20,500 --> 00:00:28,433 +Luego empieza a pagar. La financiación y los fundadores +de Perplexity, y detecta al director general, Aravind Srinivas. + +4 +00:00:29,200 --> 00:00:36,717 +El análisis del fundador corre en un segundo raíl de pago, +en Base, un proceso que el agente consulta hasta que llega. + +5 +00:00:37,500 --> 00:00:45,145 +Impulso de contratación, el rastro de noticias, informes SEC +y un scrape en vivo del sitio, cada uno hallado y pagado. + +6 +00:00:45,900 --> 00:00:54,238 +Cinco fuentes, siete pagos, dos raíles, dos cadenas, cada +uno liquidado on-chain con un presupuesto. Cero clics. + +7 +00:00:56,000 --> 00:00:57,427 +Y este es el memo que escribió. + +8 +00:00:58,300 --> 00:01:06,097 +Empresa, fundadores, impulso, hasta la señal de la SEC, +una empresa privada con fuerte demanda, en una página. + +9 +00:01:07,000 --> 00:01:13,211 +Cinco fuentes, dos raíles, dos cadenas, unos cincuenta +centavos, y ni un solo clic. + +10 +00:01:14,300 --> 00:01:18,372 +Esto es comercio listo para agentes. +Explora el catálogo de Nevermined. diff --git a/showcase/public/media/diligence-in-a-box/diligence-in-a-box.es.vtt b/showcase/public/media/diligence-in-a-box/diligence-in-a-box.es.vtt new file mode 100644 index 00000000..2013b04d --- /dev/null +++ b/showcase/public/media/diligence-in-a-box/diligence-in-a-box.es.vtt @@ -0,0 +1,50 @@ +WEBVTT + +1 +00:00:03.200 --> 00:00:10.810 +Un solo prompt en Claude Code. Redacta un memo de inversión +sobre Perplexity, en manos de un agente con su presupuesto. + +2 +00:00:11.600 --> 00:00:19.607 +Primero encuentra sus fuentes. Datos de empresa, fundadores, +contrataciones y noticias, informes SEC, un scrape web. + +3 +00:00:20.500 --> 00:00:28.433 +Luego empieza a pagar. La financiación y los fundadores +de Perplexity, y detecta al director general, Aravind Srinivas. + +4 +00:00:29.200 --> 00:00:36.717 +El análisis del fundador corre en un segundo raíl de pago, +en Base, un proceso que el agente consulta hasta que llega. + +5 +00:00:37.500 --> 00:00:45.145 +Impulso de contratación, el rastro de noticias, informes SEC +y un scrape en vivo del sitio, cada uno hallado y pagado. + +6 +00:00:45.900 --> 00:00:54.238 +Cinco fuentes, siete pagos, dos raíles, dos cadenas, cada +uno liquidado on-chain con un presupuesto. Cero clics. + +7 +00:00:56.000 --> 00:00:57.427 +Y este es el memo que escribió. + +8 +00:00:58.300 --> 00:01:06.097 +Empresa, fundadores, impulso, hasta la señal de la SEC, +una empresa privada con fuerte demanda, en una página. + +9 +00:01:07.000 --> 00:01:13.211 +Cinco fuentes, dos raíles, dos cadenas, unos cincuenta +centavos, y ni un solo clic. + +10 +00:01:14.300 --> 00:01:18.372 +Esto es comercio listo para agentes. +Explora el catálogo de Nevermined. diff --git a/showcase/public/media/song-from-the-headlines/album-cover.jpg b/showcase/public/media/song-from-the-headlines/album-cover.jpg new file mode 100644 index 00000000..17dd580d Binary files /dev/null and b/showcase/public/media/song-from-the-headlines/album-cover.jpg differ diff --git a/showcase/public/media/song-from-the-headlines/song-from-the-headlines.en.srt b/showcase/public/media/song-from-the-headlines/song-from-the-headlines.en.srt new file mode 100644 index 00000000..c9a6f9a9 --- /dev/null +++ b/showcase/public/media/song-from-the-headlines/song-from-the-headlines.en.srt @@ -0,0 +1,42 @@ +1 +00:00:03,000 --> 00:00:09,700 +This is Claude Code. One prompt — turn today's top +headline into an agent that can pay for what it needs. + +2 +00:00:10,500 --> 00:00:16,519 +First it discovers the vendors itself — +search, lyrics, music, art — in the Nevermined Catalog. + +3 +00:00:17,300 --> 00:00:22,223 +Then it starts buying — the headline, then +90s-pop lyrics — each paid through the Router. + +4 +00:00:22,800 --> 00:00:27,663 +The song renders asynchronously — about a +minute, sped up here — while the agent keeps paying. + +5 +00:00:28,300 --> 00:00:31,424 +Song, done. Then the cover — one more purchase. + +6 +00:00:32,200 --> 00:00:38,011 +Four vendors, two rails, two chains — every purchase +settled on-chain, under one budget. Zero clicks. + +7 +00:00:45,000 --> 00:00:45,913 +And here's what it made. + +8 +00:00:51,500 --> 00:00:56,074 +Four vendors, two rails, two chains, +sixteen cents — and not a single click. + +9 +00:01:04,800 --> 00:01:08,676 +This is agent-ready commerce. +Explore the Nevermined Catalog. diff --git a/showcase/public/media/song-from-the-headlines/song-from-the-headlines.en.vtt b/showcase/public/media/song-from-the-headlines/song-from-the-headlines.en.vtt new file mode 100644 index 00000000..b3094b5b --- /dev/null +++ b/showcase/public/media/song-from-the-headlines/song-from-the-headlines.en.vtt @@ -0,0 +1,44 @@ +WEBVTT + +1 +00:00:03.000 --> 00:00:09.700 +This is Claude Code. One prompt — turn today's top +headline into an agent that can pay for what it needs. + +2 +00:00:10.500 --> 00:00:16.519 +First it discovers the vendors itself — +search, lyrics, music, art — in the Nevermined Catalog. + +3 +00:00:17.300 --> 00:00:22.223 +Then it starts buying — the headline, then +90s-pop lyrics — each paid through the Router. + +4 +00:00:22.800 --> 00:00:27.663 +The song renders asynchronously — about a +minute, sped up here — while the agent keeps paying. + +5 +00:00:28.300 --> 00:00:31.424 +Song, done. Then the cover — one more purchase. + +6 +00:00:32.200 --> 00:00:38.011 +Four vendors, two rails, two chains — every purchase +settled on-chain, under one budget. Zero clicks. + +7 +00:00:45.000 --> 00:00:45.913 +And here's what it made. + +8 +00:00:51.500 --> 00:00:56.074 +Four vendors, two rails, two chains, +sixteen cents — and not a single click. + +9 +00:01:04.800 --> 00:01:08.676 +This is agent-ready commerce. +Explore the Nevermined Catalog. diff --git a/showcase/public/media/song-from-the-headlines/song-from-the-headlines.es.srt b/showcase/public/media/song-from-the-headlines/song-from-the-headlines.es.srt new file mode 100644 index 00000000..dcf51b40 --- /dev/null +++ b/showcase/public/media/song-from-the-headlines/song-from-the-headlines.es.srt @@ -0,0 +1,42 @@ +1 +00:00:03,000 --> 00:00:09,700 +Esto es Claude Code. Un solo prompt: convierte el titular +del día en un agente que puede pagar lo que necesita. + +2 +00:00:10,500 --> 00:00:16,519 +Primero descubre los proveedores por sí mismo: +búsqueda, letra, música, arte, en el catálogo de Nevermined. + +3 +00:00:17,300 --> 00:00:22,223 +Luego empieza a comprar: el titular y una letra +pop de los noventa, pagando cada uno por el Router. + +4 +00:00:22,800 --> 00:00:27,663 +La canción se genera de forma asíncrona: cerca de un +minuto, acelerado aquí, mientras el agente sigue pagando. + +5 +00:00:28,300 --> 00:00:31,424 +Canción, lista. Después la portada: una compra más. + +6 +00:00:32,200 --> 00:00:38,011 +Cuatro proveedores, dos raíles, dos cadenas: cada compra +liquidada on-chain, con un solo presupuesto. Cero clics. + +7 +00:00:45,000 --> 00:00:45,913 +Y esto es lo que creó. + +8 +00:00:51,500 --> 00:00:56,074 +Cuatro proveedores, dos raíles, dos cadenas, +dieciséis centavos, y ni un solo clic. + +9 +00:01:04,800 --> 00:01:08,676 +Esto es comercio listo para agentes. +Explora el catálogo de Nevermined. diff --git a/showcase/public/media/song-from-the-headlines/song-from-the-headlines.es.vtt b/showcase/public/media/song-from-the-headlines/song-from-the-headlines.es.vtt new file mode 100644 index 00000000..b1f8779f --- /dev/null +++ b/showcase/public/media/song-from-the-headlines/song-from-the-headlines.es.vtt @@ -0,0 +1,44 @@ +WEBVTT + +1 +00:00:03.000 --> 00:00:09.700 +Esto es Claude Code. Un solo prompt: convierte el titular +del día en un agente que puede pagar lo que necesita. + +2 +00:00:10.500 --> 00:00:16.519 +Primero descubre los proveedores por sí mismo: +búsqueda, letra, música, arte, en el catálogo de Nevermined. + +3 +00:00:17.300 --> 00:00:22.223 +Luego empieza a comprar: el titular y una letra +pop de los noventa, pagando cada uno por el Router. + +4 +00:00:22.800 --> 00:00:27.663 +La canción se genera de forma asíncrona: cerca de un +minuto, acelerado aquí, mientras el agente sigue pagando. + +5 +00:00:28.300 --> 00:00:31.424 +Canción, lista. Después la portada: una compra más. + +6 +00:00:32.200 --> 00:00:38.011 +Cuatro proveedores, dos raíles, dos cadenas: cada compra +liquidada on-chain, con un solo presupuesto. Cero clics. + +7 +00:00:45.000 --> 00:00:45.913 +Y esto es lo que creó. + +8 +00:00:51.500 --> 00:00:56.074 +Cuatro proveedores, dos raíles, dos cadenas, +dieciséis centavos, y ni un solo clic. + +9 +00:01:04.800 --> 00:01:08.676 +Esto es comercio listo para agentes. +Explora el catálogo de Nevermined. diff --git a/showcase/public/media/song-from-the-headlines/song.mp3 b/showcase/public/media/song-from-the-headlines/song.mp3 new file mode 100644 index 00000000..7a12e535 Binary files /dev/null and b/showcase/public/media/song-from-the-headlines/song.mp3 differ diff --git a/showcase/scripts/sync-media.sh b/showcase/scripts/sync-media.sh new file mode 100755 index 00000000..324d05cb --- /dev/null +++ b/showcase/scripts/sync-media.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Copy the catalog demo media into the app's public/ dir so the recap panels play. +# The large .mp4s are gitignored (see .gitignore) — run this after a fresh clone. +# ponytail: plain cp from the sibling catalog folders; no manifest to maintain. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +catalog="$here/../catalog" +dest="$here/public/media" + +copy() { # src-dir slug + local src="$catalog/$2" out="$dest/$2" + mkdir -p "$out" + # shellcheck disable=SC2086 + cp -f "$src"/*.mp4 "$src"/*.mp3 "$src"/*.jpg "$src"/*.srt "$out"/ 2>/dev/null || true + # HTML5 only accepts WebVTT, so derive .vtt from each .srt + # (WEBVTT header + comma→dot in the timestamps). + for srt in "$out"/*.srt; do + [ -e "$srt" ] || continue + # only cue-timing lines contain "-->", so anchor the comma→dot there — never touch dialogue + { printf 'WEBVTT\n\n'; sed 's/\r$//; /-->/ s/,\([0-9][0-9][0-9]\)/.\1/g' "$srt"; } > "${srt%.srt}.vtt" + done + echo "synced $2 → $(ls "$out" | tr '\n' ' ')" +} + +if [ ! -d "$catalog" ]; then + echo "catalog/ not found at $catalog — run from the showcase app inside the tutorials repo." >&2 + exit 1 +fi + +copy catalog song-from-the-headlines +copy catalog diligence-in-a-box +echo "done." diff --git a/showcase/tsconfig.json b/showcase/tsconfig.json new file mode 100644 index 00000000..f13bc903 --- /dev/null +++ b/showcase/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "ES2022"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}