logicaffeine_web/ui/components/
xp_popup.rs1use dioxus::prelude::*;
17use crate::game::XpReward;
18use crate::audio::{SoundEffect, play_sound};
19
20const XP_POPUP_STYLE: &str = r#"
21.xp-popup {
22 position: fixed;
23 top: 50%;
24 left: 50%;
25 transform: translate(-50%, -50%);
26 z-index: 1000;
27 cursor: pointer;
28 animation: xp-appear 2s ease-out forwards;
29}
30
31@keyframes xp-appear {
32 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
33 10% { opacity: 1; transform: translate(-50%, -50%) scale(1.1); }
34 20% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
35 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
36 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9) translateY(-20px); }
37}
38
39.xp-popup-content {
40 background: rgba(0, 0, 0, 0.9);
41 border: 2px solid #667eea;
42 border-radius: 16px;
43 padding: 24px 40px;
44 text-align: center;
45 box-shadow: 0 0 40px rgba(102, 126, 234, 0.4);
46}
47
48.xp-total {
49 font-size: 48px;
50 font-weight: 700;
51 color: #4ade80;
52 margin-bottom: 8px;
53}
54
55.xp-total.critical {
56 color: #fbbf24;
57 text-shadow: 0 0 20px #fbbf24;
58 animation: critical-pulse 0.5s ease-in-out infinite alternate;
59}
60
61@keyframes critical-pulse {
62 from { text-shadow: 0 0 20px #fbbf24; }
63 to { text-shadow: 0 0 40px #fbbf24, 0 0 60px #f59e0b; }
64}
65
66.xp-breakdown {
67 display: flex;
68 flex-direction: column;
69 gap: 4px;
70 font-size: 14px;
71 color: #888;
72}
73
74.xp-line {
75 display: flex;
76 justify-content: space-between;
77 gap: 16px;
78}
79
80.xp-line.combo { color: #f97316; }
81.xp-line.streak { color: #06b6d4; }
82.xp-line.critical { color: #fbbf24; font-weight: 600; }
83.xp-line.first-try { color: #a78bfa; }
84"#;
85
86#[component]
87pub fn XpPopup(reward: XpReward, on_dismiss: EventHandler<()>) -> Element {
88 use_effect(move || {
89 if reward.is_critical {
90 play_sound(SoundEffect::CriticalHit);
91 } else {
92 play_sound(SoundEffect::XpGain);
93 }
94 });
95
96 use_effect(move || {
97 let handler = on_dismiss.clone();
98 spawn(async move {
99 gloo_timers::future::TimeoutFuture::new(2000).await;
100 handler.call(());
101 });
102 });
103
104 let total_class = if reward.is_critical { "xp-total critical" } else { "xp-total" };
105
106 rsx! {
107 style { "{XP_POPUP_STYLE}" }
108 div {
109 class: "xp-popup",
110 onclick: move |_| on_dismiss.call(()),
111 div { class: "xp-popup-content",
112 div { class: "{total_class}", "+{reward.total} XP" }
113 div { class: "xp-breakdown",
114 div { class: "xp-line",
115 span { "Base" }
116 span { "+{reward.base}" }
117 }
118 if reward.combo_bonus > 0 {
119 div { class: "xp-line combo",
120 span { "Combo Bonus" }
121 span { "+{reward.combo_bonus}" }
122 }
123 }
124 if reward.streak_bonus > 0 {
125 div { class: "xp-line streak",
126 span { "Streak Bonus" }
127 span { "+{reward.streak_bonus}" }
128 }
129 }
130 if reward.first_try_bonus > 0 {
131 div { class: "xp-line first-try",
132 span { "First Try" }
133 span { "+{reward.first_try_bonus}" }
134 }
135 }
136 if reward.critical_bonus > 0 {
137 div { class: "xp-line critical",
138 span { "CRITICAL!" }
139 span { "+{reward.critical_bonus}" }
140 }
141 }
142 }
143 }
144 }
145 }
146}