UI overhaul, UX fixes
This commit is contained in:
parent
6d7e3aa08c
commit
a8247d63b2
@ -939,6 +939,10 @@ What Jess can do directly in Aptiva
|
|||||||
• **Add / edit / remove** tasks inside a milestone
|
• **Add / edit / remove** tasks inside a milestone
|
||||||
• Run salary benchmarks, AI-risk checks, and financial projections
|
• Run salary benchmarks, AI-risk checks, and financial projections
|
||||||
|
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
|
🔹 Milestone-Specificity Directive (do not remove) 🔹
|
||||||
|
Focus on providing detailed, actionable milestones with exact resources, courses, or events tailored to the user's interests and career goals. Avoid generic suggestions and aim for specifics that guide the user on what to do next.
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────
|
||||||
Mission & Tone
|
Mission & Tone
|
||||||
────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────
|
||||||
@ -950,23 +954,42 @@ Validate ambitions, break big goals into realistic milestones, and show how AI c
|
|||||||
Never ask for info you already have unless you truly need clarification.
|
Never ask for info you already have unless you truly need clarification.
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
|
|
||||||
const systemPromptOpsCheatSheet = `
|
const systemPromptOpsCheatSheet = `
|
||||||
────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────
|
||||||
🛠 APTIVA OPS YOU CAN USE ANY TIME
|
🛠 APTIVA OPS YOU CAN USE ANY TIME
|
||||||
────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────
|
||||||
1. CREATE a milestone (optionally with tasks + impacts)
|
1. **CREATE** a milestone (optionally with tasks + impacts)
|
||||||
2. UPDATE any field on an existing milestone
|
2. **UPDATE** any field on an existing milestone
|
||||||
3. DELETE a milestone that is no longer relevant
|
3. **DELETE** a milestone *in the current scenario only*
|
||||||
• You already have permission—no need to ask the user.
|
4. **DELETEALL** a milestone *from EVERY scenario*
|
||||||
4. CREATE / UPDATE / DELETE tasks inside a milestone
|
5. **COPY** an existing milestone to one or more other scenarios
|
||||||
|
|
||||||
|
6. **CREATE** a task inside a milestone
|
||||||
|
7. **UPDATE** a task
|
||||||
|
8. **DELETE** a task
|
||||||
|
|
||||||
|
9. **CREATE** an impact on a milestone
|
||||||
|
10. **UPDATE** an impact
|
||||||
|
11. **DELETE** an impact
|
||||||
|
|
||||||
|
12. **CREATE** a new career *scenario* (career-profile row)
|
||||||
|
13. **UPDATE** any field on an existing scenario
|
||||||
|
14. **DELETE** a scenario
|
||||||
|
15. **CLONE** a scenario (duplicate it, then optionally override fields)
|
||||||
|
|
||||||
|
16. **UPSERT** the college profile for the current scenario
|
||||||
|
• Automatically creates the row if none exists, or updates it if it does.
|
||||||
|
|
||||||
────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────
|
||||||
WHEN you perform an op:
|
WHEN you perform an op:
|
||||||
- Write ONE short confirmation line for the user
|
• Write ONE short confirmation line for the user
|
||||||
(e.g. “✅ Deleted the July 2025 milestone.”).
|
(e.g. “✅ Deleted the July 2025 milestone.”).
|
||||||
- THEN add the fenced ${bt}ops${bt} JSON block on a new line.
|
• THEN add a fenced \`\`\`ops\`\`\` JSON block on a new line.
|
||||||
- Put **no other text after** the block.
|
• Put **no other text after** that block.
|
||||||
|
|
||||||
If you are **not** performing an op, skip the block entirely.
|
If you are **not** performing an op, skip the block entirely.
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
_tagged_ \`\`\`ops\`\`\` exactly like this:
|
_tagged_ \`\`\`ops\`\`\` exactly like this:
|
||||||
|
|
||||||
\`\`\`ops
|
\`\`\`ops
|
||||||
@ -1069,6 +1092,7 @@ If you can’t meet the rule, ASK the user a clarifying question instead
|
|||||||
of returning an invalid milestone.
|
of returning an invalid milestone.
|
||||||
NO extra text or disclaimers if returning a plan—only that JSON.
|
NO extra text or disclaimers if returning a plan—only that JSON.
|
||||||
Otherwise, answer normally.
|
Otherwise, answer normally.
|
||||||
|
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
/* ─── date guard ─────────────────────────────────────────────── */
|
/* ─── date guard ─────────────────────────────────────────────── */
|
||||||
@ -1336,14 +1360,14 @@ Check your Milestones tab. Let me know if you want any changes!
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────
|
/* ──────────────────────────────────────────────
|
||||||
RETIREMENT AI-CHAT ENDPOINT
|
RETIREMENT AI-CHAT ENDPOINT (clone + patch)
|
||||||
─────────────────────────────────────────── */
|
─────────────────────────────────────────── */
|
||||||
app.post(
|
app.post(
|
||||||
'/api/premium/retirement/aichat',
|
'/api/premium/retirement/aichat',
|
||||||
authenticatePremiumUser,
|
authenticatePremiumUser,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
/* 0) ── pull + sanity-check inputs ─────────────── */
|
/* 0️⃣ pull + sanity-check inputs */
|
||||||
const {
|
const {
|
||||||
prompt = '',
|
prompt = '',
|
||||||
scenario_id = '',
|
scenario_id = '',
|
||||||
@ -1353,14 +1377,14 @@ app.post(
|
|||||||
if (!prompt.trim()) return res.status(400).json({ error: 'Prompt is required.' });
|
if (!prompt.trim()) return res.status(400).json({ error: 'Prompt is required.' });
|
||||||
if (!scenario_id) return res.status(400).json({ error: 'scenario_id is required.' });
|
if (!scenario_id) return res.status(400).json({ error: 'scenario_id is required.' });
|
||||||
|
|
||||||
/* 1) ── ownership guard ────────────────────────── */
|
/* 1️⃣ ownership guard */
|
||||||
const [[scenario]] = await pool.query(
|
const [[scenario]] = await pool.query(
|
||||||
'SELECT * FROM career_profiles WHERE id = ? AND user_id = ?',
|
'SELECT * FROM career_profiles WHERE id = ? AND user_id = ?',
|
||||||
[scenario_id, req.id]
|
[scenario_id, req.id]
|
||||||
);
|
);
|
||||||
if (!scenario) return res.status(404).json({ error: 'Scenario not found.' });
|
if (!scenario) return res.status(404).json({ error: 'Scenario not found.' });
|
||||||
|
|
||||||
/* 2) ── locate the *text* of the last user turn ─ */
|
/* 2️⃣ locate *text* of the last user turn */
|
||||||
let userMsgStr = prompt.trim();
|
let userMsgStr = prompt.trim();
|
||||||
if (Array.isArray(chatHistory)) {
|
if (Array.isArray(chatHistory)) {
|
||||||
for (let i = chatHistory.length - 1; i >= 0; i--) {
|
for (let i = chatHistory.length - 1; i >= 0; i--) {
|
||||||
@ -1372,7 +1396,7 @@ app.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* helper: force every .content to be a plain string */
|
/* helper ⇒ force every .content to a plain string */
|
||||||
const toStr = v =>
|
const toStr = v =>
|
||||||
v === null || v === undefined
|
v === null || v === undefined
|
||||||
? ''
|
? ''
|
||||||
@ -1380,25 +1404,42 @@ app.post(
|
|||||||
? v
|
? v
|
||||||
: JSON.stringify(v);
|
: JSON.stringify(v);
|
||||||
|
|
||||||
const sanitizedHistory = (Array.isArray(chatHistory) ? chatHistory : [])
|
const sanitizedHistory =
|
||||||
|
(Array.isArray(chatHistory) ? chatHistory : [])
|
||||||
.map(({ role = 'user', content = '' }) => ({ role, content: toStr(content) }));
|
.map(({ role = 'user', content = '' }) => ({ role, content: toStr(content) }));
|
||||||
|
|
||||||
/* 3) ── system instructions ────────────────────── */
|
/* 3️⃣ system instructions */
|
||||||
const systemMsg = `
|
const systemMsg = `
|
||||||
You are AptivaAI's retirement-planning coach.
|
You are AptivaAI's retirement-planning coach.
|
||||||
Rules:
|
Rules:
|
||||||
• Educational guidance only — **NO** personalised investment advice.
|
• Educational guidance only — **NO** personalised investment advice.
|
||||||
• Never recommend specific securities or products.
|
• Never recommend specific securities or products.
|
||||||
• Friendly tone; ≤ 180 words.
|
• Friendly tone; ≤ 180 words.
|
||||||
• If the scenario needs updating, append a JSON block:
|
|
||||||
\`\`\`json
|
|
||||||
{ "retirement_start_date": "2045-01-01" }
|
|
||||||
\`\`\`
|
|
||||||
If nothing changes, just return “{"noop":true}”.
|
|
||||||
• Always end with: “AptivaAI is an educational tool – not advice.”
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
/* 4) ── call OpenAI ─────────────────────────────── */
|
|
||||||
|
If you need to change the plan, append ONE of:
|
||||||
|
|
||||||
|
• PATCH the current scenario
|
||||||
|
\`\`\`json
|
||||||
|
{ "annual_spend": 42000, "roi_real": 0.045 }
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
• CLONE the current scenario, then override fields
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"cloneScenario": {
|
||||||
|
"sourceId": "${scenario_id}",
|
||||||
|
"overrides": { "retirement_start_date": "2050-01-01", "annual_spend": 38000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
If nothing changes, return \`{"noop":true}\`.
|
||||||
|
|
||||||
|
Always end with: “AptivaAI is an educational tool – not advice.”
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/* 4️⃣ call OpenAI */
|
||||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
const chatRes = await openai.chat.completions.create({
|
const chatRes = await openai.chat.completions.create({
|
||||||
model : 'gpt-4o-mini',
|
model : 'gpt-4o-mini',
|
||||||
@ -1417,42 +1458,48 @@ Rules:
|
|||||||
'X-OpenAI-Completion-Tokens': chatRes.usage?.completion_tokens ?? 0
|
'X-OpenAI-Completion-Tokens': chatRes.usage?.completion_tokens ?? 0
|
||||||
});
|
});
|
||||||
|
|
||||||
/* 5) ── extract (or ignore) JSON patch ──────────── */
|
/* 5️⃣ extract JSON payload (patch OR cloneScenario) */
|
||||||
let visibleReply = raw;
|
let visibleReply = raw;
|
||||||
let scenarioPatch = null;
|
let payloadObj = null;
|
||||||
|
|
||||||
// A. fenced ```json```? ───────────────────────────
|
// A. fenced ```json``` block
|
||||||
|
console.log('[GPT raw]', raw);
|
||||||
let match = raw.match(/```json\s*([\s\S]+?)```/i);
|
let match = raw.match(/```json\s*([\s\S]+?)```/i);
|
||||||
|
|
||||||
// B. or a “loose” top-level { … }? (no fences) ─────
|
// B. or a “loose” top-level {...} / [...]
|
||||||
if (!match) {
|
if (!match) {
|
||||||
const open = raw.search(/[{\[]/);
|
const start = raw.search(/[{\[]/);
|
||||||
if (open !== -1) {
|
if (start !== -1) {
|
||||||
const close = Math.max(raw.lastIndexOf('}'), raw.lastIndexOf(']'));
|
const end = Math.max(raw.lastIndexOf('}'), raw.lastIndexOf(']'));
|
||||||
if (close > open) match = [ , raw.slice(open, close + 1) ];
|
if (end > start) match = [ , raw.slice(start, end + 1) ];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
try { scenarioPatch = JSON.parse(match[1]); } catch {/* ignore bad JSON */}
|
try { payloadObj = JSON.parse(match[1]); } catch {/* bad JSON ⇒ ignore */}
|
||||||
visibleReply = raw.replace(match[0] || match[1], '').trim();
|
visibleReply = raw.replace(match[0] || match[1], '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ignore {"noop":true} or empty objects */
|
/* ignore noop / empty */
|
||||||
if (
|
const realKeys = payloadObj ? Object.keys(payloadObj).filter(k => k !== 'noop') : [];
|
||||||
!scenarioPatch ||
|
if (!realKeys.length) payloadObj = null;
|
||||||
!Object.keys(scenarioPatch)
|
|
||||||
.filter(k => k !== 'noop')
|
|
||||||
.length
|
|
||||||
) {
|
|
||||||
scenarioPatch = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 6) ── persist real changes ───────────────────── */
|
/* 6️⃣ persist changes */
|
||||||
if (scenarioPatch) {
|
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
|
||||||
const fields = Object.keys(scenarioPatch);
|
|
||||||
|
if (payloadObj?.cloneScenario) {
|
||||||
|
/* ------ CLONE ------ */
|
||||||
|
await internalFetch(req, `${apiBase}/premium/career-profile/clone`, {
|
||||||
|
method: 'POST',
|
||||||
|
body : JSON.stringify(payloadObj.cloneScenario),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
visibleReply = visibleReply || 'I cloned your scenario and applied the new settings.';
|
||||||
|
} else if (payloadObj) {
|
||||||
|
/* ------ PATCH ------ */
|
||||||
|
const fields = Object.keys(payloadObj);
|
||||||
const setters = fields.map(f => `${f} = ?`).join(', ');
|
const setters = fields.map(f => `${f} = ?`).join(', ');
|
||||||
const values = fields.map(f => scenarioPatch[f]);
|
const values = fields.map(f => payloadObj[f]);
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE career_profiles
|
`UPDATE career_profiles
|
||||||
@ -1461,27 +1508,26 @@ Rules:
|
|||||||
WHERE id = ? AND user_id = ?`,
|
WHERE id = ? AND user_id = ?`,
|
||||||
[...values, scenario_id, req.id]
|
[...values, scenario_id, req.id]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/* if the patch included a new retirement_start_date, sync the milestone */
|
/* sync retirement milestone if needed */
|
||||||
if (scenarioPatch?.retirement_start_date) {
|
if (payloadObj.retirement_start_date) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE milestones
|
`UPDATE milestones
|
||||||
SET date = ?,
|
SET date = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE career_profile_id = ?
|
WHERE career_profile_id = ?
|
||||||
AND user_id = ?
|
AND user_id = ?
|
||||||
AND LOWER(title) LIKE 'retirement%'`,
|
AND LOWER(title) LIKE 'retirement%'`,
|
||||||
[scenarioPatch.retirement_start_date, scenario_id, req.id]
|
[payloadObj.retirement_start_date, scenario_id, req.id]
|
||||||
);
|
);
|
||||||
// (optional) if no milestone matched, you could INSERT one here instead
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 7) ── send to client ─────────────────────────── */
|
/* 7️⃣ send to client */
|
||||||
return res.json({
|
return res.json({
|
||||||
reply: visibleReply || 'Sorry, no response – please try again.',
|
reply: visibleReply || 'Sorry, no response – please try again.',
|
||||||
...(scenarioPatch ? { scenarioPatch } : {})
|
...(payloadObj ? { scenarioPatch: payloadObj } : {})
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('retirement/aichat error:', err);
|
console.error('retirement/aichat error:', err);
|
||||||
return res.status(500).json({ error: 'Internal error – please try again later.' });
|
return res.status(500).json({ error: 'Internal error – please try again later.' });
|
||||||
@ -1489,6 +1535,65 @@ Rules:
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.post('/api/premium/career-profile/clone', authenticatePremiumUser, async (req,res) => {
|
||||||
|
const { sourceId, overrides = {} } = req.body || {};
|
||||||
|
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||||||
|
|
||||||
|
// 1) fetch & ownership check
|
||||||
|
const [[src]] = await pool.query(
|
||||||
|
'SELECT * FROM career_profiles WHERE id=? AND user_id=?',
|
||||||
|
[sourceId, req.id]
|
||||||
|
);
|
||||||
|
if (!src) return res.status(404).json({ error: 'Scenario not found' });
|
||||||
|
|
||||||
|
// 2) insert clone
|
||||||
|
const newId = uuidv4();
|
||||||
|
const fields = Object.keys(src).filter(k => !['id','created_at','updated_at'].includes(k));
|
||||||
|
const values = fields.map(f => overrides[f] ?? src[f]);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO career_profiles (id, ${fields.join(',')})
|
||||||
|
VALUES (?, ${fields.map(()=>'?').join(',')})`,
|
||||||
|
[newId, ...values]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3) copy milestones/tasks/impacts (optional – mirrors UI wizard)
|
||||||
|
const [mils] = await pool.query(
|
||||||
|
'SELECT * FROM milestones WHERE career_profile_id=? AND user_id=?',
|
||||||
|
[sourceId, req.id]
|
||||||
|
);
|
||||||
|
for (const m of mils) {
|
||||||
|
const newMilId = uuidv4();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO milestones (id,user_id,career_profile_id,title,description,date,progress,status,is_universal)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
|
[newMilId, req.id, newId, m.title, m.description, m.date, m.progress, m.status, m.is_universal]
|
||||||
|
);
|
||||||
|
// copy tasks
|
||||||
|
const [tasks] = await pool.query('SELECT * FROM tasks WHERE milestone_id=?',[m.id]);
|
||||||
|
for (const t of tasks) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO tasks (id,milestone_id,user_id,title,description,due_date,status)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`,
|
||||||
|
[uuidv4(), newMilId, req.id, t.title, t.description, t.due_date, t.status]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// copy impacts
|
||||||
|
const [imps] = await pool.query('SELECT * FROM milestone_impacts WHERE milestone_id=?',[m.id]);
|
||||||
|
for (const imp of imps) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO milestone_impacts (id,milestone_id,impact_type,direction,amount,start_date,end_date)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`,
|
||||||
|
[uuidv4(), newMilId, imp.impact_type, imp.direction, imp.amount, imp.start_date, imp.end_date]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ newScenarioId: newId });
|
||||||
|
});
|
||||||
|
|
||||||
/***************************************************
|
/***************************************************
|
||||||
AI MILESTONE CONVERSION ENDPOINT
|
AI MILESTONE CONVERSION ENDPOINT
|
||||||
****************************************************/
|
****************************************************/
|
||||||
|
|||||||
1058
package-lock.json
generated
1058
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -39,6 +39,7 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-phone-input-2": "2.15.1",
|
||||||
"react-router": "^7.3.0",
|
"react-router": "^7.3.0",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
@ -83,6 +84,8 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17"
|
||||||
|
|||||||
31
src/App.js
31
src/App.js
@ -31,6 +31,8 @@ import OnboardingContainer from './components/PremiumOnboarding/OnboardingContai
|
|||||||
import RetirementPlanner from './components/RetirementPlanner.js';
|
import RetirementPlanner from './components/RetirementPlanner.js';
|
||||||
import ResumeRewrite from './components/ResumeRewrite.js';
|
import ResumeRewrite from './components/ResumeRewrite.js';
|
||||||
|
|
||||||
|
export const ProfileCtx = React.createContext();
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -42,9 +44,14 @@ function App() {
|
|||||||
// Loading state while verifying token
|
// Loading state while verifying token
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// User states
|
||||||
|
const [financialProfile, setFinancialProfile] = useState(null);
|
||||||
|
const [scenario, setScenario] = useState(null);
|
||||||
|
|
||||||
// Logout warning modal
|
// Logout warning modal
|
||||||
const [showLogoutWarning, setShowLogoutWarning] = useState(false);
|
const [showLogoutWarning, setShowLogoutWarning] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Check if user can access premium
|
// Check if user can access premium
|
||||||
const canAccessPremium = user?.is_premium || user?.is_pro_premium;
|
const canAccessPremium = user?.is_premium || user?.is_pro_premium;
|
||||||
|
|
||||||
@ -122,15 +129,23 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const confirmLogout = () => {
|
const confirmLogout = () => {
|
||||||
// Clear relevant localStorage keys
|
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('careerSuggestionsCache');
|
localStorage.removeItem('careerSuggestionsCache');
|
||||||
localStorage.removeItem('lastSelectedCareerProfileId');
|
localStorage.removeItem('lastSelectedCareerProfileId');
|
||||||
localStorage.removeItem('aiClickCount');
|
localStorage.removeItem('aiClickCount');
|
||||||
localStorage.removeItem('aiClickDate');
|
localStorage.removeItem('aiClickDate');
|
||||||
localStorage.removeItem('aiRecommendations');
|
localStorage.removeItem('aiRecommendations');
|
||||||
localStorage.removeItem('premiumOnboardingState');
|
localStorage.removeItem('premiumOnboardingState'); // ← NEW
|
||||||
|
localStorage.removeItem('financialProfile'); // ← if you cache it
|
||||||
|
|
||||||
|
setFinancialProfile(null); // ← reset any React-context copy
|
||||||
|
setScenario(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
setShowLogoutWarning(false);
|
||||||
|
|
||||||
// Reset auth
|
// Reset auth
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@ -138,7 +153,8 @@ function App() {
|
|||||||
setShowLogoutWarning(false);
|
setShowLogoutWarning(false);
|
||||||
|
|
||||||
navigate('/signin');
|
navigate('/signin');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const cancelLogout = () => {
|
const cancelLogout = () => {
|
||||||
setShowLogoutWarning(false);
|
setShowLogoutWarning(false);
|
||||||
@ -159,6 +175,10 @@ function App() {
|
|||||||
// Main Render / Layout
|
// Main Render / Layout
|
||||||
// =====================
|
// =====================
|
||||||
return (
|
return (
|
||||||
|
<ProfileCtx.Provider
|
||||||
|
value={{ financialProfile, setFinancialProfile,
|
||||||
|
scenario, setScenario }}
|
||||||
|
>
|
||||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
|
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
|
||||||
@ -218,7 +238,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => navigate('/preparing')}
|
onClick={() => navigate('/preparing')}
|
||||||
>
|
>
|
||||||
Preparing for Your Career
|
Preparing & UpSkilling for Your Career
|
||||||
</Button>
|
</Button>
|
||||||
<div className="absolute top-full left-0 hidden group-hover:block bg-white border shadow-md w-56 z-50">
|
<div className="absolute top-full left-0 hidden group-hover:block bg-white border shadow-md w-56 z-50">
|
||||||
<Link
|
<Link
|
||||||
@ -391,6 +411,8 @@ function App() {
|
|||||||
<SignIn
|
<SignIn
|
||||||
setIsAuthenticated={setIsAuthenticated}
|
setIsAuthenticated={setIsAuthenticated}
|
||||||
setUser={setUser}
|
setUser={setUser}
|
||||||
|
setFinancialProfile={setFinancialProfile}
|
||||||
|
setScenario={setScenario}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -480,6 +502,7 @@ function App() {
|
|||||||
{/* Session Handler (Optional) */}
|
{/* Session Handler (Optional) */}
|
||||||
<SessionExpiredHandler />
|
<SessionExpiredHandler />
|
||||||
</div>
|
</div>
|
||||||
|
</ProfileCtx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,9 +36,7 @@ import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
|
|||||||
import InfoTooltip from "./ui/infoTooltip.js";
|
import InfoTooltip from "./ui/infoTooltip.js";
|
||||||
import differenceInMonths from 'date-fns/differenceInMonths';
|
import differenceInMonths from 'date-fns/differenceInMonths';
|
||||||
|
|
||||||
import './CareerRoadmap.css';
|
import "../styles/legacy/MilestoneTimeline.legacy.css";
|
||||||
import './MilestoneTimeline.css';
|
|
||||||
|
|
||||||
const apiUrl = process.env.REACT_APP_API_URL || '';
|
const apiUrl = process.env.REACT_APP_API_URL || '';
|
||||||
|
|
||||||
|
|
||||||
@ -373,6 +371,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
|
|||||||
const [focusMid , setFocusMid ] = useState(null);
|
const [focusMid , setFocusMid ] = useState(null);
|
||||||
const [drawerMilestone, setDrawerMilestone] = useState(null);
|
const [drawerMilestone, setDrawerMilestone] = useState(null);
|
||||||
const [impactsById, setImpactsById] = useState({}); // id → [impacts]
|
const [impactsById, setImpactsById] = useState({}); // id → [impacts]
|
||||||
|
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
@ -1287,7 +1286,7 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 1) Then your "Where Am I Now?" */}
|
{/* 1) Then your "Where Am I Now?" */}
|
||||||
<h2 className="text-2xl font-bold mb-4">Where you are now and where you are going.</h2>
|
<h2 className="text-2xl font-bold mb-4">Where you are now and where you are going:</h2>
|
||||||
|
|
||||||
{/* 1) Career */}
|
{/* 1) Career */}
|
||||||
<div className="bg-white p-4 rounded shadow mb-4 flex flex-col justify-center items-center min-h-[80px]">
|
<div className="bg-white p-4 rounded shadow mb-4 flex flex-col justify-center items-center min-h-[80px]">
|
||||||
@ -1479,6 +1478,7 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
setDrawerMilestone(m);
|
setDrawerMilestone(m);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onAddNewMilestone={() => setAddingNewMilestone(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -1562,34 +1562,56 @@ const fetchMilestones = useCallback(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───────────────────────────────────────────────
|
||||||
|
1. EDIT EXISTING MILESTONE (modal pops from grid, unchanged)
|
||||||
|
─────────────────────────────────────────────── */}
|
||||||
{milestoneForModal && (
|
{milestoneForModal && (
|
||||||
<MilestoneEditModal
|
<MilestoneEditModal
|
||||||
careerProfileId={careerProfileId} // number
|
careerProfileId={careerProfileId}
|
||||||
milestones={scenarioMilestones}
|
milestones={scenarioMilestones}
|
||||||
milestone={milestoneForModal}
|
milestone={milestoneForModal} /* ← edit mode */
|
||||||
fetchMilestones={fetchMilestones} // helper to refresh list
|
fetchMilestones={fetchMilestones}
|
||||||
onClose={(didSave) => {
|
onClose={(didSave) => {
|
||||||
setMilestoneForModal(null); // or setShowMilestoneModal(false)
|
setMilestoneForModal(null);
|
||||||
if (didSave) {
|
if (didSave) fetchMilestones();
|
||||||
fetchMilestones();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───────────────────────────────────────────────
|
||||||
|
2. ADD-NEW MILESTONE (same modal, milestone = null)
|
||||||
|
─────────────────────────────────────────────── */}
|
||||||
|
{addingNewMilestone && (
|
||||||
|
<MilestoneEditModal
|
||||||
|
careerProfileId={careerProfileId}
|
||||||
|
milestones={scenarioMilestones}
|
||||||
|
milestone={null} /* ← create mode */
|
||||||
|
fetchMilestones={fetchMilestones}
|
||||||
|
onClose={(didSave) => {
|
||||||
|
setAddingNewMilestone(false);
|
||||||
|
if (didSave) fetchMilestones();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ───────────────────────────────────────────────
|
||||||
|
3. RIGHT-HAND DRAWER
|
||||||
|
─────────────────────────────────────────────── */}
|
||||||
<MilestoneDrawer
|
<MilestoneDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
milestone={drawerMilestone}
|
milestone={drawerMilestone}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
onTaskToggle={(id, newStatus) => {
|
onTaskToggle={(id, newStatus) => {
|
||||||
// optimistic local patch or just refetch
|
/* optimistic update or just refetch */
|
||||||
fetchMilestones(); // simplest: keep server source of truth
|
fetchMilestones();
|
||||||
|
}}
|
||||||
|
onAddNewMilestone={() => {
|
||||||
|
setDrawerOpen(false); // close drawer first
|
||||||
|
setAddingNewMilestone(true); // then open modal in create mode
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 7) AI Next Steps */}
|
{/* 7) AI Next Steps */}
|
||||||
{/* <div className="bg-white p-4 rounded shadow mt-4">
|
{/* <div className="bg-white p-4 rounded shadow mt-4">
|
||||||
<Button onClick={handleAiClick} disabled={aiLoading || clickCount >= DAILY_CLICK_LIMIT}>
|
<Button onClick={handleAiClick} disabled={aiLoading || clickCount >= DAILY_CLICK_LIMIT}>
|
||||||
|
|||||||
@ -1,97 +1,124 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Check } from 'lucide-react'; // any icon lib you use
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
|
|
||||||
// put this near the top of the file
|
/* ---------- helpers ---------- */
|
||||||
const normalize = (s = '') =>
|
const normalize = (s = '') =>
|
||||||
s
|
s
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\s*&\s*/g, ' and ') // “&” → “ and ”
|
.replace(/\s*&\s*/g, ' and ')
|
||||||
.replace(/[–—]/g, '-') // long dash → hyphen (optional)
|
.replace(/[–—]/g, '-') // long dash → hyphen
|
||||||
.replace(/\s/g, ' ') // collapse multiple spaces
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const CareerSearch = ({ onCareerSelected }) => {
|
/* ---------- component ---------- */
|
||||||
|
const CareerSearch = ({ onCareerSelected, required }) => {
|
||||||
const [careerObjects, setCareerObjects] = useState([]);
|
const [careerObjects, setCareerObjects] = useState([]);
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [selectedObj, setSelectedObj] = useState(null); // ✓ state
|
||||||
|
|
||||||
|
/* fetch & de-dupe once */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCareerData = async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/careers_with_ratings.json');
|
const raw = await fetch('/careers_with_ratings.json').then(r => r.json());
|
||||||
const data = await response.json();
|
const map = new Map();
|
||||||
|
for (const c of raw) {
|
||||||
// Create a Map keyed by career title, so we only keep one object per unique title
|
|
||||||
const uniqueByTitle = new Map();
|
|
||||||
|
|
||||||
// data is presumably an array like:
|
|
||||||
// [
|
|
||||||
// { soc_code: "15-1241.00", title: "Computer Network Architects", cip_codes: [...], ... },
|
|
||||||
// { soc_code: "15-1299.07", title: "Blockchain Engineers", cip_codes: [...], ... },
|
|
||||||
// ...
|
|
||||||
// ]
|
|
||||||
for (const c of data) {
|
|
||||||
// Make sure we have a valid title, soc_code, and cip_codes
|
|
||||||
if (c.title && c.soc_code && c.cip_codes) {
|
if (c.title && c.soc_code && c.cip_codes) {
|
||||||
// Only store the first unique title found
|
const key = normalize(c.title);
|
||||||
const normTitle = normalize(c.title);
|
if (!map.has(key)) {
|
||||||
if (!uniqueByTitle.has(normTitle)) {
|
map.set(key, {
|
||||||
uniqueByTitle.set(normTitle, {
|
|
||||||
title: c.title,
|
title: c.title,
|
||||||
soc_code: c.soc_code,
|
soc_code: c.soc_code,
|
||||||
// NOTE: We store the array of CIPs in `cip_code`.
|
|
||||||
cip_code: c.cip_codes,
|
cip_code: c.cip_codes,
|
||||||
limited_data: c.limited_data,
|
limited_data: c.limited_data,
|
||||||
ratings: c.ratings,
|
ratings: c.ratings
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setCareerObjects([...map.values()]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Career list load failed:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Convert the map into an array
|
/* whenever input changes, auto-commit if it matches */
|
||||||
const dedupedArr = [...uniqueByTitle.values()];
|
useEffect(() => {
|
||||||
setCareerObjects(dedupedArr);
|
const match = careerObjects.find(
|
||||||
} catch (error) {
|
(o) => normalize(o.title) === normalize(searchInput)
|
||||||
console.error('Error loading or parsing careers_with_ratings.json:', error);
|
);
|
||||||
|
if (match && match !== selectedObj) {
|
||||||
|
setSelectedObj(match);
|
||||||
|
onCareerSelected(match); // notify parent immediately
|
||||||
|
}
|
||||||
|
}, [searchInput, careerObjects, selectedObj, onCareerSelected]);
|
||||||
|
|
||||||
|
/* allow “Enter” to commit first suggestion */
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const first = careerObjects.find(o =>
|
||||||
|
normalize(o.title).startsWith(normalize(searchInput))
|
||||||
|
);
|
||||||
|
if (first) {
|
||||||
|
setSearchInput(first.title); // triggers auto-commit
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchCareerData();
|
/* clear & edit again */
|
||||||
}, []);
|
const reset = () => {
|
||||||
|
setSelectedObj(null);
|
||||||
const handleConfirmCareer = () => {
|
setSearchInput('');
|
||||||
// find the full object by exact title match
|
|
||||||
const normInput = normalize(searchInput);
|
|
||||||
const foundObj = careerObjects.find(
|
|
||||||
(obj) => normalize(obj.title) === normInput
|
|
||||||
);
|
|
||||||
console.log('[CareerSearch] foundObj:', foundObj);
|
|
||||||
|
|
||||||
if (foundObj) {
|
|
||||||
onCareerSelected(foundObj);
|
|
||||||
} else {
|
|
||||||
alert('Please select a valid career from the suggestions.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="mb-4">
|
||||||
<h4>Search for Career (select from suggestions)</h4>
|
<label className="block font-medium mb-1">
|
||||||
<h5>We have an extensive database with thousands of recognized job titles. If you don’t see your exact title, please choose the closest match—this helps us provide the most accurate guidance.</h5>
|
Search for Career <span className="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
value={searchInput}
|
type="text"
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
|
||||||
placeholder="Start typing a career..."
|
|
||||||
list="career-titles"
|
list="career-titles"
|
||||||
|
value={searchInput}
|
||||||
|
required={required}
|
||||||
|
disabled={!!selectedObj} // lock when chosen
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full border rounded p-2 disabled:bg-gray-100"
|
||||||
|
placeholder="Start typing a career..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{selectedObj && (
|
||||||
|
<Check
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-green-600"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedObj && (
|
||||||
<datalist id="career-titles">
|
<datalist id="career-titles">
|
||||||
{careerObjects.map((obj, index) => (
|
{careerObjects.map((o) => (
|
||||||
<option key={index} value={obj.title} />
|
<option key={o.soc_code} value={o.title} />
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button onClick={handleConfirmCareer} style={{ marginLeft: '8px' }}>
|
{/* change / clear link */}
|
||||||
Confirm
|
{selectedObj && (
|
||||||
</Button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="text-blue-600 underline text-sm mt-1"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './Dashboard.css'; // or Tailwind classes
|
|
||||||
|
|
||||||
export function CareerSuggestions({
|
export function CareerSuggestions({
|
||||||
careerSuggestions = [],
|
careerSuggestions = [],
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import "./Chatbot.css";
|
import "../styles/legacy/Chatbot.legacy.css";
|
||||||
|
|
||||||
const Chatbot = ({ context }) => {
|
const Chatbot = ({ context }) => {
|
||||||
const [messages, setMessages] = useState([
|
const [messages, setMessages] = useState([
|
||||||
{
|
{
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import PopoutPanel from './PopoutPanel.js';
|
|||||||
import CareerSearch from './CareerSearch.js'; // <--- Import your new search
|
import CareerSearch from './CareerSearch.js'; // <--- Import your new search
|
||||||
import Chatbot from './Chatbot.js';
|
import Chatbot from './Chatbot.js';
|
||||||
|
|
||||||
import './Dashboard.css';
|
import "../styles/legacy/Dashboard.legacy.css";
|
||||||
import { Bar } from 'react-chartjs-2';
|
import { Bar } from 'react-chartjs-2';
|
||||||
import { fetchSchools } from '../utils/apiUtils.js';
|
import { fetchSchools } from '../utils/apiUtils.js';
|
||||||
|
|
||||||
|
|||||||
@ -62,7 +62,7 @@ function InterestMeaningModal({
|
|||||||
{/* Always ask for meaning rating */}
|
{/* Always ask for meaning rating */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block font-medium mb-1">
|
<label className="block font-medium mb-1">
|
||||||
How Meaningful is This Career to You? (1–5)
|
How Meaningful is This Career to You? (i.e. how you feel this career's contributions to society rank) (1–5)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@ -1,194 +0,0 @@
|
|||||||
.loan-repayment-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px; /* Adjust as needed */
|
|
||||||
margin: auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-container form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-container .input-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center; /* Ensures labels and inputs are aligned */
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-container label {
|
|
||||||
font-weight: bold;
|
|
||||||
width: 40%; /* Consistent width for labels */
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-container input,
|
|
||||||
.loan-repayment-container select {
|
|
||||||
width: 58%; /* Consistent width for inputs */
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-container .calculate-button-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-container button {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 300px;
|
|
||||||
padding: 10px;
|
|
||||||
background-color: green;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-container button:hover {
|
|
||||||
background-color: green;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-fields label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-fields input,
|
|
||||||
.loan-repayment-fields select {
|
|
||||||
height: 40px; /* Set a consistent height */
|
|
||||||
padding: 0 10px; /* Add horizontal padding */
|
|
||||||
font-size: 1rem; /* Consistent font size */
|
|
||||||
width: 100%; /* Make inputs span full width */
|
|
||||||
box-sizing: border-box; /* Include padding in total width */
|
|
||||||
margin-bottom: 15px; /* Space between fields */
|
|
||||||
border: 1px solid #ccc; /* Light border for all fields */
|
|
||||||
border-radius: 8px; /* Rounded corners */
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-fields button {
|
|
||||||
width: 100%; /* Full width for button */
|
|
||||||
padding: 12px;
|
|
||||||
height: 45px;
|
|
||||||
margin-top: 10px;
|
|
||||||
background-color: #4CAF50;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center; /* Horizontally center text */
|
|
||||||
align-items: center; /* Vertically center text */
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loan-repayment-fields button:hover {
|
|
||||||
background-color: #45a049;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make the container for the fields more clean */
|
|
||||||
.loan-repayment-fields {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto; /* Center the form */
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional: Adjust margins for form fields */
|
|
||||||
.loan-repayment-fields input,
|
|
||||||
.loan-repayment-fields select,
|
|
||||||
.loan-repayment-fields button {
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure the heading of the Loan Repayment Analysis is centered */
|
|
||||||
.loan-repayment-fields h3 {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background-color: #4CAF50;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: right; /* Centers the button text */
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
background-color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-container {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); /* Limit the column size to a minimum of 280px */
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 20px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px; /* Limit the maximum width of the grid */
|
|
||||||
margin: 0 auto; /* Center the grid horizontally */
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-container h3 {
|
|
||||||
grid-column: span 3; /* Ensure the header spans across the entire grid */
|
|
||||||
text-align: center; /* Align the text to the center */
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-result-card {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 15px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: grid;
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-result-card h4 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-result-card p {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-result-card .net-gain.positive {
|
|
||||||
color: #2ecc71; /* Green color for positive values */
|
|
||||||
}
|
|
||||||
|
|
||||||
.school-result-card .net-gain.negative {
|
|
||||||
color: #e74c3c; /* Red color for negative values */
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: red;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: red;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
@ -1,6 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import './LoanRepayment.css';
|
|
||||||
|
|
||||||
function LoanRepayment({
|
function LoanRepayment({
|
||||||
schools,
|
schools,
|
||||||
salaryData,
|
salaryData,
|
||||||
|
|||||||
@ -206,7 +206,7 @@ const saveInlineMilestone = async (m) => {
|
|||||||
const impPayload = {
|
const impPayload = {
|
||||||
milestone_id : saved.id,
|
milestone_id : saved.id,
|
||||||
impact_type : imp.impact_type,
|
impact_type : imp.impact_type,
|
||||||
direction : imp.direction,
|
direction : imp.impact_type === "salary" ? "add" : imp.direction,
|
||||||
amount : parseFloat(imp.amount) || 0,
|
amount : parseFloat(imp.amount) || 0,
|
||||||
start_date : toSqlDate(imp.start_date) || null,
|
start_date : toSqlDate(imp.start_date) || null,
|
||||||
end_date : toSqlDate(imp.end_date) || null
|
end_date : toSqlDate(imp.end_date) || null
|
||||||
@ -304,7 +304,7 @@ const saveNewMilestone = async () => {
|
|||||||
const impPayload = {
|
const impPayload = {
|
||||||
milestone_id : created.id,
|
milestone_id : created.id,
|
||||||
impact_type : imp.impact_type,
|
impact_type : imp.impact_type,
|
||||||
direction : imp.direction,
|
direction : imp.impact_type === "salary" ? "add" : imp.direction,
|
||||||
amount : parseFloat(imp.amount) || 0,
|
amount : parseFloat(imp.amount) || 0,
|
||||||
start_date : toSqlDate(imp.start_date) || null,
|
start_date : toSqlDate(imp.start_date) || null,
|
||||||
end_date : toSqlDate(imp.end_date) || null
|
end_date : toSqlDate(imp.end_date) || null
|
||||||
@ -429,13 +429,23 @@ const saveNewMilestone = async () => {
|
|||||||
<option value="MONTHLY">Monthly</option>
|
<option value="MONTHLY">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
<label>Direction:</label>
|
<label>Direction:</label>
|
||||||
|
{imp.impact_type !== "salary" && (
|
||||||
<select
|
<select
|
||||||
value={imp.direction}
|
value={imp.direction}
|
||||||
onChange={(e) => updateInlineImpact(m.id, idx, "direction", e.target.value)}
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setNewMilestoneData((prev) => {
|
||||||
|
const copy = [...prev.impacts];
|
||||||
|
copy[idx] = { ...copy[idx], direction: val };
|
||||||
|
return { ...prev, impacts: copy };
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="add">Add</option>
|
<option value="add">Add</option>
|
||||||
<option value="subtract">Subtract</option>
|
<option value="subtract">Subtract</option>
|
||||||
</select>
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
<label>Amount:</label>
|
<label>Amount:</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -503,23 +513,10 @@ const saveNewMilestone = async () => {
|
|||||||
<h6>Impacts</h6>
|
<h6>Impacts</h6>
|
||||||
{newMilestoneData.impacts.map((imp, idx) => (
|
{newMilestoneData.impacts.map((imp, idx) => (
|
||||||
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
|
||||||
<label>Type:</label>
|
{/* Direction – show only when NOT salary */}
|
||||||
<select
|
{imp.impact_type !== "salary" && (
|
||||||
value={imp.impact_type}
|
<>
|
||||||
onChange={(e) => {
|
<label>Add or Subtract?</label>
|
||||||
const val = e.target.value;
|
|
||||||
setNewMilestoneData((prev) => {
|
|
||||||
const copy = [...prev.impacts];
|
|
||||||
copy[idx] = { ...copy[idx], impact_type: val };
|
|
||||||
return { ...prev, impacts: copy };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="salary">Salary (annual)</option>
|
|
||||||
<option value="ONE_TIME">One-Time</option>
|
|
||||||
<option value="MONTHLY">Monthly</option>
|
|
||||||
</select>
|
|
||||||
<label>Direction:</label>
|
|
||||||
<select
|
<select
|
||||||
value={imp.direction}
|
value={imp.direction}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@ -534,6 +531,8 @@ const saveNewMilestone = async () => {
|
|||||||
<option value="add">Add</option>
|
<option value="add">Add</option>
|
||||||
<option value="subtract">Subtract</option>
|
<option value="subtract">Subtract</option>
|
||||||
</select>
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<label>Amount:</label>
|
<label>Amount:</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@ -2,10 +2,16 @@
|
|||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
export default function MilestonePanel({ groups, onEdit, onSelect }) {
|
export default function MilestonePanel({
|
||||||
|
groups = [],
|
||||||
|
onEdit = () => {},
|
||||||
|
onSelect = () => {},
|
||||||
|
onAddNewMilestone = () => {}
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<aside className="w-full md:max-w-md mx-auto"> {/* max-width ≈ 28 rem */}
|
<aside className="w-full md:max-w-md mx-auto">
|
||||||
|
|
||||||
|
{/* ── accordion groups ─────────────────────────── */}
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<details key={g.month} className="mb-3">
|
<details key={g.month} className="mb-3">
|
||||||
<summary className="cursor-pointer font-semibold flex items-center gap-1">
|
<summary className="cursor-pointer font-semibold flex items-center gap-1">
|
||||||
@ -17,30 +23,36 @@ export default function MilestonePanel({ groups, onEdit, onSelect }) {
|
|||||||
{g.items.map((m) => (
|
{g.items.map((m) => (
|
||||||
<li
|
<li
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="grid grid-cols-[1fr_auto] items-center gap-4 pr-2
|
className="grid grid-cols-[1fr_auto] items-center gap-4 pr-2 hover:bg-gray-50 rounded cursor-pointer"
|
||||||
hover:bg-gray-50 rounded cursor-pointer"
|
|
||||||
onClick={() => onSelect(m)}
|
onClick={() => onSelect(m)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{m.title}</span>
|
<span className="truncate">{m.title}</span>
|
||||||
|
|
||||||
|
{/* edit pencil */}
|
||||||
<Button
|
<Button
|
||||||
onClick={(e) => { {/* stop click bubbling so pencil still edits */}
|
|
||||||
e.stopPropagation();
|
|
||||||
onEdit(m);
|
|
||||||
}}
|
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-blue-600 hover:bg-blue-50"
|
className="text-blue-600 hover:bg-blue-50"
|
||||||
aria-label="Edit milestone"
|
aria-label="Edit milestone"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); /* keep row-click for drawer */
|
||||||
|
onEdit(m);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* ── footer action bar ────────────────────────── */}
|
||||||
|
<div className="border-t px-4 py-3 flex justify-end">
|
||||||
|
<Button variant="secondary" onClick={onAddNewMilestone}>
|
||||||
|
+ Add Milestone
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,31 +9,33 @@ const Paywall = () => {
|
|||||||
|
|
||||||
const handleSubscribe = async () => {
|
const handleSubscribe = async () => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) return navigate('/signin');
|
||||||
navigate('/signin');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/activate-premium', {
|
const res = await fetch('/api/activate-premium', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Authorization': `Bearer ${token}` }
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (res.status === 401) return navigate('/signin-landing');
|
||||||
navigate('/premium-onboarding', { state: { selectedCareer } });
|
|
||||||
} else if (response.status === 401) {
|
if (res.ok) {
|
||||||
navigate('/GettingStarted', { state: { selectedCareer } });
|
// 1) grab the fresh token / profile if the API returns it
|
||||||
|
const { token: newToken, user } = await res.json().catch(() => ({}));
|
||||||
|
if (newToken) localStorage.setItem('token', newToken);
|
||||||
|
if (user) window.dispatchEvent(new Event('user-updated')); // or your context setter
|
||||||
|
|
||||||
|
// 2) give the auth context time to update, then push
|
||||||
|
navigate('/premium-onboarding', { replace: true, state: { selectedCareer } });
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to activate premium:', await response.text());
|
console.error('activate-premium failed:', await res.text());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error activating premium:', err);
|
console.error('Error activating premium:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="paywall">
|
<div className="paywall">
|
||||||
|
|||||||
@ -15,6 +15,11 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
|||||||
const [currentlyWorking, setCurrentlyWorking] = useState('');
|
const [currentlyWorking, setCurrentlyWorking] = useState('');
|
||||||
const [selectedCareer, setSelectedCareer] = useState('');
|
const [selectedCareer, setSelectedCareer] = useState('');
|
||||||
const [collegeEnrollmentStatus, setCollegeEnrollmentStatus] = useState('');
|
const [collegeEnrollmentStatus, setCollegeEnrollmentStatus] = useState('');
|
||||||
|
const [showFinPrompt, setShowFinPrompt] = useState(false);
|
||||||
|
const [financialReady, setFinancialReady] = useState(false); // persisted later if you wish
|
||||||
|
const Req = () => <span className="text-red-600 ml-0.5">*</span>;
|
||||||
|
const ready = selectedCareer && currentlyWorking && collegeEnrollmentStatus;
|
||||||
|
|
||||||
|
|
||||||
// 1) Grab the location state values, if any
|
// 1) Grab the location state values, if any
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -85,7 +90,7 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block font-medium">
|
<label className="block font-medium">
|
||||||
Are you currently earning any income — even part-time or outside your intended career path?
|
Are you currently earning any income — even part-time or outside your intended career path? <Req />
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
(We ask this to understand your financial picture. This won’t affect how we track your progress toward your target career.)
|
(We ask this to understand your financial picture. This won’t affect how we track your progress toward your target career.)
|
||||||
@ -96,6 +101,7 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
|||||||
setCurrentlyWorking(e.target.value);
|
setCurrentlyWorking(e.target.value);
|
||||||
setData(prev => ({ ...prev, currently_working: e.target.value }));
|
setData(prev => ({ ...prev, currently_working: e.target.value }));
|
||||||
}}
|
}}
|
||||||
|
required
|
||||||
className="w-full border rounded p-2"
|
className="w-full border rounded p-2"
|
||||||
>
|
>
|
||||||
<option value="">Select one</option>
|
<option value="">Select one</option>
|
||||||
@ -107,13 +113,13 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
|||||||
{/* 2) Replace old local “Search for Career” with <CareerSearch/> */}
|
{/* 2) Replace old local “Search for Career” with <CareerSearch/> */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-medium">
|
<h3 className="font-medium">
|
||||||
What career are you planning to pursue?
|
What career are you planning to pursue? <Req />
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
This should be your <strong>target career path</strong> — whether it’s a new goal or the one you're already in.
|
This should be your <strong>target career path</strong> — whether it’s a new goal or the one you're already in.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<CareerSearch onCareerSelected={handleCareerSelected} />
|
<CareerSearch onCareerSelected={handleCareerSelected} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCareer && (
|
{selectedCareer && (
|
||||||
@ -161,14 +167,17 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block font-medium">
|
<label className="block font-medium">
|
||||||
Are you currently enrolled in college or planning to enroll?
|
Are you currently enrolled in college or planning to enroll? <Req />
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={collegeEnrollmentStatus}
|
value={collegeEnrollmentStatus}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCollegeEnrollmentStatus(e.target.value);
|
setCollegeEnrollmentStatus(e.target.value);
|
||||||
setData(prev => ({ ...prev, college_enrollment_status: e.target.value }));
|
setData(prev => ({ ...prev, college_enrollment_status: e.target.value }));
|
||||||
|
const needsPrompt = ['currently_enrolled', 'prospective_student'].includes(e.target.value);
|
||||||
|
setShowFinPrompt(needsPrompt);
|
||||||
}}
|
}}
|
||||||
|
required
|
||||||
className="w-full border rounded p-2"
|
className="w-full border rounded p-2"
|
||||||
>
|
>
|
||||||
<option value="">Select one</option>
|
<option value="">Select one</option>
|
||||||
@ -176,6 +185,39 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
|||||||
<option value="currently_enrolled">Currently Enrolled</option>
|
<option value="currently_enrolled">Currently Enrolled</option>
|
||||||
<option value="prospective_student">Planning to Enroll (Prospective)</option>
|
<option value="prospective_student">Planning to Enroll (Prospective)</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{showFinPrompt && !financialReady && (
|
||||||
|
<div className="mt-4 p-4 rounded border border-blue-300 bg-blue-50">
|
||||||
|
<p className="text-sm mb-3">
|
||||||
|
We can give you step-by-step milestones right away. <br />
|
||||||
|
If you’d also like a personal cash-flow & tuition projection,<br />
|
||||||
|
add a few financial details now—or skip and do it later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded mr-2"
|
||||||
|
onClick={() => {
|
||||||
|
/* open your ScenarioEditModal or a mini financial modal */
|
||||||
|
setShowFinPrompt(false);
|
||||||
|
/* maybe set a flag so CareerRoadmap opens the modal */
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Financial Details
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="bg-gray-200 hover:bg-gray-300 text-gray-800 py-1 px-3 rounded"
|
||||||
|
onClick={() => {
|
||||||
|
setFinancialReady(false); // they chose to skip
|
||||||
|
setShowFinPrompt(false);
|
||||||
|
nextStep(); // continue onboarding
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip for Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block font-medium">Career Goals</label>
|
<label className="block font-medium">Career Goals</label>
|
||||||
@ -196,7 +238,11 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded"
|
disabled={!ready}
|
||||||
|
className={`py-2 px-4 rounded font-semibold
|
||||||
|
${selectedCareer && currentlyWorking && collegeEnrollmentStatus
|
||||||
|
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
Financial →
|
Financial →
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -122,7 +122,7 @@ export default function ScenarioEditModal({
|
|||||||
|
|
||||||
/*********************************************************
|
/*********************************************************
|
||||||
* 7) Whenever the **modal is shown** *or* **scenario.id changes**
|
* 7) Whenever the **modal is shown** *or* **scenario.id changes**
|
||||||
+ * → hydrate the form + careerSearch box.
|
* → hydrate the form + careerSearch box.
|
||||||
*********************************************************/
|
*********************************************************/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show || !scenario) return;
|
if (!show || !scenario) return;
|
||||||
@ -139,14 +139,10 @@ export default function ScenarioEditModal({
|
|||||||
status : safe(s.status || 'planned'),
|
status : safe(s.status || 'planned'),
|
||||||
start_date : safe(s.start_date),
|
start_date : safe(s.start_date),
|
||||||
projected_end_date : safe(s.projected_end_date),
|
projected_end_date : safe(s.projected_end_date),
|
||||||
retirement_start_date: safe(
|
retirement_start_date: safe(s.retirement_start_date),
|
||||||
(s.retirement_start_date || s.projected_end_date || '')
|
desired_retirement_income_monthly : safe(
|
||||||
.toString() // handles Date objects
|
s.desired_retirement_income_monthly
|
||||||
.substring(0, 10) // keep YYYY-MM-DD
|
|
||||||
),
|
),
|
||||||
desired_retirement_income_monthly :
|
|
||||||
safe(s.desired_retirement_income_monthly
|
|
||||||
?? financialProfile?.monthly_expenses),
|
|
||||||
|
|
||||||
planned_monthly_expenses : safe(s.planned_monthly_expenses),
|
planned_monthly_expenses : safe(s.planned_monthly_expenses),
|
||||||
planned_monthly_debt_payments : safe(s.planned_monthly_debt_payments),
|
planned_monthly_debt_payments : safe(s.planned_monthly_debt_payments),
|
||||||
@ -237,9 +233,6 @@ export default function ScenarioEditModal({
|
|||||||
/*********************************************************
|
/*********************************************************
|
||||||
* 9) Career auto-suggest
|
* 9) Career auto-suggest
|
||||||
*********************************************************/
|
*********************************************************/
|
||||||
/*********************************************************
|
|
||||||
* 9) Career auto-suggest
|
|
||||||
*********************************************************/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
|
|
||||||
@ -1202,6 +1195,11 @@ if (formData.retirement_start_date) {
|
|||||||
<Button variant="primary" onClick={handleSave}>
|
<Button variant="primary" onClick={handleSave}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
{!formData.retirement_start_date && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">
|
||||||
|
Pick a Planned Retirement Date to run the simulation.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show a preview if we have simulation data */}
|
{/* Show a preview if we have simulation data */}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import './SchoolFilters.css';
|
import "../styles/legacy/SchoolFilters.legacy.css";
|
||||||
|
|
||||||
function SchoolFilters({ schools, setFilteredSchools }) {
|
function SchoolFilters({ schools, setFilteredSchools }) {
|
||||||
const [sortBy, setSortBy] = useState('tuition'); // Default: Sort by Tuition
|
const [sortBy, setSortBy] = useState('tuition'); // Default: Sort by Tuition
|
||||||
const [tuitionRange, setTuitionRange] = useState([0, 50000]); // Example range
|
const [tuitionRange, setTuitionRange] = useState([0, 50000]); // Example range
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect, useContext } from 'react';
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { ProfileCtx } from '../App.js';
|
||||||
|
|
||||||
function SignIn({ setIsAuthenticated, setUser }) {
|
function SignIn({ setIsAuthenticated, setUser }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { setFinancialProfile, setScenario } = useContext(ProfileCtx);
|
||||||
const usernameRef = useRef('');
|
const usernameRef = useRef('');
|
||||||
const passwordRef = useRef('');
|
const passwordRef = useRef('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -21,6 +23,16 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
// 0️⃣ clear everything that belongs to the *previous* user
|
||||||
|
localStorage.removeItem('careerSuggestionsCache');
|
||||||
|
localStorage.removeItem('lastSelectedCareerProfileId');
|
||||||
|
localStorage.removeItem('aiClickCount');
|
||||||
|
localStorage.removeItem('aiClickDate');
|
||||||
|
localStorage.removeItem('aiRecommendations');
|
||||||
|
localStorage.removeItem('premiumOnboardingState');
|
||||||
|
localStorage.removeItem('financialProfile'); // if you cache it
|
||||||
|
localStorage.removeItem('selectedScenario');
|
||||||
|
|
||||||
const username = usernameRef.current.value;
|
const username = usernameRef.current.value;
|
||||||
const password = passwordRef.current.value;
|
const password = passwordRef.current.value;
|
||||||
|
|
||||||
@ -30,37 +42,47 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://dev1.aptivaai.com/api/signin', { // <-here
|
const resp = await fetch('https://dev1.aptivaai.com/api/signin', {
|
||||||
method: 'POST',
|
method : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body : JSON.stringify({ username, password })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const data = await resp.json(); // ← read ONCE
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to sign in');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
|
||||||
// Destructure user, which includes is_premium, etc.
|
|
||||||
|
/* ---------------- success path ---------------- */
|
||||||
const { token, id, user } = data;
|
const { token, id, user } = data;
|
||||||
|
|
||||||
// Store token & id in localStorage
|
// fetch current user profile immediately
|
||||||
|
const profileRes = await fetch('/api/user-profile', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const profile = await profileRes.json();
|
||||||
|
setFinancialProfile(profile);
|
||||||
|
setScenario(null); // or fetch latest scenario separately
|
||||||
|
|
||||||
|
/* purge any leftovers from prior session */
|
||||||
|
['careerSuggestionsCache',
|
||||||
|
'lastSelectedCareerProfileId',
|
||||||
|
'aiClickCount',
|
||||||
|
'aiClickDate',
|
||||||
|
'aiRecommendations',
|
||||||
|
'premiumOnboardingState',
|
||||||
|
'financialProfile',
|
||||||
|
'selectedScenario'
|
||||||
|
].forEach(k => localStorage.removeItem(k));
|
||||||
|
|
||||||
|
/* store new session data */
|
||||||
localStorage.setItem('token', token);
|
localStorage.setItem('token', token);
|
||||||
localStorage.setItem('id', id);
|
localStorage.setItem('id', id);
|
||||||
|
|
||||||
// Mark user as authenticated
|
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
// Store the full user object in state, so we can check user.is_premium, etc.
|
|
||||||
if (setUser && user) {
|
|
||||||
setUser(user);
|
setUser(user);
|
||||||
|
navigate('/signin-landing');
|
||||||
navigate('/signin-landing'); // fallback if undefined
|
} catch (err) {
|
||||||
}
|
setError(err.message);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
setError(error.message);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -105,7 +127,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
|||||||
<p className="mt-4 text-center text-sm text-gray-600">
|
<p className="mt-4 text-center text-sm text-gray-600">
|
||||||
Don’t have an account?{' '}
|
Don’t have an account?{' '}
|
||||||
<Link
|
<Link
|
||||||
//to="/signup" // <- here
|
to="/signup" // <- here
|
||||||
className="font-medium text-blue-600 hover:text-blue-500"
|
className="font-medium text-blue-600 hover:text-blue-500"
|
||||||
>
|
>
|
||||||
Sign Up
|
Sign Up
|
||||||
|
|||||||
@ -56,7 +56,7 @@ function SignUp() {
|
|||||||
const [areas, setAreas] = useState([]);
|
const [areas, setAreas] = useState([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loadingAreas, setLoadingAreas] = useState(false);
|
const [loadingAreas, setLoadingAreas] = useState(false);
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('+1');
|
||||||
const [optIn, setOptIn] = useState(false);
|
const [optIn, setOptIn] = useState(false);
|
||||||
|
|
||||||
const [showCareerSituations, setShowCareerSituations] = useState(false);
|
const [showCareerSituations, setShowCareerSituations] = useState(false);
|
||||||
@ -108,9 +108,10 @@ function SignUp() {
|
|||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
const validateFields = async () => {
|
const validateFields = async () => {
|
||||||
const emailRegex = /^[^\s@]@[^\s@]\.[^\s@]{2,}$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||||
const zipRegex = /^\d{5}$/;
|
const zipRegex = /^\d{5}$/;
|
||||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
|
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
|
||||||
|
const usPhoneRegex = /^\+1\d{10}$/;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!username || !password || !confirmPassword ||
|
!username || !password || !confirmPassword ||
|
||||||
@ -145,6 +146,10 @@ function SignUp() {
|
|||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError('Passwords do not match.');
|
setError('Passwords do not match.');
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
if (!usPhoneRegex.test(phone)) {
|
||||||
|
setError('Phone number must be +1 followed by 10 digits.');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicitly check if username exists before proceeding
|
// Explicitly check if username exists before proceeding
|
||||||
@ -274,6 +279,7 @@ return (
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="w-full px-3 py-2 border rounded-md"
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
|
type="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@ -287,14 +293,22 @@ return (
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ─────────────── New: Mobile number ─────────────── */}
|
{/* ─────────────── New: Mobile number ─────────────── */}
|
||||||
<react-phone-input
|
<input
|
||||||
country="us"
|
type="tel"
|
||||||
className="w-full px-3 py-2 border rounded-md"
|
className="w-full px-3 py-2 border rounded-md"
|
||||||
placeholder="Mobile (15555555555)"
|
placeholder="+15555555555"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={e => {
|
||||||
|
// keep only digits, enforce leading 1, clamp length
|
||||||
|
let digits = e.target.value.replace(/\D/g, '');
|
||||||
|
if (!digits.startsWith('1')) digits = '1' + digits;
|
||||||
|
setPhone('+' + digits.slice(0, 11)); // +1 + 10 digits max
|
||||||
|
}}
|
||||||
|
pattern="^\+1\d{10}$"
|
||||||
|
title="Enter a U.S. number: +1 followed by 10 digits"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
{/* New: SMS opt-in checkbox */}
|
{/* New: SMS opt-in checkbox */}
|
||||||
<label className="inline-flex items-center gap-2">
|
<label className="inline-flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './ZipCodeInput.css';
|
import "../styles/legacy/ZipCodeInput.legacy.css";
|
||||||
|
|
||||||
export function ZipCodeInput({ zipCode, setZipCode, onZipSubmit }) {
|
export function ZipCodeInput({ zipCode, setZipCode, onZipSubmit }) {
|
||||||
return (
|
return (
|
||||||
<div className="zip-code-input">
|
<div className="zip-code-input">
|
||||||
|
|||||||
@ -1,17 +1,41 @@
|
|||||||
|
/* index.css – Aptiva base layer */
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
/* --- 1) BODY RESET/COLOURS ------------------------------- */
|
||||||
margin: 0;
|
@layer base {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
body {
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
@apply bg-aptiva-gray text-gray-900 antialiased;
|
||||||
sans-serif;
|
}
|
||||||
-webkit-font-smoothing: antialiased;
|
h1, h2, h3 {
|
||||||
-moz-osx-font-smoothing: grayscale;
|
@apply font-semibold text-gray-800;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
/* --- 2) REUSABLE COMPONENT CLASSES ----------------------- */
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
@layer components {
|
||||||
monospace;
|
/* Primary button */
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center gap-2
|
||||||
|
rounded-xl bg-aptiva hover:bg-aptiva-dark
|
||||||
|
text-white font-medium px-4 py-2
|
||||||
|
shadow transition disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary outline button */
|
||||||
|
.btn-outline {
|
||||||
|
@apply inline-flex items-center gap-2
|
||||||
|
rounded-xl border border-aptiva text-aptiva
|
||||||
|
px-4 py-2 hover:bg-aptiva-light/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card container */
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl bg-white shadow p-6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- 3) OPTIONAL UTILITIES -------------------------------- */
|
||||||
|
/* Example: .scrollbar-thin from tailwind-scrollbar plugin */
|
||||||
|
/* … */
|
||||||
|
|||||||
@ -1,9 +1,31 @@
|
|||||||
|
// tailwind.config.js
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||||
'./src/**/*.{js,jsx,ts,tsx}', // Ensure this matches your file structure
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
/* brand colours */
|
||||||
|
colors: {
|
||||||
|
aptiva: {
|
||||||
|
DEFAULT : '#0A84FF', // primary blue
|
||||||
|
dark : '#005FCC',
|
||||||
|
light : '#3AA0FF',
|
||||||
|
accent : '#FF7C00', // accent orange
|
||||||
|
gray : '#F7FAFC', // page bg
|
||||||
},
|
},
|
||||||
plugins: [],
|
},
|
||||||
|
/* fonts */
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
/* radii */
|
||||||
|
borderRadius: {
|
||||||
|
xl: '1rem', // 16 px extra-round corners everywhere
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/forms'), // prettier inputs
|
||||||
|
require('@tailwindcss/typography'), // prose-class for AI answers
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user