fix(streak-animation): enforce strict logo keep-out sanctuary, side flank pyrotechnics, 1-2 day fragile streak crying logic, and reliable demo/theme switch modal triggers

This commit is contained in:
2026-09-20 12:58:12 +05:30
parent 6307a24c4c
commit 3adfc9b0fd
4 changed files with 268 additions and 163 deletions

View File

@@ -36,20 +36,20 @@
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
} }
#fw { z-index: 5; } #fw { z-index: 4; }
#ec { z-index: 7; } #ec { z-index: 6; }
/* Euphoric flash overlay on big burst */ /* Subtle flash overlay on big burst */
.flash { .flash {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 6; z-index: 5;
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
background: radial-gradient(circle at 50% 46%, rgba(255, 240, 190, 0.6), rgba(212, 175, 55, 0.15) 45%, transparent 70%); background: radial-gradient(circle at 50% 46%, rgba(255, 240, 190, 0.45), rgba(212, 175, 55, 0.10) 45%, transparent 70%);
} }
body.light-theme .flash { body.light-theme .flash {
background: radial-gradient(circle at 50% 46%, rgba(12, 96, 96, 0.22), rgba(12, 96, 96, 0.08) 45%, transparent 70%); background: radial-gradient(circle at 50% 46%, rgba(12, 96, 96, 0.18), rgba(12, 96, 96, 0.05) 45%, transparent 70%);
} }
.stage.play.tier3 .flash { animation: flash 760ms ease-out 2600ms; } .stage.play.tier3 .flash { animation: flash 760ms ease-out 2600ms; }
@@ -74,6 +74,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 20; /* High z-index keeps logo completely clear and prominent */
} }
.stage.play.tier3 .scene { animation: shake 520ms ease-out 2620ms; } .stage.play.tier3 .scene { animation: shake 520ms ease-out 2620ms; }
@@ -87,7 +88,7 @@
transform: translate(-50%, -50%) scale(0.3); transform: translate(-50%, -50%) scale(0.3);
opacity: 0; opacity: 0;
border-radius: 50%; border-radius: 50%;
filter: blur(6px); filter: blur(8px);
z-index: 0; z-index: 0;
background: radial-gradient(circle, background: radial-gradient(circle,
rgba(246, 221, 147, 0.55) 0%, rgba(246, 221, 147, 0.55) 0%,
@@ -97,9 +98,9 @@
} }
body.light-theme .halo { body.light-theme .halo {
background: radial-gradient(circle, background: radial-gradient(circle,
rgba(12, 96, 96, 0.35) 0%, rgba(12, 96, 96, 0.32) 0%,
rgba(12, 96, 96, 0.18) 34%, rgba(12, 96, 96, 0.16) 34%,
rgba(12, 96, 96, 0.05) 58%, rgba(12, 96, 96, 0.04) 58%,
transparent 72%); transparent 72%);
} }
.stage.play.happy .halo { .stage.play.happy .halo {
@@ -107,14 +108,14 @@
} }
.stage.sad .halo { .stage.sad .halo {
background: radial-gradient(circle, background: radial-gradient(circle,
rgba(59, 130, 246, 0.25) 0%, rgba(59, 130, 246, 0.22) 0%,
rgba(100, 116, 139, 0.20) 36%, rgba(100, 116, 139, 0.18) 36%,
transparent 70%); transparent 70%);
} }
body.light-theme .stage.sad .halo { body.light-theme .stage.sad .halo {
background: radial-gradient(circle, background: radial-gradient(circle,
rgba(37, 99, 235, 0.22) 0%, rgba(37, 99, 235, 0.20) 0%,
rgba(148, 163, 184, 0.16) 36%, rgba(148, 163, 184, 0.14) 36%,
transparent 70%); transparent 70%);
} }
.stage.play.sad .halo { .stage.play.sad .halo {
@@ -160,7 +161,7 @@
background: url('/PragatiVeer_Logo/SVG_Logos/Square_light_background.svg') center/cover no-repeat !important; background: url('/PragatiVeer_Logo/SVG_Logos/Square_light_background.svg') center/cover no-repeat !important;
} }
.stage.sad .badge { .stage.sad .badge {
filter: saturate(0.8) brightness(0.9); filter: saturate(0.85) brightness(0.92);
} }
/* Replay tooltip / test hint */ /* Replay tooltip / test hint */
@@ -171,7 +172,7 @@
font-weight: 600; font-weight: 600;
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
color: rgba(148, 163, 184, 0.8); color: rgba(148, 163, 184, 0.75);
pointer-events: none; pointer-events: none;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
@@ -202,23 +203,23 @@
} }
@keyframes droop { @keyframes droop {
0% { transform: scale(1) translateY(0); } 0% { transform: scale(1) translateY(0); }
100% { transform: scale(0.93) translateY(8px); } 100% { transform: scale(0.94) translateY(6px); }
} }
@keyframes litglow { @keyframes litglow {
0% { filter: drop-shadow(0 0 0 rgba(212, 175, 55, 0)); } 0% { filter: drop-shadow(0 0 0 rgba(212, 175, 55, 0)); }
100% { filter: drop-shadow(0 0 28px rgba(233, 196, 99, 0.65)); } 100% { filter: drop-shadow(0 0 24px rgba(233, 196, 99, 0.65)); }
} }
@keyframes litglow-teal { @keyframes litglow-teal {
0% { filter: drop-shadow(0 0 0 rgba(12, 96, 96, 0)); } 0% { filter: drop-shadow(0 0 0 rgba(12, 96, 96, 0)); }
100% { filter: drop-shadow(0 0 26px rgba(12, 96, 96, 0.55)); } 100% { filter: drop-shadow(0 0 24px rgba(12, 96, 96, 0.50)); }
} }
@keyframes dimglow { @keyframes dimglow {
0% { filter: drop-shadow(0 0 0 rgba(100, 116, 139, 0)); } 0% { filter: drop-shadow(0 0 0 rgba(100, 116, 139, 0)); }
100% { filter: drop-shadow(0 6px 18px rgba(30, 41, 59, 0.4)); } 100% { filter: drop-shadow(0 4px 14px rgba(30, 41, 59, 0.35)); }
} }
@keyframes flash { @keyframes flash {
0% { opacity: 0; } 0% { opacity: 0; }
18% { opacity: 0.85; } 18% { opacity: 0.8; }
100% { opacity: 0; } 100% { opacity: 0; }
} }
@keyframes shake { @keyframes shake {
@@ -232,7 +233,7 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.stage.play .spinner { animation: none; transform: rotateY(0) scale(1); } .stage.play .spinner { animation: none; transform: rotateY(0) scale(1); }
.stage.play.happy .scaler { animation: none; transform: scale(var(--grow)); } .stage.play.happy .scaler { animation: none; transform: scale(var(--grow)); }
.stage.play.sad .scaler { animation: none; transform: scale(0.93); } .stage.play.sad .scaler { animation: none; transform: scale(0.94); }
.stage.play.tier3 .scene, .stage.play.tier3 .flash { animation: none; } .stage.play.tier3 .scene, .stage.play.tier3 .flash { animation: none; }
} }
</style> </style>
@@ -243,7 +244,7 @@
<canvas id="ec"></canvas> <canvas id="ec"></canvas>
<div class="flash"></div> <div class="flash"></div>
<div class="stage" id="stage" title="Click to replay animation"> <div class="stage" id="stage" title="Click to replay celebration">
<div class="scene"> <div class="scene">
<div class="halo"></div> <div class="halo"></div>
<div class="scaler"> <div class="scaler">
@@ -282,7 +283,7 @@
} }
applyThemeClass(isLight); applyThemeClass(isLight);
// Poll for parent theme switch (e.g. user toggles theme on dashboard) // Poll for parent theme switch (instant visual update)
setInterval(() => { setInterval(() => {
const nowLight = detectLight(); const nowLight = detectLight();
if (nowLight !== isLight) { if (nowLight !== isLight) {
@@ -292,7 +293,7 @@
rebuildColors(); rebuildColors();
play(); play();
} }
}, 200); }, 180);
// ── 2. Canvases & Sizing ──────────────────────────────────────────────── // ── 2. Canvases & Sizing ────────────────────────────────────────────────
const cv = document.getElementById('fw'); const cv = document.getElementById('fw');
@@ -301,21 +302,28 @@
const ectx = ec.getContext('2d'); const ectx = ec.getContext('2d');
let W, H, DPR; let W, H, DPR;
let keepoutRadiusSq = 0;
function resize() { function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1); DPR = Math.min(2, window.devicePixelRatio || 1);
W = cv.width = ec.width = Math.floor(innerWidth * DPR); W = cv.width = ec.width = Math.floor(innerWidth * DPR);
H = cv.height = ec.height = Math.floor(innerHeight * DPR); H = cv.height = ec.height = Math.floor(innerHeight * DPR);
cv.style.width = ec.style.width = innerWidth + 'px'; cv.style.width = ec.style.width = innerWidth + 'px';
cv.style.height = ec.style.height = innerHeight + 'px'; cv.style.height = ec.style.height = innerHeight + 'px';
// Logo radius is 120px CSS, keep-out radius is 155px CSS
// STRICT KEEP-OUT ZONE around center (W/2, H/2)
const keepoutR = 155 * DPR;
keepoutRadiusSq = keepoutR * keepoutR;
} }
resize(); resize();
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
// ── 3. High-Contrast Palettes ─────────────────────────────────────────── // ── 3. High-Contrast Jewel / Neon Palettes ───────────────────────────────
let SPARK_COLORS, CONFETTI_COLORS, FLASH_COLORS; let SPARK_COLORS, CONFETTI_COLORS;
function rebuildColors() { function rebuildColors() {
if (isLight) { if (isLight) {
// Saturated jewel tones that POP vividly against white background // Saturated jewel tones that POP cleanly on white cards without looking pale
SPARK_COLORS = [ SPARK_COLORS = [
'#0c6060', // Cyan AI / Deep Teal '#0c6060', // Cyan AI / Deep Teal
'#e11d48', // Vivid Crimson '#e11d48', // Vivid Crimson
@@ -323,16 +331,14 @@
'#d97706', // Amber Gold '#d97706', // Amber Gold
'#7c3aed', // Purple '#7c3aed', // Purple
'#059669', // Emerald '#059669', // Emerald
'#dc2626', // Bright Red '#dc2626', // Coral Fire
'#ea580c', // Tangerine
]; ];
CONFETTI_COLORS = [ CONFETTI_COLORS = [
'#0c6060', '#e11d48', '#2563eb', '#d97706', '#0c6060', '#e11d48', '#2563eb', '#d97706',
'#7c3aed', '#059669', '#ea580c', '#0284c7' '#7c3aed', '#059669', '#0284c7'
]; ];
FLASH_COLORS = ['#ffffff', '#fde047', '#38bdf8'];
} else { } else {
// Luminous neon & gold that glow against dark background // Radiant gold & neon that glow brilliantly against dark background
SPARK_COLORS = [ SPARK_COLORS = [
'#fde047', // Brilliant Gold '#fde047', // Brilliant Gold
'#f59e0b', // Warm Gold '#f59e0b', // Warm Gold
@@ -345,14 +351,13 @@
]; ];
CONFETTI_COLORS = [ CONFETTI_COLORS = [
'#fde047', '#00F0FF', '#f43f5e', '#a855f7', '#fde047', '#00F0FF', '#f43f5e', '#a855f7',
'#34d399', '#ffffff', '#fbbf24' '#34d399', '#ffffff'
]; ];
FLASH_COLORS = ['#ffffff', '#fef08a', '#7dd3fc'];
} }
} }
rebuildColors(); rebuildColors();
// ── 4. Particle Systems ───────────────────────────────────────────────── // ── 4. Particle Systems & STRICT LOGO KEEP-OUT ─────────────────────────
let particles = []; let particles = [];
let emojiParticles = []; let emojiParticles = [];
let shockwaves = []; let shockwaves = [];
@@ -363,85 +368,107 @@
let fwTimers = []; let fwTimers = [];
let mode = 'fireworks'; // 'fireworks' | 'faces' let mode = 'fireworks'; // 'fireworks' | 'faces'
// Firecracker Shell Burst // Grand Pyrotechnic Shell Burst — FIRED STRICTLY ON FLANKS (Left & Right)
function firecrackerBurst(xr, yr, count) { // NEVER ON TOP OF THE LOGO!
function flankBurst(isRightSide, count) {
// Left burst: x = 10% to 18%, y = 30% to 52%
// Right burst: x = 82% to 90%, y = 30% to 52%
const xr = isRightSide ? (0.81 + Math.random() * 0.09) : (0.10 + Math.random() * 0.09);
const yr = 0.32 + Math.random() * 0.22;
const x = xr * W; const x = xr * W;
const y = yr * H; const y = yr * H;
const n = count | 0; const n = count | 0;
// Center shockwave // Outer flank shockwave
shockwaves.push({ shockwaves.push({
x, y, x, y,
r: 6 * DPR, r: 6 * DPR,
maxR: (35 + Math.random() * 25) * DPR, maxR: (34 + Math.random() * 20) * DPR,
vr: 2.5 * DPR, vr: 2.2 * DPR,
life: 1, life: 1,
col: isLight ? 'rgba(12, 96, 96, 0.45)' : 'rgba(253, 224, 71, 0.6)' col: isLight ? 'rgba(12, 96, 96, 0.40)' : 'rgba(253, 224, 71, 0.55)'
}); });
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const a = (Math.PI * 2 * i) / n + (Math.random() - 0.5) * 0.35; // Angle: bias outward away from the center logo!
const sp = (2.2 + Math.random() * 4.8) * DPR; let a;
if (isRightSide) {
// Explode mostly up, right, and down-right (away from center)
a = (Math.random() - 0.25) * Math.PI * 1.5;
} else {
// Explode mostly up, left, and down-left (away from center)
a = Math.PI + (Math.random() - 0.5) * Math.PI * 1.5;
}
const sp = (2.2 + Math.random() * 4.6) * DPR;
const color = SPARK_COLORS[(Math.random() * SPARK_COLORS.length) | 0]; const color = SPARK_COLORS[(Math.random() * SPARK_COLORS.length) | 0];
particles.push({ particles.push({
t: 'spark', t: 'spark',
x, y, x, y,
vx: Math.cos(a) * sp, vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 1.2 * DPR, vy: Math.sin(a) * sp - 1.1 * DPR,
life: 1, life: 1,
decay: 0.012 + Math.random() * 0.014, decay: 0.013 + Math.random() * 0.014,
col: color, col: color,
size: (1.8 + Math.random() * 2.2) * DPR, size: (1.8 + Math.random() * 2.0) * DPR,
isCrackle: Math.random() < 0.25 // occasional crackle spark isCrackle: Math.random() < 0.2
}); });
} }
} }
// Spinning Confetti Strips // Spinning Confetti Ribbons — FALL ONLY ON LEFT AND RIGHT FLANKS
function spawnConfetti(count) { function spawnFlankConfetti(count) {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const isRight = Math.random() < 0.5;
// Left corridor: 4% to 24%; Right corridor: 76% to 96%
const xr = isRight ? (0.76 + Math.random() * 0.20) : (0.04 + Math.random() * 0.20);
particles.push({ particles.push({
t: 'conf', t: 'conf',
x: (0.2 + Math.random() * 0.6) * W, x: xr * W,
y: -25 * DPR, y: -25 * DPR,
vx: (-1.4 + Math.random() * 2.8) * DPR, vx: (isRight ? 1 : -1) * (0.2 + Math.random() * 1.6) * DPR,
vy: (1.6 + Math.random() * 2.6) * DPR, vy: (1.6 + Math.random() * 2.4) * DPR,
rot: Math.random() * Math.PI * 2, rot: Math.random() * Math.PI * 2,
vr: (-0.15 + Math.random() * 0.3), vr: (-0.14 + Math.random() * 0.28),
life: 1, life: 1,
w: (4.5 + Math.random() * 4) * DPR, w: (4.5 + Math.random() * 4) * DPR,
h: (8 + Math.random() * 7) * DPR, h: (8 + Math.random() * 6) * DPR,
col: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0] col: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0]
}); });
} }
} }
// ── 5. Emojis Raining System (Celebration OR Crying) ───────────────────── // ── 5. Emojis Raining System — STRICTLY ON FLANKS ────────────────────────
const CELEBRATE_EMOJIS = ['✨', '🎉', '🎊', '🔥', '⚡', '🌟', '💥', '🚀', '🏆', '💫']; const CELEBRATE_EMOJIS = ['✨', '🎉', '🎊', '🔥', '⚡', '🌟', '💥', '🚀', '🏆', '💫'];
const CRYING_EMOJIS = ['😭', '😢', '💧', '🥺', '💔', '🌧️', '😿', '⚡']; const CRYING_EMOJIS = ['😭', '😢', '💧', '🥺', '💔', '🌧️', '😿'];
function spawnEmojiRain(count, isSad) { function spawnFlankEmojiRain(count, isSad) {
const list = isSad ? CRYING_EMOJIS : CELEBRATE_EMOJIS; const list = isSad ? CRYING_EMOJIS : CELEBRATE_EMOJIS;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const isRight = Math.random() < 0.5;
// Spawn strictly in side corridors: 4% to 24% or 76% to 96%
const xr = isRight ? (0.76 + Math.random() * 0.20) : (0.04 + Math.random() * 0.20);
emojiParticles.push({ emojiParticles.push({
x: (0.08 + Math.random() * 0.84) * W, x: xr * W,
y: -35 * DPR, y: -35 * DPR,
vx: (-0.8 + Math.random() * 1.6) * DPR, vx: (isRight ? 0.3 : -0.3) + (Math.random() - 0.5) * 0.8 * DPR,
vy: (1.5 + Math.random() * 2.6) * DPR, vy: (1.5 + Math.random() * 2.2) * DPR,
rot: (-25 + Math.random() * 50) * Math.PI / 180, rot: (-20 + Math.random() * 40) * Math.PI / 180,
vr: (-0.02 + Math.random() * 0.04), vr: (-0.02 + Math.random() * 0.04),
life: 1, life: 1,
decay: 0.0035 + Math.random() * 0.004, decay: 0.0035 + Math.random() * 0.0035,
size: (18 + Math.random() * 14) * DPR, size: (18 + Math.random() * 12) * DPR,
emoji: list[(Math.random() * list.length) | 0], emoji: list[(Math.random() * list.length) | 0],
swayPhase: Math.random() * Math.PI * 2, swayPhase: Math.random() * Math.PI * 2,
swaySpeed: 0.03 + Math.random() * 0.03 swaySpeed: 0.028 + Math.random() * 0.025
}); });
} }
} }
// ── 6. Sad Cartoon Faces with Animated Tears ──────────────────────────── // ── 6. Sad Cartoon Faces (Flank Positioned, Outside Logo) ────────────────
function drawSadFace(p) { function drawSadFace(p) {
const r = p.r; const r = p.r;
const s = p.s; const s = p.s;
@@ -453,7 +480,6 @@
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
const line = Math.max(2, r * 0.08); const line = Math.max(2, r * 0.08);
// Light mode: deep slate navy; Dark mode: glowing soft blue-white
const stroke = isLight ? '#0f172a' : 'rgba(186, 210, 245, 0.95)'; const stroke = isLight ? '#0f172a' : 'rgba(186, 210, 245, 0.95)';
const fill = isLight ? '#f1f5f9' : 'rgba(148, 163, 184, 0.18)'; const fill = isLight ? '#f1f5f9' : 'rgba(148, 163, 184, 0.18)';
@@ -466,7 +492,7 @@
ctx.strokeStyle = stroke; ctx.strokeStyle = stroke;
ctx.stroke(); ctx.stroke();
// Eyebrows (drooping down sad angle) // Eyebrows (drooping down)
const ex = r * 0.42, ey = -r * 0.12, er = r * 0.11; const ex = r * 0.42, ey = -r * 0.12, er = r * 0.11;
const bl = r * 0.32, tilt = (0.2 + s * 0.5); const bl = r * 0.32, tilt = (0.2 + s * 0.5);
@@ -495,7 +521,7 @@
ctx.arc(ex, ey, er, 0, Math.PI * 2); ctx.arc(ex, ey, er, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// Downward curved trembling sad mouth // Downward sad mouth
const mw = r * 0.44; const mw = r * 0.44;
const my = r * 0.52; const my = r * 0.52;
const dip = r * (0.12 + s * 0.55); const dip = r * (0.12 + s * 0.55);
@@ -506,7 +532,7 @@
ctx.strokeStyle = stroke; ctx.strokeStyle = stroke;
ctx.stroke(); ctx.stroke();
// Animated Tear Drops Streaming Down // Animated Tear Drops
const drop1 = (p.tp % 1); const drop1 = (p.tp % 1);
const drop2 = ((p.tp + 0.5) % 1); const drop2 = ((p.tp + 0.5) % 1);
const tearColor = isLight ? '#0284c7' : '#38bdf8'; const tearColor = isLight ? '#0284c7' : '#38bdf8';
@@ -529,11 +555,13 @@
ctx.restore(); ctx.restore();
} }
// ── 7. Animation Loop (Fireworks Canvas) ───────────────────────────────── // ── 7. Animation Loop (Fireworks with STRICT LOGO KEEP-OUT) ──────────────
function tick() { function tick() {
// ALWAYS clearRect so modal background remains 100% crystal clean!
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
const cx = W / 2;
const cy = H / 2;
// Shockwaves // Shockwaves
for (let i = shockwaves.length - 1; i >= 0; i--) { for (let i = shockwaves.length - 1; i >= 0; i--) {
const sw = shockwaves[i]; const sw = shockwaves[i];
@@ -544,7 +572,7 @@
continue; continue;
} }
ctx.save(); ctx.save();
ctx.globalAlpha = Math.max(0, sw.life * 0.6); ctx.globalAlpha = Math.max(0, sw.life * 0.55);
ctx.lineWidth = 2 * DPR; ctx.lineWidth = 2 * DPR;
ctx.strokeStyle = sw.col; ctx.strokeStyle = sw.col;
ctx.beginPath(); ctx.beginPath();
@@ -559,7 +587,7 @@
if (p.t === 'spark') { if (p.t === 'spark') {
p.vx *= 0.982; p.vx *= 0.982;
p.vy = p.vy * 0.982 + 0.06 * DPR; // gravity p.vy = p.vy * 0.982 + 0.06 * DPR;
p.x += p.vx; p.x += p.vx;
p.y += p.vy; p.y += p.vy;
p.life -= p.decay; p.life -= p.decay;
@@ -569,10 +597,17 @@
continue; continue;
} }
// STRICT LOGO EXCLUSION ZONE: Never draw over the central logo!
const dx = p.x - cx;
const dy = p.y - cy;
if (dx * dx + dy * dy < keepoutRadiusSq) {
continue;
}
ctx.save(); ctx.save();
ctx.globalAlpha = Math.max(0, p.life); ctx.globalAlpha = Math.max(0, p.life);
// Photorealistic Spark Trail (drawn directly with math — no canvas haze!) // Photorealistic Spark Velocity Trail
ctx.strokeStyle = p.col; ctx.strokeStyle = p.col;
ctx.lineWidth = Math.max(1, p.size * p.life * 0.75); ctx.lineWidth = Math.max(1, p.size * p.life * 0.75);
ctx.lineCap = 'round'; ctx.lineCap = 'round';
@@ -581,17 +616,17 @@
ctx.lineTo(p.x - p.vx * 2.8, p.y - p.vy * 2.8); ctx.lineTo(p.x - p.vx * 2.8, p.y - p.vy * 2.8);
ctx.stroke(); ctx.stroke();
// Glowing Core / Head of spark // Glowing Spark Head
ctx.fillStyle = p.col; ctx.fillStyle = p.col;
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life * 0.9, 0, Math.PI * 2); ctx.arc(p.x, p.y, p.size * p.life * 0.9, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// Crackle mini twinkle for select sparks // Subtle crackle flash
if (p.isCrackle && Math.random() < 0.3) { if (p.isCrackle && Math.random() < 0.25) {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 1.4, 0, Math.PI * 2); ctx.arc(p.x, p.y, p.size * 1.3, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} }
@@ -615,6 +650,13 @@
continue; continue;
} }
// STRICT LOGO EXCLUSION ZONE: Never draw confetti over central logo!
const dx = p.x - cx;
const dy = p.y - cy;
if (dx * dx + dy * dy < keepoutRadiusSq) {
continue;
}
ctx.save(); ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, p.life)); ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.translate(p.x, p.y); ctx.translate(p.x, p.y);
@@ -633,14 +675,17 @@
} }
} }
// ── 8. Animation Loop (Emojis Canvas) ─────────────────────────────────── // ── 8. Animation Loop (Emojis Canvas with STRICT LOGO KEEP-OUT) ───────────
function emojiTick() { function emojiTick() {
ectx.clearRect(0, 0, W, H); ectx.clearRect(0, 0, W, H);
const cx = W / 2;
const cy = H / 2;
for (let i = emojiParticles.length - 1; i >= 0; i--) { for (let i = emojiParticles.length - 1; i >= 0; i--) {
const p = emojiParticles[i]; const p = emojiParticles[i];
p.swayPhase += p.swaySpeed; p.swayPhase += p.swaySpeed;
p.x += p.vx + Math.sin(p.swayPhase) * 0.8 * DPR; p.x += p.vx + Math.sin(p.swayPhase) * 0.6 * DPR;
p.y += p.vy; p.y += p.vy;
p.rot += p.vr; p.rot += p.vr;
p.life -= p.decay; p.life -= p.decay;
@@ -650,6 +695,13 @@
continue; continue;
} }
// STRICT LOGO EXCLUSION ZONE: Emojis NEVER overlap the central badge!
const dx = p.x - cx;
const dy = p.y - cy;
if (dx * dx + dy * dy < keepoutRadiusSq) {
continue;
}
ectx.save(); ectx.save();
ectx.globalAlpha = Math.min(1, Math.max(0, p.life)); ectx.globalAlpha = Math.min(1, Math.max(0, p.life));
ectx.translate(p.x, p.y); ectx.translate(p.x, p.y);
@@ -684,35 +736,31 @@
} }
} }
// ── 10. Celebrate (Firecrackers + Sparkles + Emojis) ──────────────────── // ── 10. Celebrate (Side Flank Pyrotechnics & Flank Emojis) ───────────────
function celebrate(t) { function celebrate(intensity) {
stopAll(); stopAll();
mode = 'fireworks'; mode = 'fireworks';
running = true; running = true;
if (!rafFW) rafFW = requestAnimationFrame(tick); if (!rafFW) rafFW = requestAnimationFrame(tick);
// Emoji celebration rain
emojiRunning = true; emojiRunning = true;
if (!rafEmoji) rafEmoji = requestAnimationFrame(emojiTick); if (!rafEmoji) rafEmoji = requestAnimationFrame(emojiTick);
const rounds = Math.round(5 + t * 9); const rounds = Math.round(5 + intensity * 8);
const gap = Math.round(330 - t * 140); const gap = Math.round(340 - intensity * 130);
const base = Math.round(32 + t * 40); const base = Math.round(30 + intensity * 36);
for (let k = 0; k < rounds; k++) { for (let k = 0; k < rounds; k++) {
const d = k * gap; const d = k * gap;
// Alternating left & right burst const isRight = (k % 2 === 1);
fwTimers.push(setTimeout(() => firecrackerBurst(0.12 + Math.random() * 0.08, 0.28 + Math.random() * 0.22, base), d));
fwTimers.push(setTimeout(() => firecrackerBurst(0.80 + Math.random() * 0.08, 0.28 + Math.random() * 0.22, base), d + 60));
// Center burst on higher streak intensity // Alternating left and right flank grand fireworks
if (t > 0.35) { fwTimers.push(setTimeout(() => flankBurst(false, base), d));
fwTimers.push(setTimeout(() => firecrackerBurst(0.44 + Math.random() * 0.12, 0.18 + Math.random() * 0.14, base + 8), d + 120)); fwTimers.push(setTimeout(() => flankBurst(true, base), d + 90));
}
// Confetti & celebratory emoji waves // Flank confetti and celebratory emojis (strictly on left/right wings)
fwTimers.push(setTimeout(() => spawnConfetti(4 + Math.round(t * 5)), d + 40)); fwTimers.push(setTimeout(() => spawnFlankConfetti(3 + Math.round(intensity * 4)), d + 40));
fwTimers.push(setTimeout(() => spawnEmojiRain(2 + Math.round(t * 3), false), d + 100)); fwTimers.push(setTimeout(() => spawnFlankEmojiRain(2 + Math.round(intensity * 3), false), d + 80));
} }
fwTimers.push(setTimeout(() => { fwTimers.push(setTimeout(() => {
@@ -721,31 +769,30 @@
}, rounds * gap + 1200)); }, rounds * gap + 1200));
} }
// ── 11. Crying Sad Mood (Broken Streak / Streak = 0) ──────────────────── // ── 11. Crying Sad Mood (Streak <= 2: 0, 1, or 2 Days) ───────────────────
function sadFaces(s) { function sadFaces(sadness) {
stopAll(); stopAll();
mode = 'faces'; mode = 'faces';
running = true; running = true;
if (!rafFW) rafFW = requestAnimationFrame(tick); if (!rafFW) rafFW = requestAnimationFrame(tick);
// Sad crying emojis rain
emojiRunning = true; emojiRunning = true;
if (!rafEmoji) rafEmoji = requestAnimationFrame(emojiTick); if (!rafEmoji) rafEmoji = requestAnimationFrame(emojiTick);
const r = Math.min(W, H) * 0.095 + s * Math.min(W, H) * 0.02; const r = Math.min(W, H) * 0.09 + sadness * Math.min(W, H) * 0.02;
const y = H * 0.42; const y = H * 0.42;
// Two persistent weeping cartoon faces // Two weeping cartoon faces on the side wings (completely clear of badge)
particles.push({ t: 'sadface', x: W * 0.16, y, r, s, phase: 0, tp: 0.1, fi: 0, life: 1 }); particles.push({ t: 'sadface', x: W * 0.14, y, r, s: sadness, phase: 0, tp: 0.1, fi: 0, life: 1 });
particles.push({ t: 'sadface', x: W * 0.84, y, r, s, phase: 1.6, tp: 0.6, fi: 0, life: 1 }); particles.push({ t: 'sadface', x: W * 0.86, y, r, s: sadness, phase: 1.6, tp: 0.6, fi: 0, life: 1 });
// Ongoing waves of crying emojis and raindrops // Ongoing waves of crying emojis down the side corridors
for (let k = 0; k < 8; k++) { for (let k = 0; k < 8; k++) {
fwTimers.push(setTimeout(() => spawnEmojiRain(3 + Math.round(s * 3), true), k * 650 + 400)); fwTimers.push(setTimeout(() => spawnFlankEmojiRain(3 + Math.round(sadness * 3), true), k * 650 + 400));
} }
} }
// ── 12. Orchestrate Play ──────────────────────────────────────────────── // ── 12. Orchestrate Play (1-Day & 2-Day Fragile Streak Logic) ─────────────
const stage = document.getElementById('stage'); const stage = document.getElementById('stage');
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
let moodTimer = null; let moodTimer = null;
@@ -761,8 +808,9 @@
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.get('mood') === 'sad') return true; if (params.get('mood') === 'sad') return true;
if (params.get('mood') === 'happy') return false; if (params.get('mood') === 'happy') return false;
// Streak 0 is Day 1 awaits / broken streak — triggers crying mood! // Streak <= 2 (0 days, 1 day, 2 days) is the fragile streak -> crying/weeping mood!
return streakVal <= 0; // Streak >= 3 (3+ days) -> triumphant celebration!
return streakVal <= 2;
} }
function play() { function play() {
@@ -771,9 +819,8 @@
const streak = parseStreak(); const streak = parseStreak();
const isSad = parseIsSad(streak); const isSad = parseIsSad(streak);
const intensity = isSad ? 0 : Math.min(1, Math.log10(Math.max(streak, 1) + 1) / 2.5); const intensity = isSad ? 0 : Math.min(1, Math.log10(Math.max(streak, 3) / 2) / 2.5);
// Mood class updates
stage.classList.remove('play', 'happy', 'sad', 'tier1', 'tier2', 'tier3'); stage.classList.remove('play', 'happy', 'sad', 'tier1', 'tier2', 'tier3');
document.body.classList.remove('sad'); document.body.classList.remove('sad');
@@ -787,17 +834,16 @@
stage.style.setProperty('--halo-w', (160 + intensity * 70).toFixed(0) + '%'); stage.style.setProperty('--halo-w', (160 + intensity * 70).toFixed(0) + '%');
} }
// Force CSS reflow & trigger coin flip animation
void stage.offsetWidth; void stage.offsetWidth;
stage.classList.add('play'); stage.classList.add('play');
const sadness = isSad ? 1 : 0; const sadness = isSad ? Math.max(0.4, (3 - streak) / 3) : 0;
if (reduce.matches) { if (reduce.matches) {
if (isSad) setTimeout(() => sadFaces(sadness), 0); if (isSad) setTimeout(() => sadFaces(sadness), 0);
return; return;
} }
// Trigger after coin spin settles (~2400ms) // Settle after 3D coin spin (~2400ms)
moodTimer = setTimeout(() => { moodTimer = setTimeout(() => {
if (isSad) { if (isSad) {
sadFaces(sadness); sadFaces(sadness);
@@ -807,7 +853,7 @@
}, 2450); }, 2450);
} }
// Click badge/stage to replay animation anytime // Click badge/stage to replay celebration anytime
stage.addEventListener('click', () => { stage.addEventListener('click', () => {
play(); play();
}); });

