feat: UI refactor

This commit is contained in:
Danish Arora 2025-03-27 14:49:19 +05:30
parent 56a81109ab
commit cb5f76021e
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
12 changed files with 805 additions and 658 deletions

View File

@ -3,6 +3,7 @@ import { Inter } from 'next/font/google'
import "./globals.css";
import { WalletProvider, RLNImplementationProvider, KeystoreProvider, RLNProvider } from "../contexts/index";
import { Header } from "../components/Header";
import { AppStateProvider } from "../contexts/AppStateContext";
const inter = Inter({
variable: "--font-inter",
@ -24,20 +25,22 @@ export default function RootLayout({
<body
className={`${inter.variable} antialiased`}
>
<WalletProvider>
<RLNImplementationProvider>
<KeystoreProvider>
<RLNProvider>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow">
{children}
</main>
</div>
</RLNProvider>
</KeystoreProvider>
</RLNImplementationProvider>
</WalletProvider>
<AppStateProvider>
<WalletProvider>
<RLNImplementationProvider>
<KeystoreProvider>
<RLNProvider>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow">
{children}
</main>
</div>
</RLNProvider>
</KeystoreProvider>
</RLNImplementationProvider>
</WalletProvider>
</AppStateProvider>
</body>
</html>
);

View File

@ -1,54 +1,15 @@
import RLNMembershipRegistration from '../components/RLNMembershipRegistration';
import { WalletInfo } from '../components/WalletInfo';
import { RLNImplementationToggle } from '../components/RLNImplementationToggle';
import KeystoreManager from '../components/KeystoreManager';
import { RLNInitButton } from '../components/RLNInitButton';
"use client";
import React from 'react';
import { Layout } from '../components/Layout';
import { MembershipRegistration } from '../components/Tabs/MembershipTab/MembershipRegistration';
import { KeystoreManagement } from '../components/Tabs/KeystoreTab/KeystoreManagement';
export default function Home() {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-md dark:bg-gray-800 p-6">
<h2 className="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Waku Keystore Management</h2>
<div className="space-y-8">
{/* RLN Implementation Toggle */}
<div>
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">RLN Implementation</h3>
<div className="space-y-4">
<RLNImplementationToggle />
<RLNInitButton />
</div>
</div>
{/* Wallet Information Section */}
<div>
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">Wallet Connection</h3>
<WalletInfo />
</div>
{/* RLN Membership Registration Section */}
<div>
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">RLN Membership</h3>
<p className="mb-4 text-gray-700 dark:text-gray-300">
Register a new RLN membership to participate in Waku RLN Relay without exposing your private key on your node.
Set your desired rate limit for messages per epoch.
</p>
<RLNMembershipRegistration />
</div>
{/* Keystore Management Section */}
<div>
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">Keystore Management</h3>
<p className="mb-4 text-gray-700 dark:text-gray-300">
Export your keystore credentials to use them with your Waku node or import existing credentials.
Keep your keystores safe as they contain your membership information.
</p>
<KeystoreManager />
</div>
</div>
</div>
</div>
</div>
<Layout>
<MembershipRegistration />
<KeystoreManagement />
</Layout>
);
}

View File

@ -5,14 +5,12 @@ import { WalletInfo } from './WalletInfo';
export function Header() {
return (
<header className="sticky top-0 z-10 bg-white dark:bg-gray-900 shadow-sm">
<div className="container mx-auto px-4 py-3 flex justify-between items-center">
<header className="bg-gray-900 border-b border-gray-800">
<div className="container mx-auto px-6 h-16 flex justify-between items-center">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Waku Keystore Management</h1>
</div>
<div className="w-80">
<WalletInfo />
<h1 className="text-xl font-medium text-white">Waku Keystore Management</h1>
</div>
<WalletInfo />
</div>
</header>
);

View File

@ -1,123 +0,0 @@
"use client";
import { useState } from 'react';
import { useKeystore } from '../contexts/index';
import { saveKeystoreToFile, readKeystoreFromFile } from '../utils/fileUtils';
export default function KeystoreManager() {
const {
isInitialized: isKeystoreInitialized,
hasStoredCredentials,
storedCredentialsHashes,
exportKeystore,
importKeystore
} = useKeystore();
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const handleExport = () => {
try {
const keystoreJson = exportKeystore();
saveKeystoreToFile(keystoreJson);
setSuccessMessage('Keystore exported successfully');
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to export keystore');
setTimeout(() => setError(null), 3000);
}
};
const handleImport = async () => {
try {
const keystoreJson = await readKeystoreFromFile();
const success = importKeystore(keystoreJson);
if (success) {
setSuccessMessage('Keystore imported successfully');
} else {
setError('Failed to import keystore');
}
setTimeout(() => {
setSuccessMessage(null);
setError(null);
}, 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import keystore');
setTimeout(() => setError(null), 3000);
}
};
if (!isKeystoreInitialized) {
return (
<div className="p-4 bg-gray-100 dark:bg-gray-700 rounded-lg">
<p className="text-gray-700 dark:text-gray-300">Initializing keystore...</p>
</div>
);
}
return (
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">Keystore Management</h2>
{/* Status */}
<div className="mb-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-700 dark:text-gray-300">
<span className="font-semibold">Status:</span> {hasStoredCredentials ? 'Credentials found' : 'No credentials stored'}
</p>
{hasStoredCredentials && (
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1">
<span className="font-semibold">Stored credentials:</span> {storedCredentialsHashes.length}
</p>
)}
</div>
{/* Notifications */}
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
)}
{successMessage && (
<div className="mb-4 p-3 bg-green-50 dark:bg-green-900 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-300">{successMessage}</p>
</div>
)}
{/* Import/Export Buttons */}
<div className="space-y-4">
{/* Export Keystore */}
<div>
<button
onClick={handleExport}
disabled={!hasStoredCredentials}
className={`w-full py-2 px-4 rounded ${
!hasStoredCredentials
? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
: 'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800 dark:bg-blue-700 dark:hover:bg-blue-800'
}`}
>
Export Keystore
</button>
{!hasStoredCredentials && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
No credentials to export
</p>
)}
</div>
{/* Import Keystore */}
<div>
<button
onClick={handleImport}
className="w-full py-2 px-4 bg-green-600 text-white rounded hover:bg-green-700 active:bg-green-800 dark:bg-green-700 dark:hover:bg-green-800"
>
Import Keystore
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import React, { Children, isValidElement } from 'react';
import { TabNavigation, TabItem } from './Tabs/TabNavigation';
import { useAppState } from '../contexts/AppStateContext';
const tabs: TabItem[] = [
{
id: 'membership',
label: 'Membership Registration',
},
{
id: 'keystore',
label: 'Keystore Management',
},
];
const componentToTabId: Record<string, string> = {
MembershipRegistration: 'membership',
KeystoreManagement: 'keystore',
};
export function Layout({ children }: { children: React.ReactNode }) {
const { activeTab, setActiveTab } = useAppState();
const childrenArray = Children.toArray(children);
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<main className="container mx-auto px-4 py-6">
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<div className="mt-6">
{childrenArray.map((child) => {
if (isValidElement(child) && typeof child.type === 'function') {
const componentName = child.type.name;
const tabId = componentToTabId[componentName];
if (tabId === activeTab) {
return child;
}
}
return null;
})}
</div>
</main>
</div>
);
}

View File

@ -1,50 +1,44 @@
"use client";
import { useRLNImplementation, type RLNImplementationType } from '../contexts/index';
import React from 'react';
import { useRLNImplementation } from '../contexts/rln';
export function RLNImplementationToggle() {
const { implementation, setImplementation } = useRLNImplementation();
const handleToggle = (newImplementation: RLNImplementationType) => {
setImplementation(newImplementation);
};
return (
<div className="flex items-center space-x-4 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
RLN Implementation:
</span>
<div className="flex rounded-md shadow-sm" role="group">
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
RLN Implementation
</label>
<div className="flex space-x-4">
<button
type="button"
onClick={() => handleToggle('standard')}
className={`px-4 py-2 text-sm font-medium rounded-l-lg ${
onClick={() => setImplementation('standard')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
implementation === 'standard'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
Standard
</button>
<button
type="button"
onClick={() => handleToggle('light')}
className={`px-4 py-2 text-sm font-medium rounded-r-lg ${
onClick={() => setImplementation('light')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
implementation === 'light'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
Light
</button>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{implementation === 'standard' ? (
<span>Using full RLN implementation</span>
) : (
<span>Using lightweight RLN implementation</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{implementation === 'standard'
? 'Standard implementation with full security features'
: 'Light implementation with optimized performance'
}
</p>
</div>
);
}

View File

@ -1,398 +0,0 @@
"use client";
import { useState } from 'react';
import { useWallet } from '../contexts/index';
import { KeystoreEntity } from '@waku/rln';
import { useRLN } from '../contexts/rln';
export default function RLNMembershipRegistration() {
const { registerMembership, isInitialized, isStarted, rateMinLimit, rateMaxLimit, error, initializeRLN } = useRLN();
const { isConnected, address, chainId } = useWallet();
const [rateLimit, setRateLimit] = useState<number>(rateMinLimit);
const [isRegistering, setIsRegistering] = useState(false);
const [isInitializing, setIsInitializing] = useState(false);
const [saveToKeystore, setSaveToKeystore] = useState(true);
const [keystorePassword, setKeystorePassword] = useState('');
const [registrationResult, setRegistrationResult] = useState<{
success?: boolean;
error?: string;
txHash?: string;
warning?: string;
credentials?: KeystoreEntity;
keystoreHash?: string;
}>({});
const isLineaSepolia = chainId === 59141;
const handleRateLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value);
setRateLimit(isNaN(value) ? rateMinLimit : value);
};
const handleInitializeRLN = async () => {
setIsInitializing(true);
try {
await initializeRLN();
} catch (err) {
console.error("Error initializing RLN:", err);
} finally {
setIsInitializing(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isConnected) {
setRegistrationResult({ success: false, error: 'Please connect your wallet first' });
return;
}
if (!isInitialized || !isStarted) {
setRegistrationResult({ success: false, error: 'RLN is not initialized' });
return;
}
if (!isLineaSepolia) {
setRegistrationResult({ success: false, error: 'Please switch to Linea Sepolia network' });
return;
}
// Validate keystore password if saving to keystore
if (saveToKeystore && keystorePassword.length < 8) {
setRegistrationResult({
success: false,
error: 'Keystore password must be at least 8 characters long'
});
return;
}
setIsRegistering(true);
setRegistrationResult({});
try {
setRegistrationResult({
success: undefined,
warning: 'Please check your wallet to sign the registration message.'
});
// Pass save options if saving to keystore
const saveOptions = saveToKeystore ? { password: keystorePassword } : undefined;
const result = await registerMembership(rateLimit, saveOptions);
setRegistrationResult({
...result,
credentials: result.credentials
});
// Clear password field after successful registration
if (result.success) {
setKeystorePassword('');
}
} catch (error) {
setRegistrationResult({
success: false,
error: error instanceof Error ? error.message : 'Registration failed'
});
} finally {
setIsRegistering(false);
}
};
return (
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
RLN Membership Registration
</h2>
{/* Network Warning */}
{isConnected && !isLineaSepolia && (
<div className="mb-4 bg-orange-50 dark:bg-orange-900 p-4 rounded-lg">
<p className="text-sm text-orange-700 dark:text-orange-400">
<strong>Warning:</strong> You are not connected to Linea Sepolia network. Please switch networks to register.
</p>
</div>
)}
{/* Informational Box */}
<div className="mb-6 bg-blue-50 dark:bg-blue-900 p-4 rounded-lg">
<h3 className="text-md font-semibold text-blue-800 dark:text-blue-300 mb-2">
About RLN Membership on Linea Sepolia
</h3>
<p className="text-sm text-blue-700 dark:text-blue-400 mb-2">
RLN (Rate Limiting Nullifier) membership allows you to participate in Waku RLN Relay with rate limiting protection,
without exposing your private keys on your node.
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mb-2">
This application is configured to use the <strong>Linea Sepolia</strong> testnet for RLN registrations.
</p>
<p className="text-sm text-blue-700 dark:text-blue-400">
When you register, your wallet will sign a message that will be used to generate a cryptographic identity
for your membership. This allows your node to prove it has permission to send messages without revealing your identity.
</p>
</div>
{/* Initialization Status */}
<div className="mb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
RLN Status:
<span className={isInitialized && isStarted ? "text-green-600 ml-2" : "text-amber-600 ml-2"}>
{isInitialized && isStarted ? "Ready" : "Not Initialized"}
</span>
</p>
</div>
{isConnected && (!isInitialized || !isStarted) && (
<button
onClick={handleInitializeRLN}
disabled={isInitializing || !isLineaSepolia}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
isInitializing
? "bg-gray-400 text-gray-700 cursor-not-allowed"
: isLineaSepolia
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-400 text-gray-700 cursor-not-allowed"
}`}
>
{isInitializing ? "Initializing..." : "Initialize RLN"}
</button>
)}
</div>
{error && (
<p className="text-xs text-red-600 mt-1">{error}</p>
)}
</div>
{isInitialized && !isStarted && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-400">
<strong>Note:</strong> RLN is partially initialized. You can still proceed with registration, but some advanced features might be limited.
</p>
</div>
)}
{!isConnected ? (
<div className="text-amber-600 dark:text-amber-400 mb-4">
Please connect your wallet to register a membership
</div>
) : !isInitialized || !isStarted ? (
<div className="text-amber-600 dark:text-amber-400 mb-4">
Please initialize RLN before registering a membership
</div>
) : (
<>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="rateLimit"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Rate Limit (messages per epoch)
</label>
<div className="flex items-center">
<input
type="range"
id="rateLimit"
name="rateLimit"
min={rateMinLimit}
max={rateMaxLimit}
value={rateLimit}
onChange={handleRateLimitChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<span className="ml-3 w-12 text-gray-700 dark:text-gray-300">{rateLimit}</span>
</div>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Select a rate limit between {rateMinLimit} and {rateMaxLimit}
</p>
</div>
{/* Keystore Options */}
<div className="space-y-2">
<div className="flex items-center">
<input
type="checkbox"
id="saveToKeystore"
checked={saveToKeystore}
onChange={(e) => setSaveToKeystore(e.target.checked)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600"
/>
<label
htmlFor="saveToKeystore"
className="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
Save credentials to keystore
</label>
</div>
{saveToKeystore && (
<div>
<label
htmlFor="keystorePassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Keystore Password (min 8 characters)
</label>
<input
type="password"
id="keystorePassword"
autoComplete='password'
value={keystorePassword}
onChange={(e) => setKeystorePassword(e.target.value)}
placeholder="Enter password to encrypt your keystore"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
minLength={8}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
The password will be used to encrypt your RLN credentials in the keystore.
You will need this password to decrypt your credentials later.
</p>
</div>
)}
</div>
{address && (
<div className="text-sm text-gray-600 dark:text-gray-400 p-3 border border-gray-200 dark:border-gray-700 rounded-md">
<p className="font-medium mb-1">Registration Details:</p>
<p>Connected Address: {address.slice(0, 8)}...{address.slice(-6)}</p>
<p className="mt-1">When you register, your wallet will sign a secure message containing a random nonce. This signature will be used to generate your RLN credentials without exposing your private key.</p>
</div>
)}
<button
type="submit"
disabled={isRegistering || !isInitialized || !isStarted || (saveToKeystore && keystorePassword.length < 8)}
className={`w-full py-2 px-4 rounded-md text-white font-medium
${isRegistering || !isInitialized || !isStarted || (saveToKeystore && keystorePassword.length < 8)
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
}`}
>
{isRegistering ? 'Registering...' : 'Register Membership'}
</button>
</form>
{registrationResult.warning && registrationResult.success === undefined && (
<div className="mt-4 p-3 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">
<p className="font-medium">Important:</p>
<p className="text-sm mt-1">{registrationResult.warning}</p>
<div className="text-sm mt-1">
You&apos;ll need to sign a message with your wallet to complete the registration.
</div>
</div>
)}
{registrationResult.success === true && (
<div className="mt-4 p-3 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
<p className="font-medium">Registration submitted!</p>
{registrationResult.txHash && (
<div>
<p className="text-sm mt-1 break-all">
{registrationResult.txHash}
</p>
{registrationResult.txHash.startsWith('0x') && (
<p className="mt-2">
<a
href={`https://sepolia.lineascan.build/tx/${registrationResult.txHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 underline"
>
View on Linea Sepolia Explorer
</a>
</p>
)}
</div>
)}
{registrationResult.warning && (
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-300">
<strong>Note:</strong> {registrationResult.warning}
</p>
)}
<p className="text-sm mt-2">
Your RLN membership is now registered and can be used with your Waku node.
</p>
{registrationResult.credentials && (
<><div className="mt-3 p-3 bg-gray-100 dark:bg-gray-800 rounded-md">
<p className="font-medium mb-2">Your RLN Credentials:</p>
<div className="text-xs font-mono overflow-auto">
<h4 className="font-semibold mt-2 mb-1">Identity:</h4>
<p className="mb-1">
<span className="font-semibold">ID Commitment:</span> {Buffer.from(registrationResult.credentials.identity.IDCommitment).toString('hex')}
</p>
<p className="mb-1">
<span className="font-semibold">ID Secret Hash:</span> {Buffer.from(registrationResult.credentials.identity.IDSecretHash).toString('hex')}
</p>
<p className="mb-1">
<span className="font-semibold">ID Nullifier:</span> {Buffer.from(registrationResult.credentials.identity.IDNullifier).toString('hex')}
</p>
<p className="mb-3">
<span className="font-semibold">ID Trapdoor:</span> {Buffer.from(registrationResult.credentials.identity.IDTrapdoor).toString('hex')}
</p>
<h4 className="font-semibold mt-3 mb-1">Membership:</h4>
<p className="mb-1">
<span className="font-semibold">Chain ID:</span> {registrationResult.credentials.membership.chainId}
</p>
<p className="mb-1">
<span className="font-semibold">Contract Address:</span> {registrationResult.credentials.membership.address}
</p>
<p className="mb-1">
<span className="font-semibold">Tree Index:</span> {registrationResult.credentials.membership.treeIndex}
</p>
<p className="mb-1">
<span className="font-semibold">Rate Limit:</span> {registrationResult.credentials.membership.rateLimit}
</p>
</div>
<p className="text-xs mt-2 text-gray-600 dark:text-gray-400">
These credentials are your proof of membership. Store them securely.
</p>
</div>
</>
)}
{registrationResult.keystoreHash && (
<p className="text-sm mt-2">
<span className="font-medium">Credentials saved to keystore!</span>
<br />
Hash: {registrationResult.keystoreHash.slice(0, 10)}...{registrationResult.keystoreHash.slice(-8)}
</p>
)}
</div>
)}
{registrationResult.success === false && (
<div className="mt-4 p-3 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded">
<p className="font-medium">Registration failed</p>
<p className="text-sm mt-1">{registrationResult.error}</p>
{registrationResult.error?.includes("field") && (
<div className="mt-2 text-sm">
<p>
This is a mathematical constraint in the zero-knowledge proof system.
Your wallet&apos;s signatures produce values that aren&apos;t compatible with the RLN cryptographic system.
</p>
<p className="mt-2 font-medium">Recommended solution:</p>
<p>Please try using a different wallet address for registration. Different wallet addresses
generate different signatures, and some are more compatible with the RLN cryptographic system.</p>
</div>
)}
</div>
)}
</>
)}
{/* Debug Info (For Development) */}
<div className="mt-6 p-3 border border-gray-200 dark:border-gray-700 rounded text-xs">
<p className="font-semibold">Debug Info:</p>
<p>Wallet Connected: {isConnected ? "Yes" : "No"}</p>
<p>RLN Initialized: {isInitialized ? "Yes" : "No"}</p>
<p>RLN Started: {isStarted ? "Yes" : "No"}</p>
<p>Min Rate: {rateMinLimit}, Max Rate: {rateMaxLimit}</p>
<p>Current Rate Limit: {rateLimit}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,252 @@
"use client";
import React, { useEffect, useState } from 'react';
import { useKeystore } from '../../../contexts/keystore';
import { useAppState } from '../../../contexts/AppStateContext';
import { useRLN } from '../../../contexts/rln';
import { saveKeystoreToFile, readKeystoreFromFile } from '../../../utils/fileUtils';
import { KeystoreEntity } from '@waku/rln';
export function KeystoreManagement() {
const {
hasStoredCredentials,
storedCredentialsHashes,
error,
exportKeystore,
importKeystore,
removeCredential,
loadCredential
} = useKeystore();
const { setGlobalError } = useAppState();
const { isInitialized, isStarted } = useRLN();
const [selectedHash, setSelectedHash] = useState<string | null>(null);
const [password, setPassword] = useState('');
const [loadingCredential, setLoadingCredential] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadedCredential, setLoadedCredential] = useState<KeystoreEntity | null>(null);
const [showSuccess, setShowSuccess] = useState(false);
useEffect(() => {
if (error) {
setGlobalError(error);
}
}, [error, setGlobalError]);
const handleExport = () => {
try {
const keystoreJson = exportKeystore();
saveKeystoreToFile(keystoreJson);
} catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to export keystore');
}
};
const handleImport = async () => {
try {
const keystoreJson = await readKeystoreFromFile();
const success = importKeystore(keystoreJson);
if (!success) {
setGlobalError('Failed to import keystore');
}
} catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to import keystore');
}
};
const handleRemoveCredential = (hash: string) => {
try {
removeCredential(hash);
if (selectedHash === hash) {
setSelectedHash(null);
setPassword('');
setLoadedCredential(null);
}
} catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to remove credential');
}
};
const handleLoadCredential = async (hash: string) => {
if (!password) {
setLoadError('Please enter a password');
return;
}
if (!isInitialized || !isStarted) {
setLoadError('Please initialize RLN first');
return;
}
setLoadingCredential(true);
setLoadError(null);
setShowSuccess(false);
try {
const credential = await loadCredential(hash, password);
if (credential) {
setLoadedCredential(credential);
setLoadError(null);
setPassword('');
setShowSuccess(true);
// Auto-hide success message after 3 seconds
setTimeout(() => setShowSuccess(false), 3000);
} else {
setLoadError('Failed to load credential');
setLoadedCredential(null);
}
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Failed to load credential');
setLoadedCredential(null);
} finally {
setLoadingCredential(false);
}
};
return (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Keystore Management
</h2>
<div className="space-y-6">
{/* Import/Export Actions */}
<div className="flex space-x-4">
<button
onClick={handleExport}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
>
Export Keystore
</button>
<button
onClick={handleImport}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
>
Import Keystore
</button>
</div>
{/* RLN Status */}
{!isInitialized || !isStarted ? (
<div className="bg-yellow-50 dark:bg-yellow-900 p-4 rounded-lg">
<p className="text-sm text-yellow-700 dark:text-yellow-300">
Please initialize RLN before loading credentials
</p>
</div>
) : null}
{/* Loaded Credential Info */}
{loadedCredential && (
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Currently Loaded Credential
</h4>
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium">Hash:</span>{' '}
<code className="break-all">{selectedHash}</code>
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium">Status:</span>{' '}
<span className="text-green-600 dark:text-green-400">Active</span>
</p>
</div>
</div>
)}
{/* Success Message */}
{showSuccess && (
<div className="bg-green-50 dark:bg-green-900 p-4 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-300">
Credential loaded successfully
</p>
</div>
)}
{/* Stored Credentials */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">
Stored Credentials
</h3>
{hasStoredCredentials ? (
<div className="space-y-4">
{storedCredentialsHashes.map((hash) => (
<div
key={hash}
className={`p-4 rounded-lg border ${
selectedHash === hash
? 'border-blue-500 dark:border-blue-400'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="space-y-1">
<code className="text-sm text-gray-600 dark:text-gray-400 break-all">
{hash}
</code>
{selectedHash === hash && (
<div className="pt-3 space-y-3">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password to load credential"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
{loadError && (
<p className="text-sm text-red-600 dark:text-red-400">
{loadError}
</p>
)}
</div>
)}
</div>
<div className="flex space-x-2">
<button
onClick={() => {
if (selectedHash === hash) {
setSelectedHash(null);
setPassword('');
setLoadedCredential(null);
} else {
setSelectedHash(hash);
setPassword('');
}
}}
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800"
>
{selectedHash === hash ? 'Cancel' : 'Load'}
</button>
{selectedHash === hash && (
<button
onClick={() => handleLoadCredential(hash)}
disabled={loadingCredential || !isInitialized || !isStarted}
className="px-3 py-1 text-sm bg-green-100 text-green-700 rounded-md hover:bg-green-200 dark:bg-green-900 dark:text-green-300 dark:hover:bg-green-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingCredential ? 'Loading...' : 'Confirm'}
</button>
)}
<button
onClick={() => handleRemoveCredential(hash)}
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 dark:bg-red-900 dark:text-red-300 dark:hover:bg-red-800"
>
Remove
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
No credentials stored yet.
</p>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,269 @@
"use client";
import React, { useState, useEffect } from 'react';
import { RLNInitButton } from '../../RLNInitButton';
import { RLNImplementationToggle } from '../../RLNImplementationToggle';
import { useAppState } from '../../../contexts/AppStateContext';
import { useRLN } from '../../../contexts/rln';
import { useWallet } from '../../../contexts/wallet';
import { KeystoreEntity } from '@waku/rln';
export function MembershipRegistration() {
const { setGlobalError } = useAppState();
const { registerMembership, isInitialized, isStarted, rateMinLimit, rateMaxLimit, error } = useRLN();
const { isConnected, chainId } = useWallet();
const [rateLimit, setRateLimit] = useState<number>(rateMinLimit);
const [isRegistering, setIsRegistering] = useState(false);
const [saveToKeystore, setSaveToKeystore] = useState(true);
const [keystorePassword, setKeystorePassword] = useState('');
const [registrationResult, setRegistrationResult] = useState<{
success?: boolean;
error?: string;
txHash?: string;
warning?: string;
credentials?: KeystoreEntity;
keystoreHash?: string;
}>({});
const isLineaSepolia = chainId === 59141;
useEffect(() => {
if (error) {
setGlobalError(error);
}
}, [error, setGlobalError]);
const handleRateLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value);
setRateLimit(isNaN(value) ? rateMinLimit : value);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isConnected) {
setRegistrationResult({ success: false, error: 'Please connect your wallet first' });
return;
}
if (!isInitialized || !isStarted) {
setRegistrationResult({ success: false, error: 'RLN is not initialized' });
return;
}
if (!isLineaSepolia) {
setRegistrationResult({ success: false, error: 'Please switch to Linea Sepolia network' });
return;
}
// Validate keystore password if saving to keystore
if (saveToKeystore && keystorePassword.length < 8) {
setRegistrationResult({
success: false,
error: 'Keystore password must be at least 8 characters long'
});
return;
}
setIsRegistering(true);
setRegistrationResult({});
try {
setRegistrationResult({
success: undefined,
warning: 'Please check your wallet to sign the registration message.'
});
// Pass save options if saving to keystore
const saveOptions = saveToKeystore ? { password: keystorePassword } : undefined;
const result = await registerMembership(rateLimit, saveOptions);
setRegistrationResult({
...result,
credentials: result.credentials
});
// Clear password field after successful registration
if (result.success) {
setKeystorePassword('');
}
} catch (error) {
setRegistrationResult({
success: false,
error: error instanceof Error ? error.message : 'Registration failed'
});
} finally {
setIsRegistering(false);
}
};
return (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
RLN Membership Registration
</h2>
<div className="space-y-6">
<div className="border-b border-gray-200 dark:border-gray-700 pb-6">
<RLNImplementationToggle />
</div>
{/* Network Warning */}
{isConnected && !isLineaSepolia && (
<div className="bg-orange-50 dark:bg-orange-900 p-4 rounded-lg">
<p className="text-sm text-orange-700 dark:text-orange-400">
<strong>Warning:</strong> You are not connected to Linea Sepolia network. Please switch networks to register.
</p>
</div>
)}
{/* Informational Box */}
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg">
<h3 className="text-md font-semibold text-blue-800 dark:text-blue-300 mb-2">
About RLN Membership on Linea Sepolia
</h3>
<p className="text-sm text-blue-700 dark:text-blue-400 mb-2">
RLN (Rate Limiting Nullifier) membership allows you to participate in Waku RLN Relay with rate limiting protection,
without exposing your private keys on your node.
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mb-2">
This application is configured to use the <strong>Linea Sepolia</strong> testnet for RLN registrations.
</p>
<p className="text-sm text-blue-700 dark:text-blue-400">
When you register, your wallet will sign a message that will be used to generate a cryptographic identity
for your membership. This allows your node to prove it has permission to send messages without revealing your identity.
</p>
</div>
<div className="flex items-center space-x-2">
<RLNInitButton />
{isInitialized && isStarted && (
<span className="text-sm text-green-600 dark:text-green-400">
RLN Initialized
</span>
)}
</div>
{!isConnected ? (
<div className="text-amber-600 dark:text-amber-400">
Please connect your wallet to register a membership
</div>
) : !isInitialized || !isStarted ? (
<div className="text-amber-600 dark:text-amber-400">
Please initialize RLN before registering a membership
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="rateLimit"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Rate Limit (messages per epoch)
</label>
<div className="flex items-center space-x-4">
<input
type="range"
id="rateLimit"
name="rateLimit"
min={rateMinLimit}
max={rateMaxLimit}
value={rateLimit}
onChange={handleRateLimitChange}
className="w-full"
/>
<span className="text-sm text-gray-600 dark:text-gray-400 w-12">
{rateLimit}
</span>
</div>
</div>
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
id="saveToKeystore"
checked={saveToKeystore}
onChange={(e) => setSaveToKeystore(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label
htmlFor="saveToKeystore"
className="ml-2 text-sm text-gray-700 dark:text-gray-300"
>
Save credentials to keystore
</label>
</div>
{saveToKeystore && (
<div>
<label
htmlFor="keystorePassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Keystore Password (min 8 characters)
</label>
<input
type="password"
id="keystorePassword"
value={keystorePassword}
onChange={(e) => setKeystorePassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Enter password to encrypt credentials"
/>
</div>
)}
</div>
<button
type="submit"
disabled={isRegistering || !isLineaSepolia || (saveToKeystore && !keystorePassword)}
className={`w-full px-4 py-2 text-sm font-medium rounded-md ${
isRegistering || !isLineaSepolia || (saveToKeystore && !keystorePassword)
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isRegistering ? 'Registering...' : 'Register Membership'}
</button>
</form>
)}
{/* Registration Result */}
{registrationResult.warning && (
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900 rounded-lg">
<p className="text-sm text-yellow-700 dark:text-yellow-400">
{registrationResult.warning}
</p>
</div>
)}
{registrationResult.error && (
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">
{registrationResult.error}
</p>
</div>
)}
{registrationResult.success && (
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900 rounded-lg">
<p className="text-sm text-green-700 dark:text-green-400 mb-2">
Membership registered successfully!
</p>
{registrationResult.txHash && (
<p className="text-xs text-green-600 dark:text-green-500">
Transaction Hash: {registrationResult.txHash}
</p>
)}
{registrationResult.keystoreHash && (
<p className="text-xs text-green-600 dark:text-green-500">
Credentials saved to keystore with hash: {registrationResult.keystoreHash}
</p>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
"use client";
import React from 'react';
export type TabItem = {
id: string;
label: string;
};
interface TabNavigationProps {
tabs: TabItem[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export function TabNavigation({ tabs, activeTab, onTabChange }: TabNavigationProps) {
return (
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
py-4 px-1 border-b-2 font-medium text-sm
${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}
`}
aria-current={activeTab === tab.id ? 'page' : undefined}
>
{tab.label}
</button>
))}
</nav>
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useWallet } from '../contexts/index';
function getNetworkName(chainId: number | null): string {
@ -23,6 +23,20 @@ interface ProviderRpcError extends Error {
export function WalletInfo() {
const { isConnected, address, balance, chainId, connectWallet, disconnectWallet, error } = useWallet();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Function to switch to Linea Sepolia network
const switchToLineaSepolia = async () => {
@ -32,13 +46,11 @@ export function WalletInfo() {
}
try {
// Try to switch to Linea Sepolia
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: LINEA_SEPOLIA_CHAIN_ID }],
});
} catch (err) {
// If the error code is 4902, the chain hasn't been added to MetaMask
const providerError = err as ProviderRpcError;
if (providerError.code === 4902) {
try {
@ -70,64 +82,90 @@ export function WalletInfo() {
// Check if user is on unsupported network
const isUnsupportedNetwork = isConnected && chainId !== 59141;
if (!isConnected) {
return (
<div className="flex items-center">
<button
onClick={connectWallet}
className="h-7 px-3 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors"
>
Connect Wallet
</button>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-sm dark:bg-gray-800 p-3">
<div className="relative" ref={dropdownRef}>
{error && (
<div className="mb-2 p-2 text-xs bg-red-100 text-red-700 rounded-md dark:bg-red-900 dark:text-red-100">
<div className="absolute right-0 -top-2 transform -translate-y-full mb-2 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded whitespace-nowrap">
<p>{error}</p>
</div>
)}
{isConnected ? (
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<div className="mr-3">
<div className="flex items-center mb-1">
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1">Address:</span>
<span className="font-mono text-xs text-gray-900 dark:text-white truncate max-w-[120px]">
{address}
</span>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-3 h-8 rounded transition-colors ${
isOpen ? 'bg-gray-800' : 'hover:bg-gray-800'
}`}
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-300 truncate max-w-[140px]">
{address}
</span>
<span className="font-mono text-xs text-gray-400">
{balance ? `${parseFloat(balance).toFixed(4)} ETH` : 'Loading...'}
</span>
</div>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-72 rounded-lg bg-gray-800 shadow-lg border border-gray-700 overflow-hidden">
<div className="p-3">
<div className="flex flex-col gap-2 text-gray-300">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Address:</span>
<span className="font-mono text-xs truncate">{address}</span>
</div>
<div className="flex items-center mb-1">
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1">Network:</span>
<span className={`font-mono text-xs ${isUnsupportedNetwork ? 'text-orange-600 dark:text-orange-400' : 'text-gray-900 dark:text-white'}`}>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Network:</span>
<span className={`font-mono text-xs ${isUnsupportedNetwork ? 'text-orange-400' : ''}`}>
{getNetworkName(chainId)}
</span>
</div>
<div className="flex items-center">
<span className="text-xs text-gray-500 dark:text-gray-400 mr-1">Balance:</span>
<span className="font-mono text-xs text-gray-900 dark:text-white">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Balance:</span>
<span className="font-mono text-xs">
{balance ? `${parseFloat(balance).toFixed(4)} ETH` : 'Loading...'}
</span>
</div>
</div>
</div>
<div className="border-t border-gray-700 p-2 flex flex-col gap-2">
{isUnsupportedNetwork && (
<button
onClick={switchToLineaSepolia}
className="w-full h-7 px-3 text-xs bg-orange-500/20 text-orange-300 rounded hover:bg-orange-500/30 transition-colors"
>
Switch to Linea Sepolia
</button>
)}
<button
onClick={disconnectWallet}
className="px-2 py-1 text-xs bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
className="w-full h-7 px-3 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors"
>
Disconnect
</button>
</div>
{isUnsupportedNetwork && (
<div className="flex flex-col gap-2 mt-1">
<button
onClick={switchToLineaSepolia}
className="w-full px-2 py-1 text-xs bg-orange-500 text-white rounded-md hover:bg-orange-600 transition-colors"
>
Switch to Linea Sepolia
</button>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center">
<button
onClick={connectWallet}
className="px-3 py-1 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Connect Wallet
</button>
</div>
)}
</div>

View File

@ -0,0 +1,62 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface AppState {
isLoading: boolean;
globalError: string | null;
setIsLoading: (loading: boolean) => void;
setGlobalError: (error: string | null) => void;
activeTab: string;
setActiveTab: (tab: string) => void;
}
const AppStateContext = createContext<AppState | undefined>(undefined);
export function AppStateProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(false);
const [globalError, setGlobalError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('membership');
const value = {
isLoading,
setIsLoading,
globalError,
setGlobalError,
activeTab,
setActiveTab,
};
return (
<AppStateContext.Provider value={value}>
{children}
{isLoading && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 flex items-center space-x-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500"></div>
<span className="text-gray-900 dark:text-white">Loading...</span>
</div>
</div>
)}
{globalError && (
<div className="fixed bottom-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50 flex items-center">
<span>{globalError}</span>
<button
onClick={() => setGlobalError(null)}
className="ml-3 text-red-700 hover:text-red-900"
>
</button>
</div>
)}
</AppStateContext.Provider>
);
}
export function useAppState() {
const context = useContext(AppStateContext);
if (context === undefined) {
throw new Error('useAppState must be used within an AppStateProvider');
}
return context;
}