Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ config.json

# Environment secrets
.env
.env.local
**/.env.local

keys/

Expand All @@ -39,3 +41,7 @@ clients/typescript/docs/
.vercel

.claude/

# Editor / tool config
.emdash.json
.vscode/
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ pnpm-lock.yaml

# TypeDoc generated API docs
clients/typescript/docs/

# Editor / tool config
.emdash.json
.vscode/
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export default [
},
{
ignores: [
'**/.claude/**',
'**/.git/**',
'**/dist/**',
'**/node_modules/**',
'**/target/**',
Expand Down
24 changes: 24 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 87 additions & 74 deletions webapp/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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') {
Expand All @@ -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 (
<AppProviders>
<SetupGuard>
<Routes>
<Route path="/setup" element={<Setup />} />
<Route
element={
<AppLayout>
<Dashboard />
</AppLayout>
}
path="/"
/>
<Route
element={
<AppLayout>
<Marketplace />
</AppLayout>
}
path="/marketplace"
/>
<Route
element={
<AppLayout>
<Delegations />
</AppLayout>
}
path="/delegations"
/>
<Route
element={
<AppLayout>
<Subscriptions />
</AppLayout>
}
path="/subscriptions"
/>
<Route
element={
<AppLayout>
<Plans />
</AppLayout>
}
path="/plans"
/>
<Route
element={
<AppLayout>
<CollectPayments />
</AppLayout>
}
path="/plans/collect"
/>
<Route
element={
<AppLayout>
<Faucet />
</AppLayout>
}
path="/faucet"
/>
<Route
element={
<AppLayout>
<Program />
</AppLayout>
}
path="/program"
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Suspense fallback={null}>
<Routes>
{import.meta.env.DEV && <Route path="/setup" element={<Setup />} />}
<Route
element={
<AppLayout>
<Dashboard />
</AppLayout>
}
path="/"
/>
<Route
element={
<AppLayout>
<Marketplace />
</AppLayout>
}
path="/marketplace"
/>
<Route
element={
<AppLayout>
<Delegations />
</AppLayout>
}
path="/delegations"
/>
<Route
element={
<AppLayout>
<Subscriptions />
</AppLayout>
}
path="/subscriptions"
/>
<Route
element={
<AppLayout>
<Plans />
</AppLayout>
}
path="/plans"
/>
<Route
element={
<AppLayout>
<CollectPayments />
</AppLayout>
}
path="/plans/collect"
/>
<Route
element={
<AppLayout>
<Faucet />
</AppLayout>
}
path="/faucet"
/>
{import.meta.env.DEV && (
<Route
element={
<AppLayout>
<Program />
</AppLayout>
}
path="/program"
/>
)}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</SetupGuard>
</AppProviders>
);
Expand Down
87 changes: 62 additions & 25 deletions webapp/src/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
iconLeft={<Settings2 />}
onClick={() => {
localStorage.removeItem('setup-complete-localnet');
localStorage.removeItem('setup-complete-devnet');
localStorage.removeItem('setup-cluster');
navigate('/setup');
}}
size="sm"
variant="secondary"
>
{label}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
iconLeft={<Settings2 />}
iconRight={<ChevronDown className="opacity-60" />}
size="sm"
variant="secondary"
>
{cluster?.label ?? 'Network'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Network</DropdownMenuLabel>
<DropdownMenuSeparator />
{clusters.map(c => (
<DropdownMenuItem
key={c.id}
onClick={() => {
void setCluster(c.id);
}}
>
{c.label}
</DropdownMenuItem>
))}
{import.meta.env.DEV && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
localStorage.removeItem('setup-complete-localnet');
localStorage.removeItem('setup-complete-devnet');
localStorage.removeItem('setup-cluster');
navigate('/setup');
}}
>
<RotateCcw className="mr-2 h-4 w-4" />
Rerun setup
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

Expand Down Expand Up @@ -63,7 +100,7 @@ export function AppHeader() {
<div className="hidden md:flex items-center gap-4 ml-auto">
<TimeTravelButton />
<WalletButton />
<NetworkButton />
<ClusterButton />
</div>

{showMenu && (
Expand Down Expand Up @@ -97,7 +134,7 @@ export function AppHeader() {
<div className="flex flex-col gap-4">
<TimeTravelButton />
<WalletButton />
<NetworkButton />
<ClusterButton />
</div>
</div>
</div>
Expand Down
11 changes: 10 additions & 1 deletion webapp/src/components/nav-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
: []),
];
Loading
Loading