"use client";
import { useMemo, useState, type ReactNode } from "react";
import {
Activity,
ArrowDownToLine,
ArrowRight,
CalendarDays,
ChartNoAxesCombined,
Check,
ChevronDown,
ChevronRight,
CircleHelp,
Clock3,
Compass,
Filter,
Layers3,
Menu,
Monitor,
Moon,
MousePointer2,
ShoppingBag,
Smartphone,
Sparkles,
Sun,
Tablet,
Target,
Users,
X,
} from "lucide-react";
import {
Avatar,
Button,
DialogDescription,
DialogDismiss,
DialogHeading,
DialogPanel,
DialogRoot,
Input,
useOptTheme,
useToast,
} from "@reopt-ai/opt-ui";
import {
BarChart,
BulletChart,
FunnelChart,
PieChart,
RetentionHeatmap,
Sparkline,
TimeSeriesChart,
type RetentionCellRef,
type TimeSeriesDef,
} from "@reopt-ai/opt-charts";
import {
ShellAppShell,
ShellCollapsibleNav,
ShellNavItem,
ShellProvider,
useShellNav,
} from "@reopt-ai/opt-shell";
import { createShellApp } from "@reopt-ai/opt-shell/core";
import {
analyticsCsv,
buildAnalytics,
CAMPAIGN,
channels,
compactMoney,
conversion,
dateKey,
DAY,
delta,
devices,
END,
formatMetric,
formatMoney,
formatNumber,
metricLabels,
metricValue,
parseDate,
shortDate,
total,
viewLabels,
windowFor,
type Analytics,
type DateWindow,
type Device,
type Metric,
type Period,
type View,
} from "./analytics-data";
import s from "./dashboard.module.css";
const manifest = createShellApp({
id: "analytics-dashboard-example",
label: "Pulse Analytics",
defaults: {
density: "comfortable",
navigationMode: "sidebar",
contentWidth: "full",
},
});
const navItems = [
{ id: "overview", icon: ChartNoAxesCombined },
{ id: "acquisition", icon: Compass },
{ id: "conversion", icon: Filter },
{ id: "retention", icon: Layers3 },
] as const;
const metricIcons = {
revenue: ShoppingBag,
sessions: Users,
orders: Check,
conversion: MousePointer2,
};
const descriptions: Record<View, string> = {
overview: "어디서 방문하고, 무엇을 구매하고, 얼마나 다시 찾는지 살펴보세요.",
acquisition: "채널별 유입 규모와 구매 기여도를 함께 비교하세요.",
conversion: "방문이 구매로 이어지는 과정에서 개선할 구간을 찾으세요.",
retention: "처음 방문한 주간별로, 고객이 얼마나 다시 돌아오는지 확인하세요.",
};
const funnelLabels = {
entryLabel: "분석 시작",
entryRateLabel: (rate: string) => `전체 방문의 ${rate}`,
stepRateLabel: (rate: string) => `이전 단계 대비 ${rate}`,
dropoffLabel: (count: string, rate: string) => `${count} 이탈 · ${rate}`,
stepAriaLabel: (index: number) => `${index}단계`,
progressAriaLabel: (name: string) => `${name} 전환`,
progressValueText: (value: string, rate: string) =>
`${value}, 전체의 ${rate}`,
};
function MobileMenu() {
const { open } = useShellNav();
return (
<Button
variant="ghost"
size="sm"
aria-label="분석 메뉴 열기"
onClick={open}
>
<Menu size={19} />
</Button>
);
}
function SectionHeading({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className={s.sectionHeading}>
<div>
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
{action}
</div>
);
}
function Change({ value, suffix = "%" }: { value: number; suffix?: string }) {
const digits = suffix === "%p" ? 2 : 1;
const rounded = Number(value.toFixed(digits));
return (
<span
className={s.change}
data-positive={rounded > 0}
data-flat={rounded === 0}
>
{rounded === 0
? "변화 없음"
: `${rounded > 0 ? "+" : ""}${rounded.toFixed(digits)}${suffix}`}
</span>
);
}
function ChannelTable({
model,
onSelect,
}: {
model: Analytics;
onSelect: (id: string) => void;
}) {
return (
<div className={s.tableScroll}>
<table className={s.table}>
<caption className={s.srOnly}>유입 채널별 성과</caption>
<thead>
<tr>
<th>채널</th>
<th>방문 세션</th>
<th>추이</th>
<th>전환율</th>
<th>매출</th>
<th>
<span className={s.srOnly}>상세</span>
</th>
</tr>
</thead>
<tbody>
{model.byChannel.map((channel) => (
<tr key={channel.id}>
<td>
<button
type="button"
className={s.channelName}
onClick={() => onSelect(channel.id)}
>
<i style={{ background: channel.color }} />
<span>
{channel.name}
<small>{channel.detail}</small>
</span>
</button>
</td>
<td>
{formatNumber(channel.sessions)}
<small>
<Change
value={delta(channel.sessions, channel.previous.sessions)}
/>
</small>
</td>
<td>
<Sparkline
data={channel.trend}
width={88}
height={30}
color={channel.color}
aria-label={`${channel.name} 방문 추이`}
/>
</td>
<td>{conversion(channel).toFixed(2)}%</td>
<td>{formatMoney(channel.revenue)}</td>
<td>
<Button
variant="ghost"
size="sm"
aria-label={`${channel.name} 채널 상세`}
onClick={() => onSelect(channel.id)}
>
상세
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function DevicePanel({
model,
onFilter,
}: {
model: Analytics;
onFilter: (device: Device) => void;
}) {
return (
<section className={s.panel}>
<SectionHeading
title="어떤 기기로 방문할까요?"
description="기기별 방문 세션 구성"
/>
<div className={s.deviceBody}>
<div className={s.donut}>
<PieChart
data={model.byDevice.map((device) => ({
name: device.name,
value: device.sessions,
color: device.color,
}))}
innerRadius="76%"
outerRadius="94%"
paddingAngle={4}
height={190}
showLegend={false}
showLabels={false}
locale="ko-KR"
aria-label="기기별 방문 비중"
/>
<div className={s.donutCenter}>
<strong>
{model.byDevice[0]
? Math.round(
(model.byDevice[0].sessions / model.totals.sessions) * 100,
)
: 0}
%
</strong>
<span>{model.byDevice[0]?.name ?? "방문 없음"}</span>
</div>
</div>
<div className={s.deviceLegend}>
{model.byDevice.map((device) => {
const Icon =
device.id === "mobile"
? Smartphone
: device.id === "desktop"
? Monitor
: Tablet;
return (
<button
type="button"
key={device.id}
onClick={() => onFilter(device.id)}
aria-label={`${device.name}만 분석`}
>
<span className={s.deviceName}>
<i style={{ background: device.color }} />
<Icon size={15} />
{device.name}
</span>
<strong>
{((device.sessions / model.totals.sessions) * 100).toFixed(1)}
%
</strong>
</button>
);
})}
</div>
</div>
</section>
);
}
function FunnelPanel({
model,
step,
setStep,
}: {
model: Analytics;
step: number;
setStep: (step: number) => void;
}) {
const current = model.funnel[step];
const previous = model.funnel[Math.max(0, step - 1)];
return (
<section className={s.panel}>
<SectionHeading
title="방문에서 구매까지"
description="단계를 선택하면 해당 구간의 전환과 이탈을 확인할 수 있어요."
action={
<span className={s.summaryPill}>
최종 전환 {conversion(model.totals).toFixed(2)}%
</span>
}
/>
<div className={s.funnel}>
<FunnelChart
data={model.funnel}
variant="steps"
selectedStep={step}
onStepSelect={setStep}
labels={funnelLabels}
locale="ko-KR"
aria-label="구매 전환 단계"
/>
</div>
<div className={s.stepDetail} aria-live="polite">
<span className={s.detailIcon}>
<Filter size={18} />
</span>
<div>
<strong>
{step === 0 ? "방문 세션" : `${previous.name} → ${current.name}`}
</strong>
<p>
{step === 0
? `분석 기간에 ${formatNumber(current.value)}건의 방문이 발생했습니다.`
: `${formatNumber(previous.value - current.value)}건이 이 구간에서 이탈했습니다. 이전 단계의 ${((current.value / previous.value) * 100).toFixed(1)}%가 다음 단계로 이어졌습니다.`}
</p>
</div>
<span className={s.stepValue}>
{formatNumber(current.value)}
<small>{current.name}</small>
</span>
</div>
</section>
);
}
function AnalyticsContent() {
const [view, setView] = useState<View>("overview");
const [window, setWindow] = useState<DateWindow>(() => windowFor(30));
const [device, setDevice] = useState<Device>("all");
const [metric, setMetric] = useState<Metric>("revenue");
const [compare, setCompare] = useState(true);
const [dateOpen, setDateOpen] = useState(false);
const [draftFrom, setDraftFrom] = useState(dateKey(windowFor(30).from));
const [draftTo, setDraftTo] = useState(dateKey(END));
const [dateError, setDateError] = useState("");
const [selectedDay, setSelectedDay] = useState<number | null>(null);
const [selectedChannel, setSelectedChannel] = useState<string | null>(null);
const [selectedCell, setSelectedCell] = useState<RetentionCellRef | null>(
null,
);
const [guide, setGuide] = useState(false);
const [step, setStep] = useState(2);
const [channelMetric, setChannelMetric] = useState<"sessions" | "revenue">(
"sessions",
);
const { resolvedMode, setMode } = useOptTheme();
const { addToast } = useToast();
const model = useMemo(() => buildAnalytics(window, device), [window, device]);
const pickedDay =
selectedDay === null
? null
: model.daily.find((day) => day.t === selectedDay);
const pickedChannel = model.byChannel.find(
(channel) => channel.id === selectedChannel,
);
const pickedCohort = model.cohorts.find(
(cohort) => cohort.cohort === selectedCell?.cohort,
);
const bestChannel = [...model.byChannel].sort(
(a, b) => conversion(b) - conversion(a),
)[0];
const bestDay = [...model.daily].sort((a, b) => b.revenue - a.revenue)[0];
const revenueChange = delta(model.totals.revenue, model.prior.revenue);
const deviceShare =
device === "all" ? 1 : devices.find((item) => item.id === device)!.share;
const target = model.days * 6_500_000 * deviceShare;
const targetPercent = (model.totals.revenue / target) * 100;
const trendData = model.daily.map((day) => ({
t: day.t,
current: metricValue(day, metric),
previous: metricValue(day.previous, metric),
}));
const trendSeries: TimeSeriesDef[] = [
{
dataKey: "current",
name: "선택 기간",
type: "area",
color: "var(--opt-chart-1)",
},
...(compare
? [
{
dataKey: "previous",
name: "이전 기간",
type: "line" as const,
comparison: true,
comparisonOf: "current",
color: "var(--opt-chart-1)",
},
]
: []),
];
const cohortRates = model.cohorts.filter(
(cohort) => cohort.values[1] !== null,
);
const retainedWeekOne = cohortRates.length
? cohortRates.reduce(
(sum, cohort) => sum + cohort.size * (cohort.values[1] ?? 0),
0,
) / cohortRates.reduce((sum, cohort) => sum + cohort.size, 0)
: null;
function changeWindow(next: DateWindow) {
setWindow(next);
setDraftFrom(dateKey(next.from));
setDraftTo(dateKey(next.to));
setDateError("");
setDateOpen(false);
setSelectedDay(null);
setSelectedCell(null);
}
function selectRange(range: DateWindow) {
const snap = (t: number) =>
Math.max(
window.from,
Math.min(window.to, END + Math.floor((t - END) / DAY) * DAY),
);
const from = snap(Math.min(range.from, range.to));
const to = snap(Math.max(range.from, range.to));
changeWindow({ from, to });
}
function filterDevice(next: Device) {
setDevice(next);
setSelectedCell(null);
}
function exportCsv() {
const url = URL.createObjectURL(
new Blob([analyticsCsv(model)], { type: "text/csv;charset=utf-8" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `pulse-analytics-${dateKey(window.from)}-${dateKey(window.to)}-${device}.csv`;
anchor.click();
URL.revokeObjectURL(url);
addToast({
title: "분석 데이터를 내보냈습니다",
message: "현재 기간과 기기 필터를 적용한 일별 데이터입니다.",
variant: "success",
});
}
function openDay(t: number) {
const nearest = model.daily.reduce(
(best, day) => (Math.abs(day.t - t) < Math.abs(best.t - t) ? day : best),
model.daily[0],
);
setSelectedDay(nearest.t);
}
return (
<div className={s.app}>
<ShellAppShell
className={s.shell}
contentClassName={s.main}
nav={
<ShellCollapsibleNav
width={216}
collapsible={false}
showToggle={false}
label="분석 메뉴"
className={s.nav}
>
<div className={s.navInner}>
<div className={s.brand}>
<span className={s.brandIcon}>
<Activity size={24} strokeWidth={2.5} />
</span>
<strong>
pulse<span>Analytics</span>
</strong>
<ChevronDown size={14} />
</div>
<div className={s.workspace}>
<span className={s.workspaceIcon}>N</span>
<div>
<strong>Northstar</strong>
<span>northstar.example</span>
</div>
</div>
<div className={s.navLinks}>
{navItems.map(({ id, icon: Icon }) => (
<ShellNavItem
key={id}
active={view === id}
onClick={() => setView(id)}
icon={<Icon size={18} />}
className={s.navItem}
>
{viewLabels[id]}
</ShellNavItem>
))}
</div>
<div className={s.navBottom}>
<div className={s.dataStatus}>
<i />
<span>
데이터 업데이트 완료<small>2026.10.27 · 샘플 데이터</small>
</span>
</div>
<button
type="button"
className={s.help}
onClick={() => setGuide(true)}
>
<CircleHelp size={17} />
분석 가이드
</button>
<div className={s.profile}>
<Avatar name="김도윤" size="sm" />
<div>
<strong>김도윤</strong>
<span>워크스페이스 관리자</span>
</div>
</div>
</div>
</div>
</ShellCollapsibleNav>
}
>
<header className={s.topbar}>
<div className={s.breadcrumb}>
<span className={s.mobileMenu}>
<MobileMenu />
</span>
<span>워크스페이스</span>
<ChevronRight size={13} />
<strong>애널리틱스</strong>
</div>
<div className={s.topActions}>
<span className={s.sample}>데모 워크스페이스</span>
<Button
variant="ghost"
size="sm"
aria-label={
resolvedMode === "dark"
? "라이트 모드로 전환"
: "다크 모드로 전환"
}
onClick={() =>
setMode(resolvedMode === "dark" ? "light" : "dark")
}
>
{resolvedMode === "dark" ? <Sun size={17} /> : <Moon size={17} />}
</Button>
<Avatar name="김도윤" size="sm" />
</div>
</header>
<div className={s.content}>
<div className={s.pageHeading}>
<div>
<h1>{viewLabels[view]}</h1>
<p>{descriptions[view]}</p>
</div>
<Button variant="secondary" size="sm" onClick={exportCsv}>
<ArrowDownToLine size={15} />
CSV 내보내기
</Button>
</div>
<div className={s.filters}>
<div className={s.periods} role="group" aria-label="분석 기간">
{([7, 30, 90] as const).map((period: Period) => (
<button
type="button"
key={period}
aria-pressed={
window.from === windowFor(period).from && window.to === END
}
onClick={() => changeWindow(windowFor(period))}
>
최근 {period}일
</button>
))}
</div>
<button
type="button"
className={s.dateButton}
aria-expanded={dateOpen}
aria-controls="analytics-date-range"
onClick={() => setDateOpen(!dateOpen)}
>
<CalendarDays size={15} />
<span>
{shortDate(window.from)} — {shortDate(window.to)}
</span>
<ChevronDown size={13} />
</button>
<label className={s.deviceSelect}>
<Monitor size={15} />
<span className={s.srOnly}>기기 필터</span>
<select
value={device}
onChange={(event) => filterDevice(event.target.value as Device)}
>
<option value="all">모든 기기</option>
{devices.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<span className={s.filterNote}>2026년 · KST</span>
{(device !== "all" || model.days !== 30 || window.to !== END) && (
<button
type="button"
className={s.reset}
onClick={() => {
changeWindow(windowFor(30));
filterDevice("all");
}}
>
<X size={13} />
필터 초기화
</button>
)}
</div>
{dateOpen && (
<form
id="analytics-date-range"
className={s.dateForm}
onSubmit={(event) => {
event.preventDefault();
const from = parseDate(draftFrom);
const to = parseDate(draftTo);
if (
!Number.isFinite(from) ||
!Number.isFinite(to) ||
from > to ||
from < windowFor(90).from ||
to > END
) {
setDateError(
"2026.07.30부터 10.27 사이의 올바른 기간을 선택해주세요.",
);
return;
}
changeWindow({ from, to });
}}
>
<label>
시작일
<Input
type="date"
value={draftFrom}
min={dateKey(windowFor(90).from)}
max={dateKey(END)}
onChange={(event) => setDraftFrom(event.target.value)}
aria-invalid={!!dateError}
aria-describedby={
dateError ? "analytics-date-error" : undefined
}
required
/>
</label>
<label>
종료일
<Input
type="date"
value={draftTo}
min={draftFrom}
max={dateKey(END)}
onChange={(event) => setDraftTo(event.target.value)}
aria-invalid={!!dateError}
required
/>
</label>
<Button type="submit" size="sm">
기간 적용
</Button>
{dateError && (
<p id="analytics-date-error" role="alert">
{dateError}
</p>
)}
</form>
)}
<section className={s.metrics} aria-label="핵심 성과 지표">
{(["revenue", "sessions", "orders", "conversion"] as const).map(
(key) => {
const Icon = metricIcons[key];
const current = metricValue(model.totals, key);
const prior = metricValue(model.prior, key);
return (
<button
type="button"
key={key}
className={s.metric}
data-active={metric === key && view === "overview"}
aria-pressed={metric === key && view === "overview"}
aria-label={`${metricLabels[key]} 추이 보기`}
onClick={() => {
setMetric(key);
if (view !== "overview") setView("overview");
}}
>
<span className={s.metricLabel}>
{metricLabels[key]}
<Icon size={16} />
</span>
<span className={s.metricMiddle}>
<strong data-testid={`metric-${key}`}>
{formatMetric(current, key)}
</strong>
<Sparkline
data={model.daily.map((day) => metricValue(day, key))}
width={76}
height={34}
color="var(--opt-chart-1)"
aria-label={`${metricLabels[key]} 미니 추이`}
/>
</span>
<span className={s.metricMeta}>
<Change
value={
key === "conversion"
? current - prior
: delta(current, prior)
}
suffix={key === "conversion" ? "%p" : "%"}
/>
<span>이전 {model.days}일 대비</span>
</span>
</button>
);
},
)}
</section>
{view === "overview" && (
<>
<div className={s.heroGrid}>
<section className={s.panel} aria-label="성과 추이">
<SectionHeading
title={`${metricLabels[metric]} 추이`}
description={`${shortDate(window.from)} – ${shortDate(window.to)} · 일별 집계`}
action={
<button
type="button"
className={s.compare}
aria-pressed={compare}
onClick={() => setCompare(!compare)}
>
<span>{compare && <Check size={11} />}</span>이전 기간
비교
</button>
}
/>
<div className={s.trendHeadline}>
<strong>
{formatMetric(metricValue(model.totals, metric), metric)}
</strong>
<span>
{compare
? `${shortDate(model.previous.from)} – ${shortDate(model.previous.to)} 비교`
: "선택 기간"}
</span>
</div>
<div className={s.trendChart}>
<TimeSeriesChart
data={trendData}
series={trendSeries}
from={window.from}
to={window.to}
granularity="day"
timeZone="Asia/Seoul"
locale="ko-KR"
height={280}
gradient
selectable
onRangeSelect={selectRange}
onPointClick={openDay}
yFormatter={(value) => formatMetric(value, metric)}
tooltipOptions={{
valueFormatter: (value) =>
metric === "revenue"
? formatMoney(value)
: formatMetric(value, metric),
}}
annotations={
window.from <= CAMPAIGN && window.to >= CAMPAIGN
? [
{
at: CAMPAIGN,
label: "가을 캠페인",
color: "var(--opt-text-secondary)",
},
]
: []
}
partialBucket={false}
showLegend={false}
margin={{ top: 24, right: 14, bottom: 4, left: 8 }}
aria-label={`${metricLabels[metric]} 기간 비교 차트`}
/>
</div>
<div className={s.chartFoot}>
<span>
<MousePointer2 size={13} />
드래그로 기간 선택 · 날짜를 누르면 상세 분석
</span>
<div>
<i />
선택 기간
{compare && (
<>
<i data-previous />
이전 기간
</>
)}
</div>
</div>
<details className={s.dailyDetails}>
<summary>
일별 데이터 보기
<ChevronDown size={14} />
</summary>
<div className={s.tableScroll}>
<table className={s.table}>
<caption className={s.srOnly}>
선택 기간 일별 데이터
</caption>
<thead>
<tr>
<th>날짜</th>
<th>방문 세션</th>
<th>구매</th>
<th>매출</th>
</tr>
</thead>
<tbody>
{model.daily.map((day) => (
<tr key={day.t}>
<td>
<button
type="button"
className={s.dateLink}
onClick={() => setSelectedDay(day.t)}
>
{shortDate(day.t)} 상세
</button>
</td>
<td>{formatNumber(day.sessions)}</td>
<td>{formatNumber(day.orders)}</td>
<td>{formatMoney(day.revenue)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
<aside className={s.insightRail}>
<section className={s.insight}>
<div className={s.insightHeading}>
<Sparkles size={17} />
<h2>숫자 속 인사이트</h2>
</div>
<h3>
매출이 이전 기간보다
<br />
<em>
{Math.abs(revenueChange).toFixed(1)}%{" "}
{revenueChange >= 0 ? "증가했어요" : "감소했어요"}
</em>
</h3>
<p>
<strong>{bestChannel.name}</strong>의 구매 전환율이{" "}
{conversion(bestChannel).toFixed(2)}%로 가장 높습니다.
유입과 전환을 함께 확인해보세요.
</p>
<button
type="button"
className={s.textLink}
onClick={() => setView("acquisition")}
>
유입 채널 살펴보기
<ArrowRight size={15} />
</button>
</section>
<section className={`${s.panel} ${s.goal}`}>
<div className={s.goalHeading}>
<Target size={17} />
<h2>기간 매출 목표</h2>
</div>
<div className={s.goalValue}>
<strong>
{targetPercent.toFixed(0)}
<span>%</span>
</strong>
<span>
{targetPercent >= 100
? "목표를 달성했어요"
: "목표를 향해 가고 있어요"}
</span>
</div>
<BulletChart
data={[
{
name: "매출",
value: model.totals.revenue,
target,
ranges: [
target * 0.5,
target * 0.8,
Math.max(target * 1.15, model.totals.revenue),
],
},
]}
valueFormatter={compactMoney}
locale="ko-KR"
labels={{ target: "목표" }}
aria-label="기간 매출 목표 달성도"
/>
<p>목표 {compactMoney(target)} · 선택 기간 / 기기 기준</p>
</section>
</aside>
</div>
<div className={s.breakdownGrid}>
<section className={s.panel}>
<SectionHeading
title="어디서 방문할까요?"
description="유입 채널별 방문 세션 · 이전 기간 비교"
action={
<Button
variant="ghost"
size="sm"
onClick={() => setView("acquisition")}
>
전체 분석
</Button>
}
/>
<div className={s.channelChart}>
<BarChart
data={model.byChannel.map((channel) => ({
name: channel.name,
current: channel.sessions,
previous: channel.previous.sessions,
}))}
series={[
{
dataKey: "current",
name: "선택 기간",
color: "var(--opt-chart-1)",
},
{
dataKey: "previous",
name: "이전 기간",
color: "var(--opt-chart-other)",
},
]}
layout="vertical"
height={230}
showLegend
showGrid={false}
maxBarSize={10}
legendOptions={{
labels: { legendAriaLabel: "채널 비교 범례" },
}}
locale="ko-KR"
aria-label="채널별 방문 비교"
/>
</div>
</section>
<DevicePanel model={model} onFilter={filterDevice} />
</div>
<section className={s.panel}>
<SectionHeading
title="유입 채널 성과"
description="규모뿐 아니라, 구매로 이어지는 채널을 확인하세요."
/>
<ChannelTable model={model} onSelect={setSelectedChannel} />
</section>
</>
)}
{view === "acquisition" && (
<>
<section className={s.panel}>
<SectionHeading
title="채널별 유입 흐름"
description="범례를 눌러 비교할 채널을 선택하세요. 아래 표에서 채널 상세를 열 수 있어요."
/>
<div className={s.wideChart}>
<TimeSeriesChart
data={model.daily.map((day) => ({
t: day.t,
...Object.fromEntries(
channels.map((channel) => [
channel.id,
total(
model.rows.filter(
(row) =>
row.t === day.t && row.channel === channel.id,
),
).sessions,
]),
),
}))}
series={channels.map((channel) => ({
dataKey: channel.id,
name: channel.name,
color: channel.color,
type: "area",
}))}
from={window.from}
to={window.to}
granularity="day"
stacked
gradient
height={300}
timeZone="Asia/Seoul"
locale="ko-KR"
showLegend
legendOptions={{
labels: { legendAriaLabel: "유입 채널 범례" },
}}
tooltipOptions={{
showTotal: true,
labels: { total: "총 방문" },
}}
partialBucket={false}
aria-label="채널별 방문 시계열"
/>
</div>
</section>
<div className={s.breakdownGrid}>
<section className={s.panel}>
<SectionHeading
title="채널 기여도"
description="방문과 매출의 기여 순서를 비교하세요."
action={
<div
className={s.segmented}
role="group"
aria-label="채널 기여도 지표"
>
<button
type="button"
aria-pressed={channelMetric === "sessions"}
onClick={() => setChannelMetric("sessions")}
>
방문
</button>
<button
type="button"
aria-pressed={channelMetric === "revenue"}
onClick={() => setChannelMetric("revenue")}
>
매출
</button>
</div>
}
/>
<div className={s.channelChart}>
<BarChart
data={[...model.byChannel]
.sort((a, b) => b[channelMetric] - a[channelMetric])
.map((channel) => ({
name: channel.name,
value: channel[channelMetric],
}))}
series={[
{
dataKey: "value",
name:
channelMetric === "sessions" ? "방문 세션" : "매출",
color: "var(--opt-chart-1)",
},
]}
layout="vertical"
height={240}
showLegend={false}
showValues
showGrid={false}
valueFormatter={(value) =>
channelMetric === "revenue"
? compactMoney(value)
: formatNumber(value)
}
onPointClick={(point) =>
setSelectedChannel(
model.byChannel.find(
(channel) => channel.name === point.category,
)?.id ?? null,
)
}
locale="ko-KR"
aria-label="채널 기여도 비교"
/>
</div>
</section>
<DevicePanel model={model} onFilter={filterDevice} />
</div>
<section className={s.panel}>
<SectionHeading title="유입 채널 상세" />
<ChannelTable model={model} onSelect={setSelectedChannel} />
</section>
</>
)}
{view === "conversion" && (
<>
<FunnelPanel model={model} step={step} setStep={setStep} />
<div className={s.breakdownGrid}>
<section className={s.panel}>
<SectionHeading
title="전환율의 변화"
description="방문 세션 대비 구매 완료 비율"
/>
<div className={s.wideChart}>
<TimeSeriesChart
data={model.daily.map((day) => ({
t: day.t,
current: conversion(day),
previous: conversion(day.previous),
}))}
series={[
{
dataKey: "current",
name: "선택 기간",
type: "line",
color: "var(--opt-chart-2)",
},
{
dataKey: "previous",
name: "이전 기간",
comparison: true,
comparisonOf: "current",
color: "var(--opt-chart-2)",
},
]}
from={window.from}
to={window.to}
height={250}
granularity="day"
timeZone="Asia/Seoul"
locale="ko-KR"
yFormatter={(value) => `${value.toFixed(1)}%`}
tooltipOptions={{
valueFormatter: (value) => `${value.toFixed(2)}%`,
}}
onPointClick={openDay}
partialBucket={false}
aria-label="구매 전환율 추이"
/>
</div>
</section>
<section className={`${s.panel} ${s.conversionSummary}`}>
<span className={s.detailIcon}>
<ShoppingBag size={22} />
</span>
<h2>구매로 이어진 방문</h2>
<strong>
{formatNumber(model.totals.orders)}
<span>건</span>
</strong>
<p>
평균 주문 금액은{" "}
<b>
{formatMoney(model.totals.revenue / model.totals.orders)}
</b>
입니다.
</p>
<dl>
<div>
<dt>결제 시작 → 구매</dt>
<dd>
{(
(model.totals.orders / model.totals.checkout) *
100
).toFixed(1)}
%
</dd>
</div>
<div>
<dt>전환율이 가장 높은 채널</dt>
<dd>{bestChannel.name}</dd>
</div>
<div>
<dt>매출이 가장 높은 날</dt>
<dd>{shortDate(bestDay.t)}</dd>
</div>
</dl>
<button
type="button"
className={s.textLink}
onClick={() => setSelectedChannel(bestChannel.id)}
>
채널 상세 보기
<ArrowRight size={15} />
</button>
</section>
</div>
</>
)}
{view === "retention" && (
<>
<div className={s.retentionIntro}>
<div>
<span>1주차 재방문율</span>
<strong>
{retainedWeekOne === null
? "—"
: `${retainedWeekOne.toFixed(1)}%`}
</strong>
<p>관찰이 완료된 코호트의 인원 가중 평균</p>
</div>
<div>
<span>분석 코호트</span>
<strong>
{model.cohorts.length}
<small>개</small>
</strong>
<p>
{shortDate(window.from)} – {shortDate(window.to)} 최초 방문
</p>
</div>
<div className={s.retentionNote}>
<Clock3 size={20} />
<p>
아직 관찰 기간이 지나지 않은 칸은 비워두었어요. 셀을 선택해
코호트의 재방문 인원을 확인하세요.
</p>
</div>
</div>
<section className={s.panel}>
<SectionHeading
title="처음 방문한 고객은 얼마나 돌아올까요?"
description="주간 코호트 · 최초 방문 주차를 100%로 표시"
/>
<div className={s.heatmap}>
<RetentionHeatmap
data={model.cohorts}
periods={[
"최초 방문",
"1주 후",
"2주 후",
"3주 후",
"4주 후",
"5주 후",
]}
onCellSelect={setSelectedCell}
cellLabel={(cell) =>
`${cell.cohort}, ${cell.period}주 후, ${cell.value}% 재방문`
}
valueFormatter={(value) => `${value.toFixed(1)}%`}
locale="ko-KR"
labels={{
cohortColumn: "최초 방문 주간",
legendLabel: (min, max) => `재방문율 ${min}부터 ${max}`,
}}
aria-label="주간 코호트 리텐션"
/>
</div>
<div className={s.panelNote}>
<CircleHelp size={14} />
리텐션은 별도의 합성 코호트 표본입니다. 기기 필터는 코호트
규모와 재방문율에 함께 반영됩니다.
</div>
</section>
</>
)}
<footer className={s.footer}>
<span>
<i />
2026.10.27 기준 샘플 데이터 · 실제 서비스에 연결되지 않습니다.
</span>
<button type="button" onClick={() => setGuide(true)}>
지표 집계 기준
</button>
</footer>
</div>
</ShellAppShell>
<DialogRoot
open={
pickedDay != null ||
pickedChannel != null ||
selectedCell != null ||
guide
}
onOpenChange={(open) => {
if (!open) {
setSelectedDay(null);
setSelectedChannel(null);
setSelectedCell(null);
setGuide(false);
}
}}
>
<DialogPanel size="lg" className={s.dialog}>
<div className={s.dialogClose}>
<DialogDismiss aria-label="상세 닫기">
<X size={18} />
</DialogDismiss>
</div>
{pickedDay && (
<>
<DialogHeading>{shortDate(pickedDay.t)} 일별 분석</DialogHeading>
<DialogDescription>
현재 기기 필터를 적용한 하루의 성과입니다.
</DialogDescription>
<div className={s.dialogMetrics}>
<div>
<span>방문 세션</span>
<strong>{formatNumber(pickedDay.sessions)}</strong>
</div>
<div>
<span>매출</span>
<strong>{compactMoney(pickedDay.revenue)}</strong>
</div>
<div>
<span>전환율</span>
<strong>{conversion(pickedDay).toFixed(2)}%</strong>
</div>
</div>
<BarChart
data={channels.map((channel) => ({
name: channel.name,
revenue: total(
model.rows.filter(
(row) =>
row.t === pickedDay.t && row.channel === channel.id,
),
).revenue,
}))}
series={[
{
dataKey: "revenue",
name: "매출",
color: "var(--opt-chart-1)",
},
]}
layout="vertical"
height={220}
valueFormatter={compactMoney}
locale="ko-KR"
aria-label="선택일 채널별 매출"
/>
<p className={s.dialogNote}>
이전 비교일 {shortDate(pickedDay.t - model.days * DAY)} 매출:{" "}
{formatMoney(pickedDay.previous.revenue)}
</p>
</>
)}
{pickedChannel && (
<>
<DialogHeading>{pickedChannel.name} 채널 분석</DialogHeading>
<DialogDescription>
{shortDate(window.from)} – {shortDate(window.to)} ·{" "}
{pickedChannel.detail}
</DialogDescription>
<div className={s.dialogMetrics}>
<div>
<span>방문 세션</span>
<strong>{formatNumber(pickedChannel.sessions)}</strong>
</div>
<div>
<span>구매 완료</span>
<strong>{formatNumber(pickedChannel.orders)}</strong>
</div>
<div>
<span>매출 기여</span>
<strong>
{(
(pickedChannel.revenue / model.totals.revenue) *
100
).toFixed(1)}
%
</strong>
</div>
</div>
<TimeSeriesChart
data={model.daily.map((day) => ({
t: day.t,
visits: total(
model.rows.filter(
(row) =>
row.t === day.t && row.channel === pickedChannel.id,
),
).sessions,
}))}
series={[
{
dataKey: "visits",
name: "방문 세션",
type: "area",
color: pickedChannel.color,
},
]}
from={window.from}
to={window.to}
granularity="day"
timeZone="Asia/Seoul"
locale="ko-KR"
height={220}
gradient
partialBucket={false}
aria-label={`${pickedChannel.name} 상세 방문 추이`}
/>
<p className={s.dialogNote}>
구매 전환율 {conversion(pickedChannel).toFixed(2)}% · 매출{" "}
{formatMoney(pickedChannel.revenue)}
</p>
</>
)}
{selectedCell && pickedCohort && (
<>
<DialogHeading>{selectedCell.cohort} 코호트</DialogHeading>
<DialogDescription>
최초 방문 후 {selectedCell.period}주차 재방문 현황 · 합성 표본
</DialogDescription>
<div className={s.dialogMetrics}>
<div>
<span>최초 방문</span>
<strong>{formatNumber(pickedCohort.size)}명</strong>
</div>
<div>
<span>재방문</span>
<strong>
{formatNumber(
Math.round(
(pickedCohort.size * selectedCell.value) / 100,
),
)}
명
</strong>
</div>
<div>
<span>재방문율</span>
<strong>{selectedCell.value}%</strong>
</div>
</div>
<BarChart
data={pickedCohort.values.flatMap((value, i) =>
value === null
? []
: [{ name: i === 0 ? "최초 방문" : `${i}주 후`, value }],
)}
series={[
{
dataKey: "value",
name: "재방문율",
color: "var(--opt-chart-1)",
},
]}
height={220}
valueFormatter={(value) => `${value}%`}
locale="ko-KR"
aria-label="선택 코호트 재방문 추이"
/>
<p className={s.dialogNote}>
관찰하지 못한 기간은 0%로 표시하지 않습니다. 재방문 인원은 표본
크기와 비율을 기준으로 반올림했습니다.
</p>
</>
)}
{guide && (
<>
<DialogHeading>지표를 읽는 방법</DialogHeading>
<DialogDescription>
이 워크스페이스는 2026년 10월 27일에 고정된 데모입니다.
</DialogDescription>
<dl className={s.guide}>
<div>
<dt>기간과 비교</dt>
<dd>
최근 7·30·90일 또는 직접 지정한 기간으로 분석합니다. 이전
기간은 바로 앞의 동일한 일수이며, 모든 날짜는 한국 표준시
기준입니다.
</dd>
</div>
<div>
<dt>방문과 전환</dt>
<dd>
방문은 순 방문자 수가 아닌 세션 수입니다. 구매 전환율은 구매
완료 ÷ 방문 세션으로 계산합니다. 차트와 요약은 같은 합성
레코드를 집계합니다.
</dd>
</div>
<div>
<dt>차트 탐색</dt>
<dd>
추이 차트의 구간을 드래그해 기간을 좁힐 수 있습니다. 일별
데이터의 날짜 버튼으로도 상세를 열 수 있으며, 범례는
키보드로 선택할 수 있습니다.
</dd>
</div>
<div>
<dt>목표와 리텐션</dt>
<dd>
매출 목표는 일일 650만 원에 선택 일수와 기기 비중을 적용한
예시입니다. 리텐션은 별도의 합성 코호트이며, 관찰이 끝나지
않은 기간은 빈칸입니다.
</dd>
</div>
</dl>
</>
)}
</DialogPanel>
</DialogRoot>
</div>
);
}
export default function DashboardTemplatePage() {
return (
<ShellProvider manifest={manifest} applyDocumentPolicy={false}>
<AnalyticsContent />
</ShellProvider>
);
}