feat: implement guest storage, refine interview flow, and fix UI crashes
Browse files- frontend/src/App.jsx +3 -2
- frontend/src/api/client.js +173 -24
- frontend/src/components/PostureMonitor.jsx +76 -2
- frontend/src/contexts/InterviewContext.jsx +29 -2
- frontend/src/hooks/useAntiCheat.js +85 -3
- frontend/src/lib/guestStorage.js +195 -0
- frontend/src/pages/Dashboard.jsx +37 -18
- frontend/src/pages/Interview.jsx +69 -8
- frontend/src/pages/PreInterview.jsx +47 -15
- frontend/src/pages/Processing.jsx +13 -1
- frontend/src/pages/Setup.jsx +17 -1
frontend/src/App.jsx
CHANGED
|
@@ -12,7 +12,7 @@ import { api } from "./api/client";
|
|
| 12 |
|
| 13 |
function App() {
|
| 14 |
const iv = useInterview();
|
| 15 |
-
const { currentUser, loading: authLoading, loginWithGoogle } = useAuth();
|
| 16 |
const [caps, setCaps] = useState({ mode: "CPU", llmMode: "api", audioEnabled: true });
|
| 17 |
|
| 18 |
// 1. Route Guard: Auto-transition to dashboard if user is known
|
|
@@ -56,7 +56,8 @@ function App() {
|
|
| 56 |
|
| 57 |
// FLOW: Dashboard
|
| 58 |
if (iv.step === "dashboard") {
|
| 59 |
-
|
|
|
|
| 60 |
iv.setStep("landing");
|
| 61 |
return null;
|
| 62 |
}
|
|
|
|
| 12 |
|
| 13 |
function App() {
|
| 14 |
const iv = useInterview();
|
| 15 |
+
const { currentUser, isGuest, loading: authLoading, loginWithGoogle } = useAuth();
|
| 16 |
const [caps, setCaps] = useState({ mode: "CPU", llmMode: "api", audioEnabled: true });
|
| 17 |
|
| 18 |
// 1. Route Guard: Auto-transition to dashboard if user is known
|
|
|
|
| 56 |
|
| 57 |
// FLOW: Dashboard
|
| 58 |
if (iv.step === "dashboard") {
|
| 59 |
+
// Allow guests (isGuest=true) even if currentUser temporarily null during async restore
|
| 60 |
+
if (!currentUser && !isGuest) {
|
| 61 |
iv.setStep("landing");
|
| 62 |
return null;
|
| 63 |
}
|
frontend/src/api/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
/**
|
| 2 |
-
* API client.
|
| 3 |
*
|
| 4 |
* VITE_API_BASE — set in frontend/.env (local) or Vercel env vars (production).
|
| 5 |
* Production example: VITE_API_BASE=https://your-space.hf.space/api
|
|
@@ -19,47 +19,196 @@ export const AUDIO_INPUT_HINT =
|
|
| 19 |
import.meta.env.VITE_AUDIO_INPUT_HINT ||
|
| 20 |
"Audio mode uses Whisper for speech-to-text. Speak clearly for best results.";
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const token = localStorage.getItem("firebaseToken");
|
| 24 |
const headers = {
|
| 25 |
"Content-Type": "application/json",
|
| 26 |
...(token ? { "Authorization": `Bearer ${token}` } : {}),
|
| 27 |
...(options.headers || {})
|
| 28 |
};
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
throw error;
|
| 40 |
}
|
| 41 |
-
return res.json();
|
| 42 |
}
|
| 43 |
|
| 44 |
-
async function
|
| 45 |
const token = localStorage.getItem("firebaseToken");
|
| 46 |
const headers = {
|
| 47 |
...(token ? { "Authorization": `Bearer ${token}` } : {})
|
| 48 |
};
|
| 49 |
|
| 50 |
-
const
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
throw error;
|
| 61 |
}
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
/** Map a MIME type string to a file extension for audio blobs. */
|
|
|
|
| 1 |
/**
|
| 2 |
+
* API client with comprehensive error handling, timeouts, and retry logic.
|
| 3 |
*
|
| 4 |
* VITE_API_BASE — set in frontend/.env (local) or Vercel env vars (production).
|
| 5 |
* Production example: VITE_API_BASE=https://your-space.hf.space/api
|
|
|
|
| 19 |
import.meta.env.VITE_AUDIO_INPUT_HINT ||
|
| 20 |
"Audio mode uses Whisper for speech-to-text. Speak clearly for best results.";
|
| 21 |
|
| 22 |
+
// Request configuration
|
| 23 |
+
const REQUEST_TIMEOUT = 30000; // 30 seconds default timeout
|
| 24 |
+
const MAX_RETRIES = 2;
|
| 25 |
+
const RETRY_DELAY = 1000; // 1 second between retries
|
| 26 |
+
const RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504]; // Statuses that warrant retry
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Create an AbortController with timeout
|
| 30 |
+
*/
|
| 31 |
+
function createTimeoutController(timeoutMs = REQUEST_TIMEOUT) {
|
| 32 |
+
const controller = new AbortController();
|
| 33 |
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
| 34 |
+
return { controller, timeoutId };
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Determine if an error is retryable
|
| 39 |
+
*/
|
| 40 |
+
function isRetryableError(error, status) {
|
| 41 |
+
// Network errors (no connection, DNS failure, etc.)
|
| 42 |
+
if (error.name === 'TypeError' || error.name === 'AbortError') {
|
| 43 |
+
return true;
|
| 44 |
+
}
|
| 45 |
+
// Server errors that might be transient
|
| 46 |
+
if (status && RETRYABLE_STATUSES.includes(status)) {
|
| 47 |
+
return true;
|
| 48 |
+
}
|
| 49 |
+
return false;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Sleep helper for retry delays
|
| 54 |
+
*/
|
| 55 |
+
function sleep(ms) {
|
| 56 |
+
return new Promise(resolve => setTimeout(resolve, ms));
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async function reqWithRetry(path, options = {}, attempt = 0) {
|
| 60 |
const token = localStorage.getItem("firebaseToken");
|
| 61 |
const headers = {
|
| 62 |
"Content-Type": "application/json",
|
| 63 |
...(token ? { "Authorization": `Bearer ${token}` } : {}),
|
| 64 |
...(options.headers || {})
|
| 65 |
};
|
| 66 |
+
|
| 67 |
+
const { controller, timeoutId } = createTimeoutController(options.timeout);
|
| 68 |
|
| 69 |
+
try {
|
| 70 |
+
const res = await fetch(`${API_BASE}${path}`, {
|
| 71 |
+
...options,
|
| 72 |
+
headers,
|
| 73 |
+
signal: controller.signal,
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
clearTimeout(timeoutId);
|
| 77 |
+
|
| 78 |
+
if (!res.ok) {
|
| 79 |
+
const data = await res.json().catch(() => ({}));
|
| 80 |
+
const error = new Error(data.detail || data.message || `API error ${res.status}`);
|
| 81 |
+
error.status = res.status;
|
| 82 |
+
error.data = data;
|
| 83 |
+
|
| 84 |
+
// Check if we should retry
|
| 85 |
+
if (attempt < MAX_RETRIES && isRetryableError(error, res.status)) {
|
| 86 |
+
console.warn(`API request failed (attempt ${attempt + 1}), retrying...`, error.message);
|
| 87 |
+
await sleep(RETRY_DELAY * (attempt + 1)); // Exponential backoff
|
| 88 |
+
return reqWithRetry(path, options, attempt + 1);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
throw error;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return res.json();
|
| 95 |
+
} catch (error) {
|
| 96 |
+
clearTimeout(timeoutId);
|
| 97 |
+
|
| 98 |
+
// Handle abort/timeout specifically
|
| 99 |
+
if (error.name === 'AbortError') {
|
| 100 |
+
const timeoutError = new Error('Request timed out. Please check your connection and try again.');
|
| 101 |
+
timeoutError.status = 408;
|
| 102 |
+
timeoutError.isTimeout = true;
|
| 103 |
+
|
| 104 |
+
if (attempt < MAX_RETRIES) {
|
| 105 |
+
console.warn(`Request timeout (attempt ${attempt + 1}), retrying...`);
|
| 106 |
+
await sleep(RETRY_DELAY * (attempt + 1));
|
| 107 |
+
return reqWithRetry(path, options, attempt + 1);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
throw timeoutError;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Handle network errors with retry
|
| 114 |
+
if (attempt < MAX_RETRIES && isRetryableError(error)) {
|
| 115 |
+
console.warn(`Network error (attempt ${attempt + 1}), retrying...`, error.message);
|
| 116 |
+
await sleep(RETRY_DELAY * (attempt + 1));
|
| 117 |
+
return reqWithRetry(path, options, attempt + 1);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// Enhance network error message
|
| 121 |
+
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
| 122 |
+
error.message = 'Network error. Please check your internet connection and try again.';
|
| 123 |
+
error.isNetworkError = true;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
throw error;
|
| 127 |
}
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
+
async function reqMultipartWithRetry(url, body, attempt = 0) {
|
| 131 |
const token = localStorage.getItem("firebaseToken");
|
| 132 |
const headers = {
|
| 133 |
...(token ? { "Authorization": `Bearer ${token}` } : {})
|
| 134 |
};
|
| 135 |
|
| 136 |
+
const { controller, timeoutId } = createTimeoutController(60000); // Longer timeout for file uploads
|
| 137 |
+
|
| 138 |
+
try {
|
| 139 |
+
const res = await fetch(url, {
|
| 140 |
+
method: "POST",
|
| 141 |
+
body,
|
| 142 |
+
headers,
|
| 143 |
+
signal: controller.signal,
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
clearTimeout(timeoutId);
|
| 147 |
+
|
| 148 |
+
if (!res.ok) {
|
| 149 |
+
const data = await res.json().catch(() => ({}));
|
| 150 |
+
const error = new Error(data.detail || data.message || `API error ${res.status}`);
|
| 151 |
+
error.status = res.status;
|
| 152 |
+
error.data = data;
|
| 153 |
+
|
| 154 |
+
// Check if we should retry (but be more conservative with file uploads)
|
| 155 |
+
if (attempt < MAX_RETRIES && isRetryableError(error, res.status) && res.status !== 413) {
|
| 156 |
+
console.warn(`Multipart request failed (attempt ${attempt + 1}), retrying...`, error.message);
|
| 157 |
+
await sleep(RETRY_DELAY * (attempt + 1));
|
| 158 |
+
return reqMultipartWithRetry(url, body, attempt + 1);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
throw error;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
return res.json();
|
| 165 |
+
} catch (error) {
|
| 166 |
+
clearTimeout(timeoutId);
|
| 167 |
+
|
| 168 |
+
if (error.name === 'AbortError') {
|
| 169 |
+
const timeoutError = new Error('Upload timed out. The file may be too large or your connection is slow.');
|
| 170 |
+
timeoutError.status = 408;
|
| 171 |
+
timeoutError.isTimeout = true;
|
| 172 |
+
throw timeoutError;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
if (attempt < MAX_RETRIES && isRetryableError(error)) {
|
| 176 |
+
console.warn(`Multipart network error (attempt ${attempt + 1}), retrying...`, error.message);
|
| 177 |
+
await sleep(RETRY_DELAY * (attempt + 1));
|
| 178 |
+
return reqMultipartWithRetry(url, body, attempt + 1);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
| 182 |
+
error.message = 'Network error during upload. Please check your connection and try again.';
|
| 183 |
+
error.isNetworkError = true;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
throw error;
|
| 187 |
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// Maintain backward compatibility with original function names
|
| 191 |
+
async function req(path, options = {}) {
|
| 192 |
+
return reqWithRetry(path, options);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
async function reqMultipart(url, body) {
|
| 196 |
+
return reqMultipartWithRetry(url, body);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/**
|
| 200 |
+
* Check if an error is a network error (for UI handling)
|
| 201 |
+
*/
|
| 202 |
+
export function isNetworkError(error) {
|
| 203 |
+
return error?.isNetworkError === true || error?.isTimeout === true ||
|
| 204 |
+
error?.name === 'TypeError' || error?.name === 'AbortError';
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/**
|
| 208 |
+
* Check if an error is retryable (for UI retry buttons)
|
| 209 |
+
*/
|
| 210 |
+
export function isRetryable(error) {
|
| 211 |
+
return isNetworkError(error) || RETRYABLE_STATUSES.includes(error?.status);
|
| 212 |
}
|
| 213 |
|
| 214 |
/** Map a MIME type string to a file extension for audio blobs. */
|
frontend/src/components/PostureMonitor.jsx
CHANGED
|
@@ -214,16 +214,90 @@ export default function PostureMonitor({ sessionId, stream }) {
|
|
| 214 |
} catch (_) {}
|
| 215 |
}, 500);
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
sendRef.current = setInterval(async () => {
|
| 218 |
if (!sessionId || metricsRef.current.length === 0) return;
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
metricsRef.current = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
}, 30000);
|
| 223 |
|
| 224 |
return () => {
|
| 225 |
clearInterval(timerRef.current);
|
| 226 |
clearInterval(sendRef.current);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
};
|
| 228 |
}, [poseReady, sessionId, analysePosture, drawSkeleton]);
|
| 229 |
|
|
|
|
| 214 |
} catch (_) {}
|
| 215 |
}, 500);
|
| 216 |
|
| 217 |
+
// Metrics buffer with retry logic
|
| 218 |
+
const metricsBuffer = [];
|
| 219 |
+
let consecutiveFailures = 0;
|
| 220 |
+
const MAX_BUFFER_SIZE = 100; // Prevent unbounded growth
|
| 221 |
+
|
| 222 |
sendRef.current = setInterval(async () => {
|
| 223 |
if (!sessionId || metricsRef.current.length === 0) return;
|
| 224 |
+
|
| 225 |
+
// Add new metrics to buffer
|
| 226 |
+
metricsBuffer.push(...metricsRef.current);
|
| 227 |
+
if (metricsBuffer.length > MAX_BUFFER_SIZE) {
|
| 228 |
+
// Keep only the most recent metrics if buffer overflows
|
| 229 |
+
metricsBuffer.splice(0, metricsBuffer.length - MAX_BUFFER_SIZE);
|
| 230 |
+
}
|
| 231 |
metricsRef.current = [];
|
| 232 |
+
|
| 233 |
+
// Send latest metric from buffer
|
| 234 |
+
const latest = metricsBuffer[metricsBuffer.length - 1];
|
| 235 |
+
if (!latest) return;
|
| 236 |
+
|
| 237 |
+
try {
|
| 238 |
+
await api.sendPosture({ session_id: sessionId, metrics: latest });
|
| 239 |
+
// Success - clear buffer and reset failure count
|
| 240 |
+
metricsBuffer.length = 0;
|
| 241 |
+
consecutiveFailures = 0;
|
| 242 |
+
} catch (err) {
|
| 243 |
+
consecutiveFailures++;
|
| 244 |
+
console.warn(`Posture metrics send failed (${consecutiveFailures}x):`, err);
|
| 245 |
+
|
| 246 |
+
// After 3 consecutive failures, send a batch of aggregated data
|
| 247 |
+
if (consecutiveFailures >= 3 && metricsBuffer.length > 1) {
|
| 248 |
+
try {
|
| 249 |
+
// Calculate average metrics for batch
|
| 250 |
+
const avgScore = metricsBuffer.reduce((sum, m) => sum + (m.posture_score || 0), 0) / metricsBuffer.length;
|
| 251 |
+
const modeLabel = metricsBuffer
|
| 252 |
+
.map(m => m.posture_label)
|
| 253 |
+
.sort((a, b) =>
|
| 254 |
+
metricsBuffer.filter(m => m.posture_label === a).length -
|
| 255 |
+
metricsBuffer.filter(m => m.posture_label === b).length
|
| 256 |
+
).pop();
|
| 257 |
+
|
| 258 |
+
const batchMetric = {
|
| 259 |
+
posture_score: avgScore,
|
| 260 |
+
posture_label: modeLabel,
|
| 261 |
+
spine_height: metricsBuffer[metricsBuffer.length - 1].spine_height,
|
| 262 |
+
hands_visible: true,
|
| 263 |
+
timestamp: Date.now(),
|
| 264 |
+
batch_size: metricsBuffer.length,
|
| 265 |
+
batch_aggregated: true
|
| 266 |
+
};
|
| 267 |
+
|
| 268 |
+
await api.sendPosture({ session_id: sessionId, metrics: batchMetric });
|
| 269 |
+
metricsBuffer.length = 0;
|
| 270 |
+
consecutiveFailures = 0;
|
| 271 |
+
} catch (batchErr) {
|
| 272 |
+
console.warn("Batch posture send also failed:", batchErr);
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
}, 30000);
|
| 277 |
|
| 278 |
return () => {
|
| 279 |
clearInterval(timerRef.current);
|
| 280 |
clearInterval(sendRef.current);
|
| 281 |
+
|
| 282 |
+
// Attempt to flush remaining metrics on unmount
|
| 283 |
+
if (sessionId && metricsBuffer.length > 0) {
|
| 284 |
+
const latest = metricsBuffer[metricsBuffer.length - 1];
|
| 285 |
+
const payload = JSON.stringify({
|
| 286 |
+
session_id: sessionId,
|
| 287 |
+
metrics: {
|
| 288 |
+
...latest,
|
| 289 |
+
flush_on_unmount: true,
|
| 290 |
+
timestamp: Date.now()
|
| 291 |
+
}
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
// Use sendBeacon for best-effort delivery on unmount
|
| 295 |
+
if (navigator.sendBeacon) {
|
| 296 |
+
navigator.sendBeacon('/api/posture/report', new Blob([payload], { type: 'application/json' }));
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
metricsBuffer.length = 0;
|
| 300 |
+
}
|
| 301 |
};
|
| 302 |
}, [poseReady, sessionId, analysePosture, drawSkeleton]);
|
| 303 |
|
frontend/src/contexts/InterviewContext.jsx
CHANGED
|
@@ -82,7 +82,27 @@ export function InterviewProvider({ children }) {
|
|
| 82 |
const recoverState = async () => {
|
| 83 |
try {
|
| 84 |
const status = await api.getSessionStatus(sid);
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
// If there's an active interview in progress
|
| 87 |
if (status.has_active_question && status.current_question) {
|
| 88 |
// Restore to interview step with current question
|
|
@@ -109,9 +129,16 @@ export function InterviewProvider({ children }) {
|
|
| 109 |
// If no active question, user stays at their current step
|
| 110 |
} catch (e) {
|
| 111 |
console.warn("Failed to recover session state:", e);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
};
|
| 114 |
-
|
| 115 |
recoverState();
|
| 116 |
}
|
| 117 |
}, []);
|
|
|
|
| 82 |
const recoverState = async () => {
|
| 83 |
try {
|
| 84 |
const status = await api.getSessionStatus(sid);
|
| 85 |
+
|
| 86 |
+
// Handle expired sessions
|
| 87 |
+
if (status.status === "expired") {
|
| 88 |
+
console.warn("Session expired:", status.message);
|
| 89 |
+
sessionStorage.removeItem(SESSION_KEY);
|
| 90 |
+
sessionStorage.removeItem("ai_interview_step");
|
| 91 |
+
dispatch({ type: "SET_ERROR", v: "Your session has expired. Please start a new interview." });
|
| 92 |
+
dispatch({ type: "SET_STEP", v: "landing" });
|
| 93 |
+
return;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Handle sessions that don't exist on backend (cleaned up or invalid)
|
| 97 |
+
if (status.status === "not_started" && storedStep === "interview") {
|
| 98 |
+
console.warn("Session not found on backend, clearing local state");
|
| 99 |
+
sessionStorage.removeItem(SESSION_KEY);
|
| 100 |
+
sessionStorage.removeItem("ai_interview_step");
|
| 101 |
+
dispatch({ type: "SET_ERROR", v: "Session not found. Please start a new interview." });
|
| 102 |
+
dispatch({ type: "SET_STEP", v: "landing" });
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
// If there's an active interview in progress
|
| 107 |
if (status.has_active_question && status.current_question) {
|
| 108 |
// Restore to interview step with current question
|
|
|
|
| 129 |
// If no active question, user stays at their current step
|
| 130 |
} catch (e) {
|
| 131 |
console.warn("Failed to recover session state:", e);
|
| 132 |
+
// If 404, session doesn't exist - clear and redirect
|
| 133 |
+
if (e.status === 404) {
|
| 134 |
+
sessionStorage.removeItem(SESSION_KEY);
|
| 135 |
+
sessionStorage.removeItem("ai_interview_step");
|
| 136 |
+
dispatch({ type: "SET_ERROR", v: "Session not found. Please start a new interview." });
|
| 137 |
+
dispatch({ type: "SET_STEP", v: "landing" });
|
| 138 |
+
}
|
| 139 |
}
|
| 140 |
};
|
| 141 |
+
|
| 142 |
recoverState();
|
| 143 |
}
|
| 144 |
}, []);
|
frontend/src/hooks/useAntiCheat.js
CHANGED
|
@@ -1,14 +1,60 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { api } from "../api/client";
|
| 3 |
|
|
|
|
|
|
|
|
|
|
| 4 |
export function useAntiCheat(sessionId, enabled = false) {
|
| 5 |
const [violations, setViolations] = useState([]);
|
| 6 |
const [showWarning, setShowWarning] = useState(false);
|
| 7 |
const [warningMessage, setWarningMessage] = useState("");
|
|
|
|
| 8 |
const countRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
const log = useCallback(async (type, details = "") => {
|
| 11 |
if (!sessionId || !enabled) return;
|
|
|
|
| 12 |
countRef.current += 1;
|
| 13 |
const entry = { type, details, timestamp: new Date().toISOString() };
|
| 14 |
setViolations(v => [...v, entry]);
|
|
@@ -19,8 +65,37 @@ export function useAntiCheat(sessionId, enabled = false) {
|
|
| 19 |
);
|
| 20 |
setShowWarning(true);
|
| 21 |
setTimeout(() => setShowWarning(false), 4000);
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
useEffect(() => {
|
| 26 |
if (!enabled) return;
|
|
@@ -65,5 +140,12 @@ export function useAntiCheat(sessionId, enabled = false) {
|
|
| 65 |
document.exitFullscreen?.();
|
| 66 |
}, []);
|
| 67 |
|
| 68 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { api } from "../api/client";
|
| 3 |
|
| 4 |
+
const MAX_RETRY_ATTEMPTS = 3;
|
| 5 |
+
const RETRY_DELAYS = [1000, 3000, 5000]; // Exponential backoff: 1s, 3s, 5s
|
| 6 |
+
|
| 7 |
export function useAntiCheat(sessionId, enabled = false) {
|
| 8 |
const [violations, setViolations] = useState([]);
|
| 9 |
const [showWarning, setShowWarning] = useState(false);
|
| 10 |
const [warningMessage, setWarningMessage] = useState("");
|
| 11 |
+
const [failedCount, setFailedCount] = useState(0);
|
| 12 |
const countRef = useRef(0);
|
| 13 |
+
const pendingQueueRef = useRef([]);
|
| 14 |
+
const retryTimerRef = useRef(null);
|
| 15 |
+
|
| 16 |
+
// Process the pending queue with retry logic
|
| 17 |
+
const processQueue = useCallback(async () => {
|
| 18 |
+
if (pendingQueueRef.current.length === 0 || !sessionId) return;
|
| 19 |
+
|
| 20 |
+
const item = pendingQueueRef.current[0];
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
await api.logViolation({
|
| 24 |
+
session_id: sessionId,
|
| 25 |
+
type: item.type,
|
| 26 |
+
details: item.details
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
// Success - remove from queue
|
| 30 |
+
pendingQueueRef.current.shift();
|
| 31 |
+
setFailedCount(pendingQueueRef.current.length);
|
| 32 |
+
|
| 33 |
+
// Process next item if any
|
| 34 |
+
if (pendingQueueRef.current.length > 0) {
|
| 35 |
+
processQueue();
|
| 36 |
+
}
|
| 37 |
+
} catch (err) {
|
| 38 |
+
// Failed - increment retry count
|
| 39 |
+
item.attempts = (item.attempts || 0) + 1;
|
| 40 |
+
|
| 41 |
+
if (item.attempts >= MAX_RETRY_ATTEMPTS) {
|
| 42 |
+
// Max retries reached - drop this violation and log to console
|
| 43 |
+
console.warn(`Anti-cheat: Dropping violation after ${MAX_RETRY_ATTEMPTS} retries:`, item);
|
| 44 |
+
pendingQueueRef.current.shift();
|
| 45 |
+
setFailedCount(pendingQueueRef.current.length);
|
| 46 |
+
} else {
|
| 47 |
+
// Schedule retry with exponential backoff
|
| 48 |
+
const delay = RETRY_DELAYS[Math.min(item.attempts - 1, RETRY_DELAYS.length - 1)];
|
| 49 |
+
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
| 50 |
+
retryTimerRef.current = setTimeout(processQueue, delay);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
}, [sessionId]);
|
| 54 |
|
| 55 |
const log = useCallback(async (type, details = "") => {
|
| 56 |
if (!sessionId || !enabled) return;
|
| 57 |
+
|
| 58 |
countRef.current += 1;
|
| 59 |
const entry = { type, details, timestamp: new Date().toISOString() };
|
| 60 |
setViolations(v => [...v, entry]);
|
|
|
|
| 65 |
);
|
| 66 |
setShowWarning(true);
|
| 67 |
setTimeout(() => setShowWarning(false), 4000);
|
| 68 |
+
|
| 69 |
+
// Add to queue and process immediately
|
| 70 |
+
pendingQueueRef.current.push({ type, details, attempts: 0 });
|
| 71 |
+
setFailedCount(pendingQueueRef.current.length);
|
| 72 |
+
processQueue();
|
| 73 |
+
}, [sessionId, enabled, processQueue]);
|
| 74 |
+
|
| 75 |
+
// Flush remaining violations on unmount
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
return () => {
|
| 78 |
+
if (retryTimerRef.current) {
|
| 79 |
+
clearTimeout(retryTimerRef.current);
|
| 80 |
+
}
|
| 81 |
+
// Attempt to send any remaining violations synchronously (best effort)
|
| 82 |
+
if (pendingQueueRef.current.length > 0 && sessionId) {
|
| 83 |
+
const remaining = [...pendingQueueRef.current];
|
| 84 |
+
// Use sendBeacon if available, otherwise fire-and-forget fetch
|
| 85 |
+
remaining.forEach(item => {
|
| 86 |
+
const payload = JSON.stringify({
|
| 87 |
+
session_id: sessionId,
|
| 88 |
+
type: item.type,
|
| 89 |
+
details: item.details
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
if (navigator.sendBeacon) {
|
| 93 |
+
navigator.sendBeacon('/api/session/violation', new Blob([payload], { type: 'application/json' }));
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
};
|
| 98 |
+
}, [sessionId]);
|
| 99 |
|
| 100 |
useEffect(() => {
|
| 101 |
if (!enabled) return;
|
|
|
|
| 140 |
document.exitFullscreen?.();
|
| 141 |
}, []);
|
| 142 |
|
| 143 |
+
return {
|
| 144 |
+
violations,
|
| 145 |
+
showWarning,
|
| 146 |
+
warningMessage,
|
| 147 |
+
failedCount,
|
| 148 |
+
enterFullscreen,
|
| 149 |
+
exitFullscreen
|
| 150 |
+
};
|
| 151 |
}
|
frontend/src/lib/guestStorage.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Guest Interview Storage - IndexedDB
|
| 3 |
+
*
|
| 4 |
+
* Provides persistent storage for guest users' interview history.
|
| 5 |
+
* Falls back to localStorage if IndexedDB unavailable.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
const DB_NAME = 'ascent_guest_db';
|
| 9 |
+
const DB_VERSION = 1;
|
| 10 |
+
const STORE_NAME = 'interviews';
|
| 11 |
+
|
| 12 |
+
let dbPromise = null;
|
| 13 |
+
|
| 14 |
+
function openDB() {
|
| 15 |
+
if (dbPromise) return dbPromise;
|
| 16 |
+
|
| 17 |
+
if (!('indexedDB' in window)) {
|
| 18 |
+
console.warn('IndexedDB not supported, falling back to localStorage');
|
| 19 |
+
return Promise.resolve(null);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
dbPromise = new Promise((resolve, reject) => {
|
| 23 |
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
| 24 |
+
|
| 25 |
+
request.onerror = () => {
|
| 26 |
+
console.warn('IndexedDB open failed, falling back to localStorage');
|
| 27 |
+
resolve(null);
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
request.onsuccess = () => resolve(request.result);
|
| 31 |
+
|
| 32 |
+
request.onupgradeneeded = (event) => {
|
| 33 |
+
const db = event.target.result;
|
| 34 |
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
| 35 |
+
const store = db.createObjectStore(STORE_NAME, { keyPath: 'session_id' });
|
| 36 |
+
store.createIndex('saved_at', 'saved_at', { unique: false });
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
return dbPromise;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Fallback localStorage key
|
| 45 |
+
const FALLBACK_KEY = 'ascent_guest_interviews';
|
| 46 |
+
|
| 47 |
+
function getFallback() {
|
| 48 |
+
try {
|
| 49 |
+
const data = localStorage.getItem(FALLBACK_KEY);
|
| 50 |
+
return data ? JSON.parse(data) : [];
|
| 51 |
+
} catch {
|
| 52 |
+
return [];
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function setFallback(interviews) {
|
| 57 |
+
try {
|
| 58 |
+
localStorage.setItem(FALLBACK_KEY, JSON.stringify(interviews));
|
| 59 |
+
} catch (e) {
|
| 60 |
+
console.warn('Failed to save to localStorage:', e);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Save an interview report for a guest user
|
| 66 |
+
*/
|
| 67 |
+
export async function saveGuestInterview(sessionId, reportData) {
|
| 68 |
+
const interview = {
|
| 69 |
+
session_id: sessionId,
|
| 70 |
+
report: reportData,
|
| 71 |
+
saved_at: new Date().toISOString(),
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
const db = await openDB();
|
| 75 |
+
|
| 76 |
+
if (!db) {
|
| 77 |
+
// Fallback to localStorage
|
| 78 |
+
const existing = getFallback();
|
| 79 |
+
const filtered = existing.filter(i => i.session_id !== sessionId);
|
| 80 |
+
filtered.unshift(interview); // Add to beginning (newest first)
|
| 81 |
+
// Keep only last 50
|
| 82 |
+
if (filtered.length > 50) filtered.pop();
|
| 83 |
+
setFallback(filtered);
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
return new Promise((resolve, reject) => {
|
| 88 |
+
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
| 89 |
+
const store = transaction.objectStore(STORE_NAME);
|
| 90 |
+
|
| 91 |
+
const request = store.put(interview);
|
| 92 |
+
|
| 93 |
+
request.onsuccess = () => resolve();
|
| 94 |
+
request.onerror = () => {
|
| 95 |
+
console.warn('IndexedDB save failed, falling back to localStorage');
|
| 96 |
+
const existing = getFallback();
|
| 97 |
+
const filtered = existing.filter(i => i.session_id !== sessionId);
|
| 98 |
+
filtered.unshift(interview);
|
| 99 |
+
if (filtered.length > 50) filtered.pop();
|
| 100 |
+
setFallback(filtered);
|
| 101 |
+
resolve();
|
| 102 |
+
};
|
| 103 |
+
});
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* Get all interview history for a guest user
|
| 108 |
+
*/
|
| 109 |
+
export async function getGuestHistory() {
|
| 110 |
+
const db = await openDB();
|
| 111 |
+
|
| 112 |
+
if (!db) {
|
| 113 |
+
return getFallback();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return new Promise((resolve) => {
|
| 117 |
+
const transaction = db.transaction([STORE_NAME], 'readonly');
|
| 118 |
+
const store = transaction.objectStore(STORE_NAME);
|
| 119 |
+
const index = store.index('saved_at');
|
| 120 |
+
|
| 121 |
+
const request = index.openCursor(null, 'prev'); // Descending order
|
| 122 |
+
const results = [];
|
| 123 |
+
|
| 124 |
+
request.onsuccess = (event) => {
|
| 125 |
+
const cursor = event.target.result;
|
| 126 |
+
if (cursor && results.length < 50) {
|
| 127 |
+
results.push(cursor.value);
|
| 128 |
+
cursor.continue();
|
| 129 |
+
} else {
|
| 130 |
+
resolve(results);
|
| 131 |
+
}
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
request.onerror = () => {
|
| 135 |
+
console.warn('IndexedDB read failed, falling back to localStorage');
|
| 136 |
+
resolve(getFallback());
|
| 137 |
+
};
|
| 138 |
+
});
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* Delete a specific guest interview
|
| 143 |
+
*/
|
| 144 |
+
export async function deleteGuestInterview(sessionId) {
|
| 145 |
+
const db = await openDB();
|
| 146 |
+
|
| 147 |
+
if (!db) {
|
| 148 |
+
const existing = getFallback();
|
| 149 |
+
const filtered = existing.filter(i => i.session_id !== sessionId);
|
| 150 |
+
setFallback(filtered);
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return new Promise((resolve) => {
|
| 155 |
+
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
| 156 |
+
const store = transaction.objectStore(STORE_NAME);
|
| 157 |
+
|
| 158 |
+
const request = store.delete(sessionId);
|
| 159 |
+
request.onsuccess = () => resolve();
|
| 160 |
+
request.onerror = () => {
|
| 161 |
+
// Fallback
|
| 162 |
+
const existing = getFallback();
|
| 163 |
+
const filtered = existing.filter(i => i.session_id !== sessionId);
|
| 164 |
+
setFallback(filtered);
|
| 165 |
+
resolve();
|
| 166 |
+
};
|
| 167 |
+
});
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* Clear all guest interview history
|
| 172 |
+
*/
|
| 173 |
+
export async function clearGuestHistory() {
|
| 174 |
+
const db = await openDB();
|
| 175 |
+
|
| 176 |
+
if (!db) {
|
| 177 |
+
localStorage.removeItem(FALLBACK_KEY);
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
return new Promise((resolve) => {
|
| 182 |
+
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
| 183 |
+
const store = transaction.objectStore(STORE_NAME);
|
| 184 |
+
|
| 185 |
+
const request = store.clear();
|
| 186 |
+
request.onsuccess = () => {
|
| 187 |
+
localStorage.removeItem(FALLBACK_KEY);
|
| 188 |
+
resolve();
|
| 189 |
+
};
|
| 190 |
+
request.onerror = () => {
|
| 191 |
+
localStorage.removeItem(FALLBACK_KEY);
|
| 192 |
+
resolve();
|
| 193 |
+
};
|
| 194 |
+
});
|
| 195 |
+
}
|
frontend/src/pages/Dashboard.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
|
| 3 |
import { ArrowRight, TrendingUp, Clock, ChevronRight, LogOut, BarChart3, Activity } from "lucide-react";
|
| 4 |
import { useAuth } from "../contexts/AuthContext";
|
| 5 |
import { api } from "../api/client";
|
|
|
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
import { Card } from "@/components/ui/card";
|
| 8 |
import { Badge } from "@/components/ui/badge";
|
|
@@ -19,31 +20,42 @@ const itemVariants = {
|
|
| 19 |
};
|
| 20 |
|
| 21 |
export default function Dashboard({ onStartNew, onViewResults }) {
|
| 22 |
-
const { currentUser, logout } = useAuth();
|
| 23 |
const [history, setHistory] = useState([]);
|
| 24 |
const [loading, setLoading] = useState(true);
|
| 25 |
const [error, setError] = useState("");
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
}
|
| 37 |
-
} catch (err) {
|
| 38 |
-
setError("Error connecting to server. Please try again later.");
|
| 39 |
-
console.error(err);
|
| 40 |
-
} finally {
|
| 41 |
setLoading(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
|
|
|
| 45 |
fetchHistory();
|
| 46 |
-
}, []);
|
| 47 |
|
| 48 |
const formatDate = (dateStr) => {
|
| 49 |
if (!dateStr) return "Unknown date";
|
|
@@ -212,7 +224,14 @@ export default function Dashboard({ onStartNew, onViewResults }) {
|
|
| 212 |
) : error ? (
|
| 213 |
<Card className="p-8 text-center bg-red-500/5 border-red-500/20">
|
| 214 |
<p className="text-red-400 mb-4">{error}</p>
|
| 215 |
-
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
</Card>
|
| 217 |
) : history.length === 0 ? (
|
| 218 |
<Card className="p-12 text-center bg-white/[0.02] border-white/5 border-dashed">
|
|
|
|
| 3 |
import { ArrowRight, TrendingUp, Clock, ChevronRight, LogOut, BarChart3, Activity } from "lucide-react";
|
| 4 |
import { useAuth } from "../contexts/AuthContext";
|
| 5 |
import { api } from "../api/client";
|
| 6 |
+
import { getGuestHistory } from "@/lib/guestStorage";
|
| 7 |
import { Button } from "@/components/ui/button";
|
| 8 |
import { Card } from "@/components/ui/card";
|
| 9 |
import { Badge } from "@/components/ui/badge";
|
|
|
|
| 20 |
};
|
| 21 |
|
| 22 |
export default function Dashboard({ onStartNew, onViewResults }) {
|
| 23 |
+
const { currentUser, isGuest, logout } = useAuth();
|
| 24 |
const [history, setHistory] = useState([]);
|
| 25 |
const [loading, setLoading] = useState(true);
|
| 26 |
const [error, setError] = useState("");
|
| 27 |
|
| 28 |
+
const fetchHistory = async () => {
|
| 29 |
+
try {
|
| 30 |
+
setLoading(true);
|
| 31 |
+
setError("");
|
| 32 |
+
|
| 33 |
+
// Guest users: load from local IndexedDB/localStorage
|
| 34 |
+
if (isGuest) {
|
| 35 |
+
const guestHistory = await getGuestHistory();
|
| 36 |
+
setHistory(guestHistory || []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
setLoading(false);
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Google users: load from backend
|
| 42 |
+
const res = await api.getUserHistory();
|
| 43 |
+
if (res.status === "ok") {
|
| 44 |
+
setHistory(res.history || []);
|
| 45 |
+
} else {
|
| 46 |
+
setError(res.detail || "Failed to load history");
|
| 47 |
}
|
| 48 |
+
} catch (err) {
|
| 49 |
+
setError(err.message || "Error connecting to server. Please try again later.");
|
| 50 |
+
console.error(err);
|
| 51 |
+
} finally {
|
| 52 |
+
setLoading(false);
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
|
| 56 |
+
useEffect(() => {
|
| 57 |
fetchHistory();
|
| 58 |
+
}, [isGuest]);
|
| 59 |
|
| 60 |
const formatDate = (dateStr) => {
|
| 61 |
if (!dateStr) return "Unknown date";
|
|
|
|
| 224 |
) : error ? (
|
| 225 |
<Card className="p-8 text-center bg-red-500/5 border-red-500/20">
|
| 226 |
<p className="text-red-400 mb-4">{error}</p>
|
| 227 |
+
<Button
|
| 228 |
+
variant="outline"
|
| 229 |
+
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
| 230 |
+
onClick={fetchHistory}
|
| 231 |
+
disabled={loading}
|
| 232 |
+
>
|
| 233 |
+
{loading ? 'Retrying...' : 'Retry'}
|
| 234 |
+
</Button>
|
| 235 |
</Card>
|
| 236 |
) : history.length === 0 ? (
|
| 237 |
<Card className="p-12 text-center bg-white/[0.02] border-white/5 border-dashed">
|
frontend/src/pages/Interview.jsx
CHANGED
|
@@ -44,6 +44,7 @@ export default function Interview({
|
|
| 44 |
const [isListening, setIsListening] = useState(false);
|
| 45 |
const [timeElapsed, setTimeElapsed] = useState(0);
|
| 46 |
const [isAnswering, setIsAnswering] = useState(false);
|
|
|
|
| 47 |
const streamRef = useRef(null);
|
| 48 |
const textareaRef = useRef(null);
|
| 49 |
const timerRef = useRef(null);
|
|
@@ -91,6 +92,15 @@ export default function Interview({
|
|
| 91 |
onSkip();
|
| 92 |
};
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
useEffect(() => {
|
| 95 |
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
| 96 |
.then(s => {
|
|
@@ -98,16 +108,36 @@ export default function Interview({
|
|
| 98 |
setCameraStream(s);
|
| 99 |
})
|
| 100 |
.catch(e => console.warn("Camera unavailable:", e));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
return () => {
|
| 102 |
-
|
| 103 |
-
|
|
|
|
| 104 |
};
|
| 105 |
-
}, []);
|
| 106 |
|
| 107 |
useEffect(() => {
|
| 108 |
setAnswer("");
|
| 109 |
resetRec();
|
| 110 |
setSub(false);
|
|
|
|
| 111 |
setIsListening(false);
|
| 112 |
setTimeElapsed(0);
|
| 113 |
setIsAnswering(false);
|
|
@@ -127,9 +157,17 @@ export default function Interview({
|
|
| 127 |
timerRef.current = setInterval(() => {
|
| 128 |
setTimeElapsed(prev => prev + 1);
|
| 129 |
}, 1000);
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
};
|
| 135 |
|
|
@@ -156,17 +194,26 @@ export default function Interview({
|
|
| 156 |
const handleTextSubmit = async () => {
|
| 157 |
if (!answer.trim() || submitting || loading || evaluating) return;
|
| 158 |
setSub(true);
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
};
|
| 162 |
|
| 163 |
const handleAudioSubmit = async () => {
|
| 164 |
if (!audioBlob || submitting || loading || evaluating) return;
|
| 165 |
setSub(true);
|
|
|
|
| 166 |
try {
|
| 167 |
await onSubmitAudio(audioBlob);
|
| 168 |
} catch (e) {
|
| 169 |
console.error("Audio submission failed:", e);
|
|
|
|
| 170 |
} finally {
|
| 171 |
setSub(false);
|
| 172 |
}
|
|
@@ -408,6 +455,20 @@ export default function Interview({
|
|
| 408 |
</div>
|
| 409 |
)}
|
| 410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
{/* Text Input */}
|
| 412 |
{mode === "text" && (
|
| 413 |
<div className="space-y-4 animate-in">
|
|
|
|
| 44 |
const [isListening, setIsListening] = useState(false);
|
| 45 |
const [timeElapsed, setTimeElapsed] = useState(0);
|
| 46 |
const [isAnswering, setIsAnswering] = useState(false);
|
| 47 |
+
const [submitError, setSubmitError] = useState(null);
|
| 48 |
const streamRef = useRef(null);
|
| 49 |
const textareaRef = useRef(null);
|
| 50 |
const timerRef = useRef(null);
|
|
|
|
| 92 |
onSkip();
|
| 93 |
};
|
| 94 |
|
| 95 |
+
// Camera cleanup helper
|
| 96 |
+
const stopCamera = useCallback(() => {
|
| 97 |
+
if (streamRef.current) {
|
| 98 |
+
streamRef.current.getTracks().forEach(t => t.stop());
|
| 99 |
+
streamRef.current = null;
|
| 100 |
+
}
|
| 101 |
+
setCameraStream(null);
|
| 102 |
+
}, []);
|
| 103 |
+
|
| 104 |
useEffect(() => {
|
| 105 |
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
| 106 |
.then(s => {
|
|
|
|
| 108 |
setCameraStream(s);
|
| 109 |
})
|
| 110 |
.catch(e => console.warn("Camera unavailable:", e));
|
| 111 |
+
|
| 112 |
+
// Cleanup on page unload (beforeunload)
|
| 113 |
+
const handleBeforeUnload = () => {
|
| 114 |
+
stopCamera();
|
| 115 |
+
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
// Cleanup on visibility change (tab switch/close)
|
| 119 |
+
const handleVisibilityChange = () => {
|
| 120 |
+
if (document.hidden && streamRef.current) {
|
| 121 |
+
// Optional: pause camera when tab hidden to save resources
|
| 122 |
+
// streamRef.current.getVideoTracks().forEach(t => t.enabled = false);
|
| 123 |
+
}
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
window.addEventListener('beforeunload', handleBeforeUnload);
|
| 127 |
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
| 128 |
+
|
| 129 |
return () => {
|
| 130 |
+
stopCamera();
|
| 131 |
+
window.removeEventListener('beforeunload', handleBeforeUnload);
|
| 132 |
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
| 133 |
};
|
| 134 |
+
}, [stopCamera]);
|
| 135 |
|
| 136 |
useEffect(() => {
|
| 137 |
setAnswer("");
|
| 138 |
resetRec();
|
| 139 |
setSub(false);
|
| 140 |
+
setSubmitError(null); // Clear any submission errors
|
| 141 |
setIsListening(false);
|
| 142 |
setTimeElapsed(0);
|
| 143 |
setIsAnswering(false);
|
|
|
|
| 157 |
timerRef.current = setInterval(() => {
|
| 158 |
setTimeElapsed(prev => prev + 1);
|
| 159 |
}, 1000);
|
| 160 |
+
|
| 161 |
+
// Check current mode at callback time, not closure time
|
| 162 |
+
// User may have switched to text mode during TTS playback
|
| 163 |
+
const currentMode = modeRef.current;
|
| 164 |
+
if (currentMode === "voice" && audioEnabled) {
|
| 165 |
+
// Double-check: verify user didn't just switch modes
|
| 166 |
+
setTimeout(() => {
|
| 167 |
+
if (modeRef.current === "voice") {
|
| 168 |
+
startRecRef.current();
|
| 169 |
+
}
|
| 170 |
+
}, 50);
|
| 171 |
}
|
| 172 |
};
|
| 173 |
|
|
|
|
| 194 |
const handleTextSubmit = async () => {
|
| 195 |
if (!answer.trim() || submitting || loading || evaluating) return;
|
| 196 |
setSub(true);
|
| 197 |
+
setSubmitError(null);
|
| 198 |
+
try {
|
| 199 |
+
await onSubmitText(answer);
|
| 200 |
+
} catch (e) {
|
| 201 |
+
console.error("Text submission failed:", e);
|
| 202 |
+
setSubmitError(e.message || "Failed to submit answer. Please try again.");
|
| 203 |
+
} finally {
|
| 204 |
+
setSub(false);
|
| 205 |
+
}
|
| 206 |
};
|
| 207 |
|
| 208 |
const handleAudioSubmit = async () => {
|
| 209 |
if (!audioBlob || submitting || loading || evaluating) return;
|
| 210 |
setSub(true);
|
| 211 |
+
setSubmitError(null);
|
| 212 |
try {
|
| 213 |
await onSubmitAudio(audioBlob);
|
| 214 |
} catch (e) {
|
| 215 |
console.error("Audio submission failed:", e);
|
| 216 |
+
setSubmitError(e.message || "Failed to submit audio. Please try again.");
|
| 217 |
} finally {
|
| 218 |
setSub(false);
|
| 219 |
}
|
|
|
|
| 455 |
</div>
|
| 456 |
)}
|
| 457 |
|
| 458 |
+
{/* Submit Error Display */}
|
| 459 |
+
{submitError && (
|
| 460 |
+
<div className="mb-4 p-3 bg-semantic-error-bg border border-semantic-error/20 rounded-sm flex items-center gap-2 text-sm text-semantic-error animate-in">
|
| 461 |
+
<AlertTriangle size={16} />
|
| 462 |
+
<span>{submitError}</span>
|
| 463 |
+
<button
|
| 464 |
+
onClick={() => setSubmitError(null)}
|
| 465 |
+
className="ml-auto text-xs hover:underline"
|
| 466 |
+
>
|
| 467 |
+
Dismiss
|
| 468 |
+
</button>
|
| 469 |
+
</div>
|
| 470 |
+
)}
|
| 471 |
+
|
| 472 |
{/* Text Input */}
|
| 473 |
{mode === "text" && (
|
| 474 |
<div className="space-y-4 animate-in">
|
frontend/src/pages/PreInterview.jsx
CHANGED
|
@@ -52,22 +52,37 @@ export default function PreInterview({ onBegin, setupData, sessionId }) {
|
|
| 52 |
|
| 53 |
// Background question generation
|
| 54 |
useEffect(() => {
|
| 55 |
-
if (!sessionId)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
const generateQuestions = async () => {
|
| 60 |
try {
|
| 61 |
setGenerating(true);
|
| 62 |
|
| 63 |
// Post setup data to backend before generating plan
|
| 64 |
-
if (setupData) {
|
|
|
|
| 65 |
await api.setJobDescription({
|
| 66 |
session_id: sessionId,
|
| 67 |
job_role: setupData.jobRole || "",
|
| 68 |
job_description: setupData.jobDescription || "",
|
| 69 |
company: setupData.company || "",
|
| 70 |
});
|
|
|
|
|
|
|
|
|
|
| 71 |
await api.setCandidateProfile({
|
| 72 |
session_id: sessionId,
|
| 73 |
name: setupData.name || "",
|
|
@@ -75,6 +90,7 @@ export default function PreInterview({ onBegin, setupData, sessionId }) {
|
|
| 75 |
experience: setupData.experience || "",
|
| 76 |
education: setupData.education || "",
|
| 77 |
});
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
const result = await api.generateDynamicInterview(sessionId);
|
|
@@ -89,20 +105,26 @@ export default function PreInterview({ onBegin, setupData, sessionId }) {
|
|
| 89 |
}
|
| 90 |
}
|
| 91 |
} catch (e) {
|
|
|
|
| 92 |
console.warn("Dynamic generation failed, falling back to static:", e);
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
| 104 |
setGenError(err.message || "Failed to generate questions");
|
| 105 |
-
}
|
|
|
|
|
|
|
| 106 |
setGenerating(false);
|
| 107 |
}
|
| 108 |
}
|
|
@@ -111,7 +133,17 @@ export default function PreInterview({ onBegin, setupData, sessionId }) {
|
|
| 111 |
|
| 112 |
generateQuestions();
|
| 113 |
|
| 114 |
-
return () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
}, [sessionId]);
|
| 116 |
|
| 117 |
useEffect(() => {
|
|
|
|
| 52 |
|
| 53 |
// Background question generation
|
| 54 |
useEffect(() => {
|
| 55 |
+
if (!sessionId) {
|
| 56 |
+
setGenError("No session found. Please go back and set up your interview again.");
|
| 57 |
+
setGenerating(false);
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
|
| 61 |
let cancelled = false;
|
| 62 |
+
const abortControllers = [];
|
| 63 |
+
|
| 64 |
+
const createAbortableRequest = () => {
|
| 65 |
+
const controller = new AbortController();
|
| 66 |
+
abortControllers.push(controller);
|
| 67 |
+
return controller;
|
| 68 |
+
};
|
| 69 |
|
| 70 |
const generateQuestions = async () => {
|
| 71 |
try {
|
| 72 |
setGenerating(true);
|
| 73 |
|
| 74 |
// Post setup data to backend before generating plan
|
| 75 |
+
if (setupData && !cancelled) {
|
| 76 |
+
const jobController = createAbortableRequest();
|
| 77 |
await api.setJobDescription({
|
| 78 |
session_id: sessionId,
|
| 79 |
job_role: setupData.jobRole || "",
|
| 80 |
job_description: setupData.jobDescription || "",
|
| 81 |
company: setupData.company || "",
|
| 82 |
});
|
| 83 |
+
if (cancelled) return;
|
| 84 |
+
|
| 85 |
+
const profileController = createAbortableRequest();
|
| 86 |
await api.setCandidateProfile({
|
| 87 |
session_id: sessionId,
|
| 88 |
name: setupData.name || "",
|
|
|
|
| 90 |
experience: setupData.experience || "",
|
| 91 |
education: setupData.education || "",
|
| 92 |
});
|
| 93 |
+
if (cancelled) return;
|
| 94 |
}
|
| 95 |
|
| 96 |
const result = await api.generateDynamicInterview(sessionId);
|
|
|
|
| 105 |
}
|
| 106 |
}
|
| 107 |
} catch (e) {
|
| 108 |
+
if (cancelled) return;
|
| 109 |
console.warn("Dynamic generation failed, falling back to static:", e);
|
| 110 |
+
|
| 111 |
+
try {
|
| 112 |
+
// FALLBACK: Generate static plan if dynamic fails
|
| 113 |
+
const fallback = await api.generatePlan(sessionId);
|
| 114 |
+
if (cancelled) return;
|
| 115 |
+
|
| 116 |
+
if (fallback.status === "ok") {
|
| 117 |
+
setQuestionsReady(true);
|
| 118 |
+
setGenError("Using standard questions (dynamic research failed)");
|
| 119 |
+
} else {
|
| 120 |
+
setGenError("Could not generate questions. Please try restarting.");
|
| 121 |
+
}
|
| 122 |
+
} catch (err) {
|
| 123 |
+
if (!cancelled) {
|
| 124 |
setGenError(err.message || "Failed to generate questions");
|
| 125 |
+
}
|
| 126 |
+
} finally {
|
| 127 |
+
if (!cancelled) {
|
| 128 |
setGenerating(false);
|
| 129 |
}
|
| 130 |
}
|
|
|
|
| 133 |
|
| 134 |
generateQuestions();
|
| 135 |
|
| 136 |
+
return () => {
|
| 137 |
+
cancelled = true;
|
| 138 |
+
// Abort any pending requests
|
| 139 |
+
abortControllers.forEach(controller => {
|
| 140 |
+
try {
|
| 141 |
+
controller.abort();
|
| 142 |
+
} catch (e) {
|
| 143 |
+
// Ignore abort errors
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
+
};
|
| 147 |
}, [sessionId]);
|
| 148 |
|
| 149 |
useEffect(() => {
|
frontend/src/pages/Processing.jsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
import { useEffect, useState, useRef } from "react";
|
| 2 |
import { api } from "../api/client";
|
|
|
|
|
|
|
| 3 |
import "./Processing.css";
|
| 4 |
|
| 5 |
const STEPS = [
|
|
@@ -10,6 +12,7 @@ const STEPS = [
|
|
| 10 |
];
|
| 11 |
|
| 12 |
export default function Processing({ sessionId, onDone }) {
|
|
|
|
| 13 |
const [currentStep, setCurrentStep] = useState(0);
|
| 14 |
const [error, setError] = useState(null);
|
| 15 |
const [completedSteps, setCompletedSteps] = useState([]);
|
|
@@ -41,6 +44,15 @@ export default function Processing({ sessionId, onDone }) {
|
|
| 41 |
const report = await api.getReport(sessionId);
|
| 42 |
setCompletedSteps(prev => [...prev, "report"]);
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
// Small delay for UX so user sees the last step complete
|
| 45 |
setTimeout(() => onDone(report), 800);
|
| 46 |
} catch (err) {
|
|
@@ -50,7 +62,7 @@ export default function Processing({ sessionId, onDone }) {
|
|
| 50 |
};
|
| 51 |
|
| 52 |
runAnalysis();
|
| 53 |
-
}, [sessionId, onDone]);
|
| 54 |
|
| 55 |
const handleRetry = () => {
|
| 56 |
setError(null);
|
|
|
|
| 1 |
import { useEffect, useState, useRef } from "react";
|
| 2 |
import { api } from "../api/client";
|
| 3 |
+
import { useAuth } from "../contexts/AuthContext";
|
| 4 |
+
import { saveGuestInterview } from "@/lib/guestStorage";
|
| 5 |
import "./Processing.css";
|
| 6 |
|
| 7 |
const STEPS = [
|
|
|
|
| 12 |
];
|
| 13 |
|
| 14 |
export default function Processing({ sessionId, onDone }) {
|
| 15 |
+
const { isGuest } = useAuth();
|
| 16 |
const [currentStep, setCurrentStep] = useState(0);
|
| 17 |
const [error, setError] = useState(null);
|
| 18 |
const [completedSteps, setCompletedSteps] = useState([]);
|
|
|
|
| 44 |
const report = await api.getReport(sessionId);
|
| 45 |
setCompletedSteps(prev => [...prev, "report"]);
|
| 46 |
|
| 47 |
+
// Save to guest storage for guest users
|
| 48 |
+
if (isGuest && report) {
|
| 49 |
+
try {
|
| 50 |
+
await saveGuestInterview(sessionId, report);
|
| 51 |
+
} catch (e) {
|
| 52 |
+
console.warn("Failed to save guest interview:", e);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
// Small delay for UX so user sees the last step complete
|
| 57 |
setTimeout(() => onDone(report), 800);
|
| 58 |
} catch (err) {
|
|
|
|
| 62 |
};
|
| 63 |
|
| 64 |
runAnalysis();
|
| 65 |
+
}, [sessionId, onDone, isGuest]);
|
| 66 |
|
| 67 |
const handleRetry = () => {
|
| 68 |
setError(null);
|
frontend/src/pages/Setup.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useRef, useState, useCallback } from "react";
|
| 2 |
import { motion, AnimatePresence } from "framer-motion";
|
| 3 |
import { Upload, Check, AlertCircle, ArrowRight, FileText, ChevronLeft } from "lucide-react";
|
| 4 |
import { useInterview } from "../contexts/InterviewContext";
|
|
@@ -90,6 +90,22 @@ export default function Setup({ onSubmit, loading: outerLoading, error: outerErr
|
|
| 90 |
const [showDetails, setShowDetails] = useState(null); // 'skills' | 'projects' | null
|
| 91 |
const fileRef = useRef();
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
| 94 |
|
| 95 |
const handleFileSelect = useCallback(async (selectedFile) => {
|
|
|
|
| 1 |
+
import { useRef, useState, useCallback, useEffect } from "react";
|
| 2 |
import { motion, AnimatePresence } from "framer-motion";
|
| 3 |
import { Upload, Check, AlertCircle, ArrowRight, FileText, ChevronLeft } from "lucide-react";
|
| 4 |
import { useInterview } from "../contexts/InterviewContext";
|
|
|
|
| 90 |
const [showDetails, setShowDetails] = useState(null); // 'skills' | 'projects' | null
|
| 91 |
const fileRef = useRef();
|
| 92 |
|
| 93 |
+
// Ensure session exists on mount (for direct navigation or refresh)
|
| 94 |
+
useEffect(() => {
|
| 95 |
+
const ensureSession = async () => {
|
| 96 |
+
if (!iv.sessionId) {
|
| 97 |
+
try {
|
| 98 |
+
const sessionRes = await api.createSession();
|
| 99 |
+
iv.setSession(sessionRes.session_id);
|
| 100 |
+
} catch (err) {
|
| 101 |
+
console.error("Failed to create session:", err);
|
| 102 |
+
setParseError("Failed to initialize session. Please refresh and try again.");
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
ensureSession();
|
| 107 |
+
}, []);
|
| 108 |
+
|
| 109 |
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
| 110 |
|
| 111 |
const handleFileSelect = useCallback(async (selectedFile) => {
|