mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-12-13 12:52:31 -05:00
refactor: restructure for better ai comprehension
This commit is contained in:
182
src/components/ui/tabs/ActionsTab.tsx
Normal file
182
src/components/ui/tabs/ActionsTab.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMiniApp } from "@neynar/react";
|
||||
import { ShareButton } from "../Share";
|
||||
import { Button } from "../Button";
|
||||
import { SignIn } from "../wallet/SignIn";
|
||||
import { type Haptics } from "@farcaster/frame-sdk";
|
||||
|
||||
/**
|
||||
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
|
||||
*
|
||||
* This component provides the main interaction interface for users to:
|
||||
* - Share the mini app with others
|
||||
* - Sign in with Farcaster
|
||||
* - Send notifications to their account
|
||||
* - Trigger haptic feedback
|
||||
* - Add the mini app to their client
|
||||
* - Copy share URLs
|
||||
*
|
||||
* The component uses the useMiniApp hook to access Farcaster context and actions.
|
||||
* All state is managed locally within this component.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ActionsTab />
|
||||
* ```
|
||||
*/
|
||||
export function ActionsTab() {
|
||||
// --- Hooks ---
|
||||
const {
|
||||
actions,
|
||||
added,
|
||||
notificationDetails,
|
||||
haptics,
|
||||
context,
|
||||
} = useMiniApp();
|
||||
|
||||
// --- State ---
|
||||
const [notificationState, setNotificationState] = useState({
|
||||
sendStatus: "",
|
||||
shareUrlCopied: false,
|
||||
});
|
||||
const [selectedHapticIntensity, setSelectedHapticIntensity] = useState<Haptics.ImpactOccurredType>('medium');
|
||||
|
||||
// --- Handlers ---
|
||||
/**
|
||||
* Sends a notification to the current user's Farcaster account.
|
||||
*
|
||||
* This function makes a POST request to the /api/send-notification endpoint
|
||||
* with the user's FID and notification details. It handles different response
|
||||
* statuses including success (200), rate limiting (429), and errors.
|
||||
*
|
||||
* @returns Promise that resolves when the notification is sent or fails
|
||||
*/
|
||||
const sendFarcasterNotification = useCallback(async () => {
|
||||
setNotificationState((prev) => ({ ...prev, sendStatus: "" }));
|
||||
if (!notificationDetails || !context) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch("/api/send-notification", {
|
||||
method: "POST",
|
||||
mode: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fid: context.user.fid,
|
||||
notificationDetails,
|
||||
}),
|
||||
});
|
||||
if (response.status === 200) {
|
||||
setNotificationState((prev) => ({ ...prev, sendStatus: "Success" }));
|
||||
return;
|
||||
} else if (response.status === 429) {
|
||||
setNotificationState((prev) => ({ ...prev, sendStatus: "Rate limited" }));
|
||||
return;
|
||||
}
|
||||
const responseText = await response.text();
|
||||
setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${responseText}` }));
|
||||
} catch (error) {
|
||||
setNotificationState((prev) => ({ ...prev, sendStatus: `Error: ${error}` }));
|
||||
}
|
||||
}, [context, notificationDetails]);
|
||||
|
||||
/**
|
||||
* Copies the share URL for the current user to the clipboard.
|
||||
*
|
||||
* This function generates a share URL using the user's FID and copies it
|
||||
* to the clipboard. It shows a temporary "Copied!" message for 2 seconds.
|
||||
*/
|
||||
const copyUserShareUrl = useCallback(async () => {
|
||||
if (context?.user?.fid) {
|
||||
const userShareUrl = `${process.env.NEXT_PUBLIC_URL}/share/${context.user.fid}`;
|
||||
await navigator.clipboard.writeText(userShareUrl);
|
||||
setNotificationState((prev) => ({ ...prev, shareUrlCopied: true }));
|
||||
setTimeout(() => setNotificationState((prev) => ({ ...prev, shareUrlCopied: false })), 2000);
|
||||
}
|
||||
}, [context?.user?.fid]);
|
||||
|
||||
/**
|
||||
* Triggers haptic feedback with the selected intensity.
|
||||
*
|
||||
* This function calls the haptics.impactOccurred method with the current
|
||||
* selectedHapticIntensity setting. It handles errors gracefully by logging them.
|
||||
*/
|
||||
const triggerHapticFeedback = useCallback(async () => {
|
||||
try {
|
||||
await haptics.impactOccurred(selectedHapticIntensity);
|
||||
} catch (error) {
|
||||
console.error('Haptic feedback failed:', error);
|
||||
}
|
||||
}, [haptics, selectedHapticIntensity]);
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<div className="space-y-3 px-6 w-full max-w-md mx-auto">
|
||||
{/* Share functionality */}
|
||||
<ShareButton
|
||||
buttonText="Share Mini App"
|
||||
cast={{
|
||||
text: "Check out this awesome frame @1 @2 @3! 🚀🪐",
|
||||
bestFriends: true,
|
||||
embeds: [`${process.env.NEXT_PUBLIC_URL}/share/${context?.user?.fid || ''}`]
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Authentication */}
|
||||
<SignIn />
|
||||
|
||||
{/* Mini app actions */}
|
||||
<Button onClick={() => actions.openUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")} className="w-full">Open Link</Button>
|
||||
|
||||
<Button onClick={actions.addMiniApp} disabled={added} className="w-full">
|
||||
Add Mini App to Client
|
||||
</Button>
|
||||
|
||||
{/* Notification functionality */}
|
||||
{notificationState.sendStatus && (
|
||||
<div className="text-sm w-full">
|
||||
Send notification result: {notificationState.sendStatus}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={sendFarcasterNotification} disabled={!notificationDetails} className="w-full">
|
||||
Send notification
|
||||
</Button>
|
||||
|
||||
{/* Share URL copying */}
|
||||
<Button
|
||||
onClick={copyUserShareUrl}
|
||||
disabled={!context?.user?.fid}
|
||||
className="w-full"
|
||||
>
|
||||
{notificationState.shareUrlCopied ? "Copied!" : "Copy share URL"}
|
||||
</Button>
|
||||
|
||||
{/* Haptic feedback controls */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Haptic Intensity
|
||||
</label>
|
||||
<select
|
||||
value={selectedHapticIntensity}
|
||||
onChange={(e) => setSelectedHapticIntensity(e.target.value as Haptics.ImpactOccurredType)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value={'light'}>Light</option>
|
||||
<option value={'medium'}>Medium</option>
|
||||
<option value={'heavy'}>Heavy</option>
|
||||
<option value={'soft'}>Soft</option>
|
||||
<option value={'rigid'}>Rigid</option>
|
||||
</select>
|
||||
<Button
|
||||
onClick={triggerHapticFeedback}
|
||||
className="w-full"
|
||||
>
|
||||
Trigger Haptic Feedback
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/tabs/ContextTab.tsx
Normal file
35
src/components/ui/tabs/ContextTab.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useMiniApp } from "@neynar/react";
|
||||
|
||||
/**
|
||||
* ContextTab component displays the current mini app context in JSON format.
|
||||
*
|
||||
* This component provides a developer-friendly view of the Farcaster mini app context,
|
||||
* including user information, client details, and other contextual data. It's useful
|
||||
* for debugging and understanding what data is available to the mini app.
|
||||
*
|
||||
* The context includes:
|
||||
* - User information (FID, username, display name, profile picture)
|
||||
* - Client information (safe area insets, platform details)
|
||||
* - Mini app configuration and state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ContextTab />
|
||||
* ```
|
||||
*/
|
||||
export function ContextTab() {
|
||||
const { context } = useMiniApp();
|
||||
|
||||
return (
|
||||
<div className="mx-6">
|
||||
<h2 className="text-lg font-semibold mb-2">Context</h2>
|
||||
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-words w-full">
|
||||
{JSON.stringify(context, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/tabs/HomeTab.tsx
Normal file
24
src/components/ui/tabs/HomeTab.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* HomeTab component displays the main landing content for the mini app.
|
||||
*
|
||||
* This is the default tab that users see when they first open the mini app.
|
||||
* It provides a simple welcome message and placeholder content that can be
|
||||
* customized for specific use cases.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <HomeTab />
|
||||
* ```
|
||||
*/
|
||||
export function HomeTab() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)] px-6">
|
||||
<div className="text-center w-full max-w-md mx-auto">
|
||||
<p className="text-lg mb-2">Put your content here!</p>
|
||||
<p className="text-sm text-gray-500">Powered by Neynar 🪐</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
src/components/ui/tabs/WalletTab.tsx
Normal file
360
src/components/ui/tabs/WalletTab.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { useAccount, useSendTransaction, useSignTypedData, useWaitForTransactionReceipt, useDisconnect, useConnect, useSwitchChain, useChainId } from "wagmi";
|
||||
import { useWallet as useSolanaWallet } from '@solana/wallet-adapter-react';
|
||||
import { base, degen, mainnet, optimism, unichain } from "wagmi/chains";
|
||||
import { Button } from "../Button";
|
||||
import { truncateAddress } from "../../../lib/truncateAddress";
|
||||
import { renderError } from "../../../lib/errorUtils";
|
||||
import { SignEvmMessage } from "../wallet/SignEvmMessage";
|
||||
import { SendEth } from "../wallet/SendEth";
|
||||
import { SignSolanaMessage } from "../wallet/SignSolanaMessage";
|
||||
import { SendSolana } from "../wallet/SendSolana";
|
||||
import { USE_WALLET, APP_NAME } from "../../../lib/constants";
|
||||
import { useMiniApp } from "@neynar/react";
|
||||
|
||||
/**
|
||||
* WalletTab component manages wallet-related UI for both EVM and Solana chains.
|
||||
*
|
||||
* This component provides a comprehensive wallet interface that supports:
|
||||
* - EVM wallet connections (Farcaster Frame, Coinbase Wallet, MetaMask)
|
||||
* - Solana wallet integration
|
||||
* - Message signing for both chains
|
||||
* - Transaction sending for both chains
|
||||
* - Chain switching for EVM chains
|
||||
* - Auto-connection in Farcaster clients
|
||||
*
|
||||
* The component automatically detects when running in a Farcaster client
|
||||
* and attempts to auto-connect using the Farcaster Frame connector.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <WalletTab />
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface WalletStatusProps {
|
||||
address?: string;
|
||||
chainId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the current wallet address and chain ID.
|
||||
*/
|
||||
function WalletStatus({ address, chainId }: WalletStatusProps) {
|
||||
return (
|
||||
<>
|
||||
{address && (
|
||||
<div className="text-xs w-full">
|
||||
Address: <pre className="inline w-full">{truncateAddress(address)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{chainId && (
|
||||
<div className="text-xs w-full">
|
||||
Chain ID: <pre className="inline w-full">{chainId}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionControlsProps {
|
||||
isConnected: boolean;
|
||||
context: any;
|
||||
connect: any;
|
||||
connectors: readonly any[];
|
||||
disconnect: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders wallet connection controls based on connection state and context.
|
||||
*/
|
||||
function ConnectionControls({
|
||||
isConnected,
|
||||
context,
|
||||
connect,
|
||||
connectors,
|
||||
disconnect,
|
||||
}: ConnectionControlsProps) {
|
||||
if (isConnected) {
|
||||
return (
|
||||
<Button onClick={() => disconnect()} className="w-full">
|
||||
Disconnect
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (context) {
|
||||
return (
|
||||
<div className="space-y-2 w-full">
|
||||
<Button onClick={() => connect({ connector: connectors[0] })} className="w-full">
|
||||
Connect (Auto)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("Manual Farcaster connection attempt");
|
||||
console.log("Connectors:", connectors.map((c, i) => `${i}: ${c.name}`));
|
||||
connect({ connector: connectors[0] });
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Connect Farcaster (Manual)
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<Button onClick={() => connect({ connector: connectors[1] })} className="w-full">
|
||||
Connect Coinbase Wallet
|
||||
</Button>
|
||||
<Button onClick={() => connect({ connector: connectors[2] })} className="w-full">
|
||||
Connect MetaMask
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WalletTab() {
|
||||
// --- State ---
|
||||
const [evmContractTransactionHash, setEvmContractTransactionHash] = useState<string | null>(null);
|
||||
|
||||
// --- Hooks ---
|
||||
const { context } = useMiniApp();
|
||||
const { address, isConnected } = useAccount();
|
||||
const chainId = useChainId();
|
||||
const solanaWallet = useSolanaWallet();
|
||||
const { publicKey: solanaPublicKey } = solanaWallet;
|
||||
|
||||
// --- Wagmi Hooks ---
|
||||
const {
|
||||
sendTransaction,
|
||||
error: evmTransactionError,
|
||||
isError: isEvmTransactionError,
|
||||
isPending: isEvmTransactionPending,
|
||||
} = useSendTransaction();
|
||||
|
||||
const { isLoading: isEvmTransactionConfirming, isSuccess: isEvmTransactionConfirmed } =
|
||||
useWaitForTransactionReceipt({
|
||||
hash: evmContractTransactionHash as `0x${string}`,
|
||||
});
|
||||
|
||||
const {
|
||||
signTypedData,
|
||||
error: evmSignTypedDataError,
|
||||
isError: isEvmSignTypedDataError,
|
||||
isPending: isEvmSignTypedDataPending,
|
||||
} = useSignTypedData();
|
||||
|
||||
const { disconnect } = useDisconnect();
|
||||
const { connect, connectors } = useConnect();
|
||||
|
||||
const {
|
||||
switchChain,
|
||||
error: chainSwitchError,
|
||||
isError: isChainSwitchError,
|
||||
isPending: isChainSwitchPending,
|
||||
} = useSwitchChain();
|
||||
|
||||
// --- Effects ---
|
||||
/**
|
||||
* Debug logging for wallet auto-connection and state changes.
|
||||
* Logs context, connection status, address, and available connectors.
|
||||
*/
|
||||
useEffect(() => {
|
||||
console.log("WalletTab Debug Info:");
|
||||
console.log("- context:", context);
|
||||
console.log("- isConnected:", isConnected);
|
||||
console.log("- address:", address);
|
||||
console.log("- connectors:", connectors);
|
||||
console.log("- context?.user:", context?.user);
|
||||
}, [context, isConnected, address, connectors]);
|
||||
|
||||
/**
|
||||
* Auto-connect when Farcaster context is available.
|
||||
*
|
||||
* This effect detects when the app is running in a Farcaster client
|
||||
* and automatically attempts to connect using the Farcaster Frame connector.
|
||||
* It includes comprehensive logging for debugging connection issues.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Check if we're in a Farcaster client environment
|
||||
const isInFarcasterClient = typeof window !== 'undefined' &&
|
||||
(window.location.href.includes('warpcast.com') ||
|
||||
window.location.href.includes('farcaster') ||
|
||||
window.ethereum?.isFarcaster ||
|
||||
context?.client);
|
||||
|
||||
if (context?.user?.fid && !isConnected && connectors.length > 0 && isInFarcasterClient) {
|
||||
console.log("Attempting auto-connection with Farcaster context...");
|
||||
console.log("- User FID:", context.user.fid);
|
||||
console.log("- Available connectors:", connectors.map((c, i) => `${i}: ${c.name}`));
|
||||
console.log("- Using connector:", connectors[0].name);
|
||||
console.log("- In Farcaster client:", isInFarcasterClient);
|
||||
|
||||
// Use the first connector (farcasterFrame) for auto-connection
|
||||
try {
|
||||
connect({ connector: connectors[0] });
|
||||
} catch (error) {
|
||||
console.error("Auto-connection failed:", error);
|
||||
}
|
||||
} else {
|
||||
console.log("Auto-connection conditions not met:");
|
||||
console.log("- Has context:", !!context?.user?.fid);
|
||||
console.log("- Is connected:", isConnected);
|
||||
console.log("- Has connectors:", connectors.length > 0);
|
||||
console.log("- In Farcaster client:", isInFarcasterClient);
|
||||
}
|
||||
}, [context?.user?.fid, isConnected, connectors, connect, context?.client]);
|
||||
|
||||
// --- Computed Values ---
|
||||
/**
|
||||
* Determines the next chain to switch to based on the current chain.
|
||||
* Cycles through: Base → Optimism → Degen → Mainnet → Unichain → Base
|
||||
*/
|
||||
const nextChain = useMemo(() => {
|
||||
if (chainId === base.id) {
|
||||
return optimism;
|
||||
} else if (chainId === optimism.id) {
|
||||
return degen;
|
||||
} else if (chainId === degen.id) {
|
||||
return mainnet;
|
||||
} else if (chainId === mainnet.id) {
|
||||
return unichain;
|
||||
} else {
|
||||
return base;
|
||||
}
|
||||
}, [chainId]);
|
||||
|
||||
// --- Handlers ---
|
||||
/**
|
||||
* Handles switching to the next chain in the rotation.
|
||||
* Uses the switchChain function from wagmi to change the active chain.
|
||||
*/
|
||||
const handleSwitchChain = useCallback(() => {
|
||||
switchChain({ chainId: nextChain.id });
|
||||
}, [switchChain, nextChain.id]);
|
||||
|
||||
/**
|
||||
* Sends a transaction to call the yoink() function on the Yoink contract.
|
||||
*
|
||||
* This function sends a transaction to a specific contract address with
|
||||
* the encoded function call data for the yoink() function.
|
||||
*/
|
||||
const sendEvmContractTransaction = useCallback(() => {
|
||||
sendTransaction(
|
||||
{
|
||||
// call yoink() on Yoink contract
|
||||
to: "0x4bBFD120d9f352A0BEd7a014bd67913a2007a878",
|
||||
data: "0x9846cd9efc000023c0",
|
||||
},
|
||||
{
|
||||
onSuccess: (hash) => {
|
||||
setEvmContractTransactionHash(hash);
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [sendTransaction]);
|
||||
|
||||
/**
|
||||
* Signs typed data using EIP-712 standard.
|
||||
*
|
||||
* This function creates a typed data structure with the app name, version,
|
||||
* and chain ID, then requests the user to sign it.
|
||||
*/
|
||||
const signTyped = useCallback(() => {
|
||||
signTypedData({
|
||||
domain: {
|
||||
name: APP_NAME,
|
||||
version: "1",
|
||||
chainId,
|
||||
},
|
||||
types: {
|
||||
Message: [{ name: "content", type: "string" }],
|
||||
},
|
||||
message: {
|
||||
content: `Hello from ${APP_NAME}!`,
|
||||
},
|
||||
primaryType: "Message",
|
||||
});
|
||||
}, [chainId, signTypedData]);
|
||||
|
||||
// --- Early Return ---
|
||||
if (!USE_WALLET) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
return (
|
||||
<div className="space-y-3 px-6 w-full max-w-md mx-auto">
|
||||
{/* Wallet Information Display */}
|
||||
<WalletStatus address={address} chainId={chainId} />
|
||||
|
||||
{/* Connection Controls */}
|
||||
<ConnectionControls
|
||||
isConnected={isConnected}
|
||||
context={context}
|
||||
connect={connect}
|
||||
connectors={connectors}
|
||||
disconnect={disconnect}
|
||||
/>
|
||||
|
||||
{/* EVM Wallet Components */}
|
||||
<SignEvmMessage />
|
||||
|
||||
{isConnected && (
|
||||
<>
|
||||
<SendEth />
|
||||
<Button
|
||||
onClick={sendEvmContractTransaction}
|
||||
disabled={!isConnected || isEvmTransactionPending}
|
||||
isLoading={isEvmTransactionPending}
|
||||
className="w-full"
|
||||
>
|
||||
Send Transaction (contract)
|
||||
</Button>
|
||||
{isEvmTransactionError && renderError(evmTransactionError)}
|
||||
{evmContractTransactionHash && (
|
||||
<div className="text-xs w-full">
|
||||
<div>Hash: {truncateAddress(evmContractTransactionHash)}</div>
|
||||
<div>
|
||||
Status:{" "}
|
||||
{isEvmTransactionConfirming
|
||||
? "Confirming..."
|
||||
: isEvmTransactionConfirmed
|
||||
? "Confirmed!"
|
||||
: "Pending"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={signTyped}
|
||||
disabled={!isConnected || isEvmSignTypedDataPending}
|
||||
isLoading={isEvmSignTypedDataPending}
|
||||
className="w-full"
|
||||
>
|
||||
Sign Typed Data
|
||||
</Button>
|
||||
{isEvmSignTypedDataError && renderError(evmSignTypedDataError)}
|
||||
<Button
|
||||
onClick={handleSwitchChain}
|
||||
disabled={isChainSwitchPending}
|
||||
isLoading={isChainSwitchPending}
|
||||
className="w-full"
|
||||
>
|
||||
Switch to {nextChain.name}
|
||||
</Button>
|
||||
{isChainSwitchError && renderError(chainSwitchError)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Solana Wallet Components */}
|
||||
{solanaPublicKey && (
|
||||
<>
|
||||
<SignSolanaMessage signMessage={solanaWallet.signMessage} />
|
||||
<SendSolana />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/ui/tabs/index.ts
Normal file
4
src/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { HomeTab } from './HomeTab';
|
||||
export { ActionsTab } from './ActionsTab';
|
||||
export { ContextTab } from './ContextTab';
|
||||
export { WalletTab } from './WalletTab';
|
||||
Reference in New Issue
Block a user