@astryxdesign/cli 0.4.5 → 0.4.6-canary.cea9ecd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/api/component/_adapter.d.mts +22 -5
- package/api/component/_adapter.mjs +51 -3
- package/api/docs/_adapter.d.mts +37 -8
- package/api/docs/_adapter.mjs +70 -41
- package/api/docs/detail/detail.d.mts +2 -0
- package/api/docs/detail/detail.mjs +12 -10
- package/api/docs/detail/section/section.d.mts +2 -0
- package/api/docs/detail/section/section.mjs +1 -0
- package/api/docs/docs.d.mts +2 -0
- package/api/docs/docs.doc.mjs +11 -2
- package/api/docs/docs.mjs +2 -1
- package/api/docs/docs.type.d.mts +15 -0
- package/api/docs/docs.type.mjs +6 -0
- package/api/docs/integrationDocs.test.mjs +208 -0
- package/api/docs/list/list.d.mts +5 -1
- package/api/docs/list/list.mjs +30 -14
- package/api/init/run/run.mjs +9 -4
- package/api/integration/validate-integration.mjs +2 -1
- package/api/integration/validate-integration.type.d.mts +1 -0
- package/api/integration/validate-integration.type.mjs +1 -0
- package/api/search/search.mjs +26 -15
- package/api/upgrade/_adapter.d.mts +2 -2
- package/api/upgrade/_adapter.mjs +7 -3
- package/api/upgrade/run/run.mjs +1 -1
- package/assets/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +245 -0
- package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +139 -13
- package/assets/docs/cli-integrations.doc.mjs +41 -3
- package/assets/docs/layout.doc.dense.mjs +275 -30
- package/assets/docs/layout.doc.mjs +412 -78
- package/assets/docs/theme.doc.mjs +3 -3
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.doc.mjs +15 -0
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupInteractive.tsx +47 -0
- package/assets/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.doc.mjs +1 -1
- package/assets/templates/blocks/components/Breadcrumbs/BreadcrumbsCustomSeparator.tsx +18 -5
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.doc.mjs +14 -0
- package/assets/templates/blocks/components/Selector/SelectorOptionDescriptions.tsx +76 -0
- package/assets/templates/pages/dashboard-cohort-funnel/page.tsx +914 -0
- package/assets/templates/pages/dashboard-cohort-funnel/template.doc.mjs +12 -0
- package/assets/templates/pages/dashboard-data/page.tsx +894 -0
- package/assets/templates/pages/dashboard-data/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-executive-summary/page.tsx +874 -0
- package/assets/templates/pages/dashboard-executive-summary/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-project-status/page.tsx +1139 -0
- package/assets/templates/pages/dashboard-project-status/template.doc.mjs +13 -0
- package/assets/templates/pages/dashboard-service-monitoring/page.tsx +1590 -0
- package/assets/templates/pages/dashboard-service-monitoring/template.doc.mjs +12 -0
- package/authoring/doctypes/_schema.d.mts +2 -0
- package/authoring/doctypes/_schema.mjs +5 -0
- package/authoring/doctypes/reference/reference.doc.mjs +14 -0
- package/authoring/doctypes/reference/type.ts +12 -0
- package/authoring/doctypes/template/type.ts +2 -0
- package/authoring/integration/integration.doc.mjs +10 -1
- package/authoring/integration/parse.d.mts +1 -0
- package/authoring/integration/parse.mjs +1 -0
- package/authoring/integration/parse.test.mjs +1 -0
- package/authoring/integration/type.ts +5 -0
- package/clients/cli/commands/component-ownership.test.mjs +63 -3
- package/clients/cli/commands/theme-build.doc.mjs +1 -1
- package/foundation/agent-docs/agent-docs.d.mts +19 -3
- package/foundation/agent-docs/agent-docs.mjs +30 -11
- package/foundation/agent-docs/agent-docs.test.mjs +38 -0
- package/foundation/config/project.d.mts +16 -0
- package/foundation/config/project.mjs +65 -4
- package/foundation/config/project.test.mjs +66 -0
- package/foundation/discovery/docs-discovery.d.mts +185 -0
- package/foundation/discovery/docs-discovery.mjs +544 -0
- package/foundation/discovery/docs-discovery.test.mjs +341 -0
- package/foundation/integrations/integrations.d.mts +8 -6
- package/foundation/integrations/integrations.mjs +6 -4
- package/foundation/integrations/validate-contributions.mjs +30 -2
- package/package.json +9 -9
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Funnel & Cohort Analytics — a growth / product-analytics dashboard.
|
|
7
|
+
*
|
|
8
|
+
* Content-only (root `Layout`); the host supplies the app shell. Composed of
|
|
9
|
+
* distinct analytics widgets stacked in the content column:
|
|
10
|
+
*
|
|
11
|
+
* KPI row | conversion funnel (stage bars + step drop-off)
|
|
12
|
+
* | conversion-over-time trend | cohort retention grid
|
|
13
|
+
*
|
|
14
|
+
* The segment control (All / Web / iOS / Android) and the funnel window
|
|
15
|
+
* (7d / 30d / 90d) reshape the funnel, the trend chart, and the KPIs. Cohort
|
|
16
|
+
* retention is a table of Token-colored cells (heatmap-by-value). All data is
|
|
17
|
+
* deterministic (fixed fixtures, no clocks/random) so previews stay stable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {useMemo, useState, type CSSProperties} from 'react';
|
|
21
|
+
import * as stylex from '@stylexjs/stylex';
|
|
22
|
+
import {
|
|
23
|
+
VStack,
|
|
24
|
+
HStack,
|
|
25
|
+
Layout,
|
|
26
|
+
LayoutContent,
|
|
27
|
+
LayoutHeader,
|
|
28
|
+
} from '@astryxdesign/core/Layout';
|
|
29
|
+
import {Grid} from '@astryxdesign/core/Grid';
|
|
30
|
+
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
31
|
+
import {Card} from '@astryxdesign/core/Card';
|
|
32
|
+
import {Button} from '@astryxdesign/core/Button';
|
|
33
|
+
import {Icon} from '@astryxdesign/core/Icon';
|
|
34
|
+
import {Divider} from '@astryxdesign/core/Divider';
|
|
35
|
+
import {Token} from '@astryxdesign/core/Token';
|
|
36
|
+
import {Selector} from '@astryxdesign/core/Selector';
|
|
37
|
+
import {
|
|
38
|
+
SegmentedControl,
|
|
39
|
+
SegmentedControlItem,
|
|
40
|
+
} from '@astryxdesign/core/SegmentedControl';
|
|
41
|
+
import {Table, pixel} from '@astryxdesign/core/Table';
|
|
42
|
+
import type {TableColumn} from '@astryxdesign/core/Table';
|
|
43
|
+
import {
|
|
44
|
+
BarChart,
|
|
45
|
+
Bar,
|
|
46
|
+
Cell,
|
|
47
|
+
LineChart,
|
|
48
|
+
Line,
|
|
49
|
+
XAxis,
|
|
50
|
+
YAxis,
|
|
51
|
+
CartesianGrid,
|
|
52
|
+
Tooltip,
|
|
53
|
+
ResponsiveContainer,
|
|
54
|
+
} from 'recharts';
|
|
55
|
+
import {
|
|
56
|
+
ArrowUpIcon,
|
|
57
|
+
ArrowDownIcon,
|
|
58
|
+
ArrowDownTrayIcon,
|
|
59
|
+
ArrowTrendingDownIcon,
|
|
60
|
+
} from '@heroicons/react/24/outline';
|
|
61
|
+
import {StopIcon} from '@heroicons/react/24/solid';
|
|
62
|
+
|
|
63
|
+
// ============= TYPES =============
|
|
64
|
+
|
|
65
|
+
type Segment = 'all' | 'web' | 'ios' | 'android';
|
|
66
|
+
type FunnelWindow = '7d' | '30d' | '90d';
|
|
67
|
+
|
|
68
|
+
interface FunnelStage {
|
|
69
|
+
key: string;
|
|
70
|
+
label: string;
|
|
71
|
+
// Absolute users entering this stage, per segment.
|
|
72
|
+
users: Record<Segment, number>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============= COLORS (design tokens w/ hex fallbacks) =============
|
|
76
|
+
|
|
77
|
+
const COLORS = {
|
|
78
|
+
blue: 'var(--color-data-categorical-blue, #0171E3)',
|
|
79
|
+
green: 'var(--color-data-categorical-green, #0B991F)',
|
|
80
|
+
orange: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
81
|
+
purple: 'var(--color-data-categorical-purple, #6B1EFD)',
|
|
82
|
+
teal: 'var(--color-data-categorical-teal, #008E80)',
|
|
83
|
+
};
|
|
84
|
+
const GRID_STROKE = 'var(--color-border, rgba(5, 54, 89, 0.1))';
|
|
85
|
+
const AXIS_TICK = {
|
|
86
|
+
fontSize: 'var(--font-size-sm, 12px)',
|
|
87
|
+
fill: 'var(--color-text-secondary, #4E606F)',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Funnel bars step through the categorical palette top-to-bottom.
|
|
91
|
+
const FUNNEL_COLORS = [
|
|
92
|
+
COLORS.blue,
|
|
93
|
+
COLORS.teal,
|
|
94
|
+
COLORS.green,
|
|
95
|
+
COLORS.orange,
|
|
96
|
+
COLORS.purple,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// ============= STYLES =============
|
|
100
|
+
|
|
101
|
+
const styles = stylex.create({
|
|
102
|
+
// Rules between the drop-off steps. Each step draws its own leading rule,
|
|
103
|
+
// pulled half a gap to the left so it sits centred in the column gap. The
|
|
104
|
+
// step that starts a row draws its rule outside the grid's content box,
|
|
105
|
+
// where `overflowX: clip` hides it — so the separators stay correct as the
|
|
106
|
+
// grid reflows from four columns down to one.
|
|
107
|
+
//
|
|
108
|
+
// The `--spacing-3` below must match the step Grid's `gap={3}`, otherwise
|
|
109
|
+
// the rules sit off-centre. StyleX needs a literal here, so the two can't
|
|
110
|
+
// share a constant — keep them in sync by hand.
|
|
111
|
+
stepGrid: {
|
|
112
|
+
overflowX: 'clip',
|
|
113
|
+
},
|
|
114
|
+
step: {
|
|
115
|
+
borderInlineStartWidth: '1px',
|
|
116
|
+
borderInlineStartStyle: 'solid',
|
|
117
|
+
borderInlineStartColor: 'var(--color-border, rgba(5, 54, 89, 0.1))',
|
|
118
|
+
marginInlineStart: 'calc(var(--spacing-3, 12px) / -2)',
|
|
119
|
+
paddingInlineStart: 'calc(var(--spacing-3, 12px) / 2)',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ============= FUNNEL DATA =============
|
|
124
|
+
|
|
125
|
+
// Window multipliers applied to the base (30d) stage counts.
|
|
126
|
+
const WINDOW_FACTOR: Record<FunnelWindow, number> = {
|
|
127
|
+
'7d': 0.24,
|
|
128
|
+
'30d': 1,
|
|
129
|
+
'90d': 3.1,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const FUNNEL_STAGES: FunnelStage[] = [
|
|
133
|
+
{
|
|
134
|
+
key: 'visit',
|
|
135
|
+
label: 'Visited site',
|
|
136
|
+
users: {all: 128400, web: 82600, ios: 27300, android: 18500},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: 'signup',
|
|
140
|
+
label: 'Signed up',
|
|
141
|
+
users: {all: 41200, web: 24800, ios: 9600, android: 6800},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: 'activate',
|
|
145
|
+
label: 'Activated',
|
|
146
|
+
users: {all: 22600, web: 12900, ios: 5900, android: 3800},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: 'subscribe',
|
|
150
|
+
label: 'Subscribed',
|
|
151
|
+
users: {all: 9100, web: 5100, ios: 2600, android: 1400},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
key: 'retain',
|
|
155
|
+
label: 'Retained 30d',
|
|
156
|
+
users: {all: 6300, web: 3600, ios: 1850, android: 850},
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// ============= CONVERSION-OVER-TIME =============
|
|
161
|
+
|
|
162
|
+
interface TrendPoint {
|
|
163
|
+
t: number;
|
|
164
|
+
label: string;
|
|
165
|
+
signup: number;
|
|
166
|
+
activate: number;
|
|
167
|
+
subscribe: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Weekly stage-conversion rates (%). Signup->activate->subscribe.
|
|
171
|
+
//
|
|
172
|
+
// Shaped as a plausible growth story rather than a smooth ramp, so the chart
|
|
173
|
+
// reads like real telemetry: a W7 pricing test lifts paid conversion, a W9-W10
|
|
174
|
+
// onboarding regression sinks activation until a fix ships, a W15 acquisition
|
|
175
|
+
// campaign spikes signups with low-intent traffic (activation dips as signup
|
|
176
|
+
// peaks, and the two series cross), W18 is an outage week that knocks all three
|
|
177
|
+
// down together, and W19+ recovers to a net-upward trend.
|
|
178
|
+
const TREND: TrendPoint[] = [
|
|
179
|
+
{t: 0, label: 'W1', signup: 30.1, activate: 52.4, subscribe: 38.2},
|
|
180
|
+
{t: 1, label: 'W2', signup: 31.4, activate: 54.1, subscribe: 36.9},
|
|
181
|
+
{t: 2, label: 'W3', signup: 29.2, activate: 51.2, subscribe: 39.8},
|
|
182
|
+
{t: 3, label: 'W4', signup: 32.6, activate: 55.8, subscribe: 37.1},
|
|
183
|
+
{t: 4, label: 'W5', signup: 28.4, activate: 53.6, subscribe: 40.4},
|
|
184
|
+
{t: 5, label: 'W6', signup: 31.8, activate: 56.2, subscribe: 38.3},
|
|
185
|
+
{t: 6, label: 'W7', signup: 33.5, activate: 54.3, subscribe: 44.6},
|
|
186
|
+
{t: 7, label: 'W8', signup: 30.2, activate: 57.1, subscribe: 42.1},
|
|
187
|
+
{t: 8, label: 'W9', signup: 34.1, activate: 48.9, subscribe: 37.4},
|
|
188
|
+
{t: 9, label: 'W10', signup: 29.6, activate: 46.2, subscribe: 35.2},
|
|
189
|
+
{t: 10, label: 'W11', signup: 32.9, activate: 50.4, subscribe: 38.9},
|
|
190
|
+
{t: 11, label: 'W12', signup: 35.4, activate: 55.6, subscribe: 41.3},
|
|
191
|
+
{t: 12, label: 'W13', signup: 31.1, activate: 57.3, subscribe: 39.2},
|
|
192
|
+
{t: 13, label: 'W14', signup: 33.8, activate: 54.8, subscribe: 42.8},
|
|
193
|
+
{t: 14, label: 'W15', signup: 41.2, activate: 49.6, subscribe: 40.1},
|
|
194
|
+
{t: 15, label: 'W16', signup: 38.6, activate: 47.8, subscribe: 36.4},
|
|
195
|
+
{t: 16, label: 'W17', signup: 30.4, activate: 53.2, subscribe: 39.7},
|
|
196
|
+
{t: 17, label: 'W18', signup: 26.8, activate: 44.1, subscribe: 34.2},
|
|
197
|
+
{t: 18, label: 'W19', signup: 31.9, activate: 51.7, subscribe: 38.1},
|
|
198
|
+
{t: 19, label: 'W20', signup: 34.7, activate: 56.4, subscribe: 41.9},
|
|
199
|
+
{t: 20, label: 'W21', signup: 32.2, activate: 58.2, subscribe: 44.2},
|
|
200
|
+
{t: 21, label: 'W22', signup: 36.1, activate: 55.1, subscribe: 40.6},
|
|
201
|
+
{t: 22, label: 'W23', signup: 33.4, activate: 59.4, subscribe: 43.8},
|
|
202
|
+
{t: 23, label: 'W24', signup: 37.2, activate: 56.8, subscribe: 46.1},
|
|
203
|
+
{t: 24, label: 'W25', signup: 34.9, activate: 60.1, subscribe: 42.7},
|
|
204
|
+
{t: 25, label: 'W26', signup: 38.4, activate: 58.6, subscribe: 45.3},
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
// One row per plotted series, so the chart lines and the legend below it can't
|
|
208
|
+
// drift apart — both are rendered from this list.
|
|
209
|
+
const TREND_SERIES = [
|
|
210
|
+
{key: 'signup', name: 'Visit → signup', color: COLORS.blue},
|
|
211
|
+
{key: 'activate', name: 'Signup → activation', color: COLORS.green},
|
|
212
|
+
{key: 'subscribe', name: 'Activation → paid', color: COLORS.orange},
|
|
213
|
+
] as const;
|
|
214
|
+
|
|
215
|
+
// Label every fifth week. Derived from TREND so the axis stays in-domain when
|
|
216
|
+
// the fixture grows or shrinks.
|
|
217
|
+
const TREND_TICK_STEP = 5;
|
|
218
|
+
const TREND_TICKS = Array.from(
|
|
219
|
+
{length: Math.ceil(TREND.length / TREND_TICK_STEP)},
|
|
220
|
+
(_, i) => i * TREND_TICK_STEP,
|
|
221
|
+
);
|
|
222
|
+
const formatTrendTick = (v: number) => TREND[v]?.label ?? '';
|
|
223
|
+
|
|
224
|
+
// ============= COHORT RETENTION =============
|
|
225
|
+
|
|
226
|
+
interface Cohort {
|
|
227
|
+
label: string;
|
|
228
|
+
size: number;
|
|
229
|
+
// Retention % by week index (week 0 = 100 by definition).
|
|
230
|
+
retention: number[];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const COHORTS: Cohort[] = [
|
|
234
|
+
{
|
|
235
|
+
label: 'Apr 6 – Apr 12',
|
|
236
|
+
size: 4820,
|
|
237
|
+
retention: [100, 62, 48, 41, 37, 34, 32, 31],
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
label: 'Apr 13 – Apr 19',
|
|
241
|
+
size: 5140,
|
|
242
|
+
retention: [100, 64, 50, 43, 39, 36, 34],
|
|
243
|
+
},
|
|
244
|
+
{label: 'Apr 20 – Apr 26', size: 5390, retention: [100, 66, 52, 45, 41, 38]},
|
|
245
|
+
{label: 'Apr 27 – May 3', size: 5610, retention: [100, 63, 49, 42, 38]},
|
|
246
|
+
{label: 'May 4 – May 10', size: 5980, retention: [100, 67, 54, 47]},
|
|
247
|
+
{label: 'May 11 – May 17', size: 6240, retention: [100, 69, 56]},
|
|
248
|
+
{label: 'May 18 – May 24', size: 6510, retention: [100, 71]},
|
|
249
|
+
{label: 'May 25 – May 31', size: 6720, retention: [100]},
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
const MAX_WEEKS = 8;
|
|
253
|
+
|
|
254
|
+
// Map a retention percentage to a Token color (green = sticky, red = leaky).
|
|
255
|
+
function retentionColor(
|
|
256
|
+
pct: number,
|
|
257
|
+
): 'green' | 'teal' | 'yellow' | 'orange' | 'red' {
|
|
258
|
+
if (pct >= 70) {
|
|
259
|
+
return 'green';
|
|
260
|
+
}
|
|
261
|
+
if (pct >= 50) {
|
|
262
|
+
return 'teal';
|
|
263
|
+
}
|
|
264
|
+
if (pct >= 40) {
|
|
265
|
+
return 'yellow';
|
|
266
|
+
}
|
|
267
|
+
if (pct >= 32) {
|
|
268
|
+
return 'orange';
|
|
269
|
+
}
|
|
270
|
+
return 'red';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============= KPIS =============
|
|
274
|
+
|
|
275
|
+
interface Kpi {
|
|
276
|
+
key: string;
|
|
277
|
+
label: string;
|
|
278
|
+
value: string;
|
|
279
|
+
delta: number;
|
|
280
|
+
higherIsBetter: boolean;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const KPIS: Record<Segment, Kpi[]> = {
|
|
284
|
+
all: [
|
|
285
|
+
{
|
|
286
|
+
key: 'v2s',
|
|
287
|
+
label: 'Visit → signup',
|
|
288
|
+
value: '32.1%',
|
|
289
|
+
delta: 1.8,
|
|
290
|
+
higherIsBetter: true,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
key: 's2a',
|
|
294
|
+
label: 'Signup → activation',
|
|
295
|
+
value: '54.9%',
|
|
296
|
+
delta: 2.4,
|
|
297
|
+
higherIsBetter: true,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
key: 'a2s',
|
|
301
|
+
label: 'Activation → paid',
|
|
302
|
+
value: '40.3%',
|
|
303
|
+
delta: -1.1,
|
|
304
|
+
higherIsBetter: true,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
key: 'w4',
|
|
308
|
+
label: 'Week-4 retention',
|
|
309
|
+
value: '43.2%',
|
|
310
|
+
delta: 3.6,
|
|
311
|
+
higherIsBetter: true,
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
web: [
|
|
315
|
+
{
|
|
316
|
+
key: 'v2s',
|
|
317
|
+
label: 'Visit → signup',
|
|
318
|
+
value: '30.0%',
|
|
319
|
+
delta: 1.2,
|
|
320
|
+
higherIsBetter: true,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
key: 's2a',
|
|
324
|
+
label: 'Signup → activation',
|
|
325
|
+
value: '52.0%',
|
|
326
|
+
delta: 1.9,
|
|
327
|
+
higherIsBetter: true,
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
key: 'a2s',
|
|
331
|
+
label: 'Activation → paid',
|
|
332
|
+
value: '39.5%',
|
|
333
|
+
delta: -0.6,
|
|
334
|
+
higherIsBetter: true,
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
key: 'w4',
|
|
338
|
+
label: 'Week-4 retention',
|
|
339
|
+
value: '41.8%',
|
|
340
|
+
delta: 2.9,
|
|
341
|
+
higherIsBetter: true,
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
ios: [
|
|
345
|
+
{
|
|
346
|
+
key: 'v2s',
|
|
347
|
+
label: 'Visit → signup',
|
|
348
|
+
value: '35.2%',
|
|
349
|
+
delta: 2.6,
|
|
350
|
+
higherIsBetter: true,
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
key: 's2a',
|
|
354
|
+
label: 'Signup → activation',
|
|
355
|
+
value: '61.5%',
|
|
356
|
+
delta: 3.1,
|
|
357
|
+
higherIsBetter: true,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
key: 'a2s',
|
|
361
|
+
label: 'Activation → paid',
|
|
362
|
+
value: '44.1%',
|
|
363
|
+
delta: 0.8,
|
|
364
|
+
higherIsBetter: true,
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
key: 'w4',
|
|
368
|
+
label: 'Week-4 retention',
|
|
369
|
+
value: '47.5%',
|
|
370
|
+
delta: 4.2,
|
|
371
|
+
higherIsBetter: true,
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
android: [
|
|
375
|
+
{
|
|
376
|
+
key: 'v2s',
|
|
377
|
+
label: 'Visit → signup',
|
|
378
|
+
value: '36.8%',
|
|
379
|
+
delta: 2.1,
|
|
380
|
+
higherIsBetter: true,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
key: 's2a',
|
|
384
|
+
label: 'Signup → activation',
|
|
385
|
+
value: '55.9%',
|
|
386
|
+
delta: 1.4,
|
|
387
|
+
higherIsBetter: true,
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
key: 'a2s',
|
|
391
|
+
label: 'Activation → paid',
|
|
392
|
+
value: '36.8%',
|
|
393
|
+
delta: -2.3,
|
|
394
|
+
higherIsBetter: true,
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
key: 'w4',
|
|
398
|
+
label: 'Week-4 retention',
|
|
399
|
+
value: '38.4%',
|
|
400
|
+
delta: -0.9,
|
|
401
|
+
higherIsBetter: true,
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const SEGMENT_OPTIONS = [
|
|
407
|
+
{value: 'all', label: 'All platforms'},
|
|
408
|
+
{value: 'web', label: 'Web'},
|
|
409
|
+
{value: 'ios', label: 'iOS'},
|
|
410
|
+
{value: 'android', label: 'Android'},
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
// ============= SHARED PIECES =============
|
|
414
|
+
|
|
415
|
+
// Icon's `color` prop only takes semantic names, but the legend swatch must
|
|
416
|
+
// match the exact chart series color — inline color required (same pattern as
|
|
417
|
+
// the shipped dashboard templates).
|
|
418
|
+
function LegendDot({color, label}: {color: string; label: string}) {
|
|
419
|
+
const dotStyle: CSSProperties = {color};
|
|
420
|
+
return (
|
|
421
|
+
<HStack gap={2} vAlign="center">
|
|
422
|
+
<Icon icon={StopIcon} size="xsm" style={dotStyle} />
|
|
423
|
+
<Text type="supporting" color="secondary">
|
|
424
|
+
{label}
|
|
425
|
+
</Text>
|
|
426
|
+
</HStack>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function nf(n: number): string {
|
|
431
|
+
return n.toLocaleString();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ============= KPI TILE =============
|
|
435
|
+
|
|
436
|
+
function KpiTile({kpi}: {kpi: Kpi}) {
|
|
437
|
+
const favorable = kpi.higherIsBetter ? kpi.delta >= 0 : kpi.delta <= 0;
|
|
438
|
+
return (
|
|
439
|
+
<Card padding={4}>
|
|
440
|
+
<VStack gap={1}>
|
|
441
|
+
<Text type="label" color="secondary">
|
|
442
|
+
{kpi.label}
|
|
443
|
+
</Text>
|
|
444
|
+
<HStack gap={3}>
|
|
445
|
+
<Heading level={1}>{kpi.value}</Heading>
|
|
446
|
+
<HStack gap={1} vAlign="center">
|
|
447
|
+
<Icon
|
|
448
|
+
icon={kpi.delta >= 0 ? ArrowUpIcon : ArrowDownIcon}
|
|
449
|
+
size="xsm"
|
|
450
|
+
color={favorable ? 'success' : 'error'}
|
|
451
|
+
/>
|
|
452
|
+
<Text type="supporting" color="secondary">
|
|
453
|
+
{kpi.delta >= 0 ? '+' : ''}
|
|
454
|
+
{kpi.delta.toFixed(1)} pts vs. prior
|
|
455
|
+
</Text>
|
|
456
|
+
</HStack>
|
|
457
|
+
</HStack>
|
|
458
|
+
</VStack>
|
|
459
|
+
</Card>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ============= FUNNEL =============
|
|
464
|
+
|
|
465
|
+
interface FunnelBar {
|
|
466
|
+
key: string;
|
|
467
|
+
label: string;
|
|
468
|
+
users: number;
|
|
469
|
+
pctOfTop: number;
|
|
470
|
+
stepPct: number | null;
|
|
471
|
+
color: string;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
interface FunnelTooltipEntry {
|
|
475
|
+
payload: FunnelBar;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function FunnelTooltip({
|
|
479
|
+
active,
|
|
480
|
+
payload,
|
|
481
|
+
}: {
|
|
482
|
+
active?: boolean;
|
|
483
|
+
payload?: FunnelTooltipEntry[];
|
|
484
|
+
}) {
|
|
485
|
+
if (!active || !payload?.length) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const bar = payload[payload.length - 1].payload;
|
|
489
|
+
return (
|
|
490
|
+
<Card padding={3}>
|
|
491
|
+
<VStack gap={1}>
|
|
492
|
+
<Text type="body" weight="semibold">
|
|
493
|
+
{bar.label}
|
|
494
|
+
</Text>
|
|
495
|
+
<Text type="supporting" color="secondary">
|
|
496
|
+
{nf(bar.users)} users · {bar.pctOfTop.toFixed(1)}% of top
|
|
497
|
+
</Text>
|
|
498
|
+
{bar.stepPct != null && (
|
|
499
|
+
<Text type="supporting" color="secondary">
|
|
500
|
+
{bar.stepPct.toFixed(1)}% from previous step
|
|
501
|
+
</Text>
|
|
502
|
+
)}
|
|
503
|
+
</VStack>
|
|
504
|
+
</Card>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function ConversionFunnel({segment}: {segment: Segment}) {
|
|
509
|
+
const bars = useMemo<FunnelBar[]>(() => {
|
|
510
|
+
const top = FUNNEL_STAGES[0].users[segment];
|
|
511
|
+
return FUNNEL_STAGES.map((stage, i) => {
|
|
512
|
+
const users = stage.users[segment];
|
|
513
|
+
const prev = i > 0 ? FUNNEL_STAGES[i - 1].users[segment] : null;
|
|
514
|
+
return {
|
|
515
|
+
key: stage.key,
|
|
516
|
+
label: stage.label,
|
|
517
|
+
users,
|
|
518
|
+
pctOfTop: (users / top) * 100,
|
|
519
|
+
stepPct: prev != null ? (users / prev) * 100 : null,
|
|
520
|
+
color: FUNNEL_COLORS[i % FUNNEL_COLORS.length],
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
}, [segment]);
|
|
524
|
+
|
|
525
|
+
return (
|
|
526
|
+
<VStack gap={4}>
|
|
527
|
+
<ResponsiveContainer width="100%" height={260}>
|
|
528
|
+
<BarChart
|
|
529
|
+
data={bars}
|
|
530
|
+
layout="vertical"
|
|
531
|
+
margin={{top: 0, right: 24, left: 8, bottom: 0}}
|
|
532
|
+
barCategoryGap={10}>
|
|
533
|
+
<CartesianGrid horizontal={false} vertical stroke={GRID_STROKE} />
|
|
534
|
+
<XAxis
|
|
535
|
+
type="number"
|
|
536
|
+
tick={AXIS_TICK}
|
|
537
|
+
axisLine={false}
|
|
538
|
+
tickLine={false}
|
|
539
|
+
/>
|
|
540
|
+
<YAxis
|
|
541
|
+
type="category"
|
|
542
|
+
dataKey="label"
|
|
543
|
+
tick={AXIS_TICK}
|
|
544
|
+
axisLine={false}
|
|
545
|
+
tickLine={false}
|
|
546
|
+
width={110}
|
|
547
|
+
/>
|
|
548
|
+
<Tooltip content={<FunnelTooltip />} cursor={{fill: GRID_STROKE}} />
|
|
549
|
+
<Bar dataKey="users" radius={4} isAnimationActive={false}>
|
|
550
|
+
{bars.map(bar => (
|
|
551
|
+
<Cell key={bar.key} fill={bar.color} />
|
|
552
|
+
))}
|
|
553
|
+
</Bar>
|
|
554
|
+
</BarChart>
|
|
555
|
+
</ResponsiveContainer>
|
|
556
|
+
</VStack>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Step-by-step drop-off cards beneath the funnel.
|
|
561
|
+
function FunnelSteps({segment}: {segment: Segment}) {
|
|
562
|
+
const steps = useMemo(() => {
|
|
563
|
+
return FUNNEL_STAGES.slice(1).map((stage, i) => {
|
|
564
|
+
const prev = FUNNEL_STAGES[i];
|
|
565
|
+
const from = prev.users[segment];
|
|
566
|
+
const to = stage.users[segment];
|
|
567
|
+
const conv = (to / from) * 100;
|
|
568
|
+
const drop = 100 - conv;
|
|
569
|
+
return {
|
|
570
|
+
key: stage.key,
|
|
571
|
+
label: `${prev.label} → ${stage.label}`,
|
|
572
|
+
conv,
|
|
573
|
+
drop,
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
}, [segment]);
|
|
577
|
+
|
|
578
|
+
return (
|
|
579
|
+
<Grid
|
|
580
|
+
columns={{minWidth: 200, repeat: 'fit'}}
|
|
581
|
+
gap={3}
|
|
582
|
+
xstyle={styles.stepGrid}>
|
|
583
|
+
{steps.map(step => {
|
|
584
|
+
const leaky = step.drop >= 55;
|
|
585
|
+
return (
|
|
586
|
+
<VStack key={step.key} gap={1} xstyle={styles.step}>
|
|
587
|
+
<Text type="label" color="secondary">
|
|
588
|
+
{step.label}
|
|
589
|
+
</Text>
|
|
590
|
+
<HStack gap={2} vAlign="center">
|
|
591
|
+
<Heading level={2}>{step.conv.toFixed(1)}%</Heading>
|
|
592
|
+
<Token
|
|
593
|
+
size="sm"
|
|
594
|
+
color={leaky ? 'red' : 'green'}
|
|
595
|
+
icon={
|
|
596
|
+
<Icon
|
|
597
|
+
icon={ArrowTrendingDownIcon}
|
|
598
|
+
size="xsm"
|
|
599
|
+
color="inherit"
|
|
600
|
+
/>
|
|
601
|
+
}
|
|
602
|
+
label={`-${step.drop.toFixed(0)}%`}
|
|
603
|
+
/>
|
|
604
|
+
</HStack>
|
|
605
|
+
</VStack>
|
|
606
|
+
);
|
|
607
|
+
})}
|
|
608
|
+
</Grid>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ============= CONVERSION TREND =============
|
|
613
|
+
|
|
614
|
+
interface TrendTooltipEntry {
|
|
615
|
+
name: string;
|
|
616
|
+
value: number;
|
|
617
|
+
color: string;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function TrendTooltip({
|
|
621
|
+
active,
|
|
622
|
+
payload,
|
|
623
|
+
label,
|
|
624
|
+
}: {
|
|
625
|
+
active?: boolean;
|
|
626
|
+
payload?: TrendTooltipEntry[];
|
|
627
|
+
label?: number;
|
|
628
|
+
}) {
|
|
629
|
+
if (!active || !payload?.length) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
const wk = typeof label === 'number' ? (TREND[label]?.label ?? '') : '';
|
|
633
|
+
return (
|
|
634
|
+
<Card padding={3}>
|
|
635
|
+
<VStack gap={1}>
|
|
636
|
+
<Text type="supporting" color="secondary">
|
|
637
|
+
{wk}
|
|
638
|
+
</Text>
|
|
639
|
+
{payload.map(entry => (
|
|
640
|
+
<LegendDot
|
|
641
|
+
key={entry.name}
|
|
642
|
+
color={entry.color}
|
|
643
|
+
label={`${entry.name}: ${entry.value.toFixed(1)}%`}
|
|
644
|
+
/>
|
|
645
|
+
))}
|
|
646
|
+
</VStack>
|
|
647
|
+
</Card>
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function ConversionTrend() {
|
|
652
|
+
return (
|
|
653
|
+
<VStack gap={3}>
|
|
654
|
+
<ResponsiveContainer width="100%" height={260}>
|
|
655
|
+
<LineChart
|
|
656
|
+
data={TREND}
|
|
657
|
+
margin={{top: 5, right: 12, left: 0, bottom: 5}}>
|
|
658
|
+
<CartesianGrid horizontal vertical={false} stroke={GRID_STROKE} />
|
|
659
|
+
<XAxis
|
|
660
|
+
dataKey="t"
|
|
661
|
+
type="number"
|
|
662
|
+
domain={[0, TREND.length - 1]}
|
|
663
|
+
ticks={TREND_TICKS}
|
|
664
|
+
tickFormatter={formatTrendTick}
|
|
665
|
+
tick={AXIS_TICK}
|
|
666
|
+
axisLine={false}
|
|
667
|
+
tickLine={false}
|
|
668
|
+
/>
|
|
669
|
+
<YAxis
|
|
670
|
+
tick={AXIS_TICK}
|
|
671
|
+
axisLine={false}
|
|
672
|
+
tickLine={false}
|
|
673
|
+
width={40}
|
|
674
|
+
unit="%"
|
|
675
|
+
/>
|
|
676
|
+
<Tooltip content={<TrendTooltip />} cursor={{stroke: GRID_STROKE}} />
|
|
677
|
+
{TREND_SERIES.map(series => (
|
|
678
|
+
<Line
|
|
679
|
+
key={series.key}
|
|
680
|
+
type="linear"
|
|
681
|
+
dataKey={series.key}
|
|
682
|
+
name={series.name}
|
|
683
|
+
stroke={series.color}
|
|
684
|
+
strokeWidth={1.5}
|
|
685
|
+
strokeLinejoin="miter"
|
|
686
|
+
dot={false}
|
|
687
|
+
isAnimationActive={false}
|
|
688
|
+
/>
|
|
689
|
+
))}
|
|
690
|
+
</LineChart>
|
|
691
|
+
</ResponsiveContainer>
|
|
692
|
+
<HStack gap={5} vAlign="center" wrap="wrap">
|
|
693
|
+
{TREND_SERIES.map(series => (
|
|
694
|
+
<LegendDot
|
|
695
|
+
key={series.key}
|
|
696
|
+
color={series.color}
|
|
697
|
+
label={series.name}
|
|
698
|
+
/>
|
|
699
|
+
))}
|
|
700
|
+
</HStack>
|
|
701
|
+
</VStack>
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ============= COHORT RETENTION GRID =============
|
|
706
|
+
|
|
707
|
+
interface CohortRow extends Record<string, unknown> {
|
|
708
|
+
id: string;
|
|
709
|
+
label: string;
|
|
710
|
+
size: number;
|
|
711
|
+
retention: number[];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function buildCohortColumns(): TableColumn<CohortRow>[] {
|
|
715
|
+
const cols: TableColumn<CohortRow>[] = [
|
|
716
|
+
{
|
|
717
|
+
key: 'label',
|
|
718
|
+
header: 'Cohort',
|
|
719
|
+
width: pixel(150),
|
|
720
|
+
renderCell: (item: CohortRow) => (
|
|
721
|
+
<Text type="body" weight="semibold">
|
|
722
|
+
{item.label}
|
|
723
|
+
</Text>
|
|
724
|
+
),
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
key: 'size',
|
|
728
|
+
header: 'Users',
|
|
729
|
+
width: pixel(90),
|
|
730
|
+
renderCell: (item: CohortRow) => (
|
|
731
|
+
<Text type="body" color="secondary">
|
|
732
|
+
{nf(item.size)}
|
|
733
|
+
</Text>
|
|
734
|
+
),
|
|
735
|
+
},
|
|
736
|
+
];
|
|
737
|
+
for (let week = 0; week < MAX_WEEKS; week++) {
|
|
738
|
+
cols.push({
|
|
739
|
+
key: `w${week}`,
|
|
740
|
+
header: `W${week}`,
|
|
741
|
+
width: pixel(72),
|
|
742
|
+
renderCell: (item: CohortRow) => {
|
|
743
|
+
const pct = item.retention[week];
|
|
744
|
+
if (pct == null) {
|
|
745
|
+
return (
|
|
746
|
+
<Text type="supporting" color="secondary">
|
|
747
|
+
—
|
|
748
|
+
</Text>
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
return (
|
|
752
|
+
<Token size="sm" color={retentionColor(pct)} label={`${pct}%`} />
|
|
753
|
+
);
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
return cols;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const cohortColumns = buildCohortColumns();
|
|
761
|
+
const cohortRows: CohortRow[] = COHORTS.map((c, i) => ({
|
|
762
|
+
id: `c${i}`,
|
|
763
|
+
label: c.label,
|
|
764
|
+
size: c.size,
|
|
765
|
+
retention: c.retention,
|
|
766
|
+
}));
|
|
767
|
+
|
|
768
|
+
// ============= SECTION HEADING =============
|
|
769
|
+
|
|
770
|
+
function SectionHeading({title, hint}: {title: string; hint?: string}) {
|
|
771
|
+
return (
|
|
772
|
+
<VStack>
|
|
773
|
+
<Heading level={2}>{title}</Heading>
|
|
774
|
+
{hint ? (
|
|
775
|
+
<Text type="supporting" color="secondary">
|
|
776
|
+
{hint}
|
|
777
|
+
</Text>
|
|
778
|
+
) : null}
|
|
779
|
+
</VStack>
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ============= MAIN =============
|
|
784
|
+
|
|
785
|
+
export default function FunnelCohortAnalyticsPage() {
|
|
786
|
+
const [segment, setSegment] = useState<Segment>('all');
|
|
787
|
+
const [funnelWindow, setFunnelWindow] = useState<FunnelWindow>('30d');
|
|
788
|
+
|
|
789
|
+
const kpis = KPIS[segment];
|
|
790
|
+
const topOfFunnel = useMemo(() => {
|
|
791
|
+
const base = FUNNEL_STAGES[0].users[segment];
|
|
792
|
+
return Math.round(base * WINDOW_FACTOR[funnelWindow]);
|
|
793
|
+
}, [segment, funnelWindow]);
|
|
794
|
+
|
|
795
|
+
const overallConversion = useMemo(() => {
|
|
796
|
+
const top = FUNNEL_STAGES[0].users[segment];
|
|
797
|
+
const bottom = FUNNEL_STAGES[FUNNEL_STAGES.length - 1].users[segment];
|
|
798
|
+
return ((bottom / top) * 100).toFixed(1);
|
|
799
|
+
}, [segment]);
|
|
800
|
+
|
|
801
|
+
return (
|
|
802
|
+
<Layout
|
|
803
|
+
height="fill"
|
|
804
|
+
padding={4}
|
|
805
|
+
contentWidth={1440}
|
|
806
|
+
header={
|
|
807
|
+
<LayoutHeader hasDivider>
|
|
808
|
+
<HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
|
|
809
|
+
<VStack gap={0}>
|
|
810
|
+
<Heading level={1}>Funnel & cohort analytics</Heading>
|
|
811
|
+
<Text type="supporting" color="secondary">
|
|
812
|
+
{nf(topOfFunnel)} visitors · {overallConversion}% end-to-end
|
|
813
|
+
conversion
|
|
814
|
+
</Text>
|
|
815
|
+
</VStack>
|
|
816
|
+
<HStack gap={2}>
|
|
817
|
+
<SegmentedControl
|
|
818
|
+
label="Funnel window"
|
|
819
|
+
value={funnelWindow}
|
|
820
|
+
onChange={value => setFunnelWindow(value as FunnelWindow)}>
|
|
821
|
+
<SegmentedControlItem label="7d" value="7d" />
|
|
822
|
+
<SegmentedControlItem label="30d" value="30d" />
|
|
823
|
+
<SegmentedControlItem label="90d" value="90d" />
|
|
824
|
+
</SegmentedControl>
|
|
825
|
+
<Selector
|
|
826
|
+
label="Segment"
|
|
827
|
+
isLabelHidden
|
|
828
|
+
options={SEGMENT_OPTIONS}
|
|
829
|
+
value={segment}
|
|
830
|
+
onChange={value => setSegment(value as Segment)}
|
|
831
|
+
/>
|
|
832
|
+
<Button
|
|
833
|
+
label="Export"
|
|
834
|
+
variant="secondary"
|
|
835
|
+
icon={<Icon icon={ArrowDownTrayIcon} size="sm" />}
|
|
836
|
+
/>
|
|
837
|
+
</HStack>
|
|
838
|
+
</HStack>
|
|
839
|
+
</LayoutHeader>
|
|
840
|
+
}
|
|
841
|
+
content={
|
|
842
|
+
<LayoutContent>
|
|
843
|
+
<VStack gap={10}>
|
|
844
|
+
<VStack gap={4}>
|
|
845
|
+
{/* KPI row */}
|
|
846
|
+
<Grid columns={{minWidth: 240, repeat: 'fit'}} gap={3}>
|
|
847
|
+
{kpis.map(kpi => (
|
|
848
|
+
<KpiTile key={kpi.key} kpi={kpi} />
|
|
849
|
+
))}
|
|
850
|
+
</Grid>
|
|
851
|
+
|
|
852
|
+
{/* Conversion funnel */}
|
|
853
|
+
<Card padding={6}>
|
|
854
|
+
<VStack gap={6}>
|
|
855
|
+
<SectionHeading
|
|
856
|
+
title="Conversion funnel"
|
|
857
|
+
hint={`${SEGMENT_OPTIONS.find(o => o.value === segment)?.label} · ${funnelWindow}`}
|
|
858
|
+
/>
|
|
859
|
+
<ConversionFunnel segment={segment} />
|
|
860
|
+
<Divider />
|
|
861
|
+
<FunnelSteps segment={segment} />
|
|
862
|
+
</VStack>
|
|
863
|
+
</Card>
|
|
864
|
+
|
|
865
|
+
{/* Conversion over time */}
|
|
866
|
+
<Card padding={6}>
|
|
867
|
+
<VStack gap={6}>
|
|
868
|
+
<SectionHeading
|
|
869
|
+
title="Conversion over time"
|
|
870
|
+
hint={`Stage conversion rates · trailing ${TREND.length} weeks`}
|
|
871
|
+
/>
|
|
872
|
+
<ConversionTrend />
|
|
873
|
+
</VStack>
|
|
874
|
+
</Card>
|
|
875
|
+
</VStack>
|
|
876
|
+
|
|
877
|
+
<Divider />
|
|
878
|
+
|
|
879
|
+
{/* Cohort retention grid */}
|
|
880
|
+
<VStack gap={6}>
|
|
881
|
+
<SectionHeading
|
|
882
|
+
title="Weekly retention cohorts"
|
|
883
|
+
hint="Signup week × weeks since signup"
|
|
884
|
+
/>
|
|
885
|
+
<Card padding={6}>
|
|
886
|
+
<VStack gap={8}>
|
|
887
|
+
<Table<CohortRow>
|
|
888
|
+
data={cohortRows}
|
|
889
|
+
columns={cohortColumns}
|
|
890
|
+
idKey="id"
|
|
891
|
+
density="balanced"
|
|
892
|
+
dividers="rows"
|
|
893
|
+
/>
|
|
894
|
+
<HStack gap={4} vAlign="center" wrap="wrap">
|
|
895
|
+
<Text type="supporting" color="secondary">
|
|
896
|
+
Retention:
|
|
897
|
+
</Text>
|
|
898
|
+
<HStack gap={2} vAlign="center">
|
|
899
|
+
<Token size="sm" color="red" label="< 32%" />
|
|
900
|
+
<Token size="sm" color="orange" label="32–40%" />
|
|
901
|
+
<Token size="sm" color="yellow" label="40–50%" />
|
|
902
|
+
<Token size="sm" color="teal" label="50–70%" />
|
|
903
|
+
<Token size="sm" color="green" label="≥ 70%" />
|
|
904
|
+
</HStack>
|
|
905
|
+
</HStack>
|
|
906
|
+
</VStack>
|
|
907
|
+
</Card>
|
|
908
|
+
</VStack>
|
|
909
|
+
</VStack>
|
|
910
|
+
</LayoutContent>
|
|
911
|
+
}
|
|
912
|
+
/>
|
|
913
|
+
);
|
|
914
|
+
}
|