faais-k commited on
Commit
9cd7dfb
·
1 Parent(s): ed7ca08

feat: implement guest storage, refine interview flow, and fix UI crashes

Browse files
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
- if (!currentUser) {
 
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
- async function req(path, options = {}) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const res = await fetch(`${API_BASE}${path}`, {
31
- ...options,
32
- headers,
33
- });
34
- if (!res.ok) {
35
- const data = await res.json().catch(() => ({}));
36
- const error = new Error(data.detail || data.message || `API error ${res.status}`);
37
- error.status = res.status;
38
- error.data = data;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  throw error;
40
  }
41
- return res.json();
42
  }
43
 
44
- async function reqMultipart(url, body) {
45
  const token = localStorage.getItem("firebaseToken");
46
  const headers = {
47
  ...(token ? { "Authorization": `Bearer ${token}` } : {})
48
  };
49
 
50
- const res = await fetch(url, {
51
- method: "POST",
52
- body,
53
- headers
54
- });
55
- if (!res.ok) {
56
- const data = await res.json().catch(() => ({}));
57
- const error = new Error(data.detail || data.message || `API error ${res.status}`);
58
- error.status = res.status;
59
- error.data = data;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  throw error;
61
  }
62
- return res.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- const latest = metricsRef.current[metricsRef.current.length - 1];
220
- try { await api.sendPosture({ session_id: sessionId, metrics: latest }); } catch (_) {}
 
 
 
 
 
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
- try { await api.logViolation({ session_id: sessionId, type, details }); } catch (_) {}
23
- }, [sessionId, enabled]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  useEffect(() => {
26
  if (!enabled) return;
@@ -65,5 +140,12 @@ export function useAntiCheat(sessionId, enabled = false) {
65
  document.exitFullscreen?.();
66
  }, []);
67
 
68
- return { violations, showWarning, warningMessage, enterFullscreen, exitFullscreen };
 
 
 
 
 
 
 
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
- useEffect(() => {
28
- const fetchHistory = async () => {
29
- try {
30
- setLoading(true);
31
- const res = await api.getUserHistory();
32
- if (res.status === "ok") {
33
- setHistory(res.history || []);
34
- } else {
35
- setError(res.detail || "Failed to load history");
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 variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={() => window.location.reload()}>Retry</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
- streamRef.current?.getTracks().forEach(t => t.stop());
103
- setCameraStream(null);
 
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
- if (modeRef.current === "voice") {
132
- startRecRef.current();
 
 
 
 
 
 
 
 
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
- await onSubmitText(answer);
160
- setSub(false);
 
 
 
 
 
 
 
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) return;
 
 
 
 
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
- if (!cancelled) {
94
- try {
95
- // FALLBACK: Generate static plan if dynamic fails
96
- const fallback = await api.generatePlan(sessionId);
97
- if (fallback.status === "ok") {
98
- setQuestionsReady(true);
99
- setGenError("Using standard questions (dynamic research failed)");
100
- } else {
101
- setGenError("Could not generate questions. Please try restarting.");
102
- }
103
- } catch (err) {
 
 
 
104
  setGenError(err.message || "Failed to generate questions");
105
- } finally {
 
 
106
  setGenerating(false);
107
  }
108
  }
@@ -111,7 +133,17 @@ export default function PreInterview({ onBegin, setupData, sessionId }) {
111
 
112
  generateQuestions();
113
 
114
- return () => { cancelled = true; };
 
 
 
 
 
 
 
 
 
 
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) => {