Skip to main content

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
Location: 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}
      />
    )
  }
)
Usage:
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>
))
Usage:
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}
      />
    )
  }
)
Usage with React Hook Form:
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>
  )
}
Usage:
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

Build docs developers (and LLMs) love