Kennedy Johnson Cursor commited on
Commit
c8cd296
·
1 Parent(s): 6653199

Add guest try-mode and internship vs full-time path gate.

Browse files

Let visitors start without an account, and require internship or full-time focus so advisors steer the conversation correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>

launchpad_config.yaml CHANGED
@@ -114,6 +114,9 @@ personas:
114
  - Be encouraging but honest about competitive markets; never invent job openings or fake company contacts.
115
  - Prefer Handshake, LinkedIn, university career services, and official company career pages as primary channels.
116
  - When a USER CAREER PROFILE is provided, personalize advice to academic standing, career focus, target roles, constraints, and work style.
 
 
 
117
  - Never invent job openings, alumni contacts, metrics, or experience the user did not claim.
118
  personas_dir: "personas/career_advisors"
119
 
 
114
  - Be encouraging but honest about competitive markets; never invent job openings or fake company contacts.
115
  - Prefer Handshake, LinkedIn, university career services, and official company career pages as primary channels.
116
  - When a USER CAREER PROFILE is provided, personalize advice to academic standing, career focus, target roles, constraints, and work style.
117
+ - Treat career_focus as the primary trajectory switch:
118
+ - "Internship search" → prioritize internships, Handshake/career fairs, junior-friendly roles, return-offer paths.
119
+ - "Full-time / entry-level" → prioritize new-grad / entry-level FT search, stronger interview rigor, offer comparison.
120
  - Never invent job openings, alumni contacts, metrics, or experience the user did not claim.
121
  personas_dir: "personas/career_advisors"
122
 
multi_llm_chatbot_backend/app/api/routes/auth.py CHANGED
@@ -15,6 +15,8 @@ from app.core.auth import (
15
  )
16
  from app.core.database import get_database
17
  import logging
 
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
@@ -98,6 +100,8 @@ async def signup(user_data: UserCreate):
98
  profile_seed["knowledge_level"] = user_data.academicStage
99
  if user_data.researchArea:
100
  profile_seed["timezone"] = user_data.researchArea
 
 
101
  await db.user_profiles.update_one(
102
  {"user_id": user.id},
103
  {"$set": profile_seed},
@@ -173,6 +177,52 @@ async def login(user_credentials: UserLogin):
173
  detail="Login failed"
174
  )
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  @router.get("/me", response_model=UserResponse)
177
  async def get_current_user_profile(current_user: User = Depends(get_current_active_user)):
178
  """
 
15
  )
16
  from app.core.database import get_database
17
  import logging
18
+ import secrets
19
+ import uuid
20
 
21
  logger = logging.getLogger(__name__)
22
 
 
100
  profile_seed["knowledge_level"] = user_data.academicStage
101
  if user_data.researchArea:
102
  profile_seed["timezone"] = user_data.researchArea
103
+ if user_data.careerFocus:
104
+ profile_seed["cyber_role"] = user_data.careerFocus
105
  await db.user_profiles.update_one(
106
  {"user_id": user.id},
107
  {"$set": profile_seed},
 
177
  detail="Login failed"
178
  )
179
 
180
+
181
+ @router.post("/guest", response_model=Token)
182
+ async def guest_login():
183
+ """Create a disposable guest session so visitors can try the advisors without signing up."""
184
+ try:
185
+ db = get_database()
186
+ guest_id = uuid.uuid4().hex[:12]
187
+ email = f"guest-{guest_id}@guest.launchpad.local"
188
+ user = User(
189
+ firstName="Guest",
190
+ lastName="Explorer",
191
+ email=email,
192
+ hashed_password=get_password_hash(secrets.token_urlsafe(32)),
193
+ created_at=datetime.utcnow(),
194
+ last_login=datetime.utcnow(),
195
+ is_active=True,
196
+ is_guest=True,
197
+ )
198
+ result = await db.users.insert_one(user.dict(by_alias=True))
199
+ user.id = result.inserted_id
200
+ await db.user_profiles.update_one(
201
+ {"user_id": user.id},
202
+ {
203
+ "$set": {
204
+ "user_id": user.id,
205
+ "updated_at": datetime.utcnow(),
206
+ }
207
+ },
208
+ upsert=True,
209
+ )
210
+ access_token = create_access_token(
211
+ data={"sub": str(user.id), "guest": True},
212
+ expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
213
+ )
214
+ return Token(
215
+ access_token=access_token,
216
+ token_type="bearer",
217
+ user=create_user_response(user),
218
+ )
219
+ except Exception as e:
220
+ logger.error(f"Error during guest login: {e}")
221
+ raise HTTPException(
222
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
223
+ detail="Could not start guest session",
224
+ )
225
+
226
  @router.get("/me", response_model=UserResponse)
227
  async def get_current_user_profile(current_user: User = Depends(get_current_active_user)):
228
  """
multi_llm_chatbot_backend/app/core/auth.py CHANGED
@@ -115,5 +115,6 @@ def create_user_response(user: User) -> UserResponse:
115
  researchArea=user.researchArea,
116
  avatarId=user.avatarId,
117
  created_at=user.created_at,
118
- last_login=user.last_login
 
119
  )
 
115
  researchArea=user.researchArea,
116
  avatarId=user.avatarId,
117
  created_at=user.created_at,
118
+ last_login=user.last_login,
119
+ is_guest=bool(getattr(user, "is_guest", False)),
120
  )
multi_llm_chatbot_backend/app/models/user.py CHANGED
@@ -28,6 +28,8 @@ class UserCreate(BaseModel):
28
  password: str
29
  academicStage: Optional[str] = None
30
  researchArea: Optional[str] = None
 
 
31
 
