From 453caca8812978d914037dbf3a08bbe5b6789994 Mon Sep 17 00:00:00 2001 From: Jo D Date: Thu, 30 Apr 2026 13:32:26 -0400 Subject: [PATCH 1/4] feat(webapp): support vercel prod deploy with dev/prod gating - vercel.json for Vite + pnpm workspace builds - static network config (config/networks.ts); localnet from env vars - lib/cluster.ts decouples clusterIdToNetwork from api-client - lazy-load Setup + Program routes, render only when DEV - useNetworkConfig falls back to static; PROD never hits api - SetupGuard no-ops in PROD or when localnet env vars present - just dev-local bootstraps surfpool + USDC mint + .env.local - gitignore .env.local --- .gitignore | 2 + justfile | 24 ++++ webapp/src/App.tsx | 161 +++++++++++++++------------ webapp/src/components/app-header.tsx | 4 +- webapp/src/components/nav-items.ts | 11 +- webapp/src/config/networks.ts | 46 ++++++++ webapp/src/hooks/use-token-config.ts | 20 +++- webapp/src/lib/cluster.ts | 8 ++ webapp/vercel.json | 7 ++ 9 files changed, 201 insertions(+), 82 deletions(-) create mode 100644 webapp/src/config/networks.ts create mode 100644 webapp/src/lib/cluster.ts create mode 100644 webapp/vercel.json diff --git a/.gitignore b/.gitignore index 74a7f2e..3e59c8d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ config.json # Environment secrets .env +.env.local +**/.env.local keys/ diff --git a/justfile b/justfile index 27d80a6..fd68ad9 100644 --- a/justfile +++ b/justfile @@ -166,6 +166,30 @@ ensure-surfpool: just kill-validator exit 1 +# Bootstrap localnet (validator + program + mock USDC) and write webapp/.env.local for pnpm dev (no api server) +dev-local: ensure-surfpool + #!/usr/bin/env bash + set -euo pipefail + + pushd {{webapp_dir}}/scripts > /dev/null + pnpm install --silent + NETWORK=localnet RPC_URL=http://127.0.0.1:8899 pnpm run init + popd > /dev/null + + PROG_ID=$(just program-id) + USDC_MINT=$(node -e "const c=JSON.parse(require('fs').readFileSync('{{webapp_dir}}/config.json'));process.stdout.write(c.networks.localnet.tokens.find(t=>t.symbol==='USDC').mint)") + + { + echo "VITE_DEFAULT_CLUSTER=solana:localnet" + echo "VITE_LOCALNET_PROGRAM=$PROG_ID" + echo "VITE_LOCALNET_USDC_MINT=$USDC_MINT" + } > {{webapp_dir}}/.env.local + echo "✓ {{webapp_dir}}/.env.local written" + echo " VITE_LOCALNET_PROGRAM=$PROG_ID" + echo " VITE_LOCALNET_USDC_MINT=$USDC_MINT" + echo "" + echo "Next: pnpm --filter webapp dev" + # Stop all validators kill-validator: #!/usr/bin/env bash diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 6672360..ff9d3bf 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,4 +1,7 @@ +import { lazy, Suspense } from 'react'; import { Route, Routes, Navigate, useLocation } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { createSolanaRpc } from '@solana/kit'; import { AppProviders } from '@/components/app-providers'; import { AppLayout } from '@/components/app-layout'; import { Dashboard } from '@/routes/dashboard'; @@ -8,13 +11,12 @@ import { Delegations } from '@/routes/delegations'; import { Subscriptions } from '@/routes/subscriptions'; import { Plans } from '@/routes/plans'; import { CollectPayments } from '@/routes/collect-payments'; -import { Program } from '@/routes/program'; -import { Setup } from '@/routes/setup'; import { useNetworkConfig } from '@/hooks/use-token-config'; -import { clusterIdToNetwork } from '@/lib/api-client'; +import { clusterIdToNetwork } from '@/lib/cluster'; import { useClusterConfig } from '@/hooks/use-cluster-config'; -import { useQuery } from '@tanstack/react-query'; -import { createSolanaRpc } from '@solana/kit'; + +const Setup = lazy(() => import('@/routes/setup').then(m => ({ default: m.Setup }))); +const Program = lazy(() => import('@/routes/program').then(m => ({ default: m.Program }))); function useRpcReachable() { const { id, url } = useClusterConfig(); @@ -63,7 +65,7 @@ function useIsSetupValid(): { ready: boolean; loading: boolean } { return { ready: true, loading: false }; } -function SetupGuard({ children }: { children: React.ReactNode }) { +function DevSetupGuard({ children }: { children: React.ReactNode }) { const location = useLocation(); const { ready } = useIsSetupValid(); if (!ready && location.pathname !== '/setup') { @@ -72,78 +74,89 @@ function SetupGuard({ children }: { children: React.ReactNode }) { return <>{children}; } +function ProdSetupGuard({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +const HAS_LOCALNET_CONFIG = !!import.meta.env.VITE_LOCALNET_USDC_MINT; +const SetupGuard = import.meta.env.DEV && !HAS_LOCALNET_CONFIG ? DevSetupGuard : ProdSetupGuard; + export default function App() { return ( - - } /> - - - - } - path="/" - /> - - - - } - path="/marketplace" - /> - - - - } - path="/delegations" - /> - - - - } - path="/subscriptions" - /> - - - - } - path="/plans" - /> - - - - } - path="/plans/collect" - /> - - - - } - path="/faucet" - /> - - - - } - path="/program" - /> - } /> - + + + {import.meta.env.DEV && } />} + + + + } + path="/" + /> + + + + } + path="/marketplace" + /> + + + + } + path="/delegations" + /> + + + + } + path="/subscriptions" + /> + + + + } + path="/plans" + /> + + + + } + path="/plans/collect" + /> + + + + } + path="/faucet" + /> + {import.meta.env.DEV && ( + + + + } + path="/program" + /> + )} + } /> + + ); diff --git a/webapp/src/components/app-header.tsx b/webapp/src/components/app-header.tsx index 6ccdf23..91cbbdf 100644 --- a/webapp/src/components/app-header.tsx +++ b/webapp/src/components/app-header.tsx @@ -63,7 +63,7 @@ export function AppHeader() {
- + {import.meta.env.DEV && }
{showMenu && ( @@ -97,7 +97,7 @@ export function AppHeader() {
- + {import.meta.env.DEV && }
diff --git a/webapp/src/components/nav-items.ts b/webapp/src/components/nav-items.ts index 77e2f63..7d447ce 100644 --- a/webapp/src/components/nav-items.ts +++ b/webapp/src/components/nav-items.ts @@ -21,5 +21,14 @@ export const NAV_ITEMS: NavItem[] = [ path: '/plans', }, { clusterFilter: ['solana:localnet', 'solana:devnet'], icon: Droplets, label: 'Faucet', path: '/faucet' }, - { clusterFilter: ['solana:devnet', 'solana:testnet'], icon: Code2, label: 'Program', path: '/program' }, + ...(import.meta.env.DEV + ? [ + { + clusterFilter: ['solana:devnet', 'solana:testnet'], + icon: Code2, + label: 'Program', + path: '/program', + } satisfies NavItem, + ] + : []), ]; diff --git a/webapp/src/config/networks.ts b/webapp/src/config/networks.ts new file mode 100644 index 0000000..792388c --- /dev/null +++ b/webapp/src/config/networks.ts @@ -0,0 +1,46 @@ +import type { Network } from '@/lib/cluster'; + +export interface TokenConfig { + symbol: string; + name: string; + mint: string; + decimals: number; +} + +export interface NetworkConfig { + programAddress: string | null; + tokens: TokenConfig[]; +} + +const PROGRAM_ID = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44'; + +const DEVNET_USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; +const MAINNET_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +export const STATIC_NETWORKS: Record = { + localnet: { + programAddress: import.meta.env.VITE_LOCALNET_PROGRAM ?? PROGRAM_ID, + tokens: import.meta.env.VITE_LOCALNET_USDC_MINT + ? [ + { + symbol: 'USDC', + name: 'USD Coin (mock)', + mint: import.meta.env.VITE_LOCALNET_USDC_MINT, + decimals: 6, + }, + ] + : [], + }, + devnet: { + programAddress: PROGRAM_ID, + tokens: [{ symbol: 'USDC', name: 'USD Coin', mint: DEVNET_USDC, decimals: 6 }], + }, + testnet: { + programAddress: PROGRAM_ID, + tokens: [], + }, + mainnet: { + programAddress: PROGRAM_ID, + tokens: [{ symbol: 'USDC', name: 'USD Coin', mint: MAINNET_USDC, decimals: 6 }], + }, +}; diff --git a/webapp/src/hooks/use-token-config.ts b/webapp/src/hooks/use-token-config.ts index 1ba2989..e6170bd 100644 --- a/webapp/src/hooks/use-token-config.ts +++ b/webapp/src/hooks/use-token-config.ts @@ -1,16 +1,26 @@ import { useQuery } from '@tanstack/react-query'; import { useClusterConfig } from '@/hooks/use-cluster-config'; -import type { NetworkConfigResponse } from '@/lib/api-client'; -import { api, clusterIdToNetwork } from '@/lib/api-client'; +import { STATIC_NETWORKS, type NetworkConfig } from '@/config/networks'; +import { clusterIdToNetwork } from '@/lib/cluster'; +import { api } from '@/lib/api-client'; export function useNetworkConfig() { const { id } = useClusterConfig(); const network = clusterIdToNetwork(id); - return useQuery({ - queryFn: () => api.config.getNetworkConfig(network), - queryKey: ['network-config', network], + return useQuery({ + queryFn: async () => { + if (import.meta.env.DEV) { + try { + return await api.config.getNetworkConfig(network); + } catch { + return STATIC_NETWORKS[network]; + } + } + return STATIC_NETWORKS[network]; + }, + queryKey: ['network-config', network, import.meta.env.DEV], retry: 2, staleTime: 30_000, }); diff --git a/webapp/src/lib/cluster.ts b/webapp/src/lib/cluster.ts new file mode 100644 index 0000000..44c1f3f --- /dev/null +++ b/webapp/src/lib/cluster.ts @@ -0,0 +1,8 @@ +export type Network = 'localnet' | 'devnet' | 'testnet' | 'mainnet'; + +export function clusterIdToNetwork(id: string): Network { + if (id.includes('devnet')) return 'devnet'; + if (id.includes('testnet')) return 'testnet'; + if (id.includes('mainnet')) return 'mainnet'; + return 'localnet'; +} diff --git a/webapp/vercel.json b/webapp/vercel.json new file mode 100644 index 0000000..6199675 --- /dev/null +++ b/webapp/vercel.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "buildCommand": "pnpm --filter @subscriptions/client build && pnpm --filter webapp build", + "installCommand": "pnpm install --frozen-lockfile", + "outputDirectory": "dist" +} From afd034d677a8b91b761881c0cc302fba3ef1a333 Mon Sep 17 00:00:00 2001 From: Jo D Date: Thu, 30 Apr 2026 13:35:13 -0400 Subject: [PATCH 2/4] chore: ignore .emdash.json and .vscode + format vercel.json prettier ignore + gitignore for editor/tool configs that vary per dev. --- .gitignore | 4 ++++ .prettierignore | 4 ++++ webapp/vercel.json | 10 +++++----- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3e59c8d..9122f85 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ clients/typescript/docs/ .vercel .claude/ + +# Editor / tool config +.emdash.json +.vscode/ diff --git a/.prettierignore b/.prettierignore index 17a9f54..91eefa0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,3 +19,7 @@ pnpm-lock.yaml # TypeDoc generated API docs clients/typescript/docs/ + +# Editor / tool config +.emdash.json +.vscode/ diff --git a/webapp/vercel.json b/webapp/vercel.json index 6199675..cc3b588 100644 --- a/webapp/vercel.json +++ b/webapp/vercel.json @@ -1,7 +1,7 @@ { - "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": "vite", - "buildCommand": "pnpm --filter @subscriptions/client build && pnpm --filter webapp build", - "installCommand": "pnpm install --frozen-lockfile", - "outputDirectory": "dist" + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "buildCommand": "pnpm --filter @subscriptions/client build && pnpm --filter webapp build", + "installCommand": "pnpm install --frozen-lockfile", + "outputDirectory": "dist" } From 0e3b038496590ae728f181bc0ccd5801414e5185 Mon Sep 17 00:00:00 2001 From: Jo D Date: Thu, 30 Apr 2026 14:02:49 -0400 Subject: [PATCH 3/4] chore: ignore .claude/.git in eslint + sort keys/imports eslint was scanning worktrees under .claude/ and flagging deleted apps/. --- eslint.config.mjs | 2 ++ webapp/src/config/networks.ts | 24 ++++++++++++------------ webapp/src/hooks/use-token-config.ts | 4 ++-- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 2244eab..021a7cf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,8 @@ export default [ }, { ignores: [ + '**/.claude/**', + '**/.git/**', '**/dist/**', '**/node_modules/**', '**/target/**', diff --git a/webapp/src/config/networks.ts b/webapp/src/config/networks.ts index 792388c..2031018 100644 --- a/webapp/src/config/networks.ts +++ b/webapp/src/config/networks.ts @@ -1,10 +1,10 @@ import type { Network } from '@/lib/cluster'; export interface TokenConfig { - symbol: string; - name: string; - mint: string; decimals: number; + mint: string; + name: string; + symbol: string; } export interface NetworkConfig { @@ -18,29 +18,29 @@ const DEVNET_USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'; const MAINNET_USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; export const STATIC_NETWORKS: Record = { + devnet: { + programAddress: PROGRAM_ID, + tokens: [{ decimals: 6, mint: DEVNET_USDC, name: 'USD Coin', symbol: 'USDC' }], + }, localnet: { programAddress: import.meta.env.VITE_LOCALNET_PROGRAM ?? PROGRAM_ID, tokens: import.meta.env.VITE_LOCALNET_USDC_MINT ? [ { - symbol: 'USDC', - name: 'USD Coin (mock)', - mint: import.meta.env.VITE_LOCALNET_USDC_MINT, decimals: 6, + mint: import.meta.env.VITE_LOCALNET_USDC_MINT, + name: 'USD Coin (mock)', + symbol: 'USDC', }, ] : [], }, - devnet: { + mainnet: { programAddress: PROGRAM_ID, - tokens: [{ symbol: 'USDC', name: 'USD Coin', mint: DEVNET_USDC, decimals: 6 }], + tokens: [{ decimals: 6, mint: MAINNET_USDC, name: 'USD Coin', symbol: 'USDC' }], }, testnet: { programAddress: PROGRAM_ID, tokens: [], }, - mainnet: { - programAddress: PROGRAM_ID, - tokens: [{ symbol: 'USDC', name: 'USD Coin', mint: MAINNET_USDC, decimals: 6 }], - }, }; diff --git a/webapp/src/hooks/use-token-config.ts b/webapp/src/hooks/use-token-config.ts index e6170bd..f26370a 100644 --- a/webapp/src/hooks/use-token-config.ts +++ b/webapp/src/hooks/use-token-config.ts @@ -1,9 +1,9 @@ import { useQuery } from '@tanstack/react-query'; +import { type NetworkConfig, STATIC_NETWORKS } from '@/config/networks'; import { useClusterConfig } from '@/hooks/use-cluster-config'; -import { STATIC_NETWORKS, type NetworkConfig } from '@/config/networks'; -import { clusterIdToNetwork } from '@/lib/cluster'; import { api } from '@/lib/api-client'; +import { clusterIdToNetwork } from '@/lib/cluster'; export function useNetworkConfig() { const { id } = useClusterConfig(); From 36cb16ce6b4cf6c892523611a2186fe532f95ac9 Mon Sep 17 00:00:00 2001 From: Jo D Date: Thu, 30 Apr 2026 14:25:27 -0400 Subject: [PATCH 4/4] feat(webapp): cluster dropdown with mainnet support Replace setup-only NetworkButton with a dropdown using useCluster().setCluster() so users can switch between devnet, mainnet, testnet (and localnet in DEV) without going through the setup wizard. Mainnet RPC defaults to public endpoint, overridable via VITE_MAINNET_RPC_URL. "Rerun setup" stays available in DEV. --- webapp/src/components/app-header.tsx | 87 +++++++++++++------ .../src/components/solana/solana-provider.tsx | 16 ++-- 2 files changed, 73 insertions(+), 30 deletions(-) diff --git a/webapp/src/components/app-header.tsx b/webapp/src/components/app-header.tsx index 91cbbdf..8065c81 100644 --- a/webapp/src/components/app-header.tsx +++ b/webapp/src/components/app-header.tsx @@ -1,33 +1,70 @@ -import { useState } from 'react'; +import { useCluster } from '@solana/connector/react'; import { Button } from '@solana/design-system'; -import { Menu, X, Settings2 } from 'lucide-react'; -import { WalletButton } from './solana/solana-provider'; -import { TimeTravelButton } from './time-travel/time-travel-button'; +import { ChevronDown, Menu, RotateCcw, Settings2, X } from 'lucide-react'; +import { useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router'; -import { useCluster } from '@solana/connector/react'; -import { NAV_ITEMS } from './nav-items'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { CURRENT_PROGRAM_VERSION } from '@subscriptions/client'; -function NetworkButton() { +import { NAV_ITEMS } from './nav-items'; +import { WalletButton } from './solana/solana-provider'; +import { TimeTravelButton } from './time-travel/time-travel-button'; + +function ClusterButton() { + const { cluster, clusters, setCluster } = useCluster(); const navigate = useNavigate(); - const setupCluster = localStorage.getItem('setup-cluster') ?? ''; - const label = - setupCluster === 'solana:devnet' ? 'Devnet' : setupCluster === 'solana:testnet' ? 'Testnet' : 'Localnet'; return ( - + + + + + + Network + + {clusters.map(c => ( + { + void setCluster(c.id); + }} + > + {c.label} + + ))} + {import.meta.env.DEV && ( + <> + + { + localStorage.removeItem('setup-complete-localnet'); + localStorage.removeItem('setup-complete-devnet'); + localStorage.removeItem('setup-cluster'); + navigate('/setup'); + }} + > + + Rerun setup + + + )} + + ); } @@ -63,7 +100,7 @@ export function AppHeader() {
- {import.meta.env.DEV && } +
{showMenu && ( @@ -97,7 +134,7 @@ export function AppHeader() {
- {import.meta.env.DEV && } +
diff --git a/webapp/src/components/solana/solana-provider.tsx b/webapp/src/components/solana/solana-provider.tsx index 5992c3c..68719ce 100644 --- a/webapp/src/components/solana/solana-provider.tsx +++ b/webapp/src/components/solana/solana-provider.tsx @@ -27,22 +27,28 @@ import { ellipsify } from '@/lib/utils'; function defaultClusterId(): SolanaClusterId { const stored = localStorage.getItem('setup-cluster'); const configured = import.meta.env.VITE_DEFAULT_CLUSTER; - const id = stored || configured || 'solana:localnet'; - return id === 'solana:devnet' || id === 'solana:testnet' || id === 'solana:localnet' + const id = stored || configured || (import.meta.env.DEV ? 'solana:localnet' : 'solana:devnet'); + return id === 'solana:devnet' || id === 'solana:testnet' || id === 'solana:localnet' || id === 'solana:mainnet' ? (id as SolanaClusterId) - : 'solana:localnet'; + : 'solana:devnet'; } -function networkFromClusterId(clusterId: SolanaClusterId): 'devnet' | 'localnet' | 'testnet' { +function networkFromClusterId(clusterId: SolanaClusterId): 'devnet' | 'localnet' | 'mainnet' | 'testnet' { if (clusterId === 'solana:devnet') return 'devnet'; if (clusterId === 'solana:testnet') return 'testnet'; + if (clusterId === 'solana:mainnet') return 'mainnet'; return 'localnet'; } const clusters = [ - { id: 'solana:localnet' as const, label: 'Localnet', url: '/rpc' }, + ...(import.meta.env.DEV ? [{ id: 'solana:localnet' as const, label: 'Localnet', url: '/rpc' }] : []), { id: 'solana:devnet' as const, label: 'Devnet', url: 'https://api.devnet.solana.com' }, { id: 'solana:testnet' as const, label: 'Testnet', url: 'https://api.testnet.solana.com' }, + { + id: 'solana:mainnet' as const, + label: 'Mainnet', + url: import.meta.env.VITE_MAINNET_RPC_URL ?? 'https://api.mainnet-beta.solana.com', + }, ]; export function WalletButton() {