From ae5fa35a133891e2513714af4b76ae47176bbc1c Mon Sep 17 00:00:00 2001 From: tenequm Date: Thu, 11 Dec 2025 15:23:23 +0000 Subject: [PATCH 01/28] feat(docs): add ADR-0004 for Cascade Market Architecture --- docs/adr/0004-cascade-market-architecture.md | 690 +++++++++++++++++++ 1 file changed, 690 insertions(+) create mode 100644 docs/adr/0004-cascade-market-architecture.md diff --git a/docs/adr/0004-cascade-market-architecture.md b/docs/adr/0004-cascade-market-architecture.md new file mode 100644 index 0000000..9285db9 --- /dev/null +++ b/docs/adr/0004-cascade-market-architecture.md @@ -0,0 +1,690 @@ +# ADR-0004: Cascade Market Architecture + +**Date:** 2025-12-11 +**Status:** Accepted +**Goal:** Build "ngrok for paid MCPs" - MCP monetization platform that drives Cascade Splits adoption + +--- + +## Problem + +MCP developers need a simple way to monetize their MCPs. Currently: +- No turnkey solution for paid MCP endpoints +- Developers must implement payment handling themselves +- Revenue distribution requires custom infrastructure + +**Core Value Prop:** Developer runs one command, gets a paid MCP endpoint with automatic revenue distribution. + +--- + +## Product Hierarchy + +``` +Cascade Ecosystem +│ +├── Market (PRIMARY) ─────── Main consumer-facing product at cascade.fyi +│ └── MCP devs monetizing + Clients paying via Tabs under the hood +│ +├── Tabs (DEVELOPER TOOL) ── SDK/API for payment integration +│ └── Devs building custom x402 integrations +│ +└── Splits (DEVELOPER TOOL) ─ SDK/API for revenue splitting + └── Devs using splitting protocol directly +``` + +**Market abstracts away Tabs + Splits.** Users don't need to know they exist. Developers building custom solutions access them via `/tabs` and `/splits` routes. + +--- + +## Architecture Decisions + +### Single App with Route-Based Separation + +One unified app at `cascade.fyi` with distinct route trees: + +``` +cascade.fyi/ → Market landing + dashboard (consumer-focused) +cascade.fyi/dashboard → Services dashboard +cascade.fyi/services/new → Create service wizard +cascade.fyi/explore → Browse MCPs +cascade.fyi/pay → Client onboarding (Tabs embedded) +cascade.fyi/tabs → Tabs developer console +cascade.fyi/splits → Splits developer console +``` + +**Rationale:** Single deployment, shared wallet state, one codebase. Can migrate to separate apps later if needed. + +### Tech Stack + +| Choice | Decision | Rationale | +|--------|----------|-----------| +| **Framework** | TanStack Start | Server functions, type-safe RPC, file-based routing, TanStack Query integration | +| **Bundler** | Vite + Cloudflare plugin | Runs in actual Workers runtime locally | +| **Deployment** | Cloudflare Workers | Modern full-stack approach, D1/KV bindings | +| **Styling** | Tailwind CSS v4 | Primary styling, utility-first | +| **UI Components** | shadcn/ui sidebar template | Built on Tailwind, pre-built responsive layout | +| **Approach** | Mobile-first | Sidebar handles responsive behavior automatically | +| **Starting point** | Fresh `apps/market` | Clean slate, no legacy patterns | + +### SSR Strategy + +Minimal SSR - only for public/SEO pages: + +| Route | SSR | Why | +|-------|-----|-----| +| `/` (landing) | ✅ | SEO, social previews | +| `/explore` | ✅ | Discoverability | +| `/dashboard/*` | ❌ | Authenticated, wallet | +| `/services/*` | ❌ | Authenticated | +| `/pay` | ❌ | Wallet-heavy | +| `/tabs/*` | ❌ | Authenticated | +| `/splits/*` | ❌ | Authenticated | + +Per-route SSR control: + +```tsx +// routes/dashboard.tsx - client-only +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + ssr: false, // No SSR - runs entirely on client + component: Dashboard, +}) + +// routes/explore.tsx - server-rendered for SEO +export const Route = createFileRoute('/explore')({ + ssr: true, // Default, but explicit for clarity + loader: () => fetchPublicMCPs(), + component: Explore, +}) +``` + +### Wallet Integration + +Wallet adapters require browser APIs. Use `ClientOnly` from `@tanstack/react-router`: + +```tsx +// routes/__root.tsx +import { ClientOnly, Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router' +import { WalletProvider } from '~/components/wallet-provider' + +export const Route = createRootRoute({ + shellComponent: RootShell, + component: RootComponent, +}) + +function RootShell({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) +} + +function RootComponent() { + return ( + Loading...}> + {() => ( + + + + )} + + ) +} +``` + +Vite config (no polyfills needed with `@solana/kit`): + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import { cloudflare } from '@cloudflare/vite-plugin' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' } }), + tanstackStart(), + viteReact(), + ], +}) +``` + +> **Note:** Using `@solana/client` and `@solana/react-hooks` from Solana Kit (web3.js v2) - fully browser-native, no Node.js polyfills required. + +### Why Not Refactor Existing Apps? + +- `apps/dashboard` and `apps/tabs` have their own patterns and quirks +- Refactoring = fighting existing decisions +- Fresh start = faster, cleaner, fewer bugs + +--- + +## Overview + +``` +Developer Experience: + +$ cascade --token csc_xxx localhost:3000 + +✓ Authenticated: twitter-research +✓ Split: 7xK9...3mP → your-wallet.sol +✓ Price: $0.001/call +✓ Live at: https://twitter-research.mcps.cascade.fyi + +Dashboard: https://cascade.fyi/dashboard +``` + +**What happens behind the scenes:** +1. CLI establishes tunnel to Cascade edge +2. Platform already created Cascade Split (dev = 99%, protocol = 1%) during registration +3. Public URL assigned, MCP discoverable +4. Incoming requests: no payment → 402, payment → verify → forward +5. Settlements go to split vault (USDC) +6. Platform batches `execute_split()` periodically +7. Dev sees analytics in dashboard + +--- + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ CLIENT FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Client has Tabs account (Squads smart account + spending limit) │ +│ 2. Client uses tabsFetch() to call paid MCP │ +│ │ +│ tabsFetch("https://twitter.mcps.cascade.fyi/mcp", { │ +│ tabsApiKey: "tabs_..." │ +│ }) │ +│ │ +│ 3. On 402 (payTo = split_vault): │ +│ └── tabsFetch calls tabs.cascade.fyi/api/settle │ +│ └── Tabs builds useSpendingLimit tx (smart_account → split_vault) │ +│ └── Returns signed tx │ +│ │ +│ 4. tabsFetch retries with PAYMENT-SIGNATURE header │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ MCP GATEWAY │ +│ *.mcps.cascade.fyi │ +│ (Part of Market App deployment - Hono + Durable Objects) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ x402HTTPResourceServer (from @x402/hono) │ +│ ├── Dynamic payTo: lookup split_vault by subdomain │ +│ ├── Dynamic price: lookup price from service registry │ +│ ├── Bazaar extension: advertise MCP for discovery │ +│ └── onAfterSettle hook: record payment for split execution │ +│ │ +│ HTTPFacilitatorClient → tabs.cascade.fyi │ +│ └── Verifies smart wallet (Squads) payment transactions │ +│ │ +│ TunnelRelay (Durable Object with WebSocket Hibernation) │ +│ └── Forward verified requests to developer's MCP │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ Payment lands in split vault + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ CASCADE SPLITS │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Split Vault (USDC ATA owned by SplitConfig PDA) │ +│ ├── Recipients: [ {dev_address, 99%} ] │ +│ └── Protocol fee: 1% (Cascade) │ +│ │ +│ Platform batches execute_split() periodically │ +│ └── Distributes vault balance to configured recipients │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Tabs vs Gateway (Different x402 Roles) + +| | Tabs (`tabs.cascade.fyi`) | Gateway (`*.mcps.cascade.fyi`) | +|---|---|---| +| **x402 Role** | Client facilitator | Resource server | +| **What it does** | Builds spending limit tx for payers | Routes payments to split vaults | +| **Who calls it** | tabsFetch() in client apps | MCP clients making requests | +| **Position in flow** | Before payment sent | After payment received | + +Tabs remains separate - it's general-purpose x402 client infrastructure, not specific to Cascade Market. + +--- + +## Existing Infrastructure + +| Component | Status | Location | +|-----------|--------|----------| +| **Cascade Splits** | ✅ Deployed | `SPL1T3rERcu6P6dyBiG7K8LUr21CssZqDAszwANzNMB` | +| **Cascade Tabs** | ✅ Deployed | `tabs.cascade.fyi` | +| **tabs-sdk** | ✅ Published | `@cascade-fyi/tabs-sdk` | +| **splits-sdk** | ✅ Published | `@cascade-fyi/splits-sdk` | + +--- + +## Components to Build + +| Component | Description | Tech | +|-----------|-------------|------| +| **Market App** | Dashboard + Gateway (single deployment) | TanStack Start + Hono + Durable Objects | +| **cascade CLI** | Tunnel client, connects to gateway | Node.js (can port to Go later) | + +> **Note:** Market App and Gateway are a single Cloudflare Workers deployment. +> TanStack Start handles `cascade.fyi` (dashboard, server functions). +> Hono handles `*.mcps.cascade.fyi` (x402 payments, tunnels). +> Routing by hostname in custom server entry. Can extract Gateway later if needed. + +--- + +## Directory Structure + +``` +cascade-splits/ +├── apps/ +│ └── market/ # Single deployment: cascade.fyi + *.mcps.cascade.fyi +│ ├── src/ +│ │ ├── routes/ # TanStack Start file-based routes +│ │ │ ├── __root.tsx # Root layout with SidebarProvider +│ │ │ ├── index.tsx # Landing page +│ │ │ ├── dashboard.tsx # Services overview +│ │ │ ├── services/ +│ │ │ │ ├── index.tsx # Services list +│ │ │ │ ├── new.tsx # Create service wizard +│ │ │ │ └── $id.tsx # Service detail +│ │ │ ├── explore.tsx # Browse MCPs +│ │ │ ├── pay.tsx # Client onboarding (embedded Tabs) +│ │ │ ├── tabs/ # Tabs developer console +│ │ │ └── splits/ # Splits developer console +│ │ │ +│ │ ├── components/ +│ │ │ ├── app-sidebar.tsx # shadcn sidebar +│ │ │ ├── nav-main.tsx +│ │ │ ├── nav-user.tsx +│ │ │ └── ... +│ │ │ +│ │ ├── server/ # Server functions (D1 CRUD) +│ │ │ ├── services.ts # createService, getServices, etc. +│ │ │ └── tokens.ts # Token generation/validation +│ │ │ +│ │ ├── gateway/ # Hono app for *.mcps.cascade.fyi +│ │ │ ├── index.ts # x402HTTPResourceServer + routing +│ │ │ └── tunnel.ts # TunnelRelay Durable Object +│ │ │ +│ │ ├── server.ts # Custom server entry (hostname routing) +│ │ ├── router.tsx # TanStack Router config +│ │ └── styles.css +│ │ +│ ├── public/ +│ ├── package.json +│ ├── vite.config.ts +│ └── wrangler.jsonc +│ +├── packages/ +│ ├── cascade-cli/ # CLI (Node.js initially) +│ │ ├── src/ +│ │ │ ├── index.ts +│ │ │ ├── tunnel.ts +│ │ │ └── config.ts +│ │ └── package.json +│ ├── tabs-sdk/ # Existing +│ └── splits-sdk/ # Existing +│ +└── programs/ + └── cascade-splits/ # Solana program +``` + +--- + +## Server Entry Point + +Custom server entry routes requests by hostname: + +```typescript +// apps/market/src/server.ts +import handler, { createServerEntry } from '@tanstack/react-start/server-entry' +import { gatewayApp } from './gateway' + +export default createServerEntry({ + async fetch(request, env) { + const url = new URL(request.url); + + // Gateway: *.mcps.cascade.fyi → Hono (x402, tunnels) + if (url.hostname.endsWith('.mcps.cascade.fyi')) { + return gatewayApp.fetch(request, env); + } + + // Market: cascade.fyi → TanStack Start (dashboard, server functions) + return handler.fetch(request, { context: { env } }); + }, +}) +``` + +**Why this pattern:** +- Single deployment, single wrangler config +- Gateway can be extracted to separate app later (just move `src/gateway/`) +- Both access same D1 database (appropriate for single-team MVP) +- Durable Objects defined in wrangler.jsonc, work with either entry point + +--- + +## UI Structure + +### Sidebar Navigation + +```tsx +// Market section (consumer-focused) +const marketNav = [ + { title: "Dashboard", url: "/dashboard", icon: LayoutDashboard }, + { title: "My Services", url: "/services", icon: Server }, + { title: "Explore", url: "/explore", icon: Search }, +] + +// Developer tools section +const devNav = [ + { title: "Tabs", url: "/tabs", icon: CreditCard }, + { title: "Splits", url: "/splits", icon: GitBranch }, +] + +// Sidebar footer = wallet button + user menu +``` + +### Responsive Behavior (handled by shadcn) + +- **Mobile**: Sidebar becomes off-canvas drawer (hamburger trigger) +- **Desktop**: Persistent sidebar, collapsible to icons +- **State**: Persisted via cookie + +--- + +## Developer Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 1. Developer visits cascade.fyi │ +│ └── Sees landing page with value prop │ +│ └── Connects Solana wallet │ +│ │ +│ 2. Navigates to Dashboard → "Create Service" │ +│ └── Name: "twitter-research" │ +│ └── Price: $0.001/call │ +│ └── (Receiving address = wallet by default) │ +│ │ +│ 3. Dashboard creates Cascade Split │ +│ └── createSplitConfig({ │ +│ authority: platform_authority, // For execute_split │ +│ mint: USDC, │ +│ recipients: [{ address: dev_wallet, percentage_bps: 9900 }], │ +│ unique_id: derived_from_service_id │ +│ }) │ +│ └── Dev signs tx, pays ~$2 rent (refundable) │ +│ │ +│ 4. Success modal shows: │ +│ └── API token: csc_xxx │ +│ └── CLI command: cascade --token csc_xxx localhost:3000 │ +│ └── Public URL: https://twitter-research.mcps.cascade.fyi │ +│ │ +│ 5. Developer runs CLI locally: │ +│ │ +│ $ cascade --token csc_xxx localhost:3000 │ +│ │ +│ ✓ Authenticated: twitter-research │ +│ ✓ Live at: https://twitter-research.mcps.cascade.fyi │ +│ │ +│ 6. Dashboard shows: │ +│ └── Status: 🟢 Online │ +│ └── Stats: calls, revenue, pending distribution │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Client Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 1. Client discovers MCP on cascade.fyi/explore │ +│ │ +│ 2. Clicks "Use this MCP" → redirected to /pay if no Tabs account │ +│ │ +│ 3. /pay page (embedded Tabs onboarding): │ +│ └── Create Squads smart account │ +│ └── Deposit USDC │ +│ └── Set daily spending limit │ +│ └── Get API key: tabs_xxx │ +│ │ +│ 4. Client uses tabsFetch() in their code: │ +│ │ +│ import { tabsFetch } from "@cascade-fyi/tabs-sdk"; │ +│ │ +│ const response = await tabsFetch( │ +│ "https://twitter-research.mcps.cascade.fyi/mcp", │ +│ { tabsApiKey: "tabs_xxx" } │ +│ ); │ +│ │ +│ 5. tabsFetch handles x402 automatically │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## API Token Design + +```typescript +interface ServiceToken { + serviceId: string; // Unique service identifier + splitConfig: string; // SplitConfig PDA address + splitVault: string; // Vault ATA address (payTo) + price: string; // Price per call in USDC base units + createdAt: number; // Timestamp + signature: string; // Platform signature for verification +} + +// Encoded as: csc_ +// CLI sends token to Gateway for tunnel authentication +// Gateway verifies token (checks signature field to ensure platform issued it) +``` + +--- + +## x402 Integration + +The MCP Gateway uses `x402HTTPResourceServer` from `@x402/hono` with dynamic routing: + +```typescript +// apps/market/src/gateway/index.ts +import { Hono } from "hono"; +import { paymentMiddleware, x402ResourceServer } from "@x402/hono"; +import { HTTPFacilitatorClient } from "@x402/http"; +import { enableBazaar } from "@x402/extensions/bazaar"; + +const app = new Hono<{ Bindings: Env }>(); + +// Service registry lookup (from D1) +async function getServiceBySubdomain(subdomain: string, db: D1Database) { + return db.prepare( + "SELECT split_vault, price, name FROM services WHERE name = ?" + ).bind(subdomain).first(); +} + +// Configure x402 resource server +const x402Server = new x402ResourceServer({ + facilitatorClient: new HTTPFacilitatorClient("https://tabs.cascade.fyi/api"), + + // Dynamic payTo: route payments to split vault by subdomain + payTo: async (context) => { + const subdomain = context.adapter.getHeader("host")?.split(".")[0]; + const service = await getServiceBySubdomain(subdomain!, context.env.DB); + return service?.split_vault; + }, + + // Dynamic price: lookup from service registry + price: async (context) => { + const subdomain = context.adapter.getHeader("host")?.split(".")[0]; + const service = await getServiceBySubdomain(subdomain!, context.env.DB); + return service?.price ?? "1000"; // Default $0.001 + }, + + hooks: { + // Record payment for split execution + onAfterSettle: async (context, payment) => { + const subdomain = context.adapter.getHeader("host")?.split(".")[0]; + await context.env.DB.prepare( + "UPDATE services SET pending_balance = pending_balance + ?, total_calls = total_calls + 1 WHERE name = ?" + ).bind(payment.amount, subdomain).run(); + }, + }, +}); + +// Enable MCP discovery via Bazaar extension +enableBazaar(x402Server, { + async getResources(context) { + const services = await context.env.DB + .prepare("SELECT name, price FROM services WHERE status = 'online'") + .all(); + return services.results.map((s) => ({ + name: s.name, + price: s.price, + endpoint: `https://${s.name}.mcps.cascade.fyi/mcp`, + })); + }, +}); + +// Apply payment middleware to MCP routes +app.use("/mcp/*", paymentMiddleware(x402Server)); + +// Forward verified requests to developer's MCP via tunnel +app.all("/mcp/*", async (c) => { + const subdomain = c.req.header("host")?.split(".")[0]; + const tunnelId = c.env.TUNNEL_RELAY.idFromName(subdomain!); + const tunnel = c.env.TUNNEL_RELAY.get(tunnelId); + return tunnel.fetch(c.req.raw); +}); + +export default app; +``` + +### Key x402 Patterns Used + +| Pattern | Usage | +|---------|-------| +| **Dynamic payTo** | Route payments to per-service split vaults | +| **Dynamic price** | Per-service pricing from D1 | +| **HTTPFacilitatorClient** | Delegate verify/settle to tabs.cascade.fyi (understands smart wallet payments) | +| **Bazaar extension** | Advertise MCPs for client/agent discovery | +| **onAfterSettle hook** | Update cached stats in D1 for dashboard | + +--- + +## Database Schema (D1) + +```sql +-- Services (one per MCP registration) +CREATE TABLE services ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, -- Subdomain: "twitter-research" + owner_address TEXT NOT NULL, -- Developer's Solana wallet + + -- Cascade Split + split_config TEXT NOT NULL, -- SplitConfig PDA + split_vault TEXT NOT NULL, -- Vault ATA (payTo address) + + -- Pricing + price TEXT NOT NULL, -- USDC base units per call + + -- State + status TEXT DEFAULT 'offline', -- online/offline + tunnel_id TEXT, -- Active tunnel connection + + -- Stats (denormalized for fast reads) + total_calls INTEGER DEFAULT 0, + total_revenue TEXT DEFAULT '0', + pending_balance TEXT DEFAULT '0', + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW(), + last_connected_at TIMESTAMP, + last_executed_at TIMESTAMP -- Last execute_split +); + +-- Index for split executor +CREATE INDEX idx_services_pending ON services(pending_balance, last_executed_at) + WHERE pending_balance > '0'; + +-- Note: Payment history queried from on-chain indexer (Helius/Solscan), not duplicated here +``` + +--- + +## Implementation Order + +1. **Market App Scaffold** - TanStack Start + Vite + Cloudflare + shadcn sidebar +2. **Landing + Dashboard UI** - Basic routes and navigation +3. **Service Creation Flow** - Server functions → Split creation → Token generation +4. **Gateway Integration** - Add gateway/ with x402HTTPResourceServer + TunnelRelay DO +5. **CLI** - Node.js tunnel client (packages/cascade-cli) +6. **Client Onboarding** - Embedded Tabs flow at /pay +7. **Explore Page** - MCP discovery (backed by Bazaar extension) + +--- + +## Key Decisions + +1. **Solana only** - Simplifies everything, uses existing Tabs + Splits infrastructure + +2. **Single app with route separation** - Simpler than multiple apps, can split later + +3. **TanStack Start** - Server functions for type-safe D1 CRUD, collocated server/client code, built-in TanStack Query integration + +4. **shadcn sidebar template** - Pre-built responsive layout, removes maintenance burden + +5. **Mobile-first** - Sidebar handles responsive behavior automatically + +6. **Fresh app from scratch** - Cleaner than refactoring existing dashboard/tabs apps + +7. **Developer pays rent** - ~$2 registration (refundable), natural skin in game + +8. **Tabs facilitator for everything** - `tabs.cascade.fyi` handles both client-side settlement (tabsFetch) AND Gateway payment verification (understands smart wallet transactions) + +9. **Batched execute_split (deferred)** - Platform bears gas cost (covered by 1%), implement later + +10. **Streamable HTTP only** - No stdio MCP support, modern transport only + +11. **Single deployment for Market + Gateway** - TanStack Start handles cascade.fyi, Hono handles *.mcps.cascade.fyi, hostname routing in server.ts. Can extract Gateway later if needed. + +12. **x402HTTPResourceServer with dynamic payTo** - Route payments to per-service split vaults using subdomain lookup + +13. **Tabs stays separate** - Different x402 role (client facilitator vs resource server), remains general-purpose infrastructure + +14. **Shared D1 access** - Both dashboard and gateway read/write same D1 database directly. Appropriate for single-team MVP. Add API layer later if organizational boundaries require it. + +15. **Component strategy** - Fresh shadcn install in market app with same config as dashboard (new-york style, slate base, OKLCH colors). Copy `index.css` color tokens from dashboard for visual consistency. Consolidate to shared `packages/ui` later when both apps stabilize. + +16. **Minimal SSR** - Only landing (`/`) and explore (`/explore`) pages use SSR for SEO. All authenticated/wallet routes use `ssr: false` to avoid hydration complexity. + +--- + +## Future Considerations (Deferred) + +- **Split Executor** - Batch `execute_split()` service (CF Queue + Worker) for automatic revenue distribution +- **Shared UI package** - Extract common components to `packages/ui` once market app stabilizes +- ERC-8004 integration for on-chain discovery/reputation +- Multi-chain support (Base EVM) +- Custom split configurations (revenue sharing with API providers) +- Subscription/tiered pricing models +- Advanced Bazaar features (capability descriptions, categories, ratings) From c59527f1dfd4ce5ee1b906f6627e99e76a28460d Mon Sep 17 00:00:00 2001 From: tenequm Date: Thu, 11 Dec 2025 17:39:20 +0000 Subject: [PATCH 02/28] feat: implement initial app scaffold --- apps/market/.cta.json | 12 + apps/market/.gitignore | 17 + apps/market/.vscode/settings.json | 11 + apps/market/README.md | 290 ++ apps/market/components.json | 22 + apps/market/package.json | 70 + apps/market/public/manifest.json | 25 + apps/market/public/robots.txt | 3 + apps/market/public/water-wave-cascade.svg | 3 + apps/market/schema.sql | 53 + apps/market/src/components/About.tsx | 152 + apps/market/src/components/Dashboard.tsx | 148 + apps/market/src/components/Header.tsx | 193 ++ .../src/components/solana/wallet-button.tsx | 91 + .../market/src/components/ui/alert-dialog.tsx | 155 + apps/market/src/components/ui/avatar.tsx | 53 + apps/market/src/components/ui/badge.tsx | 46 + apps/market/src/components/ui/button.tsx | 60 + apps/market/src/components/ui/card.tsx | 92 + apps/market/src/components/ui/dialog.tsx | 141 + .../src/components/ui/dropdown-menu.tsx | 255 ++ apps/market/src/components/ui/empty.tsx | 104 + .../src/components/ui/error-boundary.tsx | 64 + apps/market/src/components/ui/field.tsx | 248 ++ apps/market/src/components/ui/input.tsx | 21 + apps/market/src/components/ui/label.tsx | 22 + apps/market/src/components/ui/separator.tsx | 26 + apps/market/src/components/ui/sheet.tsx | 139 + apps/market/src/components/ui/skeleton.tsx | 13 + apps/market/src/components/ui/sonner.tsx | 36 + apps/market/src/components/ui/table.tsx | 114 + apps/market/src/components/ui/tooltip.tsx | 59 + apps/market/src/gateway/index.ts | 160 ++ apps/market/src/gateway/tunnel.ts | 226 ++ apps/market/src/hooks/use-mobile.ts | 21 + apps/market/src/lib/utils.ts | 14 + apps/market/src/routeTree.gen.ts | 177 ++ apps/market/src/router.tsx | 15 + apps/market/src/routes/__root.tsx | 131 + apps/market/src/routes/about.tsx | 6 + apps/market/src/routes/dashboard.tsx | 7 + apps/market/src/routes/index.tsx | 15 + apps/market/src/routes/services/$id.tsx | 184 ++ apps/market/src/routes/services/index.tsx | 6 + apps/market/src/routes/services/new.tsx | 215 ++ apps/market/src/server.ts | 37 + apps/market/src/server/services.ts | 207 ++ apps/market/src/server/tokens.ts | 204 ++ apps/market/src/styles.css | 120 + apps/market/tsconfig.json | 28 + apps/market/tsconfig.tsbuildinfo | 1 + apps/market/vite.config.ts | 23 + apps/market/wrangler.jsonc | 45 + biome.json | 2 + pnpm-lock.yaml | 2541 ++++++++++++++++- 55 files changed, 7034 insertions(+), 89 deletions(-) create mode 100644 apps/market/.cta.json create mode 100644 apps/market/.gitignore create mode 100644 apps/market/.vscode/settings.json create mode 100644 apps/market/README.md create mode 100644 apps/market/components.json create mode 100644 apps/market/package.json create mode 100644 apps/market/public/manifest.json create mode 100644 apps/market/public/robots.txt create mode 100644 apps/market/public/water-wave-cascade.svg create mode 100644 apps/market/schema.sql create mode 100644 apps/market/src/components/About.tsx create mode 100644 apps/market/src/components/Dashboard.tsx create mode 100644 apps/market/src/components/Header.tsx create mode 100644 apps/market/src/components/solana/wallet-button.tsx create mode 100644 apps/market/src/components/ui/alert-dialog.tsx create mode 100644 apps/market/src/components/ui/avatar.tsx create mode 100644 apps/market/src/components/ui/badge.tsx create mode 100644 apps/market/src/components/ui/button.tsx create mode 100644 apps/market/src/components/ui/card.tsx create mode 100644 apps/market/src/components/ui/dialog.tsx create mode 100644 apps/market/src/components/ui/dropdown-menu.tsx create mode 100644 apps/market/src/components/ui/empty.tsx create mode 100644 apps/market/src/components/ui/error-boundary.tsx create mode 100644 apps/market/src/components/ui/field.tsx create mode 100644 apps/market/src/components/ui/input.tsx create mode 100644 apps/market/src/components/ui/label.tsx create mode 100644 apps/market/src/components/ui/separator.tsx create mode 100644 apps/market/src/components/ui/sheet.tsx create mode 100644 apps/market/src/components/ui/skeleton.tsx create mode 100644 apps/market/src/components/ui/sonner.tsx create mode 100644 apps/market/src/components/ui/table.tsx create mode 100644 apps/market/src/components/ui/tooltip.tsx create mode 100644 apps/market/src/gateway/index.ts create mode 100644 apps/market/src/gateway/tunnel.ts create mode 100644 apps/market/src/hooks/use-mobile.ts create mode 100644 apps/market/src/lib/utils.ts create mode 100644 apps/market/src/routeTree.gen.ts create mode 100644 apps/market/src/router.tsx create mode 100644 apps/market/src/routes/__root.tsx create mode 100644 apps/market/src/routes/about.tsx create mode 100644 apps/market/src/routes/dashboard.tsx create mode 100644 apps/market/src/routes/index.tsx create mode 100644 apps/market/src/routes/services/$id.tsx create mode 100644 apps/market/src/routes/services/index.tsx create mode 100644 apps/market/src/routes/services/new.tsx create mode 100644 apps/market/src/server.ts create mode 100644 apps/market/src/server/services.ts create mode 100644 apps/market/src/server/tokens.ts create mode 100644 apps/market/src/styles.css create mode 100644 apps/market/tsconfig.json create mode 100644 apps/market/tsconfig.tsbuildinfo create mode 100644 apps/market/vite.config.ts create mode 100644 apps/market/wrangler.jsonc diff --git a/apps/market/.cta.json b/apps/market/.cta.json new file mode 100644 index 0000000..3ac7000 --- /dev/null +++ b/apps/market/.cta.json @@ -0,0 +1,12 @@ +{ + "projectName": "market", + "mode": "file-router", + "typescript": true, + "tailwind": true, + "packageManager": "pnpm", + "git": false, + "addOnOptions": {}, + "version": 1, + "framework": "react-cra", + "chosenAddOns": ["start", "cloudflare"] +} diff --git a/apps/market/.gitignore b/apps/market/.gitignore new file mode 100644 index 0000000..055af72 --- /dev/null +++ b/apps/market/.gitignore @@ -0,0 +1,17 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +count.txt +.env +.nitro +.tanstack +.wrangler +.output +.vinxi +todos.json + +.dev.vars* +!.dev.vars.example +!.env.example diff --git a/apps/market/.vscode/settings.json b/apps/market/.vscode/settings.json new file mode 100644 index 0000000..00b5278 --- /dev/null +++ b/apps/market/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/apps/market/README.md b/apps/market/README.md new file mode 100644 index 0000000..a4739fd --- /dev/null +++ b/apps/market/README.md @@ -0,0 +1,290 @@ +Welcome to your new TanStack app! + +# Getting Started + +To run this application: + +```bash +pnpm install +pnpm start +``` + +# Building For Production + +To build this application for production: + +```bash +pnpm build +``` + +## Testing + +This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with: + +```bash +pnpm test +``` + +## Styling + +This project uses [Tailwind CSS](https://tailwindcss.com/) for styling. + + + + +## Routing +This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`. + +### Adding A Route + +To add a new route to your application just add another a new file in the `./src/routes` directory. + +TanStack will automatically generate the content of the route file for you. + +Now that you have two routes you can use a `Link` component to navigate between them. + +### Adding Links + +To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`. + +```tsx +import { Link } from "@tanstack/react-router"; +``` + +Then anywhere in your JSX you can use it like so: + +```tsx +About +``` + +This will create a link that will navigate to the `/about` route. + +More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent). + +### Using A Layout + +In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `` component. + +Here is an example layout that includes a header: + +```tsx +import { Outlet, createRootRoute } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' + +import { Link } from "@tanstack/react-router"; + +export const Route = createRootRoute({ + component: () => ( + <> +
+ +
+ + + + ), +}) +``` + +The `` component is not required so you can remove it if you don't want it in your layout. + +More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts). + + +## Data Fetching + +There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered. + +For example: + +```tsx +const peopleRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/people", + loader: async () => { + const response = await fetch("https://swapi.dev/api/people"); + return response.json() as Promise<{ + results: { + name: string; + }[]; + }>; + }, + component: () => { + const data = peopleRoute.useLoaderData(); + return ( +
    + {data.results.map((person) => ( +
  • {person.name}
  • + ))} +
