| import { getActivityDateKey } from './activityDates'; |
|
|
| export const AUTH_TOKEN_KEY = 'ryp_auth_token'; |
|
|
| const LOCAL_AUTH_TOKEN_PREFIX = 'local-demo:'; |
| const LOCAL_AUTH_USERS_KEY = 'ryp_local_users'; |
|
|
| export type AuthUser = { |
| id: string; |
| email: string; |
| displayName: string; |
| photoURL?: string; |
| createdAt: string; |
| currentStreak: number; |
| longestStreak: number; |
| coins: number; |
| }; |
|
|
| export type CoinTransaction = { |
| id: string; |
| userId: string; |
| amount: number; |
| source: string; |
| category: string; |
| createdAt: string; |
| }; |
|
|
| export type ProgressLeaderboardEntry = { |
| id: string; |
| displayName: string; |
| solvedCount: number; |
| currentStreak: number; |
| weeklySolved: number; |
| coins: number; |
| score: number; |
| rank: number; |
| isCurrentUser: boolean; |
| codingStreak?: number; |
| languageStats?: Record<string, number>; |
| }; |
|
|
| export type UserProgressStats = { |
| solvedQuestionIds: string[]; |
| dailyActivity: Record<string, number>; |
| dailyActivityBreakdown?: Record<string, Record<string, number>>; |
| solvedCount: number; |
| activeDays: number; |
| weeklySolved: number; |
| leaderboard: ProgressLeaderboardEntry[]; |
| codingLeaderboard: ProgressLeaderboardEntry[]; |
| codingSolvedCount: number; |
| }; |
|
|
| type StoredLocalUser = AuthUser & { |
| password: string; |
| lastActiveAt?: string; |
| streakTimeZone?: string; |
| solvedQuestionIds?: string[]; |
| dailyActivity?: Record<string, number>; |
| dailyActivityBreakdown?: Record<string, Record<string, number>>; |
| languageStats?: Record<string, number>; |
| purchasedItemIds?: number[]; |
| }; |
|
|
| function apiUrl(path: string): string { |
| const base = ( |
| import.meta.env.VITE_API_BASE as string | undefined |
| ) |
| ?.trim() |
| .replace(/\/$/, '') ?? ''; |
| const p = path.startsWith('/') ? path : `/${path}`; |
| return `${base}${p}`; |
| } |
|
|
| function clientTimeZone(): string | null { |
| try { |
| const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone?.trim(); |
| return timeZone || null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| function isLocalDevOrigin() { |
| if (typeof window === 'undefined') { |
| return false; |
| } |
|
|
| const host = window.location.hostname; |
| return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; |
| } |
|
|
| function shouldUseLocalAuthFallback(error: unknown) { |
| return ( |
| isLocalDevOrigin() && |
| error instanceof Error && |
| error.message.startsWith('Cannot reach server') |
| ); |
| } |
|
|
| function loadLocalUsers(): StoredLocalUser[] { |
| if (typeof window === 'undefined') { |
| return []; |
| } |
|
|
| const raw = window.localStorage.getItem(LOCAL_AUTH_USERS_KEY); |
| if (!raw) { |
| return []; |
| } |
|
|
| try { |
| const parsed = JSON.parse(raw); |
| return Array.isArray(parsed) ? parsed : []; |
| } catch { |
| return []; |
| } |
| } |
|
|
| function saveLocalUsers(users: StoredLocalUser[]) { |
| if (typeof window === 'undefined') { |
| return; |
| } |
|
|
| window.localStorage.setItem(LOCAL_AUTH_USERS_KEY, JSON.stringify(users)); |
| } |
|
|
| function normalizeEmail(email: string) { |
| return email.trim().toLowerCase(); |
| } |
|
|
| function createLocalUserId() { |
| if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { |
| return crypto.randomUUID(); |
| } |
|
|
| return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; |
| } |
|
|
| function createLocalToken(userId: string) { |
| return `${LOCAL_AUTH_TOKEN_PREFIX}${userId}`; |
| } |
|
|
| function isLocalToken(token: string) { |
| return token.startsWith(LOCAL_AUTH_TOKEN_PREFIX); |
| } |
|
|
| function toAuthUser(user: StoredLocalUser): AuthUser { |
| const normalizedUser = normalizeLocalUser(user); |
| const { password: _password, lastActiveAt: _lastActiveAt, streakTimeZone: _streakTimeZone, solvedQuestionIds: _solvedQuestionIds, dailyActivity: _dailyActivity, purchasedItemIds: _purchasedItemIds, ...safeUser } = normalizedUser; |
|
|
| return { |
| ...safeUser, |
| currentStreak: visibleLocalCurrentStreak(normalizedUser), |
| }; |
| } |
|
|
| function findLocalUserByToken(token: string) { |
| if (!isLocalToken(token)) { |
| return null; |
| } |
|
|
| const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); |
| return loadLocalUsers().find((user) => user.id === userId) ?? null; |
| } |
|
|
| function registerLocalUser( |
| email: string, |
| password: string, |
| displayName: string, |
| ): { token: string; user: AuthUser } { |
| const users = loadLocalUsers(); |
| const normalizedEmail = normalizeEmail(email); |
|
|
| if (users.some((user) => normalizeEmail(user.email) === normalizedEmail)) { |
| throw new Error('An account with this email already exists.'); |
| } |
|
|
| const newUser: StoredLocalUser = { |
| id: createLocalUserId(), |
| email: normalizedEmail, |
| password, |
| displayName: displayName.trim(), |
| createdAt: new Date().toISOString(), |
| lastActiveAt: new Date().toISOString(), |
| currentStreak: 1, |
| longestStreak: 1, |
| coins: 0, |
| streakTimeZone: clientTimeZone() ?? 'UTC', |
| solvedQuestionIds: [], |
| dailyActivity: {}, |
| purchasedItemIds: [], |
| }; |
|
|
| users.push(newUser); |
| saveLocalUsers(users); |
|
|
| return { |
| token: createLocalToken(newUser.id), |
| user: toAuthUser(newUser), |
| }; |
| } |
|
|
| function loginLocalUser(email: string, password: string): { token: string; user: AuthUser } { |
| const normalizedEmail = normalizeEmail(email); |
| const users = loadLocalUsers(); |
| const userIndex = users.findIndex( |
| (candidate) => |
| normalizeEmail(candidate.email) === normalizedEmail && candidate.password === password, |
| ); |
|
|
| if (userIndex === -1) { |
| throw new Error('Invalid email or password.'); |
| } |
|
|
| const user = normalizeLocalUser(users[userIndex]); |
| touchLocalStreak(user, new Date()); |
| users[userIndex] = user; |
| saveLocalUsers(users); |
|
|
| return { |
| token: createLocalToken(user.id), |
| user: toAuthUser(user), |
| }; |
| } |
|
|
| function updateLocalUser( |
| token: string, |
| data: { displayName?: string; oldPassword?: string; newPassword?: string }, |
| ): { user: AuthUser } { |
| const users = loadLocalUsers(); |
| const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); |
| const userIndex = users.findIndex((candidate) => candidate.id === userId); |
|
|
| if (userIndex === -1) { |
| throw new Error('Not authenticated'); |
| } |
|
|
| const user = users[userIndex]; |
|
|
| if (data.newPassword) { |
| if (!data.oldPassword || data.oldPassword !== user.password) { |
| throw new Error('Current password is incorrect.'); |
| } |
| user.password = data.newPassword; |
| } |
|
|
| if (data.displayName?.trim()) { |
| user.displayName = data.displayName.trim(); |
| } |
|
|
| users[userIndex] = user; |
| saveLocalUsers(users); |
|
|
| return { user: toAuthUser(user) }; |
| } |
|
|
| function updateLocalCoins(token: string, amount: number): { user: AuthUser } { |
| const users = loadLocalUsers(); |
| const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); |
| const userIndex = users.findIndex((candidate) => candidate.id === userId); |
|
|
| if (userIndex === -1) { |
| throw new Error('Not authenticated'); |
| } |
|
|
| const user = users[userIndex]; |
| user.coins = Math.max(0, (user.coins || 0) + amount); |
| users[userIndex] = user; |
| saveLocalUsers(users); |
|
|
| return { user: toAuthUser(user) }; |
| } |
|
|
| function normalizeLocalSolvedQuestionIds(ids: string[] | undefined) { |
| const seen = new Set<string>(); |
| const normalized: string[] = []; |
|
|
| for (const rawId of ids ?? []) { |
| const id = rawId.trim(); |
| if (!id || seen.has(id)) { |
| continue; |
| } |
|
|
| seen.add(id); |
| normalized.push(id); |
| } |
|
|
| return normalized; |
| } |
|
|
| function normalizeLocalDailyActivity(activity: Record<string, number> | undefined) { |
| const normalized: Record<string, number> = {}; |
|
|
| for (const [key, count] of Object.entries(activity ?? {})) { |
| if (typeof count !== 'number' || !Number.isFinite(count) || count <= 0) { |
| continue; |
| } |
|
|
| normalized[key.trim()] = Math.floor(count); |
| } |
|
|
| return normalized; |
| } |
|
|
| function normalizeLocalUser(user: StoredLocalUser): StoredLocalUser { |
| const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds); |
| const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity); |
| const currentStreak = Math.max(0, Math.floor(user.currentStreak || 0)); |
| const longestStreak = Math.max(currentStreak, Math.floor(user.longestStreak || 0)); |
| const coins = Math.max(0, Math.floor(user.coins || 0)); |
|
|
| return { |
| ...user, |
| currentStreak, |
| longestStreak, |
| coins, |
| streakTimeZone: user.streakTimeZone || clientTimeZone() || 'UTC', |
| solvedQuestionIds, |
| dailyActivity, |
| purchasedItemIds: Array.isArray(user.purchasedItemIds) ? user.purchasedItemIds : [], |
| }; |
| } |
|
|
| function differenceInLocalDays(later: Date, earlier: Date) { |
| const laterDate = new Date(later); |
| const earlierDate = new Date(earlier); |
| laterDate.setHours(0, 0, 0, 0); |
| earlierDate.setHours(0, 0, 0, 0); |
| return Math.round((laterDate.getTime() - earlierDate.getTime()) / (1000 * 60 * 60 * 24)); |
| } |
|
|
| function visibleLocalCurrentStreak(user: StoredLocalUser) { |
| if (!user.lastActiveAt) { |
| return 0; |
| } |
|
|
| const lastActiveAt = new Date(user.lastActiveAt); |
| if (Number.isNaN(lastActiveAt.getTime())) { |
| return 0; |
| } |
|
|
| const dayDelta = differenceInLocalDays(new Date(), lastActiveAt); |
| return dayDelta <= 1 ? Math.max(0, Math.floor(user.currentStreak || 0)) : 0; |
| } |
|
|
| function weeklySolvedFromLocalActivity(activity: Record<string, number>) { |
| const normalizedActivity = normalizeLocalDailyActivity(activity); |
| let total = 0; |
|
|
| for (let offset = 0; offset < 7; offset += 1) { |
| const currentDate = new Date(); |
| currentDate.setDate(currentDate.getDate() - offset); |
| total += normalizedActivity[getActivityDateKey(currentDate)] || 0; |
| } |
|
|
| return total; |
| } |
|
|
| function trimLocalLeaderboard(entries: ProgressLeaderboardEntry[], currentUserId: string, limit = 10) { |
| if (entries.length <= limit) { |
| return entries; |
| } |
|
|
| const currentIndex = entries.findIndex((entry) => entry.id === currentUserId); |
| if (currentIndex === -1 || currentIndex < limit) { |
| return entries.slice(0, limit); |
| } |
|
|
| return [...entries.slice(0, limit - 1), entries[currentIndex]]; |
| } |
|
|
| function weeklyCodingSolvedFromLocalActivity(breakdown: Record<string, Record<string, number>> | undefined) { |
| if (!breakdown) return 0; |
| let total = 0; |
| for (let offset = 0; offset < 7; offset += 1) { |
| const currentDate = new Date(); |
| currentDate.setDate(currentDate.getDate() - offset); |
| const dayKey = getActivityDateKey(currentDate); |
| const dayBreakdown = breakdown[dayKey]; |
| if (dayBreakdown && typeof dayBreakdown.coding === 'number') { |
| total += dayBreakdown.coding; |
| } |
| } |
| return total; |
| } |
|
|
| function codingCurrentStreakFromLocalActivity(breakdown: Record<string, Record<string, number>> | undefined) { |
| if (!breakdown || Object.keys(breakdown).length === 0) return 0; |
| let streak = 0; |
| const now = new Date(); |
| |
| const todayKey = getActivityDateKey(now); |
| const yesterday = new Date(now); |
| yesterday.setDate(yesterday.getDate() - 1); |
| const yesterdayKey = getActivityDateKey(yesterday); |
| |
| const todayCoding = breakdown[todayKey]?.['coding'] || 0; |
| const yesterdayCoding = breakdown[yesterdayKey]?.['coding'] || 0; |
| |
| if (todayCoding === 0 && yesterdayCoding === 0) return 0; |
| |
| let currentDay = new Date(now); |
| if (todayCoding === 0) { |
| currentDay.setDate(currentDay.getDate() - 1); |
| } |
| |
| while (true) { |
| const dayKey = getActivityDateKey(currentDay); |
| const codingCount = breakdown[dayKey]?.['coding'] || 0; |
| if (codingCount > 0) { |
| streak++; |
| currentDay.setDate(currentDay.getDate() - 1); |
| } else { |
| break; |
| } |
| } |
| |
| return streak; |
| } |
|
|
| function buildLocalCodingLeaderboard(users: StoredLocalUser[], currentUserId: string) { |
| const leaderboard = users |
| .map((candidate) => { |
| const user = normalizeLocalUser(candidate); |
| |
| let codingSolvedCount = 0; |
| for (const id of user.solvedQuestionIds || []) { |
| if (!id.startsWith('sd-') && !id.startsWith('cs-') && !id.startsWith('apt-')) { |
| codingSolvedCount++; |
| } |
| } |
|
|
| const weeklySolved = weeklyCodingSolvedFromLocalActivity(user.dailyActivityBreakdown); |
| const codingStreak = codingCurrentStreakFromLocalActivity(user.dailyActivityBreakdown); |
| const currentStreak = visibleLocalCurrentStreak(user); |
| const coins = Math.max(0, user.coins || 0); |
|
|
| return { |
| id: user.id, |
| displayName: user.displayName.trim() || 'User', |
| solvedCount: codingSolvedCount, |
| currentStreak, |
| codingStreak, |
| weeklySolved, |
| coins, |
| score: codingSolvedCount * 100, |
| rank: 0, |
| languageStats: user.languageStats, |
| isCurrentUser: user.id === currentUserId, |
| } satisfies ProgressLeaderboardEntry; |
| }) |
| .sort((left, right) => { |
| if (right.score !== left.score) return right.score - left.score; |
| if (right.solvedCount !== left.solvedCount) return right.solvedCount - left.solvedCount; |
| if (right.weeklySolved !== left.weeklySolved) return right.weeklySolved - left.weeklySolved; |
| if (right.coins !== left.coins) return right.coins - left.coins; |
| return left.displayName.localeCompare(right.displayName); |
| }) |
| .map((entry, index) => ({ |
| ...entry, |
| rank: index + 1, |
| })); |
|
|
| return leaderboard; |
| } |
|
|
| function buildLocalLeaderboard(users: StoredLocalUser[], currentUserId: string) { |
| const leaderboard = users |
| .map((candidate) => { |
| const user = normalizeLocalUser(candidate); |
| const solvedCount = user.solvedQuestionIds?.length || 0; |
| const weeklySolved = weeklySolvedFromLocalActivity(user.dailyActivity || {}); |
| const currentStreak = visibleLocalCurrentStreak(user); |
| const coins = Math.max(0, user.coins || 0); |
|
|
| return { |
| id: user.id, |
| displayName: user.displayName.trim() || 'User', |
| solvedCount, |
| currentStreak, |
| weeklySolved, |
| coins, |
| score: solvedCount * 100 + weeklySolved * 15 + currentStreak * 30 + coins, |
| rank: 0, |
| isCurrentUser: user.id === currentUserId, |
| } satisfies ProgressLeaderboardEntry; |
| }) |
| .sort((left, right) => { |
| if (right.score !== left.score) { |
| return right.score - left.score; |
| } |
| if (right.solvedCount !== left.solvedCount) { |
| return right.solvedCount - left.solvedCount; |
| } |
| if (right.weeklySolved !== left.weeklySolved) { |
| return right.weeklySolved - left.weeklySolved; |
| } |
| if (right.coins !== left.coins) { |
| return right.coins - left.coins; |
| } |
| return left.displayName.localeCompare(right.displayName); |
| }) |
| .map((entry, index) => ({ |
| ...entry, |
| rank: index + 1, |
| })); |
|
|
| |
| return leaderboard; |
| } |
|
|
| function buildLocalProgressStats(userId: string, users: StoredLocalUser[]): UserProgressStats { |
| const user = users.map(normalizeLocalUser).find((candidate) => candidate.id === userId); |
| if (!user) { |
| throw new Error('Not authenticated'); |
| } |
|
|
| const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds); |
| const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity); |
| const rawUser = users.find((candidate) => candidate.id === userId); |
| const dailyActivityBreakdown: Record<string, Record<string, number>> = rawUser?.dailyActivityBreakdown ?? {}; |
|
|
| let codingSolvedCount = 0; |
| for (const id of solvedQuestionIds) { |
| if (!id.startsWith('sd-') && !id.startsWith('cs-') && !id.startsWith('apt-')) { |
| codingSolvedCount++; |
| } |
| } |
|
|
| return { |
| solvedQuestionIds, |
| dailyActivity, |
| dailyActivityBreakdown, |
| solvedCount: solvedQuestionIds.length, |
| activeDays: Object.values(dailyActivity).filter((count) => count > 0).length, |
| weeklySolved: weeklySolvedFromLocalActivity(dailyActivity), |
| leaderboard: buildLocalLeaderboard(users, userId), |
| codingLeaderboard: buildLocalCodingLeaderboard(users, userId), |
| codingSolvedCount, |
| }; |
| } |
|
|
| function updateLocalStreakForSolve(user: StoredLocalUser, now: Date) { |
| const lastActiveAt = user.lastActiveAt ? new Date(user.lastActiveAt) : null; |
| const lastActiveIsValid = Boolean(lastActiveAt && !Number.isNaN(lastActiveAt.getTime())); |
|
|
| if (!lastActiveIsValid) { |
| user.currentStreak = 1; |
| user.longestStreak = Math.max(user.longestStreak || 0, 1); |
| user.lastActiveAt = now.toISOString(); |
| return; |
| } |
|
|
| const dayDelta = differenceInLocalDays(now, lastActiveAt!); |
| if (dayDelta <= 0) { |
| return; |
| } |
|
|
| if (dayDelta === 1) { |
| user.currentStreak = Math.max(0, user.currentStreak || 0) + 1; |
| } else { |
| user.currentStreak = 1; |
| } |
|
|
| user.longestStreak = Math.max(user.longestStreak || 0, user.currentStreak); |
| user.lastActiveAt = now.toISOString(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function touchLocalStreak(user: StoredLocalUser, now: Date) { |
| const lastActiveAt = user.lastActiveAt ? new Date(user.lastActiveAt) : null; |
| const lastActiveIsValid = Boolean(lastActiveAt && !Number.isNaN(lastActiveAt.getTime())); |
|
|
| if (!lastActiveIsValid) { |
| user.currentStreak = 1; |
| user.longestStreak = Math.max(user.longestStreak || 0, 1); |
| user.lastActiveAt = now.toISOString(); |
| return; |
| } |
|
|
| const dayDelta = differenceInLocalDays(now, lastActiveAt!); |
|
|
| if (dayDelta <= 0) { |
| |
| return; |
| } |
|
|
| if (dayDelta === 1) { |
| user.currentStreak = Math.max(0, user.currentStreak || 0) + 1; |
| } else { |
| |
| user.currentStreak = 1; |
| } |
|
|
| user.longestStreak = Math.max(user.longestStreak || 0, user.currentStreak); |
| user.lastActiveAt = now.toISOString(); |
| } |
|
|
| function mergeLocalProgress(token: string, data: { solvedQuestionIds: string[]; dailyActivity: Record<string, number> }) { |
| const users = loadLocalUsers(); |
| const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); |
| const userIndex = users.findIndex((candidate) => candidate.id === userId); |
|
|
| if (userIndex === -1) { |
| throw new Error('Not authenticated'); |
| } |
|
|
| const user = normalizeLocalUser(users[userIndex]); |
| const mergedSolvedQuestionIds = normalizeLocalSolvedQuestionIds([ |
| ...(user.solvedQuestionIds || []), |
| ...normalizeLocalSolvedQuestionIds(data.solvedQuestionIds), |
| ]); |
| const mergedDailyActivity = { |
| ...normalizeLocalDailyActivity(user.dailyActivity), |
| ...normalizeLocalDailyActivity(data.dailyActivity), |
| }; |
|
|
| user.solvedQuestionIds = mergedSolvedQuestionIds; |
| user.dailyActivity = mergedDailyActivity; |
| users[userIndex] = user; |
| saveLocalUsers(users); |
|
|
| return { |
| user: toAuthUser(user), |
| stats: buildLocalProgressStats(user.id, users), |
| }; |
| } |
|
|
| function recordLocalSolvedQuestion( |
| token: string, |
| questionId: string, |
| difficulty: string, |
| language?: string, |
| ) { |
| const users = loadLocalUsers(); |
| const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); |
| const userIndex = users.findIndex((candidate) => candidate.id === userId); |
|
|
| if (userIndex === -1) { |
| throw new Error('Not authenticated'); |
| } |
|
|
| const reward = getSolveReward(difficulty); |
| if (reward == null) { |
| throw new Error('Question difficulty is required.'); |
| } |
|
|
| const user = normalizeLocalUser(users[userIndex]); |
| const solvedQuestionIds = normalizeLocalSolvedQuestionIds(user.solvedQuestionIds); |
| const dailyActivity = normalizeLocalDailyActivity(user.dailyActivity); |
| const alreadySolved = solvedQuestionIds.includes(questionId); |
|
|
| if (!alreadySolved) { |
| const now = new Date(); |
| solvedQuestionIds.push(questionId); |
| const todayKey = getActivityDateKey(now); |
| dailyActivity[todayKey] = (dailyActivity[todayKey] || 0) + 1; |
|
|
| |
| let section = 'coding'; |
| if (questionId.startsWith('sd-')) { |
| section = 'systemDesign'; |
| } else if (questionId.startsWith('cs-dbms-')) { |
| section = 'dbms'; |
| } else if (questionId.startsWith('cs-os-')) { |
| section = 'os'; |
| } else if (questionId.startsWith('cs-cn-')) { |
| section = 'cn'; |
| } else if (questionId.startsWith('cs-')) { |
| section = 'csFundamentals'; |
| } else if (questionId.startsWith('apt-')) { |
| section = 'aptitude'; |
| } |
| const breakdown = user.dailyActivityBreakdown ?? {}; |
| if (!breakdown[todayKey]) { |
| breakdown[todayKey] = {}; |
| } |
| breakdown[todayKey][section] = (breakdown[todayKey][section] || 0) + 1; |
| user.dailyActivityBreakdown = breakdown; |
|
|
| user.solvedQuestionIds = solvedQuestionIds; |
| user.dailyActivity = dailyActivity; |
| |
| if (section === 'coding' && language) { |
| const stats = user.languageStats ?? {}; |
| const lang = language.toLowerCase().trim(); |
| stats[lang] = (stats[lang] || 0) + 1; |
| user.languageStats = stats; |
| } |
| |
| updateLocalStreakForSolve(user, now); |
| user.coins = Math.max(0, user.coins || 0) + reward; |
| } |
|
|
| users[userIndex] = user; |
| saveLocalUsers(users); |
|
|
| return { |
| user: toAuthUser(user), |
| stats: buildLocalProgressStats(user.id, users), |
| }; |
| } |
|
|
| function spendLocalCoins(token: string, amount: number): { user: AuthUser; stats: UserProgressStats } { |
| const users = loadLocalUsers(); |
| const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); |
| const userIndex = users.findIndex((candidate) => candidate.id === userId); |
|
|
| if (userIndex === -1) { |
| throw new Error('Not authenticated'); |
| } |
|
|
| const user = normalizeLocalUser(users[userIndex]); |
| if (amount <= 0) { |
| throw new Error('Amount must be greater than 0.'); |
| } |
| if (user.coins < amount) { |
| throw new Error('Not enough coins.'); |
| } |
|
|
| user.coins -= amount; |
| users[userIndex] = user; |
| saveLocalUsers(users); |
|
|
| return { |
| user: toAuthUser(user), |
| stats: buildLocalProgressStats(user.id, users), |
| }; |
| } |
|
|
| const REACHABILITY_HINT = |
| 'Start the API with "npm run api". For a single-server launch, run "npm start" and open http://localhost:3000. For Vite development, run "npm run api" and "npm run dev", then open http://localhost:5173. If the UI is on another port, add VITE_API_BASE=http://localhost:3000 to .env and restart.'; |
|
|
| export async function apiFetch(path: string, init?: RequestInit): Promise<Response> { |
| const url = apiUrl(path); |
| const headers = new Headers(init?.headers); |
| const timeZone = clientTimeZone(); |
| if (timeZone && !headers.has('X-Timezone')) { |
| headers.set('X-Timezone', timeZone); |
| } |
| try { |
| const response = await fetch(url, { |
| ...init, |
| headers, |
| }); |
| return response; |
| } catch (error) { |
| console.error('API fetch error:', { url, error }); |
| throw new Error(`Cannot reach server (${url}). ${REACHABILITY_HINT}`); |
| } |
| } |
|
|
| async function parseJson(res: Response) { |
| const text = await res.text(); |
| try { |
| return text ? JSON.parse(text) : {}; |
| } catch (error) { |
| console.error('JSON parse error:', { text, error }); |
| return { error: 'Invalid server response' }; |
| } |
| } |
|
|
| export async function registerUser( |
| email: string, |
| password: string, |
| displayName: string, |
| ): Promise<{ token: string; user: AuthUser }> { |
| try { |
| const res = await apiFetch('/api/auth/register', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ email, password, displayName }), |
| }); |
| const data = await parseJson(res); |
| if (!res.ok) { |
| if (isLocalDevOrigin() && res.status >= 500) { |
| return registerLocalUser(email, password, displayName); |
| } |
| throw new Error(data.error || 'Registration failed'); |
| } |
| return data as { token: string; user: AuthUser }; |
| } catch (error) { |
| if (shouldUseLocalAuthFallback(error)) { |
| return registerLocalUser(email, password, displayName); |
| } |
| console.error('Registration error:', error); |
| throw error; |
| } |
| } |
|
|
| export async function loginUser( |
| email: string, |
| password: string, |
| ): Promise<{ token: string; user: AuthUser }> { |
| try { |
| const res = await apiFetch('/api/auth/login', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ email, password }), |
| }); |
| const data = await parseJson(res); |
| if (!res.ok) { |
| if (isLocalDevOrigin() && res.status >= 500) { |
| return loginLocalUser(email, password); |
| } |
| throw new Error(data.error || 'Login failed'); |
| } |
| return data as { token: string; user: AuthUser }; |
| } catch (error) { |
| if (shouldUseLocalAuthFallback(error)) { |
| return loginLocalUser(email, password); |
| } |
| console.error('Login error:', error); |
| throw error; |
| } |
| } |
|
|
| export async function fetchSessionUser(token: string): Promise<AuthUser | null> { |
| if (isLocalToken(token)) { |
| const users = loadLocalUsers(); |
| const userId = token.slice(LOCAL_AUTH_TOKEN_PREFIX.length); |
| const userIndex = users.findIndex((u) => u.id === userId); |
| if (userIndex === -1) return null; |
| const user = normalizeLocalUser(users[userIndex]); |
| touchLocalStreak(user, new Date()); |
| users[userIndex] = user; |
| saveLocalUsers(users); |
| return toAuthUser(user); |
| } |
|
|
| try { |
| const res = await apiFetch('/api/auth/me', { |
| headers: { Authorization: `Bearer ${token}` }, |
| }); |
| if (!res.ok) return null; |
| const data = await parseJson(res); |
| return (data as { user: AuthUser }).user ?? null; |
| } catch (error) { |
| console.error('Fetch session user error:', error); |
| return null; |
| } |
| } |
|
|
| export async function updateProfile( |
| token: string, |
| data: { displayName?: string; oldPassword?: string; newPassword?: string }, |
| ): Promise<{ user: AuthUser }> { |
| if (isLocalToken(token)) { |
| const result = updateLocalUser(token, data); |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); |
| } |
| return result; |
| } |
|
|
| const res = await apiFetch('/api/auth/update', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${token}`, |
| }, |
| body: JSON.stringify(data), |
| }); |
| const resData = await parseJson(res); |
| if (!res.ok) { |
| throw new Error(resData.error || 'Failed to update profile'); |
| } |
| return resData as { user: AuthUser }; |
| } |
|
|
| export async function addCoins( |
| token: string, |
| amount: number, |
| ): Promise<{ user: AuthUser }> { |
| if (isLocalToken(token)) { |
| const result = updateLocalCoins(token, amount); |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); |
| } |
| return result; |
| } |
|
|
| const res = await apiFetch('/api/user/add-coins', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${token}`, |
| }, |
| body: JSON.stringify({ amount }), |
| }); |
| const resData = await parseJson(res); |
| if (!res.ok) { |
| throw new Error(resData.error || 'Failed to add coins'); |
| } |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: resData.user })); |
| } |
| return resData as { user: AuthUser }; |
| } |
|
|
| function getSolveReward(difficulty: string) { |
| const normalizedDifficulty = difficulty.trim().toLowerCase(); |
| if (normalizedDifficulty === 'easy') { |
| return 10; |
| } |
| if (normalizedDifficulty === 'medium') { |
| return 25; |
| } |
| if (normalizedDifficulty === 'hard') { |
| return 50; |
| } |
| return null; |
| } |
|
|
| export async function fetchUserProgress( |
| token: string, |
| ): Promise<UserProgressStats> { |
| if (isLocalToken(token)) { |
| const localUser = findLocalUserByToken(token); |
| if (!localUser) { |
| throw new Error('Not authenticated'); |
| } |
| return buildLocalProgressStats(localUser.id, loadLocalUsers()); |
| } |
|
|
| const res = await apiFetch('/api/user/stats', { |
| headers: { |
| Authorization: `Bearer ${token}`, |
| }, |
| }); |
| const resData = await parseJson(res); |
| if (!res.ok) { |
| throw new Error(resData.error || 'Failed to load progress'); |
| } |
|
|
| const typed = resData as { user?: AuthUser; stats: UserProgressStats }; |
| if (typed.user && typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user })); |
| } |
|
|
| return typed.stats; |
| } |
|
|
| export async function importUserProgress( |
| token: string, |
| data: { solvedQuestionIds: string[]; dailyActivity: Record<string, number> }, |
| ): Promise<{ user: AuthUser; stats: UserProgressStats }> { |
| if (isLocalToken(token)) { |
| const result = mergeLocalProgress(token, data); |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); |
| } |
| return result; |
| } |
|
|
| const res = await apiFetch('/api/user/import-progress', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${token}`, |
| }, |
| body: JSON.stringify(data), |
| }); |
| const resData = await parseJson(res); |
| if (!res.ok) { |
| throw new Error(resData.error || 'Failed to import progress'); |
| } |
|
|
| const typed = resData as { user: AuthUser; stats: UserProgressStats }; |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user })); |
| } |
| return typed; |
| } |
|
|
| export async function recordSolvedQuestion( |
| token: string, |
| data: { questionId: string; difficulty: string; language?: string }, |
| ): Promise<{ user: AuthUser; stats: UserProgressStats }> { |
| if (isLocalToken(token)) { |
| const result = recordLocalSolvedQuestion(token, data.questionId, data.difficulty, data.language); |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); |
| } |
| return result; |
| } |
|
|
| const res = await apiFetch('/api/user/solve-question', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${token}`, |
| }, |
| body: JSON.stringify(data), |
| }); |
| const resData = await parseJson(res); |
| if (!res.ok) { |
| throw new Error(resData.error || 'Failed to record solved question'); |
| } |
|
|
| const typed = resData as { user: AuthUser; stats: UserProgressStats }; |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user })); |
| } |
| return typed; |
| } |
|
|
| export async function spendCoins( |
| token: string, |
| amount: number, |
| ): Promise<{ user: AuthUser; stats: UserProgressStats }> { |
| if (isLocalToken(token)) { |
| const result = spendLocalCoins(token, amount); |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: result.user })); |
| } |
| return result; |
| } |
|
|
| const res = await apiFetch('/api/user/spend-coins', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${token}`, |
| }, |
| body: JSON.stringify({ amount }), |
| }); |
| const resData = await parseJson(res); |
| if (!res.ok) { |
| throw new Error(resData.error || 'Failed to spend coins'); |
| } |
|
|
| const typed = resData as { user: AuthUser; stats: UserProgressStats }; |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('user-updated', { detail: typed.user })); |
| } |
| return typed; |
| } |
|
|
| export async function fetchCoinHistory(token: string): Promise<CoinTransaction[]> { |
| if (isLocalToken(token)) { |
| const localUser = findLocalUserByToken(token); |
| if (!localUser || !localUser.coins) { |
| return []; |
| } |
| return [ |
| { |
| id: 'legacy-balance', |
| userId: localUser.id, |
| amount: localUser.coins, |
| source: 'Initial Balance', |
| category: 'bonus', |
| createdAt: localUser.createdAt || new Date().toISOString(), |
| } |
| ]; |
| } |
|
|
| const res = await apiFetch('/api/user/coin-history', { |
| headers: { |
| Authorization: `Bearer ${token}`, |
| }, |
| }); |
| const resData = await parseJson(res); |
| if (!res.ok) { |
| throw new Error(resData.error || 'Failed to fetch coin history'); |
| } |
|
|
| return resData as CoinTransaction[]; |
| } |
|
|