32
  class UserLogin(BaseModel):
33
  email: EmailStr
@@ -51,6 +53,7 @@ class User(BaseModel):
51
  created_at: datetime = Field(default_factory=datetime.utcnow)
52
  last_login: Optional[datetime] = None
53
  is_active: bool = True
 
54
 
55
  class UserUpdate(BaseModel):
56
  avatarId: Optional[str] = None
@@ -71,6 +74,7 @@ class UserResponse(BaseModel):
71
  avatarId: Optional[str] = None
72
  created_at: datetime
73
  last_login: Optional[datetime] = None
 
74
 
75
  class ChatSession(BaseModel):
76
  model_config = ConfigDict(
 
28
  password: str
29
  academicStage: Optional[str] = None
30
  researchArea: Optional[str] = None
31
+ # Maps to profile.cyber_role — "Internship search" | "Full-time / entry-level" | etc.
32
+ careerFocus: Optional[str] = None
33
 
34
  class UserLogin(BaseModel):
35
  email: EmailStr
 
53
  created_at: datetime = Field(default_factory=datetime.utcnow)
54
  last_login: Optional[datetime] = None
55
  is_active: bool = True
56
+ is_guest: bool = False
57
 
58
  class UserUpdate(BaseModel):
59
  avatarId: Optional[str] = None
 
74
  avatarId: Optional[str] = None
75
  created_at: datetime
76
  last_login: Optional[datetime] = None
77
+ is_guest: bool = False
78
 
79
  class ChatSession(BaseModel):
80
  model_config = ConfigDict(
phd-advisor-frontend/src/App.js CHANGED
@@ -115,6 +115,26 @@ function App() {
115
  setCurrentView('chat');
116
  };
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  const handleSignOut = () => {
119
  clearAuthState();
120
  };
@@ -133,6 +153,7 @@ function App() {
133
  onNavigateToHome={navigateToHome}
134
  onNavigateToChat={sessionReady ? navigateToChat : navigateToAuth}
135
  onNavigateToCanvas={sessionReady ? navigateToCanvas : navigateToAuth}
 
136
  isAuthenticated={sessionReady}
137
  />
138
  )}
 
115
  setCurrentView('chat');
116
  };
117
 
118
+ const handleGuestStart = async () => {
119
+ if (authBootstrapping) {
120
+ return;
121
+ }
122
+ try {
123
+ const response = await fetch(`${getApiBaseUrl()}/auth/guest`, {
124
+ method: 'POST',
125
+ headers: { 'Content-Type': 'application/json' },
126
+ });
127
+ const data = await response.json();
128
+ if (response.ok) {
129
+ handleAuthSuccess(data.user, data.access_token);
130
+ } else {
131
+ setCurrentView('auth');
132
+ }
133
+ } catch {
134
+ setCurrentView('auth');
135
+ }
136
+ };
137
+
138
  const handleSignOut = () => {
139
  clearAuthState();
140
  };
 
153
  onNavigateToHome={navigateToHome}
154
  onNavigateToChat={sessionReady ? navigateToChat : navigateToAuth}
155
  onNavigateToCanvas={sessionReady ? navigateToCanvas : navigateToAuth}
156
+ onTryAsGuest={sessionReady ? navigateToChat : handleGuestStart}
157
  isAuthenticated={sessionReady}
158
  />
159
  )}
phd-advisor-frontend/src/components/Login.js CHANGED
@@ -1,7 +1,7 @@
1
  import React, { useState } from 'react';
2
  import { Eye, EyeOff, Mail, Lock, ArrowRight } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
- import { persistAuth } from '../utils/authStorage';
5
  import CopyrightNotice from './CopyrightNotice';
6
  import '../styles/Login.css';
7
 
@@ -14,6 +14,7 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
14
  password: ''
15
  });
16
  const [isLoading, setIsLoading] = useState(false);
 
17
  const [errors, setErrors] = useState({});
18
 
19
  const handleInputChange = (e) => {
@@ -22,7 +23,6 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
22
  ...prev,
23
  [name]: value
24
  }));
