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:
@@ -36,20 +36,20 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#fw { z-index: 5; }
|
||||
#ec { z-index: 7; }
|
||||
#fw { z-index: 4; }
|
||||
#ec { z-index: 6; }
|
||||
|
||||
/* Euphoric flash overlay on big burst */
|
||||
/* Subtle flash overlay on big burst */
|
||||
.flash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
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 {
|
||||
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; }
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
display: flex;
|
||||
align-items: 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; }
|
||||
|
||||
@@ -87,7 +88,7 @@
|
||||
transform: translate(-50%, -50%) scale(0.3);
|
||||
opacity: 0;
|
||||
border-radius: 50%;
|
||||
filter: blur(6px);
|
||||
filter: blur(8px);
|
||||
z-index: 0;
|
||||
background: radial-gradient(circle,
|
||||
rgba(246, 221, 147, 0.55) 0%,
|
||||
@@ -97,9 +98,9 @@
|
||||
}
|
||||
body.light-theme .halo {
|
||||
background: radial-gradient(circle,
|
||||
rgba(12, 96, 96, 0.35) 0%,
|
||||
rgba(12, 96, 96, 0.18) 34%,
|
||||
rgba(12, 96, 96, 0.05) 58%,
|
||||
rgba(12, 96, 96, 0.32) 0%,
|
||||
rgba(12, 96, 96, 0.16) 34%,
|
||||
rgba(12, 96, 96, 0.04) 58%,
|
||||
transparent 72%);
|
||||
}
|
||||
.stage.play.happy .halo {
|
||||
@@ -107,14 +108,14 @@
|
||||
}
|
||||
.stage.sad .halo {
|
||||
background: radial-gradient(circle,
|
||||
rgba(59, 130, 246, 0.25) 0%,
|
||||
rgba(100, 116, 139, 0.20) 36%,
|
||||
rgba(59, 130, 246, 0.22) 0%,
|
||||
rgba(100, 116, 139, 0.18) 36%,
|
||||
transparent 70%);
|
||||
}
|
||||
body.light-theme .stage.sad .halo {
|
||||
background: radial-gradient(circle,
|
||||
rgba(37, 99, 235, 0.22) 0%,
|
||||
rgba(148, 163, 184, 0.16) 36%,
|
||||
rgba(37, 99, 235, 0.20) 0%,
|
||||
rgba(148, 163, 184, 0.14) 36%,
|
||||
transparent 70%);
|
||||
}
|
||||
.stage.play.sad .halo {
|
||||
@@ -160,7 +161,7 @@
|
||||
background: url('/PragatiVeer_Logo/SVG_Logos/Square_light_background.svg') center/cover no-repeat !important;
|
||||
}
|
||||
.stage.sad .badge {
|
||||
filter: saturate(0.8) brightness(0.9);
|
||||
filter: saturate(0.85) brightness(0.92);
|
||||
}
|
||||
|
||||
/* Replay tooltip / test hint */
|
||||
@@ -171,7 +172,7 @@
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
color: rgba(148, 163, 184, 0.8);
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
pointer-events: none;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -202,23 +203,23 @@
|
||||
}
|
||||
@keyframes droop {
|
||||
0% { transform: scale(1) translateY(0); }
|
||||
100% { transform: scale(0.93) translateY(8px); }
|
||||
100% { transform: scale(0.94) translateY(6px); }
|
||||
}
|
||||
@keyframes litglow {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
0% { opacity: 0; }
|
||||
18% { opacity: 0.85; }
|
||||
18% { opacity: 0.8; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@keyframes shake {
|
||||
@@ -232,7 +233,7 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stage.play .spinner { animation: none; transform: rotateY(0) scale(1); }
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
@@ -243,7 +244,7 @@
|
||||
<canvas id="ec"></canvas>
|
||||
<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="halo"></div>
|
||||
<div class="scaler">
|
||||
@@ -282,7 +283,7 @@
|
||||
}
|
||||
applyThemeClass(isLight);
|
||||
|
||||
// Poll for parent theme switch (e.g. user toggles theme on dashboard)
|
||||
// Poll for parent theme switch (instant visual update)
|
||||
setInterval(() => {
|
||||
const nowLight = detectLight();
|
||||
if (nowLight !== isLight) {
|
||||
@@ -292,7 +293,7 @@
|
||||
rebuildColors();
|
||||
play();
|
||||
}
|
||||
}, 200);
|
||||
}, 180);
|
||||
|
||||
// ── 2. Canvases & Sizing ────────────────────────────────────────────────
|
||||
const cv = document.getElementById('fw');
|
||||
@@ -301,21 +302,28 @@
|
||||
const ectx = ec.getContext('2d');
|
||||
|
||||
let W, H, DPR;
|
||||
let keepoutRadiusSq = 0;
|
||||
|
||||
function resize() {
|
||||
DPR = Math.min(2, window.devicePixelRatio || 1);
|
||||
W = cv.width = ec.width = Math.floor(innerWidth * DPR);
|
||||
H = cv.height = ec.height = Math.floor(innerHeight * DPR);
|
||||
cv.style.width = ec.style.width = innerWidth + '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();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// ── 3. High-Contrast Palettes ───────────────────────────────────────────
|
||||
let SPARK_COLORS, CONFETTI_COLORS, FLASH_COLORS;
|
||||
// ── 3. High-Contrast Jewel / Neon Palettes ───────────────────────────────
|
||||
let SPARK_COLORS, CONFETTI_COLORS;
|
||||
function rebuildColors() {
|
||||
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 = [
|
||||
'#0c6060', // Cyan AI / Deep Teal
|
||||
'#e11d48', // Vivid Crimson
|
||||
@@ -323,16 +331,14 @@
|
||||
'#d97706', // Amber Gold
|
||||
'#7c3aed', // Purple
|
||||
'#059669', // Emerald
|
||||
'#dc2626', // Bright Red
|
||||
'#ea580c', // Tangerine
|
||||
'#dc2626', // Coral Fire
|
||||
];
|
||||
CONFETTI_COLORS = [
|
||||
'#0c6060', '#e11d48', '#2563eb', '#d97706',
|
||||
'#7c3aed', '#059669', '#ea580c', '#0284c7'
|
||||
'#7c3aed', '#059669', '#0284c7'
|
||||
];
|
||||
FLASH_COLORS = ['#ffffff', '#fde047', '#38bdf8'];
|
||||
} else {
|
||||
// Luminous neon & gold that glow against dark background
|
||||
// Radiant gold & neon that glow brilliantly against dark background
|
||||
SPARK_COLORS = [
|
||||
'#fde047', // Brilliant Gold
|
||||
'#f59e0b', // Warm Gold
|
||||
@@ -345,14 +351,13 @@
|
||||
];
|
||||
CONFETTI_COLORS = [
|
||||
'#fde047', '#00F0FF', '#f43f5e', '#a855f7',
|
||||
'#34d399', '#ffffff', '#fbbf24'
|
||||
'#34d399', '#ffffff'
|
||||
];
|
||||
FLASH_COLORS = ['#ffffff', '#fef08a', '#7dd3fc'];
|
||||
}
|
||||
}
|
||||
rebuildColors();
|
||||
|
||||
// ── 4. Particle Systems ─────────────────────────────────────────────────
|
||||
// ── 4. Particle Systems & STRICT LOGO KEEP-OUT ─────────────────────────
|
||||
let particles = [];
|
||||
let emojiParticles = [];
|
||||
let shockwaves = [];
|
||||
@@ -363,85 +368,107 @@
|
||||
let fwTimers = [];
|
||||
let mode = 'fireworks'; // 'fireworks' | 'faces'
|
||||
|
||||
// Firecracker Shell Burst
|
||||
function firecrackerBurst(xr, yr, count) {
|
||||
// Grand Pyrotechnic Shell Burst — FIRED STRICTLY ON FLANKS (Left & Right)
|
||||
// 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 y = yr * H;
|
||||
const n = count | 0;
|
||||
|
||||
// Center shockwave
|
||||
// Outer flank shockwave
|
||||
shockwaves.push({
|
||||
x, y,
|
||||
r: 6 * DPR,
|
||||
maxR: (35 + Math.random() * 25) * DPR,
|
||||
vr: 2.5 * DPR,
|
||||
maxR: (34 + Math.random() * 20) * DPR,
|
||||
vr: 2.2 * DPR,
|
||||
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++) {
|
||||
const a = (Math.PI * 2 * i) / n + (Math.random() - 0.5) * 0.35;
|
||||
const sp = (2.2 + Math.random() * 4.8) * DPR;
|
||||
// Angle: bias outward away from the center logo!
|
||||
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];
|
||||
|
||||
particles.push({
|
||||
t: 'spark',
|
||||
x, y,
|
||||
vx: Math.cos(a) * sp,
|
||||
vy: Math.sin(a) * sp - 1.2 * DPR,
|
||||
vy: Math.sin(a) * sp - 1.1 * DPR,
|
||||
life: 1,
|
||||
decay: 0.012 + Math.random() * 0.014,
|
||||
decay: 0.013 + Math.random() * 0.014,
|
||||
col: color,
|
||||
size: (1.8 + Math.random() * 2.2) * DPR,
|
||||
isCrackle: Math.random() < 0.25 // occasional crackle spark
|
||||
size: (1.8 + Math.random() * 2.0) * DPR,
|
||||
isCrackle: Math.random() < 0.2
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Spinning Confetti Strips
|
||||
function spawnConfetti(count) {
|
||||
// Spinning Confetti Ribbons — FALL ONLY ON LEFT AND RIGHT FLANKS
|
||||
function spawnFlankConfetti(count) {
|
||||
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({
|
||||
t: 'conf',
|
||||
x: (0.2 + Math.random() * 0.6) * W,
|
||||
x: xr * W,
|
||||
y: -25 * DPR,
|
||||
vx: (-1.4 + Math.random() * 2.8) * DPR,
|
||||
vy: (1.6 + Math.random() * 2.6) * DPR,
|
||||
vx: (isRight ? 1 : -1) * (0.2 + Math.random() * 1.6) * DPR,
|
||||
vy: (1.6 + Math.random() * 2.4) * DPR,
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
vr: (-0.15 + Math.random() * 0.3),
|
||||
vr: (-0.14 + Math.random() * 0.28),
|
||||
life: 1,
|
||||
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]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Emojis Raining System (Celebration OR Crying) ─────────────────────
|
||||
// ── 5. Emojis Raining System — STRICTLY ON FLANKS ────────────────────────
|
||||
const CELEBRATE_EMOJIS = ['✨', '🎉', '🎊', '🔥', '⚡', '🌟', '💥', '🚀', '🏆', '💫'];
|
||||
const CRYING_EMOJIS = ['😭', '😢', '💧', '🥺', '💔', '🌧️', '😿', '⚡'];
|
||||
const CRYING_EMOJIS = ['😭', '😢', '💧', '🥺', '💔', '🌧️', '😿'];
|
||||
|
||||
function spawnEmojiRain(count, isSad) {
|
||||
function spawnFlankEmojiRain(count, isSad) {
|
||||
const list = isSad ? CRYING_EMOJIS : CELEBRATE_EMOJIS;
|
||||
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({
|
||||
x: (0.08 + Math.random() * 0.84) * W,
|
||||
x: xr * W,
|
||||
y: -35 * DPR,
|
||||
vx: (-0.8 + Math.random() * 1.6) * DPR,
|
||||
vy: (1.5 + Math.random() * 2.6) * DPR,
|
||||
rot: (-25 + Math.random() * 50) * Math.PI / 180,
|
||||
vx: (isRight ? 0.3 : -0.3) + (Math.random() - 0.5) * 0.8 * DPR,
|
||||
vy: (1.5 + Math.random() * 2.2) * DPR,
|
||||
rot: (-20 + Math.random() * 40) * Math.PI / 180,
|
||||
vr: (-0.02 + Math.random() * 0.04),
|
||||
life: 1,
|
||||
decay: 0.0035 + Math.random() * 0.004,
|
||||
size: (18 + Math.random() * 14) * DPR,
|
||||
decay: 0.0035 + Math.random() * 0.0035,
|
||||
size: (18 + Math.random() * 12) * DPR,
|
||||
emoji: list[(Math.random() * list.length) | 0],
|
||||
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) {
|
||||
const r = p.r;
|
||||
const s = p.s;
|
||||
@@ -453,7 +480,6 @@
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
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 fill = isLight ? '#f1f5f9' : 'rgba(148, 163, 184, 0.18)';
|
||||
|
||||
@@ -466,7 +492,7 @@
|
||||
ctx.strokeStyle = 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 bl = r * 0.32, tilt = (0.2 + s * 0.5);
|
||||
|
||||
@@ -495,7 +521,7 @@
|
||||
ctx.arc(ex, ey, er, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Downward curved trembling sad mouth
|
||||
// Downward sad mouth
|
||||
const mw = r * 0.44;
|
||||
const my = r * 0.52;
|
||||
const dip = r * (0.12 + s * 0.55);
|
||||
@@ -506,7 +532,7 @@
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
|
||||
// Animated Tear Drops Streaming Down
|
||||
// Animated Tear Drops
|
||||
const drop1 = (p.tp % 1);
|
||||
const drop2 = ((p.tp + 0.5) % 1);
|
||||
const tearColor = isLight ? '#0284c7' : '#38bdf8';
|
||||
@@ -529,11 +555,13 @@
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── 7. Animation Loop (Fireworks Canvas) ─────────────────────────────────
|
||||
// ── 7. Animation Loop (Fireworks with STRICT LOGO KEEP-OUT) ──────────────
|
||||
function tick() {
|
||||
// ALWAYS clearRect so modal background remains 100% crystal clean!
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
|
||||
// Shockwaves
|
||||
for (let i = shockwaves.length - 1; i >= 0; i--) {
|
||||
const sw = shockwaves[i];
|
||||
@@ -544,7 +572,7 @@
|
||||
continue;
|
||||
}
|
||||
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.strokeStyle = sw.col;
|
||||
ctx.beginPath();
|
||||
@@ -559,7 +587,7 @@
|
||||
|
||||
if (p.t === 'spark') {
|
||||
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.y += p.vy;
|
||||
p.life -= p.decay;
|
||||
@@ -569,10 +597,17 @@
|
||||
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.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.lineWidth = Math.max(1, p.size * p.life * 0.75);
|
||||
ctx.lineCap = 'round';
|
||||
@@ -581,17 +616,17 @@
|
||||
ctx.lineTo(p.x - p.vx * 2.8, p.y - p.vy * 2.8);
|
||||
ctx.stroke();
|
||||
|
||||
// Glowing Core / Head of spark
|
||||
// Glowing Spark Head
|
||||
ctx.fillStyle = p.col;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * p.life * 0.9, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Crackle mini twinkle for select sparks
|
||||
if (p.isCrackle && Math.random() < 0.3) {
|
||||
// Subtle crackle flash
|
||||
if (p.isCrackle && Math.random() < 0.25) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -615,6 +650,13 @@
|
||||
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.globalAlpha = Math.max(0, Math.min(1, p.life));
|
||||
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() {
|
||||
ectx.clearRect(0, 0, W, H);
|
||||
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
|
||||
for (let i = emojiParticles.length - 1; i >= 0; i--) {
|
||||
const p = emojiParticles[i];
|
||||
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.rot += p.vr;
|
||||
p.life -= p.decay;
|
||||
@@ -650,6 +695,13 @@
|
||||
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.globalAlpha = Math.min(1, Math.max(0, p.life));
|
||||
ectx.translate(p.x, p.y);
|
||||
@@ -684,35 +736,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── 10. Celebrate (Firecrackers + Sparkles + Emojis) ────────────────────
|
||||
function celebrate(t) {
|
||||
// ── 10. Celebrate (Side Flank Pyrotechnics & Flank Emojis) ───────────────
|
||||
function celebrate(intensity) {
|
||||
stopAll();
|
||||
mode = 'fireworks';
|
||||
running = true;
|
||||
if (!rafFW) rafFW = requestAnimationFrame(tick);
|
||||
|
||||
// Emoji celebration rain
|
||||
emojiRunning = true;
|
||||
if (!rafEmoji) rafEmoji = requestAnimationFrame(emojiTick);
|
||||
|
||||
const rounds = Math.round(5 + t * 9);
|
||||
const gap = Math.round(330 - t * 140);
|
||||
const base = Math.round(32 + t * 40);
|
||||
const rounds = Math.round(5 + intensity * 8);
|
||||
const gap = Math.round(340 - intensity * 130);
|
||||
const base = Math.round(30 + intensity * 36);
|
||||
|
||||
for (let k = 0; k < rounds; k++) {
|
||||
const d = k * gap;
|
||||
// Alternating left & right burst
|
||||
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));
|
||||
const isRight = (k % 2 === 1);
|
||||
|
||||
// Center burst on higher streak intensity
|
||||
if (t > 0.35) {
|
||||
fwTimers.push(setTimeout(() => firecrackerBurst(0.44 + Math.random() * 0.12, 0.18 + Math.random() * 0.14, base + 8), d + 120));
|
||||
}
|
||||
// Alternating left and right flank grand fireworks
|
||||
fwTimers.push(setTimeout(() => flankBurst(false, base), d));
|
||||
fwTimers.push(setTimeout(() => flankBurst(true, base), d + 90));
|
||||
|
||||
// Confetti & celebratory emoji waves
|
||||
fwTimers.push(setTimeout(() => spawnConfetti(4 + Math.round(t * 5)), d + 40));
|
||||
fwTimers.push(setTimeout(() => spawnEmojiRain(2 + Math.round(t * 3), false), d + 100));
|
||||
// Flank confetti and celebratory emojis (strictly on left/right wings)
|
||||
fwTimers.push(setTimeout(() => spawnFlankConfetti(3 + Math.round(intensity * 4)), d + 40));
|
||||
fwTimers.push(setTimeout(() => spawnFlankEmojiRain(2 + Math.round(intensity * 3), false), d + 80));
|
||||
}
|
||||
|
||||
fwTimers.push(setTimeout(() => {
|
||||
@@ -721,31 +769,30 @@
|
||||
}, rounds * gap + 1200));
|
||||
}
|
||||
|
||||
// ── 11. Crying Sad Mood (Broken Streak / Streak = 0) ────────────────────
|
||||
function sadFaces(s) {
|
||||
// ── 11. Crying Sad Mood (Streak <= 2: 0, 1, or 2 Days) ───────────────────
|
||||
function sadFaces(sadness) {
|
||||
stopAll();
|
||||
mode = 'faces';
|
||||
running = true;
|
||||
if (!rafFW) rafFW = requestAnimationFrame(tick);
|
||||
|
||||
// Sad crying emojis rain
|
||||
emojiRunning = true;
|
||||
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;
|
||||
|
||||
// Two persistent weeping cartoon faces
|
||||
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.84, y, r, s, phase: 1.6, tp: 0.6, fi: 0, life: 1 });
|
||||
// Two weeping cartoon faces on the side wings (completely clear of badge)
|
||||
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.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++) {
|
||||
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 reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
let moodTimer = null;
|
||||
@@ -761,8 +808,9 @@
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('mood') === 'sad') return true;
|
||||
if (params.get('mood') === 'happy') return false;
|
||||
// Streak 0 is Day 1 awaits / broken streak — triggers crying mood!
|
||||
return streakVal <= 0;
|
||||
// Streak <= 2 (0 days, 1 day, 2 days) is the fragile streak -> crying/weeping mood!
|
||||
// Streak >= 3 (3+ days) -> triumphant celebration!
|
||||
return streakVal <= 2;
|
||||
}
|
||||
|
||||
function play() {
|
||||
@@ -771,9 +819,8 @@
|
||||
|
||||
const streak = parseStreak();
|
||||
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');
|
||||
document.body.classList.remove('sad');
|
||||
|
||||
@@ -787,17 +834,16 @@
|
||||
stage.style.setProperty('--halo-w', (160 + intensity * 70).toFixed(0) + '%');
|
||||
}
|
||||
|
||||
// Force CSS reflow & trigger coin flip animation
|
||||
void stage.offsetWidth;
|
||||
stage.classList.add('play');
|
||||
|
||||
const sadness = isSad ? 1 : 0;
|
||||
const sadness = isSad ? Math.max(0.4, (3 - streak) / 3) : 0;
|
||||
if (reduce.matches) {
|
||||
if (isSad) setTimeout(() => sadFaces(sadness), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger after coin spin settles (~2400ms)
|
||||
// Settle after 3D coin spin (~2400ms)
|
||||
moodTimer = setTimeout(() => {
|
||||
if (isSad) {
|
||||
sadFaces(sadness);
|
||||
@@ -807,7 +853,7 @@
|
||||
}, 2450);
|
||||
}
|
||||
|
||||
// Click badge/stage to replay animation anytime
|
||||
// Click badge/stage to replay celebration anytime
|
||||
stage.addEventListener('click', () => {
|
||||
play();
|
||||
});
|
||||
|
||||
@@ -2,54 +2,97 @@
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Flame, Sparkles, X } from "lucide-react";
|
||||
import { useActiveUser } from "@/hooks/useActiveUser";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Flame, Sparkles, X, Droplets } from "lucide-react";
|
||||
import { useActiveUser, isDemoUser } from "@/hooks/useActiveUser";
|
||||
|
||||
export function StreakOnboardingModal() {
|
||||
const { user, loading } = useActiveUser();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const activeTheme = resolvedTheme ?? "light";
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState<"light" | "dark">("light");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const lastUserIdRef = useRef<string | null>(null);
|
||||
const lastThemeRef = useRef<string | null>(null);
|
||||
const [renderCount, setRenderCount] = useState(0);
|
||||
|
||||
// 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(() => {
|
||||
if (loading || !user) return;
|
||||
|
||||
// Detect theme transitions on dashboard
|
||||
const isFirstThemeLoad = lastThemeRef.current === null;
|
||||
const themeChanged = !isFirstThemeLoad && lastThemeRef.current !== activeTheme;
|
||||
lastThemeRef.current = activeTheme;
|
||||
const isDifferentUser = prevUserIdRef.current !== null && prevUserIdRef.current !== user.id;
|
||||
prevUserIdRef.current = user.id;
|
||||
|
||||
// Detect account switches (e.g. demo switcher between Neha, Rahul, Priya, Amit)
|
||||
const userChanged = lastUserIdRef.current !== null && lastUserIdRef.current !== user.id;
|
||||
lastUserIdRef.current = user.id;
|
||||
|
||||
// First visit check per user account
|
||||
// Show popup if:
|
||||
// 1. User account switched (demo plans: Neha, Rahul, Priya, Amit)
|
||||
// 2. First time user visits (not yet dismissed in session)
|
||||
// 3. User is on a demo account
|
||||
const hasSeen = sessionStorage.getItem(`hasSeenStreakPopup_${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) {
|
||||
if (isDifferentUser || !hasSeen || isDemoUser(user.id)) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [user, loading, activeTheme]);
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
}, [user, loading]);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const streak = user.streak_count;
|
||||
const isSad = streak <= 0;
|
||||
const streak = user.streak_count || 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";
|
||||
|
||||
// Streak-based wishes and titles
|
||||
@@ -59,7 +102,10 @@ export function StreakOnboardingModal() {
|
||||
if (streak === 0) {
|
||||
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!";
|
||||
} 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! 🔥";
|
||||
wishMessage = `Great job! You have maintained a ${streak}-day study streak. Keep practicing daily to build solid muscle memory!`;
|
||||
} else {
|
||||
@@ -81,13 +127,13 @@ export function StreakOnboardingModal() {
|
||||
trialDaysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// Key forces iframe remount whenever user or theme changes so animation replays fresh
|
||||
const iframeKey = `${user.id}-${activeTheme}-${streak}`;
|
||||
const iframeSrc = `/streak-animation.html?streak=${streak}&theme=${activeTheme}${isSad ? '&mood=sad' : ''}`;
|
||||
// Key forces iframe remount whenever user, theme, or streak changes
|
||||
const iframeKey = `${user.id}-${currentTheme}-${streak}-${renderCount}`;
|
||||
const iframeSrc = `/streak-animation.html?streak=${streak}&theme=${currentTheme}&mood=${isSad ? 'sad' : 'happy'}&v=${renderCount}`;
|
||||
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
{/* 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 -left-20 -bottom-20 size-48 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
{/* Top Celebration Animation Panel
|
||||
key forces iframe remount when user switches or theme changes
|
||||
so animation always replays fresh with the correct colours */}
|
||||
{/* Top Celebration Animation Panel (Clean, symphony-professional pyrotechnics) */}
|
||||
<div className="relative w-full h-[60%] z-10 pointer-events-none overflow-hidden bg-background">
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
@@ -126,10 +170,14 @@ export function StreakOnboardingModal() {
|
||||
<div className="space-y-4">
|
||||
{/* Badge Indicators */}
|
||||
<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">
|
||||
<Flame size={16} className="fill-current animate-bounce" /> {streak} Day Streak Active
|
||||
</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">
|
||||
<Sparkles size={16} className="animate-spin" style={{ animationDuration: '4s' }} /> Day 1 Awaits
|
||||
|
||||
@@ -19,14 +19,17 @@ export function ThemeToggle() {
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (isDark) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.setItem('theme', 'light');
|
||||
setIsDark(false)
|
||||
} else {
|
||||
const nextIsDark = !isDark
|
||||
if (nextIsDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
localStorage.setItem('theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark')
|
||||
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' } }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,11 @@ export function useActiveUser() {
|
||||
setUserIdState(newId);
|
||||
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 {
|
||||
// ── Run session update + new user profile fetch IN PARALLEL ─────────
|
||||
const [_, newUserRes] = await Promise.all([
|
||||
@@ -254,6 +259,9 @@ export function useActiveUser() {
|
||||
if (newUserRes && newUserRes.ok) {
|
||||
const data = await newUserRes.json();
|
||||
setUser(data);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("pragativeer-user-switched", { detail: { userId: newId, user: data } }));
|
||||
}
|
||||
} else {
|
||||
// Fallback: use the regular fetch path
|
||||
await fetchUserProfile(newId);
|
||||
|
||||
Reference in New Issue
Block a user