15 KiB
Phase 2: Wallet UI Components Implementation
Overview
Phase 2 focuses on building UI components for the wallet and integrating them with the Next.js frontend. This phase transforms the core wallet functionality into usable UI components and connects them to the application.
Timeline
Duration: 2 weeks Dependencies: Phase 1 (wallet-core package) Team: Frontend team
Directory Structure
graph TD
A[services/ui/src/wallet] --> B[components/]
A --> C[hooks/]
A --> D[providers/]
B --> B1[WalletConnectButton.tsx]
B --> B2[WalletModal.tsx]
B --> B3[TransactionApproval.tsx]
B --> B4[SignMessageModal.tsx]
B --> B5[AccountSelector.tsx]
B --> B6[NetworkSelector.tsx]
B --> B7[BalanceDisplay.tsx]
C --> C1[useWalletUI.ts]
C --> C2[useTransaction.ts]
D --> D1[WalletUIProvider.tsx]
E[apps/deploy-fe/src] --> F[components/wallet/]
F --> F1[WalletProvider.tsx]
F --> F2[ConnectWallet.tsx]
G[apps/deploy-fe/src/app/api] --> H[wallet/]
H --> H1[balance/route.ts]
H --> H2[sign/route.ts]
H --> H3[connect/route.ts]
Migration Reference
These implementations adapt functionality from:
WalletModal.tsx
→ from/repos/laconic-wallet-web/src/components/wallet/AutoSignInIFrameModal.tsx
TransactionApproval.tsx
→ from/repos/laconic-wallet-web/src/screens/ApproveTransaction.tsx
WalletUIProvider.tsx
→ from/repos/laconic-wallet-web/src/context/WalletContextProvider.tsx
Security Considerations
Before implementation, please note these important security considerations:
- CORS Configuration: API routes must have proper CORS configuration to prevent unauthorized access
- Input Validation: All user input and transaction data must be validated server-side using Zod or similar
- Error Handling: Implement comprehensive error handling to prevent information leakage
- Key Management: Never expose private keys in client-side code
- Signature Verification: Always verify signatures on the server-side
- Responsive Design: Wallet UI components should be mobile-responsive with proper accessibility attributes
Step-by-Step Implementation
1. Create Wallet UI Components
Create the directory structure:
mkdir -p services/ui/src/wallet/components
mkdir -p services/ui/src/wallet/hooks
mkdir -p services/ui/src/wallet/providers
2. Implement Key Components
Create services/ui/src/wallet/components/WalletConnectButton.tsx
:
'use client'
import { Button } from '@workspace/ui/components/button'
import React from 'react'
import { useWalletUI } from '../hooks/useWalletUI'
interface WalletConnectButtonProps {
variant?: 'default' | 'outline' | 'ghost'
size?: 'default' | 'sm' | 'lg'
}
export function WalletConnectButton({
variant = 'default',
size = 'default'
}: WalletConnectButtonProps) {
const { isConnected, connect, disconnect, wallet } = useWalletUI()
return (
<Button
variant={variant}
size={size}
onClick={isConnected ? disconnect : connect}
>
{isConnected
? `Connected: ${wallet?.address?.slice(0, 6)}...${wallet?.address?.slice(-4)}`
: 'Connect Wallet'}
</Button>
)
}
Create services/ui/src/wallet/components/WalletModal.tsx
:
'use client'
import React, { useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@workspace/ui/components/dialog'
import { Button } from '@workspace/ui/components/button'
import { useWalletUI } from '../hooks/useWalletUI'
import { signIn } from '@workspace/wallet-core'
export function WalletModal() {
const { isOpen, closeModal, wallet, setWallet, connectWallet } = useWalletUI()
useEffect(() => {
async function handleSignIn() {
if (wallet?.address) {
try {
// Replace the direct iframe messaging with server action
await signIn(wallet.address)
} catch (error) {
console.error('Error during sign-in:', error)
}
}
}
if (wallet?.address) {
handleSignIn()
}
}, [wallet])
return (
<Dialog open={isOpen} onOpenChange={closeModal}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Connect your wallet</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<Button onClick={connectWallet}>
Connect Laconic Wallet
</Button>
</div>
</DialogContent>
</Dialog>
)
}
Create services/ui/src/wallet/components/TransactionApproval.tsx
:
'use client'
import React, { useState } from 'react'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '@workspace/ui/components/card'
import { Button } from '@workspace/ui/components/button'
import { useWalletUI } from '../hooks/useWalletUI'
interface TransactionApprovalProps {
amount: string
recipient: string
denom: string
onApprove: () => void
onReject: () => void
}
export function TransactionApproval({
amount,
recipient,
denom,
onApprove,
onReject
}: TransactionApprovalProps) {
const { wallet } = useWalletUI()
const [isProcessing, setIsProcessing] = useState(false)
const handleApprove = async () => {
setIsProcessing(true)
try {
await onApprove()
} finally {
setIsProcessing(false)
}
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Approve Transaction</CardTitle>
<CardDescription>Review and approve this transaction</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
<div className="text-sm font-medium">From:</div>
<div className="col-span-2 text-sm truncate">
{wallet?.address || 'Not connected'}
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="text-sm font-medium">To:</div>
<div className="col-span-2 text-sm truncate">{recipient}</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="text-sm font-medium">Amount:</div>
<div className="col-span-2 text-sm">
{amount} {denom}
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={onReject}
disabled={isProcessing}
>
Reject
</Button>
<Button
onClick={handleApprove}
disabled={isProcessing}
>
{isProcessing ? 'Processing...' : 'Approve'}
</Button>
</CardFooter>
</Card>
)
}
3. Implement UI Hooks
Create services/ui/src/wallet/hooks/useWalletUI.ts
:
'use client'
import { useEffect, useState } from 'react'
import { useWallet } from '@workspace/wallet-core'
import { toast } from 'sonner'
export function useWalletUI() {
const walletCore = useWallet()
const [isOpen, setIsOpen] = useState(false)
const openModal = () => setIsOpen(true)
const closeModal = () => setIsOpen(false)
const connectWallet = async () => {
try {
// This now uses server actions instead of iframe messaging
await walletCore.connect()
closeModal()
toast.success('Wallet connected')
} catch (error) {
toast.error('Failed to connect wallet')
console.error(error)
}
}
const disconnect = () => {
walletCore.disconnect()
toast.info('Wallet disconnected')
}
// Expose the core wallet methods and UI-specific methods
return {
...walletCore,
isOpen,
openModal,
closeModal,
connectWallet
}
}
4. Implement Provider
Create services/ui/src/wallet/providers/WalletUIProvider.tsx
:
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react'
import { WalletProvider } from '@workspace/wallet-core'
import { WalletModal } from '../components/WalletModal'
import { toast } from 'sonner'
interface WalletUIContextType {
openWalletModal: () => void
closeWalletModal: () => void
isModalOpen: boolean
}
const WalletUIContext = createContext<WalletUIContextType>({
openWalletModal: () => {},
closeWalletModal: () => {},
isModalOpen: false
})
export function useWalletUIContext() {
return useContext(WalletUIContext)
}
export function WalletUIProvider({ children }: { children: React.ReactNode }) {
const [isModalOpen, setIsModalOpen] = useState(false)
const openWalletModal = () => setIsModalOpen(true)
const closeWalletModal = () => setIsModalOpen(false)
return (
<WalletProvider>
<WalletUIContext.Provider
value={{
openWalletModal,
closeWalletModal,
isModalOpen
}}
>
{children}
<WalletModal open={isModalOpen} onOpenChange={setIsModalOpen} />
</WalletUIContext.Provider>
</WalletProvider>
)
}
5. Create Index Exports
Create services/ui/src/wallet/index.ts
:
// Component exports
export { WalletConnectButton } from './components/WalletConnectButton'
export { WalletModal } from './components/WalletModal'
export { TransactionApproval } from './components/TransactionApproval'
export { AccountSelector } from './components/AccountSelector'
export { NetworkSelector } from './components/NetworkSelector'
export { BalanceDisplay } from './components/BalanceDisplay'
// Hook exports
export { useWalletUI } from './hooks/useWalletUI'
export { useTransaction } from './hooks/useTransaction'
// Provider exports
export { WalletUIProvider, useWalletUIContext } from './providers/WalletUIProvider'
6. Create API Routes for Wallet Communication
Create apps/deploy-fe/src/app/api/wallet/balance/route.ts
:
import { checkBalance } from '@workspace/wallet-core'
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { chainId, address, amount } = body
const result = await checkBalance(chainId, address, amount)
return NextResponse.json(result)
} catch (error) {
console.error('Balance check error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
Create apps/deploy-fe/src/app/api/wallet/sign/route.ts
:
import { validateSignature } from '@workspace/wallet-core'
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
// Define validation schema
const signRequestSchema = z.object({
message: z.string().min(1, "Message is required"),
signature: z.string().min(1, "Signature is required")
})
export async function POST(request: Request) {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
// Validate input data
const result = signRequestSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid request data', details: result.error.format() },
{ status: 400 }
)
}
const { message, signature } = result.data
// Add additional validation for signature format if needed
// (e.g., check if it matches expected pattern)
const validationResult = await validateSignature(message, signature)
return NextResponse.json(validationResult)
} catch (error) {
console.error('Signature validation error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
7. Integrate with Next.js App
Create apps/deploy-fe/src/components/wallet/WalletProvider.tsx
:
'use client'
import { ReactNode } from 'react'
import { WalletUIProvider } from '@workspace/ui/wallet'
export function WalletProvider({ children }: { children: ReactNode }) {
return <WalletUIProvider>{children}</WalletUIProvider>
}
Update apps/deploy-fe/src/components/providers/index.tsx
:
'use client'
import React, { ReactNode } from 'react'
import { ThemeProvider } from 'next-themes'
import { WalletProvider } from '../wallet/WalletProvider'
export function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
<WalletProvider>{children}</WalletProvider>
</ThemeProvider>
)
}
Integration Example
Create a wallet connection button in apps/deploy-fe/src/components/foundation/top-navigation/TopNavigation.tsx
:
'use client'
import React from 'react'
import { WalletConnectButton } from '@workspace/ui/wallet'
export function TopNavigation() {
return (
<header className="border-b sticky top-0 z-40 bg-background">
<div className="container flex h-16 items-center px-4 sm:px-6">
<nav className="flex-1 flex items-center justify-between">
{/* Logo and other navigation items */}
{/* Right side items */}
<div className="flex items-center space-x-4">
<WalletConnectButton />
{/* Other right-side items */}
</div>
</nav>
</div>
</header>
)
}
Replace apps/deploy-fe/src/components/iframe/check-balance-iframe/CheckBalanceIframe.tsx
with server action:
'use client'
import { useEffect } from 'react'
import { checkBalance } from '@workspace/wallet-core'
interface CheckBalanceProps {
onBalanceChange: (value: boolean | undefined) => void
isPollingEnabled: boolean
amount: string
}
export default function CheckBalance({
onBalanceChange,
isPollingEnabled,
amount
}: CheckBalanceProps) {
useEffect(() => {
let interval: NodeJS.Timeout
const fetchBalance = async () => {
try {
// Uses server action instead of iframe
const chainId = process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID || ''
const result = await fetch('/api/wallet/balance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chainId, amount })
}).then(res => res.json())
onBalanceChange(result.hasEnoughBalance)
} catch (error) {
console.error('Error checking balance:', error)
onBalanceChange(undefined)
}
}
fetchBalance()
if (isPollingEnabled) {
interval = setInterval(fetchBalance, 5000)
}
return () => {
if (interval) clearInterval(interval)
}
}, [amount, isPollingEnabled, onBalanceChange])
// This component doesn't render anything visually
return null
}
Testing
- Build the UI package:
cd services/ui
pnpm build
- Run the frontend app:
cd apps/deploy-fe
pnpm dev
- Test wallet functionality:
- Connection button in navigation
- Login flow with wallet
- Balance checks
- Transaction signing
Next Steps
- Phase 3: Complete Clerk auth integration
- Phase 3: Implement comprehensive middleware
- Phase 3: Develop unified authentication flow