Skip to content
Open
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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
"prettier": "^3.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0",
"vite": "^6.3.0"
"vite": "^6.3.0",
"vite-plugin-pwa": "^0.21.0",
"workbox-window": "^7.3.0"
}
}
3,151 changes: 2,844 additions & 307 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

51 changes: 40 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,50 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useRegisterSW } from 'virtual:pwa-register/react';
import { useState } from 'react';
import { Header } from '@/components/Header';
import { AutoSign } from '@/components/AutoSign';
import { OfflineShell } from '@/components/OfflineShell';
import { UpdateToast } from '@/components/UpdateToast';
import { InstallPrompt } from '@/components/InstallPrompt';
import Send from '@/pages/Send';
import Receive from '@/pages/Receive';

export function App() {
const [showUpdate, setShowUpdate] = useState(false);

const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onNeedRefresh() {
setShowUpdate(true);
},
});

const handleUpdate = () => {
setShowUpdate(false);
updateServiceWorker(true);
};

return (
<div className="flex min-h-screen flex-col">
<Header />
<AutoSign />
<main className="mx-auto w-full max-w-[720px] flex-1 px-4 pb-16 pt-8 sm:px-6 sm:pb-24 sm:pt-10">
<Routes>
<Route path="/send" element={<Send />} />
<Route path="/receive" element={<Receive />} />
<Route path="*" element={<Navigate to="/send" replace />} />
</Routes>
</main>
</div>
<OfflineShell>
<div className="flex min-h-screen flex-col">
<Header />
<AutoSign />
<main className="mx-auto w-full max-w-[720px] flex-1 px-4 pb-16 pt-8 sm:px-6 sm:pb-24 sm:pt-10">
<Routes>
<Route path="/send" element={<Send />} />
<Route path="/receive" element={<Receive />} />
<Route path="*" element={<Navigate to="/send" replace />} />
</Routes>
</main>
</div>

{needRefresh && showUpdate && (
<UpdateToast onUpdate={handleUpdate} onDismiss={() => setShowUpdate(false)} />
)}

<InstallPrompt />
</OfflineShell>
);
}
49 changes: 49 additions & 0 deletions src/components/InstallPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';

interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [dismissed, setDismissed] = useState(false);

useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);

if (!deferredPrompt || dismissed) return null;

const handleInstall = async () => {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted' || outcome === 'dismissed') {
setDeferredPrompt(null);
}
};

return (
<div className="fixed bottom-6 right-4 z-50 flex items-center gap-2 border border-outline bg-surface-container px-4 py-3 text-sm shadow-lg sm:right-6">
<span className="text-on-surface-variant">Install Wraith</span>
<button
onClick={handleInstall}
className="font-medium text-primary underline-offset-2 hover:underline"
>
Add to home screen
</button>
<button
onClick={() => setDismissed(true)}
aria-label="Dismiss install prompt"
className="ml-1 text-outline hover:text-on-surface"
>
</button>
</div>
);
}
48 changes: 48 additions & 0 deletions src/components/OfflineShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';

export function OfflineShell({ children }: { children: React.ReactNode }) {
const [isOffline, setIsOffline] = useState(!navigator.onLine);

useEffect(() => {
const goOffline = () => setIsOffline(true);
const goOnline = () => setIsOffline(false);
window.addEventListener('offline', goOffline);
window.addEventListener('online', goOnline);
return () => {
window.removeEventListener('offline', goOffline);
window.removeEventListener('online', goOnline);
};
}, []);

if (!isOffline) return <>{children}</>;

return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-12 w-12 text-outline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 3l18 18M8.111 8.111A7.5 7.5 0 0116.5 12m-1.415 4.085A4.5 4.5 0 019 12m-2.457-2.457A7.5 7.5 0 0112 4.5c1.93 0 3.7.731 5.03 1.93"
/>
</svg>
<h1 className="font-heading text-2xl font-semibold text-on-surface">You're offline</h1>
<p className="max-w-sm text-on-surface-variant">
Wraith needs a network connection for chain operations. Connect and refresh.
</p>
<button
onClick={() => window.location.reload()}
className="mt-2 border border-outline px-4 py-2 text-sm text-on-surface transition-colors hover:border-primary hover:text-primary"
>
Retry
</button>
</div>
);
}
29 changes: 29 additions & 0 deletions src/components/UpdateToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
interface UpdateToastProps {
onUpdate: () => void;
onDismiss: () => void;
}

export function UpdateToast({ onUpdate, onDismiss }: UpdateToastProps) {
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 border border-outline bg-surface-container px-4 py-3 text-sm shadow-lg"
>
<span className="text-on-surface-variant">A new version is ready.</span>
<button
onClick={onUpdate}
className="font-medium text-primary underline-offset-2 hover:underline"
>
Reload
</button>
<button
onClick={onDismiss}
aria-label="Dismiss update notification"
className="ml-1 text-outline hover:text-on-surface"
>
</button>
</div>
);
}
1 change: 1 addition & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
96 changes: 95 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,103 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path';

export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'prompt', // prompt user before activating new SW
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'og-image.png'],
manifest: {
name: 'Wraith Demo',
short_name: 'Wraith',
description:
'Developer demo for the Wraith Protocol stealth address SDK. Send and receive private payments on Horizen and Stellar.',
start_url: '/',
display: 'standalone',
background_color: '#0e0e0e',
theme_color: '#0e0e0e',
icons: [
{
src: '/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
},
workbox: {
// Precache the app shell
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
// Cap total cache at 20 MB
maximumFileSizeToCacheInBytes: 20 * 1024 * 1024,
// Never cache anything that looks like a key or signed tx
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
// Google Fonts — cache-first, long TTL
{
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 * 24 * 365 },
cacheableResponse: { statuses: [0, 200] },
},
},
// Stellar RPC balance lookups — network-first, 30 s TTL
{
urlPattern: ({ url }) =>
url.hostname.includes('stellar') ||
url.hostname.includes('horizon') ||
url.pathname.includes('/accounts/'),
handler: 'NetworkFirst',
options: {
cacheName: 'stellar-rpc-balance',
networkTimeoutSeconds: 10,
expiration: { maxEntries: 50, maxAgeSeconds: 30 },
cacheableResponse: { statuses: [0, 200] },
},
},
// Stellar announcement / event streams — network-first, 5 min TTL
{
urlPattern: ({ url }) =>
url.hostname.includes('stellar') && url.pathname.includes('/transactions'),
handler: 'NetworkFirst',
options: {
cacheName: 'stellar-rpc-announcements',
networkTimeoutSeconds: 10,
expiration: { maxEntries: 100, maxAgeSeconds: 5 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
// EVM / Horizen RPC — network-first, no persistent cache (state changes)
{
urlPattern: ({ url }) =>
url.hostname.includes('horizen') ||
url.hostname.includes('eon') ||
(url.pathname === '/' && url.port !== ''),
handler: 'NetworkFirst',
options: {
cacheName: 'evm-rpc',
networkTimeoutSeconds: 10,
expiration: { maxEntries: 20, maxAgeSeconds: 30 },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
devOptions: {
enabled: false, // keep dev fast; SW only active in production build
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
Expand Down