Spaces:
Sleeping
Sleeping
File size: 7,180 Bytes
998d987 98b952a 998d987 98b952a 998d987 98b952a 998d987 98b952a 998d987 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | "use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type Point = { t: number; before: number; after: number };
const DROP_REASONS: Record<number, string> = {
6: "Weak hook caused early drop-off β viewers didn't engage with the opening line",
12: "Mid-section lacks pacing β generic advice triggered attention drop",
20: "CTA appeared too early β created friction before value was delivered",
30: "Momentum lost after repositioned CTA β body needs stronger bridge",
45: "Engagement floor reached β retained viewers are highly-interested segment",
60: "End-card CTA fires here β drop is expected at natural completion point",
};
function auc(points: Point[], key: "before" | "after"): number {
let area = 0;
for (let i = 1; i < points.length; i++) {
const dt = points[i].t - points[i - 1].t;
area += ((points[i][key] + points[i - 1][key]) / 2) * dt;
}
return area / (points[points.length - 1].t * 100);
}
interface CustomDotProps {
cx?: number;
cy?: number;
payload?: Point;
dataKey?: string;
activePoint?: number | null;
onHover?: (t: number | null) => void;
}
function CustomDot({ cx = 0, cy = 0, payload, dataKey, activePoint, onHover }: CustomDotProps) {
if (!payload || !onHover) return null;
const isActive = activePoint === payload.t;
const color = dataKey === "after" ? "#8b5cf6" : "#4b3a7a";
return (
<circle
cx={cx}
cy={cy}
r={isActive ? 7 : 4}
fill={color}
stroke="#09080f"
strokeWidth={2}
style={{ cursor: "pointer" }}
onMouseEnter={() => onHover(payload.t)}
onMouseLeave={() => onHover(null)}
/>
);
}
export function RetentionChart({ data }: { data: Point[] }) {
const [activePoint, setActivePoint] = useState<number | null>(null);
const aucBefore = auc(data, "before");
const aucAfter = auc(data, "after");
const dropPoint = data.find((p, i) => i > 0 && p.before < data[i - 1].before - 10);
const dropAfterPoint = data.find((p, i) => i > 0 && p.after < data[i - 1].after - 10);
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-white">Retention Curve (0β60s)</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} onMouseLeave={() => setActivePoint(null)}>
<XAxis
dataKey="t"
tick={{ fill: "#a78bfa", fontSize: 12 }}
label={{ value: "Time (seconds)", position: "insideBottom", offset: -2, fontSize: 11, fill: "#a78bfa" }}
/>
<YAxis
tick={{ fill: "#a78bfa", fontSize: 12 }}
label={{ value: "Viewers (%)", angle: -90, position: "insideLeft", offset: 10, fontSize: 11, fill: "#a78bfa" }}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const t = payload[0].payload.t as number;
const reason = DROP_REASONS[t];
return (
<div className="rounded-xl border border-purple-700/40 bg-[#120f1e] p-3 shadow-md text-xs max-w-[220px]">
<p className="font-semibold mb-1 text-white">t = {t}s</p>
<p className="text-purple-300">Before: {payload.find((p) => p.dataKey === "before")?.value}%</p>
<p className="text-violet-400">After: {payload.find((p) => p.dataKey === "after")?.value}%</p>
{reason && <p className="mt-2 text-purple-400/70 italic">{reason}</p>}
</div>
);
}}
/>
<Line
type="monotone"
dataKey="before"
stroke="#4b3a7a"
strokeWidth={2}
isAnimationActive
animationDuration={800}
dot={(props) => (
<CustomDot {...props} dataKey="before" activePoint={activePoint} onHover={setActivePoint} />
)}
/>
<Line
type="monotone"
dataKey="after"
stroke="#8b5cf6"
strokeWidth={3}
isAnimationActive
animationDuration={1000}
dot={(props) => (
<CustomDot {...props} dataKey="after" activePoint={activePoint} onHover={setActivePoint} />
)}
/>
</LineChart>
</ResponsiveContainer>
</div>
<AnimatePresence>
{activePoint !== null && DROP_REASONS[activePoint] && (
<motion.div
key={activePoint}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="rounded-xl bg-purple-900/40 border border-purple-700/40 px-4 py-2 text-sm text-purple-200"
>
<span className="font-semibold text-white">t={activePoint}s β </span>
{DROP_REASONS[activePoint]}
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-white">Retention Analysis</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3 text-sm">
<div>
<p className="text-xs text-purple-400/70 mb-1">AUC Before β After</p>
<p className="font-bold text-violet-400">
{aucBefore.toFixed(2)} β {aucAfter.toFixed(2)}
</p>
<p className="text-xs text-emerald-400 mt-0.5">
+{(((aucAfter - aucBefore) / aucBefore) * 100).toFixed(0)}% improvement
</p>
</div>
<div>
<p className="text-xs text-purple-400/70 mb-1">First Major Drop</p>
<p className="font-bold text-purple-100">
{dropPoint?.t ?? "β"}s β {dropAfterPoint?.t ?? "β"}s
</p>
<p className="text-xs text-emerald-400 mt-0.5">Drop point moved later</p>
</div>
<div>
<p className="text-xs text-purple-400/70 mb-1">Explanation</p>
<p className="text-purple-200/70 text-xs">
Hook rewrite improved early engagement by delaying the first major drop-off and creating a stronger open loop in the first 6 seconds.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
|