@squadbase/vantage-template 0.0.1
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/README.md +112 -0
- package/base-template/.squadbase/skills/vantage-add-feature/SKILL.md +162 -0
- package/base-template/.squadbase/skills/vantage-app/SKILL.md +120 -0
- package/base-template/.squadbase/skills/vantage-pitfalls/SKILL.md +137 -0
- package/base-template/AGENTS.md +339 -0
- package/base-template/_404.tsx +18 -0
- package/base-template/_error.tsx +14 -0
- package/base-template/_gitignore +5 -0
- package/base-template/_layout.tsx +60 -0
- package/base-template/components/user-card.tsx +132 -0
- package/base-template/index.tsx +59 -0
- package/base-template/package.json +27 -0
- package/base-template/server/api/health.ts +12 -0
- package/base-template/squadbase.yml +5 -0
- package/base-template/styles.css +18 -0
- package/base-template/tsconfig.json +28 -0
- package/chart-presets/berry.css +16 -0
- package/chart-presets/blue.css +16 -0
- package/chart-presets/forest.css +16 -0
- package/chart-presets/mono.css +16 -0
- package/chart-presets/ocean.css +16 -0
- package/chart-presets/sunset.css +16 -0
- package/dist/index.js +1157 -0
- package/package.json +55 -0
- package/ui-templates/CLAUDE.md +200 -0
- package/ui-templates/blank/manifest.json +8 -0
- package/ui-templates/blank/pages/index.tsx +33 -0
- package/ui-templates/blank-ja/manifest.json +8 -0
- package/ui-templates/blank-ja/pages/index.tsx +33 -0
- package/ui-templates/chart-grid/lib/mock-data.ts +139 -0
- package/ui-templates/chart-grid/lib/types.ts +31 -0
- package/ui-templates/chart-grid/manifest.json +10 -0
- package/ui-templates/chart-grid/pages/index.tsx +331 -0
- package/ui-templates/chart-grid/preview-square.png +0 -0
- package/ui-templates/chart-grid/preview-wide.png +0 -0
- package/ui-templates/chart-grid-ja/lib/mock-data.ts +139 -0
- package/ui-templates/chart-grid-ja/lib/types.ts +31 -0
- package/ui-templates/chart-grid-ja/manifest.json +10 -0
- package/ui-templates/chart-grid-ja/pages/index.tsx +331 -0
- package/ui-templates/chart-grid-ja/preview-square.png +0 -0
- package/ui-templates/chart-grid-ja/preview-wide.png +0 -0
- package/ui-templates/funnel/components/chart-helpers.ts +11 -0
- package/ui-templates/funnel/components/stage-table.tsx +63 -0
- package/ui-templates/funnel/lib/mock-data.ts +22 -0
- package/ui-templates/funnel/lib/types.ts +11 -0
- package/ui-templates/funnel/manifest.json +12 -0
- package/ui-templates/funnel/pages/index.tsx +252 -0
- package/ui-templates/funnel/preview-square.png +0 -0
- package/ui-templates/funnel/preview-wide.png +0 -0
- package/ui-templates/funnel-ja/components/chart-helpers.ts +11 -0
- package/ui-templates/funnel-ja/components/stage-table.tsx +63 -0
- package/ui-templates/funnel-ja/lib/mock-data.ts +22 -0
- package/ui-templates/funnel-ja/lib/types.ts +11 -0
- package/ui-templates/funnel-ja/manifest.json +12 -0
- package/ui-templates/funnel-ja/pages/index.tsx +251 -0
- package/ui-templates/funnel-ja/preview-square.png +0 -0
- package/ui-templates/funnel-ja/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-advanced/components/campaign-table.tsx +69 -0
- package/ui-templates/kpi-chart-advanced/lib/mock-data.ts +109 -0
- package/ui-templates/kpi-chart-advanced/lib/types.ts +38 -0
- package/ui-templates/kpi-chart-advanced/manifest.json +11 -0
- package/ui-templates/kpi-chart-advanced/pages/index.tsx +339 -0
- package/ui-templates/kpi-chart-advanced/preview-square.png +0 -0
- package/ui-templates/kpi-chart-advanced/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-advanced-ja/components/campaign-table.tsx +69 -0
- package/ui-templates/kpi-chart-advanced-ja/lib/mock-data.ts +109 -0
- package/ui-templates/kpi-chart-advanced-ja/lib/types.ts +38 -0
- package/ui-templates/kpi-chart-advanced-ja/manifest.json +11 -0
- package/ui-templates/kpi-chart-advanced-ja/pages/index.tsx +333 -0
- package/ui-templates/kpi-chart-advanced-ja/preview-square.png +0 -0
- package/ui-templates/kpi-chart-advanced-ja/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-simple/components/top-items-table.tsx +73 -0
- package/ui-templates/kpi-chart-simple/lib/types.ts +44 -0
- package/ui-templates/kpi-chart-simple/manifest.json +11 -0
- package/ui-templates/kpi-chart-simple/pages/index.tsx +200 -0
- package/ui-templates/kpi-chart-simple/preview-square.png +0 -0
- package/ui-templates/kpi-chart-simple/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-simple/server/kpi-summary.ts +141 -0
- package/ui-templates/kpi-chart-simple-ja/components/top-items-table.tsx +73 -0
- package/ui-templates/kpi-chart-simple-ja/lib/types.ts +44 -0
- package/ui-templates/kpi-chart-simple-ja/manifest.json +11 -0
- package/ui-templates/kpi-chart-simple-ja/pages/index.tsx +200 -0
- package/ui-templates/kpi-chart-simple-ja/preview-square.png +0 -0
- package/ui-templates/kpi-chart-simple-ja/preview-wide.png +0 -0
- package/ui-templates/kpi-chart-simple-ja/server/kpi-summary.ts +141 -0
- package/ui-templates/table-focus/components/detail-table.tsx +88 -0
- package/ui-templates/table-focus/lib/mock-data.ts +69 -0
- package/ui-templates/table-focus/lib/types.ts +24 -0
- package/ui-templates/table-focus/manifest.json +11 -0
- package/ui-templates/table-focus/pages/index.tsx +180 -0
- package/ui-templates/table-focus/preview-square.png +0 -0
- package/ui-templates/table-focus/preview-wide.png +0 -0
- package/ui-templates/table-focus-ja/components/detail-table.tsx +95 -0
- package/ui-templates/table-focus-ja/lib/mock-data.ts +69 -0
- package/ui-templates/table-focus-ja/lib/types.ts +24 -0
- package/ui-templates/table-focus-ja/manifest.json +11 -0
- package/ui-templates/table-focus-ja/pages/index.tsx +180 -0
- package/ui-templates/table-focus-ja/preview-square.png +0 -0
- package/ui-templates/table-focus-ja/preview-wide.png +0 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { subDays } from "date-fns"
|
|
3
|
+
import { TrendingDown, TrendingUp, Users, Target } from "lucide-react"
|
|
4
|
+
import { definePage } from "@squadbase/vantage"
|
|
5
|
+
import {
|
|
6
|
+
DashboardCard,
|
|
7
|
+
DashboardCardHeader,
|
|
8
|
+
DashboardCardTitle,
|
|
9
|
+
DashboardCardAction,
|
|
10
|
+
DashboardCardContent,
|
|
11
|
+
DashboardCardPreset,
|
|
12
|
+
DateRangePicker,
|
|
13
|
+
EChart,
|
|
14
|
+
PageShell,
|
|
15
|
+
PageShellHeader,
|
|
16
|
+
PageShellHeading,
|
|
17
|
+
PageShellTitle,
|
|
18
|
+
PageShellDescription,
|
|
19
|
+
PageShellHeaderEnd,
|
|
20
|
+
PageShellContent,
|
|
21
|
+
Placeholder,
|
|
22
|
+
} from "@squadbase/vantage/components"
|
|
23
|
+
import type { DateRange, EChartsOption } from "@squadbase/vantage/components"
|
|
24
|
+
import { ToggleGroup, ToggleGroupItem } from "@squadbase/vantage/ui"
|
|
25
|
+
|
|
26
|
+
import { StageTable } from "./components/funnel/stage-table"
|
|
27
|
+
import { funnelStages, stageRows } from "./components/funnel/mock-data"
|
|
28
|
+
import type { FunnelStage } from "./components/funnel/types"
|
|
29
|
+
|
|
30
|
+
export const page = definePage({
|
|
31
|
+
title: "Funnel",
|
|
32
|
+
description:
|
|
33
|
+
"Pipeline-centric layout: summary KPIs, a theme-coloured funnel chart with a per-stage sidebar, and a stage-performance table.",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const today = new Date()
|
|
37
|
+
|
|
38
|
+
const STAGE_NAMES = ["Stage 1", "Stage 2", "Stage 3", "Stage 4", "Stage 5"]
|
|
39
|
+
|
|
40
|
+
type DisplayMode = "count" | "percent"
|
|
41
|
+
|
|
42
|
+
function funnelOption(
|
|
43
|
+
data: FunnelStage[],
|
|
44
|
+
labels: string[],
|
|
45
|
+
display: DisplayMode,
|
|
46
|
+
): EChartsOption {
|
|
47
|
+
const max = data[0]?.value ?? 0
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
tooltip: { trigger: "item" },
|
|
51
|
+
series: [
|
|
52
|
+
{
|
|
53
|
+
type: "funnel",
|
|
54
|
+
orient: "horizontal",
|
|
55
|
+
funnelAlign: "center",
|
|
56
|
+
left: "2%",
|
|
57
|
+
right: "2%",
|
|
58
|
+
top: 16,
|
|
59
|
+
bottom: 64,
|
|
60
|
+
min: 0,
|
|
61
|
+
max,
|
|
62
|
+
sort: "descending",
|
|
63
|
+
gap: 4,
|
|
64
|
+
label: {
|
|
65
|
+
show: true,
|
|
66
|
+
position: "bottom",
|
|
67
|
+
formatter: (p: unknown) => {
|
|
68
|
+
const params = p as {
|
|
69
|
+
name: string
|
|
70
|
+
value: number
|
|
71
|
+
data: { conversionRate: number; share: number }
|
|
72
|
+
}
|
|
73
|
+
const main =
|
|
74
|
+
display === "percent"
|
|
75
|
+
? `${params.data.share.toFixed(1)}%`
|
|
76
|
+
: params.value.toLocaleString("en-US")
|
|
77
|
+
return `{name|${params.name}}\n{value|${main}}`
|
|
78
|
+
},
|
|
79
|
+
rich: {
|
|
80
|
+
name: { fontSize: 12, fontWeight: 600, lineHeight: 16, align: "center" },
|
|
81
|
+
value: { fontSize: 11, opacity: 0.6, lineHeight: 14, align: "center" },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
labelLine: { show: false },
|
|
85
|
+
// No `itemStyle.color` and no `option.color`: the stages take
|
|
86
|
+
// `--chart-1..5`, and the labels the foreground token, so the chart
|
|
87
|
+
// rebrands with the theme (v0.2.1+).
|
|
88
|
+
data: data.map((d, i) => ({
|
|
89
|
+
name: labels[i] ?? "",
|
|
90
|
+
value: d.value,
|
|
91
|
+
conversionRate: d.conversionRate,
|
|
92
|
+
share: (d.value / Math.max(1, max)) * 100,
|
|
93
|
+
})),
|
|
94
|
+
},
|
|
95
|
+
] as EChartsOption["series"],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const summaryCards = [
|
|
100
|
+
{
|
|
101
|
+
label: "Entered",
|
|
102
|
+
value: "124,500",
|
|
103
|
+
sub: "Total entering the funnel",
|
|
104
|
+
icon: <Users className="size-4 text-muted-foreground" />,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
label: "Converted",
|
|
108
|
+
value: "1,186",
|
|
109
|
+
sub: "Completed conversions",
|
|
110
|
+
icon: <Target className="size-4 text-muted-foreground" />,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
label: "Best step",
|
|
114
|
+
value: "31.9%",
|
|
115
|
+
sub: "Highest step conversion",
|
|
116
|
+
icon: <TrendingUp className="size-4 text-emerald-600" />,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
label: "Biggest drop",
|
|
120
|
+
value: "68.2%",
|
|
121
|
+
sub: "Largest step drop-off",
|
|
122
|
+
icon: <TrendingDown className="size-4 text-rose-600" />,
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
export default function HomePage() {
|
|
127
|
+
const [dateRange, setDateRange] = useState<DateRange>({
|
|
128
|
+
from: subDays(today, 29),
|
|
129
|
+
to: today,
|
|
130
|
+
})
|
|
131
|
+
const [display, setDisplay] = useState<DisplayMode>("count")
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<PageShell>
|
|
135
|
+
<PageShellHeader>
|
|
136
|
+
<PageShellHeading>
|
|
137
|
+
<PageShellTitle>[Template] Conversion funnel</PageShellTitle>
|
|
138
|
+
<PageShellDescription>
|
|
139
|
+
Stage-by-stage conversion for the selected period.
|
|
140
|
+
</PageShellDescription>
|
|
141
|
+
</PageShellHeading>
|
|
142
|
+
<PageShellHeaderEnd className="flex-row items-center gap-2">
|
|
143
|
+
{/* Base UI toggle groups are always multi-value — keep the array at length 1. */}
|
|
144
|
+
<ToggleGroup
|
|
145
|
+
value={[display]}
|
|
146
|
+
onValueChange={(value) => {
|
|
147
|
+
const next = value[0]
|
|
148
|
+
if (next) setDisplay(next as DisplayMode)
|
|
149
|
+
}}
|
|
150
|
+
variant="outline"
|
|
151
|
+
size="sm"
|
|
152
|
+
>
|
|
153
|
+
<ToggleGroupItem value="count">Option A</ToggleGroupItem>
|
|
154
|
+
<ToggleGroupItem value="percent">Option B</ToggleGroupItem>
|
|
155
|
+
</ToggleGroup>
|
|
156
|
+
<DateRangePicker
|
|
157
|
+
value={dateRange}
|
|
158
|
+
onChange={setDateRange}
|
|
159
|
+
maxDate={today}
|
|
160
|
+
/>
|
|
161
|
+
</PageShellHeaderEnd>
|
|
162
|
+
</PageShellHeader>
|
|
163
|
+
|
|
164
|
+
<PageShellContent className="space-y-6">
|
|
165
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
166
|
+
{summaryCards.map(({ label, value, sub, icon }) => (
|
|
167
|
+
<DashboardCard key={label}>
|
|
168
|
+
<DashboardCardHeader>
|
|
169
|
+
<DashboardCardTitle className="text-muted-foreground">
|
|
170
|
+
{label}
|
|
171
|
+
</DashboardCardTitle>
|
|
172
|
+
<DashboardCardAction>{icon}</DashboardCardAction>
|
|
173
|
+
</DashboardCardHeader>
|
|
174
|
+
<DashboardCardContent>
|
|
175
|
+
<Placeholder className="text-2xl font-bold">{value}</Placeholder>
|
|
176
|
+
<p className="mt-2 text-xs text-muted-foreground">{sub}</p>
|
|
177
|
+
</DashboardCardContent>
|
|
178
|
+
</DashboardCard>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div className="grid gap-4 lg:grid-cols-3">
|
|
183
|
+
<div className="lg:col-span-2">
|
|
184
|
+
<DashboardCardPreset
|
|
185
|
+
title="Conversion funnel"
|
|
186
|
+
description="Stage-by-stage conversion"
|
|
187
|
+
>
|
|
188
|
+
<EChart
|
|
189
|
+
option={funnelOption(funnelStages, STAGE_NAMES, display)}
|
|
190
|
+
height="440px"
|
|
191
|
+
/>
|
|
192
|
+
</DashboardCardPreset>
|
|
193
|
+
</div>
|
|
194
|
+
<DashboardCardPreset title="Stages" description="Conversion by stage">
|
|
195
|
+
<StagesSidebar />
|
|
196
|
+
</DashboardCardPreset>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<DashboardCardPreset
|
|
200
|
+
title="Stage breakdown"
|
|
201
|
+
description="Conversion between stages"
|
|
202
|
+
>
|
|
203
|
+
<StageTable data={stageRows} />
|
|
204
|
+
</DashboardCardPreset>
|
|
205
|
+
</PageShellContent>
|
|
206
|
+
</PageShell>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function StagesSidebar() {
|
|
211
|
+
return (
|
|
212
|
+
<ol className="space-y-4">
|
|
213
|
+
{funnelStages.map((s, i) => {
|
|
214
|
+
const prev = i > 0 ? funnelStages[i - 1] : null
|
|
215
|
+
const dropoffPct = prev ? ((prev.value - s.value) / prev.value) * 100 : 0
|
|
216
|
+
return (
|
|
217
|
+
<li key={i} className="space-y-2">
|
|
218
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
219
|
+
<div className="flex items-baseline gap-2 min-w-0">
|
|
220
|
+
<span className="text-xs font-semibold text-muted-foreground tabular-nums">
|
|
221
|
+
{String(i + 1).padStart(2, "0")}
|
|
222
|
+
</span>
|
|
223
|
+
<span className="truncate text-sm font-medium">{STAGE_NAMES[i]}</span>
|
|
224
|
+
</div>
|
|
225
|
+
<Placeholder className="text-sm font-medium">
|
|
226
|
+
{s.value.toLocaleString("en-US")}
|
|
227
|
+
</Placeholder>
|
|
228
|
+
</div>
|
|
229
|
+
<div className="grid grid-cols-2 gap-3 text-xs text-muted-foreground">
|
|
230
|
+
<span>Conversion rate</span>
|
|
231
|
+
<div className="flex justify-end">
|
|
232
|
+
<Placeholder>{`${s.conversionRate.toFixed(1)}%`}</Placeholder>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
{prev && (
|
|
236
|
+
<div className="flex h-1.5 overflow-hidden rounded-full bg-muted">
|
|
237
|
+
<div
|
|
238
|
+
className="h-full bg-chart-1"
|
|
239
|
+
style={{ width: `${s.conversionRate.toFixed(1)}%` }}
|
|
240
|
+
/>
|
|
241
|
+
<div
|
|
242
|
+
className="h-full bg-muted-foreground/30"
|
|
243
|
+
style={{ width: `${dropoffPct.toFixed(1)}%` }}
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</li>
|
|
248
|
+
)
|
|
249
|
+
})}
|
|
250
|
+
</ol>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function formatNumber(n: number): string {
|
|
2
|
+
const sign = n < 0 ? "-" : ""
|
|
3
|
+
const abs = Math.abs(n)
|
|
4
|
+
if (abs >= 100_000_000) return `${sign}${(abs / 100_000_000).toFixed(1)}億`
|
|
5
|
+
if (abs >= 10_000) return `${sign}${(abs / 10_000).toFixed(0)}万`
|
|
6
|
+
return n.toLocaleString("ja-JP")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatPercent(value: number, digits = 1): string {
|
|
10
|
+
return `${value.toFixed(digits)}%`
|
|
11
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
import { DataTablePreset, Placeholder } from "@squadbase/vantage/components"
|
|
3
|
+
import type { ColumnDef } from "@squadbase/vantage/components"
|
|
4
|
+
|
|
5
|
+
import { formatNumber, formatPercent } from "./chart-helpers"
|
|
6
|
+
import type { StageRow } from "./types"
|
|
7
|
+
|
|
8
|
+
const STEP_NAMES = ["ステージ1 → 2", "ステージ2 → 3", "ステージ3 → 4", "ステージ4 → 5"]
|
|
9
|
+
|
|
10
|
+
interface StageTableProps {
|
|
11
|
+
data: StageRow[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function StageTable({ data }: StageTableProps) {
|
|
15
|
+
const columns = useMemo<ColumnDef<StageRow>[]>(
|
|
16
|
+
() => [
|
|
17
|
+
{
|
|
18
|
+
id: "step",
|
|
19
|
+
header: "列1",
|
|
20
|
+
cell: ({ row }) => STEP_NAMES[row.index],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
accessorKey: "count",
|
|
24
|
+
header: "列2",
|
|
25
|
+
cell: ({ row }) => (
|
|
26
|
+
<div className="flex justify-end">
|
|
27
|
+
<Placeholder>{formatNumber(row.original.count)}</Placeholder>
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
accessorKey: "conversionRate",
|
|
33
|
+
header: "列3",
|
|
34
|
+
cell: ({ row }) => (
|
|
35
|
+
<div className="flex justify-end">
|
|
36
|
+
<Placeholder>{formatPercent(row.original.conversionRate)}</Placeholder>
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
accessorKey: "avgDays",
|
|
42
|
+
header: "列4",
|
|
43
|
+
cell: ({ row }) => (
|
|
44
|
+
<div className="flex justify-end">
|
|
45
|
+
<Placeholder>{`${row.original.avgDays.toFixed(1)}日`}</Placeholder>
|
|
46
|
+
</div>
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
accessorKey: "dropoff",
|
|
51
|
+
header: "列5",
|
|
52
|
+
cell: ({ row }) => (
|
|
53
|
+
<div className="flex justify-end">
|
|
54
|
+
<Placeholder>{formatNumber(row.original.dropoff)}</Placeholder>
|
|
55
|
+
</div>
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
[],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return <DataTablePreset columns={columns} data={data} enableSorting />
|
|
63
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FunnelStage, StageRow } from "./types"
|
|
2
|
+
|
|
3
|
+
const VISITORS = 124_500
|
|
4
|
+
const LEADS = 38_240
|
|
5
|
+
const QUALIFIED = 12_180
|
|
6
|
+
const OPPORTUNITIES = 4_520
|
|
7
|
+
const CONVERSIONS = 1_186
|
|
8
|
+
|
|
9
|
+
export const funnelStages: FunnelStage[] = [
|
|
10
|
+
{ value: VISITORS, conversionRate: 100 },
|
|
11
|
+
{ value: LEADS, conversionRate: (LEADS / VISITORS) * 100 },
|
|
12
|
+
{ value: QUALIFIED, conversionRate: (QUALIFIED / LEADS) * 100 },
|
|
13
|
+
{ value: OPPORTUNITIES, conversionRate: (OPPORTUNITIES / QUALIFIED) * 100 },
|
|
14
|
+
{ value: CONVERSIONS, conversionRate: (CONVERSIONS / OPPORTUNITIES) * 100 },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export const stageRows: StageRow[] = [
|
|
18
|
+
{ count: LEADS, conversionRate: (LEADS / VISITORS) * 100, avgDays: 0.6, dropoff: VISITORS - LEADS },
|
|
19
|
+
{ count: QUALIFIED, conversionRate: (QUALIFIED / LEADS) * 100, avgDays: 3.2, dropoff: LEADS - QUALIFIED },
|
|
20
|
+
{ count: OPPORTUNITIES, conversionRate: (OPPORTUNITIES / QUALIFIED) * 100, avgDays: 7.4, dropoff: QUALIFIED - OPPORTUNITIES },
|
|
21
|
+
{ count: CONVERSIONS, conversionRate: (CONVERSIONS / OPPORTUNITIES) * 100, avgDays: 12.8, dropoff: OPPORTUNITIES - CONVERSIONS },
|
|
22
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "funnel-ja",
|
|
3
|
+
"description": "[日本語] ファネル — 上部に 4 つのサマリ KPI (ファネル流入 / エンドツーエンド転換率 / 最良ステージ / ボトルネック)、その下に左揃えでテーマ配色 (--chart-1..5) のファネルとステージ別「通過率 + 離脱」サイドバー、最下段にステージ別パフォーマンステーブル。コンバージョン/パイプライン分析向け",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"files": [
|
|
6
|
+
{ "src": "pages/index.tsx", "dest": "index.tsx", "action": "replace" },
|
|
7
|
+
{ "src": "components/stage-table.tsx", "dest": "components/funnel/stage-table.tsx", "action": "add" },
|
|
8
|
+
{ "src": "components/chart-helpers.ts", "dest": "components/funnel/chart-helpers.ts", "action": "add" },
|
|
9
|
+
{ "src": "lib/mock-data.ts", "dest": "components/funnel/mock-data.ts", "action": "add", "relabel": false },
|
|
10
|
+
{ "src": "lib/types.ts", "dest": "components/funnel/types.ts", "action": "add", "relabel": false }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { subDays } from "date-fns"
|
|
3
|
+
import { TrendingDown, TrendingUp, Users, Target } from "lucide-react"
|
|
4
|
+
import { definePage } from "@squadbase/vantage"
|
|
5
|
+
import {
|
|
6
|
+
DashboardCard,
|
|
7
|
+
DashboardCardHeader,
|
|
8
|
+
DashboardCardTitle,
|
|
9
|
+
DashboardCardAction,
|
|
10
|
+
DashboardCardContent,
|
|
11
|
+
DashboardCardPreset,
|
|
12
|
+
DateRangePicker,
|
|
13
|
+
EChart,
|
|
14
|
+
PageShell,
|
|
15
|
+
PageShellHeader,
|
|
16
|
+
PageShellHeading,
|
|
17
|
+
PageShellTitle,
|
|
18
|
+
PageShellDescription,
|
|
19
|
+
PageShellHeaderEnd,
|
|
20
|
+
PageShellContent,
|
|
21
|
+
Placeholder,
|
|
22
|
+
} from "@squadbase/vantage/components"
|
|
23
|
+
import type { DateRange, EChartsOption } from "@squadbase/vantage/components"
|
|
24
|
+
import { ToggleGroup, ToggleGroupItem } from "@squadbase/vantage/ui"
|
|
25
|
+
|
|
26
|
+
import { StageTable } from "./components/funnel/stage-table"
|
|
27
|
+
import { funnelStages, stageRows } from "./components/funnel/mock-data"
|
|
28
|
+
import type { FunnelStage } from "./components/funnel/types"
|
|
29
|
+
|
|
30
|
+
export const page = definePage({
|
|
31
|
+
title: "ファネル",
|
|
32
|
+
description:
|
|
33
|
+
"パイプライン中心のレイアウト。サマリ KPI、テーマ配色のファネルとステージ別サイドバー、ステージ別パフォーマンステーブル。",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const today = new Date()
|
|
37
|
+
|
|
38
|
+
const STAGE_NAMES = ["ステージ1", "ステージ2", "ステージ3", "ステージ4", "ステージ5"]
|
|
39
|
+
|
|
40
|
+
type DisplayMode = "count" | "percent"
|
|
41
|
+
|
|
42
|
+
function funnelOption(
|
|
43
|
+
data: FunnelStage[],
|
|
44
|
+
labels: string[],
|
|
45
|
+
display: DisplayMode,
|
|
46
|
+
): EChartsOption {
|
|
47
|
+
const max = data[0]?.value ?? 0
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
tooltip: { trigger: "item" },
|
|
51
|
+
series: [
|
|
52
|
+
{
|
|
53
|
+
type: "funnel",
|
|
54
|
+
orient: "horizontal",
|
|
55
|
+
funnelAlign: "center",
|
|
56
|
+
left: "2%",
|
|
57
|
+
right: "2%",
|
|
58
|
+
top: 16,
|
|
59
|
+
bottom: 64,
|
|
60
|
+
min: 0,
|
|
61
|
+
max,
|
|
62
|
+
sort: "descending",
|
|
63
|
+
gap: 4,
|
|
64
|
+
label: {
|
|
65
|
+
show: true,
|
|
66
|
+
position: "bottom",
|
|
67
|
+
formatter: (p: unknown) => {
|
|
68
|
+
const params = p as {
|
|
69
|
+
name: string
|
|
70
|
+
value: number
|
|
71
|
+
data: { conversionRate: number; share: number }
|
|
72
|
+
}
|
|
73
|
+
const main =
|
|
74
|
+
display === "percent"
|
|
75
|
+
? `${params.data.share.toFixed(1)}%`
|
|
76
|
+
: params.value.toLocaleString("ja-JP")
|
|
77
|
+
return `{name|${params.name}}\n{value|${main}}`
|
|
78
|
+
},
|
|
79
|
+
rich: {
|
|
80
|
+
name: { fontSize: 12, fontWeight: 600, lineHeight: 16, align: "center" },
|
|
81
|
+
value: { fontSize: 11, opacity: 0.6, lineHeight: 14, align: "center" },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
labelLine: { show: false },
|
|
85
|
+
// itemStyle.color も option.color も持たせない: ステージは --chart-1..5、
|
|
86
|
+
// ラベルは文字色トークンで描かれ、テーマ差し替えに追従する (v0.2.1〜)。
|
|
87
|
+
data: data.map((d, i) => ({
|
|
88
|
+
name: labels[i] ?? "",
|
|
89
|
+
value: d.value,
|
|
90
|
+
conversionRate: d.conversionRate,
|
|
91
|
+
share: (d.value / Math.max(1, max)) * 100,
|
|
92
|
+
})),
|
|
93
|
+
},
|
|
94
|
+
] as EChartsOption["series"],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const summaryCards = [
|
|
99
|
+
{
|
|
100
|
+
label: "流入数",
|
|
101
|
+
value: "124,500",
|
|
102
|
+
sub: "ファネルへの総流入数",
|
|
103
|
+
icon: <Users className="size-4 text-muted-foreground" />,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
label: "コンバージョン数",
|
|
107
|
+
value: "1,186",
|
|
108
|
+
sub: "完了したコンバージョン",
|
|
109
|
+
icon: <Target className="size-4 text-muted-foreground" />,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
label: "最良ステップ",
|
|
113
|
+
value: "31.9%",
|
|
114
|
+
sub: "最も高いステップ転換率",
|
|
115
|
+
icon: <TrendingUp className="size-4 text-emerald-600" />,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
label: "最大の離脱",
|
|
119
|
+
value: "68.2%",
|
|
120
|
+
sub: "最大のステップ離脱率",
|
|
121
|
+
icon: <TrendingDown className="size-4 text-rose-600" />,
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
export default function HomePage() {
|
|
126
|
+
const [dateRange, setDateRange] = useState<DateRange>({
|
|
127
|
+
from: subDays(today, 29),
|
|
128
|
+
to: today,
|
|
129
|
+
})
|
|
130
|
+
const [display, setDisplay] = useState<DisplayMode>("count")
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<PageShell>
|
|
134
|
+
<PageShellHeader>
|
|
135
|
+
<PageShellHeading>
|
|
136
|
+
<PageShellTitle>[テンプレート] コンバージョンファネル</PageShellTitle>
|
|
137
|
+
<PageShellDescription>
|
|
138
|
+
選択期間のステージ別コンバージョン。
|
|
139
|
+
</PageShellDescription>
|
|
140
|
+
</PageShellHeading>
|
|
141
|
+
<PageShellHeaderEnd className="flex-row items-center gap-2">
|
|
142
|
+
{/* Base UI の ToggleGroup は常に配列。単一選択は長さ 1 に保つ。 */}
|
|
143
|
+
<ToggleGroup
|
|
144
|
+
value={[display]}
|
|
145
|
+
onValueChange={(value) => {
|
|
146
|
+
const next = value[0]
|
|
147
|
+
if (next) setDisplay(next as DisplayMode)
|
|
148
|
+
}}
|
|
149
|
+
variant="outline"
|
|
150
|
+
size="sm"
|
|
151
|
+
>
|
|
152
|
+
<ToggleGroupItem value="count">選択肢A</ToggleGroupItem>
|
|
153
|
+
<ToggleGroupItem value="percent">選択肢B</ToggleGroupItem>
|
|
154
|
+
</ToggleGroup>
|
|
155
|
+
<DateRangePicker
|
|
156
|
+
value={dateRange}
|
|
157
|
+
onChange={setDateRange}
|
|
158
|
+
maxDate={today}
|
|
159
|
+
/>
|
|
160
|
+
</PageShellHeaderEnd>
|
|
161
|
+
</PageShellHeader>
|
|
162
|
+
|
|
163
|
+
<PageShellContent className="space-y-6">
|
|
164
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
165
|
+
{summaryCards.map(({ label, value, sub, icon }) => (
|
|
166
|
+
<DashboardCard key={label}>
|
|
167
|
+
<DashboardCardHeader>
|
|
168
|
+
<DashboardCardTitle className="text-muted-foreground">
|
|
169
|
+
{label}
|
|
170
|
+
</DashboardCardTitle>
|
|
171
|
+
<DashboardCardAction>{icon}</DashboardCardAction>
|
|
172
|
+
</DashboardCardHeader>
|
|
173
|
+
<DashboardCardContent>
|
|
174
|
+
<Placeholder className="text-2xl font-bold">{value}</Placeholder>
|
|
175
|
+
<p className="mt-2 text-xs text-muted-foreground">{sub}</p>
|
|
176
|
+
</DashboardCardContent>
|
|
177
|
+
</DashboardCard>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div className="grid gap-4 lg:grid-cols-3">
|
|
182
|
+
<div className="lg:col-span-2">
|
|
183
|
+
<DashboardCardPreset
|
|
184
|
+
title="コンバージョンファネル"
|
|
185
|
+
description="ステージ別コンバージョン"
|
|
186
|
+
>
|
|
187
|
+
<EChart
|
|
188
|
+
option={funnelOption(funnelStages, STAGE_NAMES, display)}
|
|
189
|
+
height="440px"
|
|
190
|
+
/>
|
|
191
|
+
</DashboardCardPreset>
|
|
192
|
+
</div>
|
|
193
|
+
<DashboardCardPreset title="ステージ" description="ステージ別コンバージョン">
|
|
194
|
+
<StagesSidebar />
|
|
195
|
+
</DashboardCardPreset>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<DashboardCardPreset
|
|
199
|
+
title="ステージ内訳"
|
|
200
|
+
description="ステージ間のコンバージョン"
|
|
201
|
+
>
|
|
202
|
+
<StageTable data={stageRows} />
|
|
203
|
+
</DashboardCardPreset>
|
|
204
|
+
</PageShellContent>
|
|
205
|
+
</PageShell>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function StagesSidebar() {
|
|
210
|
+
return (
|
|
211
|
+
<ol className="space-y-4">
|
|
212
|
+
{funnelStages.map((s, i) => {
|
|
213
|
+
const prev = i > 0 ? funnelStages[i - 1] : null
|
|
214
|
+
const dropoffPct = prev ? ((prev.value - s.value) / prev.value) * 100 : 0
|
|
215
|
+
return (
|
|
216
|
+
<li key={i} className="space-y-2">
|
|
217
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
218
|
+
<div className="flex items-baseline gap-2 min-w-0">
|
|
219
|
+
<span className="text-xs font-semibold text-muted-foreground tabular-nums">
|
|
220
|
+
{String(i + 1).padStart(2, "0")}
|
|
221
|
+
</span>
|
|
222
|
+
<span className="truncate text-sm font-medium">{STAGE_NAMES[i]}</span>
|
|
223
|
+
</div>
|
|
224
|
+
<Placeholder className="text-sm font-medium">
|
|
225
|
+
{s.value.toLocaleString("ja-JP")}
|
|
226
|
+
</Placeholder>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="grid grid-cols-2 gap-3 text-xs text-muted-foreground">
|
|
229
|
+
<span>転換率</span>
|
|
230
|
+
<div className="flex justify-end">
|
|
231
|
+
<Placeholder>{`${s.conversionRate.toFixed(1)}%`}</Placeholder>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
{prev && (
|
|
235
|
+
<div className="flex h-1.5 overflow-hidden rounded-full bg-muted">
|
|
236
|
+
<div
|
|
237
|
+
className="h-full bg-chart-1"
|
|
238
|
+
style={{ width: `${s.conversionRate.toFixed(1)}%` }}
|
|
239
|
+
/>
|
|
240
|
+
<div
|
|
241
|
+
className="h-full bg-muted-foreground/30"
|
|
242
|
+
style={{ width: `${dropoffPct.toFixed(1)}%` }}
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</li>
|
|
247
|
+
)
|
|
248
|
+
})}
|
|
249
|
+
</ol>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
Binary file
|
|
Binary file
|