Component Patterns - ThriveSend B2B2G
Component Patterns - ThriveSend B2B2G
Section titled “Component Patterns - ThriveSend B2B2G”Last Updated: October 28, 2025
Overview
Section titled “Overview”This guide demonstrates the React component patterns used in ThriveSend B2B2G, with real code examples from the production codebase. All code is taken directly from the src/components/ directory.
Component Architecture
Section titled “Component Architecture”ThriveSend uses a layered component architecture:
Base UI Components (46) ↓ used byDashboard Components (29) ↓ used byPage Components (14)Screenshot Placeholder:
*Visual representation of component layers and dependencies*Base UI Component Pattern
Section titled “Base UI Component Pattern”Example: Button Component
Section titled “Example: Button Component”Real code from src/components/ui/button.tsx:
import * as React from "react"import { Slot } from "@radix-ui/react-slot"import { cva, type VariantProps } from "class-variance-authority"import { cn } from "@/lib/utils"
// Define variants using class-variance-authority (CVA)const buttonVariants = cva( // Base styles applied to all variants "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-95", { variants: { // Visual variants variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, // Size variants size: { default: "h-10 px-4 py-2", sm: "h-10 rounded-md px-3 text-xs", lg: "h-11 rounded-md px-8", icon: "h-10 w-10 min-w-[44px] min-h-[44px]", }, }, defaultVariants: { variant: "default", size: "default", }, })
// TypeScript interface extending HTML button propsexport interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean // Radix UI pattern: render as child element}
// Forward ref pattern for DOM accessconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, ...props }, ref) => { // Use Slot for composition or button element const Comp = asChild ? Slot : "button" return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> ) })Button.displayName = "Button"
export { Button, buttonVariants }Usage Examples
Section titled “Usage Examples”import { Button } from '@/components/ui/button'
// Basic usage<Button>Click me</Button>
// With variant<Button variant="destructive">Delete</Button>
// With size<Button size="sm">Small Button</Button>
// With custom className<Button className="w-full">Full Width</Button>
// As child (composition pattern)<Button asChild> <Link href="/dashboard">Go to Dashboard</Link></Button>
// With icon<Button variant="outline" size="icon"> <PlusIcon className="h-4 w-4" /></Button>Screenshot Placeholder:
*All button variants (default, destructive, outline, secondary, ghost, link) displayed*Key Pattern: Class Variance Authority (CVA)
Section titled “Key Pattern: Class Variance Authority (CVA)”ThriveSend uses cva for variant-based styling:
import { cva } from "class-variance-authority"
const componentVariants = cva( "base-classes", // Always applied { variants: { variant: { primary: "variant-classes", secondary: "variant-classes", }, size: { sm: "size-classes", lg: "size-classes", }, }, defaultVariants: { variant: "primary", size: "sm", }, })Benefits:
- Type-safe variant props
- Automatic TypeScript inference
- Clean API for consumers
- Easy to extend with new variants
Dashboard Component Pattern
Section titled “Dashboard Component Pattern”Example: MetricCard Component
Section titled “Example: MetricCard Component”Real code from src/components/dashboard/MetricCard.tsx:
'use client';
import { LucideIcon } from 'lucide-react';import { cn } from '@/lib/utils';
// Clear TypeScript interfaceinterface MetricCardProps { title: string; value: string | number; subtitle?: string; // Optional prop icon: LucideIcon; // Lucide icon component type className?: string; // Allow styling customization}
export function MetricCard({ title, value, subtitle, icon: Icon, // Rename for component usage className}: MetricCardProps) { return ( <div className={cn( // Base styles "rounded-lg border bg-card text-card-foreground shadow-sm transition-all duration-200 hover:shadow-md", "p-6 space-y-3", // Merge custom className className )} > {/* Header: Title and Icon */} <div className="flex items-center justify-between"> <h3 className="text-sm font-medium text-muted-foreground"> {title} </h3> <Icon className="h-4 w-4 text-muted-foreground" /> </div>
{/* Value */} <div className="space-y-1"> <p className="text-2xl font-bold tracking-tight break-all"> {value} </p>
{/* Conditional subtitle */} {subtitle && ( <p className="text-xs text-muted-foreground"> {subtitle} </p> )} </div> </div> );}Usage Examples
Section titled “Usage Examples”import { MetricCard } from '@/components/dashboard/MetricCard'import { Users, TrendingUp, DollarSign } from 'lucide-react'
// Basic usage<MetricCard title="Total Clients" value={42} icon={Users}/>
// With subtitle<MetricCard title="Revenue This Month" value="R 125,000" subtitle="+15% from last month" icon={DollarSign}/>
// With custom styling<MetricCard title="Engagement Rate" value="89.3%" subtitle="Across all campaigns" icon={TrendingUp} className="border-green-200 bg-green-50"/>Screenshot Placeholder:
*MetricCard displaying different metrics on dashboard*Complex Component Pattern
Section titled “Complex Component Pattern”Example: ClientListView Component
Section titled “Example: ClientListView Component”Real code from src/components/dashboard/ClientListView.tsx:
'use client';
import React from 'react';import Link from 'next/link';import { Card } from '@/components/ui/card';import { Badge } from '@/components/ui/badge';import { Button } from '@/components/ui/button';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// Complex data typesexport interface ClientData { id: string; name: string; type: ClientType; status: ClientStatus; contactEmail: string; contactPhone?: string; popiaConsent: boolean; complianceStatus: 'COMPLIANT' | 'NON_COMPLIANT' | 'PENDING'; createdAt: Date; updatedAt: Date; lastActivity?: Date; governmentLevel?: 'LOCAL' | 'PROVINCIAL' | 'NATIONAL'; department?: string; campaignCount: number; activeCampaignCount: number;}
export interface ClientListResponse { clients: ClientData[]; pagination: { total: number; page: number; limit: number; totalPages: number; hasNext: boolean; hasPrev: boolean; }; statistics: { totalClients: number; governmentClients: number; businessClients: number; activeClients: number; };}
// Complex props with callbacksexport interface ClientListViewProps { clientsData: ClientListResponse | null; loading: boolean; error: string | null; searchQuery: string; typeFilter: 'ALL' | 'GOVERNMENT' | 'BUSINESS'; statusFilter: ClientStatus | 'ALL'; currentPage: number; // Event handlers onSearchChange: (query: string) => void; onTypeFilterChange: (type: 'ALL' | 'GOVERNMENT' | 'BUSINESS') => void; onStatusFilterChange: (status: ClientStatus | 'ALL') => void; onPageChange: (page: number) => void; onClearFilters: () => void; onStatusChange: (clientId: string, newStatus: ClientStatus) => void;}
export function ClientListView({ clientsData, loading, error, searchQuery, typeFilter, statusFilter, currentPage, onSearchChange, onTypeFilterChange, onStatusFilterChange, onPageChange, onClearFilters, onStatusChange,}: ClientListViewProps) {
// Loading state handling if (loading && !clientsData) { return ( <div className="space-y-6"> <div className="grid gap-4"> {/* Loading skeletons */} {[...Array(3)].map((_, i) => ( <Card key={i} className="p-6 animate-pulse"> <div className="h-4 bg-gray-200 rounded w-1/4 mb-4"></div> <div className="h-3 bg-gray-200 rounded w-1/2"></div> </Card> ))} </div> </div> ); }
// Error state handling if (error) { return ( <Card className="p-6"> <p className="text-destructive">{error}</p> </Card> ); }
// Empty state handling if (!clientsData || clientsData.clients.length === 0) { return ( <Card className="p-12 text-center"> <p className="text-muted-foreground">No clients found</p> <Button onClick={onClearFilters} className="mt-4"> Clear Filters </Button> </Card> ); }
return ( <div className="space-y-6"> {/* Filters */} <div className="flex gap-4"> <Select value={typeFilter} onValueChange={onTypeFilterChange}> <SelectTrigger className="w-[180px]"> <SelectValue placeholder="Client Type" /> </SelectTrigger> <SelectContent> <SelectItem value="ALL">All Types</SelectItem> <SelectItem value="GOVERNMENT">Government</SelectItem> <SelectItem value="BUSINESS">Business</SelectItem> </SelectContent> </Select>
<Select value={statusFilter} onValueChange={onStatusFilterChange}> <SelectTrigger className="w-[180px]"> <SelectValue placeholder="Status" /> </SelectTrigger> <SelectContent> <SelectItem value="ALL">All Statuses</SelectItem> <SelectItem value="ACTIVE">Active</SelectItem> <SelectItem value="INACTIVE">Inactive</SelectItem> </SelectContent> </Select> </div>
{/* Client list */} <div className="grid gap-4"> {clientsData.clients.map((client) => ( <Card key={client.id} className="p-6"> <div className="flex items-start justify-between"> <div> <h3 className="font-semibold">{client.name}</h3> <p className="text-sm text-muted-foreground">{client.contactEmail}</p> </div> <Badge variant={client.status === 'ACTIVE' ? 'default' : 'secondary'}> {client.status} </Badge> </div>
<div className="mt-4 flex gap-2"> <Link href={`/dashboard/clients/${client.id}`}> <Button variant="outline" size="sm">View Details</Button> </Link> </div> </Card> ))} </div>
{/* Pagination */} {clientsData.pagination.totalPages > 1 && ( <div className="flex justify-center gap-2"> <Button variant="outline" disabled={!clientsData.pagination.hasPrev} onClick={() => onPageChange(currentPage - 1)} > Previous </Button> <span className="flex items-center px-4"> Page {clientsData.pagination.page} of {clientsData.pagination.totalPages} </span> <Button variant="outline" disabled={!clientsData.pagination.hasNext} onClick={() => onPageChange(currentPage + 1)} > Next </Button> </div> )} </div> );}Screenshot Placeholder:
*ClientListView displaying filtered list of clients with pagination*Component Patterns Summary
Section titled “Component Patterns Summary”1. Base UI Components
Section titled “1. Base UI Components”- Use
React.forwardReffor DOM access - Extend native HTML element props
- Use CVA for variant styling
- Support
asChildprop for composition (Radix pattern) - Export both component and variants
const Component = React.forwardRef<HTMLElement, Props>( (props, ref) => { return <element ref={ref} {...props} /> })Component.displayName = "Component"2. Client Components
Section titled “2. Client Components”- Mark with
'use client'directive - Use hooks (useState, useEffect, etc.)
- Handle user interactions
- Manage local state
'use client';
export function InteractiveComponent() { const [state, setState] = useState(initialState); return <div onClick={handleClick}>...</div>}3. Server Components (Default)
Section titled “3. Server Components (Default)”- No
'use client'directive - Can fetch data directly
- No hooks or interactivity
- Render on server
// Server component by defaultexport async function ServerComponent() { const data = await fetchData(); return <div>{data}</div>}4. Presentation Components
Section titled “4. Presentation Components”- Accept all data via props
- No business logic
- Focus on UI rendering
- Highly reusable
interface Props { data: Data; onAction: () => void;}
export function PresentationComponent({ data, onAction }: Props) { return <div onClick={onAction}>{data.name}</div>}5. Container Components
Section titled “5. Container Components”- Handle data fetching
- Manage state
- Pass data to presentation components
'use client';
export function ContainerComponent() { const [data, setData] = useState<Data[]>([]);
useEffect(() => { fetchData().then(setData); }, []);
return <PresentationComponent data={data} />}Styling Patterns
Section titled “Styling Patterns”1. Tailwind CSS Classes
Section titled “1. Tailwind CSS Classes”ThriveSend uses Tailwind CSS with semantic color system:
// ✅ Good: Use semantic colors<Button className="bg-primary text-primary-foreground" />
// ❌ Bad: Don't use raw colors<Button className="bg-blue-600 text-white" />2. cn() Utility
Section titled “2. cn() Utility”Use the cn() utility to merge classes:
import { cn } from "@/lib/utils"
<div className={cn( "base-classes", condition && "conditional-classes", className)} />3. Conditional Styling
Section titled “3. Conditional Styling”<Badge variant={status === 'ACTIVE' ? 'default' : 'secondary'}> {status}</Badge>State Management Patterns
Section titled “State Management Patterns”1. Local State (useState)
Section titled “1. Local State (useState)”const [count, setCount] = useState(0);const [isOpen, setIsOpen] = useState(false);const [data, setData] = useState<Data | null>(null);2. Side Effects (useEffect)
Section titled “2. Side Effects (useEffect)”useEffect(() => { // Run on mount fetchData();
// Cleanup return () => { cancelRequest(); };}, [dependencies]);3. Context (React Context)
Section titled “3. Context (React Context)”import { useServiceProvider } from '@/hooks/use-service-provider';
export function Component() { const { organization, user } = useServiceProvider(); return <div>{organization.name}</div>}TypeScript Best Practices
Section titled “TypeScript Best Practices”1. Explicit Interfaces
Section titled “1. Explicit Interfaces”// ✅ Good: Clear interfaceinterface ButtonProps { variant?: 'default' | 'destructive'; size?: 'sm' | 'md' | 'lg'; onClick?: () => void;}
// ❌ Bad: Inline typesfunction Button(props: { variant?: string; size?: string }) {}2. Extend Native Props
Section titled “2. Extend Native Props”interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { label?: string;}3. Optional Props
Section titled “3. Optional Props”interface Props { required: string; // Required optional?: string; // Optional withDefault?: string; // Optional with default in destructuring}
function Component({ required, optional, withDefault = "default" }: Props) {}Accessibility
Section titled “Accessibility”1. ARIA Attributes
Section titled “1. ARIA Attributes”<button aria-label="Close dialog" aria-expanded={isOpen} aria-controls="dialog-content"> Close</button>2. Keyboard Navigation
Section titled “2. Keyboard Navigation”<div role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { handleClick(); } }}> Clickable Div</div>3. Focus Management
Section titled “3. Focus Management”const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => { if (shouldFocus) { buttonRef.current?.focus(); }}, [shouldFocus]);
<Button ref={buttonRef}>Focus Me</Button>Testing Components
Section titled “Testing Components”Unit Test Example
Section titled “Unit Test Example”import { render, screen, fireEvent } from '@testing-library/react';import { Button } from './button';
describe('Button', () => { it('renders with text', () => { render(<Button>Click me</Button>); expect(screen.getByText('Click me')).toBeInTheDocument(); });
it('calls onClick when clicked', () => { const handleClick = jest.fn(); render(<Button onClick={handleClick}>Click</Button>); fireEvent.click(screen.getByText('Click')); expect(handleClick).toHaveBeenCalledTimes(1); });
it('applies variant classes', () => { render(<Button variant="destructive">Delete</Button>); const button = screen.getByText('Delete'); expect(button).toHaveClass('bg-destructive'); });});Last Updated: October 28, 2025