Ship Novya PWA with backend for deployable production use.
Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, hasSessionToken } from '@/services/backend';
|
||||
import { getLatestByPhase } from '@/services/assessmentService';
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (useLocalOnly) {
|
||||
const local = await mergeAssessmentCompletion(readLocalProgress(userId));
|
||||
setProgress(local);
|
||||
setBackendPermissions(null);
|
||||
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 {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mapBackendJourney, mergeAssessmentCompletion, userId, useLocalOnly]);
|
||||
|
||||
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(() => {
|
||||
return {
|
||||
canTakePreTest: !progress?.pre_test_completed_at,
|
||||
canOpenTraining: Boolean(progress?.pre_test_completed_at),
|
||||
canTakePostTest: Boolean(
|
||||
progress?.pre_test_completed_at &&
|
||||
progress?.training_completed_at &&
|
||||
!progress?.post_test_completed_at
|
||||
),
|
||||
isCompleted: Boolean(progress?.post_test_completed_at)
|
||||
};
|
||||
}, [progress]);
|
||||
|
||||
const permissions = backendPermissions ?? derivedPermissions;
|
||||
|
||||
return {
|
||||
progress,
|
||||
loading,
|
||||
permissions,
|
||||
reloadProgress: loadProgress,
|
||||
markPreTestDone,
|
||||
markTrainingDone,
|
||||
markPostTestDone
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user