Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,6 @@ export default function LessonDetailPage({ params }: PageProps) {
console.warn('⚠️ Backend did not fully award exp, attempting direct user-service call...');
try {
await userApi.addExperience({ exp: awardedExp });
console.log('✅ Successfully awarded exp via fallback mechanism');
} catch (fallbackError) {
console.error('❌ Fallback exp award also failed:', fallbackError);
showToast.error('Không thể cộng điểm kinh nghiệm. Vui lòng kiểm tra lại sau.');
Expand Down Expand Up @@ -761,16 +760,6 @@ export default function LessonDetailPage({ params }: PageProps) {
);
})}
</div>

{nextLessonId && (
<button
type="button"
onClick={() => router.push(`/user/my-courses/${courseId}/lessons/${nextLessonId}`)}
className="w-full px-3 py-2 rounded-lg bg-gradient-to-r from-orange-500 to-orange-600 text-white text-xs font-semibold hover:from-orange-600 hover:to-orange-700 shadow-sm transition-all"
>
Bài tiếp theo →
</button>
)}
</CardContent>
</Card>
</aside>
Expand Down
16 changes: 9 additions & 7 deletions frontend/src/app/user/my-courses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,10 @@ function MyCoursesContent() {
</div>
</div>

<div className="flex flex-wrap items-center gap-3 justify-between">
<div className="relative flex-1 min-w-[240px]">
{/* Mobile-friendly layout */}
<div className="flex flex-col gap-3">
{/* Search bar - full width on mobile */}
<div className="relative w-full">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
Expand All @@ -409,8 +411,8 @@ function MyCoursesContent() {
</svg>
</div>

{/* View Mode Toggle */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
{/* View Mode Toggle - Hide on mobile */}
<div className="hidden md:flex items-center gap-1 bg-gray-100 p-1 rounded-lg self-end">
<button
onClick={() => setViewMode('grid')}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
Expand Down Expand Up @@ -573,7 +575,7 @@ function MyCoursesContent() {
)}

{!loading && !error && (
<div className={viewMode === 'grid' ? 'grid gap-5 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr' : 'space-y-4'}>
<div className={viewMode === 'grid' ? 'grid gap-4 sm:gap-5 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr' : 'space-y-4'}>
{remaining.map((course) => (
<div
key={course.id}
Expand All @@ -583,7 +585,7 @@ function MyCoursesContent() {
</div>
))}
{remaining.length === 0 && courses.length > 0 && (
<div className="bg-white rounded-xl p-10 text-center border border-dashed border-gray-300 col-span-full">
<div className="bg-white rounded-xl p-8 sm:p-10 text-center border border-dashed border-gray-300 col-span-full">
<p className="text-gray-600">Không tìm thấy khóa học phù hợp với bộ lọc.</p>
<button
onClick={() => { setSearch(''); setLevelFilter('all'); setStatusFilter('all'); }}
Expand All @@ -594,7 +596,7 @@ function MyCoursesContent() {
</div>
)}
{courses.length === 0 && (
<div className="bg-white rounded-xl p-12 text-center border border-dashed border-gray-300 col-span-full">
<div className="bg-white rounded-xl p-8 sm:p-12 text-center border border-dashed border-gray-300 col-span-full">
<svg className="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Expand Down
63 changes: 40 additions & 23 deletions frontend/src/app/user/my-quizzes/[quizId]/play/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export default function QuizPlayPage({ params }: PageProps) {
const timerRef = useRef<NodeJS.Timeout | null>(null);
const [timerFrozen, setTimerFrozen] = useState(false);
const freezeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isProcessingTimeout = useRef(false);

// Power-ups
const [powerUps, setPowerUps] = useState<PowerUp[]>([
Expand Down Expand Up @@ -193,10 +194,6 @@ export default function QuizPlayPage({ params }: PageProps) {
const responseData = userInfoResp?.data as { Info?: { current_exp?: number; subscription?: number }; info?: { current_exp?: number; subscription?: number } } | undefined;
// Try both Info (capital) and info (lowercase) for compatibility
const userInfo = responseData?.Info || responseData?.info;
console.log('User info response:', responseData);
console.log('Extracted user info:', userInfo);
console.log('Current EXP:', userInfo?.current_exp);
console.log('Subscription:', userInfo?.subscription);

const exp = userInfo?.current_exp || 0;
const subscription = userInfo?.subscription || 0;
Expand Down Expand Up @@ -233,10 +230,22 @@ export default function QuizPlayPage({ params }: PageProps) {
}, [quizId]);

const handleTimeUp = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current);
// Prevent duplicate execution
if (isProcessingTimeout.current) {
return;
}
isProcessingTimeout.current = true;

if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}

const currentCard = quiz?.cards[currentIndex];
if (!currentCard) return;
if (!currentCard) {
isProcessingTimeout.current = false;
return;
}

// Show toast after state updates to avoid setState in render
setTimeout(() => {
Expand All @@ -257,6 +266,8 @@ export default function QuizPlayPage({ params }: PageProps) {
// Auto submit on last question
handleSubmit();
}
// Reset flag after transition
isProcessingTimeout.current = false;
}, 1500);
}, [quiz, currentIndex, answers, timePerQuestion]);

Expand All @@ -266,9 +277,9 @@ export default function QuizPlayPage({ params }: PageProps) {

timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 0) {
// Time's up for this question
handleTimeUp();
if (prev <= 1) {

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition changed from prev <= 0 to prev <= 1, which means the timeout will trigger 1 second earlier than before. This changes the business logic and may not align with user expectations. If a quiz question has 10 seconds, users would expect to see "0" before the timeout, not have it trigger when there's still 1 second remaining. Consider reverting to prev <= 0 or explaining why this change is necessary.

Suggested change
if (prev <= 1) {
if (prev <= 0) {

Copilot uses AI. Check for mistakes.
// Time's up for this question - use ref to avoid dependency issues
handleTimeUpRef.current();
return 0;
}

Expand All @@ -282,9 +293,12 @@ export default function QuizPlayPage({ params }: PageProps) {
}, 1000);

return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [quiz, submitted, loading, currentIndex, timePerQuestion, handleTimeUp, timerFrozen]);
}, [quiz, submitted, loading, currentIndex, timePerQuestion, timerFrozen]);

const handleTimeUpRef = useRef(handleTimeUp);
useEffect(() => {
Expand Down Expand Up @@ -576,13 +590,11 @@ export default function QuizPlayPage({ params }: PageProps) {

// Calculate average score for saving as previous_score
const avgScore = quiz ? Math.round(actualTotalScore / quiz.cards.length) : 0;
console.log('Average score for quiz:', avgScore);

// Update previous_score if this score is higher
if (avgScore > (quiz.previous_score || 0)) {
try {
await quizApi.updatePreviousScore(quizId, avgScore);
console.log('Updated previous_score to:', avgScore);
} catch (updateError) {
console.error('Failed to update previous_score:', updateError);
// Don't show error to user, just log it
Expand Down Expand Up @@ -618,10 +630,15 @@ export default function QuizPlayPage({ params }: PageProps) {
setEliminatedOptions({});
setShowHint({});
setPowerUps(prev => prev.map(p => ({ ...p, usesLeft: p.maxUses, active: false })));
isProcessingTimeout.current = false;

if (freezeTimeoutRef.current) {
clearTimeout(freezeTimeoutRef.current);
}
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}

// Reload to fetch fresh user EXP
window.location.reload();
Expand Down Expand Up @@ -860,12 +877,12 @@ export default function QuizPlayPage({ params }: PageProps) {
</div>

{/* Power-ups */}
<div className="bg-white/70 backdrop-blur-sm rounded-xl p-4 shadow-sm border border-gray-100">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-purple-600" />
<h3 className="font-bold text-gray-900 text-sm">Items (EXP: {userExp})</h3>
<div className="bg-white/70 backdrop-blur-sm rounded-xl p-3 sm:p-4 shadow-sm border border-gray-100">
<div className="flex items-center gap-1.5 sm:gap-2 mb-2 sm:mb-3">
<Zap className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-purple-600" />
<h3 className="font-bold text-gray-900 text-xs sm:text-sm">Items (EXP: {userExp})</h3>
</div>
<div className="flex gap-2 flex-wrap">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-1.5 sm:gap-2">
{powerUps.map((powerUp) => {
const isActive = powerUp.active;
const canUse = powerUp.usesLeft > 0 && userExp >= powerUp.cost;
Expand All @@ -883,7 +900,7 @@ export default function QuizPlayPage({ params }: PageProps) {
key={powerUp.id}
onClick={() => activatePowerUp(powerUp.id)}
disabled={!canUse || isActive}
className={`group relative flex items-center gap-2 px-4 py-2.5 rounded-lg border-2 font-semibold text-sm transition-all ${
className={`group relative flex flex-col lg:flex-row items-center justify-center lg:justify-start gap-1 sm:gap-1.5 lg:gap-2 px-2 sm:px-3 lg:px-4 py-2 sm:py-2.5 rounded-lg border-2 font-semibold text-[10px] sm:text-xs lg:text-sm transition-all ${

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text size text-[10px] is extremely small and will be difficult to read on mobile devices. Consider using at least text-xs (12px) for better accessibility.

Suggested change
className={`group relative flex flex-col lg:flex-row items-center justify-center lg:justify-start gap-1 sm:gap-1.5 lg:gap-2 px-2 sm:px-3 lg:px-4 py-2 sm:py-2.5 rounded-lg border-2 font-semibold text-[10px] sm:text-xs lg:text-sm transition-all ${
className={`group relative flex flex-col lg:flex-row items-center justify-center lg:justify-start gap-1 sm:gap-1.5 lg:gap-2 px-2 sm:px-3 lg:px-4 py-2 sm:py-2.5 rounded-lg border-2 font-semibold text-xs sm:text-xs lg:text-sm transition-all ${

Copilot uses AI. Check for mistakes.
isActive
? 'border-green-500 bg-green-50 text-green-700'
: canUse
Expand All @@ -892,11 +909,11 @@ export default function QuizPlayPage({ params }: PageProps) {
}`}
title={powerUp.description}
>
<PowerUpIcon className="w-4 h-4" />
<span>{powerUp.name}</span>
<span className="text-xs opacity-75">({powerUp.usesLeft}/{powerUp.maxUses})</span>
<PowerUpIcon className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="text-center lg:text-left leading-tight">{powerUp.name}</span>
<span className="text-[9px] sm:text-xs opacity-75">({powerUp.usesLeft}/{powerUp.maxUses})</span>

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The text size text-[9px] is extremely small (9px) and will be very difficult to read on mobile devices. This is below accessibility standards. Consider using at least text-xs (12px).

Copilot uses AI. Check for mistakes.
{canUse && !isActive && (
<span className="absolute -top-2 -right-2 bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
<span className="absolute -top-1.5 sm:-top-2 -right-1.5 sm:-right-2 bg-purple-600 text-white text-[9px] sm:text-xs px-1 sm:px-1.5 py-0.5 rounded-full">
-{powerUp.cost}
</span>
)}
Expand Down
26 changes: 13 additions & 13 deletions frontend/src/app/user/my-quizzes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,9 @@ function MyQuizzesContent() {
</div>
</div>

<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="relative">
<div className="space-y-4 sm:space-y-6">
<div className="flex flex-col gap-3">
<div className="relative w-full">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
Expand All @@ -253,14 +253,14 @@ function MyQuizzesContent() {
</svg>
</div>

<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Độ khó</label>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide shrink-0">Độ khó</label>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value as LevelFilter)}
className="h-10 pl-3 pr-8 rounded-lg bg-white border border-gray-200 shadow-sm text-sm focus:ring-2 focus:ring-sky-500/40 focus:border-sky-400"
className="flex-1 sm:flex-initial h-10 pl-3 pr-8 rounded-lg bg-white border border-gray-200 shadow-sm text-sm focus:ring-2 focus:ring-sky-500/40 focus:border-sky-400"
>
<option value="all">Tất cả</option>
<option value="easy">Dễ</option>
Expand All @@ -270,12 +270,12 @@ function MyQuizzesContent() {
</div>

<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide hidden sm:block">Sắp xếp</label>
<div className="relative">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide shrink-0">Sắp xếp</label>
<div className="relative flex-1 sm:flex-initial">
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
className="appearance-none h-10 pl-4 pr-10 rounded-lg bg-white border border-gray-200 shadow-sm text-sm focus:ring-2 focus:ring-sky-500/40 focus:border-sky-400 cursor-pointer"
className="appearance-none w-full h-10 pl-4 pr-10 rounded-lg bg-white border border-gray-200 shadow-sm text-sm focus:ring-2 focus:ring-sky-500/40 focus:border-sky-400 cursor-pointer"
>
<option value="latest">Mới nhất</option>
<option value="questions_desc">Số câu hỏi giảm dần</option>
Expand Down Expand Up @@ -318,8 +318,8 @@ function MyQuizzesContent() {

<div>
{paginatedQuizzes.length === 0 ? (
<div className="bg-white rounded-xl p-12 text-center border border-dashed border-gray-300">
<p className="text-gray-600 mb-4">
<div className="bg-white rounded-xl p-8 sm:p-12 text-center border border-dashed border-gray-300">
<p className="text-gray-600 mb-4 text-sm sm:text-base">
{filteredMy.length === 0 && (activeTab === 'my' ? myQuizzes : publicQuizzes).length === 0
? (activeTab === 'my' ? 'Bạn chưa có quiz nào.' : 'Chưa có quiz công khai nào.')
: 'Không tìm thấy quiz phù hợp.'}
Expand All @@ -335,7 +335,7 @@ function MyQuizzesContent() {
</div>
) : (
<>
<div className={viewMode === 'grid' ? 'grid gap-5 sm:grid-cols-2 lg:grid-cols-3' : 'space-y-4'}>
<div className={viewMode === 'grid' ? 'grid gap-4 sm:gap-5 md:grid-cols-2 lg:grid-cols-3' : 'space-y-4'}>
{paginatedQuizzes.map((quiz) => (
<QuizCard key={quiz.id || quiz.quiz_id} quiz={quiz} viewMode={viewMode} />
))}
Expand Down
Loading
Loading