reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

FunnelAnalysis

surface

퍼널 분석 Surface. StepBuilder + 3탭(Funnel, Breakdown, Trend) + FunnelChart + DataTable 조합.

컴포넌트 의존 관계

깊이
▼ USES (7)FunnelAnalysisfunnel-chartstep-builderdata-tablefilter-barloading-overlaytime-range-selectortabs
100%

기본 사용

Funnel Analysis

Funnel Steps

1
2
3
4
Funnel details
Page View·10000
Sign Up·3500
Activation·1800
Purchase·650

테스트 커버리지

2026년 2월 4일

생성된 테스트 결과를 찾지 못했습니다.

FunnelAnalysis 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.

테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.

FunnelAnalysis Props

Prop타입기본값설명
steps*StepDef[]—퍼널 단계 배열
funnelData*FunnelDataPoint[]—퍼널 차트 데이터
onStepsChange(steps: StepDef[]) => void—단계 변경 핸들러
breakdownDataFunnelTableRow[]—분석 테이블 데이터
trendDataFunnelTableRow[]—추세 테이블 데이터

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add funnel-analysis

Consumer target

복사된 파일은 components/surfaces 아래에 저장됩니다.

tsx
import { FunnelAnalysis } from "@/components/surfaces/funnel-analysis";

Registry metadata

설명
퍼널 분석 Surface. StepBuilder + 3탭(Funnel, Breakdown, Trend) + FunnelChart + DataTable 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
charttablefilteranalytics
Install notes
없음

포함 파일

  • funnel-analysis.tsx→funnel-analysis.tsx
Surface 소스 보기
funnel-analysis.tsx
"use client";

import * as React from "react";
import {
  TabsRoot,
  TabList,
  Tab,
  TabPanel,
  StepBuilder,
  FilterBar,
  DataTable,
  SurfaceLayout,
  TimeRangeSelector,
  NumberInput,
  cn,
  type StepDef,
  type ColumnDef,
  type FilterGroupDef,
  type DateRange,
} from "@reopt-ai/opt-ui";
import { FunnelChart, type FunnelDataPoint } from "@reopt-ai/opt-charts";

interface FunnelTableRow {
  id: string;
  [k: string]: unknown;
}

/** Type definition for `ConversionWindowUnit`. */
export type ConversionWindowUnit = "minutes" | "hours" | "days";

const CONVERSION_WINDOW_UNITS: {
  value: ConversionWindowUnit;
  label: string;
}[] = [
  { value: "minutes", label: "Minutes" },
  { value: "hours", label: "Hours" },
  { value: "days", label: "Days" },
];

/** Labels for `FunnelAnalysis`. */
export interface FunnelAnalysisLabels {
  title?: string;
  funnel?: string;
  breakdown?: string;
  trend?: string;
  compare?: string;
  steps?: string;
  currentPeriod?: string;
  filtersLabel?: string;
  detailsLabel?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  conversionWindowLabel?: string;
}

const defaultLabels: Required<FunnelAnalysisLabels> = {
  title: "Funnel Analysis",
  funnel: "Funnel",
  breakdown: "Breakdown",
  trend: "Trend",
  compare: "Compare",
  steps: "Funnel Steps",
  currentPeriod: "Current Period",
  filtersLabel: "Filters",
  detailsLabel: "Funnel details",
  emptyTitle: "No funnel steps defined",
  emptyDescription: "Add steps to build your conversion funnel.",
  conversionWindowLabel: "Conversion window",
};

/** Props for `FunnelAnalysis`. */
export interface FunnelAnalysisProps {
  steps: StepDef[];
  onStepsChange?: (steps: StepDef[]) => void;
  funnelData: FunnelDataPoint[];
  breakdownData?: FunnelTableRow[];
  breakdownColumns?: ColumnDef<FunnelTableRow>[];
  trendData?: FunnelTableRow[];
  trendColumns?: ColumnDef<FunnelTableRow>[];
  /** Period comparison funnel data (displayed in Compare tab) */
  comparisonData?: FunnelDataPoint[];
  /** Label for the comparison period */
  comparisonLabel?: string;
  /** FilterBar filter definitions (e.g., segment, conversion window) */
  filters?: FilterGroupDef[];
  onFilterChange?: (
    filterId: string,
    value: string | string[] | boolean,
  ) => void;
  /** Current conversion window value */
  conversionWindow?: number;
  /** Conversion window change handler */
  onConversionWindowChange?: (window: number) => void;
  /** Conversion window unit */
  conversionWindowUnit?: ConversionWindowUnit;
  /** Conversion window unit change handler */
  onConversionWindowUnitChange?: (unit: ConversionWindowUnit) => void;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: FunnelAnalysisLabels;
  loading?: boolean;
  timeRange?: DateRange;
  onTimeRangeChange?: (range: DateRange) => void;
  className?: string;
}

