branch:
client.tsx
7987 bytesRaw
import { useCallback, useState } from "react";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@cloudflare/agents-ui/hooks";
import {
  ConnectionIndicator,
  ModeToggle,
  PoweredByAgents,
  type ConnectionStatus
} from "@cloudflare/agents-ui";
import { Button, Badge, Surface, Text, Empty } from "@cloudflare/kumo";
import {
  CurrencyDollarIcon,
  ShieldCheckIcon,
  PaperPlaneRightIcon,
  TrashIcon,
  InfoIcon,
  WarningCircleIcon,
  CheckCircleIcon
} from "@phosphor-icons/react";
import { useAgent } from "agents/react";
import { nanoid } from "nanoid";
import "./styles.css";

let sessionId = localStorage.getItem("x402-session");
if (!sessionId) {
  sessionId = nanoid(8);
  localStorage.setItem("x402-session", sessionId);
}

interface FetchResult {
  text: string;
  isError: boolean;
  timestamp: number;
}

function App() {
  const [status, setStatus] = useState<ConnectionStatus>("connecting");
  const [results, setResults] = useState<FetchResult[]>([]);
  const [loading, setLoading] = useState(false);

  const agent = useAgent({
    agent: "pay-agent",
    name: sessionId!,
    onOpen: useCallback(() => setStatus("connected"), []),
    onClose: useCallback(() => setStatus("disconnected"), [])
  });

  const handleFetch = async () => {
    setLoading(true);
    try {
      const res = (await agent.call("fetchProtectedRoute", [])) as {
        text: string;
        isError: boolean;
      };
      setResults((prev) => [
        { text: res.text, isError: res.isError, timestamp: Date.now() },
        ...prev
      ]);
    } catch (err) {
      setResults((prev) => [
        {
          text: err instanceof Error ? err.message : String(err),
          isError: true,
          timestamp: Date.now()
        },
        ...prev
      ]);
    }
    setLoading(false);
  };

  return (
    <div className="h-full flex flex-col bg-kumo-base">
      <header className="px-5 py-4 border-b border-kumo-line">
        <div className="max-w-2xl mx-auto flex items-center justify-between">
          <div className="flex items-center gap-3">
            <CurrencyDollarIcon
              size={22}
              className="text-kumo-accent"
              weight="bold"
            />
            <h1 className="text-lg font-semibold text-kumo-default">
              x402 Payments
            </h1>
          </div>
          <div className="flex items-center gap-3">
            <ConnectionIndicator status={status} />
            <ModeToggle />
          </div>
        </div>
      </header>

      <main className="flex-1 overflow-auto p-5">
        <div className="max-w-2xl mx-auto space-y-6">
          <Surface className="p-4 rounded-xl ring ring-kumo-line">
            <div className="flex gap-3">
              <InfoIcon
                size={20}
                weight="bold"
                className="text-kumo-accent shrink-0 mt-0.5"
              />
              <div>
                <Text size="sm" bold>
                  HTTP Payment Gating (x402)
                </Text>
                <span className="mt-1 block">
                  <Text size="xs" variant="secondary">
                    This demo uses the{" "}
                    <code className="text-xs px-1 py-0.5 rounded bg-kumo-elevated font-mono">
                      @x402/*
                    </code>{" "}
                    libraries to gate an HTTP endpoint behind a $0.10 paywall.
                    An Agent with a test wallet pays automatically using{" "}
                    <code className="text-xs px-1 py-0.5 rounded bg-kumo-elevated font-mono">
                      wrapFetchWithPayment
                    </code>
                    . The server uses Hono middleware to verify payments.
                  </Text>
                </span>
              </div>
            </div>
          </Surface>

          {status === "disconnected" && (
            <Empty
              icon={<CurrencyDollarIcon size={32} />}
              title="Disconnected"
              description="Could not connect to the agent. Make sure the server is running."
            />
          )}

          {status === "connected" && (
            <>
              <Surface className="p-5 rounded-xl ring ring-kumo-line">
                <div className="flex items-center gap-2 mb-3">
                  <ShieldCheckIcon
                    size={18}
                    weight="bold"
                    className="text-kumo-subtle"
                  />
                  <Text size="sm" bold>
                    Protected Route
                  </Text>
                  <Badge variant="secondary">$0.10</Badge>
                </div>
                <span className="block mb-4">
                  <Text size="xs" variant="secondary">
                    The agent will fetch{" "}
                    <code className="text-xs px-1 py-0.5 rounded bg-kumo-elevated font-mono">
                      /protected-route
                    </code>{" "}
                    and automatically sign a $0.10 payment on Base Sepolia using
                    its configured wallet.
                  </Text>
                </span>
                <Button
                  variant="primary"
                  size="sm"
                  loading={loading}
                  icon={<PaperPlaneRightIcon size={14} />}
                  onClick={handleFetch}
                >
                  Fetch & Pay
                </Button>
              </Surface>

              {results.length > 0 && (
                <section>
                  <div className="flex items-center justify-between mb-3">
                    <Text size="base" bold>
                      Results
                    </Text>
                    <Button
                      variant="ghost"
                      size="sm"
                      icon={<TrashIcon size={14} />}
                      onClick={() => setResults([])}
                    >
                      Clear
                    </Button>
                  </div>
                  <div className="space-y-2">
                    {results.map((r) => (
                      <Surface
                        key={r.timestamp}
                        className={`p-3 rounded-xl ring ${r.isError ? "ring-red-500/30 bg-red-50 dark:bg-red-950/20" : "ring-kumo-line"}`}
                      >
                        <div className="flex items-start gap-2">
                          {r.isError ? (
                            <WarningCircleIcon
                              size={16}
                              weight="fill"
                              className="text-red-500 shrink-0 mt-0.5"
                            />
                          ) : (
                            <CheckCircleIcon
                              size={16}
                              weight="fill"
                              className="text-green-600 shrink-0 mt-0.5"
                            />
                          )}
                          <pre
                            className={`text-sm flex-1 whitespace-pre-wrap break-words font-mono ${r.isError ? "text-red-600 dark:text-red-400" : "text-kumo-default"}`}
                          >
                            {r.text}
                          </pre>
                          <span className="text-[10px] text-kumo-inactive tabular-nums shrink-0">
                            {new Date(r.timestamp).toLocaleTimeString()}
                          </span>
                        </div>
                      </Surface>
                    ))}
                  </div>
                </section>
              )}
            </>
          )}
        </div>
      </main>

      <footer className="border-t border-kumo-line py-3">
        <div className="flex justify-center">
          <PoweredByAgents />
        </div>
      </footer>
    </div>
  );
}

createRoot(document.getElementById("root")!).render(
  <ThemeProvider>
    <App />
  </ThemeProvider>
);