Release 0.14.0-alpha #42
@@ -7,24 +7,9 @@ import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats
|
|||||||
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui'
|
||||||
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { FunnelDetailSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import FunnelChart from '@/components/dashboard/FunnelChart'
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Cell
|
|
||||||
} from 'recharts'
|
|
||||||
import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts'
|
|
||||||
import { getDateRange } from '@ciphera-net/ui'
|
import { getDateRange } from '@ciphera-net/ui'
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
visitors: {
|
|
||||||
label: 'Visitors',
|
|
||||||
color: 'var(--chart-1)',
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig
|
|
||||||
|
|
||||||
export default function FunnelReportPage() {
|
export default function FunnelReportPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -188,56 +173,7 @@ export default function FunnelReportPage() {
|
|||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-6">
|
||||||
Funnel Visualization
|
Funnel Visualization
|
||||||
</h3>
|
</h3>
|
||||||
<ChartContainer config={chartConfig} className="h-[400px] w-full">
|
<FunnelChart steps={chartData} className="h-[350px]" />
|
||||||
<BarChart accessibilityLayer data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" strokeOpacity={0.5} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="name"
|
|
||||||
stroke="var(--chart-axis)"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="var(--chart-axis)"
|
|
||||||
fontSize={12}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
cursor={{ fill: 'transparent' }}
|
|
||||||
content={({ active, payload, label }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const data = payload[0].payload;
|
|
||||||
return (
|
|
||||||
<div className="p-3 rounded-lg shadow-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
|
|
||||||
<p className="font-medium text-neutral-900 dark:text-white mb-1">{label}</p>
|
|
||||||
<p className="text-brand-orange font-bold text-lg">
|
|
||||||
{data.visitors.toLocaleString()} visitors
|
|
||||||
</p>
|
|
||||||
{data.dropoff > 0 && (
|
|
||||||
<p className="text-red-500 text-sm">
|
|
||||||
{Math.round(data.dropoff)}% drop-off
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{data.conversion > 0 && (
|
|
||||||
<p className="text-green-500 text-sm">
|
|
||||||
{Math.round(data.conversion)}% conversion (overall)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="visitors" radius={[6, 6, 0, 0]} barSize={60}>
|
|
||||||
{chartData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill="var(--color-visitors)" fillOpacity={Math.max(0.1, 1 - index * 0.15)} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detailed Stats Table */}
|
{/* Detailed Stats Table */}
|
||||||
|
|||||||
121
components/dashboard/FunnelChart.tsx
Normal file
121
components/dashboard/FunnelChart.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { cn, formatNumber } from '@ciphera-net/ui'
|
||||||
|
|
||||||
|
interface FunnelChartProps {
|
||||||
|
steps: Array<{
|
||||||
|
name: string
|
||||||
|
visitors: number
|
||||||
|
dropoff: number
|
||||||
|
conversion: number
|
||||||
|
}>
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FunnelChart({ steps, className }: FunnelChartProps) {
|
||||||
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
if (!steps.length) return null
|
||||||
|
|
||||||
|
const maxVisitors = steps[0].visitors
|
||||||
|
const n = steps.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-6', className)}>
|
||||||
|
{/* Left labels */}
|
||||||
|
<div className="hidden md:flex flex-col w-40 shrink-0">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center justify-end transition-opacity duration-200',
|
||||||
|
hoveredIndex !== null && hoveredIndex !== i && 'opacity-30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-[11px] font-medium text-neutral-400 dark:text-neutral-500 uppercase tracking-wider">
|
||||||
|
Step {i + 1}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium text-neutral-900 dark:text-white truncate max-w-[152px]">
|
||||||
|
{step.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Funnel segments */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
const topPct = maxVisitors > 0
|
||||||
|
? Math.max(30, (step.visitors / maxVisitors) * 100)
|
||||||
|
: 100
|
||||||
|
const bottomPct = steps[i + 1]
|
||||||
|
? Math.max(30, (steps[i + 1].visitors / maxVisitors) * 100)
|
||||||
|
: topPct * 0.7
|
||||||
|
const topInset = (100 - topPct) / 2
|
||||||
|
const bottomInset = (100 - bottomPct) / 2
|
||||||
|
const opacity = Math.max(0.3, 1 - i * (0.55 / n))
|
||||||
|
const isHovered = hoveredIndex === i
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 relative cursor-default"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `rgba(253, 94, 15, ${isHovered ? Math.min(1, opacity + 0.15) : opacity})`,
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
clipPath: 'polygon(50% 0%, 50% 0%, 50% 100%, 50% 100%)',
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
clipPath: `polygon(${topInset}% 0%, ${100 - topInset}% 0%, ${100 - bottomInset}% 100%, ${bottomInset}% 100%)`,
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
transition={{ delay: i * 0.1, duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
onMouseEnter={() => setHoveredIndex(i)}
|
||||||
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right stats */}
|
||||||
|
<div className="flex flex-col w-28 sm:w-32 shrink-0">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center transition-opacity duration-200',
|
||||||
|
hoveredIndex !== null && hoveredIndex !== i && 'opacity-30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-brand-orange">
|
||||||
|
{formatNumber(step.visitors)}
|
||||||
|
</p>
|
||||||
|
{i > 0 ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px]">
|
||||||
|
<span className="text-red-500 dark:text-red-400 font-medium">
|
||||||
|
↓{Math.round(step.dropoff)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||||
|
{Math.round(step.conversion)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||||
|
baseline
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user