View File

@@ -2,54 +2,97 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Flame, Sparkles, X } from "lucide-react"; import { Flame, Sparkles, X, Droplets } from "lucide-react";
import { useActiveUser } from "@/hooks/useActiveUser"; import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser";
import { useTheme } from "next-themes";
export function StreakOnboardingModal() { export function StreakOnboardingModal() {
const { user, loading } = useActiveUser(); const { user, loading } = useActiveUser();
const { resolvedTheme } = useTheme(); const [currentTheme, setCurrentTheme] = useState<"light" | "dark">("light");
const activeTheme = resolvedTheme ?? "light";
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const lastUserIdRef = useRef<string | null>(null); const [renderCount, setRenderCount] = useState(0);
const lastThemeRef = useRef<string | null>(null);
// Keep track of the active user id to trigger modal whenever it changes
const prevUserIdRef = useRef<string | null>(null);
// Sync theme directly from document.documentElement
useEffect(() => {
if (typeof window === "undefined") return;
const checkTheme = () => {
const isDark = document.documentElement.classList.contains("dark");
return isDark ? "dark" : "light";
};
setCurrentTheme(checkTheme());
// 1. Observe HTML class mutations (for immediate theme change detection)
const observer = new MutationObserver(() => {
const newTheme = checkTheme();
setCurrentTheme(newTheme);
// Re-trigger streak celebration whenever theme transitions on dashboard
setIsOpen(true);
setRenderCount((c) => c + 1);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
// 2. Custom event listener from ThemeToggle
const handleThemeSwitched = (e: any) => {
const t = e.detail?.theme || checkTheme();
setCurrentTheme(t);
setIsOpen(true);
setRenderCount((c) => c + 1);
};
window.addEventListener("pragativeer-theme-switched", handleThemeSwitched);
// 3. Custom event listener for user account switch (demo switcher)
const handleUserSwitched = () => {
setIsOpen(true);
setRenderCount((c) => c + 1);
};
window.addEventListener("pragativeer-user-switched", handleUserSwitched);
// 4. Custom event from top navigation flame streak counter
const handleManualOpen = () => {
setIsOpen(true);
setRenderCount((c) => c + 1);
};
window.addEventListener("pragativeer-show-streak-animation", handleManualOpen);
return () => {
observer.disconnect();
window.removeEventListener("pragativeer-theme-switched", handleThemeSwitched);
window.removeEventListener("pragativeer-user-switched", handleUserSwitched);
window.removeEventListener("pragativeer-show-streak-animation", handleManualOpen);
};
}, []);
// Account switch & initial load trigger
useEffect(() => { useEffect(() => {
if (loading || !user) return; if (loading || !user) return;
// Detect theme transitions on dashboard const isDifferentUser = prevUserIdRef.current !== null && prevUserIdRef.current !== user.id;
const isFirstThemeLoad = lastThemeRef.current === null; prevUserIdRef.current = user.id;
const themeChanged = !isFirstThemeLoad && lastThemeRef.current !== activeTheme;
lastThemeRef.current = activeTheme;
// Detect account switches (e.g. demo switcher between Neha, Rahul, Priya, Amit) // Show popup if:
const userChanged = lastUserIdRef.current !== null && lastUserIdRef.current !== user.id; // 1. User account switched (demo plans: Neha, Rahul, Priya, Amit)
lastUserIdRef.current = user.id; // 2. First time user visits (not yet dismissed in session)
// 3. User is on a demo account
// First visit check per user account
const hasSeen = sessionStorage.getItem(`hasSeenStreakPopup_${user.id}`); const hasSeen = sessionStorage.getItem(`hasSeenStreakPopup_${user.id}`);
if (isDifferentUser || !hasSeen || isDemoUser(user.id)) {
// Re-trigger modal when:
// 1) First time user visits (has not seen popup)
// 2) User account switched (e.g., Demo switch between Neha, Priya, Rahul, Amit)
// 3) Theme switched on dashboard (light <-> dark transition for testing & visual feedback)
if (!hasSeen || userChanged || themeChanged) {
setIsOpen(true); setIsOpen(true);
} }
}, [user, loading, activeTheme]); }, [user, loading]);
// Listen for global custom trigger event (e.g. clicking streak counter in top navbar)
useEffect(() => {
const handleOpen = () => setIsOpen(true);
window.addEventListener("pragativeer-show-streak-animation", handleOpen);
return () => window.removeEventListener("pragativeer-show-streak-animation", handleOpen);
}, []);
if (!isOpen || !user) return null; if (!isOpen || !user) return null;
const streak = user.streak_count; const streak = user.streak_count || 0;
const isSad = streak <= 0; // 1-day & 2-days (and 0-days) are the fragile streak zone -> weeping/crying emojis animation
// 3+ days is established streak -> triumphant pyrotechnics celebration!
const isSad = streak <= 2;
const targetExam = user.target_exam?.replace("_", " ") || "JEE/NEET"; const targetExam = user.target_exam?.replace("_", " ") || "JEE/NEET";
// Streak-based wishes and titles // Streak-based wishes and titles
@@ -59,7 +102,10 @@ export function StreakOnboardingModal() {
if (streak === 0) { if (streak === 0) {
wishTitle = "Start Your Streak Today! 🚀"; wishTitle = "Start Your Streak Today! 🚀";
wishMessage = "Today is Day 1 of your journey. Establish your streak now and build the consistency needed for IIT/AIIMS!"; wishMessage = "Today is Day 1 of your journey. Establish your streak now and build the consistency needed for IIT/AIIMS!";
} else if (streak < 4) { } else if (streak <= 2) {
wishTitle = "Don't Break the Chain! 💧";
wishMessage = `You are on Day ${streak} — the most fragile stage! Consistency in the first 3 days builds unstoppable momentum. Practice today!`;
} else if (streak < 7) {
wishTitle = "Keep the Fire Burning! 🔥"; wishTitle = "Keep the Fire Burning! 🔥";
wishMessage = `Great job! You have maintained a ${streak}-day study streak. Keep practicing daily to build solid muscle memory!`; wishMessage = `Great job! You have maintained a ${streak}-day study streak. Keep practicing daily to build solid muscle memory!`;
} else { } else {
@@ -81,13 +127,13 @@ export function StreakOnboardingModal() {
trialDaysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); trialDaysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
} }
// Key forces iframe remount whenever user or theme changes so animation replays fresh // Key forces iframe remount whenever user, theme, or streak changes
const iframeKey = `${user.id}-${activeTheme}-${streak}`; const iframeKey = `${user.id}-${currentTheme}-${streak}-${renderCount}`;
const iframeSrc = `/streak-animation.html?streak=${streak}&theme=${activeTheme}${isSad ? '&mood=sad' : ''}`; const iframeSrc = `/streak-animation.html?streak=${streak}&theme=${currentTheme}&mood=${isSad ? 'sad' : 'happy'}&v=${renderCount}`;
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md animate-in fade-in duration-300" className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-md animate-in fade-in duration-300"
onClick={handleDismiss} onClick={handleDismiss}
> >
{/* Centered Modal Card */} {/* Centered Modal Card */}
@@ -108,9 +154,7 @@ export function StreakOnboardingModal() {
<div className="absolute -right-20 -top-20 size-48 bg-[var(--cyan-ai-10)] rounded-full blur-3xl pointer-events-none" /> <div className="absolute -right-20 -top-20 size-48 bg-[var(--cyan-ai-10)] rounded-full blur-3xl pointer-events-none" />
<div className="absolute -left-20 -bottom-20 size-48 bg-primary/5 rounded-full blur-3xl pointer-events-none" /> <div className="absolute -left-20 -bottom-20 size-48 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
{/* Top Celebration Animation Panel {/* Top Celebration Animation Panel (Clean, symphony-professional pyrotechnics) */}
key forces iframe remount when user switches or theme changes
so animation always replays fresh with the correct colours */}
<div className="relative w-full h-[60%] z-10 pointer-events-none overflow-hidden bg-background"> <div className="relative w-full h-[60%] z-10 pointer-events-none overflow-hidden bg-background">
<iframe <iframe
key={iframeKey} key={iframeKey}
@@ -126,10 +170,14 @@ export function StreakOnboardingModal() {
<div className="space-y-4"> <div className="space-y-4">
{/* Badge Indicators */} {/* Badge Indicators */}
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
{streak > 0 ? ( {streak >= 3 ? (
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-orange-500/10 border border-orange-500/30 text-orange-500 text-sm md:text-base font-extrabold rounded-full tracking-wider uppercase"> <div className="inline-flex items-center gap-2 px-4 py-1.5 bg-orange-500/10 border border-orange-500/30 text-orange-500 text-sm md:text-base font-extrabold rounded-full tracking-wider uppercase">
<Flame size={16} className="fill-current animate-bounce" /> {streak} Day Streak Active <Flame size={16} className="fill-current animate-bounce" /> {streak} Day Streak Active
</div> </div>
) : streak > 0 ? (
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-blue-500/10 border border-blue-500/30 text-blue-600 dark:text-blue-400 text-sm md:text-base font-extrabold rounded-full tracking-wider uppercase">
<Droplets size={16} className="animate-pulse" /> Day {streak} Fragile Streak — Stay Strong!
</div>
) : ( ) : (
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 text-sm md:text-base font-extrabold rounded-full tracking-wider uppercase"> <div className="inline-flex items-center gap-2 px-4 py-1.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 text-sm md:text-base font-extrabold rounded-full tracking-wider uppercase">
<Sparkles size={16} className="animate-spin" style={{ animationDuration: '4s' }} /> Day 1 Awaits <Sparkles size={16} className="animate-spin" style={{ animationDuration: '4s' }} /> Day 1 Awaits

View File

@@ -19,14 +19,17 @@ export function ThemeToggle() {
}, []) }, [])
const toggleTheme = () => { const toggleTheme = () => {
if (isDark) { const nextIsDark = !isDark
document.documentElement.classList.remove('dark') if (nextIsDark) {
localStorage.setItem('theme', 'light');
setIsDark(false)
} else {
document.documentElement.classList.add('dark') document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark'); localStorage.setItem('theme', 'dark')
setIsDark(true) setIsDark(true)
window.dispatchEvent(new CustomEvent("pragativeer-theme-switched", { detail: { theme: 'dark' } }))
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
setIsDark(false)
window.dispatchEvent(new CustomEvent("pragativeer-theme-switched", { detail: { theme: 'light' } }))
} }
} }

View File

@@ -238,6 +238,11 @@ export function useActiveUser() {
setUserIdState(newId); setUserIdState(newId);
setUser(null); // Clear old user immediately so stale data isn't shown setUser(null); // Clear old user immediately so stale data isn't shown
// Notify listeners immediately of account switch
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: newId } }));
}
try { try {
// ── Run session update + new user profile fetch IN PARALLEL ───────── // ── Run session update + new user profile fetch IN PARALLEL ─────────
const [_, newUserRes] = await Promise.all([ const [_, newUserRes] = await Promise.all([
@@ -254,6 +259,9 @@ export function useActiveUser() {
if (newUserRes && newUserRes.ok) { if (newUserRes && newUserRes.ok) {
const data = await newUserRes.json(); const data = await newUserRes.json();
setUser(data); setUser(data);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: newId, user: data } }));
}
} else { } else {
// Fallback: use the regular fetch path // Fallback: use the regular fetch path
await fetchUserProfile(newId); await fetchUserProfile(newId);