Skip to content

Component Patterns - ThriveSend B2B2G

Last Updated: October 28, 2025


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.


ThriveSend uses a layered component architecture:

Base UI Components (46)
↓ used by
Dashboard Components (29)
↓ used by
Page Components (14)

Screenshot Placeholder:

../../../assets/images/screenshots/dev-component-hierarchy.png
*Visual representation of component layers and dependencies*

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 props
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean // Radix UI pattern: render as child element
}
// Forward ref pattern for DOM access
const 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 }
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:

../../../assets/images/screenshots/dev-component-button-variants.png
*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

Real code from src/components/dashboard/MetricCard.tsx:

'use client';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
// Clear TypeScript interface
interface 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>
);
}
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:

../../../assets/images/screenshots/dev-component-metric-card.png
*MetricCard displaying different metrics on dashboard*

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 types
export 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 callbacks
export 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:

../../../assets/images/screenshots/dev-component-client-list-view.png
*ClientListView displaying filtered list of clients with pagination*

  • Use React.forwardRef for DOM access
  • Extend native HTML element props
  • Use CVA for variant styling
  • Support asChild prop 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"
  • 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>
}
  • No 'use client' directive
  • Can fetch data directly
  • No hooks or interactivity
  • Render on server
// Server component by default
export async function ServerComponent() {
const data = await fetchData();
return <div>{data}</div>
}
  • 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>
}
  • 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} />
}

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" />

Use the cn() utility to merge classes:

import { cn } from "@/lib/utils"
<div className={cn(
"base-classes",
condition && "conditional-classes",
className
)} />
<Badge variant={status === 'ACTIVE' ? 'default' : 'secondary'}>
{status}
</Badge>

const [count, setCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [data, setData] = useState<Data | null>(null);
useEffect(() => {
// Run on mount
fetchData();
// Cleanup
return () => {
cancelRequest();
};
}, [dependencies]);
import { useServiceProvider } from '@/hooks/use-service-provider';
export function Component() {
const { organization, user } = useServiceProvider();
return <div>{organization.name}</div>
}

// ✅ Good: Clear interface
interface ButtonProps {
variant?: 'default' | 'destructive';
size?: 'sm' | 'md' | 'lg';
onClick?: () => void;
}
// ❌ Bad: Inline types
function Button(props: { variant?: string; size?: string }) {}
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
interface Props {
required: string; // Required
optional?: string; // Optional
withDefault?: string; // Optional with default in destructuring
}
function Component({ required, optional, withDefault = "default" }: Props) {}

<button
aria-label="Close dialog"
aria-expanded={isOpen}
aria-controls="dialog-content"
>
Close
</button>
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
>
Clickable Div
</div>
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (shouldFocus) {
buttonRef.current?.focus();
}
}, [shouldFocus]);
<Button ref={buttonRef}>Focus Me</Button>

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