import React, { useState, useEffect, useCallback } from 'react';
import { useUnleashContext, useUnleashClient } from '@unleash/proxy-client-react';
import styled, { keyframes } from 'styled-components';
// --- Simple Animations ---
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
`;
const spin = keyframes`
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
`;
// --- Layout Components ---
const Container = styled.div`
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem 1rem;
`;
const MainContent = styled.div`
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 2rem;
`;
const Card = styled.div`
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
animation: ${fadeIn} 0.6s ease-out;
`;
// --- Header Components ---
const Header = styled.div`
text-align: center;
margin-bottom: 1rem;
`;
const Title = styled.h1`
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
`;
const Subtitle = styled.p`
font-size: 1.1rem;
color: #64748b;
font-weight: 400;
`;
// --- Form Components ---
const ConfigSection = styled.div`
display: grid;
grid-template-columns: 1fr auto;
gap: 1.5rem;
align-items: end;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
`;
const InputGroup = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
`;
const Label = styled.label`
font-size: 0.9rem;
font-weight: 600;
color: #374151;
`;
const Input = styled.input`
padding: 0.875rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
transition: all 0.2s ease;
background: white;
&:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
`;
const Button = styled.button`
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
`;
const Spinner = styled.div`
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: ${spin} 1s linear infinite;
`;
// --- Status Components ---
const StatusBanner = styled.div`
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
padding: 1rem 1.5rem;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
animation: ${fadeIn} 0.3s ease-out;
`;
const StatusSpinner = styled(Spinner)`
border-color: rgba(255, 255, 255, 0.3);
border-top-color: white;
`;
// --- Stats Components ---
const StatsGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
`;
const StatCard = styled(Card)`
text-align: center;
padding: 1.5rem;
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
`;
const StatValue = styled.div`
font-size: 2.5rem;
font-weight: 800;
color: ${props => props.color || '#1e293b'};
margin-bottom: 0.5rem;
`;
const StatLabel = styled.div`
font-size: 0.9rem;
color: #64748b;
font-weight: 500;
`;
// --- Variant Display Components ---
const VariantGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
`;
const VariantCard = styled.div`
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
border-radius: 16px;
padding: 1.5rem;
text-align: center;
border: 2px solid rgba(255, 255, 255, 0.5);
`;
const VariantName = styled.h4`
font-size: 1.1rem;
font-weight: 600;
color: #334155;
margin-bottom: 1rem;
`;
const SimulatedButton = styled.button`
padding: 0.75rem 1.5rem;
border-radius: 10px;
font-weight: 600;
color: white;
background: ${props => props.color || '#3b82f6'};
border: none;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
`;
const VariantDescription = styled.p`
font-size: 0.85rem;
color: #64748b;
margin-top: 0.75rem;
`;
// --- Progress Components ---
const ProgressContainer = styled.div`
margin: 1.5rem 0;
`;
const ProgressBar = styled.div`
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
display: flex;
`;
const ProgressSegment = styled.div`
height: 100%;
background: ${props => props.color || '#3b82f6'};
width: ${props => props.percentage}%;
transition: width 0.5s ease;
`;
const ProgressLabels = styled.div`
display: flex;
justify-content: space-between;
margin-top: 0.75rem;
font-size: 0.85rem;
color: #64748b;
`;
// --- Table Components ---
const TableContainer = styled.div`
overflow-x: auto;
border-radius: 12px;
border: 1px solid #e5e7eb;
`;
const Table = styled.table`
width: 100%;
border-collapse: collapse;
background: white;
`;
const TableHeader = styled.th`
padding: 1rem 1.5rem;
text-align: left;
background: #f8fafc;
font-weight: 600;
color: #374151;
font-size: 0.875rem;
border-bottom: 1px solid #e5e7eb;
`;
const TableRow = styled.tr`
&:nth-child(even) {
background: #f9fafb;
}
&:hover {
background: #f3f4f6;
}
`;
const TableCell = styled.td`
padding: 1rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
font-size: 0.9rem;
color: #374151;
`;
// --- User Display Components ---
const UserInfo = styled.div`
display: flex;
align-items: center;
gap: 0.75rem;
`;
const UserAvatar = styled.div`
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.85rem;
`;
const UserDetails = styled.div``;
const UserName = styled.div`
font-weight: 500;
color: #1f2937;
`;
const UserEmail = styled.div`
font-size: 0.8rem;
color: #6b7280;
`;
// --- Badge Components ---
const Badge = styled.span`
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
background: ${props => {
switch (props.type) {
case 'Premium': return 'linear-gradient(135deg, #fbbf24, #f59e0b)';
case 'Gold': return 'linear-gradient(135deg, #3b82f6, #1d4ed8)';
case 'Standard': return 'linear-gradient(135deg, #8b5cf6, #7c3aed)';
default: return '#e5e7eb';
}
}};
color: ${props => props.type ? 'white' : '#374151'};
`;
const StatusBadge = styled.span`
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
background: ${props => {
switch (props.type) {
case 'enabled': return 'linear-gradient(135deg, #10b981, #059669)';
case 'disabled': return 'linear-gradient(135deg, #ef4444, #dc2626)';
case 'error': return 'linear-gradient(135deg, #ef4444, #dc2626)';
default: return '#e5e7eb';
}
}};
color: ${props => props.type ? 'white' : '#374151'};
`;
// --- Empty State Components ---
const EmptyState = styled(Card)`
text-align: center;
padding: 3rem 2rem;
`;
const EmptyIcon = styled.div`
font-size: 4rem;
margin-bottom: 1rem;
`;
const EmptyTitle = styled.h3`
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
`;
const EmptyText = styled.p`
color: #6b7280;
margin-bottom: 1rem;
`;
const EmptyNote = styled.p`
font-size: 0.85rem;
color: #9ca3af;
font-style: italic;
`;
const SectionTitle = styled.h2`
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 1rem;
`;
// --- Helper Functions ---
const generateRandomId = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
const generateDummyUsers = (count = 100) => {
return Array.from({ length: count }, (_, i) => ({
id: generateRandomId(),
name: `Test User ${i + 1}`,
email: `user${i + 1}@example.com`,
accountType: i % 3 === 0 ? 'Premium' : i % 2 === 0 ? 'Gold' : 'Standard',
}));
};
// --- Main Component ---
const ABTestSimulator = () => {
const [testResults, setTestResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [currentTestingUser, setCurrentTestingUser] = useState('');
const [featureFlagName, setFeatureFlagName] = useState('ab-test-variant-feature');
const updateContext = useUnleashContext();
const unleashClient = useUnleashClient();
const expectedVariants = [
{ name: 'control-cta', buttonText: 'Learn More', buttonColor: '#3b82f6', description: 'Standard blue button' },
{ name: 'variant-a-cta', buttonText: 'Discover Now', buttonColor: '#10b981', description: 'Green button for discovery' },
{ name: 'variant-b-cta', buttonText: 'Explore Features', buttonColor: '#ef4444', description: 'Red button to explore' },
];
const dummyUsers = generateDummyUsers(100);
const runABCTest = useCallback(async () => {
setIsLoading(true);
setTestResults([]);
const results = [];
for (let i = 0; i < dummyUsers.length; i++) {
const user = dummyUsers[i];
setCurrentTestingUser(user.name);
try {
await updateContext({
userId: user.id,
});
await new Promise((resolve) => setTimeout(resolve, 100));
const variant = unleashClient.getVariant(featureFlagName);
results.push({
...user,
variantName: variant.name,
variantPayload: variant.payload ? JSON.parse(variant.payload.value) : null,
testTimestamp: new Date().toLocaleTimeString(),
flagEnabled: variant.enabled,
});
} catch (error) {
console.error(`Error testing user ${user.id}:`, error);
results.push({
...user,
variantName: 'error',
variantPayload: null,
testTimestamp: new Date().toLocaleTimeString(),
flagEnabled: false,
error: true,
});
}
}
setTestResults(results);
setCurrentTestingUser('');
setIsLoading(false);
}, [dummyUsers, featureFlagName, updateContext, unleashClient]);
const calculateStats = useCallback(() => {
if (testResults.length === 0) {
return {
total: 0,
variants: expectedVariants.map(v => ({
name: v.name,
count: 0,
percentage: 0,
color: v.buttonColor,
buttonText: v.buttonText,
})),
totalEnabled: 0,
totalEnabledPercentage: 0,
};
}
const variantCounts = {};
let totalEnabled = 0;
testResults.forEach((result) => {
if (result.flagEnabled) {
totalEnabled++;
variantCounts[result.variantName] = (variantCounts[result.variantName] || 0) + 1;
}
});
const stats = expectedVariants.map(v => {
const count = variantCounts[v.name] || 0;
const percentage = totalEnabled > 0 ? Math.round((count / totalEnabled) * 100) : 0;
return {
name: v.name,
count,
percentage,
color: v.buttonColor,
buttonText: v.buttonText,
};
});
return {
total: testResults.length,
variants: stats,
totalEnabled: totalEnabled,
totalEnabledPercentage: testResults.length > 0 ? Math.round((totalEnabled / testResults.length) * 100) : 0,
};
}, [testResults, expectedVariants]);
const stats = calculateStats();
return (
<Container>
<MainContent>
{/* Header */}
<Card>
<Header>
<Title>✨ Unleash A/B/C Test Simulator</Title>
<Subtitle>
Simulate multivariate testing with real Unleash flag variants and observe distribution.
</Subtitle>
</Header>
</Card>
{/* Configuration */}
<Card>
<SectionTitle>Configuration</SectionTitle>
<ConfigSection>
<InputGroup>
<Label htmlFor="flagName">Feature Flag Name</Label>
<Input
id="flagName"
type="text"
value={featureFlagName}
onChange={(e) => setFeatureFlagName(e.target.value)}
placeholder="e.g., ab-test-variant-feature"
/>
</InputGroup>
<Button onClick={runABCTest} disabled={isLoading}>
{isLoading ? (
<>
<Spinner />
Testing...
</>
) : (
<>🚀 Start A/B/C Test ({dummyUsers.length} Users)</>
)}
</Button>
</ConfigSection>
</Card>
{/* Status */}
{isLoading && (
<StatusBanner>
<StatusSpinner />
<span>Currently testing: {currentTestingUser}</span>
</StatusBanner>
)}
{/* Variant Preview */}
<Card>
<SectionTitle>Simulated Variant Preview</SectionTitle>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: '1rem' }}>
These are the expected UI variations based on the payload configured in Unleash.
</p>
<VariantGrid>
{expectedVariants.map((variant) => (
<VariantCard key={variant.name}>
<VariantName>{variant.name}</VariantName>
<SimulatedButton color={variant.buttonColor}>
{variant.buttonText}
</SimulatedButton>
<VariantDescription>{variant.description}</VariantDescription>
</VariantCard>
))}
</VariantGrid>
</Card>
{/* Statistics */}
{testResults.length > 0 && (
<>
<StatsGrid>
<StatCard>
<StatValue color="#3b82f6">{stats.total}</StatValue>
<StatLabel>Total Users Tested</StatLabel>
</StatCard>
<StatCard>
<StatValue color="#10b981">{stats.totalEnabled}</StatValue>
<StatLabel>Flag Enabled Users</StatLabel>
</StatCard>
<StatCard>
<StatValue color="#8b5cf6">{stats.totalEnabledPercentage}%</StatValue>
<StatLabel>Total Rollout Percentage</StatLabel>
</StatCard>
</StatsGrid>
<Card>
<SectionTitle>Variant Distribution</SectionTitle>
<ProgressContainer>
<ProgressBar>
{stats.variants.map((variantStat) => (
<ProgressSegment
key={variantStat.name}
percentage={variantStat.percentage}
color={variantStat.color}
/>
))}
</ProgressBar>
<ProgressLabels>
{stats.variants.map((variantStat) => (
<span key={`label-${variantStat.name}`} style={{ color: variantStat.color }}>
{variantStat.name} ({variantStat.count})
</span>
))}
</ProgressLabels>
</ProgressContainer>
</Card>
</>
)}
{/* Results Table */}
{testResults.length > 0 && (
<Card style={{ padding: 0 }}>
<div style={{ padding: '2rem 2rem 1rem' }}>
<SectionTitle style={{ margin: 0 }}>Detailed Test Results</SectionTitle>
</div>
<TableContainer>
<Table>
<thead>
<tr>
<TableHeader>User</TableHeader>
<TableHeader>Account Type</TableHeader>
<TableHeader>Assigned Variant</TableHeader>
<TableHeader>Flag Enabled</TableHeader>
<TableHeader>Test Time</TableHeader>
</tr>
</thead>
<tbody>
{testResults.map((result) => (
<TableRow key={result.id}>
<TableCell>
<UserInfo>
<UserAvatar>
{result.name
.split(' ')
.map((n) => n[0])
.join('')
.slice(0, 2)}
</UserAvatar>
<UserDetails>
<UserName>{result.name}</UserName>
<UserEmail>{result.email}</UserEmail>
</UserDetails>
</UserInfo>
</TableCell>
<TableCell>
<Badge type={result.accountType}>
{result.accountType}
</Badge>
</TableCell>
<TableCell>
{result.error ? (
<StatusBadge type="error">❌ Error</StatusBadge>
) : result.flagEnabled ? (
<StatusBadge type="enabled">{result.variantName}</StatusBadge>
) : (
<StatusBadge type="disabled">N/A (Disabled)</StatusBadge>
)}
</TableCell>
<TableCell>
{result.error ? (
<StatusBadge type="error">❌ No</StatusBadge>
) : result.flagEnabled ? (
<StatusBadge type="enabled">✅ Yes</StatusBadge>
) : (
<StatusBadge type="disabled">❌ No</StatusBadge>
)}
</TableCell>
<TableCell>{result.testTimestamp}</TableCell>
</TableRow>
))}
</tbody>
</Table>
</TableContainer>
</Card>
)}
{/* Empty State */}
{testResults.length === 0 && !isLoading && (
<EmptyState>
<EmptyIcon>📊</EmptyIcon>
<EmptyTitle>Ready to Simulate A/B/C Tests</EmptyTitle>
<EmptyText>
Enter your feature flag name and click "Start Test" to see how Unleash distributes users across variants.
</EmptyText>
<EmptyNote>
Ensure your flag "{featureFlagName}" is configured in Unleash with at least three variants and a gradual rollout strategy.
</EmptyNote>
</EmptyState>
)}
</MainContent>
</Container>
);
};
export default ABTestSimulator;
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (0)