import { useEffect, useState } from "react"; import { ConfettiBurst } from "./ConfettiBurst"; type Props = { size?: number; strokeWidth?: number; previousXP: number; gainedXP: number; levelMinXP: number; levelMaxXP: number; level: number; }; const STYLES = ` @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap'); .clp-wrap { width: 100%; font-family: 'Nunito', sans-serif; } /* Outer card — full width */ .clp-card { width: 100%; background: white; border: 2.5px solid #f3f4f6; border-radius: 24px; padding: 1.25rem 1.5rem; box-shadow: 0 6px 24px rgba(0,0,0,0.05); display: flex; flex-direction: column; gap: 0.85rem; box-sizing: border-box; } /* Top row: level badge + XP gained chip */ .clp-top-row { display: flex; align-items: center; justify-content: space-between; } .clp-level-badge { display: flex; align-items: center; gap: 0.6rem; } .clp-level-bubble { width: 52px; height: 52px; border-radius: 50%; background: linear-gradient(135deg, #c084fc, #a855f7); display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 0 #7e22ce44; flex-shrink: 0; } .clp-level-num { font-size: 1.5rem; font-weight: 900; color: white; line-height: 1; letter-spacing: -0.02em; } .clp-level-text { display: flex; flex-direction: column; gap: 1px; } .clp-level-word { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: #9ca3af; } .clp-level-title { font-size: 1rem; font-weight: 900; color: #1e1b4b; line-height: 1; } /* XP gained chip */ .clp-xp-chip { display: flex; align-items: center; gap: 0.35rem; background: #fff7ed; border: 2px solid #fed7aa; border-radius: 100px; padding: 0.4rem 0.9rem; font-size: 0.82rem; font-weight: 800; color: #f97316; } /* Bar section */ .clp-bar-wrap { width: 100%; display: flex; flex-direction: column; gap: 0.4rem; } .clp-bar-labels { display: flex; justify-content: space-between; font-size: 0.66rem; font-weight: 700; color: #9ca3af; } .clp-bar-track { width: 100%; height: 12px; background: #f3f4f6; border-radius: 100px; overflow: hidden; } .clp-bar-fill { height: 100%; border-radius: 100px; background: linear-gradient(90deg, #c084fc, #f97316); transition: width 1.2s cubic-bezier(0.4,0,0.2,1); } /* XP total */ .clp-xp-pill { display: flex; align-items: center; gap: 0.4rem; font-size: 0.72rem; font-weight: 700; color: #9ca3af; animation: clpFadeUp 0.5s cubic-bezier(0.34,1.56,0.64,1) both; } .clp-xp-pill .xp-dot { width: 7px; height: 7px; border-radius: 50%; background: #f97316; flex-shrink: 0; } /* Level-up banner */ .clp-levelup { display: flex; align-items: center; justify-content: center; gap: 0.5rem; background: #fdf4ff; border: 2.5px solid #e9d5ff; border-radius: 14px; padding: 0.6rem 1rem; font-size: 0.85rem; font-weight: 900; color: #9333ea; animation: clpPop 0.45s cubic-bezier(0.34,1.56,0.64,1) both; box-shadow: 0 4px 12px rgba(147,51,234,0.1); } @keyframes clpPop { from { opacity:0; transform: scale(0.8); } to { opacity:1; transform: scale(1); } } @keyframes clpFadeUp { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: translateY(0); } } `; export const CircularLevelProgress = ({ previousXP, gainedXP, levelMinXP, levelMaxXP, level, }: Props) => { const levelRange = levelMaxXP - levelMinXP; const normalize = (xp: number) => Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange; const [barProgress, setBarProgress] = useState(normalize(previousXP)); const [currentLevel, setCurrentLevel] = useState(level); const [showLevelUp, setShowLevelUp] = useState(false); const [showXPTotal, setShowXPTotal] = useState(false); useEffect(() => { let animationFrame: number; let start: number | null = null; const availableXP = previousXP + gainedXP; const crossesLevel = availableXP >= levelMaxXP; const phase1Target = crossesLevel ? 1 : normalize(availableXP); const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0; const duration = 1200; const animatePhase1 = (timestamp: number) => { if (!start) start = timestamp; const t = Math.min((timestamp - start) / duration, 1); setBarProgress( normalize(previousXP) + t * (phase1Target - normalize(previousXP)), ); if (t < 1) { animationFrame = requestAnimationFrame(animatePhase1); } else if (crossesLevel) { setShowLevelUp(true); setTimeout(startPhase2, 1200); } else { setShowXPTotal(true); } }; const startPhase2 = () => { start = null; setShowLevelUp(false); setCurrentLevel((l) => l + 1); setBarProgress(0); const target = Math.min(leftoverXP / levelRange, 1); const animatePhase2 = (timestamp: number) => { if (!start) start = timestamp; const t = Math.min((timestamp - start) / duration, 1); setBarProgress(t * target); if (t < 1) { animationFrame = requestAnimationFrame(animatePhase2); } else { setShowXPTotal(true); } }; animationFrame = requestAnimationFrame(animatePhase2); }; animationFrame = requestAnimationFrame(animatePhase1); return () => cancelAnimationFrame(animationFrame); }, []); const barPct = Math.round(barProgress * 100); const totalXP = previousXP + gainedXP; return (