25
- // Clear error when user starts typing
26
  if (errors[name]) {
27
  setErrors(prev => ({
28
  ...prev,
@@ -33,32 +33,55 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
33
 
34
  const validateForm = () => {
35
  const newErrors = {};
36
-
37
  if (!formData.email) {
38
  newErrors.email = 'Email is required';
39
  } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
40
  newErrors.email = 'Please enter a valid email address';
41
  }
42
-
43
  if (!formData.password) {
44
  newErrors.password = 'Password is required';
45
  } else if (formData.password.length < 6) {
46
  newErrors.password = 'Password must be at least 6 characters';
47
  }
48
-
49
  setErrors(newErrors);
50
  return Object.keys(newErrors).length === 0;
51
  };
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const handleSubmit = async (e) => {
54
  e.preventDefault();
55
-
56
  if (!validateForm()) return;
57
-
58
  setIsLoading(true);
59
-
60
  try {
61
- const response = await fetch(`${process.env.REACT_APP_API_URL}/auth/login`, {
62
  method: 'POST',
63
  headers: {
64
  'Content-Type': 'application/json',
@@ -77,7 +100,7 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
77
  } else {
78
  setErrors({ submit: data.detail || 'Login failed. Please try again.' });
79
  }
80
-
81
  } catch (error) {
82
  console.error('Login error:', error);
83
  setErrors({ submit: 'Login failed. Please try again.' });
@@ -86,11 +109,12 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
86
  }
87
  };
88
 
 
 
89
  return (
90
  <div className="login-page">
91
  <div className="login-content">
92
  <div className="login-container">
93
- {/* Header */}
94
  <div className="login-header">
95
  <div className="logo-container">
96
  <LogoIcon className="logo-icon" />
@@ -101,11 +125,23 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
101
  </p>
102
  </div>
103
 
104
- {/* Main Login Form */}
105
  <div className="login-form-container">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  <form onSubmit={handleSubmit} className="login-form">
107
-
108
- {/* Email Field */}
109
  <div className="form-group">
110
  <label htmlFor="email" className="form-label">
111
  Email Address
@@ -120,7 +156,7 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
120
  onChange={handleInputChange}
121
  className={`form-input ${errors.email ? 'error' : ''}`}
122
  placeholder="Enter your email"
123
- disabled={isLoading}
124
  />
125
  </div>
126
  {errors.email && (
@@ -128,7 +164,6 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
128
  )}
129
  </div>
130
 
131
- {/* Password Field */}
132
  <div className="form-group">
133
  <label htmlFor="password" className="form-label">
134
  Password
@@ -143,13 +178,13 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
143
  onChange={handleInputChange}
144
  className={`form-input ${errors.password ? 'error' : ''}`}
145
  placeholder="Enter your password"
146
- disabled={isLoading}
147
  />
148
  <button
149
  type="button"
150
  onClick={() => setShowPassword(!showPassword)}
151
  className="password-toggle"
152
- disabled={isLoading}
153
  >
154
  {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
155
  </button>
@@ -159,25 +194,22 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
159
  )}
160
  </div>
161
 
162
- {/* Forgot Password */}
163
  <div className="form-actions">
164
  <button type="button" className="forgot-password">
165
  Forgot your password?
166
  </button>
167
  </div>
168
 
169
- {/* Submit Error */}
170
  {errors.submit && (
171
  <div className="submit-error">
172
  {errors.submit}
173
  </div>
174
  )}
175
 
176
- {/* Submit Button */}
177
- <button
178
- type="submit"
179
  className={`submit-btn ${isLoading ? 'loading' : ''}`}
180
- disabled={isLoading}
181
  >
182
  {isLoading ? (
183
  <>
@@ -194,14 +226,14 @@ const Login = ({ onNavigateToSignup, onNavigateToHome }) => {
194
  </form>
195
  </div>
196
 
197
- {/* Footer */}
198
  <div className="login-footer">
199
  <p>
200
  Don't have an account?{' '}
201
- <button
202
- type="button"
203
  className="link-btn"
204
  onClick={onNavigateToSignup}
 
205
  >
206
  Sign up here
207
  </button>
 
1
  import React, { useState } from 'react';
2
  import { Eye, EyeOff, Mail, Lock, ArrowRight } from 'lucide-react';
3
  import { useAppConfig } from '../contexts/AppConfigContext';
4
+ import { persistAuth, getApiBaseUrl } from '../utils/authStorage';
5
  import CopyrightNotice from './CopyrightNotice';
6
  import '../styles/Login.css';
7
 
 
14
  password: ''
15
  });
16
  const [isLoading, setIsLoading] = useState(false);
17
+ const [isGuestLoading, setIsGuestLoading] = useState(false);
18
  const [errors, setErrors] = useState({});
19
 
20
  const handleInputChange = (e) => {
 
23
  ...prev,
24
  [name]: value
25
  }));
 
26
  if (errors[name]) {
27
  setErrors(prev => ({
28
  ...prev,
 
33
 
34
  const validateForm = () => {
35
  const newErrors = {};
36
+
37
  if (!formData.email) {
38
  newErrors.email = 'Email is required';
39
  } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
40
  newErrors.email = 'Please enter a valid email address';
41
  }
42
+
43
  if (!formData.password) {
44
  newErrors.password = 'Password is required';
45
  } else if (formData.password.length < 6) {
46
  newErrors.password = 'Password must be at least 6 characters';
47
  }
48
+
49
  setErrors(newErrors);
50
  return Object.keys(newErrors).length === 0;
51
  };
52
 
53
+ const handleGuestContinue = async () => {
54
+ setIsGuestLoading(true);
55
+ setErrors({});
56
+ try {
57
+ const response = await fetch(`${getApiBaseUrl()}/auth/guest`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ });
61
+ const data = await response.json();
62
+ if (response.ok) {
63
+ persistAuth(data.user, data.access_token);
64
+ onNavigateToHome?.(data.user, data.access_token);
65
+ } else {
66
+ setErrors({ submit: data.detail || 'Could not start a guest session.' });
67
+ }
68
+ } catch (error) {
69
+ console.error('Guest login error:', error);
70
+ setErrors({ submit: 'Could not start a guest session. Please try again.' });
71
+ } finally {
72
+ setIsGuestLoading(false);
73
+ }
74
+ };
75
+
76
  const handleSubmit = async (e) => {
77
  e.preventDefault();
78
+
79
  if (!validateForm()) return;
80
+
81
  setIsLoading(true);
82
+
83
  try {
84
+ const response = await fetch(`${getApiBaseUrl()}/auth/login`, {
85
  method: 'POST',
86
  headers: {
87
  'Content-Type': 'application/json',
 
100
  } else {
101
  setErrors({ submit: data.detail || 'Login failed. Please try again.' });
102
  }
103
+
104
  } catch (error) {
105
  console.error('Login error:', error);
106
  setErrors({ submit: 'Login failed. Please try again.' });
 
109
  }
110
  };
111
 
112
+ const busy = isLoading || isGuestLoading;
113
+
114
  return (
115
  <div className="login-page">
116
  <div className="login-content">
117
  <div className="login-container">
 
118
  <div className="login-header">
119
  <div className="logo-container">
120
  <LogoIcon className="logo-icon" />
 
125
  </p>
126
  </div>
127
 
 
128
  <div className="login-form-container">
129
+ <button
130
+ type="button"
131
+ className={`guest-continue-btn ${isGuestLoading ? 'loading' : ''}`}
132
+ onClick={handleGuestContinue}
133
+ disabled={busy}
134
+ >
135
+ {isGuestLoading ? 'Starting guest mode…' : 'Try without an account'}
136
+ </button>
137
+ <p className="guest-continue-hint">
138
+ Play with the advisors first. Create an account later if you want to save your chats.
139
+ </p>
140
+ <div className="login-divider" aria-hidden="true">
141
+ <span>or sign in</span>
142
+ </div>
143
+
144
  <form onSubmit={handleSubmit} className="login-form">
 
 
145
  <div className="form-group">
146
  <label htmlFor="email" className="form-label">
147
  Email Address
 
156
  onChange={handleInputChange}
157
  className={`form-input ${errors.email ? 'error' : ''}`}
158
  placeholder="Enter your email"
159
+ disabled={busy}
160
  />
161
  </div>
162
  {errors.email && (
 
164
  )}
165
  </div>
166
 
 
167
  <div className="form-group">
168
  <label htmlFor="password" className="form-label">
169
  Password
 
178
  onChange={handleInputChange}
179
  className={`form-input ${errors.password ? 'error' : ''}`}
180
  placeholder="Enter your password"
181
+ disabled={busy}
182
  />
183
  <button
184
  type="button"
185
  onClick={() => setShowPassword(!showPassword)}
186
  className="password-toggle"
187
+ disabled={busy}
188
  >
189
  {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
190
  </button>
 
194
  )}
195
  </div>
196
 
 
197
  <div className="form-actions">
198
  <button type="button" className="forgot-password">
199
  Forgot your password?
200
  </button>
201
  </div>
202
 
 
203
  {errors.submit && (
204
  <div className="submit-error">
205
  {errors.submit}
206
  </div>
207
  )}
208
 
209
+ <button
210
+ type="submit"
 
211
  className={`submit-btn ${isLoading ? 'loading' : ''}`}
212
+ disabled={busy}
213
  >
214
  {isLoading ? (
215
  <>
 
226
  </form>
227
  </div>
228
 
 
229
  <div className="login-footer">
230
  <p>
231
  Don't have an account?{' '}
232
+ <button
233
+ type="button"
234
  className="link-btn"
235
  onClick={onNavigateToSignup}
236
+ disabled={busy}
237
  >
238
  Sign up here
239
  </button>
phd-advisor-frontend/src/components/SearchPathGate.css ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .search-path-backdrop {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: 1200;
5
+ background: rgba(15, 23, 42, 0.55);
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ padding: 24px 16px;
10
+ backdrop-filter: blur(4px);
11
+ }
12
+
13
+ .search-path-card {
14
+ width: 100%;
15
+ max-width: 560px;
16
+ background: var(--bg-primary, #fff);
17
+ border: 1px solid var(--border-primary, #e2e8f0);
18
+ border-radius: 20px;
19
+ padding: 28px 24px 22px;
20
+ box-shadow: var(--shadow-xl, 0 20px 40px rgba(0, 0, 0, 0.18));
21
+ }
22
+
23
+ .search-path-card h2 {
24
+ margin: 0 0 8px;
25
+ font-size: 1.35rem;
26
+ color: var(--text-primary, #0f172a);
27
+ }
28
+
29
+ .search-path-sub {
30
+ margin: 0 0 20px;
31
+ color: var(--text-secondary, #475569);
32
+ line-height: 1.5;
33
+ font-size: 0.95rem;
34
+ }
35
+
36
+ .search-path-options {
37
+ display: grid;
38
+ grid-template-columns: 1fr 1fr;
39
+ gap: 12px;
40
+ margin-bottom: 16px;
41
+ }
42
+
43
+ @media (max-width: 560px) {
44
+ .search-path-options {
45
+ grid-template-columns: 1fr;
46
+ }
47
+ }
48
+
49
+ .search-path-option {
50
+ text-align: left;
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: 6px;
54
+ padding: 16px;
55
+ border-radius: 14px;
56
+ border: 2px solid var(--border-primary, #e2e8f0);
57
+ background: var(--bg-secondary, #f8fafc);
58
+ cursor: pointer;
59
+ transition: border-color 0.15s, background 0.15s, transform 0.15s;
60
+ }
61
+
62
+ .search-path-option:hover {
63
+ border-color: #0f766e;
64
+ transform: translateY(-1px);
65
+ }
66
+
67
+ .search-path-option.active {
68
+ border-color: #0f766e;
69
+ background: #ccfbf1;
70
+ }
71
+
72
+ .search-path-option-icon {
73
+ color: #0f766e;
74
+ }
75
+
76
+ .search-path-option-title {
77
+ font-weight: 700;
78
+ font-size: 1.05rem;
79
+ color: var(--text-primary, #0f172a);
80
+ }
81
+
82
+ .search-path-option-desc {
83
+ font-size: 0.85rem;
84
+ line-height: 1.4;
85
+ color: var(--text-secondary, #475569);
86
+ }
87
+
88
+ .search-path-error {
89
+ color: #b91c1c;
90
+ font-size: 0.875rem;
91
+ margin-bottom: 10px;
92
+ }
93
+
94
+ .search-path-continue {
95
+ width: 100%;
96
+ display: inline-flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ gap: 8px;
100
+ border: none;
101
+ border-radius: 12px;
102
+ padding: 12px 16px;
103
+ background: #0f766e;
104
+ color: #fff;
105
+ font-weight: 600;
106
+ cursor: pointer;
107
+ }
108
+
109
+ .search-path-continue:disabled {
110
+ opacity: 0.55;
111
+ cursor: not-allowed;
112
+ }
phd-advisor-frontend/src/components/SearchPathGate.js ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Briefcase, GraduationCap, ArrowRight } from 'lucide-react';
3
+ import './SearchPathGate.css';
4
+
5
+ export const SEARCH_PATH_OPTIONS = [
6
+ {
7
+ value: 'Internship search',
8
+ title: 'Internship',
9
+ description: 'I am looking for an internship (typical for juniors and earlier).',
10
+ icon: GraduationCap,
11
+ },
12
+ {
13
+ value: 'Full-time / entry-level',
14
+ title: 'Full-time job',
15
+ description: 'I am looking for a full-time / entry-level role (typical for seniors and grads).',
16
+ icon: Briefcase,
17
+ },
18
+ ];
19
+
20
+ /**
21
+ * First-run gate: internship vs full-time changes advisor trajectory.
22
+ */
23
+ const SearchPathGate = ({ authToken, onComplete }) => {
24
+ const [selected, setSelected] = useState(null);
25
+ const [saving, setSaving] = useState(false);
26
+ const [error, setError] = useState('');
27
+
28
+ const handleContinue = async () => {
29
+ if (!selected) {
30
+ setError('Pick internship or full-time to continue.');
31
+ return;
32
+ }
33
+ setSaving(true);
34
+ setError('');
35
+ try {
36
+ const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, {
37
+ method: 'PUT',
38
+ headers: {
39
+ Authorization: `Bearer ${authToken}`,
40
+ 'Content-Type': 'application/json',
41
+ },
42
+ body: JSON.stringify({ cyber_role: selected }),
43
+ });
44
+ if (!resp.ok) {
45
+ const data = await resp.json().catch(() => ({}));
46
+ throw new Error(data.detail || 'Could not save your choice');
47
+ }
48
+ const profile = await resp.json();
49
+ localStorage.setItem('launchpadSearchPath', selected);
50
+ onComplete?.(profile, selected);
51
+ } catch (e) {
52
+ setError(e.message || 'Could not save your choice');
53
+ } finally {
54
+ setSaving(false);
55
+ }
56
+ };
57
+
58
+ return (
59
+ <div className="search-path-backdrop" role="dialog" aria-modal="true" aria-labelledby="search-path-title">
60
+ <div className="search-path-card">
61
+ <h2 id="search-path-title">What are you aiming for right now?</h2>
62
+ <p className="search-path-sub">
63
+ This steers every advisor — search strategy, resume tone, interview prep, and weekly cadence.
64
+ </p>
65
+ <div className="search-path-options">
66
+ {SEARCH_PATH_OPTIONS.map((opt) => {
67
+ const Icon = opt.icon;
68
+ const active = selected === opt.value;
69
+ return (
70
+ <button
71
+ key={opt.value}
72
+ type="button"
73
+ className={`search-path-option ${active ? 'active' : ''}`}
74
+ onClick={() => { setSelected(opt.value); setError(''); }}
75
+ disabled={saving}
76
+ >
77
+ <Icon className="search-path-option-icon" size={22} />
78
+ <span className="search-path-option-title">{opt.title}</span>
79
+ <span className="search-path-option-desc">{opt.description}</span>
80
+ </button>
81
+ );
82
+ })}
83
+ </div>
84
+ {error && <div className="search-path-error">{error}</div>}
85
+ <button
86
+ type="button"
87
+ className="search-path-continue"
88
+ onClick={handleContinue}
89
+ disabled={saving || !selected}
90
+ >
91
+ {saving ? 'Saving…' : 'Continue to advisors'}
92
+ {!saving && <ArrowRight size={16} />}
93
+ </button>
94
+ </div>
95
+ </div>
96
+ );
97
+ };
98
+
99
+ export function needsSearchPath(profile) {
100
+ const role = profile?.cyber_role;
101
+ if (!role) return true;
102
+ const normalized = String(role).toLowerCase();
103
+ return !(
104
+ normalized.includes('internship')
105
+ || normalized.includes('full-time')
106
+ || normalized.includes('full time')
107
+ || normalized.includes('both')
108
+ );
109
+ }
110
+
111
+ export default SearchPathGate;
phd-advisor-frontend/src/components/Signup.js CHANGED
@@ -16,7 +16,8 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
16
  password: '',
17
  confirmPassword: '',
18
  academicStage: '',
19
- researchArea: ''
 
20
  });
21
  const [isLoading, setIsLoading] = useState(false);
22
  const [errors, setErrors] = useState({});
@@ -86,6 +87,10 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
86
  if (!formData.academicStage) {
87
  newErrors.academicStage = 'Please select your academic standing';
88
  }
 
 
 
 
89
 
90
  setErrors(newErrors);
91
  return Object.keys(newErrors).length === 0;
@@ -110,7 +115,8 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
110
  email: formData.email,
111
  password: formData.password,
112
  academicStage: formData.academicStage,
113
- researchArea: formData.researchArea
 
114
  }),
115
  });
116
 
@@ -282,10 +288,10 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
282
  </div>
283
  </div>
284
 
285
- {/* Fitness level */}
286
  <div className="form-group">
287
  <label htmlFor="academicStage" className="form-label">
288
- Fitness level
289
  </label>
290
  <div className="input-container">
291
  <Shield className="input-icon" />
@@ -309,6 +315,35 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
309
  )}
310
  </div>
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  {/* Time zone (optional) */}
313
  <div className="form-group">
314
  <label htmlFor="researchArea" className="form-label">
@@ -382,6 +417,28 @@ const Signup = ({ onNavigateToLogin, onNavigateToHome }) => {
382
  >
383
  Sign in here
384
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  </p>
386
  </div>
387
  </div>
 
16
  password: '',
17
  confirmPassword: '',
18
  academicStage: '',
19
+ researchArea: '',
20
+ careerFocus: '',
21
  });
22
  const [isLoading, setIsLoading] = useState(false);
23
  const [errors, setErrors] = useState({});
 
87
  if (!formData.academicStage) {
88
  newErrors.academicStage = 'Please select your academic standing';
89
  }
90
+
91
+ if (!formData.careerFocus) {
92
+ newErrors.careerFocus = 'Please choose internship or full-time';
93
+ }
94
 
95
  setErrors(newErrors);
96
  return Object.keys(newErrors).length === 0;
 
115
  email: formData.email,
116
  password: formData.password,
117
  academicStage: formData.academicStage,
118
+ researchArea: formData.researchArea,
119
+ careerFocus: formData.careerFocus,
120
  }),
121
  });
122
 
 
288
  </div>
289
  </div>
290
 
291
+ {/* Academic standing */}
292
  <div className="form-group">
293
  <label htmlFor="academicStage" className="form-label">
294
+ Academic standing
295
  </label>
296
  <div className="input-container">
297
  <Shield className="input-icon" />
 
315
  )}
316
  </div>
317
 
318
+ {/* Internship vs full-time */}
319
+ <div className="form-group">
320
+ <span className="form-label">What are you aiming for?</span>
321
+ <div className="career-focus-options">
322
+ {[
323
+ { value: 'Internship search', label: 'Internship' },
324
+ { value: 'Full-time / entry-level', label: 'Full-time job' },
325
+ ].map((opt) => (
326
+ <label
327
+ key={opt.value}
328
+ className={`career-focus-option ${formData.careerFocus === opt.value ? 'active' : ''}`}
329
+ >
330
+ <input
331
+ type="radio"
332
+ name="careerFocus"
333
+ value={opt.value}
334
+ checked={formData.careerFocus === opt.value}
335
+ onChange={handleInputChange}
336
+ disabled={isLoading}
337
+ />
338
+ {opt.label}
339
+ </label>
340
+ ))}
341
+ </div>
342
+ {errors.careerFocus && (
343
+ <span className="error-message">{errors.careerFocus}</span>
344
+ )}
345
+ </div>
346
+
347
  {/* Time zone (optional) */}
348
  <div className="form-group">
349
  <label htmlFor="researchArea" className="form-label">
 
417
  >
418
  Sign in here
419
  </button>
420
+ {' · '}
421
+ <button
422
+ type="button"
423
+ className="link-btn"
424
+ onClick={async () => {
425
+ try {
426
+ const response = await fetch(`${process.env.REACT_APP_API_URL || ''}/auth/guest`, {
427
+ method: 'POST',
428
+ headers: { 'Content-Type': 'application/json' },
429
+ });
430
+ const data = await response.json();
431
+ if (response.ok) {
432
+ persistAuth(data.user, data.access_token);
433
+ onNavigateToHome?.(data.user, data.access_token);
434
+ }
435
+ } catch (e) {
436
+ console.error(e);
437
+ }
438
+ }}
439
+ >
440
+ Try without an account
441
+ </button>
442
  </p>
443
  </div>
444
  </div>
phd-advisor-frontend/src/pages/ChatPage.js CHANGED
@@ -15,6 +15,7 @@ import '../styles/EnhancedChatInput.css';
15
  import AdvisorCarousel from '../components/AdvisorCarousel';
16
  import OnboardingChat from '../components/OnboardingChat';
17
  import ProfileWalkthrough from '../components/ProfileWalkthrough';
 
18
  import ClearDataModal from '../components/ClearDataModal';
19
  import AccountModal from '../components/AccountModal';
20
 
@@ -65,20 +66,33 @@ const ChatPage = ({ user, authToken, onNavigateToHome, onNavigateToCanvas, onSig
65
  const [showClearData, setShowClearData] = useState(false);
66
  const [showAccount, setShowAccount] = useState(false);
67
  const [userProfile, setUserProfile] = useState(null);
 
 
68
 
69
  const loadProfile = async () => {
70
  try {
71
  const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, {
72
  headers: { Authorization: `Bearer ${authToken}` },
73
  });
74
- if (resp.ok) setUserProfile(await resp.json());
 
 
 
 
 
 
75
  } catch (e) {
76
- /* ignore */
 
 
77
  }
78
  };
79
 
80
  useEffect(() => {
81
- if (authToken) loadProfile();
 
 
 
82
  }, [authToken]);
83
 
84
  useEffect(() => {
@@ -872,6 +886,7 @@ const handleNewChat = async (sessionId = null) => {
872
  }, [messages]);
873
 
874
  const handleInputSubmit = async (inputMessage) => {
 
875
  if (replyingTo) {
876
  // This is a reply to a specific message
877
  await handleReplyToAdvisor(inputMessage, replyingTo);
@@ -951,6 +966,11 @@ const handleNewChat = async (sessionId = null) => {
951
  <HelpCircle size={18} />
952
  </button>
953
  </AppHeader>
 
 
 
 
 
954
 
955
  {/* Main Content */}
956
  <div className="chat-content">
@@ -1097,11 +1117,13 @@ const handleNewChat = async (sessionId = null) => {
1097
  onSendMessage={handleInputSubmit}
1098
  onFileUploaded={handleFileUploaded}
1099
  uploadedDocuments={uploadedDocuments}
1100
- isLoading={isLoading}
1101
  currentChatSessionId={currentSessionId}
1102
  authToken={authToken}
1103
  placeholder={
1104
- replyingTo
 
 
1105
  ? `Reply to ${replyingTo.advisorName}...`
1106
  : chatPlaceholder
1107
  }
@@ -1118,6 +1140,16 @@ const handleNewChat = async (sessionId = null) => {
1118
  />
1119
  )}
1120
 
 
 
 
 
 
 
 
 
 
 
1121
  {showAccount && (
1122
  <AccountModal
1123
  user={user}
 
15
  import AdvisorCarousel from '../components/AdvisorCarousel';
16
  import OnboardingChat from '../components/OnboardingChat';
17
  import ProfileWalkthrough from '../components/ProfileWalkthrough';
18
+ import SearchPathGate, { needsSearchPath } from '../components/SearchPathGate';
19
  import ClearDataModal from '../components/ClearDataModal';
20
  import AccountModal from '../components/AccountModal';
21
 
 
66
  const [showClearData, setShowClearData] = useState(false);
67
  const [showAccount, setShowAccount] = useState(false);
68
  const [userProfile, setUserProfile] = useState(null);
69
+ const [profileLoaded, setProfileLoaded] = useState(false);
70
+ const [showSearchPathGate, setShowSearchPathGate] = useState(false);
71
 
72
  const loadProfile = async () => {
73
  try {
74
  const resp = await fetch(`${process.env.REACT_APP_API_URL}/api/users/me/profile`, {
75
  headers: { Authorization: `Bearer ${authToken}` },
76
  });
77
+ if (resp.ok) {
78
+ const profile = await resp.json();
79
+ setUserProfile(profile);
80
+ setShowSearchPathGate(needsSearchPath(profile));
81
+ } else {
82
+ setShowSearchPathGate(true);
83
+ }
84
  } catch (e) {
85
+ setShowSearchPathGate(true);
86
+ } finally {
87
+ setProfileLoaded(true);
88
  }
89
  };
90
 
91
  useEffect(() => {
92
+ if (authToken) {
93
+ setProfileLoaded(false);
94
+ loadProfile();
95
+ }
96
  }, [authToken]);
97
 
98
  useEffect(() => {
 
886
  }, [messages]);
887
 
888
  const handleInputSubmit = async (inputMessage) => {
889
+ if (showSearchPathGate) return;
890
  if (replyingTo) {
891
  // This is a reply to a specific message
892
  await handleReplyToAdvisor(inputMessage, replyingTo);
 
966
  <HelpCircle size={18} />
967
  </button>
968
  </AppHeader>
969
+ {user?.is_guest && (
970
+ <div className="guest-mode-banner" role="status">
971
+ You&apos;re exploring as a guest. Sign out from the menu when you&apos;re ready to create an account and keep longer-term progress.
972
+ </div>
973
+ )}
974
 
975
  {/* Main Content */}
976
  <div className="chat-content">
 
1117
  onSendMessage={handleInputSubmit}
1118
  onFileUploaded={handleFileUploaded}
1119
  uploadedDocuments={uploadedDocuments}
1120
+ isLoading={isLoading || showSearchPathGate}
1121
  currentChatSessionId={currentSessionId}
1122
  authToken={authToken}
1123
  placeholder={
1124
+ showSearchPathGate
1125
+ ? 'Choose internship or full-time above to start…'
1126
+ : replyingTo
1127
  ? `Reply to ${replyingTo.advisorName}...`
1128
  : chatPlaceholder
1129
  }
 
1140
  />
1141
  )}
1142
 
1143
+ {profileLoaded && showSearchPathGate && (
1144
+ <SearchPathGate
1145
+ authToken={authToken}
1146
+ onComplete={(profile) => {
1147
+ setUserProfile(profile);
1148
+ setShowSearchPathGate(false);
1149
+ }}
1150
+ />
1151
+ )}
1152
+
1153
  {showAccount && (
1154
  <AccountModal
1155
  user={user}
phd-advisor-frontend/src/pages/HomePage.js CHANGED
@@ -5,7 +5,7 @@ import AppHeader from '../components/AppHeader';
5
  import CopyrightNotice from '../components/CopyrightNotice';
6
  import { useAppConfig } from '../contexts/AppConfigContext';
7
 
8
- const HomePage = ({ onNavigateToChat, isAuthenticated, onNavigateToHome, onNavigateToCanvas }) => {
9
  const { config, advisors, resolveIcon } = useAppConfig();
10
 
11
  return (
@@ -27,14 +27,25 @@ const HomePage = ({ onNavigateToChat, isAuthenticated, onNavigateToHome, onNavig
27
  <p className="hero-subtitle">
28
  {config.homepage.description}
29
  </p>
30
- <button
31
- onClick={onNavigateToChat}
32
- className="cta-button"
33
- >
34
- <MessageCircle className="cta-icon" />
35
- <span>{isAuthenticated ? 'Continue Conversation' : 'Start Conversation'}</span>
36
- <ArrowRight className="cta-arrow" />
37
- </button>
 
 
 
 
 
 
 
 
 
 
 
38
  </div>
39
 
40
  {/* Advisors Grid */}
 
5
  import CopyrightNotice from '../components/CopyrightNotice';
6
  import { useAppConfig } from '../contexts/AppConfigContext';
7
 
8
+ const HomePage = ({ onNavigateToChat, onTryAsGuest, isAuthenticated, onNavigateToHome, onNavigateToCanvas }) => {
9
  const { config, advisors, resolveIcon } = useAppConfig();
10
 
11
  return (
 
27
  <p className="hero-subtitle">
28
  {config.homepage.description}
29
  </p>
30
+ <div className="cta-group">
31
+ <button
32
+ onClick={onNavigateToChat}
33
+ className="cta-button"
34
+ >
35
+ <MessageCircle className="cta-icon" />
36
+ <span>{isAuthenticated ? 'Continue Conversation' : 'Start Conversation'}</span>
37
+ <ArrowRight className="cta-arrow" />
38
+ </button>
39
+ {!isAuthenticated && (
40
+ <button
41
+ type="button"
42
+ onClick={onTryAsGuest}
43
+ className="cta-button cta-button-secondary"
44
+ >
45
+ <span>Try without an account</span>
46
+ </button>
47
+ )}
48
+ </div>
49
  </div>
50
 
51
  {/* Advisors Grid */}
phd-advisor-frontend/src/styles/ChatPage.css CHANGED
@@ -13,6 +13,26 @@
13
  transform: translateZ(0);
14
  }
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  /* Floating Header */
17
  .floating-header {
18
  position: sticky;
 
13
  transform: translateZ(0);
14
  }
15
 
16
+ .guest-mode-banner {
17
+ flex-shrink: 0;
18
+ padding: 8px 16px;
19
+ background: #ccfbf1;
20
+ color: #115e59;
21
+ font-size: 0.875rem;
22
+ text-align: center;
23
+ border-bottom: 1px solid #99f6e4;
24
+ }
25
+
26
+ .guest-mode-banner button {
27
+ background: none;
28
+ border: none;
29
+ color: #0f766e;
30
+ font-weight: 700;
31
+ text-decoration: underline;
32
+ cursor: pointer;
33
+ padding: 0;
34
+ }
35
+
36
  /* Floating Header */
37
  .floating-header {
38
  position: sticky;
phd-advisor-frontend/src/styles/Login.css CHANGED
@@ -93,6 +93,58 @@
93
  }
94
 
95
  /* Form Container */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  .login-form-container {
97
  padding: 32px;
98
  }
 
93
  }
94
 
95
  /* Form Container */
96
+ .guest-continue-btn {
97
+ width: 100%;
98
+ display: inline-flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ gap: 8px;
102
+ border: 2px solid #0f766e;
103
+ border-radius: 12px;
104
+ padding: 12px 16px;
105
+ background: #ccfbf1;
106
+ color: #0f766e;
107
+ font-weight: 700;
108
+ cursor: pointer;
109
+ margin-bottom: 8px;
110
+ }
111
+
112
+ .guest-continue-btn:hover:not(:disabled) {
113
+ background: #99f6e4;
114
+ }
115
+
116
+ .guest-continue-btn:disabled {
117
+ opacity: 0.6;
118
+ cursor: not-allowed;
119
+ }
120
+
121
+ .guest-continue-hint {
122
+ margin: 0 0 16px;
123
+ text-align: center;
124
+ font-size: 0.85rem;
125
+ color: var(--text-secondary, #64748b);
126
+ line-height: 1.4;
127
+ }
128
+
129
+ .login-divider {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 12px;
133
+ margin: 0 0 18px;
134
+ color: var(--text-tertiary, #94a3b8);
135
+ font-size: 0.8rem;
136
+ text-transform: uppercase;
137
+ letter-spacing: 0.04em;
138
+ }
139
+
140
+ .login-divider::before,
141
+ .login-divider::after {
142
+ content: '';
143
+ flex: 1;
144
+ height: 1px;
145
+ background: var(--border-primary, #e2e8f0);
146
+ }
147
+
148
  .login-form-container {
149
  padding: 32px;
150
  }
phd-advisor-frontend/src/styles/Signup.css CHANGED
@@ -100,6 +100,34 @@
100
  margin-bottom: 8px;
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  .optional {
104
  font-weight: 400;
105
  color: var(--text-tertiary);
 
100
  margin-bottom: 8px;
101
  }
102
 
103
+ .career-focus-options {
104
+ display: grid;
105
+ grid-template-columns: 1fr 1fr;
106
+ gap: 10px;
107
+ }
108
+
109
+ .career-focus-option {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 8px;
113
+ padding: 12px 14px;
114
+ border: 2px solid var(--border-primary, #e2e8f0);
115
+ border-radius: 12px;
116
+ cursor: pointer;
117
+ font-weight: 600;
118
+ background: var(--bg-secondary, #f8fafc);
119
+ }
120
+
121
+ .career-focus-option.active {
122
+ border-color: #0f766e;
123
+ background: #ccfbf1;
124
+ color: #0f766e;
125
+ }
126
+
127
+ .career-focus-option input {
128
+ accent-color: #0f766e;
129
+ }
130
+
131
  .optional {
132
  font-weight: 400;
133
  color: var(--text-tertiary);
phd-advisor-frontend/src/styles/components.css CHANGED
@@ -241,6 +241,25 @@
241
  text-decoration: none;
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  .cta-button:hover {
245
  transform: scale(1.05);
246
  }
 
241
  text-decoration: none;
242
  }
243
 
244
+ .cta-group {
245
+ display: flex;
246
+ flex-wrap: wrap;
247
+ gap: 12px;
248
+ align-items: center;
249
+ justify-content: center;
250
+ }
251
+
252
+ .cta-button-secondary {
253
+ background: transparent;
254
+ color: var(--text-primary, #0f172a);
255
+ border: 2px solid #0f766e;
256
+ box-shadow: none;
257
+ }
258
+
259
+ .cta-button-secondary:hover {
260
+ background: #ccfbf1;
261
+ }
262
+
263
  .cta-button:hover {
264
  transform: scale(1.05);
265
  }