import React, { useState, useEffect, useCallback } from 'react';
import { useUnleashContext, useUnleashClient } from '@unleash/proxy-client-react';
import styled, { keyframes, css } from 'styled-components';
// --- Styled Components ---
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); }
`;
const Container = styled.div`
font-family: 'Inter', sans-serif;
background-color: #f8fafc; /* Tailwind gray-50 */
min-height: 100vh;
padding: 2rem;
display: flex;
justify-content: center;
align-items: flex-start;
`;
const MaxWidthWrapper = styled.div`
max-width: 960px;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
`;
const Card = styled.div`
background-color: white;
border-radius: 0.75rem; /* rounded-xl */
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); /* shadow-xl */
padding: 1.5rem;
border: 1px solid #e2e8f0; /* border-gray-200 */
`;
const Header = styled.div`
text-align: center;
margin-bottom: 1rem;
`;
const Title = styled.h1`
font-size: 2.25rem; /* text-4xl */
font-weight: 700; /* font-bold */
color: #1a202c; /* gray-900 */
margin-bottom: 0.5rem;
`;
const Subtitle = styled.p`
font-size: 1.125rem; /* text-lg */
color: #4a5568; /* gray-600 */
`;
const SectionTitle = styled.h2`
font-size: 1.5rem; /* text-2xl */
font-weight: 600; /* font-semibold */
color: #2d3748; /* gray-800 */
margin-bottom: 1rem;
`;
const ConfigPanel = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
@media (min-width: 640px) { /* sm */
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
}
`;
const InputGroup = styled.div`
flex-grow: 1;
`;
const Label = styled.label`
display: block;
font-size: 0.875rem; /* text-sm */
font-weight: 500; /* font-medium */
color: #2d3748; /* gray-700 */
margin-bottom: 0.5rem;
`;
const Input = styled.input`
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e0; /* border-gray-300 */
border-radius: 0.5rem; /* rounded-lg */
font-size: 1rem;
color: #2d3748;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
&:focus {
outline: none;
border-color: #3b82f6; /* blue-500 */
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); /* ring-blue-300 */
}
`;
const Button = styled.button`
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background-color: #3b82f6; /* blue-500 */
color: white;
font-weight: 600; /* font-semibold */
border-radius: 0.5rem; /* rounded-lg */
border: none;
cursor: pointer;
transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* shadow-md */
&:hover {
background-color: #2563eb; /* blue-600 */
box-shadow: 0 6px 8px -1px rgba(0, 0, 0, 0.15), 0 4px 6px -2px rgba(0, 0, 0, 0.08);
}
&:disabled {
background-color: #9ca3af; /* gray-400 */
cursor: not-allowed;
box-shadow: none;
}
`;
const Spinner = styled.div`
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
animation: ${spin} 1s linear infinite;
`;
const StatusBanner = styled.div`
background-color: #eff6ff; /* blue-50 */
border: 1px solid #bfdbfe; /* blue-200 */
color: #1e40af; /* blue-800 */
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
animation: ${fadeIn} 0.5s ease-out;
`;
const StatusContent = styled.div`
display: flex;
align-items: center;
gap: 0.75rem;
`;
const StatusSpinner = styled(Spinner)`
border-color: rgba(30, 64, 175, 0.3);
border-top-color: #1e40af;
width: 1.25rem;
height: 1.25rem;
`;
const StatusText = styled.p`
font-size: 1rem;
font-weight: 500;
`;
const StatsGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
`;
const StatCard = styled(Card)`
padding: 1rem;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #f0f9ff; /* blue-50 */
`;
const StatValue = styled.p`
font-size: 2.5rem; /* text-5xl */
font-weight: 700; /* font-bold */
color: ${(props) => props.color || '#1a202c'};
line-height: 1;
margin-bottom: 0.5rem;
`;
const StatLabel = styled.p`
font-size: 0.875rem; /* text-sm */
color: #4a5568; /* gray-600 */
font-weight: 500;
`;
const ProgressSection = styled(Card)`
padding: 1.5rem;
`;
const ProgressTitle = styled(SectionTitle)`
margin-bottom: 1.5rem;
text-align: center;
`;
const ProgressBarContainer = styled.div`
width: 100%;
background-color: #e2e8f0; /* gray-200 */
border-radius: 9999px; /* rounded-full */
height: 1.5rem;
overflow: hidden;
display: flex; /* To allow multiple bars */
`;
const ProgressBarSegment = styled.div`
height: 100%;
background-color: ${(props) => props.color || '#3b82f6'};
width: ${(props) => props.percentage}%;
transition: width 0.5s ease-out;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
text-shadow: 1px 1px 2px rgba(0,0,0,0.2);
`;
const ProgressLabels = styled.div`
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #4a5568;
margin-top: 0.5rem;
`;
const ProgressCenter = styled.span`
text-align: center;
flex-grow: 1;
`;
const ResultsCard = styled(Card)`
padding: 0; /* Remove padding from card, table will handle it */
overflow: hidden; /* For rounded corners on table */
`;
const ResultsHeader = styled.div`
padding: 1.5rem;
border-bottom: 1px solid #e2e8f0;
`;
const ResultsTitle = styled(SectionTitle)`
margin-bottom: 0;
`;
const TableContainer = styled.div`
overflow-x: auto;
-webkit-overflow-scrolling: touch; /* For smooth scrolling on iOS */
`;
const Table = styled.table`
width: 100%;
border-collapse: collapse;
`;
const TableHead = styled.thead`
background-color: #f8fafc; /* gray-50 */
`;
const TableHeader = styled.th`
padding: 1rem 1.5rem;
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: #4a5568;
text-transform: uppercase;
letter-spacing: 0.05em;
`;
const TableRow = styled.tr`
&:nth-child(even) {
background-color: #f8fafc; /* gray-50 */
}
&:hover {
background-color: #f0f4f8; /* slightly darker on hover */
}
`;
const TableCell = styled.td`
padding: 1rem 1.5rem;
font-size: 0.9375rem;
color: #2d3748;
vertical-align: middle;
`;
const UserInfo = styled.div`
display: flex;
align-items: center;
gap: 0.75rem;
`;
const UserAvatar = styled.div`
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background-color: #60a5fa; /* blue-400 */
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
`;
const UserInitials = styled.span``;
const UserDetails = styled.div`
display: flex;
flex-direction: column;
`;
const UserName = styled.p`
font-weight: 500;
`;
const UserEmail = styled.p`
font-size: 0.8125rem; /* text-xs */
color: #718096; /* gray-500 */
`;
const Badge = styled.span`
display: inline-flex;
padding: 0.25rem 0.75rem;
border-radius: 9999px; /* rounded-full */
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
${(props) => {
switch (props.type) {
case 'Premium':
return css`
background-color: #fef3c7; /* yellow-100 */
color: #b45309; /* yellow-800 */
`;
case 'Gold':
return css`
background-color: #dbeafe; /* blue-100 */
color: #1e40af; /* blue-800 */
`;
case 'Standard':
return css`
background-color: #e0e7ff; /* indigo-100 */
color: #4338ca; /* indigo-800 */
`;
default:
return css`
background-color: #e2e8f0;
color: #4a5568;
`;
}
}}
`;
const StatusBadge = styled.span`
display: inline-flex;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
${(props) => {
switch (props.type) {
case 'enabled':
return css`
background-color: #dcfce7; /* green-100 */
color: #16a34a; /* green-700 */
`;
case 'disabled':
return css`
background-color: #fee2e2; /* red-100 */
color: #dc2626; /* red-700 */
`;
case 'error':
return css`
background-color: #fee2e2; /* red-100 */
color: #dc2626; /* red-700 */
`;
default:
return css`
background-color: #e2e8f0;
color: #4a5568;
`;
}
}}
`;
const EmptyState = styled.div`
text-align: center;
padding: 3rem 1.5rem;
background-color: white;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
animation: ${fadeIn} 0.5s ease-out;
`;
const EmptyStateIcon = styled.span`
font-size: 3rem;
margin-bottom: 1rem;
display: block;
`;
const EmptyStateTitle = styled.h3`
font-size: 1.5rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 0.5rem;
`;
const EmptyStateText = styled.p`
font-size: 1rem;
color: #4a5568;
margin-bottom: 1rem;
`;
const EmptyStateNote = styled.p`
font-size: 0.875rem;
color: #718096;
font-style: italic;
`;
const VariantDisplaySection = styled(Card)`
padding: 1.5rem;
`;
const VariantDisplayGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
`;
const VariantCard = styled.div`
background-color: #f0f9ff; /* blue-50 */
border: 1px solid #bfdbfe; /* blue-200 */
border-radius: 0.75rem;
padding: 1.25rem;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;
const VariantName = styled.h4`
font-size: 1.125rem;
font-weight: 600;
color: #1e40af; /* blue-800 */
margin-bottom: 0.75rem;
`;
const SimulatedButton = styled.button`
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
color: white;
background-color: ${(props) => props.color || '#3b82f6'};
border: none;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.25);
}
`;
const VariantDescription = styled.p`
font-size: 0.875rem;
color: #4a5568;
margin-top: 0.75rem;
`;
// --- Helper Functions ---
const generateRandomId = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
const generateDummyUsers = (count = 100) => { // Increased default count for better stats
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'); // Default flag name
const updateContext = useUnleashContext();
const unleashClient = useUnleashClient();
// Define expected variants for display purposes (these should match Unleash config)
// In a real app, you might fetch these or have them configured.
const expectedVariants = [
{ name: 'control-cta', buttonText: 'Learn More', buttonColor: '#2563eb', description: 'Standard blue button' },
{ name: 'variant-a-cta', buttonText: 'Discover Now', buttonColor: '#16a34a', description: 'Green button for discovery' },
{ name: 'variant-b-cta', buttonText: 'Explore Features', buttonColor: '#dc2626', description: 'Red button to explore' },
];
const dummyUsers = generateDummyUsers(100); // Generate 100 dummy users for better distribution
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 {
// Update context for this user
await updateContext({
userId: user.id,
// You can add other context properties if your Unleash strategies use them
// e.g., accountType: user.accountType,
});
// Small delay to allow context to fully propagate and client to potentially re-evaluate
// This is more for visual effect in a rapid loop, less for functional necessity with useUnleashClient
await new Promise((resolve) => setTimeout(resolve, 100));
// Get the variant for the current user and feature flag
const variant = unleashClient.getVariant(featureFlagName);
results.push({
...user,
variantName: variant.name,
variantPayload: variant.payload ? JSON.parse(variant.payload.value) : null, // Parse payload if it exists
testTimestamp: new Date().toLocaleTimeString(),
flagEnabled: variant.enabled, // Track if the flag was enabled for this user
});
} 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]);
// Calculate rollout statistics for each variant
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) { // Only count if the flag was actually enabled for the user
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>
<MaxWidthWrapper>
{/* 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 Panel */}
<Card>
<SectionTitle>Configuration</SectionTitle>
<ConfigPanel>
<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>
</ConfigPanel>
</Card>
{/* Current Status */}
{isLoading && (
<StatusBanner>
<StatusContent>
<StatusSpinner />
<StatusText>Currently testing: {currentTestingUser}</StatusText>
</StatusContent>
</StatusBanner>
)}
{/* Visual Representation of Variants */}
<VariantDisplaySection>
<SectionTitle>Simulated Variant Preview</SectionTitle>
<p style={{ color: '#4a5568', fontSize: '0.9rem', marginBottom: '1rem' }}>
These are the expected UI variations based on the payload configured in Unleash.
Your application would render one of these for each user.
</p>
<VariantDisplayGrid>
{expectedVariants.map((variant) => (
<VariantCard key={variant.name}>
<VariantName>{variant.name}</VariantName>
<SimulatedButton color={variant.buttonColor}>
{variant.buttonText}
</SimulatedButton>
<VariantDescription>{variant.description}</VariantDescription>
</VariantCard>
))}
</VariantDisplayGrid>
</VariantDisplaySection>
{/* Statistics Dashboard */}
{testResults.length > 0 && (
<>
<StatsGrid>
<StatCard>
<StatValue color="#2563eb">{stats.total}</StatValue>
<StatLabel>Total Users Tested</StatLabel>
</StatCard>
<StatCard>
<StatValue color="#16a34a">{stats.totalEnabled}</StatValue>
<StatLabel>Flag Enabled Users</StatLabel>
</StatCard>
<StatCard>
<StatValue color="#7c3aed">{stats.totalEnabledPercentage}%</StatValue>
<StatLabel>Total Rollout Percentage</StatLabel>
</StatCard>
</StatsGrid>
<ProgressSection>
<ProgressTitle>Variant Distribution</ProgressTitle>
<ProgressBarContainer>
{stats.variants.map((variantStat) => (
<ProgressBarSegment key={variantStat.name} percentage={variantStat.percentage} color={variantStat.color}>
{variantStat.percentage > 0 && `${variantStat.percentage}%`}
</ProgressBarSegment>
))}
</ProgressBarContainer>
<ProgressLabels>
{stats.variants.map((variantStat) => (
<span key={`label-${variantStat.name}`} style={{ flex: 1, textAlign: 'center', color: variantStat.color }}>
{variantStat.name} ({variantStat.count})
</span>
))}
</ProgressLabels>
</ProgressSection>
</>
)}
{/* Results Table */}
{testResults.length > 0 && (
<ResultsCard>
<ResultsHeader>
<ResultsTitle>Detailed Test Results</ResultsTitle>
</ResultsHeader>
<TableContainer>
<Table>
<TableHead>
<tr>
<TableHeader>User</TableHeader>
<TableHeader>Account Type</TableHeader>
<TableHeader>Assigned Variant</TableHeader>
<TableHeader>Flag Enabled</TableHeader>
<TableHeader>Test Time</TableHeader>
</tr>
</TableHead>
<tbody>
{testResults.map((result, index) => (
<TableRow key={result.id}>
<TableCell>
<UserInfo>
<UserAvatar>
<UserInitials>
{result.name
.split(' ')
.map((n) => n[0])
.join('')
.slice(0, 2)}
</UserInitials>
</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>
</ResultsCard>
)}
{/* Initial State */}
{testResults.length === 0 && !isLoading && (
<EmptyState>
<EmptyStateIcon>๐</EmptyStateIcon>
<EmptyStateTitle>Ready to Simulate A/B/C Tests</EmptyStateTitle>
<EmptyStateText>
Enter your feature flag name and click "Start Test" to see how Unleash distributes users across variants.
</EmptyStateText>
<EmptyStateNote>
Ensure your flag "{featureFlagName}" is configured in Unleash with at least three variants and a gradual rollout strategy.
</EmptyStateNote>
</EmptyState>
)}
</MaxWidthWrapper>
</Container>
);
};
export default ABTestSimulator;
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (0)