+ ); + }, +}); +``` + +Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters). + +### React-Query + +React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze. + +First add your dependencies: + +```bash +pnpm add @tanstack/react-query @tanstack/react-query-devtools +``` + +Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`. + +```tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// ... + +const queryClient = new QueryClient(); + +// ... + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + + root.render( + + + + ); +} +``` + +You can also add TanStack Query Devtools to the root route (optional). + +```tsx +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; + +const rootRoute = createRootRoute({ + component: () => ( + <> + + + + + ), +}); +``` + +Now you can use `useQuery` to fetch your data. + +```tsx +import { useQuery } from "@tanstack/react-query"; + +import "./App.css"; + +function App() { + const { data } = useQuery({ + queryKey: ["people"], + queryFn: () => + fetch("https://swapi.dev/api/people") + .then((res) => res.json()) + .then((data) => data.results as { name: string }[]), + initialData: [], + }); + + return ( +
+
    + {data.map((person) => ( +
  • {person.name}
  • + ))} +
+
+ ); +} + +export default App; +``` + +You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview). + +## State Management + +Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project. + +First you need to add TanStack Store as a dependency: + +```bash +pnpm add @tanstack/store +``` + +Now let's create a simple counter in the `src/App.tsx` file as a demonstration. + +```tsx +import { useStore } from "@tanstack/react-store"; +import { Store } from "@tanstack/store"; +import "./App.css"; + +const countStore = new Store(0); + +function App() { + const count = useStore(countStore); + return ( +
+ +
+ ); +} + +export default App; +``` + +One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates. + +Let's check this out by doubling the count using derived state. + +```tsx +import { useStore } from "@tanstack/react-store"; +import { Store, Derived } from "@tanstack/store"; +import "./App.css"; + +const countStore = new Store(0); + +const doubledStore = new Derived({ + fn: () => countStore.state * 2, + deps: [countStore], +}); +doubledStore.mount(); + +function App() { + const count = useStore(countStore); + const doubledCount = useStore(doubledStore); + + return ( +
+ +
Doubled - {doubledCount}
+
+ ); +} + +export default App; +``` + +We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating. + +Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook. + +You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest). + +# Demo files + +Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed. + +# Learn More + +You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com). diff --git a/apps/market/components.json b/apps/market/components.json new file mode 100644 index 0000000..67b287a --- /dev/null +++ b/apps/market/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/apps/market/package.json b/apps/market/package.json new file mode 100644 index 0000000..9fd5890 --- /dev/null +++ b/apps/market/package.json @@ -0,0 +1,70 @@ +{ + "name": "market", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "build": "vite build", + "serve": "vite preview", + "test": "vitest run", + "deploy": "pnpm run build && wrangler deploy", + "preview": "pnpm run build && vite preview", + "cf-typegen": "wrangler types", + "type-check": "tsc -b", + "lint": "biome check", + "check": "pnpm type-check && biome check --write" + }, + "dependencies": { + "@cascade-fyi/splits-sdk": "workspace:*", + "@cascade-fyi/tabs-sdk": "workspace:*", + "@cloudflare/vite-plugin": "^1.17.1", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "@solana/client": "^1.1.4", + "@solana/kit": "^5.0.0", + "@solana/react-hooks": "^1.1.4", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-devtools": "^0.7.0", + "@tanstack/react-query": "^5.90.12", + "@tanstack/react-router": "^1.141.0", + "@tanstack/react-router-devtools": "^1.141.0", + "@tanstack/react-router-ssr-query": "^1.141.0", + "@tanstack/react-start": "^1.141.0", + "@tanstack/router-plugin": "^1.141.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "hono": "^4.10.8", + "lucide-react": "^0.560.0", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.17", + "vite-tsconfig-paths": "^5.1.4", + "zod": "^4.1.13" + }, + "devDependencies": { + "@tanstack/devtools-vite": "^0.3.11", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.2.0", + "@types/node": "^22.19.2", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "jsdom": "^27.0.0", + "shadcn": "^3.5.2", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vitest": "^3.0.5", + "web-vitals": "^5.1.0", + "wrangler": "^4.54.0" + } +} diff --git a/apps/market/public/manifest.json b/apps/market/public/manifest.json new file mode 100644 index 0000000..078ef50 --- /dev/null +++ b/apps/market/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "TanStack App", + "name": "Create TanStack App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/apps/market/public/robots.txt b/apps/market/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/apps/market/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/apps/market/public/water-wave-cascade.svg b/apps/market/public/water-wave-cascade.svg new file mode 100644 index 0000000..f007a99 --- /dev/null +++ b/apps/market/public/water-wave-cascade.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/market/schema.sql b/apps/market/schema.sql new file mode 100644 index 0000000..b54aefd --- /dev/null +++ b/apps/market/schema.sql @@ -0,0 +1,53 @@ +-- Cascade Market Database Schema +-- D1 SQLite database for service registry + +-- Services table (one per MCP registration) +CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, -- Subdomain: "twitter-research" + owner_address TEXT NOT NULL, -- Developer's Solana wallet + + -- Cascade Split + split_config TEXT NOT NULL, -- SplitConfig PDA + split_vault TEXT NOT NULL, -- Vault ATA (payTo address) + + -- Pricing + price TEXT NOT NULL, -- USDC base units per call + + -- State + status TEXT DEFAULT 'offline', -- online/offline + tunnel_id TEXT, -- Active tunnel connection + + -- Stats (denormalized for fast reads) + total_calls INTEGER DEFAULT 0, + total_revenue TEXT DEFAULT '0', + pending_balance TEXT DEFAULT '0', + + -- Timestamps + created_at TEXT DEFAULT (datetime('now')), + last_connected_at TEXT, + last_executed_at TEXT -- Last execute_split +); + +-- Index for owner queries +CREATE INDEX IF NOT EXISTS idx_services_owner ON services(owner_address); + +-- Index for split executor (find services with pending balance) +CREATE INDEX IF NOT EXISTS idx_services_pending ON services(pending_balance, last_executed_at) + WHERE pending_balance > '0'; + +-- Index for subdomain lookups (gateway) +CREATE INDEX IF NOT EXISTS idx_services_name ON services(name); + +-- API tokens table (for additional security, optional) +CREATE TABLE IF NOT EXISTS tokens ( + id TEXT PRIMARY KEY, + service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, -- SHA256 hash of token + created_at TEXT DEFAULT (datetime('now')), + last_used_at TEXT, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_tokens_service ON tokens(service_id); +CREATE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(token_hash); diff --git a/apps/market/src/components/About.tsx b/apps/market/src/components/About.tsx new file mode 100644 index 0000000..108e012 --- /dev/null +++ b/apps/market/src/components/About.tsx @@ -0,0 +1,152 @@ +import { Link } from "@tanstack/react-router"; +import { useWalletConnection } from "@solana/react-hooks"; +import { Terminal, Server, DollarSign, Wallet, ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function About() { + const { connect, connectors, connecting, connected } = useWalletConnection(); + + return ( +
+
+ {/* Hero */} +
+

+ Monetize your MCP in one command +

+

+ Public endpoint. Automatic revenue. No infrastructure. +

+
+ + {/* Code Block - static, tabs style colors */} +
+
+
+
+
+
+
+
+ + terminal + +
+
+
+
+ $ cascade --token{" "} + + csc_xxx + {" "} + localhost:3000 +
+
+
+ ✓ Authenticated:{" "} + twitter-research +
+
+ ✓ Split: 7xK9...3mP + {" → "} + your-wallet.sol +
+
+ ✓ Price: $0.001/call +
+
+ ✓ Live at:{" "} + + https://twitter-research.mcps.cascade.fyi + +
+
+
+ + {/* Value Props - compact 3-column grid like tabs */} +
+
+ +

One command

+

+ Run the CLI to tunnel your local MCP to a public endpoint. +

+
+ +
+ +

Public endpoint

+

+ Get a URL anyone can use. No servers to manage. +

+
+ +
+ +

Automatic revenue

+

+ Payments split directly to your wallet via Cascade Splits. +

+
+
+ + {/* CTA */} +
+ {connected ? ( + + ) : ( + <> + + + + + + {connectors.map((connector) => ( + connect(connector.id)} + > + {connector.icon && ( + {connector.name} + )} + {connector.name} + + ))} + + +

+ Powered by{" "} + + Cascade Splits + +

+ + )} +
+
+
+ ); +} diff --git a/apps/market/src/components/Dashboard.tsx b/apps/market/src/components/Dashboard.tsx new file mode 100644 index 0000000..d5e6d7e --- /dev/null +++ b/apps/market/src/components/Dashboard.tsx @@ -0,0 +1,148 @@ +import { Link } from "@tanstack/react-router"; +import { Plus, Server, Activity, DollarSign } from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyMedia, + EmptyTitle, + EmptyDescription, +} from "@/components/ui/empty"; + +interface Service { + id: string; + name: string; + status: "online" | "offline"; + price: string; + totalCalls: number; + totalRevenue: string; +} + +export function Dashboard() { + // TODO: Fetch services from server function + const services: Service[] = []; + + return ( +
+ {/* Page Header */} +
+
+

Dashboard

+

+ Manage your MCP services and track revenue +

+
+ +
+ + {/* Stats */} +
+ } + label="Services" + value={services.length.toString()} + /> + } + label="Total Calls" + value="0" + /> + } + label="Total Revenue" + value="$0.00" + /> +
+ + {/* Services List */} + + + My Services + + + {services.length === 0 ? ( + + + + + No services yet + + Create your first paid MCP endpoint to get started. + + + + ) : ( +
+ {services.map((service) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function StatCard({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( + + +
+ {icon} + {label} +
+
{value}
+
+
+ ); +} + +function ServiceRow({ service }: { service: Service }) { + return ( + +
+
+
+
{service.name}
+
+ {service.name}.mcps.cascade.fyi +
+
+
+
+
{service.totalCalls} calls
+
+ {service.price}/call +
+
+ + ); +} diff --git a/apps/market/src/components/Header.tsx b/apps/market/src/components/Header.tsx new file mode 100644 index 0000000..b1770d6 --- /dev/null +++ b/apps/market/src/components/Header.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from "react"; +import { Link, useLocation } from "@tanstack/react-router"; +import { Menu } from "lucide-react"; +import { WalletButton } from "@/components/solana/wallet-button"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Separator } from "@/components/ui/separator"; + +const navItems = [ + { title: "Dashboard", to: "/" }, + { title: "About", to: "/about" }, +]; + +function NavLink({ + to, + children, + mobile, +}: { + to: string; + children: React.ReactNode; + mobile?: boolean; +}) { + const location = useLocation(); + const isActive = location.pathname === to; + + const baseClasses = mobile + ? "block rounded-md px-3 py-2 text-base transition-colors" + : "rounded-md px-3 py-1.5 text-sm transition-colors"; + + const activeClasses = isActive + ? "bg-accent font-medium text-accent-foreground" + : "text-muted-foreground hover:text-foreground"; + + return ( + + {children} + + ); +} + +export function Header() { + const [open, setOpen] = useState(false); + const location = useLocation(); + + // Close sheet on navigation + useEffect(() => { + if (location.pathname) setOpen(false); + }, [location.pathname]); + + return ( +
+
+
+ {/* Left group: Logo + Title + Nav */} +
+ + Cascade Market logo +

+ Cascade Market +

+ + + {/* Desktop Navigation - hidden on mobile */} + +
+ + {/* Right group: Social icons + Wallet + Mobile menu */} +
+ {/* Desktop social icons - hidden on mobile */} + + + GitHub + + + GitHub + + + + X + + + X + + + {/* Wallet button - always visible */} + + + {/* Mobile menu button */} + + + + + + + Menu + +
+ + + +
+
+
+
+
+
+
+ ); +} diff --git a/apps/market/src/components/solana/wallet-button.tsx b/apps/market/src/components/solana/wallet-button.tsx new file mode 100644 index 0000000..a4cb21d --- /dev/null +++ b/apps/market/src/components/solana/wallet-button.tsx @@ -0,0 +1,91 @@ +import { useWalletConnection } from "@solana/react-hooks"; +import { Wallet, ChevronDown, LogOut, Copy, Check } from "lucide-react"; +import { useState, useCallback } from "react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +/** + * Solana wallet button using framework-kit's useWalletConnection hook. + */ +export function WalletButton() { + const { connect, disconnect, connectors, connecting, connected, wallet } = + useWalletConnection(); + const [copied, setCopied] = useState(false); + + const address = wallet?.account.address ?? ""; + const shortAddress = address + ? `${address.slice(0, 4)}...${address.slice(-4)}` + : ""; + + const copyAddress = useCallback(async () => { + if (!address) return; + await navigator.clipboard.writeText(address); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [address]); + + // Not connected - show wallet selector dropdown + if (!connected) { + return ( + + + + + + {connectors.map((connector) => ( + connect(connector.id)} + > + {connector.icon && ( + {connector.name} + )} + {connector.name} + + ))} + + + ); + } + + // Connected - show address with dropdown + return ( + + + + + + + {copied ? ( + + ) : ( + + )} + {copied ? "Copied!" : "Copy Address"} + + + + + Disconnect + + + + ); +} diff --git a/apps/market/src/components/ui/alert-dialog.tsx b/apps/market/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..88f06c1 --- /dev/null +++ b/apps/market/src/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import type * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/apps/market/src/components/ui/avatar.tsx b/apps/market/src/components/ui/avatar.tsx new file mode 100644 index 0000000..df4e0ce --- /dev/null +++ b/apps/market/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client"; + +import type * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/market/src/components/ui/badge.tsx b/apps/market/src/components/ui/badge.tsx new file mode 100644 index 0000000..ea391c4 --- /dev/null +++ b/apps/market/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import type * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/market/src/components/ui/button.tsx b/apps/market/src/components/ui/button.tsx new file mode 100644 index 0000000..0828e6b --- /dev/null +++ b/apps/market/src/components/ui/button.tsx @@ -0,0 +1,60 @@ +import type * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/market/src/components/ui/card.tsx b/apps/market/src/components/ui/card.tsx new file mode 100644 index 0000000..cc1ff8a --- /dev/null +++ b/apps/market/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/apps/market/src/components/ui/dialog.tsx b/apps/market/src/components/ui/dialog.tsx new file mode 100644 index 0000000..67c0128 --- /dev/null +++ b/apps/market/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import type * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/market/src/components/ui/dropdown-menu.tsx b/apps/market/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..76bf451 --- /dev/null +++ b/apps/market/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import type * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/apps/market/src/components/ui/empty.tsx b/apps/market/src/components/ui/empty.tsx new file mode 100644 index 0000000..e4a0754 --- /dev/null +++ b/apps/market/src/components/ui/empty.tsx @@ -0,0 +1,104 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const emptyMediaVariants = cva( + "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +}; diff --git a/apps/market/src/components/ui/error-boundary.tsx b/apps/market/src/components/ui/error-boundary.tsx new file mode 100644 index 0000000..3222a55 --- /dev/null +++ b/apps/market/src/components/ui/error-boundary.tsx @@ -0,0 +1,64 @@ +import { Component, type ReactNode } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; +import { Button } from "./button"; + +interface Props { + children: ReactNode; + /** Optional fallback component to render on error */ + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Error boundary for catching React rendering errors. + * Provides a fallback UI with retry capability. + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + // Log error for debugging (could integrate with error reporting service) + console.error("ErrorBoundary caught an error:", error, errorInfo); + } + + handleRetry = (): void => { + this.setState({ hasError: false, error: null }); + }; + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+ +
+

Something went wrong

+

+ {this.state.error?.message || "An unexpected error occurred"} +

+
+ +
+ ); + } + + return this.props.children; + } +} diff --git a/apps/market/src/components/ui/field.tsx b/apps/market/src/components/ui/field.tsx new file mode 100644 index 0000000..09e93a4 --- /dev/null +++ b/apps/market/src/components/ui/field.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useMemo } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className, + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +