FunnelAnalysis
surface퍼널 분석 Surface. StepBuilder + 3탭(Funnel, Breakdown, Trend) + FunnelChart + DataTable 조합.
컴포넌트 의존 관계
깊이
100%
기본 사용
Funnel Analysis
Funnel Steps
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 | — | 단계 변경 핸들러 |
breakdownData | FunnelTableRow[] | — | 분석 테이블 데이터 |
trendData | FunnelTableRow[] | — | 추세 테이블 데이터 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add funnel-analysisConsumer 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.tsxfunnel-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";