Keep chat pinned to the latest message while AI thinks or streams, and align buttons/inputs with the logo teal/blue focus treatment. Co-authored-by: Cursor <cursoragent@cursor.com>
289 lines
9.1 KiB
TypeScript
289 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { api, hasSessionToken } from '@/services/backend';
|
|
import { getLatestByPhase } from '@/services/assessmentService';
|
|
import { getProgramProgress } from '@/services/programService';
|
|
import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions';
|
|
|
|
/** Sessions 1..(N-1) done ⇒ learner is on final session; post-test may open manually. */
|
|
const POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS = PROGRAM_SESSION_COUNT - 1;
|
|
|
|
export type LearningStep =
|
|
| 'registered'
|
|
| 'pre_test_done'
|
|
| 'training_in_progress'
|
|
| 'training_completed'
|
|
| 'post_test_done'
|
|
| 'completed';
|
|
|
|
export interface LearningProgress {
|
|
user_id: string;
|
|
current_step: LearningStep;
|
|
pre_test_completed_at: string | null;
|
|
training_started_at: string | null;
|
|
training_completed_at: string | null;
|
|
post_test_completed_at: string | null;
|
|
updated_at?: string;
|
|
}
|
|
|
|
const getStorageKey = (userId: string) => `learning-progress:${userId}`;
|
|
|
|
const defaultProgress = (userId: string): LearningProgress => ({
|
|
user_id: userId,
|
|
current_step: 'registered',
|
|
pre_test_completed_at: null,
|
|
training_started_at: null,
|
|
training_completed_at: null,
|
|
post_test_completed_at: null
|
|
});
|
|
|
|
const readLocalProgress = (userId: string): LearningProgress => {
|
|
try {
|
|
const saved = localStorage.getItem(getStorageKey(userId));
|
|
return saved ? (JSON.parse(saved) as LearningProgress) : defaultProgress(userId);
|
|
} catch {
|
|
return defaultProgress(userId);
|
|
}
|
|
};
|
|
|
|
const writeLocalProgress = (progress: LearningProgress) => {
|
|
localStorage.setItem(getStorageKey(progress.user_id), JSON.stringify(progress));
|
|
};
|
|
|
|
/** Mirrors backend deriveCurrentStep (timestamps are authoritative). */
|
|
const deriveStep = (progress: LearningProgress): LearningStep => {
|
|
if (progress.post_test_completed_at) return 'completed';
|
|
if (progress.training_completed_at) return 'training_completed';
|
|
if (progress.training_started_at && progress.pre_test_completed_at) return 'training_in_progress';
|
|
if (progress.pre_test_completed_at) return 'pre_test_done';
|
|
return 'registered';
|
|
};
|
|
|
|
export function useLearningJourney(userId?: string) {
|
|
/** Offline guest ids are `guest-…` and have no backend session. */
|
|
const useLocalOnly = !hasSessionToken() || Boolean(userId?.startsWith('guest-'));
|
|
|
|
const [progress, setProgress] = useState<LearningProgress | null>(null);
|
|
const [backendPermissions, setBackendPermissions] = useState<{
|
|
canTakePreTest: boolean;
|
|
canOpenTraining: boolean;
|
|
canTakePostTest: boolean;
|
|
isCompleted: boolean;
|
|
} | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
/** Skeleton / blocking load only on first fetch — silent on reloadProgress. */
|
|
const hasLoadedOnceRef = useRef(false);
|
|
|
|
const mapBackendJourney = useCallback((journey: {
|
|
currentStep: LearningStep;
|
|
preTestCompletedAt: string | null;
|
|
trainingStartedAt: string | null;
|
|
trainingCompletedAt: string | null;
|
|
postTestCompletedAt: string | null;
|
|
lastActivityAt: string | null;
|
|
}): LearningProgress => ({
|
|
user_id: userId ?? '',
|
|
current_step: journey.currentStep,
|
|
pre_test_completed_at: journey.preTestCompletedAt,
|
|
training_started_at: journey.trainingStartedAt,
|
|
training_completed_at: journey.trainingCompletedAt,
|
|
post_test_completed_at: journey.postTestCompletedAt,
|
|
updated_at: journey.lastActivityAt ?? undefined,
|
|
}), [userId]);
|
|
|
|
const mergeAssessmentCompletion = useCallback(
|
|
async (base: LearningProgress): Promise<LearningProgress> => {
|
|
try {
|
|
const [preAttempt, postAttempt] = await Promise.all([
|
|
getLatestByPhase(base.user_id, 'pre'),
|
|
getLatestByPhase(base.user_id, 'post'),
|
|
]);
|
|
|
|
const merged: LearningProgress = {
|
|
...base,
|
|
pre_test_completed_at: base.pre_test_completed_at ?? preAttempt?.completedAt ?? null,
|
|
training_started_at:
|
|
base.training_started_at ??
|
|
base.pre_test_completed_at ??
|
|
preAttempt?.completedAt ??
|
|
null,
|
|
post_test_completed_at: base.post_test_completed_at ?? postAttempt?.completedAt ?? null,
|
|
};
|
|
merged.current_step = deriveStep(merged);
|
|
return merged;
|
|
} catch {
|
|
return base;
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const loadProgress = useCallback(async () => {
|
|
if (!userId) {
|
|
setProgress(null);
|
|
setBackendPermissions(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const isInitialLoad = !hasLoadedOnceRef.current;
|
|
if (isInitialLoad) {
|
|
setLoading(true);
|
|
}
|
|
|
|
if (useLocalOnly) {
|
|
const local = await mergeAssessmentCompletion(readLocalProgress(userId));
|
|
setProgress(local);
|
|
setBackendPermissions(null);
|
|
hasLoadedOnceRef.current = true;
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const journey = await api.get<{
|
|
currentStep: LearningStep;
|
|
preTestCompletedAt: string | null;
|
|
trainingStartedAt: string | null;
|
|
trainingCompletedAt: string | null;
|
|
postTestCompletedAt: string | null;
|
|
lastActivityAt: string | null;
|
|
permissions: {
|
|
canTakePreTest: boolean;
|
|
canOpenTraining: boolean;
|
|
canTakePostTest: boolean;
|
|
isCompleted: boolean;
|
|
};
|
|
}>('/journey');
|
|
const mapped = mapBackendJourney(journey);
|
|
const merged = await mergeAssessmentCompletion(mapped);
|
|
setProgress(merged);
|
|
setBackendPermissions(journey.permissions);
|
|
} catch (error) {
|
|
console.error('Error loading learning progress:', error);
|
|
const local = readLocalProgress(userId);
|
|
const merged = await mergeAssessmentCompletion(local);
|
|
setProgress(merged);
|
|
setBackendPermissions(null);
|
|
} finally {
|
|
hasLoadedOnceRef.current = true;
|
|
setLoading(false);
|
|
}
|
|
}, [mapBackendJourney, mergeAssessmentCompletion, userId, useLocalOnly]);
|
|
|
|
useEffect(() => {
|
|
hasLoadedOnceRef.current = false;
|
|
}, [userId]);
|
|
|
|
useEffect(() => {
|
|
loadProgress();
|
|
}, [loadProgress]);
|
|
|
|
const saveProgress = useCallback(
|
|
async (next: LearningProgress) => {
|
|
// Optimistic update - فوری state را update کن
|
|
setProgress(next);
|
|
|
|
if (useLocalOnly) {
|
|
writeLocalProgress(next);
|
|
return;
|
|
}
|
|
|
|
// Backend is source of truth for authenticated users.
|
|
// Do not write journey state directly from frontend.
|
|
writeLocalProgress(next);
|
|
},
|
|
[useLocalOnly]
|
|
);
|
|
|
|
const markPreTestDone = useCallback(async (completedAt?: string) => {
|
|
const now = completedAt ?? new Date().toISOString();
|
|
const base = progress ?? {
|
|
user_id: userId ?? '',
|
|
current_step: 'registered' as const,
|
|
pre_test_completed_at: null,
|
|
training_started_at: null,
|
|
training_completed_at: null,
|
|
post_test_completed_at: null
|
|
};
|
|
const next = {
|
|
...base,
|
|
current_step: 'training_in_progress' as const,
|
|
pre_test_completed_at: base.pre_test_completed_at || now,
|
|
training_started_at: base.training_started_at || now
|
|
};
|
|
await saveProgress(next);
|
|
return true;
|
|
}, [progress, saveProgress, userId]);
|
|
|
|
const markTrainingDone = useCallback(async () => {
|
|
if (!userId) return false;
|
|
if (!useLocalOnly) {
|
|
await loadProgress();
|
|
return true;
|
|
}
|
|
const now = new Date().toISOString();
|
|
const base = progress ?? defaultProgress(userId);
|
|
if (base.training_completed_at) {
|
|
return true;
|
|
}
|
|
const next = {
|
|
...base,
|
|
user_id: userId,
|
|
current_step: 'training_completed' as const,
|
|
training_started_at: base.training_started_at || now,
|
|
training_completed_at: now
|
|
};
|
|
await saveProgress(next);
|
|
return true;
|
|
}, [loadProgress, progress, saveProgress, useLocalOnly, userId]);
|
|
|
|
const markPostTestDone = useCallback(async (completedAt?: string) => {
|
|
if (!userId) return false;
|
|
const now = completedAt ?? new Date().toISOString();
|
|
const base = progress ?? defaultProgress(userId);
|
|
if (base.post_test_completed_at) {
|
|
return true;
|
|
}
|
|
const next = {
|
|
...base,
|
|
user_id: userId,
|
|
current_step: 'completed' as const,
|
|
post_test_completed_at: now
|
|
};
|
|
await saveProgress(next);
|
|
return true;
|
|
}, [progress, saveProgress, userId]);
|
|
|
|
const derivedPermissions = useMemo(() => {
|
|
const completedSessions = userId
|
|
? getProgramProgress(userId).completedSessions.length
|
|
: 0;
|
|
const reachedFinalSession =
|
|
Boolean(progress?.training_completed_at) ||
|
|
completedSessions >= POST_TEST_UNLOCK_MIN_COMPLETED_SESSIONS;
|
|
|
|
return {
|
|
canTakePreTest: !progress?.pre_test_completed_at,
|
|
canOpenTraining: Boolean(progress?.pre_test_completed_at),
|
|
canTakePostTest: Boolean(
|
|
progress?.pre_test_completed_at &&
|
|
reachedFinalSession &&
|
|
!progress?.post_test_completed_at
|
|
),
|
|
isCompleted: Boolean(progress?.post_test_completed_at)
|
|
};
|
|
}, [progress, userId]);
|
|
|
|
const permissions = backendPermissions ?? derivedPermissions;
|
|
|
|
return {
|
|
progress,
|
|
loading,
|
|
permissions,
|
|
reloadProgress: loadProgress,
|
|
markPreTestDone,
|
|
markTrainingDone,
|
|
markPostTestDone
|
|
};
|
|
}
|