// Customer Journey — animated path through the retention lifecycle. // A dotted hand-drawn path connects 5 stages; an orange "customer" dot // travels along it; emails pop up at each stage as the dot passes. const { useEffect, useRef, useState } = React; const JOURNEY_GEO = [ { id: "discovery", icon: "👀", x: 6, y: 50 }, { id: "first", icon: "🛍️", x: 28, y: 22 }, { id: "engage", icon: "✦", x: 52, y: 68 }, { id: "loyal", icon: "♛", x: 76, y: 26 }, { id: "advocate", icon: "♡", x: 96, y: 58 } ]; function JourneyTrack() { const jc = useCopy().journey; const JOURNEY_STAGES = JOURNEY_GEO.map((g, i) => Object.assign({}, g, jc.stages[i])); const trackRef = useRef(null); const [progress, setProgress] = useState(0); // 0..1 const [playing, setPlaying] = useState(true); const rafRef = useRef(null); const lastTs = useRef(0); useEffect(() => { if (!playing) return; const DURATION = 12000; // ms full loop const tick = (ts) => { if (!lastTs.current) lastTs.current = ts; const dt = ts - lastTs.current; lastTs.current = ts; setProgress((p) => { let np = p + dt / DURATION; if (np > 1) np = 0; return np; }); rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { cancelAnimationFrame(rafRef.current); lastTs.current = 0; }; }, [playing]); // Compute dot position by interpolating between stages along their x/y const dot = (() => { const segs = JOURNEY_STAGES.length - 1; const t = progress * segs; const i = Math.min(Math.floor(t), segs - 1); const f = t - i; const a = JOURNEY_STAGES[i]; const b = JOURNEY_STAGES[i + 1]; // Use a sine-ease bend so motion feels organic const ease = (1 - Math.cos(f * Math.PI)) / 2; return { x: a.x + (b.x - a.x) * ease, y: a.y + (b.y - a.y) * ease, activeIdx: f > 0.5 ? i + 1 : i, neighborhood: JOURNEY_STAGES.reduce((acc, s, idx) => { // each stage 'lights' as the dot is near its x const dist = Math.abs(s.x - (a.x + (b.x - a.x) * ease)); acc[idx] = dist < 12 ? 1 : 0; return acc; }, {}) }; })(); // SVG path: smooth curve through all stage points const pathD = (() => { const pts = JOURNEY_STAGES.map((s) => ({ x: s.x, y: s.y })); if (pts.length < 2) return ""; let d = `M ${pts[0].x} ${pts[0].y}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i]; const p1 = pts[i + 1]; const mx = (p0.x + p1.x) / 2; // alternate curve direction for hand-drawn feel const cy = i % 2 === 0 ? p0.y - 16 : p0.y + 16; d += ` Q ${mx} ${cy}, ${p1.x} ${p1.y}`; } return d; })(); // Progress path stroke-dash for the traced segment const pathRef = useRef(null); const [pathLen, setPathLen] = useState(0); useEffect(() => { if (pathRef.current) { setPathLen(pathRef.current.getTotalLength()); } }, []); return (
Customer Journey
{jc.boardTitle}
{/* dashed background path */} {/* traced overlay */} {pathLen > 0 && ( )} {/* stages */} {JOURNEY_STAGES.map((s, i) => { const active = dot.activeIdx >= i; return (
{s.stage}
{s.icon}
{s.title}
{s.sub}
); })} {/* email pops */} {JOURNEY_STAGES.map((s, i) => { const show = dot.neighborhood[i] === 1; // place email pop offset from stage, alternating up/down const offY = (i % 2 === 0) ? 18 : -22; return (
{s.email.from}
{s.email.subj}
); })} {/* moving dot */}
{jc.legend} {jc.legendDot} {jc.legendPath}
); } Object.assign(window, { JourneyTrack });