Overview
SFLUV components are organized by feature and built with:- Radix UI - Accessible primitives (Dialog, Select, etc.)
- Tailwind CSS - Utility styling
- TypeScript - Type safety
- React Hook Form + Zod - Form validation
frontend/components/
Component Organization
components/
├── ui/ # Base UI primitives (Radix + Tailwind)
│ ├── button.tsx # Button component
│ ├── dialog.tsx # Modal dialogs
│ ├── input.tsx # Form inputs
│ ├── select.tsx # Dropdowns
│ ├── card.tsx # Container cards
│ ├── badge.tsx # Status badges
│ └── ...
├── workflows/ # Workflow-related components
│ ├── workflow-details-modal.tsx
│ └── ...
├── wallets/ # Wallet components
│ ├── wallet-balance-card.tsx
│ ├── send-crypto-modal.tsx
│ ├── new-wallet-modal.tsx
│ └── ...
├── locations/ # Map and location components
│ ├── map-view.tsx
│ ├── location-modal.tsx
│ └── list-view.tsx
├── events/ # Faucet event components
│ ├── event-card.tsx
│ ├── qr-code-card.tsx
│ └── add-event-modal.tsx
├── contacts/ # Contact book components
│ ├── contact-card.tsx
│ └── add-contact-modal.tsx
├── opportunities/ # Improver opportunity components
│ ├── opportunity-card.tsx
│ └── search-filters.tsx
├── dashboard/ # Dashboard layout
│ └── sidebar.tsx
└── ...
UI Primitives
Button Component
frontend/components/ui/button.tsx
import { ButtonHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/lib/utils'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'ghost'
size?: 'default' | 'sm' | 'lg'
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md font-medium",
"transition-colors focus-visible:outline-none focus-visible:ring-2",
{
"bg-blue-600 text-white hover:bg-blue-700": variant === 'default',
"bg-red-600 text-white hover:bg-red-700": variant === 'destructive',
"border border-gray-300 hover:bg-gray-100": variant === 'outline',
"hover:bg-gray-100": variant === 'ghost',
"h-10 px-4 py-2": size === 'default',
"h-8 px-3 text-sm": size === 'sm',
"h-12 px-6": size === 'lg',
},
className
)}
ref={ref}
{...props}
/>
)
}
)
import { Button } from '@/components/ui/button'
<Button variant="default" onClick={handleClick}>
Create Workflow
</Button>
<Button variant="destructive" size="sm">
Delete
</Button>
Dialog (Modal)
frontend/components/ui/dialog.tsx
Wraps Radix Dialog with Tailwind styling:
import * as DialogPrimitive from '@radix-ui/react-dialog'
export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogContent = forwardRef<...>(({ children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
<DialogPrimitive.Content
ref={ref}
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 max-w-md"
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
const [open, setOpen] = useState(false)
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Open Modal</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Workflow Details</DialogTitle>
<p>Workflow content here...</p>
</DialogContent>
</Dialog>
Input
frontend/components/ui/input.tsx
import { InputHTMLAttributes, forwardRef } from 'react'
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => {
return (
<input
className={cn(
"flex h-10 w-full rounded-md border border-gray-300 px-3 py-2",
"focus:outline-none focus:ring-2 focus:ring-blue-500",
className
)}
ref={ref}
{...props}
/>
)
}
)
import { useForm } from 'react-hook-form'
import { Input } from '@/components/ui/input'
const { register, handleSubmit } = useForm()
<form onSubmit={handleSubmit(onSubmit)}>
<Input
{...register('title', { required: true })}
placeholder="Workflow title"
/>
<Button type="submit">Submit</Button>
</form>
Feature Components
Wallet Balance Card
frontend/components/wallets/wallet-balance-card.tsx
import { AppWallet } from '@/lib/wallets/wallets'
import { Card } from '@/components/ui/card'
import { formatUnits } from 'viem'
interface WalletBalanceCardProps {
wallet: AppWallet
onClick?: () => void
}
export function WalletBalanceCard({ wallet, onClick }: WalletBalanceCardProps) {
const balance = formatUnits(wallet.balance, 18)
return (
<Card
className="p-4 cursor-pointer hover:shadow-lg transition"
onClick={onClick}
>
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-gray-500">{wallet.name}</p>
<p className="text-xs text-gray-400">
{wallet.address.slice(0, 6)}...{wallet.address.slice(-4)}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold">{balance}</p>
<p className="text-sm text-gray-500">SFLUV</p>
</div>
</div>
</Card>
)
}
import { useApp } from '@/context/AppProvider'
import { WalletBalanceCard } from '@/components/wallets/wallet-balance-card'
const { wallets } = useApp()
return (
<div className="grid gap-4">
{wallets.map(wallet => (
<WalletBalanceCard
key={wallet.address}
wallet={wallet}
onClick={() => setSelectedWallet(wallet)}
/>
))}
</div>
)
Send Crypto Modal
frontend/components/wallets/send-crypto-modal.tsx
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { AppWallet } from '@/lib/wallets/wallets'
import { parseUnits } from 'viem'
interface SendCryptoModalProps {
wallet: AppWallet
open: boolean
onOpenChange: (open: boolean) => void
}
export function SendCryptoModal({ wallet, open, onOpenChange }: SendCryptoModalProps) {
const [sending, setSending] = useState(false)
const { register, handleSubmit, formState: { errors } } = useForm()
const onSubmit = async (data: any) => {
setSending(true)
try {
const amount = parseUnits(data.amount, 18)
await wallet.sendTransaction(data.recipient, amount)
onOpenChange(false)
} catch (error) {
console.error(error)
} finally {
setSending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogTitle>Send SFLUV</DialogTitle>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="text-sm font-medium">Recipient Address</label>
<Input
{...register('recipient', { required: true, pattern: /^0x[a-fA-F0-9]{40}$/ })}
placeholder="0x..."
/>
{errors.recipient && <p className="text-red-500 text-sm">Invalid address</p>}
</div>
<div>
<label className="text-sm font-medium">Amount (SFLUV)</label>
<Input
{...register('amount', { required: true, min: 0 })}
type="number"
step="0.01"
placeholder="0.00"
/>
</div>
<Button type="submit" disabled={sending} className="w-full">
{sending ? 'Sending...' : 'Send'}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
Workflow Details Modal
frontend/components/workflows/workflow-details-modal.tsx
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Workflow } from '@/types/workflow'
interface WorkflowDetailsModalProps {
workflow: Workflow
open: boolean
onOpenChange: (open: boolean) => void
}
export function WorkflowDetailsModal({ workflow, open, onOpenChange }: WorkflowDetailsModalProps) {
const statusColors = {
pending: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
in_progress: 'bg-blue-100 text-blue-800',
completed: 'bg-gray-100 text-gray-800',
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogTitle>{workflow.title}</DialogTitle>
<Badge className={statusColors[workflow.status]}>
{workflow.status}
</Badge>
<div className="space-y-4">
<div>
<h3 className="font-semibold">Description</h3>
<p className="text-gray-600">{workflow.description}</p>
</div>
<div>
<h3 className="font-semibold">Steps</h3>
<div className="space-y-2">
{workflow.steps?.map((step, i) => (
<div key={step.id} className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step {i + 1}: {step.description}</p>
<p className="text-sm text-gray-500">
{step.hours_allocated}h | {step.sfluv_allocated} SFLUV
</p>
</div>
))}
</div>
</div>
<div className="flex justify-between text-sm text-gray-500">
<span>Start: {new Date(workflow.start_at).toLocaleDateString()}</span>
<span>End: {new Date(workflow.end_at).toLocaleDateString()}</span>
</div>
</div>
</DialogContent>
</Dialog>
)
}
Form Patterns
React Hook Form + Zod Validation
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
const schema = z.object({
title: z.string().min(1, 'Title is required').max(100),
description: z.string().min(10, 'Description must be at least 10 characters'),
sfluv_amount: z.number().positive('Amount must be positive')
})
type FormData = z.infer<typeof schema>
export function WorkflowForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema)
})
const onSubmit = async (data: FormData) => {
// Submit to API
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Input {...register('title')} placeholder="Workflow title" />
{errors.title && <p className="text-red-500 text-sm">{errors.title.message}</p>}
</div>
<div>
<textarea {...register('description')} className="w-full border rounded p-2" />
{errors.description && <p className="text-red-500 text-sm">{errors.description.message}</p>}
</div>
<Button type="submit">Create Workflow</Button>
</form>
)
}
Map Components
Map View
frontend/components/locations/map-view.tsx
import { Map, AdvancedMarker } from '@vis.gl/react-google-maps'
import { Location } from '@/types/location'
interface MapViewProps {
locations: Location[]
onMarkerClick?: (location: Location) => void
}
export function MapView({ locations, onMarkerClick }: MapViewProps) {
return (
<Map
defaultCenter={{ lat: 37.7749, lng: -122.4194 }}
defaultZoom={12}
mapId={process.env.NEXT_PUBLIC_MAP_ID}
className="w-full h-screen"
>
{locations.map(location => (
<AdvancedMarker
key={location.id}
position={{ lat: location.lat, lng: location.lng }}
onClick={() => onMarkerClick?.(location)}
/>
))}
</Map>
)
}
Component Best Practices
1. Props Interface
Always define TypeScript interfaces:interface MyComponentProps {
title: string
onClick?: () => void
children?: ReactNode
}
export function MyComponent({ title, onClick, children }: MyComponentProps) {
// ...
}
2. Controlled Components
Use controlled state for modals:const [open, setOpen] = useState(false)
<Dialog open={open} onOpenChange={setOpen}>
{/* ... */}
</Dialog>
3. Loading States
const [loading, setLoading] = useState(false)
<Button disabled={loading}>
{loading ? 'Loading...' : 'Submit'}
</Button>
4. Error Handling
const [error, setError] = useState<string | null>(null)
try {
await submitForm()
} catch (err) {
setError(err.message)
}
{error && <p className="text-red-500">{error}</p>}
Next Steps
Context
Using AppProvider in components
Testing
Component testing strategies