From 5f724959768fee6f895a02744391562c0f4770c1 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 9 Apr 2026 15:25:30 -0300 Subject: [PATCH 01/51] =?UTF-8?q?feat:=20Experiments=20V2=20=E2=80=94=20wi?= =?UTF-8?q?zard=20components,=20results=20page,=20and=20Storybook=20storie?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Experiments V2 UI from the Pencil designs as separated, modular React components with mock data. This is the frontend scaffolding for the new experiment creation wizard and results dashboard. Components are organised into four layers: - `wizard/` — Reusable wizard primitives (Layout, Sidebar, StepIndicator, Header, NavButtons). Zero experiment knowledge — can be reused for any future multi-step wizard. - `shared/` — Experiment-specific building blocks (SelectableCard, VariationTable, TrafficSplitBar, StatusBadge, ExperimentStatCard, MetricsComparisonTable). - `steps/` — Five wizard step components, each a pure presentation component receiving state via props. - `flag-detail/` — LinkedExperimentSection for the feature flag detail page. - **CreateExperimentPage** (`/experiments/create`) — 5-step wizard: 1. Experiment Details (name, hypothesis, type) 2. Select Metrics (search, primary/secondary) 3. Flag & Variations (flag dropdown, variation table) 4. Audience & Traffic (segment, traffic slider, split bar) 5. Review & Launch (summary with edit buttons) - **ExperimentResultsPage** (`/experiments/:experimentId`) — Dashboard with stat cards (users enrolled, winning variation, probability, lift) and metrics comparison table. 19 stories under `Experiments/` with interactive controls and light/dark mode support. Wizard components are nested under `Experiments/Wizard/`. A composed `Wizard` story demonstrates all primitives working together. - All data is mock/fake — no API calls yet. Mock data is centralised in `types.ts` for easy replacement with RTK Query hooks later. - Routes gated behind `experimental_flags` feature flag. - Global `Select` (react-select) registered in Storybook preview for SearchableSelect compatibility. Co-Authored-By: Claude Sonnet 4.6 --- .../AudienceTrafficStep.stories.tsx | 50 ++++ .../ExperimentDetailsStep.stories.tsx | 52 ++++ .../ExperimentStatCard.stories.tsx | 40 +++ .../FlagVariationsStep.stories.tsx | 46 +++ .../LinkedExperimentSection.stories.tsx | 34 +++ .../MetricsComparisonTable.stories.tsx | 27 ++ .../experiments/ReviewLaunchStep.stories.tsx | 49 ++++ .../experiments/SelectMetricsStep.stories.tsx | 60 ++++ .../experiments/SelectableCard.stories.tsx | 98 +++++++ .../experiments/StatusBadge.stories.tsx | 15 + .../experiments/TrafficSplitBar.stories.tsx | 38 +++ .../experiments/VariationTable.stories.tsx | 49 ++++ .../experiments/Wizard.stories.tsx | 88 ++++++ .../experiments/WizardHeader.stories.tsx | 31 ++ .../experiments/WizardLayout.stories.tsx | 41 +++ .../experiments/WizardNavButtons.stories.tsx | 40 +++ .../experiments/WizardSidebar.stories.tsx | 49 ++++ .../WizardStepIndicator.stories.tsx | 52 ++++ .../experiments-v2/CreateExperimentPage.scss | 12 + .../experiments-v2/CreateExperimentPage.tsx | 179 ++++++++++++ .../experiments-v2/ExperimentResultsPage.scss | 70 +++++ .../experiments-v2/ExperimentResultsPage.tsx | 77 +++++ .../flag-detail/LinkedExperimentSection.scss | 141 +++++++++ .../flag-detail/LinkedExperimentSection.tsx | 119 ++++++++ .../shared/ExperimentStatCard.scss | 39 +++ .../shared/ExperimentStatCard.tsx | 36 +++ .../shared/MetricsComparisonTable.scss | 63 ++++ .../shared/MetricsComparisonTable.tsx | 59 ++++ .../experiments-v2/shared/SelectableCard.scss | 71 +++++ .../experiments-v2/shared/SelectableCard.tsx | 49 ++++ .../experiments-v2/shared/StatusBadge.scss | 42 +++ .../experiments-v2/shared/StatusBadge.tsx | 25 ++ .../shared/TrafficSplitBar.scss | 53 ++++ .../experiments-v2/shared/TrafficSplitBar.tsx | 47 +++ .../experiments-v2/shared/VariationTable.scss | 120 ++++++++ .../experiments-v2/shared/VariationTable.tsx | 75 +++++ .../steps/AudienceTrafficStep.scss | 81 +++++ .../steps/AudienceTrafficStep.tsx | 89 ++++++ .../steps/ExperimentDetailsStep.scss | 53 ++++ .../steps/ExperimentDetailsStep.tsx | 103 +++++++ .../steps/FlagVariationsStep.scss | 33 +++ .../steps/FlagVariationsStep.tsx | 123 ++++++++ .../steps/ReviewLaunchStep.scss | 80 +++++ .../experiments-v2/steps/ReviewLaunchStep.tsx | 139 +++++++++ .../steps/SelectMetricsStep.scss | 44 +++ .../steps/SelectMetricsStep.tsx | 88 ++++++ .../web/components/experiments-v2/types.ts | 276 ++++++++++++++++++ .../experiments-v2/wizard/WizardHeader.scss | 39 +++ .../experiments-v2/wizard/WizardHeader.tsx | 44 +++ .../experiments-v2/wizard/WizardLayout.scss | 17 ++ .../experiments-v2/wizard/WizardLayout.tsx | 19 ++ .../wizard/WizardNavButtons.scss | 13 + .../wizard/WizardNavButtons.tsx | 54 ++++ .../experiments-v2/wizard/WizardSidebar.scss | 6 + .../experiments-v2/wizard/WizardSidebar.tsx | 45 +++ .../wizard/WizardStepIndicator.scss | 121 ++++++++ .../wizard/WizardStepIndicator.tsx | 76 +++++ frontend/web/routes.js | 26 ++ 58 files changed, 3705 insertions(+) create mode 100644 frontend/documentation/experiments/AudienceTrafficStep.stories.tsx create mode 100644 frontend/documentation/experiments/ExperimentDetailsStep.stories.tsx create mode 100644 frontend/documentation/experiments/ExperimentStatCard.stories.tsx create mode 100644 frontend/documentation/experiments/FlagVariationsStep.stories.tsx create mode 100644 frontend/documentation/experiments/LinkedExperimentSection.stories.tsx create mode 100644 frontend/documentation/experiments/MetricsComparisonTable.stories.tsx create mode 100644 frontend/documentation/experiments/ReviewLaunchStep.stories.tsx create mode 100644 frontend/documentation/experiments/SelectMetricsStep.stories.tsx create mode 100644 frontend/documentation/experiments/SelectableCard.stories.tsx create mode 100644 frontend/documentation/experiments/StatusBadge.stories.tsx create mode 100644 frontend/documentation/experiments/TrafficSplitBar.stories.tsx create mode 100644 frontend/documentation/experiments/VariationTable.stories.tsx create mode 100644 frontend/documentation/experiments/Wizard.stories.tsx create mode 100644 frontend/documentation/experiments/WizardHeader.stories.tsx create mode 100644 frontend/documentation/experiments/WizardLayout.stories.tsx create mode 100644 frontend/documentation/experiments/WizardNavButtons.stories.tsx create mode 100644 frontend/documentation/experiments/WizardSidebar.stories.tsx create mode 100644 frontend/documentation/experiments/WizardStepIndicator.stories.tsx create mode 100644 frontend/web/components/experiments-v2/CreateExperimentPage.scss create mode 100644 frontend/web/components/experiments-v2/CreateExperimentPage.tsx create mode 100644 frontend/web/components/experiments-v2/ExperimentResultsPage.scss create mode 100644 frontend/web/components/experiments-v2/ExperimentResultsPage.tsx create mode 100644 frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss create mode 100644 frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx create mode 100644 frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss create mode 100644 frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx create mode 100644 frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss create mode 100644 frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx create mode 100644 frontend/web/components/experiments-v2/shared/SelectableCard.scss create mode 100644 frontend/web/components/experiments-v2/shared/SelectableCard.tsx create mode 100644 frontend/web/components/experiments-v2/shared/StatusBadge.scss create mode 100644 frontend/web/components/experiments-v2/shared/StatusBadge.tsx create mode 100644 frontend/web/components/experiments-v2/shared/TrafficSplitBar.scss create mode 100644 frontend/web/components/experiments-v2/shared/TrafficSplitBar.tsx create mode 100644 frontend/web/components/experiments-v2/shared/VariationTable.scss create mode 100644 frontend/web/components/experiments-v2/shared/VariationTable.tsx create mode 100644 frontend/web/components/experiments-v2/steps/AudienceTrafficStep.scss create mode 100644 frontend/web/components/experiments-v2/steps/AudienceTrafficStep.tsx create mode 100644 frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss create mode 100644 frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx create mode 100644 frontend/web/components/experiments-v2/steps/FlagVariationsStep.scss create mode 100644 frontend/web/components/experiments-v2/steps/FlagVariationsStep.tsx create mode 100644 frontend/web/components/experiments-v2/steps/ReviewLaunchStep.scss create mode 100644 frontend/web/components/experiments-v2/steps/ReviewLaunchStep.tsx create mode 100644 frontend/web/components/experiments-v2/steps/SelectMetricsStep.scss create mode 100644 frontend/web/components/experiments-v2/steps/SelectMetricsStep.tsx create mode 100644 frontend/web/components/experiments-v2/types.ts create mode 100644 frontend/web/components/experiments-v2/wizard/WizardHeader.scss create mode 100644 frontend/web/components/experiments-v2/wizard/WizardHeader.tsx create mode 100644 frontend/web/components/experiments-v2/wizard/WizardLayout.scss create mode 100644 frontend/web/components/experiments-v2/wizard/WizardLayout.tsx create mode 100644 frontend/web/components/experiments-v2/wizard/WizardNavButtons.scss create mode 100644 frontend/web/components/experiments-v2/wizard/WizardNavButtons.tsx create mode 100644 frontend/web/components/experiments-v2/wizard/WizardSidebar.scss create mode 100644 frontend/web/components/experiments-v2/wizard/WizardSidebar.tsx create mode 100644 frontend/web/components/experiments-v2/wizard/WizardStepIndicator.scss create mode 100644 frontend/web/components/experiments-v2/wizard/WizardStepIndicator.tsx diff --git a/frontend/documentation/experiments/AudienceTrafficStep.stories.tsx b/frontend/documentation/experiments/AudienceTrafficStep.stories.tsx new file mode 100644 index 000000000000..6da74561d6d0 --- /dev/null +++ b/frontend/documentation/experiments/AudienceTrafficStep.stories.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import AudienceTrafficStep from 'components/experiments-v2/steps/AudienceTrafficStep' +import { + AudienceConfig, + MOCK_VARIATIONS, +} from 'components/experiments-v2/types' + +const meta: Meta = { + component: AudienceTrafficStep, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/Steps/AudienceTrafficStep', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + audience: { segmentId: null, splits: [], trafficPercentage: 50 }, + onChange: () => {}, + variations: MOCK_VARIATIONS, + }, +} + +export const Interactive: Story = { + decorators: [ + () => { + const [audience, setAudience] = useState({ + segmentId: 'seg-1', + splits: [], + trafficPercentage: 50, + }) + return ( + + ) + }, + ], +} diff --git a/frontend/documentation/experiments/ExperimentDetailsStep.stories.tsx b/frontend/documentation/experiments/ExperimentDetailsStep.stories.tsx new file mode 100644 index 000000000000..b76e1dfc1a49 --- /dev/null +++ b/frontend/documentation/experiments/ExperimentDetailsStep.stories.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import ExperimentDetailsStep from 'components/experiments-v2/steps/ExperimentDetailsStep' +import { ExperimentDetails } from 'components/experiments-v2/types' + +const meta: Meta = { + component: ExperimentDetailsStep, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/Steps/ExperimentDetailsStep', +} +export default meta + +type Story = StoryObj + +export const Empty: Story = { + args: { + details: { hypothesis: '', name: '', type: null }, + onChange: () => {}, + }, +} + +export const Filled: Story = { + args: { + details: { + hypothesis: + 'Redesigning the checkout button with a clearer CTA will increase conversion rates by 15%', + name: 'Checkout Flow Redesign', + type: 'ab_test', + }, + onChange: () => {}, + }, +} + +export const Interactive: Story = { + decorators: [ + () => { + const [details, setDetails] = useState({ + hypothesis: '', + name: '', + type: null, + }) + return + }, + ], +} diff --git a/frontend/documentation/experiments/ExperimentStatCard.stories.tsx b/frontend/documentation/experiments/ExperimentStatCard.stories.tsx new file mode 100644 index 000000000000..1dbbc0e0fac2 --- /dev/null +++ b/frontend/documentation/experiments/ExperimentStatCard.stories.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import ExperimentStatCard from 'components/experiments-v2/shared/ExperimentStatCard' + +const meta: Meta = { + component: ExperimentStatCard, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/ExperimentStatCard', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { label: 'Users Enrolled', value: 12847 }, +} + +export const Positive: Story = { + args: { + label: 'Winning Variation', + trend: 'positive', + value: 'Treatment B', + }, +} + +export const WithSubtitle: Story = { + args: { + label: 'Lift vs Control', + subtitle: 'vs control group', + trend: 'positive', + value: '+18.3%', + }, +} diff --git a/frontend/documentation/experiments/FlagVariationsStep.stories.tsx b/frontend/documentation/experiments/FlagVariationsStep.stories.tsx new file mode 100644 index 000000000000..97be530dfb72 --- /dev/null +++ b/frontend/documentation/experiments/FlagVariationsStep.stories.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import FlagVariationsStep from 'components/experiments-v2/steps/FlagVariationsStep' +import { MOCK_VARIATIONS, Variation } from 'components/experiments-v2/types' + +const meta: Meta = { + component: FlagVariationsStep, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/Steps/FlagVariationsStep', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + featureFlagId: 'flag-1', + onFlagChange: () => {}, + onVariationsChange: () => {}, + variations: MOCK_VARIATIONS, + }, +} + +export const Interactive: Story = { + decorators: [ + () => { + const [flagId, setFlagId] = useState('flag-1') + const [variations, setVariations] = useState(MOCK_VARIATIONS) + return ( + + ) + }, + ], +} diff --git a/frontend/documentation/experiments/LinkedExperimentSection.stories.tsx b/frontend/documentation/experiments/LinkedExperimentSection.stories.tsx new file mode 100644 index 000000000000..eccdc440e343 --- /dev/null +++ b/frontend/documentation/experiments/LinkedExperimentSection.stories.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import LinkedExperimentSection from 'components/experiments-v2/flag-detail/LinkedExperimentSection' +import { MOCK_LINKED_EXPERIMENT } from 'components/experiments-v2/types' + +const meta: Meta = { + component: LinkedExperimentSection, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/LinkedExperimentSection', +} +export default meta + +type Story = StoryObj + +export const WithExperiment: Story = { + args: { + experiment: MOCK_LINKED_EXPERIMENT, + onViewResults: () => alert('View results'), + }, +} + +export const Empty: Story = { + args: { + experiment: null, + onCreateExperiment: () => alert('Create experiment'), + }, +} diff --git a/frontend/documentation/experiments/MetricsComparisonTable.stories.tsx b/frontend/documentation/experiments/MetricsComparisonTable.stories.tsx new file mode 100644 index 000000000000..6d5d3f68848a --- /dev/null +++ b/frontend/documentation/experiments/MetricsComparisonTable.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from 'storybook' +import MetricsComparisonTable from 'components/experiments-v2/shared/MetricsComparisonTable' +import { MOCK_EXPERIMENT_RESULT } from 'components/experiments-v2/types' + +const meta: Meta = { + component: MetricsComparisonTable, + tags: ['autodocs'], + title: 'Experiments/MetricsComparisonTable', +} +export default meta + +type Story = StoryObj + +export const WithSignificance: Story = { + args: { metrics: MOCK_EXPERIMENT_RESULT.metrics }, +} + +export const NoSignificance: Story = { + args: { + metrics: MOCK_EXPERIMENT_RESULT.metrics.map((m) => ({ + ...m, + isSignificant: false, + liftDirection: 'neutral' as const, + significance: 'not significant', + })), + }, +} diff --git a/frontend/documentation/experiments/ReviewLaunchStep.stories.tsx b/frontend/documentation/experiments/ReviewLaunchStep.stories.tsx new file mode 100644 index 000000000000..0a042e178f6d --- /dev/null +++ b/frontend/documentation/experiments/ReviewLaunchStep.stories.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import ReviewLaunchStep from 'components/experiments-v2/steps/ReviewLaunchStep' +import { + ExperimentWizardState, + MOCK_METRICS, + MOCK_VARIATIONS, +} from 'components/experiments-v2/types' + +const MOCK_WIZARD_STATE: ExperimentWizardState = { + audience: { + segmentId: 'seg-1', + splits: [], + trafficPercentage: 50, + }, + currentStep: 4, + details: { + hypothesis: + 'Redesigning the checkout button will increase conversions by 15%', + name: 'Checkout Flow Redesign', + type: 'ab_test', + }, + featureFlagId: 'flag-1', + metrics: [MOCK_METRICS[0], { ...MOCK_METRICS[1], role: 'secondary' }], + variations: MOCK_VARIATIONS, +} + +const meta: Meta = { + component: ReviewLaunchStep, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/Steps/ReviewLaunchStep', +} +export default meta + +type Story = StoryObj + +export const FullReview: Story = { + args: { + onEditStep: (step: number) => alert(`Edit step ${step}`), + wizardState: MOCK_WIZARD_STATE, + }, +} diff --git a/frontend/documentation/experiments/SelectMetricsStep.stories.tsx b/frontend/documentation/experiments/SelectMetricsStep.stories.tsx new file mode 100644 index 000000000000..36c3d2b73a13 --- /dev/null +++ b/frontend/documentation/experiments/SelectMetricsStep.stories.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import SelectMetricsStep from 'components/experiments-v2/steps/SelectMetricsStep' +import { Metric, MOCK_METRICS } from 'components/experiments-v2/types' + +const meta: Meta = { + component: SelectMetricsStep, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/Steps/SelectMetricsStep', +} +export default meta + +type Story = StoryObj + +export const NoneSelected: Story = { + args: { + onToggleMetric: () => {}, + selectedMetrics: [], + }, +} + +export const WithMetrics: Story = { + args: { + onToggleMetric: () => {}, + selectedMetrics: [ + MOCK_METRICS[0], + { ...MOCK_METRICS[1], role: 'secondary' }, + ], + }, +} + +export const Interactive: Story = { + decorators: [ + () => { + const [selected, setSelected] = useState([]) + const handleToggle = (metric: Metric) => { + const exists = selected.find((m) => m.id === metric.id) + if (exists) { + setSelected(selected.filter((m) => m.id !== metric.id)) + } else { + const role = selected.length === 0 ? 'primary' : 'secondary' + setSelected([...selected, { ...metric, role }]) + } + } + return ( + + ) + }, + ], +} diff --git a/frontend/documentation/experiments/SelectableCard.stories.tsx b/frontend/documentation/experiments/SelectableCard.stories.tsx new file mode 100644 index 000000000000..d1c76d528bc3 --- /dev/null +++ b/frontend/documentation/experiments/SelectableCard.stories.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import SelectableCard from 'components/experiments-v2/shared/SelectableCard' + +const meta: Meta = { + component: SelectableCard, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/SelectableCard', +} +export default meta + +type Story = StoryObj + +export const WithIcon: Story = { + args: { + description: 'Compare two variations', + icon: 'bar-chart', + onClick: () => {}, + selected: true, + title: 'A/B Test', + }, +} + +export const WithBadge: Story = { + args: { + badge: { label: 'Primary', variant: 'primary' }, + description: 'Percentage of users completing checkout', + onClick: () => {}, + selected: true, + title: 'Checkout Conversion Rate', + }, +} + +export const Unselected: Story = { + args: { + description: 'Test multiple variables', + icon: 'layers', + onClick: () => {}, + selected: false, + title: 'Multivariate', + }, +} + +export const SecondaryBadge: Story = { + args: { + badge: { label: 'Secondary', variant: 'secondary' }, + description: 'Average revenue generated per user session', + onClick: () => {}, + selected: true, + title: 'Revenue per User', + }, +} + +export const InteractiveGroup: Story = { + decorators: [ + () => { + const [selected, setSelected] = useState(0) + const items = [ + { + description: 'Compare two variations', + icon: 'bar-chart' as const, + title: 'A/B Test', + }, + { + description: 'Test multiple variables', + icon: 'layers' as const, + title: 'Multivariate', + }, + { + description: 'Toggle feature on/off', + icon: 'features' as const, + title: 'Feature Flag', + }, + ] + return ( +
+ {items.map((item, i) => ( + setSelected(i)} + /> + ))} +
+ ) + }, + ], +} diff --git a/frontend/documentation/experiments/StatusBadge.stories.tsx b/frontend/documentation/experiments/StatusBadge.stories.tsx new file mode 100644 index 000000000000..fd9601842f7e --- /dev/null +++ b/frontend/documentation/experiments/StatusBadge.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from 'storybook' +import StatusBadge from 'components/experiments-v2/shared/StatusBadge' + +const meta: Meta = { + component: StatusBadge, + tags: ['autodocs'], + title: 'Experiments/StatusBadge', +} +export default meta + +type Story = StoryObj + +export const Running: Story = { args: { status: 'running' } } +export const Paused: Story = { args: { status: 'paused' } } +export const Completed: Story = { args: { status: 'completed' } } diff --git a/frontend/documentation/experiments/TrafficSplitBar.stories.tsx b/frontend/documentation/experiments/TrafficSplitBar.stories.tsx new file mode 100644 index 000000000000..87d61342f926 --- /dev/null +++ b/frontend/documentation/experiments/TrafficSplitBar.stories.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import TrafficSplitBar from 'components/experiments-v2/shared/TrafficSplitBar' + +const meta: Meta = { + component: TrafficSplitBar, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/TrafficSplitBar', +} +export default meta + +type Story = StoryObj + +export const EvenSplit: Story = { + args: { + splits: [ + { colour: 'var(--green-500)', name: 'Control', percentage: 50 }, + { colour: 'var(--purple-500)', name: 'Treatment B', percentage: 50 }, + ], + }, +} + +export const UnevenSplit: Story = { + args: { + splits: [ + { colour: 'var(--green-500)', name: 'Control', percentage: 30 }, + { colour: 'var(--purple-500)', name: 'Treatment B', percentage: 50 }, + { colour: 'var(--orange-500)', name: 'Treatment C', percentage: 20 }, + ], + }, +} diff --git a/frontend/documentation/experiments/VariationTable.stories.tsx b/frontend/documentation/experiments/VariationTable.stories.tsx new file mode 100644 index 000000000000..197f10771deb --- /dev/null +++ b/frontend/documentation/experiments/VariationTable.stories.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import VariationTable from 'components/experiments-v2/shared/VariationTable' +import { MOCK_VARIATIONS } from 'components/experiments-v2/types' + +const meta: Meta = { + component: VariationTable, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/VariationTable', +} +export default meta + +type Story = StoryObj + +export const TwoVariations: Story = { + args: { + variations: MOCK_VARIATIONS, + }, +} + +export const ThreeVariations: Story = { + args: { + variations: [ + ...MOCK_VARIATIONS, + { + colour: 'var(--orange-500)', + description: 'Alternative CTA text with urgency messaging', + id: 'var-3', + name: 'Treatment C', + value: 'false', + }, + ], + }, +} + +export const Editable: Story = { + args: { + editable: true, + onRemove: (id: string) => alert(`Remove ${id}`), + variations: MOCK_VARIATIONS, + }, +} diff --git a/frontend/documentation/experiments/Wizard.stories.tsx b/frontend/documentation/experiments/Wizard.stories.tsx new file mode 100644 index 000000000000..57b6ecc5b4e2 --- /dev/null +++ b/frontend/documentation/experiments/Wizard.stories.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import WizardLayout from 'components/experiments-v2/wizard/WizardLayout' +import WizardSidebar from 'components/experiments-v2/wizard/WizardSidebar' +import WizardHeader from 'components/experiments-v2/wizard/WizardHeader' +import WizardNavButtons from 'components/experiments-v2/wizard/WizardNavButtons' +import { EXPERIMENT_WIZARD_STEPS } from 'components/experiments-v2/types' + +const meta: Meta = { + tags: ['autodocs'], + title: 'Experiments/Wizard', +} +export default meta + +type Story = StoryObj + +export const FullWizard: Story = { + decorators: [ + () => { + const [currentStep, setCurrentStep] = useState(2) + + const stepsWithSummary = EXPERIMENT_WIZARD_STEPS.map((step, i) => { + if (i >= currentStep) return step + const summaries = [ + 'Checkout Flow Redesign · A/B Test', + '1 primary · 2 secondary', + ] + return { ...step, completeSummary: summaries[i] } + }) + + return ( +
+ alert('Cancel')} + /> + +
+ + + } + > +
+ Step {currentStep + 1} content:{' '} + {EXPERIMENT_WIZARD_STEPS[currentStep].title} +
+ + setCurrentStep(Math.max(0, currentStep - 1))} + onContinue={() => + setCurrentStep( + Math.min(EXPERIMENT_WIZARD_STEPS.length - 1, currentStep + 1), + ) + } + /> +
+
+ ) + }, + ], +} diff --git a/frontend/documentation/experiments/WizardHeader.stories.tsx b/frontend/documentation/experiments/WizardHeader.stories.tsx new file mode 100644 index 000000000000..ffd726cffc70 --- /dev/null +++ b/frontend/documentation/experiments/WizardHeader.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from 'storybook' +import WizardHeader from 'components/experiments-v2/wizard/WizardHeader' + +const meta: Meta = { + args: { + onCancel: () => alert('Cancel clicked'), + }, + component: WizardHeader, + tags: ['autodocs'], + title: 'Experiments/Wizard/WizardHeader', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + breadcrumbs: [{ label: 'Experiments' }, { label: 'Create Experiment' }], + title: 'Create Experiment', + }, +} + +export const LongTitle: Story = { + args: { + breadcrumbs: [ + { label: 'Experiments' }, + { label: 'Checkout Button Redesign A/B Test' }, + ], + title: 'Checkout Button Redesign A/B Test', + }, +} diff --git a/frontend/documentation/experiments/WizardLayout.stories.tsx b/frontend/documentation/experiments/WizardLayout.stories.tsx new file mode 100644 index 000000000000..a210c373fbfc --- /dev/null +++ b/frontend/documentation/experiments/WizardLayout.stories.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import WizardLayout from 'components/experiments-v2/wizard/WizardLayout' + +const meta: Meta = { + component: WizardLayout, + tags: ['autodocs'], + title: 'Experiments/Wizard/WizardLayout', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + children: ( +
+ Main content area +
+ ), + sidebar: ( +
+ Sidebar content +
+ ), + }, +} diff --git a/frontend/documentation/experiments/WizardNavButtons.stories.tsx b/frontend/documentation/experiments/WizardNavButtons.stories.tsx new file mode 100644 index 000000000000..d70dd7ff3e0f --- /dev/null +++ b/frontend/documentation/experiments/WizardNavButtons.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from 'storybook' +import WizardNavButtons from 'components/experiments-v2/wizard/WizardNavButtons' + +const meta: Meta = { + args: { + onBack: () => alert('Back clicked'), + onContinue: () => alert('Continue clicked'), + }, + component: WizardNavButtons, + tags: ['autodocs'], + title: 'Experiments/Wizard/WizardNavButtons', +} +export default meta + +type Story = StoryObj + +export const FirstStep: Story = { + args: { + isFirstStep: true, + }, +} + +export const MiddleStep: Story = { + args: { + isFirstStep: false, + isLastStep: false, + }, +} + +export const LastStep: Story = { + args: { + isLastStep: true, + }, +} + +export const Disabled: Story = { + args: { + continueDisabled: true, + }, +} diff --git a/frontend/documentation/experiments/WizardSidebar.stories.tsx b/frontend/documentation/experiments/WizardSidebar.stories.tsx new file mode 100644 index 000000000000..71248880ec14 --- /dev/null +++ b/frontend/documentation/experiments/WizardSidebar.stories.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import WizardSidebar from 'components/experiments-v2/wizard/WizardSidebar' +import { EXPERIMENT_WIZARD_STEPS } from 'components/experiments-v2/types' + +const meta: Meta = { + args: { + steps: EXPERIMENT_WIZARD_STEPS, + }, + component: WizardSidebar, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/Wizard/WizardSidebar', +} +export default meta + +type Story = StoryObj + +export const Step1Active: Story = { + args: { currentStep: 0 }, +} + +export const Step3Active: Story = { + args: { + currentStep: 2, + steps: EXPERIMENT_WIZARD_STEPS.map((s, i) => { + let completeSummary: string | undefined + if (i === 0) completeSummary = 'Checkout Flow Redesign · A/B Test' + else if (i === 1) completeSummary = '1 primary · 2 secondary' + return { ...s, completeSummary } + }), + }, +} + +export const AllDone: Story = { + args: { + currentStep: 5, + steps: EXPERIMENT_WIZARD_STEPS.map((s, i) => ({ + ...s, + completeSummary: `Step ${i + 1} complete`, + })), + }, +} diff --git a/frontend/documentation/experiments/WizardStepIndicator.stories.tsx b/frontend/documentation/experiments/WizardStepIndicator.stories.tsx new file mode 100644 index 000000000000..f65d8d9f3beb --- /dev/null +++ b/frontend/documentation/experiments/WizardStepIndicator.stories.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import WizardStepIndicator from 'components/experiments-v2/wizard/WizardStepIndicator' + +const meta: Meta = { + args: { + showConnector: true, + stepNumber: 1, + subtitle: 'Define the basics of your experiment', + title: 'Experiment Details', + }, + component: WizardStepIndicator, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Experiments/Wizard/WizardStepIndicator', +} +export default meta + +type Story = StoryObj + +export const Done: Story = { + args: { + completeSummary: 'Checkout Flow Redesign · A/B Test', + onClick: () => alert('Step clicked'), + status: 'done', + }, +} + +export const Active: Story = { + args: { + status: 'active', + stepNumber: 2, + subtitle: 'Choose primary and secondary metrics to measure', + title: 'Select Metrics', + }, +} + +export const Upcoming: Story = { + args: { + showConnector: false, + status: 'upcoming', + stepNumber: 5, + subtitle: 'Review your configuration and launch', + title: 'Review & Launch', + }, +} diff --git a/frontend/web/components/experiments-v2/CreateExperimentPage.scss b/frontend/web/components/experiments-v2/CreateExperimentPage.scss new file mode 100644 index 000000000000..0ac91952e5ab --- /dev/null +++ b/frontend/web/components/experiments-v2/CreateExperimentPage.scss @@ -0,0 +1,12 @@ +.create-experiment-page { + display: flex; + flex-direction: column; + gap: 24px; + padding: 32px 48px; + min-height: 100vh; + + &__divider { + height: 1px; + background: var(--color-border-default); + } +} diff --git a/frontend/web/components/experiments-v2/CreateExperimentPage.tsx b/frontend/web/components/experiments-v2/CreateExperimentPage.tsx new file mode 100644 index 000000000000..1b9d8f8b638a --- /dev/null +++ b/frontend/web/components/experiments-v2/CreateExperimentPage.tsx @@ -0,0 +1,179 @@ +import React, { FC, useCallback, useState } from 'react' +import { useHistory } from 'react-router-dom' +import WizardLayout from './wizard/WizardLayout' +import WizardSidebar from './wizard/WizardSidebar' +import WizardHeader from './wizard/WizardHeader' +import WizardNavButtons from './wizard/WizardNavButtons' +import ExperimentDetailsStep from './steps/ExperimentDetailsStep' +import SelectMetricsStep from './steps/SelectMetricsStep' +import FlagVariationsStep from './steps/FlagVariationsStep' +import AudienceTrafficStep from './steps/AudienceTrafficStep' +import ReviewLaunchStep from './steps/ReviewLaunchStep' +import { + EXPERIMENT_WIZARD_STEPS, + ExperimentWizardState, + Metric, + MOCK_VARIATIONS, +} from './types' +import './CreateExperimentPage.scss' + +const INITIAL_STATE: ExperimentWizardState = { + audience: { segmentId: null, splits: [], trafficPercentage: 50 }, + currentStep: 0, + details: { hypothesis: '', name: '', type: null }, + featureFlagId: null, + metrics: [], + variations: MOCK_VARIATIONS, +} + +const TOTAL_STEPS = EXPERIMENT_WIZARD_STEPS.length + +const CreateExperimentPage: FC = () => { + const history = useHistory() + const [state, setState] = useState(INITIAL_STATE) + + const goToStep = useCallback((step: number) => { + setState((prev) => ({ ...prev, currentStep: step })) + }, []) + + const handleBack = useCallback(() => { + goToStep(Math.max(0, state.currentStep - 1)) + }, [state.currentStep, goToStep]) + + const handleContinue = useCallback(() => { + if (state.currentStep < TOTAL_STEPS - 1) { + goToStep(state.currentStep + 1) + } else { + // Launch — for now just alert with mock data + alert('Experiment launched! (mock)') + } + }, [state.currentStep, goToStep]) + + const handleCancel = useCallback(() => { + history.goBack() + }, [history]) + + const handleToggleMetric = useCallback((metric: Metric) => { + setState((prev) => { + const exists = prev.metrics.find((m) => m.id === metric.id) + if (exists) { + return { + ...prev, + metrics: prev.metrics.filter((m) => m.id !== metric.id), + } + } + const role = prev.metrics.length === 0 ? 'primary' : 'secondary' + return { ...prev, metrics: [...prev.metrics, { ...metric, role }] } + }) + }, []) + + const stepsWithSummary = EXPERIMENT_WIZARD_STEPS.map((step, i) => { + if (i >= state.currentStep) return step + + let completeSummary: string | undefined + switch (i) { + case 0: + completeSummary = [ + state.details.name, + state.details.type?.replace('_', ' '), + ] + .filter(Boolean) + .join(' · ') + break + case 1: + completeSummary = `${ + state.metrics.filter((m) => m.role === 'primary').length + } primary · ${ + state.metrics.filter((m) => m.role === 'secondary').length + } secondary` + break + case 2: + completeSummary = `${state.variations.length} variations` + break + case 3: + completeSummary = `${state.audience.trafficPercentage}% traffic` + break + default: + break + } + return { ...step, completeSummary } + }) + + const renderStepContent = () => { + switch (state.currentStep) { + case 0: + return ( + setState((prev) => ({ ...prev, details }))} + /> + ) + case 1: + return ( + + ) + case 2: + return ( + + setState((prev) => ({ ...prev, featureFlagId: flagId })) + } + onVariationsChange={(variations) => + setState((prev) => ({ ...prev, variations })) + } + /> + ) + case 3: + return ( + setState((prev) => ({ ...prev, audience }))} + /> + ) + case 4: + return + default: + return null + } + } + + return ( +
+ + +
+ + + } + > + {renderStepContent()} + + + +
+ ) +} + +CreateExperimentPage.displayName = 'CreateExperimentPage' +export default CreateExperimentPage diff --git a/frontend/web/components/experiments-v2/ExperimentResultsPage.scss b/frontend/web/components/experiments-v2/ExperimentResultsPage.scss new file mode 100644 index 000000000000..0f16ea12d1fb --- /dev/null +++ b/frontend/web/components/experiments-v2/ExperimentResultsPage.scss @@ -0,0 +1,70 @@ +.experiment-results-page { + display: flex; + flex-direction: column; + gap: 24px; + padding: 32px 32px 24px; + + &__header { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__title-row { + display: flex; + align-items: center; + gap: 16px; + } + + &__title { + font-family: 'JetBrains Mono', monospace; + font-size: 22px; + font-weight: 700; + color: var(--color-text-default); + margin: 0; + } + + &__days { + font-size: 13px; + color: var(--color-text-secondary); + } + + &__action-row { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__subtitle { + font-size: 13px; + color: var(--color-text-secondary); + } + + &__actions { + display: flex; + gap: 8px; + + .btn svg { + margin-right: 4px; + } + } + + &__cards { + display: flex; + gap: 16px; + } + + &__table-section { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__section-title { + font-family: 'JetBrains Mono', monospace; + font-size: 16px; + font-weight: 700; + color: var(--color-text-default); + margin: 0; + } +} diff --git a/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx b/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx new file mode 100644 index 000000000000..60d997f7918e --- /dev/null +++ b/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx @@ -0,0 +1,77 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import StatusBadge from './shared/StatusBadge' +import ExperimentStatCard from './shared/ExperimentStatCard' +import MetricsComparisonTable from './shared/MetricsComparisonTable' +import { MOCK_EXPERIMENT_RESULT } from './types' +import './ExperimentResultsPage.scss' + +const ExperimentResultsPage: FC = () => { + const result = MOCK_EXPERIMENT_RESULT + + return ( +
+
+
+

{result.name}

+ + + Day {result.daysCurrent} of {result.daysTotal} + +
+ +
+ + Primary metric: {result.primaryMetric} · Last updated{' '} + {result.lastUpdated} + +
+ + +
+
+
+ +
+ + + + +
+ +
+

+ Metrics Comparison +

+ +
+
+ ) +} + +ExperimentResultsPage.displayName = 'ExperimentResultsPage' +export default ExperimentResultsPage diff --git a/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss new file mode 100644 index 000000000000..627ceaaa2158 --- /dev/null +++ b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss @@ -0,0 +1,141 @@ +.linked-experiment-section { + display: flex; + flex-direction: column; + gap: 16px; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + + .btn svg { + margin-right: 4px; + } + } + + &__title { + font-size: 16px; + font-weight: 600; + color: var(--color-text-default); + } + + // Experiment card (inlined from LinkedExperimentCard) + &__card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-action); + border-radius: var(--radius-lg); + } + + &__card-top { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__card-name { + font-size: 15px; + font-weight: 600; + color: var(--color-text-default); + } + + &__details { + display: flex; + flex-direction: column; + gap: 10px; + } + + &__detail-row { + display: flex; + align-items: center; + gap: 8px; + + svg, + .icon { + color: var(--color-icon-secondary); + flex-shrink: 0; + } + } + + &__detail-label { + font-size: 13px; + color: var(--color-text-secondary); + } + + &__detail-value { + font-size: 13px; + font-weight: 500; + color: var(--color-text-default); + } + + &__progress { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__progress-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__progress-label { + font-size: 12px; + color: var(--color-text-secondary); + } + + &__progress-value { + font-size: 12px; + font-weight: 500; + color: var(--color-text-default); + } + + &__progress-track { + height: 6px; + background: var(--color-surface-muted); + border-radius: var(--radius-full); + overflow: hidden; + } + + &__progress-fill { + height: 100%; + background: var(--color-surface-action); + border-radius: var(--radius-full); + transition: width var(--duration-slow) var(--easing-standard); + } + + // Empty state + &__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 40px 20px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + text-align: center; + + svg, + .icon { + color: var(--color-icon-secondary); + } + } + + &__empty-title { + font-size: 15px; + font-weight: 600; + color: var(--color-text-default); + } + + &__empty-desc { + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.5; + max-width: 400px; + } +} diff --git a/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx new file mode 100644 index 000000000000..5b2babe18174 --- /dev/null +++ b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx @@ -0,0 +1,119 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import StatusBadge from 'components/experiments-v2/shared/StatusBadge' +import { LinkedExperiment } from 'components/experiments-v2/types' +import './LinkedExperimentSection.scss' + +type LinkedExperimentSectionProps = { + experiment?: LinkedExperiment | null + onCreateExperiment?: () => void + onViewResults?: () => void +} + +const LinkedExperimentSection: FC = ({ + experiment, + onCreateExperiment, + onViewResults, +}) => { + const progressPercent = experiment + ? Math.round((experiment.sampleProgress / experiment.sampleTarget) * 100) + : 0 + + return ( +
+
+ + Linked Experiment + + {experiment && ( + + )} +
+ + {experiment ? ( +
+
+ + {experiment.name} + + +
+ +
+
+ + + Primary Metric: + + + {experiment.primaryMetric} + +
+
+ + + Running Since: + + + {experiment.runningSince} + +
+
+ + + Traffic: + + + {experiment.trafficSplit} + +
+
+ +
+
+ + Sample Progress + + + {progressPercent}% ({experiment.sampleProgress.toLocaleString()}{' '} + / {experiment.sampleTarget.toLocaleString()}) + +
+
+
+
+
+
+ ) : ( +
+ + + No experiment linked + + + Create an experiment to test variations of this feature flag and + measure their impact on your key metrics. + + +
+ )} +
+ ) +} + +LinkedExperimentSection.displayName = 'LinkedExperimentSection' +export default LinkedExperimentSection diff --git a/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss new file mode 100644 index 000000000000..f6557853137c --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss @@ -0,0 +1,39 @@ +.experiment-stat-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + flex: 1; + + &__label { + font-size: 12px; + color: var(--color-text-secondary); + } + + &__value { + font-family: 'JetBrains Mono', monospace; + font-size: 28px; + font-weight: 700; + color: var(--color-text-default); + + &--positive { + color: var(--color-text-success); + } + + &--negative { + color: var(--color-text-danger); + } + + &--neutral { + color: var(--color-text-secondary); + } + } + + &__subtitle { + font-size: 12px; + color: var(--color-text-secondary); + } +} diff --git a/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx new file mode 100644 index 000000000000..5eef91c2a119 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react' +import { LiftDirection } from 'components/experiments-v2/types' +import './ExperimentStatCard.scss' + +type ExperimentStatCardProps = { + label: string + value: string | number + subtitle?: string + trend?: LiftDirection +} + +const ExperimentStatCard: FC = ({ + label, + subtitle, + trend, + value, +}) => { + return ( +
+ {label} + + {typeof value === 'number' ? value.toLocaleString() : value} + + {subtitle && ( + {subtitle} + )} +
+ ) +} + +ExperimentStatCard.displayName = 'ExperimentStatCard' +export default ExperimentStatCard diff --git a/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss new file mode 100644 index 000000000000..5bb25a066995 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss @@ -0,0 +1,63 @@ +.metrics-comparison-table { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + overflow: hidden; + + &__head { + display: flex; + background: var(--color-surface-emphasis); + } + + &__th { + flex: 1; + padding: 12px 16px; + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); + } + + &__row { + display: flex; + border-top: 1px solid var(--color-border-default); + transition: background var(--duration-fast) var(--easing-standard); + + &:hover { + background: var(--color-surface-hover); + } + } + + &__td { + flex: 1; + padding: 14px 16px; + font-size: 14px; + color: var(--color-text-default); + + &--mono { + font-family: 'JetBrains Mono', monospace; + } + + &--positive { + color: var(--color-text-success); + font-weight: 600; + } + + &--negative { + color: var(--color-text-danger); + font-weight: 600; + } + + &--neutral { + color: var(--color-text-secondary); + } + + &--significance { + display: flex; + align-items: center; + gap: 4px; + } + + &--significant { + color: var(--color-text-success); + } + } +} diff --git a/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx new file mode 100644 index 000000000000..c93c8f0ac0c3 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx @@ -0,0 +1,59 @@ +import React, { FC } from 'react' +import Icon from 'components/icons/Icon' +import { MetricComparison } from 'components/experiments-v2/types' +import './MetricsComparisonTable.scss' + +type MetricsComparisonTableProps = { + metrics: MetricComparison[] +} + +const MetricsComparisonTable: FC = ({ + metrics, +}) => { + return ( +
+
+ Metric + Control + Treatment B + Lift + Significance +
+ + {metrics.map((metric) => ( +
+ {metric.name} + + {metric.control} + + + {metric.treatment} + + + {metric.lift} + + + {metric.significance} + {metric.isSignificant && ( + <> + {' '} + + + )} + +
+ ))} +
+ ) +} + +MetricsComparisonTable.displayName = 'MetricsComparisonTable' +export default MetricsComparisonTable diff --git a/frontend/web/components/experiments-v2/shared/SelectableCard.scss b/frontend/web/components/experiments-v2/shared/SelectableCard.scss new file mode 100644 index 000000000000..28f7f04d4240 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/SelectableCard.scss @@ -0,0 +1,71 @@ +.selectable-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-radius: var(--radius-xl); + border: 1px solid var(--color-border-default); + background: transparent; + cursor: pointer; + text-align: left; + width: 100%; + transition: + border-color var(--duration-fast) var(--easing-standard), + background var(--duration-fast) var(--easing-standard); + + &:hover { + border-color: var(--color-border-strong); + } + + &--selected { + border-color: var(--color-border-action); + border-width: 2px; + background: var(--color-surface-action-subtle); + + svg, + .icon { + color: var(--color-icon-action); + } + } + + &__content { + display: flex; + flex-direction: column; + gap: 4px; + + svg, + .icon { + color: var(--color-icon-secondary); + margin-bottom: 4px; + } + } + + &__title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-default); + } + + &__description { + font-size: 12px; + color: var(--color-text-secondary); + } + + &__badge { + font-size: 11px; + font-weight: 600; + padding: 4px 12px; + border-radius: var(--radius-full); + flex-shrink: 0; + + &--primary { + background: var(--color-surface-action); + color: #ffffff; + } + + &--secondary { + border: 1px solid var(--color-border-default); + color: var(--color-text-secondary); + } + } +} diff --git a/frontend/web/components/experiments-v2/shared/SelectableCard.tsx b/frontend/web/components/experiments-v2/shared/SelectableCard.tsx new file mode 100644 index 000000000000..5ace541ffe3f --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/SelectableCard.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react' +import Icon, { IconName } from 'components/icons/Icon' +import './SelectableCard.scss' + +type BadgeVariant = 'primary' | 'secondary' + +type SelectableCardProps = { + selected: boolean + onClick: () => void + icon?: IconName + title: string + description: string + badge?: { label: string; variant: BadgeVariant } +} + +const SelectableCard: FC = ({ + badge, + description, + icon, + onClick, + selected, + title, +}) => { + return ( + + ) +} + +SelectableCard.displayName = 'SelectableCard' +export default SelectableCard diff --git a/frontend/web/components/experiments-v2/shared/StatusBadge.scss b/frontend/web/components/experiments-v2/shared/StatusBadge.scss new file mode 100644 index 000000000000..ac5c788906e3 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/StatusBadge.scss @@ -0,0 +1,42 @@ +.status-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; + + &__dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + } + + &--running { + background: var(--color-surface-success); + color: var(--color-text-success); + + .status-badge__dot { + background: var(--color-text-success); + } + } + + &--paused { + background: var(--color-surface-warning); + color: var(--color-text-warning); + + .status-badge__dot { + background: var(--color-text-warning); + } + } + + &--completed { + background: var(--color-surface-muted); + color: var(--color-text-secondary); + + .status-badge__dot { + background: var(--color-text-secondary); + } + } +} diff --git a/frontend/web/components/experiments-v2/shared/StatusBadge.tsx b/frontend/web/components/experiments-v2/shared/StatusBadge.tsx new file mode 100644 index 000000000000..d3f9777e7b97 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/StatusBadge.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react' +import { ExperimentStatus } from 'components/experiments-v2/types' +import './StatusBadge.scss' + +type StatusBadgeProps = { + status: ExperimentStatus +} + +const STATUS_LABELS: Record = { + completed: 'Completed', + paused: 'Paused', + running: 'Running', +} + +const StatusBadge: FC = ({ status }) => { + return ( + + + {STATUS_LABELS[status]} + + ) +} + +StatusBadge.displayName = 'StatusBadge' +export default StatusBadge diff --git a/frontend/web/components/experiments-v2/shared/TrafficSplitBar.scss b/frontend/web/components/experiments-v2/shared/TrafficSplitBar.scss new file mode 100644 index 000000000000..35783ba4bfa6 --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/TrafficSplitBar.scss @@ -0,0 +1,53 @@ +.traffic-split-bar { + display: flex; + flex-direction: column; + gap: 12px; + + &__bar { + display: flex; + height: 8px; + border-radius: var(--radius-full); + overflow: hidden; + background: var(--color-surface-muted); + } + + &__segment { + height: 100%; + transition: width var(--duration-slow) var(--easing-standard); + + &:first-child { + border-radius: var(--radius-full) 0 0 var(--radius-full); + } + + &:last-child { + border-radius: 0 var(--radius-full) var(--radius-full) 0; + } + + &:only-child { + border-radius: var(--radius-full); + } + } + + &__labels { + display: flex; + gap: 16px; + } + + &__label { + display: flex; + align-items: center; + gap: 6px; + } + + &__label-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + flex-shrink: 0; + } + + &__label-text { + font-size: 13px; + color: var(--color-text-secondary); + } +} diff --git a/frontend/web/components/experiments-v2/shared/TrafficSplitBar.tsx b/frontend/web/components/experiments-v2/shared/TrafficSplitBar.tsx new file mode 100644 index 000000000000..369980b95aee --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/TrafficSplitBar.tsx @@ -0,0 +1,47 @@ +import React, { FC } from 'react' +import './TrafficSplitBar.scss' + +type Split = { + name: string + percentage: number + colour: string +} + +type TrafficSplitBarProps = { + splits: Split[] +} + +const TrafficSplitBar: FC = ({ splits }) => { + return ( +
+
+ {splits.map((split, index) => ( +
+ ))} +
+
+ {splits.map((split, index) => ( +
+ + + {split.name}: {split.percentage}% + +
+ ))} +
+
+ ) +} + +TrafficSplitBar.displayName = 'TrafficSplitBar' +export default TrafficSplitBar diff --git a/frontend/web/components/experiments-v2/shared/VariationTable.scss b/frontend/web/components/experiments-v2/shared/VariationTable.scss new file mode 100644 index 000000000000..93249491c45d --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/VariationTable.scss @@ -0,0 +1,120 @@ +.variation-table { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + overflow: hidden; + + &__head { + display: flex; + padding: 12px 20px; + background: var(--color-surface-muted); + gap: 16px; + } + + &__th { + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.02em; + + &--name, + &--desc { + flex: 1; + } + + &--value { + width: 100px; + } + + &--actions { + width: 40px; + } + } + + &__row { + display: flex; + padding: 16px 20px; + gap: 16px; + border-top: 1px solid var(--color-border-default); + align-items: center; + transition: background var(--duration-fast) var(--easing-standard); + + &:hover { + background: var(--color-surface-hover); + } + } + + &__cell { + &--name { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + } + + &--desc { + flex: 1; + } + + &--value { + width: 100px; + } + + &--actions { + width: 40px; + display: flex; + justify-content: center; + } + } + + &__dot { + width: 10px; + height: 10px; + border-radius: var(--radius-full); + flex-shrink: 0; + } + + &__name-text { + font-size: 14px; + font-weight: 600; + color: var(--color-text-default); + } + + &__control-tag { + font-size: 11px; + font-weight: 500; + color: var(--color-text-secondary); + background: var(--color-surface-muted); + padding: 2px 8px; + border-radius: var(--radius-sm); + } + + &__desc-text { + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.4; + } + + &__value-badge { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--color-text-default); + background: var(--color-surface-muted); + padding: 6px 12px; + border-radius: var(--radius-md); + } + + &__action-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--color-icon-secondary); + border-radius: var(--radius-sm); + transition: color var(--duration-fast) var(--easing-standard); + + &:hover { + color: var(--color-icon-danger); + } + } +} diff --git a/frontend/web/components/experiments-v2/shared/VariationTable.tsx b/frontend/web/components/experiments-v2/shared/VariationTable.tsx new file mode 100644 index 000000000000..c0c53b577dda --- /dev/null +++ b/frontend/web/components/experiments-v2/shared/VariationTable.tsx @@ -0,0 +1,75 @@ +import React, { FC } from 'react' +import Icon from 'components/icons/Icon' +import { Variation } from 'components/experiments-v2/types' +import './VariationTable.scss' + +type VariationTableProps = { + variations: Variation[] + editable?: boolean + onRemove?: (id: string) => void +} + +const VariationTable: FC = ({ + editable = false, + onRemove, + variations, +}) => { + return ( +
+
+ + Name + + + Description + + + Value + + {editable && ( + + )} +
+ + {variations.map((variation) => ( +
+
+ + {variation.name} + {variation.name === 'Control' && ( + control + )} +
+
+ + {variation.description} + +
+
+ + {variation.value} + +
+ {editable && ( +
+ +
+ )} +
+ ))} +
+ ) +} + +VariationTable.displayName = 'VariationTable' +export default VariationTable diff --git a/frontend/web/components/experiments-v2/steps/AudienceTrafficStep.scss b/frontend/web/components/experiments-v2/steps/AudienceTrafficStep.scss new file mode 100644 index 000000000000..b33401a3a9c5 --- /dev/null +++ b/frontend/web/components/experiments-v2/steps/AudienceTrafficStep.scss @@ -0,0 +1,81 @@ +.audience-traffic-step { + display: flex; + flex-direction: column; + gap: 24px; + + &__field { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__label { + font-size: 13px; + font-weight: 500; + color: var(--color-text-default); + } + + &__slider-row { + display: flex; + align-items: center; + gap: 16px; + } + + &__slider { + flex: 1; + appearance: none; + height: 6px; + border-radius: var(--radius-full); + background: var(--color-surface-muted); + outline: none; + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; + width: 20px; + height: 20px; + border-radius: var(--radius-full); + background: var(--color-surface-action); + cursor: pointer; + border: 2px solid var(--color-surface-default); + box-shadow: var(--shadow-sm); + } + + &::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: var(--radius-full); + background: var(--color-surface-action); + cursor: pointer; + border: 2px solid var(--color-surface-default); + box-shadow: var(--shadow-sm); + } + } + + &__sample-estimate { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + font-size: 13px; + color: var(--color-text-secondary); + + svg, + .icon { + color: var(--color-icon-action); + flex-shrink: 0; + } + } + + &__percentage { + font-family: 'JetBrains Mono', monospace; + font-size: 16px; + font-weight: 600; + color: var(--color-text-default); + min-width: 48px; + text-align: right; + } +} diff --git a/frontend/web/components/experiments-v2/steps/AudienceTrafficStep.tsx b/frontend/web/components/experiments-v2/steps/AudienceTrafficStep.tsx new file mode 100644 index 000000000000..8bffa1bee8be --- /dev/null +++ b/frontend/web/components/experiments-v2/steps/AudienceTrafficStep.tsx @@ -0,0 +1,89 @@ +import React, { FC } from 'react' +import SearchableSelect from 'components/base/select/SearchableSelect' +import Icon from 'components/icons/Icon' +import TrafficSplitBar from 'components/experiments-v2/shared/TrafficSplitBar' +import { OptionType } from 'components/base/select/SearchableSelect' +import { + AudienceConfig, + MOCK_SEGMENTS, + Variation, +} from 'components/experiments-v2/types' +import './AudienceTrafficStep.scss' + +type AudienceTrafficStepProps = { + audience: AudienceConfig + variations: Variation[] + onChange: (audience: AudienceConfig) => void +} + +const AudienceTrafficStep: FC = ({ + audience, + onChange, + variations, +}) => { + const splitPerVariation = Math.round( + audience.trafficPercentage / Math.max(variations.length, 1), + ) + + const splits = variations.map((v) => ({ + colour: v.colour, + name: v.name, + percentage: splitPerVariation, + })) + + return ( +
+
+ + { + onChange({ ...audience, segmentId: opt.value }) + }} + options={MOCK_SEGMENTS} + placeholder='Select a segment...' + /> +
+ +
+ +
+ + onChange({ + ...audience, + trafficPercentage: Number(e.target.value), + }) + } + className='audience-traffic-step__slider' + /> + + {audience.trafficPercentage}% + +
+
+ +
+ + +
+ +
+ + + ~6,200 users per variation · Est. 14 days to significance + +
+
+ ) +} + +AudienceTrafficStep.displayName = 'AudienceTrafficStep' +export default AudienceTrafficStep diff --git a/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss new file mode 100644 index 000000000000..027462eb362f --- /dev/null +++ b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss @@ -0,0 +1,53 @@ +.experiment-details-step { + display: flex; + flex-direction: column; + gap: 20px; + padding: 20px; + background: var(--color-surface-subtle); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + + &__field { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__label { + font-size: 13px; + font-weight: 500; + color: var(--color-text-default); + } + + &__required { + color: var(--color-text-danger); + } + + &__textarea { + width: 100%; + min-height: 80px; + padding: 10px 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + background: transparent; + color: var(--color-text-default); + font-size: 14px; + font-family: inherit; + resize: vertical; + transition: border-color var(--duration-fast) var(--easing-standard); + + &::placeholder { + color: var(--color-text-disabled); + } + + &:focus { + outline: none; + border-color: var(--color-border-action); + } + } + + &__type-cards { + display: flex; + gap: 12px; + } +} diff --git a/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx new file mode 100644 index 000000000000..9f823c5101b7 --- /dev/null +++ b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx @@ -0,0 +1,103 @@ +import React, { FC } from 'react' +import Input from 'components/base/forms/Input' +import { IconName } from 'components/icons/Icon' +import SelectableCard from 'components/experiments-v2/shared/SelectableCard' +import { + ExperimentDetails, + ExperimentType, +} from 'components/experiments-v2/types' +import './ExperimentDetailsStep.scss' + +type ExperimentDetailsStepProps = { + details: ExperimentDetails + onChange: (details: ExperimentDetails) => void +} + +const TYPE_CONFIG: Record< + ExperimentType, + { icon: IconName; title: string; description: string } +> = { + ab_test: { + description: 'Compare two variations', + icon: 'bar-chart', + title: 'A/B Test', + }, + feature_flag: { + description: 'Toggle feature on/off', + icon: 'features', + title: 'Feature Flag', + }, + multivariate: { + description: 'Test multiple variables', + icon: 'layers', + title: 'Multivariate', + }, +} + +const EXPERIMENT_TYPES: ExperimentType[] = [ + 'ab_test', + 'multivariate', + 'feature_flag', +] + +const ExperimentDetailsStep: FC = ({ + details, + onChange, +}) => { + return ( +
+
+ + ) => + onChange({ ...details, name: e.target.value }) + } + placeholder='e.g. Checkout Flow Redesign' + /> +
+ +
+ +