diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f4ddeec..ab85393 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,6 +5,7 @@ import { ThemeScript, TransactionProvider, } from '@bridgewise/ui-components'; +import { OfflineBanner } from '../components/OfflineBanner'; import './globals.css'; const customTheme = { @@ -47,7 +48,10 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > - {children} + + + {children} + diff --git a/apps/web/app/sandbox/page.tsx b/apps/web/app/sandbox/page.tsx new file mode 100644 index 0000000..702257a --- /dev/null +++ b/apps/web/app/sandbox/page.tsx @@ -0,0 +1,266 @@ +'use client'; + +import React, { useState } from 'react'; +import { useOfflineDetection } from '../../hooks/useOfflineDetection'; + +// ─── Testnet configuration ────────────────────────────────────────────────── + +const TESTNETS = [ + { id: 'goerli', name: 'Goerli (Ethereum testnet)', chainId: 5 }, + { id: 'mumbai', name: 'Mumbai (Polygon testnet)', chainId: 80001 }, + { id: 'fuji', name: 'Fuji (Avalanche testnet)', chainId: 43113 }, + { id: 'bsc-testnet', name: 'BSC Testnet', chainId: 97 }, +]; + +// ─── Sample demo bridge flows ──────────────────────────────────────────────── + +const DEMO_FLOWS = [ + { + id: 'usdc-eth-to-polygon', + label: 'USDC: Goerli → Mumbai', + sourceChain: 'goerli', + destChain: 'mumbai', + token: 'USDC', + amount: '100', + estimatedFee: '$0.12', + estimatedTime: '~2 min', + bridge: 'Across Protocol (testnet)', + }, + { + id: 'eth-to-avax', + label: 'ETH: Goerli → Fuji', + sourceChain: 'goerli', + destChain: 'fuji', + token: 'ETH', + amount: '0.05', + estimatedFee: '$0.08', + estimatedTime: '~3 min', + bridge: 'LayerZero (testnet)', + }, + { + id: 'bnb-to-polygon', + label: 'BNB: BSC Testnet → Mumbai', + sourceChain: 'bsc-testnet', + destChain: 'mumbai', + token: 'BNB', + amount: '1', + estimatedFee: '$0.05', + estimatedTime: '~5 min', + bridge: 'Stargate (testnet)', + }, +]; + +type FlowStatus = 'idle' | 'running' | 'success' | 'failed'; + +interface FlowState { + status: FlowStatus; + progress: number; + step: string; + txHash?: string; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +export default function SandboxPage() { + const { isOffline } = useOfflineDetection(); + const [activeFlow, setActiveFlow] = useState(null); + const [flowStates, setFlowStates] = useState>({}); + + function updateFlow(id: string, updates: Partial) { + setFlowStates((prev) => ({ + ...prev, + [id]: { ...prev[id], ...updates }, + })); + } + + function runFlow(flowId: string) { + if (isOffline) return; + setActiveFlow(flowId); + updateFlow(flowId, { status: 'running', progress: 0, step: 'Initializing…', txHash: undefined }); + + const steps = [ + { pct: 15, label: 'Approving token spend…' }, + { pct: 35, label: 'Submitting bridge transaction…' }, + { pct: 55, label: 'Waiting for source confirmation…' }, + { pct: 75, label: 'Relaying to destination chain…' }, + { pct: 90, label: 'Finalizing transfer…' }, + { pct: 100, label: 'Complete!' }, + ]; + + let idx = 0; + const tick = setInterval(() => { + if (idx >= steps.length) { + clearInterval(tick); + const hash = '0x' + Math.random().toString(16).slice(2, 66); + updateFlow(flowId, { status: 'success', progress: 100, step: 'Complete!', txHash: hash }); + setActiveFlow(null); + return; + } + const s = steps[idx++]; + updateFlow(flowId, { progress: s.pct, step: s.label }); + }, 900); + } + + function resetFlow(flowId: string) { + setFlowStates((prev) => { + const next = { ...prev }; + delete next[flowId]; + return next; + }); + } + + return ( +
+ {/* Header */} +
+
+ + SANDBOX + + Testnet only — no real funds +
+

Dev Sandbox & Demo Environment

+

+ Test BridgeWise integrations safely using testnets. Run sample bridge flows, + inspect transaction states, and verify your integration without touching mainnet assets. +

+ {isOffline && ( +

+ You are offline. Demo flows are disabled until connection is restored. +

+ )} +
+ +
+ {/* Supported Testnets */} +
+

Supported Testnets

+
    + {TESTNETS.map((net) => ( +
  • + {net.name} + chainId: {net.chainId} +
  • + ))} +
+
+ + {/* Demo Flows */} +
+

Sample Bridge Flows

+
    + {DEMO_FLOWS.map((flow) => { + const state = flowStates[flow.id]; + const isRunning = state?.status === 'running'; + const isDone = state?.status === 'success' || state?.status === 'failed'; + + return ( +
  • +
    +
    +

    {flow.label}

    +

    {flow.bridge}

    +
    + + {flow.amount} {flow.token} + +
    + + {/* Meta */} +
    + Fee: {flow.estimatedFee} + Time: {flow.estimatedTime} +
    + + {/* Progress */} + {(isRunning || isDone) && state && ( +
    +
    + {state.step} + {state.progress}% +
    +
    +
    +
    + {state.txHash && ( +

    + tx: {state.txHash} +

    + )} +
    + )} + + {/* Actions */} +
    + {!isRunning && !isDone && ( + + )} + {isRunning && ( + + Running… + + )} + {isDone && ( + <> + + {state.status === 'success' ? 'Success' : 'Failed'} + + + + )} +
    +
  • + ); + })} +
+
+
+ + {/* Setup instructions */} +
+

Sandbox Setup

+
    +
  1. + Set BRIDGE_ENV=testnet in + your .env.local. +
  2. +
  3. + Connect a wallet to one of the supported testnets above and fund it via a faucet. +
  4. +
  5. Click Run Flow on any demo flow to simulate an end-to-end bridge.
  6. +
  7. Inspect transaction state changes in the BridgeStatus heartbeat (bottom of page).
  8. +
+
+
+ ); +} diff --git a/apps/web/components/OfflineBanner.tsx b/apps/web/components/OfflineBanner.tsx new file mode 100644 index 0000000..8099d65 --- /dev/null +++ b/apps/web/components/OfflineBanner.tsx @@ -0,0 +1,41 @@ +'use client'; + +import React from 'react'; +import { useOfflineDetection } from '../hooks/useOfflineDetection'; + +export function OfflineBanner() { + const { isOffline, cache } = useOfflineDetection(); + + if (!isOffline) return null; + + const cachedAt = cache?.cachedAt + ? new Date(cache.cachedAt).toLocaleTimeString() + : null; + + return ( +
+ + + You are offline. Showing{' '} + {cachedAt ? `cached data from ${cachedAt}` : 'limited functionality'}. + +
+ ); +} diff --git a/apps/web/hooks/useOfflineDetection.ts b/apps/web/hooks/useOfflineDetection.ts new file mode 100644 index 0000000..cc94806 --- /dev/null +++ b/apps/web/hooks/useOfflineDetection.ts @@ -0,0 +1,72 @@ +import { useState, useEffect, useCallback } from 'react'; +import { isBrowser, ssrLocalStorage } from '../components/ui-lib/utils/ssr'; + +const CACHE_KEY = 'bridgewise_offline_cache'; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +export interface OfflineCache { + routes: unknown[]; + quotes: unknown[]; + cachedAt: number; +} + +export interface UseOfflineDetectionResult { + isOffline: boolean; + cache: OfflineCache | null; + saveToCache: (data: Partial) => void; + clearCache: () => void; +} + +export function useOfflineDetection(): UseOfflineDetectionResult { + const [isOffline, setIsOffline] = useState( + isBrowser ? !navigator.onLine : false, + ); + const [cache, setCache] = useState(() => { + const stored = ssrLocalStorage.getItem(CACHE_KEY); + if (!stored) return null; + try { + const parsed: OfflineCache = JSON.parse(stored); + if (Date.now() - parsed.cachedAt > CACHE_TTL_MS) { + ssrLocalStorage.removeItem(CACHE_KEY); + return null; + } + return parsed; + } catch { + return null; + } + }); + + useEffect(() => { + if (!isBrowser) return; + + const handleOnline = () => setIsOffline(false); + const handleOffline = () => setIsOffline(true); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + const saveToCache = useCallback((data: Partial) => { + setCache((prev) => { + const next: OfflineCache = { + routes: data.routes ?? prev?.routes ?? [], + quotes: data.quotes ?? prev?.quotes ?? [], + cachedAt: Date.now(), + }; + ssrLocalStorage.setItem(CACHE_KEY, JSON.stringify(next)); + return next; + }); + }, []); + + const clearCache = useCallback(() => { + ssrLocalStorage.removeItem(CACHE_KEY); + setCache(null); + }, []); + + return { isOffline, cache, saveToCache, clearCache }; +}