Skip to content

Commit 3090d29

Browse files
committed
fix: add use client, error boundary, extract ToastContainer, pure setState, fix deploy gate
1 parent 2df3ba7 commit 3090d29

6 files changed

Lines changed: 88 additions & 32 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ jobs:
2121
check-name: 'Backend Tests'
2222
repo-token: ${{ secrets.GITHUB_TOKEN }}
2323
wait-interval: 10
24-
allowed-conclusions: success,failure
25-
continue-on-error: true
24+
allowed-conclusions: success
2625

2726
- name: Wait for frontend tests
2827
uses: lewagon/wait-on-check-action@v1.3.1

frontend/app/dashboard/page.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useState, useCallback } from "react";
3+
import { useEffect, useRef, useState, useCallback } from "react";
44
import { motion } from "framer-motion";
55
import { useRouter } from "next/navigation";
66
import Link from "next/link";
@@ -9,7 +9,7 @@ import XPProgressBar from "@/components/XPProgressBar";
99
import TopicCard from "@/components/TopicCard";
1010
import RecommendedCard from "@/components/RecommendedCard";
1111
import LoadingScreen from "@/components/LoadingScreen";
12-
import { useToast } from "@/components/Toast";
12+
import { useToast, ToastContainer } from "@/components/Toast";
1313
import { useRealtimeXP } from "@/lib/useRealtimeXP";
1414
import { useAuth } from "@/lib/useAuth";
1515
import { useLoadingState } from "@/hooks/useLoadingState";
@@ -61,6 +61,7 @@ export default function Dashboard() {
6161
const { userId, user, loading: authLoading } = useAuth();
6262
const router = useRouter();
6363
const [progress, setProgress] = useState<UserProgress | null>(null);
64+
const prevLevelRef = useRef<number>(-1);
6465
const [recommendations, setRecommendations] =
6566
useState<RecommendationResponse | null>(null);
6667
const { isLoading, withLoading } = useLoadingState(true);
@@ -90,7 +91,7 @@ export default function Dashboard() {
9091
});
9192

9293
// Real-time updates
93-
const { showToast, ToastContainer } = useToast();
94+
const { showToast, toasts, dismissToast } = useToast();
9495

9596
// Stable callbacks for real-time events
9697
const handleXPGain = useCallback(
@@ -103,17 +104,11 @@ export default function Dashboard() {
103104
});
104105
showToast(`${topic || "Study"} completed!`, xp, "xp");
105106

106-
// Update progress with new XP
107+
// Update progress with new XP — no side effects inside the updater
107108
setProgress((prev) => {
108109
if (!prev) return prev;
109110
const newTotalXP = prev.total_xp + xp;
110111
const newLevel = Math.floor(newTotalXP / 500) + 1;
111-
112-
// Check for level up
113-
if (newLevel > prev.level) {
114-
handleLevelUp(newLevel);
115-
}
116-
117112
return {
118113
...prev,
119114
total_xp: newTotalXP,
@@ -190,6 +185,20 @@ export default function Dashboard() {
190185
[],
191186
);
192187

188+
// Detect level-up after state update — side effect kept outside the setState updater
189+
useEffect(() => {
190+
if (!progress) return;
191+
if (prevLevelRef.current === -1) {
192+
// First load: record baseline level without triggering level-up
193+
prevLevelRef.current = progress.level;
194+
return;
195+
}
196+
if (progress.level > prevLevelRef.current) {
197+
handleLevelUp(progress.level);
198+
}
199+
prevLevelRef.current = progress.level;
200+
}, [progress?.level]); // eslint-disable-line react-hooks/exhaustive-deps
201+
193202
// Subscribe to real-time XP updates
194203
const { isConnected } = useRealtimeXP({
195204
userId: userId || "",
@@ -571,7 +580,7 @@ export default function Dashboard() {
571580
</div>
572581

573582
{/* Toast Notifications */}
574-
<ToastContainer />
583+
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
575584

576585
{/* Celebration Modal */}
577586
<CelebrationModal

frontend/app/error.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
5+
interface ErrorProps {
6+
error: Error & { digest?: string };
7+
reset: () => void;
8+
}
9+
10+
export default function Error({ error, reset }: ErrorProps) {
11+
useEffect(() => {
12+
console.error(error);
13+
}, [error]);
14+
15+
return (
16+
<div className="min-h-screen bg-terminal-black text-terminal-white flex items-center justify-center p-8 font-mono">
17+
<div className="border border-terminal-white p-8 max-w-md w-full">
18+
<div className="text-terminal-gray text-xs mb-2">// RUNTIME_ERROR</div>
19+
<h2 className="text-2xl font-bold mb-4">[!] SOMETHING_WENT_WRONG</h2>
20+
<p className="text-terminal-gray text-sm mb-6 break-all">
21+
{error.message || "An unexpected error occurred."}
22+
</p>
23+
{error.digest && (
24+
<p className="text-terminal-gray text-xs mb-6">
25+
digest: {error.digest}
26+
</p>
27+
)}
28+
<button
29+
onClick={reset}
30+
className="w-full bg-terminal-black text-terminal-white border border-terminal-white px-6 py-3 hover:bg-terminal-white hover:text-terminal-black transition-colors"
31+
>
32+
RETRY()
33+
</button>
34+
</div>
35+
</div>
36+
);
37+
}

frontend/components/Header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import { motion } from "framer-motion";
24
import Link from "next/link";
35
import { BlinkingCursor } from "./TypingText";

frontend/components/RecommendedCard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use client";
2+
13
import { useState } from "react";
24
import { useRouter } from "next/navigation";
35
import { motion } from "framer-motion";

frontend/components/Toast.tsx

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,31 @@ function XPToast({ toast, onDismiss }: XPToastProps) {
5353
);
5454
}
5555

56+
interface ToastContainerProps {
57+
toasts: Toast[];
58+
onDismiss: (id: string) => void;
59+
}
60+
61+
export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
62+
return (
63+
<AnimatePresence mode="popLayout">
64+
{toasts.map((toast, index) => (
65+
<motion.div
66+
key={toast.id}
67+
style={{
68+
position: "fixed",
69+
top: `${32 + index * 80}px`,
70+
left: "50%",
71+
zIndex: 50,
72+
}}
73+
>
74+
<XPToast toast={toast} onDismiss={onDismiss} />
75+
</motion.div>
76+
))}
77+
</AnimatePresence>
78+
);
79+
}
80+
5681
let toastCounter = 0;
5782

5883
export function useToast() {
@@ -71,23 +96,5 @@ export function useToast() {
7196
setToasts((prev) => prev.filter((t) => t.id !== id));
7297
}, []);
7398

74-
const ToastContainer = () => (
75-
<AnimatePresence mode="popLayout">
76-
{toasts.map((toast, index) => (
77-
<motion.div
78-
key={toast.id}
79-
style={{
80-
position: "fixed",
81-
top: `${32 + index * 80}px`,
82-
left: "50%",
83-
zIndex: 50,
84-
}}
85-
>
86-
<XPToast toast={toast} onDismiss={dismissToast} />
87-
</motion.div>
88-
))}
89-
</AnimatePresence>
90-
);
91-
92-
return { showToast, ToastContainer };
99+
return { showToast, toasts, dismissToast };
93100
}

0 commit comments

Comments
 (0)