/** Renders the `FunnelAnalysis` component. */
export function FunnelAnalysis({
  steps,
  onStepsChange,
  funnelData,
  breakdownData = [],
  breakdownColumns = [
    {
      id: "segment",
      header: "Segment",
      accessor: "segment" as keyof FunnelTableRow,
    },
    {
      id: "conversion",
      header: "Conversion",
      accessor: "conversion" as keyof FunnelTableRow,
    },
    {
      id: "dropoff",
      header: "Drop-off",
      accessor: "dropoff" as keyof FunnelTableRow,
    },
  ],
  trendData = [],
  trendColumns = [
    { id: "date", header: "Date", accessor: "date" as keyof FunnelTableRow },
    {
      id: "conversion",
      header: "Conversion",
      accessor: "conversion" as keyof FunnelTableRow,
    },
    { id: "total", header: "Total", accessor: "total" as keyof FunnelTableRow },
  ],
  comparisonData,
  comparisonLabel = "Previous Period",
  filters,
  onFilterChange,
  conversionWindow,
  onConversionWindowChange,
  conversionWindowUnit = "days",
  onConversionWindowUnitChange,
  header,
  actions,
  labels: customLabels,
  loading = false,
  timeRange,
  onTimeRangeChange,
  className,
}: FunnelAnalysisProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const sectionId = React.useId();

  const isEmpty = steps.length === 0;
  const hasFilters = filters && filters.length > 0;
  const hasComparison = comparisonData && comparisonData.length > 0;
  const hasConversionWindow = conversionWindow !== undefined;

  return (
    <SurfaceLayout loading={loading} className={className}>
      {header || actions ? (
        <div className="flex items-center justify-between">
          {header && <div>{header}</div>}
          <div className="gap-element flex items-center">
            <TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
            {actions}
          </div>
        </div>
      ) : (
        <div className="flex items-center justify-between">
          <h2 className="text-text-primary text-lg font-semibold">
            {labels.title}
          </h2>
          <TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
        </div>
      )}

      {isEmpty ? (
        <div
          role="status"
          className="flex flex-col items-center justify-center py-16 text-center"
        >
          <h3 className="text-text-primary text-lg font-medium">
            {labels.emptyTitle}
          </h3>
          <p className="text-text-tertiary mt-1 text-sm">
            {labels.emptyDescription}
          </p>
        </div>
      ) : (
        <>
          {(hasFilters || hasConversionWindow) && (
            <section aria-labelledby={`${sectionId}-filters`}>
              <span id={`${sectionId}-filters`} className="sr-only">
                {labels.filtersLabel}
              </span>
              <div className="gap-group flex flex-wrap items-end">
                {hasFilters && (
                  <FilterBar
                    filters={filters}
                    onFilterChange={onFilterChange}
                  />
                )}
                {hasConversionWindow && (
                  <div>
                    <label className="text-text-tertiary mb-1 block text-xs font-medium">
                      {labels.conversionWindowLabel}
                    </label>
                    <div className="gap-element flex items-center">
                      <NumberInput
                        value={conversionWindow}
                        onChange={onConversionWindowChange}
                        min={1}
                        aria-label={labels.conversionWindowLabel}
                        className="w-28"
                      />
                      <div className="gap-element flex">
                        {CONVERSION_WINDOW_UNITS.map((u) => (
                          <button
                            key={u.value}
                            type="button"
                            onClick={() =>
                              onConversionWindowUnitChange?.(u.value)
                            }
                            className={cn(
                              "focus-visible:ring-accent rounded-md px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none",
                              conversionWindowUnit === u.value
                                ? "bg-accent text-accent-fg"
                                : "bg-bg-subtle text-text-secondary hover:bg-bg-muted",
                            )}
                          >
                            {u.label}
                          </button>
                        ))}
                      </div>
                    </div>
                  </div>
                )}
              </div>
            </section>
          )}

          {/* Step Builder */}
          {onStepsChange && (
            <section aria-labelledby={`${sectionId}-steps`}>
              <h3
                id={`${sectionId}-steps`}
                className="text-text-tertiary mb-2 text-sm font-medium"
              >
                {labels.steps}
              </h3>
              <StepBuilder steps={steps} onChange={onStepsChange} />
            </section>
          )}

          <section aria-labelledby={`${sectionId}-tabs`}>
            <span id={`${sectionId}-tabs`} className="sr-only">
              {labels.detailsLabel}
            </span>
            <TabsRoot defaultSelectedId="funnel">
              <TabList>
                <Tab id="funnel">{labels.funnel}</Tab>
                <Tab id="breakdown">{labels.breakdown}</Tab>
                <Tab id="trend">{labels.trend}</Tab>
                {hasComparison && <Tab id="compare">{labels.compare}</Tab>}
              </TabList>
              <TabPanel tabId="funnel">
                <div className="mx-auto max-w-md pt-4">
                  <FunnelChart
                    data={funnelData}
                    aria-label="Conversion funnel"
                  />
                </div>
              </TabPanel>
              <TabPanel tabId="breakdown">
                <div className="pt-4">
                  <DataTable
                    columns={breakdownColumns}
                    data={breakdownData}
                    keyExtractor={(r) => r.id}
                  />
                </div>
              </TabPanel>
              <TabPanel tabId="trend">
                <div className="pt-4">
                  <DataTable
                    columns={trendColumns}
                    data={trendData}
                    keyExtractor={(r) => r.id}
                  />
                </div>
              </TabPanel>
              {hasComparison && (
                <TabPanel tabId="compare">
                  <div className="gap-section grid grid-cols-1 pt-4 md:grid-cols-2">
                    <div>
                      <h4 className="text-text-secondary mb-2 text-sm font-medium">
                        {labels.currentPeriod}
                      </h4>
                      <FunnelChart
                        data={funnelData}
                        aria-label="Current period funnel"
                      />
                    </div>
                    <div>
                      <h4 className="text-text-secondary mb-2 text-sm font-medium">
                        {comparisonLabel}
                      </h4>
                      <FunnelChart
                        data={comparisonData}
                        aria-label={`${comparisonLabel} funnel`}
                      />
                    </div>
                  </div>
                </TabPanel>
              )}
            </TabsRoot>
          </section>
        </>
      )}
    </SurfaceLayout>
  );
}

FunnelAnalysis.displayName = "FunnelAnalysis";