Cleanup, all technical fixes prior to prod creation

This commit is contained in:
Josh 2025-08-03 18:44:36 +00:00
parent 7a425a955b
commit ee098148a4
50 changed files with 851 additions and 3651 deletions

2
.env
View File

@ -2,4 +2,4 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://
SERVER1_PORT=5000 SERVER1_PORT=5000
SERVER2_PORT=5001 SERVER2_PORT=5001
SERVER3_PORT=5002 SERVER3_PORT=5002
IMG_TAG=202508011207 IMG_TAG=66721ee-202508031720

View File

@ -75,6 +75,7 @@ function internalFetch(req, urlPath, opts = {}) {
}); });
} }
const auth = (req, urlPath, opts = {}) => internalFetch(req, urlPath, opts);
app.post('/api/premium/stripe/webhook', app.post('/api/premium/stripe/webhook',
express.raw({ type: 'application/json' }), express.raw({ type: 'application/json' }),

View File

@ -10,78 +10,3 @@ const BASE = (
process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL ||
'' ''
).replace(/\/+$/, ''); // trim *all* trailing “/” ).replace(/\/+$/, ''); // trim *all* trailing “/”
export const api = (path = '') =>
`${BASE}${path.startsWith('/') ? '' : '/'}${path}`;
/* ------------------------------------------------------------------
Fetch areas-by-state (static JSON in public/ or served by nginx)
------------------------------------------------------------------*/
export const fetchAreasByState = async (state) => {
try {
// NOTE: if Institution_data.json is in /public, nginx serves it
const res = await fetch(api('/Institution_data.json'));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Adjust this part if your JSON structure is different
const json = await res.json();
return json[state]?.areas || [];
} catch (err) {
console.error('Error fetching areas:', err.message);
return [];
}
};
/* ------------------------------------------------------------------
Client-side Google Maps geocode
------------------------------------------------------------------*/
export async function clientGeocodeZip(zip) {
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY ??
process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
const url = `https://maps.googleapis.com/maps/api/geocode/json` +
`?address=${encodeURIComponent(zip)}&key=${apiKey}`;
const resp = await axios.get(url);
const { status, results } = resp.data;
if (status === 'OK' && results.length) {
return results[0].geometry.location; // { lat, lng }
}
throw new Error('Geocoding failed.');
}
/* ------------------------------------------------------------------
Haversine distance helper (miles)
------------------------------------------------------------------*/
export function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 3959; // earth radius in miles
const toRad = (v) => (v * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) ** 2;
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/* ------------------------------------------------------------------
Fetch schools for one or many CIP prefixes
------------------------------------------------------------------*/
export async function fetchSchools(cipCodes) {
try {
// 1) Ensure array-ness, then join with commas
const codes = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
const cipParam = codes.join(',');
// 2) Hit backend
const res = await axios.get(api('/api/schools'), {
params: { cipCodes: cipParam },
});
return res.data;
} catch (err) {
console.error('Error fetching schools:', err.message);
return [];
}
}

View File

@ -184,12 +184,20 @@ export default function chatFreeEndpoint(
authenticateUser, authenticateUser,
async (req, res) => { async (req, res) => {
try { try {
res.writeHead(200, { const headers = {
"Content-Type": "text/plain; charset=utf-8", // streaming MIME type browsers still treat it as text, but
// it signals “keep pushing” semantics more clearly
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
Connection : "keep-alive", "X-Accel-Buffering": "no" // disables Nginx/ALB buffering
"X-Accel-Buffering": "no" };
});
// “Connection” is allowed **only** on HTTP/1.x
if (req.httpVersionMajor < 2) {
headers.Connection = "keep-alive";
}
res.writeHead(200, headers);
res.flushHeaders?.(); res.flushHeaders?.();
const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); }; const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); };

View File

@ -1,170 +0,0 @@
/**
* applyOps execute a fenced ```ops``` block returned by Jess.
* Supports milestones, tasks, impacts, scenario utilities, and college profile.
*
* @param {object} opsObj parsed JSON inside ```ops```
* @param {object} req Express request (for auth header)
* @param {string} scenarioId current career_profile_id (optional but lets us
* auto-fill when the bot forgets)
* @return {string[]} human-readable confirmations
*/
export async function applyOps(opsObj = {}, req, scenarioId = null) {
if (!Array.isArray(opsObj?.milestones) && !Array.isArray(opsObj?.tasks)
&& !Array.isArray(opsObj?.impacts) && !Array.isArray(opsObj?.scenarios)
&& !opsObj.collegeProfile) return [];
const apiBase = process.env.APTIVA_INTERNAL_API || 'http://localhost:5002/api';
const auth = (p, o = {}) =>
internalFetch(req, `${apiBase}${p}`, {
headers: { 'Content-Type': 'application/json', ...(o.headers || {}) },
...o
});
const confirmations = [];
/*
1. MILESTONE-LEVEL OPS (unchanged behaviour)
*/
for (const m of opsObj.milestones || []) {
const op = (m?.op || '').toUpperCase();
if (op === 'DELETE' && m.id) { // single-scenario delete
const r = await auth(`/premium/milestones/${m.id.trim()}`, { method: 'DELETE' });
if (r.ok) confirmations.push(`Deleted milestone ${m.id}`);
continue;
}
if (op === 'UPDATE' && m.id && m.patch) {
const r = await auth(`/premium/milestones/${m.id}`, {
method: 'PUT', body: JSON.stringify(m.patch)
});
if (r.ok) confirmations.push(`Updated milestone ${m.id}`);
continue;
}
if (op === 'CREATE' && m.data) {
m.data.career_profile_id = m.data.career_profile_id || scenarioId;
const r = await auth('/premium/milestone', { method: 'POST', body: JSON.stringify(m.data) });
if (r.ok) {
const j = await r.json();
const newId = Array.isArray(j) ? j[0]?.id : j.id;
confirmations.push(`Created milestone ${newId || '(new)'}`);
}
continue;
}
if (op === 'DELETEALL' && m.id) { // delete across every scenario
const r = await auth(`/premium/milestones/${m.id}/all`, { method: 'DELETE' });
if (r.ok) confirmations.push(`Deleted milestone ${m.id} from all scenarios`);
continue;
}
if (op === 'COPY' && m.id && Array.isArray(m.targetScenarioIds)) {
const r = await auth('/premium/milestone/copy', {
method: 'POST',
body : JSON.stringify({ milestoneId: m.id, scenarioIds: m.targetScenarioIds })
});
if (r.ok) confirmations.push(`Copied milestone ${m.id}${m.targetScenarioIds.length} scenario(s)`);
continue;
}
}
/*
2. TASK-LEVEL OPS
*/
for (const t of opsObj.tasks || []) {
const op = (t?.op || '').toUpperCase();
if (op === 'CREATE' && t.data && t.data.milestone_id) {
await auth('/premium/tasks', { method: 'POST', body: JSON.stringify(t.data) });
confirmations.push(`Added task to milestone ${t.data.milestone_id}`);
continue;
}
if (op === 'UPDATE' && t.taskId && t.patch) {
await auth(`/premium/tasks/${t.taskId}`, { method: 'PUT', body: JSON.stringify(t.patch) });
confirmations.push(`Updated task ${t.taskId}`);
continue;
}
if (op === 'DELETE' && t.taskId) {
await auth(`/premium/tasks/${t.taskId}`, { method: 'DELETE' });
confirmations.push(`Deleted task ${t.taskId}`);
continue;
}
}
/*
3. IMPACT-LEVEL OPS
*/
for (const imp of opsObj.impacts || []) {
const op = (imp?.op || '').toUpperCase();
if (op === 'CREATE' && imp.data && imp.data.milestone_id) {
await auth('/premium/milestone-impacts', { method: 'POST', body: JSON.stringify(imp.data) });
confirmations.push(`Added impact to milestone ${imp.data.milestone_id}`);
continue;
}
if (op === 'UPDATE' && imp.impactId && imp.patch) {
await auth(`/premium/milestone-impacts/${imp.impactId}`, { method: 'PUT', body: JSON.stringify(imp.patch) });
confirmations.push(`Updated impact ${imp.impactId}`);
continue;
}
if (op === 'DELETE' && imp.impactId) {
await auth(`/premium/milestone-impacts/${imp.impactId}`, { method: 'DELETE' });
confirmations.push(`Deleted impact ${imp.impactId}`);
continue;
}
}
/*
4. SCENARIO (career_profile) OPS
*/
for (const s of opsObj.scenarios || []) {
const op = (s?.op || '').toUpperCase();
if (op === 'CREATE' && s.data?.career_name) {
await auth('/premium/career-profile', { method: 'POST', body: JSON.stringify(s.data) });
confirmations.push(`Created scenario “${s.data.career_name}`);
continue;
}
if (op === 'UPDATE' && s.scenarioId && s.patch) {
/* if only goals are patched, hit the goals route; otherwise create a PUT route */
const hasOnlyGoals = Object.keys(s.patch).length === 1 && s.patch.career_goals !== undefined;
const url = hasOnlyGoals
? `/premium/career-profile/${s.scenarioId}/goals`
: `/premium/career-profile`; // <-- add generic PATCH if you implemented one
await auth(url.replace(/\/$/, `/${s.scenarioId}`), { method: 'PUT', body: JSON.stringify(s.patch) });
confirmations.push(`Updated scenario ${s.scenarioId}`);
continue;
}
if (op === 'DELETE' && s.scenarioId) {
await auth(`/premium/career-profile/${s.scenarioId}`, { method: 'DELETE' });
confirmations.push(`Deleted scenario ${s.scenarioId}`);
continue;
}
if (op === 'CLONE' && s.sourceId) {
await auth('/premium/career-profile/clone', { method: 'POST', body: JSON.stringify({
sourceId : s.sourceId,
overrides : s.overrides || {}
})});
confirmations.push(`Cloned scenario ${s.sourceId}`);
continue;
}
}
/*
5. COLLEGE PROFILE (single op per block)
*/
if (opsObj.collegeProfile?.op?.toUpperCase() === 'UPSERT' && opsObj.collegeProfile.data) {
await auth('/premium/college-profile', { method: 'POST', body: JSON.stringify(opsObj.collegeProfile.data) });
confirmations.push('Saved college profile');
}
return confirmations;
}

30
eslint.config.js Normal file
View File

@ -0,0 +1,30 @@
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import importPlugin from "eslint-plugin-import";
import globals from "globals";
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
globals: globals.browser,
parserOptions: { sourceType: "module" }
},
plugins: {
react: reactPlugin,
import: importPlugin
},
rules: {
...reactPlugin.configs.recommended.rules,
"import/no-unused-modules": ["warn", { unusedExports: true }]
}
},
{
files: ["**/*.cjs"],
languageOptions: { sourceType: "commonjs" }
}
];

445
package-lock.json generated
View File

@ -63,12 +63,21 @@
"devDependencies": { "devDependencies": {
"@babel/parser": "^7.28.0", "@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/js": "^9.32.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"glob": "^11.0.3", "glob": "^11.0.3",
"globals": "^16.3.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17",
"typescript-eslint": "^8.38.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@ -150,9 +159,9 @@
} }
}, },
"node_modules/@babel/eslint-parser": { "node_modules/@babel/eslint-parser": {
"version": "7.27.5", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.27.5.tgz", "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.0.tgz",
"integrity": "sha512-HLkYQfRICudzcOtjGwkPvGc5nF1b4ljLZh1IRDj50lRZ718NAKVgQpIAUX8bfg6u/yuSKY3L7E0YzIV+OxrB8Q==", "integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
@ -1069,6 +1078,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-classes/node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/plugin-transform-computed-properties": { "node_modules/@babel/plugin-transform-computed-properties": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz",
@ -2051,6 +2069,15 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/traverse/node_modules/globals": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.28.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
@ -2452,12 +2479,16 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "8.57.1", "version": "9.32.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz",
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
} }
}, },
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
@ -4582,6 +4613,42 @@
} }
} }
}, },
"node_modules/@typescript-eslint/project-service": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz",
"integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.38.0",
"@typescript-eslint/types": "^8.38.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz",
"integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "5.62.0", "version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
@ -4599,6 +4666,23 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz",
"integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "5.62.0", "version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
@ -8495,6 +8579,48 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint-config-airbnb": {
"version": "19.0.4",
"resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz",
"integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-config-airbnb-base": "^15.0.0",
"object.assign": "^4.1.2",
"object.entries": "^1.1.5"
},
"engines": {
"node": "^10.12.0 || ^12.22.0 || ^14.17.0 || >=16.0.0"
},
"peerDependencies": {
"eslint": "^7.32.0 || ^8.2.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0"
}
},
"node_modules/eslint-config-airbnb-base": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz",
"integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==",
"dev": true,
"license": "MIT",
"dependencies": {
"confusing-browser-globals": "^1.0.10",
"object.assign": "^4.1.2",
"object.entries": "^1.1.5",
"semver": "^6.3.0"
},
"engines": {
"node": "^10.12.0 || >=12.0.0"
},
"peerDependencies": {
"eslint": "^7.32.0 || ^8.2.0",
"eslint-plugin-import": "^2.25.2"
}
},
"node_modules/eslint-config-react-app": { "node_modules/eslint-config-react-app": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz",
@ -8864,6 +8990,15 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/eslint/node_modules/@eslint/js": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/eslint/node_modules/argparse": { "node_modules/eslint/node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -10191,12 +10326,16 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "11.12.0", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=4" "node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/globalthis": { "node_modules/globalthis": {
@ -19470,6 +19609,19 @@
"integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/ts-interface-checker": { "node_modules/ts-interface-checker": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -19724,6 +19876,275 @@
"node": ">=4.2.0" "node": ">=4.2.0"
} }
}, },
"node_modules/typescript-eslint": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz",
"integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"@typescript-eslint/typescript-estree": "8.38.0",
"@typescript-eslint/utils": "8.38.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz",
"integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/type-utils": "8.38.0",
"@typescript-eslint/utils": "8.38.0",
"@typescript-eslint/visitor-keys": "8.38.0",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz",
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/types": "8.38.0",
"@typescript-eslint/typescript-estree": "8.38.0",
"@typescript-eslint/visitor-keys": "8.38.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz",
"integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.38.0",
"@typescript-eslint/visitor-keys": "8.38.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz",
"integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.38.0",
"@typescript-eslint/typescript-estree": "8.38.0",
"@typescript-eslint/utils": "8.38.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/types": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz",
"integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz",
"integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.38.0",
"@typescript-eslint/tsconfig-utils": "8.38.0",
"@typescript-eslint/types": "8.38.0",
"@typescript-eslint/visitor-keys": "8.38.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^2.1.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz",
"integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/types": "8.38.0",
"@typescript-eslint/typescript-estree": "8.38.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.38.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz",
"integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.38.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/typescript-eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/typescript-eslint/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/typescript-eslint/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/typescript-eslint/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",

View File

@ -56,6 +56,8 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"scripts": { "scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"start": "react-scripts start --host 0.0.0.0", "start": "react-scripts start --host 0.0.0.0",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
@ -87,11 +89,20 @@
"devDependencies": { "devDependencies": {
"@babel/parser": "^7.28.0", "@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/js": "^9.32.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"glob": "^11.0.3", "glob": "^11.0.3",
"globals": "^16.3.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17",
"typescript-eslint": "^8.38.0"
} }
} }

View File

@ -1,42 +0,0 @@
// parseLine.js
function parseLine(line) {
// Split on tabs
const cols = line.split(/\t/).map((c) => c.trim());
// We expect 15 columns, but we won't skip lines if some are missing or extra.
// We'll fill them with "" if not present, to preserve all data
const col0 = cols[0] || ""; // O*NET-SOC Code
const col1 = cols[1] || ""; // Title
const col2 = cols[2] || ""; // Element ID
const col3 = cols[3] || ""; // Element Name
const col4 = cols[4] || ""; // Scale ID
const col5 = cols[5] || ""; // Scale Name
const col6 = cols[6] || ""; // Data Value
const col7 = cols[7] || ""; // N
const col8 = cols[8] || ""; // Standard Error
const col9 = cols[9] || ""; // Lower CI Bound
const col10 = cols[10] || ""; // Upper CI Bound
const col11 = cols[11] || ""; // Recommend Suppress
const col12 = cols[12] || ""; // Not Relevant
const col13 = cols[13] || ""; // Date
const col14 = cols[14] || ""; // Domain Source
// Return an object with keys matching your definitions
return {
onetSocCode: cols[0], // e.g. "11-1011.00"
elementID: cols[1], // e.g. "2.C.1.a"
elementName: cols[2], // e.g. "Administration and Management"
scaleID: cols[3], // e.g. "IM" or "LV"
dataValue: cols[4], // e.g. "4.78"
n: cols[5], // e.g. "28"
standardError: cols[6], // e.g. "0.1102"
lowerCI: cols[7],
upperCI: cols[8],
recommendSuppress: cols[9],
notRelevant: cols[10],
date: cols[11],
domainSource: cols[12]
};
}
export default parseLine;

View File

@ -22,7 +22,6 @@ import EducationalProgramsPage from './components/EducationalProgramsPage.js';
import EnhancingLanding from './components/EnhancingLanding.js'; import EnhancingLanding from './components/EnhancingLanding.js';
import RetirementLanding from './components/RetirementLanding.js'; import RetirementLanding from './components/RetirementLanding.js';
import InterestInventory from './components/InterestInventory.js'; import InterestInventory from './components/InterestInventory.js';
import Dashboard from './components/Dashboard.js';
import UserProfile from './components/UserProfile.js'; import UserProfile from './components/UserProfile.js';
import FinancialProfileForm from './components/FinancialProfileForm.js'; import FinancialProfileForm from './components/FinancialProfileForm.js';
import CareerProfileList from './components/CareerProfileList.js'; import CareerProfileList from './components/CareerProfileList.js';
@ -78,6 +77,10 @@ const uiToolHandlers = useMemo(() => {
return {}; // every other page exposes no UI tools return {}; // every other page exposes no UI tools
}, [pageContext]); }, [pageContext]);
// Retirement bot is only relevant on these pages
const canShowRetireBot =
pageContext === 'RetirementPlanner' ||
pageContext === 'RetirementLanding';
// Auth states // Auth states
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
@ -228,18 +231,16 @@ const uiToolHandlers = useMemo(() => {
<ChatCtx.Provider value={{ setChatSnapshot, <ChatCtx.Provider value={{ setChatSnapshot,
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); }, openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
openRetire : (props) => { openRetire : (props) => {
setRetireProps(props); if (!canShowRetireBot) {
setDrawerPane('retire');
setDrawerOpen(true);
if (pageContext === 'RetirementPlanner' || pageContext === 'RetirementLanding') {
setRetireProps(props);
setDrawerPane('retire');
setDrawerOpen(true);
} else {
console.warn('Retirement bot disabled on this page'); console.warn('Retirement bot disabled on this page');
return;
} }
}}}>
setRetireProps(props);
setDrawerPane('retire');
setDrawerOpen(true);
}
}}>
<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">
@ -517,7 +518,6 @@ const uiToolHandlers = useMemo(() => {
<> <>
<Route path="/signin-landing" element={<SignInLanding user={user} />}/> <Route path="/signin-landing" element={<SignInLanding user={user} />}/>
<Route path="/interest-inventory" element={<InterestInventory />} /> <Route path="/interest-inventory" element={<InterestInventory />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<UserProfile />} /> <Route path="/profile" element={<UserProfile />} />
<Route path="/planning" element={<PlanningLanding />} /> <Route path="/planning" element={<PlanningLanding />} />
<Route path="/career-explorer" element={<CareerExplorer />} /> <Route path="/career-explorer" element={<CareerExplorer />} />
@ -608,6 +608,7 @@ const uiToolHandlers = useMemo(() => {
pageContext={pageContext} pageContext={pageContext}
snapshot={chatSnapshot} snapshot={chatSnapshot}
uiToolHandlers={uiToolHandlers} uiToolHandlers={uiToolHandlers}
canShowRetireBot={canShowRetireBot}
/> />
{/* Session Handler (Optional) */} {/* Session Handler (Optional) */}

View File

@ -1,183 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button } from './ui/button.js';
const AISuggestedMilestones = ({ id, career, careerProfileId, authFetch, activeView, projectionData }) => {
const [suggestedMilestones, setSuggestedMilestones] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
const [aiLoading, setAiLoading] = useState(true); // Start loading state true initially
useEffect(() => {
const fetchAISuggestions = async () => {
if (!career || !careerProfileId || !Array.isArray(projectionData) || projectionData.length === 0) {
console.warn('Holding fetch, required data not yet available.');
setAiLoading(true);
return;
}
setAiLoading(true);
try {
const milestonesRes = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
const { milestones } = await milestonesRes.json();
const response = await authFetch('/api/premium/milestone/ai-suggestions', {
method: 'POST',
body: JSON.stringify({ career, careerProfileId, projectionData, existingMilestones: milestones }),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error('Failed to fetch AI suggestions');
const data = await response.json();
setSuggestedMilestones(data.suggestedMilestones.map((m) => ({
title: m.title,
date: m.date,
description: m.description,
progress: 0,
})));
} catch (error) {
console.error('Error fetching AI suggestions:', error);
} finally {
setAiLoading(false);
}
};
fetchAISuggestions();
}, [career, careerProfileId, projectionData, authFetch]);
const regenerateSuggestions = async () => {
setAiLoading(true);
try {
const milestonesRes = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
const { milestones } = await milestonesRes.json();
const previouslySuggestedMilestones = suggestedMilestones;
// Explicitly reduce projection data size by sampling every 6 months
const sampledProjectionData = projectionData.filter((_, i) => i % 6 === 0);
// Fetch career goals explicitly if defined (you'll implement this later; for now send empty or placeholder)
// const careerGoals = selectedCareer?.careerGoals || '';
const response = await authFetch('/api/premium/milestone/ai-suggestions', {
method: 'POST',
body: JSON.stringify({
career,
careerProfileId,
projectionData: sampledProjectionData,
existingMilestones: milestones,
previouslySuggestedMilestones,
regenerate: true,
//careerGoals, // explicitly included
}),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) throw new Error('Failed to fetch AI suggestions');
const data = await response.json();
setSuggestedMilestones(
data.suggestedMilestones.map((m) => ({
title: m.title,
date: m.date,
description: m.description,
progress: 0,
}))
);
} catch (error) {
console.error('Error regenerating AI suggestions:', error);
} finally {
setAiLoading(false);
}
};
const toggleSelect = (index) => {
setSelected((prev) =>
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
);
};
const confirmSelectedMilestones = async () => {
const milestonesToSend = selected.map((index) => {
const m = suggestedMilestones[index];
return {
title: m.title,
description: m.description,
date: m.date,
progress: m.progress,
milestone_type: activeView || 'Career',
career_profile_id: careerProfileId
};
});
try {
setLoading(true);
const res = await authFetch(`/api/premium/milestone`, {
method: 'POST',
body: JSON.stringify({ milestones: milestonesToSend }),
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error('Failed to save selected milestones');
setSelected([]);
window.location.reload();
} catch (error) {
console.error('Error saving selected milestones:', error);
} finally {
setLoading(false);
}
};
// Explicit spinner shown whenever aiLoading is true
if (aiLoading) {
return (
<div className="mt-4 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
<span className="ml-2 text-gray-600">Generating AI-suggested milestones...</span>
</div>
);
}
if (!suggestedMilestones.length) return null;
return (
<div className="mt-4 p-4 border rounded bg-gray-50 shadow">
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold mb-2">AI-Suggested Milestones</h4>
<Button
className="mb-2"
onClick={() => regenerateSuggestions()}
disabled={aiLoading}
variant="outline"
>
{aiLoading ? 'Regenerating...' : 'Regenerate Suggestions'}
</Button>
</div>
<ul className="space-y-1">
{suggestedMilestones.map((m, i) => (
<li key={i} className="flex items-center gap-2">
<input
type="checkbox"
className="rounded border-gray-300 text-indigo-600 shadow-sm"
checked={selected.includes(i)}
onChange={() => toggleSelect(i)}
/>
<span className="text-sm">{m.title} {m.date}</span>
</li>
))}
</ul>
<Button
className="mt-3"
onClick={confirmSelectedMilestones}
disabled={loading || selected.length === 0}
>
{loading ? 'Saving...' : 'Confirm Selected'}
</Button>
</div>
);
};
export default AISuggestedMilestones;

View File

@ -40,11 +40,11 @@ export default function BillingResult() {
</p> </p>
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link to="/premium-onboarding">Set up Premium Features</Link> <Link to="/premium-onboarding" className="block w-full">Set up Premium Features</Link>
</Button> </Button>
<Button variant="secondary" asChild className="w-full"> <Button variant="secondary" asChild className="w-full">
<Link to="/profile">Go to my account</Link> <Link to="/profile" className="block w-full">Go to my account</Link>
</Button> </Button>
</div> </div>
); );
@ -59,7 +59,7 @@ export default function BillingResult() {
<p className="text-gray-600">No changes were made to your account.</p> <p className="text-gray-600">No changes were made to your account.</p>
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link to="/paywall">Back to pricing</Link> <Link to="/paywall" className="block w-full">Back to pricing</Link>
</Button> </Button>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react'; import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation, createSearchParams } from 'react-router-dom';
import ChatCtx from '../contexts/ChatCtx.js'; import ChatCtx from '../contexts/ChatCtx.js';
import CareerSuggestions from './CareerSuggestions.js'; import CareerSuggestions from './CareerSuggestions.js';

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { AlertTriangle } from 'lucide-react';
import isAllOther from '../utils/isAllOther.js';
@ -55,6 +57,22 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50"> <div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50">
<div className="bg-white rounded-lg shadow-lg w-full max-w-5xl p-6 m-4 max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-lg w-full max-w-5xl p-6 m-4 max-h-[90vh] overflow-y-auto">
{isAllOther(career) && (
<div className="mb-4 flex items-start rounded-md border-l-4 border-yellow-500 bg-yellow-50 p-3">
<AlertTriangle className="mt-[2px] mr-2 h-5 w-5 text-yellow-600" />
<p className="text-sm text-yellow-800">
You've selected an "umbrella" field that covers a wide range of careersmany
people begin a career journey with a broad interest area and we don't want to discourage
anyone from taking this approach. It's just difficult to display detailed career data
and daytoday tasks for this allother occupatio.. Use it as a starting point,
keep exploring specializations, and we can show you richer insights as soon as you are able
to narrow it down to a more specific role. If you know this is the field for you, go ahead to
add it to your comparison list or move straight into Preparing & Upskilling for Your Career!
</p>
</div>
)}
{/* Title row */} {/* Title row */}
<div className="flex justify-between items-center mb-4 pb-2 border-b"> <div className="flex justify-between items-center mb-4 pb-2 border-b">
<div> <div>

View File

@ -56,8 +56,8 @@ export default function CareerProfileForm() {
career_name : d.career_name ?? '', career_name : d.career_name ?? '',
soc_code : d.soc_code ?? '', soc_code : d.soc_code ?? '',
status : d.status ?? 'current', status : d.status ?? 'current',
start_date : d.start_date ?? '', start_date : (d.start_date || '').slice(0, 10), // ← trim
retirement_start_date : d.retirement_start_date ?? '', retirement_start_date : (d.retirement_start_date || '').slice(0, 10),
college_enrollment_status : d.college_enrollment_status ?? '', college_enrollment_status : d.college_enrollment_status ?? '',
career_goals : d.career_goals ?? '', career_goals : d.career_goals ?? '',
desired_retirement_income_monthly : desired_retirement_income_monthly :
@ -68,7 +68,7 @@ export default function CareerProfileForm() {
/* ---------- 4. save ---------- */ /* ---------- 4. save ---------- */
async function save() { async function save() {
if (!form.soc_code) { if (!careerLocked && !form.soc_code) {
alert('Please pick a valid career from the list first.'); alert('Please pick a valid career from the list first.');
return; return;
} }
@ -78,6 +78,8 @@ export default function CareerProfileForm() {
headers : { 'Content-Type': 'application/json' }, headers : { 'Content-Type': 'application/json' },
body : JSON.stringify({ body : JSON.stringify({
...form, ...form,
start_date : form.start_date?.slice(0, 10) || null,
retirement_start_date : form.retirement_start_date?.slice(0, 10) || null,
id: id === 'new' ? undefined : id // upsert id: id === 'new' ? undefined : id // upsert
}) })
}); });

View File

@ -693,7 +693,7 @@ useEffect(() => {
(async function init () { (async function init () {
/* 1 ▸ get every row the user owns */ /* 1 ▸ get every row the user owns */
const r = await authFetch('api/premium/career-profile/all'); const r = await authFetch('/api/premium/career-profile/all');
if (!r?.ok || cancelled) return; if (!r?.ok || cancelled) return;
const { careerProfiles=[] } = await r.json(); const { careerProfiles=[] } = await r.json();
setExistingCareerProfiles(careerProfiles); setExistingCareerProfiles(careerProfiles);
@ -777,7 +777,7 @@ useEffect(() => {
const refetchScenario = useCallback(async () => { const refetchScenario = useCallback(async () => {
if (!careerProfileId) return; if (!careerProfileId) return;
const r = await authFetch('api/premium/career-profile/${careerProfileId}'); const r = await authFetch('/api/premium/career-profile/${careerProfileId}');
if (r.ok) setScenarioRow(await r.json()); if (r.ok) setScenarioRow(await r.json());
}, [careerProfileId]); }, [careerProfileId]);
@ -835,7 +835,7 @@ try {
if (err.response && err.response.status === 404) { if (err.response && err.response.status === 404) {
try { try {
// Call GPT via server3 // Call GPT via server3
const aiRes = await axios.post('api/public/ai-risk-analysis', { const aiRes = await axios.post('/api/public/ai-risk-analysis', {
socCode, socCode,
careerName, careerName,
jobDescription: description, jobDescription: description,
@ -869,7 +869,7 @@ try {
} }
// 3) Store in server2 // 3) Store in server2
await axios.post('api/ai-risk', storePayload); await axios.post('/api/ai-risk', storePayload);
// Construct final object for usage here // Construct final object for usage here
aiRisk = { aiRisk = {

View File

@ -1,23 +1,63 @@
// src/components/CareerSuggestions.js
import React from 'react'; import React from 'react';
import { Button } from './ui/button.js';
export function CareerSuggestions({ /**
* Grid of career buttons.
*
* @param {Object[]} careerSuggestions array of career objects
* @param {(career) => void} onCareerClick callback when a button is clicked
*/
export default function CareerSuggestions({
careerSuggestions = [], careerSuggestions = [],
onCareerClick, onCareerClick,
}) { }) {
return ( return (
<div className="career-suggestions-grid"> <div
{careerSuggestions.map((career) => ( /* similar to: grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap:10px; padding:10px */
<button className="
key={career.code} grid w-full
className={`career-button ${career.limitedData ? 'limited-data' : ''}`} gap-[10px] p-[10px]
onClick={() => onCareerClick(career)} [grid-template-columns:repeat(auto-fit,minmax(12rem,1fr))]
"
> >
{career.title} {careerSuggestions.map((career) => {
{career.limitedData && <span className="warning-icon"> </span>} const isLimited = career.limitedData;
</button>
))} return (
<Button
key={career.code}
onClick={() => onCareerClick(career)}
/* Tailwind recreation of your old CSS ------------------------ */
variant="outline"
className={`
flex h-full w-full items-center justify-center text-center
whitespace-normal break-words
font-bold text-[14px] leading-snug
px-[10px] py-[8px] rounded-[3px]
transition-colors
/* default style = #007bff -> darker on hover */
bg-[#007bff] hover:bg-[#0056b3] text-white
/* limited-data override */
${isLimited && `
!bg-blue-300 !hover:bg-blue-200 /* light blue fill */
!text-white
border-2 !border-amber-500 /* orange border */
`}
`}
>
<span>{career.title}</span>
{isLimited && (
<span className="ml-[6px] text-[14px] font-bold text-yellow-300">
</span>
)}
</Button>
);
})}
</div> </div>
); );
} }
export default CareerSuggestions;

View File

@ -22,6 +22,7 @@ export default function ChatDrawer({
pane: controlledPane = 'support', pane: controlledPane = 'support',
setPane: setControlledPane, setPane: setControlledPane,
retireProps = null, // { scenario, financialProfile, … } retireProps = null, // { scenario, financialProfile, … }
canShowRetireBot
}) { }) {
/* ─────────────────────────── internal / fallback state ───────── */ /* ─────────────────────────── internal / fallback state ───────── */
const [openLocal, setOpenLocal] = useState(false); const [openLocal, setOpenLocal] = useState(false);
@ -59,6 +60,13 @@ export default function ChatDrawer({
return [...prev, { role: 'assistant', content: chunk }]; return [...prev, { role: 'assistant', content: chunk }];
}); });
useEffect(() => {
if (!canShowRetireBot && pane === 'retire') {
setPane('support');
}
}, [canShowRetireBot, pane, setPane]);
/* ───────────────────────── send support-bot prompt ───────────── */ /* ───────────────────────── send support-bot prompt ───────────── */
async function sendPrompt() { async function sendPrompt() {
const text = prompt.trim(); const text = prompt.trim();
@ -125,49 +133,63 @@ export default function ChatDrawer({
} }
}; };
/* ──────────────────────────── UI ─────────────────────────────── */ /* ---------- render ---------- */
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
{/* floating FAB */} {/* floating action button */}
<SheetTrigger asChild> <SheetTrigger asChild>
<button <button
type="button"
aria-label="Open chat" aria-label="Open chat"
className="fixed bottom-6 right-6 z-50 rounded-full bg-blue-600 p-3 text-white shadow-lg hover:bg-blue-700" className="fixed bottom-6 right-6 z-40 flex h-14 w-14
onClick={() => onOpenChange(!open)} items-center justify-center rounded-full
bg-blue-600 text-white shadow-lg
hover:bg-blue-700 focus:outline-none"
/* -------- explicitly open Support pane -------- */
onClick={() => {
setPane('support');
onOpenChange(true); // < force the controlled state
}}
> >
<MessageCircle size={24} /> <MessageCircle className="h-6 w-6" />
</button> </button>
</SheetTrigger> </SheetTrigger>
{/* side-drawer */} {/* side drawer */}
<SheetContent <SheetContent
side="right" side="right"
className="flex max-h-screen w-[380px] flex-col px-0 md:w-[420px]" className="flex h-full w-[370px] flex-col p-0 md:w-[420px]"
> >
{/* header tab switch */} {/* header (tabs only if retirement bot is allowed) */}
<div className="flex border-b text-sm font-semibold"> <div className="flex border-b">
{[
{ id: 'support', label: 'Aptiva Support' },
{ id: 'retire', label: 'Retirement Helper' },
].map((tab) => (
<button <button
key={tab.id} className={
onClick={() => setPane(tab.id)} pane === 'support'
className={cn( ? 'flex-1 px-4 py-3 text-sm font-semibold border-b-2 border-blue-600'
'flex-1 py-2', : 'flex-1 px-4 py-3 text-sm text-gray-500 hover:bg-gray-50'
pane === tab.id }
? 'border-b-2 border-blue-600' onClick={() => setPane('support')}
: 'text-gray-500 hover:text-gray-700'
)}
> >
{tab.label} Aptiva Support
</button> </button>
))}
{canShowRetireBot && (
<button
className={
pane === 'retire'
? 'flex-1 px-4 py-3 text-sm font-semibold border-b-2 border-blue-600'
: 'flex-1 px-4 py-3 text-sm text-gray-500 hover:bg-gray-50'
}
onClick={() => setPane('retire')}
>
Retirement Helper
</button>
)}
</div> </div>
{/* body conditional panes */} {/* body */}
{pane === 'support' ? ( {pane === 'support' ? (
/* ─────────── Support bot ─────────── */ /* ── Support bot pane ── */
<> <>
<div <div
ref={listRef} ref={listRef}
@ -175,13 +197,9 @@ export default function ChatDrawer({
> >
{messages.map((m, i) => ( {messages.map((m, i) => (
<div <div
/* eslint-disable react/no-array-index-key */
key={i} key={i}
/* eslint-enable react/no-array-index-key */
className={ className={
m.role === 'user' m.role === 'user' ? 'text-right' : 'text-left text-gray-800'
? 'text-right'
: 'text-left text-gray-800'
} }
> >
{m.content} {m.content}
@ -200,8 +218,13 @@ export default function ChatDrawer({
<Input <Input
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything…" placeholder="Ask me anything…"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendPrompt();
}
}}
className="flex-1" className="flex-1"
/> />
<Button type="submit" disabled={!prompt.trim()}> <Button type="submit" disabled={!prompt.trim()}>
@ -211,9 +234,10 @@ export default function ChatDrawer({
</div> </div>
</> </>
) : retireProps ? ( ) : retireProps ? (
/* ───────── Retirement helper ─────── */ /* ── Retirement helper pane ── */
<RetirementChatBar {...retireProps} /> <RetirementChatBar {...retireProps} />
) : ( ) : (
/* failsafe (retire tab opened before selecting a scenario) */
<div className="m-auto px-6 text-center text-sm text-gray-400"> <div className="m-auto px-6 text-center text-sm text-gray-400">
Select a scenario in&nbsp; Select a scenario in&nbsp;
<strong>Retirement Planner</strong> <strong>Retirement Planner</strong>

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import moment from 'moment/moment.js';
/** ----------------------------------------------------------- /** -----------------------------------------------------------
@ -52,6 +53,9 @@ export default function CollegeProfileForm() {
const [ipeds, setIpeds] = useState([]); const [ipeds, setIpeds] = useState([]);
const [schoolValid, setSchoolValid] = useState(true); const [schoolValid, setSchoolValid] = useState(true);
const [programValid, setProgramValid] = useState(true); const [programValid, setProgramValid] = useState(true);
const [autoGradDate, setAutoGradDate] = useState('');
const [graduationTouched, setGraduationTouched] = useState(false);
const [programLengthTouched, setProgramLengthTouched] = useState(false);
const schoolData = cipRows; const schoolData = cipRows;
@ -88,6 +92,7 @@ const handleFieldChange = (e) => {
].includes(name) ].includes(name)
) { ) {
draft[name] = value === '' ? '' : parseFloat(value); draft[name] = value === '' ? '' : parseFloat(value);
if (name === 'program_length') setProgramLengthTouched(true);
} else { } else {
draft[name] = value; draft[name] = value;
} }
@ -178,7 +183,6 @@ useEffect(()=>{
setTypes([...new Set(t)]); setTypes([...new Set(t)]);
},[form.selected_school, form.selected_program, cipRows]); },[form.selected_school, form.selected_program, cipRows]);
useEffect(() => { useEffect(() => {
if (!ipeds.length) return; if (!ipeds.length) return;
if (!form.selected_school || if (!form.selected_school ||
@ -235,6 +239,73 @@ const chosenTuition = manualTuition.trim() === ''
? autoTuition ? autoTuition
: parseFloat(manualTuition); : parseFloat(manualTuition);
/*
Autocalculate PROGRAM LENGTH when the user hasnt typed in
the field themselves. Triggers when hours / CHPY change.
*/
useEffect(() => {
if (programLengthTouched) return; // user override
const chpy = parseFloat(form.credit_hours_per_year);
if (!chpy || chpy <= 0) return;
/* 1  figure out how many credits remain.
If the plan doesnt have credit_hours_required saved
we fall back to a common default for the degree type. */
let creditsNeeded = parseFloat(form.credit_hours_required);
if (!creditsNeeded) {
switch (form.program_type) {
case "Associate's Degree": creditsNeeded = 60; break;
case "Master's Degree": creditsNeeded = 30; break;
case "Doctoral Degree": creditsNeeded = 60; break;
default: /* Bachelor et al. */ creditsNeeded = 120;
}
}
creditsNeeded -= parseFloat(form.hours_completed || 0);
if (creditsNeeded <= 0) return;
/* 2  years = credits / CHPY → one decimal place */
const years = Math.ceil((creditsNeeded / chpy) * 10) / 10;
if (years !== form.program_length) {
setForm(prev => ({ ...prev, program_length: years }));
}
}, [
form.credit_hours_required,
form.credit_hours_per_year,
form.hours_completed,
form.program_type,
programLengthTouched
]);
useEffect(() => {
if (graduationTouched) return;
const years = parseFloat(form.program_length);
if (!years || years <= 0) return;
const start = form.enrollment_date
? moment(form.enrollment_date)
: moment();
const iso = start.add(years, 'years')
.startOf('month')
.format('YYYY-MM-DD');
setAutoGradDate(iso);
setForm(prev => ({ ...prev, expected_graduation: iso }));
}, [
form.program_length,
form.credit_hours_required,
form.credit_hours_per_year,
form.hours_completed,
form.credit_hours_per_year,
form.enrollment_date,
graduationTouched
]);
return ( return (
<div className="max-w-md mx-auto p-6 space-y-4"> <div className="max-w-md mx-auto p-6 space-y-4">
<h2 className="text-2xl font-semibold"> <h2 className="text-2xl font-semibold">
@ -378,6 +449,36 @@ return (
/> />
</div> </div>
{/* ──────────────── Dates ──────────────── */}
{form.college_enrollment_status === 'prospective_student' && (
<div className="space-y-1">
<label className="block font-medium">Anticipated Start Date</label>
<input
type="date"
name="enrollment_date"
value={form.enrollment_date || ''}
onChange={handleFieldChange}
className="w-full border rounded p-2"
/>
</div>
)}
<div className="space-y-1">
<label className="block font-medium">Expected Graduation Date</label>
<input
type="date"
name="expected_graduation"
value={form.expected_graduation || autoGradDate}
onChange={e => {
handleFieldChange(e);
setGraduationTouched(true);
}}
className="w-full border rounded p-2"
required
/>
</div>
{/* 7 │ Tuition & aid */} {/* 7 │ Tuition & aid */}
<div className="space-y-1"> <div className="space-y-1">
<label className="block font-medium">Yearly Tuition</label> <label className="block font-medium">Yearly Tuition</label>

View File

@ -1,788 +0,0 @@
import axios from 'axios';
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { CareerSuggestions } from './CareerSuggestions.js';
import PopoutPanel from './PopoutPanel.js';
import CareerSearch from './CareerSearch.js'; // <--- Import your new search
import Chatbot from './Chatbot.js';
import "../styles/legacy/Dashboard.legacy.css";
import { Bar } from 'react-chartjs-2';
import { fetchSchools } from '../utils/apiUtils.js';
const STATES = [
{ name: 'Alabama', code: 'AL' },
{ name: 'Alaska', code: 'AK' },
{ name: 'Arizona', code: 'AZ' },
{ name: 'Arkansas', code: 'AR' },
{ name: 'California', code: 'CA' },
{ name: 'Colorado', code: 'CO' },
{ name: 'Connecticut', code: 'CT' },
{ name: 'Delaware', code: 'DE' },
{ name: 'District of Columbia', code: 'DC' },
{ name: 'Florida', code: 'FL' },
{ name: 'Georgia', code: 'GA' },
{ name: 'Hawaii', code: 'HI' },
{ name: 'Idaho', code: 'ID' },
{ name: 'Illinois', code: 'IL' },
{ name: 'Indiana', code: 'IN' },
{ name: 'Iowa', code: 'IA' },
{ name: 'Kansas', code: 'KS' },
{ name: 'Kentucky', code: 'KY' },
{ name: 'Louisiana', code: 'LA' },
{ name: 'Maine', code: 'ME' },
{ name: 'Maryland', code: 'MD' },
{ name: 'Massachusetts', code: 'MA' },
{ name: 'Michigan', code: 'MI' },
{ name: 'Minnesota', code: 'MN' },
{ name: 'Mississippi', code: 'MS' },
{ name: 'Missouri', code: 'MO' },
{ name: 'Montana', code: 'MT' },
{ name: 'Nebraska', code: 'NE' },
{ name: 'Nevada', code: 'NV' },
{ name: 'New Hampshire', code: 'NH' },
{ name: 'New Jersey', code: 'NJ' },
{ name: 'New Mexico', code: 'NM' },
{ name: 'New York', code: 'NY' },
{ name: 'North Carolina', code: 'NC' },
{ name: 'North Dakota', code: 'ND' },
{ name: 'Ohio', code: 'OH' },
{ name: 'Oklahoma', code: 'OK' },
{ name: 'Oregon', code: 'OR' },
{ name: 'Pennsylvania', code: 'PA' },
{ name: 'Rhode Island', code: 'RI' },
{ name: 'South Carolina', code: 'SC' },
{ name: 'South Dakota', code: 'SD' },
{ name: 'Tennessee', code: 'TN' },
{ name: 'Texas', code: 'TX' },
{ name: 'Utah', code: 'UT' },
{ name: 'Vermont', code: 'VT' },
{ name: 'Virginia', code: 'VA' },
{ name: 'Washington', code: 'WA' },
{ name: 'West Virginia', code: 'WV' },
{ name: 'Wisconsin', code: 'WI' },
{ name: 'Wyoming', code: 'WY' },
];
// 2) Helper to convert state code => full name
function getFullStateName(code) {
const found = STATES.find((s) => s.code === code?.toUpperCase());
return found ? found.name : '';
}
// Haversine formula helper
function haversineDistance(lat1, lon1, lat2, lon2) {
// approximate radius of earth in miles
const R = 3959;
const toRad = (val) => (val * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // in miles
}
// **Added**: A small helper to geocode the user's ZIP on the client side.
async function clientGeocodeZip(zip) {
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
zip
)}&key=${apiKey}`;
const resp = await axios.get(url);
if (
resp.data.status === 'OK' &&
resp.data.results &&
resp.data.results.length > 0
) {
return resp.data.results[0].geometry.location; // { lat, lng }
}
throw new Error('Geocoding failed.');
}
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
function Dashboard() {
const location = useLocation();
const navigate = useNavigate();
// ============= Existing States =============
const [careerSuggestions, setCareerSuggestions] = useState([]);
const [careerDetails, setCareerDetails] = useState(null);
const [riaSecScores, setRiaSecScores] = useState([]);
const [selectedCareer, setSelectedCareer] = useState(null);
const [schools, setSchools] = useState([]);
const [salaryData, setSalaryData] = useState([]);
const [economicProjections, setEconomicProjections] = useState(null);
const [tuitionData, setTuitionData] = useState(null);
// Overall Dashboard loading
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState(null);
const [userState, setUserState] = useState(null);
const [areaTitle, setAreaTitle] = useState(null);
const [userZipcode, setUserZipcode] = useState(null);
const [riaSecDescriptions, setRiaSecDescriptions] = useState([]);
const [selectedJobZone, setSelectedJobZone] = useState('');
const [careersWithJobZone, setCareersWithJobZone] = useState([]);
const [selectedFit, setSelectedFit] = useState('');
const [results, setResults] = useState([]);
const [chatbotContext, setChatbotContext] = useState({});
// Show session expired modal
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false);
// ============= NEW State =============
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
// We'll treat "loading" as "loadingSuggestions"
const loadingSuggestions = loading;
const popoutVisible = !!selectedCareer;
// ============= Auth & URL Setup =============
// AUTH fetch
const authFetch = async (url, options = {}, onUnauthorized) => {
const token = localStorage.getItem('token');
if (!token) {
console.log('Token is missing, triggering session expired modal.');
if (typeof onUnauthorized === 'function') onUnauthorized();
return null;
}
const finalOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `Bearer ${token}`,
},
};
try {
const res = await fetch(url, finalOptions);
console.log('Response Status:', res.status);
if (res.status === 401 || res.status === 403) {
console.log('Session expired, triggering session expired modal.');
if (typeof onUnauthorized === 'function') onUnauthorized();
return null;
}
return res;
} catch (err) {
console.error('Fetch error:', err);
if (typeof onUnauthorized === 'function') onUnauthorized();
return null;
}
};
// ============= Fetch user profile =============
const fetchUserProfile = async () => {
const res = await authFetch('api/user-profile');
if (!res) return;
if (res.ok) {
const profileData = await res.json();
setUserState(profileData.state);
setAreaTitle(profileData.area.trim() || '');
setUserZipcode(profileData.zipcode);
// Store entire userProfile if needed
setUserProfile(profileData);
} else {
console.error('Failed to fetch user profile');
}
};
// We'll store the userProfile for reference
const [userProfile, setUserProfile] = useState(null);
// ============= Lifecycle: Load Profile =============
useEffect(() => {
fetchUserProfile();
}, []); // load once
// ============= jobZone & Fit Setup =============
const jobZoneLabels = {
'1': 'Little or No Preparation',
'2': 'Some Preparation Needed',
'3': 'Medium Preparation Needed',
'4': 'Considerable Preparation Needed',
'5': 'Extensive Preparation Needed',
};
const fitLabels = {
Best: 'Best - Very Strong Match',
Great: 'Great - Strong Match',
Good: 'Good - Less Strong Match',
};
// ============= "Mimic" InterestInventory submission if user has 60 answers =============
const mimicInterestInventorySubmission = async (answers) => {
try {
setLoading(true);
setProgress(0);
const response = await authFetch('api/onet/submit_answers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
});
if (!response || !response.ok) {
throw new Error('Failed to submit stored answers');
}
const data = await response.json();
const { careers, riaSecScores } = data;
// This sets location.state, so the next effect sees it as if we came from InterestInventory
navigate('/dashboard', {
state: { careerSuggestions: careers, riaSecScores },
});
} catch (err) {
console.error('Error mimicking submission:', err);
alert('We could not load your saved answers. Please retake the Interest Inventory.');
navigate('/interest-inventory');
} finally {
setLoading(false);
}
};
// ============= On Page Load: get careerSuggestions from location.state, or mimic =============
useEffect(() => {
// If we have location.state from InterestInventory, proceed as normal
if (location.state) {
let descriptions = [];
const { careerSuggestions: suggestions, riaSecScores: scores } = location.state || {};
descriptions = (scores || []).map((score) => score.description || 'No description available.');
setCareerSuggestions(suggestions || []);
setRiaSecScores(scores || []);
setRiaSecDescriptions(descriptions);
} else {
// We came here directly; wait for userProfile, then check answers
if (!userProfile) return; // wait until userProfile is loaded
const storedAnswers = userProfile.interest_inventory_answers;
if (storedAnswers && storedAnswers.length === 60) {
// Mimic the submission so we get suggestions
mimicInterestInventorySubmission(storedAnswers);
} else {
alert(
'We need your Interest Inventory answers to generate career suggestions. Redirecting...'
);
navigate('/interest-inventory');
}
}
}, [location.state, navigate, userProfile]);
// ============= jobZone fetch =============
useEffect(() => {
const fetchJobZones = async () => {
if (careerSuggestions.length === 0) return;
const socCodes = careerSuggestions.map((career) => career.code);
try {
const response = await axios.post('api/job-zones', { socCodes });
const jobZoneData = response.data;
const updatedCareers = careerSuggestions.map((career) => ({
...career,
job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null,
}));
setCareersWithJobZone(updatedCareers);
} catch (error) {
console.error('Error fetching job zone information:', error);
}
};
fetchJobZones();
}, [careerSuggestions]);
// ============= Filter by job zone, fit =============
const filteredCareers = useMemo(() => {
return careersWithJobZone.filter((career) => {
const jobZoneMatches = selectedJobZone
? career.job_zone !== null &&
career.job_zone !== undefined &&
typeof career.job_zone === 'number' &&
Number(career.job_zone) === Number(selectedJobZone)
: true;
const fitMatches = selectedFit ? career.fit === selectedFit : true;
return jobZoneMatches && fitMatches;
});
}, [careersWithJobZone, selectedJobZone, selectedFit]);
// ============= Merge data into chatbot context =============
const updateChatbotContext = (updatedData) => {
setChatbotContext((prevContext) => {
const mergedContext = {
...prevContext,
...Object.keys(updatedData).reduce((acc, key) => {
if (updatedData[key] !== undefined && updatedData[key] !== null) {
acc[key] = updatedData[key];
}
return acc;
}, {}),
};
return mergedContext;
});
};
useEffect(() => {
if (
careerSuggestions.length > 0 &&
riaSecScores.length > 0 &&
userState !== null &&
areaTitle !== null &&
userZipcode !== null
) {
const newChatbotContext = {
careerSuggestions: [...careersWithJobZone],
riaSecScores: [...riaSecScores],
userState: userState || '',
areaTitle: areaTitle || '',
userZipcode: userZipcode || '',
};
setChatbotContext(newChatbotContext);
}
}, [careerSuggestions, riaSecScores, userState, areaTitle, userZipcode, careersWithJobZone]);
// ============= handleCareerClick =============
const handleCareerClick = useCallback(
async (career) => {
console.log('[handleCareerClick] career =>', career);
const socCode = career.code;
console.log('[handleCareerClick] career.code =>', socCode);
setSelectedCareer(career);
setLoading(true);
setError(null);
setCareerDetails({});
setSchools([]);
setSalaryData([]);
setEconomicProjections({});
setTuitionData([]);
if (!socCode) {
console.error('SOC Code is missing');
setError('SOC Code is missing');
return;
}
try {
// CIP fetch
const cipResponse = await fetch(`api/cip/${socCode}`);
if (!cipResponse.ok) throw new Error('Failed to fetch CIP Code');
const { cipCode } = await cipResponse.json();
const cleanedCipCode = cipCode.replace('.', '').slice(0, 4);
// Job details
const jobDetailsResponse = await fetch(`api/onet/career-description/${socCode}`);
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
const { description, tasks } = await jobDetailsResponse.json();
// Salary
let salaryResponse;
try {
salaryResponse = await axios.get('api/salary', {
params: { socCode: socCode.split('.')[0], area: areaTitle },
});
} catch (error) {
salaryResponse = { data: {} };
}
const fullName = getFullStateName(userState);
// Economic
let economicResponse;
try {
economicResponse = await axios.get(`api/projections/${socCode.split('.')[0]}`, {
params: { state: fullName }, // e.g. "Kentucky"
});
} catch (error) {
economicResponse = { data: {} };
}
// Tuition
let tuitionResponse;
try {
tuitionResponse = await axios.get('api/tuition', {
params: { cipCode: cleanedCipCode, state: userState },
});
} catch (error) {
tuitionResponse = { data: {} };
}
// ** FETCH SCHOOLS NORMALLY **
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
// ** 1) Geocode user zip once on the client **
let userLat = null;
let userLng = null;
if (userZipcode) {
try {
const geocodeResult = await clientGeocodeZip(userZipcode);
userLat = geocodeResult.lat;
userLng = geocodeResult.lng;
} catch (err) {
console.warn('Unable to geocode user ZIP, distances will be N/A.');
}
}
// ** 2) Compute Haversine distance locally for each school **
const schoolsWithDistance = filteredSchools.map((sch) => {
// only if we have lat/lon for both user + school
const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null;
const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null;
if (userLat && userLng && lat2 && lon2) {
const distMiles = haversineDistance(userLat, userLng, lat2, lon2);
return {
...sch,
distance: distMiles.toFixed(1) + ' mi',
duration: 'N/A',
};
} else {
return {
...sch,
distance: 'N/A',
duration: 'N/A',
};
}
});
// Build salary array
const sData = salaryResponse.data || {};
const salaryDataPoints =
sData && Object.keys(sData).length > 0
? [
{
percentile: '10th Percentile',
regionalSalary: parseInt(sData.regional?.regional_PCT10, 10) || 0,
nationalSalary: parseInt(sData.national?.national_PCT10, 10) || 0,
},
{
percentile: '25th Percentile',
regionalSalary: parseInt(sData.regional?.regional_PCT25, 10) || 0,
nationalSalary: parseInt(sData.national?.national_PCT25, 10) || 0,
},
{
percentile: 'Median',
regionalSalary: parseInt(sData.regional?.regional_MEDIAN, 10) || 0,
nationalSalary: parseInt(sData.national?.national_MEDIAN, 10) || 0,
},
{
percentile: '75th Percentile',
regionalSalary: parseInt(sData.regional?.regional_PCT75, 10) || 0,
nationalSalary: parseInt(sData.national?.national_PCT75, 10) || 0,
},
{
percentile: '90th Percentile',
regionalSalary: parseInt(sData.regional?.regional_PCT90, 10) || 0,
nationalSalary: parseInt(sData.national?.national_PCT90, 10) || 0,
},
]
: [];
// Build final details
const updatedCareerDetails = {
...career,
jobDescription: description,
tasks: tasks,
economicProjections: economicResponse.data || {},
salaryData: salaryDataPoints,
schools: schoolsWithDistance,
tuitionData: tuitionResponse.data || [],
};
setCareerDetails(updatedCareerDetails);
updateChatbotContext({ careerDetails: updatedCareerDetails });
} catch (error) {
console.error('Error processing career click:', error.message);
setError('Failed to load data');
} finally {
setLoading(false);
}
},
[userState, areaTitle, userZipcode, updateChatbotContext]
);
// ============= Let typed careers open PopoutPanel =============
const handleCareerFromSearch = useCallback(
(obj) => {
const adapted = {
code: obj.soc_code,
title: obj.title,
cipCode: obj.cip_code,
};
console.log('[Dashboard -> handleCareerFromSearch] adapted =>', adapted);
handleCareerClick(adapted);
},
[handleCareerClick]
);
useEffect(() => {
if (pendingCareerForModal) {
console.log('[useEffect] pendingCareerForModal =>', pendingCareerForModal);
handleCareerFromSearch(pendingCareerForModal);
setPendingCareerForModal(null);
}
}, [pendingCareerForModal, handleCareerFromSearch]);
// ============= RIASEC Chart Data =============
const chartData = {
labels: riaSecScores.map((score) => score.area),
datasets: [
{
label: 'RIASEC Scores',
data: riaSecScores.map((score) => score.score),
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1,
},
],
};
// ============= Hide the spinner if popout is open =============
const renderLoadingOverlay = () => {
if (!loadingSuggestions || popoutVisible) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">
<div className="rounded bg-white p-6 shadow-lg">
<div className="mb-2 w-full max-w-md rounded bg-gray-200">
<div
className="h-2 rounded bg-blue-500 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<p className="mt-1 text-center text-sm text-gray-600">
{progress}% Loading Career Suggestions...
</p>
</div>
</div>
);
};
// ============= Popout Panel Setup =============
const memoizedPopoutPanel = useMemo(() => {
return (
<PopoutPanel
isVisible={!!selectedCareer}
data={careerDetails}
schools={schools}
salaryData={salaryData}
economicProjections={economicProjections}
tuitionData={tuitionData}
closePanel={() => setSelectedCareer(null)}
loading={loading}
error={error}
userState={userState}
results={results}
updateChatbotContext={updateChatbotContext}
/>
);
}, [
selectedCareer,
careerDetails,
schools,
salaryData,
economicProjections,
tuitionData,
loading,
error,
userState,
results,
updateChatbotContext,
]);
return (
<div className="dashboard">
{showSessionExpiredModal && (
<div className="modal-overlay">
<div className="modal">
<h3>Session Expired</h3>
<p>Your session has expired or is invalid.</p>
<div className="modal-actions">
<button
className="confirm-btn"
onClick={() => setShowSessionExpiredModal(false)}
>
Stay Signed In
</button>
<button
className="confirm-btn"
onClick={() => {
localStorage.removeItem('token');
localStorage.removeItem('id');
setShowSessionExpiredModal(false);
navigate('/signin');
}}
>
Sign In Again
</button>
</div>
</div>
</div>
)}
{renderLoadingOverlay()}
<div className="dashboard-content">
{/* ====== 1) The new CareerSearch bar ====== */}
{/* Existing filters + suggestions */}
<div className="career-suggestions-container">
<div
className="career-suggestions-header"
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '15px',
justifyContent: 'center',
gap: '15px',
}}
>
<label>
Preparation Level:
<select
value={selectedJobZone}
onChange={(e) => setSelectedJobZone(Number(e.target.value))}
style={{ marginLeft: '5px', padding: '2px', width: '200px' }}
>
<option value="">All Preparation Levels</option>
{Object.entries(jobZoneLabels).map(([zone, label]) => (
<option key={zone} value={zone}>
{label}
</option>
))}
</select>
</label>
<label>
Fit:
<select
value={selectedFit}
onChange={(e) => setSelectedFit(e.target.value)}
style={{ marginLeft: '5px', padding: '2px', width: '150px' }}
>
<option value="">All Fit Levels</option>
{Object.entries(fitLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<div style={{ marginLeft: 'auto' }}>
<CareerSearch
onCareerSelected={(careerObj) => {
console.log('[Dashboard] onCareerSelected =>', careerObj);
// Set the "pendingCareerForModal" so our useEffect fires
setPendingCareerForModal(careerObj);
}}
/>
</div>
</div>
<CareerSuggestions
careerSuggestions={filteredCareers}
onCareerClick={handleCareerClick}
setLoading={setLoading}
setProgress={setProgress}
userState={userState}
areaTitle={areaTitle}
/>
</div>
{/* RIASEC Container */}
<div className="riasec-container">
<div className="riasec-scores">
<h2>RIASEC Scores</h2>
<Bar data={chartData} />
</div>
<div className="riasec-descriptions">
<h3>RIASEC Personality Descriptions</h3>
{riaSecDescriptions.length > 0 ? (
<ul>
{riaSecDescriptions.map((desc, index) => (
<li key={index}>
<strong>{riaSecScores[index]?.area}:</strong> {desc}
</li>
))}
</ul>
) : (
<p>Loading descriptions...</p>
)}
</div>
</div>
</div>
{/* The PopoutPanel */}
{memoizedPopoutPanel}
{/* Chatbot */}
<div className="chatbot-widget">
{careerSuggestions.length > 0 ? (
<Chatbot context={chatbotContext} />
) : (
<p>Loading Chatbot...</p>
)}
</div>
<div
className="data-source-acknowledgment"
style={{
marginTop: '20px',
padding: '10px',
borderTop: '1px solid #ccc',
fontSize: '12px',
color: '#666',
textAlign: 'center',
}}
>
<p>
Career results and RIASEC scores are provided by
<a
href="https://www.onetcenter.org"
target="_blank"
rel="noopener noreferrer"
>
{' '}
O*Net
</a>
, in conjunction with the
<a
href="https://www.bls.gov"
target="_blank"
rel="noopener noreferrer"
>
{' '}
Bureau of Labor Statistics
</a>
, and the
<a
href="https://nces.ed.gov"
target="_blank"
rel="noopener noreferrer"
>
{' '}
National Center for Education Statistics (NCES)
</a>
.
</p>
</div>
</div>
);
}
export default Dashboard;

View File

@ -1,57 +0,0 @@
// src/components/EditableCareerGoals.js
import React, { useState } from 'react';
import { Button } from './ui/button.js';
import { Pencil, Save } from 'lucide-react';
import authFetch from '../utils/authFetch.js';
export default function EditableCareerGoals({ initialGoals='', careerProfileId, onSaved }) {
const [editing , setEditing ] = useState(false);
const [draftText, setDraftText] = useState(initialGoals);
const [saving , setSaving ] = useState(false);
async function save() {
setSaving(true);
const res = await authFetch(`/api/premium/career-profile/${careerProfileId}/goals`, {
method : 'PUT',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify({ career_goals: draftText })
});
if (res.ok) {
onSaved(draftText);
setEditing(false);
}
setSaving(false);
}
return (
<div className="bg-white p-4 rounded shadow">
<div className="flex items-start justify-between">
<h3 className="text-lg font-semibold">Your Career Goals</h3>
{!editing && (
<Button size="icon" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="w-4 h-4" />
</Button>
)}
</div>
{editing ? (
<>
<textarea
value={draftText}
onChange={e => setDraftText(e.target.value)}
rows={4}
className="w-full border rounded p-2 mt-2"
placeholder="Describe your short- and long-term goals…"
/>
<Button onClick={save} disabled={saving} className="mt-2">
{saving ? 'Saving…' : <><Save className="w-4 h-4 mr-1" /> Save</>}
</Button>
</>
) : (
<p className="mt-2 whitespace-pre-wrap text-gray-700">
{initialGoals || <span className="italic text-gray-400">No goals entered yet.</span>}
</p>
)}
</div>
);
}

View File

@ -1,51 +0,0 @@
import React, { useState, useEffect } from 'react';
import { fetchSchools } from '../utils/apiUtils.js';
function EducationalPrograms({ cipCode, userState }) {
const [schools, setSchools] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const loadSchools = async () => {
if (!cipCode || !userState) {
setError('CIP Code or user state is missing');
return;
}
try {
const filteredSchools = await fetchSchools(cipCode, userState);
setSchools(filteredSchools);
} catch (error) {
console.error('Error fetching schools:', error);
setError('Failed to load schools data');
}
};
loadSchools();
}, [cipCode, userState]);
if (error) {
return <div className="error">{error}</div>;
}
if (schools.length === 0) {
return <div>No schools found for the selected CIP Code.</div>;
}
return (
<div className="educational-programs">
<h3>Educational Programs</h3>
<ul>
{schools.map((school, index) => (
<li key={index}>
<strong>{school['Institution Name']}</strong><br />
Degree Type: {school['Degree Type']}<br />
CIP Code: {school['CIP Code']}
</li>
))}
</ul>
</div>
);
}
export default EducationalPrograms;

View File

@ -1,87 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
function GettingStarted() {
const navigate = useNavigate();
const handleStartInventory = () => {
navigate('/interest-inventory');
};
return (
<div className="mx-auto max-w-4xl px-4 py-8">
{/* Page Title */}
<h1 className="mb-2 text-center text-3xl font-semibold">
Welcome to AptivaAI
</h1>
<p className="mx-auto mb-8 max-w-xl text-center text-gray-700">
Lets start by getting to know you better. Completing the steps below
will help us tailor career recommendations based on your interests.
</p>
{/* Steps Container */}
<div className="space-y-6">
{/* Step 1 */}
<div className="flex items-center space-x-4 rounded-lg bg-blue-50 p-6 shadow-sm">
<span className="text-3xl">📄</span>
<div>
<h2 className="text-lg font-medium">
Step 1: Set Up Your Profile
</h2>
<p className="mb-3 text-sm text-gray-700">
Add details like your skills, education, and experience to further
personalize your recommendations.
</p>
<button
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={() => navigate('/profile')}
>
Go to Profile
</button>
</div>
</div>
{/* Step 2 */}
<div className="flex items-center space-x-4 rounded-lg bg-blue-50 p-6 shadow-sm">
<span className="text-3xl">🎯</span>
<div>
<h2 className="text-lg font-medium">
Step 2: Complete the O*Net Interest Inventory
</h2>
<p className="mb-3 text-sm text-gray-700">
Discover your career interests by taking the O*Net inventory.
This will help us suggest personalized career paths for you.
</p>
<button
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handleStartInventory}
>
Start Inventory
</button>
</div>
</div>
</div>
{/* Premium Access */}
<div className="mt-8 rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm">
<h3 className="mb-2 text-lg font-medium">Already know your path?</h3>
<p className="mb-4 text-sm text-gray-700">
You can skip ahead and begin planning your journey now.
</p>
<button
className="rounded bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700"
onClick={() =>
navigate('/premium-onboarding', { state: { fromGettingStarted: true } })
}
>
Access Premium Content{' '}
<span className="ml-1 text-xs font-normal text-gray-100">
(Premium)
</span>
</button>
</div>
</div>
);
}
export default GettingStarted;

View File

@ -155,7 +155,7 @@ const InterestInventory = () => {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
const response = await authFetch(`${process.env.REACT_APP_API_URL}/onet/submit_answers`, { const response = await authFetch('/api/onet/submit_answers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }), body: JSON.stringify({ answers }),

View File

@ -1,258 +0,0 @@
// src/components/MilestoneAddModal.js
import React, { useState, useEffect } from 'react';
import authFetch from '../utils/authFetch.js';
/*
CONSTANTS
*/
const IMPACT_TYPES = ['salary', 'cost', 'tuition', 'note'];
const FREQ_OPTIONS = ['ONE_TIME', 'MONTHLY'];
export default function MilestoneAddModal({
show,
onClose,
scenarioId, // active scenario UUID
editMilestone = null // pass full row when editing
}) {
/* ────────────── state ────────────── */
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [impacts, setImpacts] = useState([]);
/* ────────────── init / reset ────────────── */
useEffect(() => {
if (!show) return;
if (editMilestone) {
setTitle(editMilestone.title || '');
setDescription(editMilestone.description || '');
setImpacts(editMilestone.impacts || []);
} else {
setTitle(''); setDescription(''); setImpacts([]);
}
}, [show, editMilestone]);
/* ────────────── helpers ────────────── */
const addImpactRow = () =>
setImpacts(prev => [
...prev,
{
impact_type : 'cost',
frequency : 'ONE_TIME',
direction : 'subtract',
amount : 0,
start_date : '', // ISO yyyymmdd
end_date : '' // blank ⇒ indefinite
}
]);
const updateImpact = (idx, field, value) =>
setImpacts(prev => {
const copy = [...prev];
copy[idx] = { ...copy[idx], [field]: value };
return copy;
});
const removeImpact = idx =>
setImpacts(prev => prev.filter((_, i) => i !== idx));
/* ────────────── save ────────────── */
async function handleSave() {
try {
/* 1⃣ create OR update the milestone row */
let milestoneId = editMilestone?.id;
if (milestoneId) {
await authFetch(`api/premium/milestones/${milestoneId}`, {
method : 'PUT',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify({ title, description })
});
} else {
const res = await authFetch('api/premium/milestones', {
method : 'POST',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify({
title,
description,
career_profile_id: scenarioId
})
});
if (!res.ok) throw new Error('Milestone create failed');
const json = await res.json();
milestoneId = json.id ?? json[0]?.id; // array OR obj
}
/* 2⃣ upsert each impact (one call per row) */
for (const imp of impacts) {
const body = {
milestone_id : milestoneId,
impact_type : imp.impact_type,
frequency : imp.frequency, // ONE_TIME / MONTHLY
direction : imp.direction,
amount : parseFloat(imp.amount) || 0,
start_date : imp.start_date || null,
end_date : imp.frequency === 'MONTHLY' && imp.end_date
? imp.end_date
: null
};
await authFetch('api/premium/milestone-impacts', {
method : 'POST',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify(body)
});
}
onClose(true); // ← parent will refetch
} catch (err) {
console.error('Save failed:', err);
alert('Sorry, something went wrong please try again.');
}
}
/* ────────────── UI ────────────── */
if (!show) return null;
return (
<div className="modal-backdrop">
<div className="modal-container w-full max-w-lg">
<h2 className="text-xl font-bold mb-2">
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
</h2>
{/* basic fields */}
<label className="block font-semibold mt-2">Title</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
className="border w-full px-2 py-1"
/>
<label className="block font-semibold mt-4">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
className="border w-full px-2 py-1"
/>
{/* impacts */}
<h3 className="text-lg font-semibold mt-6">FinancialImpacts</h3>
{impacts.map((imp, i) => (
<div key={i} className="border rounded p-3 mt-4 space-y-2">
<div className="flex justify-between items-center">
<span className="font-medium">Impact #{i + 1}</span>
<button
className="text-red-600 text-sm"
onClick={() => removeImpact(i)}
>
Remove
</button>
</div>
{/* type */}
<div>
<label className="block text-sm font-semibold">Type</label>
<select
value={imp.impact_type}
onChange={e => updateImpact(i, 'impact_type', e.target.value)}
className="border px-2 py-1 w-full"
>
{IMPACT_TYPES.map(t => (
<option key={t} value={t}>
{t === 'salary' ? 'Salary change'
: t === 'cost' ? 'Cost / expense'
: t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
{/* frequency */}
<div>
<label className="block text-sm font-semibold">Frequency</label>
<select
value={imp.frequency}
onChange={e => updateImpact(i, 'frequency', e.target.value)}
className="border px-2 py-1 w-full"
>
<option value="ONE_TIME">Onetime</option>
<option value="MONTHLY">Monthly (recurring)</option>
</select>
</div>
{/* direction */}
<div>
<label className="block text-sm font-semibold">Direction</label>
<select
value={imp.direction}
onChange={e => updateImpact(i, 'direction', e.target.value)}
className="border px-2 py-1 w-full"
>
<option value="add">Add (income)</option>
<option value="subtract">Subtract (expense)</option>
</select>
</div>
{/* amount */}
<div>
<label className="block text-sm font-semibold">Amount ($)</label>
<input
type="number"
value={imp.amount}
onChange={e => updateImpact(i, 'amount', e.target.value)}
className="border px-2 py-1 w-full"
/>
</div>
{/* dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold">Start date</label>
<input
type="date"
value={imp.start_date}
onChange={e => updateImpact(i, 'start_date', e.target.value)}
className="border px-2 py-1 w-full"
/>
</div>
{imp.frequency === 'MONTHLY' && (
<div>
<label className="block text-sm font-semibold">
End date (optional)
</label>
<input
type="date"
value={imp.end_date || ''}
onChange={e => updateImpact(i, 'end_date', e.target.value)}
className="border px-2 py-1 w-full"
/>
</div>
)}
</div>
</div>
))}
<button
onClick={addImpactRow}
className="bg-gray-200 px-4 py-1 rounded mt-4"
>
+ Add impact
</button>
{/* actions */}
<div className="flex justify-end gap-3 mt-6">
<button onClick={() => onClose(false)} className="px-4 py-2">
Cancel
</button>
<button
onClick={handleSave}
className="bg-blue-600 text-white px-5 py-2 rounded"
>
Save
</button>
</div>
</div>
</div>
);
}

View File

@ -512,9 +512,3 @@ export default function MilestoneEditModal({
</div> </div>
); );
} }
/* -------------- tiny utility styles (or swap for Tailwind) ---- */
const inputBase = 'border rounded-md w-full px-2 py-1 text-sm';
const labelBase = 'block text-xs font-medium text-gray-600';
export const input = inputBase; // export so you can reuse
export const label = labelBase;

View File

@ -1,120 +0,0 @@
import React from 'react';
import { Button } from './ui/button.js';
export default function MilestoneModal({
show,
onClose,
milestones,
editingMilestone,
showForm,
handleNewMilestone,
handleEditMilestone,
handleDeleteMilestone,
handleAddTask,
showTaskForm,
editingTask,
handleEditTask,
deleteTask,
saveTask,
saveMilestone,
copyWizardMilestone,
setCopyWizardMilestone
}) {
if (!show) return null; // if we don't want to render at all when hidden
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-start justify-center overflow-auto">
<div className="bg-white p-4 m-4 max-w-4xl w-full relative">
<h3 className="text-xl font-bold mb-4">Edit Milestones</h3>
<Button onClick={handleNewMilestone}>+ New Milestone</Button>
{/*
1) Render existing milestones
*/}
{milestones.map((m) => {
const tasks = m.tasks || [];
return (
<div key={m.id} className="border p-2 my-2">
<h5>{m.title}</h5>
{m.description && <p>{m.description}</p>}
<p>
<strong>Date:</strong> {m.date}
<strong>Progress:</strong> {m.progress}%
</p>
{/* tasks list */}
{tasks.length > 0 && (
<ul>
{tasks.map((t) => (
<li key={t.id}>
<strong>{t.title}</strong>
{t.description ? ` - ${t.description}` : ''}
{t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
<Button onClick={() => handleEditTask(m.id, t)}>Edit</Button>
<Button style={{ color: 'red' }} onClick={() => deleteTask(t.id)}>
Delete
</Button>
</li>
))}
</ul>
)}
<Button onClick={() => handleAddTask(m.id)}>+ Task</Button>
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
<Button
onClick={() => setCopyWizardMilestone(m)}
style={{ marginLeft: '0.5rem' }}
>
Copy
</Button>
<Button
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
onClick={() => handleDeleteMilestone(m)}
>
Delete
</Button>
{/* The "Add/Edit Task" form if showTaskForm === m.id */}
{showTaskForm === m.id && (
<div style={{ border: '1px solid #aaa', padding: '0.5rem', marginTop: '0.5rem' }}>
<h5>{editingTask.id ? 'Edit Task' : 'New Task'}</h5>
{/* same form logic... */}
<Button onClick={() => saveTask(m.id)}>
{editingTask.id ? 'Update' : 'Add'} Task
</Button>
<Button /* ... */>Cancel</Button>
</div>
)}
</div>
);
})}
{/*
2) The big milestone form if showForm is true
*/}
{showForm && (
<div className="form border p-2 my-2">
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4>
{/* ... your milestone form code (title, date, impacts, etc.) */}
<Button onClick={saveMilestone}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</Button>
<Button onClick={onClose}>Cancel</Button>
</div>
)}
{/* Copy wizard if copyWizardMilestone */}
{copyWizardMilestone && (
<div>
{/* your copy wizard UI */}
</div>
)}
<div className="text-right">
<Button onClick={onClose}>Close</Button>
</div>
</div>
</div>
);
}

View File

@ -1,756 +0,0 @@
// src/components/MilestoneTimeline.js
import React, { useEffect, useState, useCallback } from 'react';
import { Button } from './ui/button.js';
/**
* Renders a simple vertical list of milestones for the given careerProfileId.
* Also includes Task CRUD (create/edit/delete) for each milestone,
* plus a small "copy milestone" wizard, "financial impacts" form, etc.
*/
export default function MilestoneTimeline({
careerProfileId,
authFetch,
activeView, // 'Career' or 'Financial'
setActiveView, // optional, if you need to switch between views
onMilestoneUpdated // callback after saving/deleting a milestone
}) {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
// For CREATE/EDIT milestone
const [newMilestone, setNewMilestone] = useState({
title: '',
description: '',
date: '',
progress: 0,
newSalary: '',
impacts: [],
isUniversal: 0
});
const [impactsToDelete, setImpactsToDelete] = useState([]);
const [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null);
// For CREATE/EDIT tasks
const [showTaskForm, setShowTaskForm] = useState(null); // which milestone ID is showing the form
const [newTask, setNewTask] = useState({
id: null,
title: '',
description: '',
due_date: ''
});
// For the "Copy to other scenarios" wizard
const [scenarios, setScenarios] = useState([]);
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
// ------------------------------------------------------------------
// 1) Financial Impacts sub-form helpers
// ------------------------------------------------------------------
function addNewImpact() {
setNewMilestone((prev) => ({
...prev,
impacts: [
...prev.impacts,
{ impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }
]
}));
}
function removeImpact(idx) {
setNewMilestone((prev) => {
const newImpacts = [...prev.impacts];
const removed = newImpacts[idx];
if (removed && removed.id) {
setImpactsToDelete((old) => [...old, removed.id]);
}
newImpacts.splice(idx, 1);
return { ...prev, impacts: newImpacts };
});
}
function updateImpact(idx, field, value) {
setNewMilestone((prev) => {
const newImpacts = [...prev.impacts];
newImpacts[idx] = { ...newImpacts[idx], [field]: value };
return { ...prev, impacts: newImpacts };
});
}
// ------------------------------------------------------------------
// 2) Fetch milestones => store in "milestones[Career]" / "milestones[Financial]"
// ------------------------------------------------------------------
const fetchMilestones = useCallback(async () => {
if (!careerProfileId) return;
try {
const res = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
if (!res.ok) {
console.error('Failed to fetch milestones. Status:', res.status);
return;
}
const data = await res.json();
if (!data.milestones) {
console.warn('No milestones in response:', data);
return;
}
} catch (err) {
console.error('Failed to fetch milestones:', err);
}
}, [careerProfileId, authFetch]);
useEffect(() => {
fetchMilestones();
}, [fetchMilestones]);
// ------------------------------------------------------------------
// 3) Load all scenarios for the copy wizard
// ------------------------------------------------------------------
useEffect(() => {
async function loadScenarios() {
try {
const res = await authFetch('/api/premium/career-profile/all');
if (res.ok) {
const data = await res.json();
setScenarios(data.careerProfiles || []);
}
} catch (err) {
console.error('Error loading scenarios for copy wizard:', err);
}
}
loadScenarios();
}, [authFetch]);
// ------------------------------------------------------------------
// 4) Edit Milestone => fetch impacts
// ------------------------------------------------------------------
async function handleEditMilestone(m) {
try {
setImpactsToDelete([]);
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
if (!res.ok) {
console.error('Failed to fetch milestone impacts. Status:', res.status);
return;
}
const data = await res.json();
const fetchedImpacts = data.impacts || [];
setNewMilestone({
title: m.title || '',
description: m.description || '',
date: m.date || '',
progress: m.progress || 0,
impacts: fetchedImpacts.map((imp) => ({
id: imp.id,
impact_type: imp.impact_type || 'ONE_TIME',
direction: imp.direction || 'subtract',
amount: imp.amount || 0,
start_date: imp.start_date || '',
end_date: imp.end_date || ''
})),
isUniversal: m.is_universal ? 1 : 0
});
setEditingMilestone(m);
setShowForm(true);
} catch (err) {
console.error('Error in handleEditMilestone:', err);
}
}
// ------------------------------------------------------------------
// 5) Save (create/update) => handle impacts
// ------------------------------------------------------------------
async function saveMilestone() {
if (!activeView) return;
const url = editingMilestone
? `/api/premium/milestones/${editingMilestone.id}`
: `/api/premium/milestone`;
const method = editingMilestone ? 'PUT' : 'POST';
const payload = {
milestone_type: activeView, // 'Career' or 'Financial'
title: newMilestone.title,
description: newMilestone.description,
date: newMilestone.date,
career_profile_id: careerProfileId,
progress: newMilestone.progress,
status: newMilestone.progress >= 100 ? 'completed' : 'planned',
is_universal: newMilestone.isUniversal || 0
};
try {
const res = await authFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errData = await res.json();
console.error('Failed to save milestone:', errData);
alert(errData.error || 'Error saving milestone');
return;
}
const savedMilestone = await res.json();
console.log('Milestone saved/updated:', savedMilestone);
// If it's a "Financial" milestone => handle impacts
if (activeView === 'Financial') {
// 1) Delete old impacts
for (const impactId of impactsToDelete) {
if (impactId) {
const delRes = await authFetch(`/api/premium/milestone-impacts/${impactId}`, {
method: 'DELETE'
});
if (!delRes.ok) {
console.error('Failed deleting old impact', impactId, await delRes.text());
}
}
}
// 2) Insert/Update new impacts
for (let i = 0; i < newMilestone.impacts.length; i++) {
const imp = newMilestone.impacts[i];
if (imp.id) {
// existing => PUT
const putPayload = {
milestone_id: savedMilestone.id,
impact_type: imp.impact_type,
direction: imp.direction,
amount: parseFloat(imp.amount) || 0,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
const impRes = await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(putPayload)
});
if (!impRes.ok) {
const errImp = await impRes.json();
console.error('Failed updating impact:', errImp);
}
} else {
// new => POST
const postPayload = {
milestone_id: savedMilestone.id,
impact_type: imp.impact_type,
direction: imp.direction,
amount: parseFloat(imp.amount) || 0,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
const impRes = await authFetch('/api/premium/milestone-impacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postPayload)
});
if (!impRes.ok) {
console.error('Failed creating new impact:', await impRes.text());
}
}
}
}
// Re-fetch milestones
await fetchMilestones();
// reset form
setShowForm(false);
setEditingMilestone(null);
setNewMilestone({
title: '',
description: '',
date: '',
progress: 0,
newSalary: '',
impacts: [],
isUniversal: 0
});
setImpactsToDelete([]);
if (onMilestoneUpdated) {
onMilestoneUpdated();
}
} catch (err) {
console.error('Error saving milestone:', err);
}
}
// ------------------------------------------------------------------
// 6) TASK CRUD
// ------------------------------------------------------------------
// A) “Add Task” button => sets newTask for a new item
function handleAddTask(milestoneId) {
setShowTaskForm(milestoneId);
setNewTask({ id: null, title: '', description: '', due_date: '' });
}
// B) “Edit Task” => fill newTask with the existing fields
function handleEditTask(milestoneId, task) {
setShowTaskForm(milestoneId);
setNewTask({
id: task.id,
title: task.title,
description: task.description || '',
due_date: task.due_date || ''
});
}
// C) Save (create or update) task
async function saveTask(milestoneId) {
if (!newTask.title.trim()) {
alert('Task needs a title');
return;
}
const payload = {
milestone_id: milestoneId,
title: newTask.title,
description: newTask.description,
due_date: newTask.due_date
};
let url = '/api/premium/tasks';
let method = 'POST';
if (newTask.id) {
// existing => PUT
url = `/api/premium/tasks/${newTask.id}`;
method = 'PUT';
}
try {
const res = await authFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
console.error('Failed to save task:', errData);
alert(errData.error || 'Error saving task');
return;
}
// re-fetch
await fetchMilestones();
// reset
setShowTaskForm(null);
setNewTask({ id: null, title: '', description: '', due_date: '' });
} catch (err) {
console.error('Error saving task:', err);
}
}
// D) Delete an existing task
async function deleteTask(taskId) {
if (!taskId) return;
try {
const res = await authFetch(`/api/premium/tasks/${taskId}`, { method: 'DELETE' });
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
console.error('Failed to delete task:', errData);
alert(errData.error || 'Error deleting task');
return;
}
await fetchMilestones();
} catch (err) {
console.error('Error deleting task:', err);
}
}
// ------------------------------------------------------------------
// 7) Copy Wizard for universal/cross-scenario
// ------------------------------------------------------------------
function CopyMilestoneWizard({ milestone, scenarios, onClose, authFetch }) {
const [selectedScenarios, setSelectedScenarios] = useState([]);
if (!milestone) return null;
function toggleScenario(scenarioId) {
setSelectedScenarios((prev) =>
prev.includes(scenarioId) ? prev.filter((id) => id !== scenarioId) : [...prev, scenarioId]
);
}
async function handleCopy() {
try {
const res = await authFetch('/api/premium/milestone/copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
milestoneId: milestone.id,
scenarioIds: selectedScenarios
})
});
if (!res.ok) throw new Error('Failed to copy milestone');
window.location.reload();
onClose();
} catch (err) {
console.error('Error copying milestone:', err);
}
}
return (
<div className="modal-backdrop">
<div className="modal-container">
<h3>Copy Milestone to Other Scenarios</h3>
<p>
Milestone: <strong>{milestone.title}</strong>
</p>
{scenarios.map((s) => (
<div key={s.id}>
<label>
<input
type="checkbox"
checked={selectedScenarios.includes(s.id)}
onChange={() => toggleScenario(s.id)}
/>
{s.career_name || s.scenario_title || '(untitled)'}
</label>
</div>
))}
<div style={{ marginTop: '1rem' }}>
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
Cancel
</Button>
<Button onClick={handleCopy}>Copy</Button>
</div>
</div>
</div>
);
}
// ------------------------------------------------------------------
// 8) Delete Milestone
// ------------------------------------------------------------------
async function handleDeleteMilestone(m) {
if (m.is_universal === 1) {
const userChoice = window.confirm(
'This milestone is universal. OK => remove from ALL scenarios, Cancel => only remove from this scenario.'
);
if (userChoice) {
// delete from all
try {
const delAll = await authFetch(`/api/premium/milestones/${m.id}/all`, {
method: 'DELETE'
});
if (!delAll.ok) {
console.error('Failed removing universal from all. Status:', delAll.status);
return;
}
} catch (err) {
console.error('Error deleting universal milestone from all:', err);
}
} else {
// remove from single scenario
await deleteSingleMilestone(m);
return;
}
} else {
// normal => single scenario
await deleteSingleMilestone(m);
}
window.location.reload();
}
async function deleteSingleMilestone(m) {
try {
const delRes = await authFetch(`/api/premium/milestones/${m.id}`, {
method: 'DELETE'
});
if (!delRes.ok) {
console.error('Failed to delete milestone:', delRes.status);
}
} catch (err) {
console.error('Error removing milestone from scenario:', err);
}
}
// ------------------------------------------------------------------
// 9) Render
// ------------------------------------------------------------------
// Combine "Career" + "Financial" if you want them in a single list:
return (
<div className="milestone-timeline" style={{ padding: '1rem' }}>
{/* “+ New Milestone” toggles the same form as before */}
<Button
onClick={() => {
if (showForm) {
// Cancel form
setShowForm(false);
setEditingMilestone(null);
setNewMilestone({
title: '',
description: '',
date: '',
progress: 0,
newSalary: '',
impacts: [],
isUniversal: 0
});
setImpactsToDelete([]);
} else {
setShowForm(true);
}
}}
style={{ marginBottom: '0.5rem' }}
>
{showForm ? 'Cancel' : '+ New Milestone'}
</Button>
{/* If showForm => the create/edit milestone sub-form */}
{showForm && (
<div className="border p-2 my-2">
<h4>{editingMilestone ? 'Edit Milestone' : 'New Milestone'}</h4>
<input
type="text"
placeholder="Title"
value={newMilestone.title}
onChange={(e) => setNewMilestone({ ...newMilestone, title: e.target.value })}
/>
<input
type="text"
placeholder="Description"
value={newMilestone.description}
onChange={(e) => setNewMilestone({ ...newMilestone, description: e.target.value })}
/>
<input
type="date"
value={newMilestone.date}
onChange={(e) => setNewMilestone({ ...newMilestone, date: e.target.value })}
/>
<input
type="number"
placeholder="Progress (%)"
value={newMilestone.progress === 0 ? '' : newMilestone.progress}
onChange={(e) =>
setNewMilestone((prev) => ({
...prev,
progress: parseInt(e.target.value || '0', 10)
}))
}
/>
{/* If “Financial” => show impacts */}
{activeView === 'Financial' && (
<div style={{ border: '1px solid #ccc', padding: '1rem', marginTop: '1rem' }}>
<h5>Financial Impacts</h5>
{newMilestone.impacts.map((imp, idx) => (
<div
key={idx}
style={{ border: '1px solid #bbb', margin: '0.5rem', padding: '0.5rem' }}
>
{imp.id && <p style={{ fontSize: '0.8rem' }}>ID: {imp.id}</p>}
<div>
<label>Type: </label>
<select
value={imp.impact_type}
onChange={(e) => updateImpact(idx, 'impact_type', e.target.value)}
>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
</div>
<div>
<label>Direction: </label>
<select
value={imp.direction}
onChange={(e) => updateImpact(idx, 'direction', e.target.value)}
>
<option value="add">Add (Income)</option>
<option value="subtract">Subtract (Expense)</option>
</select>
</div>
<div>
<label>Amount: </label>
<input
type="number"
value={imp.amount}
onChange={(e) => updateImpact(idx, 'amount', e.target.value)}
/>
</div>
<div>
<label>Start Date: </label>
<input
type="date"
value={imp.start_date || ''}
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
/>
</div>
{imp.impact_type === 'MONTHLY' && (
<div>
<label>End Date: </label>
<input
type="date"
value={imp.end_date || ''}
onChange={(e) => updateImpact(idx, 'end_date', e.target.value)}
/>
</div>
)}
<Button
style={{ marginLeft: '0.5rem', color: 'red' }}
onClick={() => removeImpact(idx)}
>
Remove
</Button>
</div>
))}
<Button onClick={addNewImpact}>+ Add Impact</Button>
</div>
)}
<div style={{ marginTop: '1rem' }}>
<label>
<input
type="checkbox"
checked={!!newMilestone.isUniversal}
onChange={(e) =>
setNewMilestone((prev) => ({
...prev,
isUniversal: e.target.checked ? 1 : 0
}))
}
/>{' '}
Apply this milestone to all scenarios
</label>
</div>
<div style={{ marginTop: '1rem' }}>
<Button onClick={saveMilestone}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</Button>
</div>
</div>
)}
{/* Render the (Career + Financial) milestones in a simple vertical list */}
{Object.keys(milestones).map((typeKey) =>
milestones[typeKey].map((m) => {
const tasks = m.tasks || [];
return (
<div
key={m.id}
style={{ border: '1px solid #ccc', marginTop: '1rem', padding: '0.5rem' }}
>
<h5>{m.title}</h5>
{m.description && <p>{m.description}</p>}
<p>
<strong>Date:</strong> {m.date} <strong>Progress:</strong> {m.progress}%
</p>
{/* tasks list */}
{tasks.length > 0 && (
<ul>
{tasks.map((t) => (
<li key={t.id}>
<strong>{t.title}</strong>
{t.description ? ` - ${t.description}` : ''}
{t.due_date ? ` (Due: ${t.due_date})` : ''}{' '}
{/* EDIT & DELETE Task buttons */}
<Button
onClick={() => handleEditTask(m.id, t)}
style={{ marginLeft: '0.5rem' }}
>
Edit
</Button>
<Button
onClick={() => deleteTask(t.id)}
style={{ marginLeft: '0.5rem', color: 'red' }}
>
Delete
</Button>
</li>
))}
</ul>
)}
{/* Add or edit a task */}
<Button
onClick={() => {
// if we are already showing the form for this milestone => Cancel
if (showTaskForm === m.id) {
setShowTaskForm(null);
setNewTask({ id: null, title: '', description: '', due_date: '' });
} else {
handleAddTask(m.id);
}
}}
style={{ marginRight: '0.5rem' }}
>
{showTaskForm === m.id ? 'Cancel Task' : '+ Task'}
</Button>
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
<Button
style={{ marginLeft: '0.5rem' }}
onClick={() => setCopyWizardMilestone(m)}
>
Copy
</Button>
<Button
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
onClick={() => handleDeleteMilestone(m)}
>
Delete
</Button>
{/* If this is the milestone whose tasks we're editing => show the form */}
{showTaskForm === m.id && (
<div
style={{
marginTop: '0.5rem',
border: '1px solid #aaa',
padding: '0.5rem'
}}
>
<h5>{newTask.id ? 'Edit Task' : 'New Task'}</h5>
<input
type="text"
placeholder="Task Title"
value={newTask.title}
onChange={(e) =>
setNewTask((prev) => ({ ...prev, title: e.target.value }))
}
/>
<input
type="text"
placeholder="Task Description"
value={newTask.description}
onChange={(e) =>
setNewTask((prev) => ({ ...prev, description: e.target.value }))
}
/>
<input
type="date"
value={newTask.due_date || ''}
onChange={(e) =>
setNewTask((prev) => ({ ...prev, due_date: e.target.value }))
}
/>
<Button onClick={() => saveTask(m.id)}>
{newTask.id ? 'Update' : 'Add'} Task
</Button>
</div>
)}
</div>
);
})
)}
{/* Copy wizard if open */}
{copyWizardMilestone && (
<CopyMilestoneWizard
milestone={copyWizardMilestone}
scenarios={scenarios}
onClose={() => setCopyWizardMilestone(null)}
authFetch={authFetch}
/>
)}
</div>
);
}

View File

@ -1,502 +0,0 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { ClipLoader } from "react-spinners";
import LoanRepayment from "./LoanRepayment.js";
function PopoutPanel({
isVisible,
data = {},
userState = "N/A",
loading = false,
error = null,
closePanel,
updateChatbotContext,
}) {
// Original local states
const [isCalculated, setIsCalculated] = useState(false);
const [results, setResults] = useState([]);
const [loadingCalculation, setLoadingCalculation] = useState(false);
const [persistedROI, setPersistedROI] = useState({});
const [programLengths, setProgramLengths] = useState([]);
const [sortBy, setSortBy] = useState("tuition");
const [maxTuition, setMaxTuition] = useState(50000);
const [maxDistance, setMaxDistance] = useState(100);
const token = localStorage.getItem("token");
const navigate = useNavigate();
// Destructure your data
const {
jobDescription = null,
tasks = null,
title = "Career Details",
economicProjections = {},
salaryData = [],
schools = [],
} = data || {};
// Clear results if sorting or filters change
useEffect(() => {
setResults([]);
setIsCalculated(false);
}, [sortBy, maxTuition, maxDistance]);
// Derive program lengths from school CREDDESC
useEffect(() => {
setProgramLengths(
schools.map((school) => getProgramLength(school["CREDDESC"]))
);
}, [schools]);
// Update chatbot context if data is present
useEffect(() => {
if (data && Object.keys(data).length > 0) {
updateChatbotContext({
careerDetails: data,
schools,
salaryData,
economicProjections,
results,
persistedROI,
});
}
}, [
data,
schools,
salaryData,
economicProjections,
results,
persistedROI,
updateChatbotContext,
]);
// If panel isn't visible, don't render
if (!isVisible) return null;
// If the panel or the loan calc is loading, show a spinner
if (loading || loadingCalculation) {
return (
<div className="popout-panel fixed top-0 right-0 z-50 h-full w-full max-w-xl overflow-y-auto bg-white shadow-xl">
<div className="p-4">
<button
className="mb-4 rounded bg-red-100 px-2 py-1 text-sm text-red-600 hover:bg-red-200"
onClick={closePanel}
>
X
</button>
<h2 className="mb-2 text-xl font-semibold">Loading Career Details...</h2>
<ClipLoader size={35} color="#4A90E2" />
</div>
</div>
);
}
// Original helper
function getProgramLength(degreeType) {
if (degreeType?.includes("Associate")) return 2;
if (degreeType?.includes("Bachelor")) return 4;
if (degreeType?.includes("Master")) return 6;
if (degreeType?.includes("Doctoral") || degreeType?.includes("First Professional"))
return 8;
if (degreeType?.includes("Certificate")) return 1;
return 4;
}
// Original close logic
function handleClosePanel() {
setResults([]);
setIsCalculated(false);
closePanel();
}
async function handlePlanMyPath() {
if (!token) {
alert("You need to be logged in to create a career path.");
return;
}
try {
// 1) Fetch existing career profiles (a.k.a. "careerProfiles")
const allPathsResponse = await fetch(
`${process.env.REACT_APP_API_URL}/premium/career-profile/all`,
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (!allPathsResponse.ok) {
throw new Error(`HTTP error ${allPathsResponse.status}`);
}
// The server returns { careerProfiles: [...] }
const { careerProfiles } = await allPathsResponse.json();
// 2) Check if there's already a career path with the same name
const match = careerProfiles.find((cp) => cp.career_name === data.title);
if (match) {
// If a path already exists for this career, confirm with the user
const decision = window.confirm(
`A career path (scenario) for "${data.title}" already exists.\n\n` +
`Click OK to RELOAD the existing path.\nClick Cancel to CREATE a new one.`
);
if (decision) {
// Reload existing path → go to Paywall
navigate("/paywall", {
state: {
selectedCareer: {
career_profile_id: match.id, // 'id' is the primary key from the DB
career_name: data.title,
},
},
});
return;
}
}
// 3) Otherwise, create a new career profile using POST /premium/career-profile
const newResponse = await fetch(
`${process.env.REACT_APP_API_URL}/premium/career-profile`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
// The server expects at least career_name
career_name: data.title,
// Optionally pass scenario_title, start_date, etc.
}),
}
);
if (!newResponse.ok) {
throw new Error("Failed to create new career path.");
}
// The server returns something like { message: 'Career profile upserted.', career_profile_id: 'xxx-xxx' }
const result = await newResponse.json();
const newlyCreatedId = result?.career_profile_id;
// 4) Navigate to /paywall, passing the newly created career_profile_id
navigate("/paywall", {
state: {
selectedCareer: {
career_profile_id: newlyCreatedId,
career_name: data.title,
},
},
});
} catch (error) {
console.error("Error in Plan My Path:", error);
}
}
// Filter & sort schools
const filteredAndSortedSchools = [...schools]
.filter((school) => {
const inStateCost = parseFloat(school["In_state cost"]) || 0;
const distance = parseFloat((school["distance"] || "0").replace(" mi", ""));
return inStateCost <= maxTuition && distance <= maxDistance;
})
.sort((a, b) => {
if (sortBy === "tuition") return a["In_state cost"] - b["In_state cost"];
if (sortBy === "distance") {
const distA = parseFloat((a["distance"] || "0").replace(" mi", ""));
const distB = parseFloat((b["distance"] || "0").replace(" mi", ""));
return distA - distB;
}
return 0;
});
return (
<div className="popout-panel fixed top-0 right-0 z-50 flex h-full w-full max-w-xl flex-col bg-white shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b p-4">
<button
className="rounded bg-red-100 px-2 py-1 text-sm text-red-600 hover:bg-red-200"
onClick={handleClosePanel}
>
X
</button>
<button
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handlePlanMyPath}
>
Plan My Path
</button>
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Title */}
<h2 className="text-xl font-semibold">{title}</h2>
{/* Error */}
{error && (
<div className="rounded bg-red-50 p-2 text-red-600">
{error}
</div>
)}
{/* Job Description */}
<div className="rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">Job Description</h3>
<p className="text-sm text-gray-700">
{jobDescription || "No description available"}
</p>
</div>
{/* Tasks */}
<div className="rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">Expected Tasks</h3>
{tasks && tasks.length > 0 ? (
<ul className="list-disc space-y-1 pl-5 text-sm text-gray-700">
{tasks.map((task, idx) => (
<li key={idx}>{task}</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">No tasks available.</p>
)}
</div>
{/* Economic Projections */}
<div className="rounded bg-gray-50 p-4">
{(() => {
if (!economicProjections.state && !economicProjections.national) {
return (
<p className="text-sm text-gray-500">
No economic projections available.
</p>
);
}
// Otherwise, proceed
const st = economicProjections.state || {};
const nat = economicProjections.national || {};
return (
<>
<h3 className="mb-2 text-base font-medium">
Economic Projections for {userState} {' '}
{st.occupationName ? st.occupationName : 'N/A'}
</h3>
<table className="w-full text-sm text-gray-700">
<thead>
<tr className="bg-gray-200">
<th className="p-2 text-left">Metric</th>
<th className="p-2 text-left">{st.area ?? 'State'}</th>
<th className="p-2 text-left">{nat.area ?? 'U.S.'}</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-2 font-medium">2022 Employment</td>
<td className="p-2">{st.base ?? 'N/A'}</td>
<td className="p-2">{nat.base ?? 'N/A'}</td>
</tr>
<tr>
<td className="p-2 font-medium">2032 Employment</td>
<td className="p-2">{st.projection ?? 'N/A'}</td>
<td className="p-2">{nat.projection ?? 'N/A'}</td>
</tr>
<tr>
<td className="p-2 font-medium">Annual Openings</td>
<td className="p-2">{st.annualOpenings ?? 'N/A'}</td>
<td className="p-2">{nat.annualOpenings ?? 'N/A'}</td>
</tr>
</tbody>
</table>
</>
);
})()}
</div>
{/* Salary Data */}
<div className="rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">Salary Data</h3>
{salaryData.length > 0 ? (
<table className="w-full text-sm text-gray-700">
<thead>
<tr className="bg-gray-200 text-left">
<th className="p-2">Percentile</th>
<th className="p-2">Regional Salary</th>
<th className="p-2">US Salary</th>
</tr>
</thead>
<tbody>
{salaryData.map((point, idx) => (
<tr key={idx} className="border-b">
<td className="p-2">{point.percentile}</td>
<td className="p-2">
{point.regionalSalary > 0
? `$${parseInt(point.regionalSalary, 10).toLocaleString()}`
: "N/A"}
</td>
<td className="p-2">
{point.nationalSalary > 0
? `$${parseInt(point.nationalSalary, 10).toLocaleString()}`
: "N/A"}
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-500">
Salary data is not available.
</p>
)}
</div>
{/* Schools Offering Programs */}
<div>
<h3 className="mb-2 text-base font-medium">Schools Offering Programs</h3>
{/* Filter Bar */}
<div className="mb-4 flex items-center space-x-4">
<label className="text-sm text-gray-600">
Sort:
<select
className="ml-2 rounded border px-2 py-1 text-sm"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="tuition">Tuition</option>
<option value="distance">Distance</option>
</select>
</label>
<label className="text-sm text-gray-600">
Tuition (max):
<input
type="number"
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
value={maxTuition}
onChange={(e) => setMaxTuition(Number(e.target.value))}
/>
</label>
<label className="text-sm text-gray-600">
Distance (max):
<input
type="number"
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
value={maxDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))}
/>
</label>
</div>
{filteredAndSortedSchools.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{filteredAndSortedSchools.map((school, idx) => (
<div key={idx} className="rounded border p-3 text-sm">
<strong>{school["INSTNM"] || "Unnamed School"}</strong>
<p>Degree Type: {school["CREDDESC"] || "N/A"}</p>
<p>In-State Tuition: ${school["In_state cost"] || "N/A"}</p>
<p>Out-of-State Tuition: ${school["Out_state cost"] || "N/A"}</p>
<p>Distance: {school["distance"] || "N/A"}</p>
<p>
Website:{" "}
<a
href={school["Website"]}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{school["Website"]}
</a>
</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">
No schools matching your filters.
</p>
)}
</div>
{/* Loan Repayment Analysis */}
<section className="rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">Loan Repayment Analysis</h3>
<LoanRepayment
schools={filteredAndSortedSchools.map((school, i) => ({
schoolName: school["INSTNM"],
inState: parseFloat(school["In_state cost"]) || 0,
outOfState: parseFloat(school["Out_state cost"]) || 0,
inStateGraduate:
parseFloat(school["In State Graduate"]) ||
parseFloat(school["In_state cost"]) ||
0,
outStateGraduate:
parseFloat(school["Out State Graduate"]) ||
parseFloat(school["Out_state cost"]) ||
0,
degreeType: school["CREDDESC"],
programLength: programLengths[i],
}))}
salaryData={salaryData}
setResults={setResults}
setLoading={setLoadingCalculation}
setPersistedROI={setPersistedROI}
/>
</section>
{/* Results Display */}
{results.length > 0 && (
<div className="results-container rounded bg-gray-50 p-4">
<h3 className="mb-2 text-base font-medium">
Comparisons by School over the life of the loan
</h3>
{/*
=========================================
Here's the key part: a grid for results.
=========================================
*/}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{results.map((result, idx) => (
<div
key={idx}
className="rounded border p-3 text-sm text-gray-700"
>
<h4 className="mb-1 text-sm font-medium">
{result.schoolName} - {result.degreeType || "N/A"}
</h4>
<p>Total Tuition: ${result.totalTuition}</p>
<p>Monthly Payment: ${result.monthlyPayment}</p>
<p>
Total Monthly Payment (with extra): $
{result.totalMonthlyPayment}
</p>
<p>Total Loan Cost: ${result.totalLoanCost}</p>
<p
className={
parseFloat(result.netGain) < 0
? "text-red-600"
: "text-green-600"
}
>
Net Gain: ${result.netGain}
</p>
<p>Monthly Salary (Gross): ${result.monthlySalary}</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
export default PopoutPanel;

View File

@ -1,26 +0,0 @@
import React from "react";
import InfoTooltip from "./ui/infoTooltip.js";
/**
* Compact badge that shows a 0-100 readiness score.
*
* Props:
* score (Number 0-100)required
*/
export default function ReadinessPill({ score = 0 }) {
const pct = Math.max(0, Math.min(100, Math.round(score)));
const bg =
pct >= 80 ? "bg-green-600"
: pct >= 60 ? "bg-yellow-500"
: "bg-red-600";
return (
<span
className={`ml-2 inline-flex items-center gap-1 rounded-full px-2 py-px text-xs font-semibold text-white ${bg}`}
>
{pct}
<InfoTooltip message="How long your portfolio can fund your desired spending, mapped onto a 30-year scale (100 ≈ ≥30 yrs)" />
</span>
);
}

View File

@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { cn } from '../utils/cn.js'; import { cn } from '../utils/cn.js';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import { ChevronDown } from 'lucide-react';
/* /*
1. Static OPS cheat-sheet card 1. Static OPS cheat-sheet card
@ -101,10 +102,26 @@ export default function RetirementChatBar({
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [forceCtx, setForceCtx] = useState(false); const [forceCtx, setForceCtx] = useState(false);
const [scenarios, setScenarios] = useState([]);
const [currentScenario, setCurrentScenario] = useState(scenario);
const bottomRef = useRef(null); const bottomRef = useRef(null);
/* wipe chat on scenario change */ /* wipe chat on scenario change */
useEffect(() => setChatHistory([]), [scenario?.id]); useEffect(() => setChatHistory([]), [currentScenario?.id]);
/* keep propdriven scenario in sync (e.g. user clicked a card) */
useEffect(() => { if (scenario?.id) setCurrentScenario(scenario); }, [scenario]);
/* fetch the users scenarios once */
useEffect(() => {
(async () => {
try {
const res = await authFetch('/api/premium/career-profile/all');
const json = await res.json();
setScenarios(json.careerProfiles || []);
} catch (e) { console.error('Scenario load failed', e); }
})();
}, []);
/* autoscroll */ /* autoscroll */
useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), useEffect(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }),
@ -115,7 +132,7 @@ export default function RetirementChatBar({
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
async function sendPrompt() { async function sendPrompt() {
const prompt = input.trim(); const prompt = input.trim();
if (!prompt || !scenario?.id) return; if (!prompt || !currentScenario?.id) return;
/* ① optimistic UI show the user bubble immediately */ /* ① optimistic UI show the user bubble immediately */
const userMsg = { role: 'user', content: prompt }; const userMsg = { role: 'user', content: prompt };
@ -128,7 +145,7 @@ async function sendPrompt() {
const messagesToSend = buildMessages({ const messagesToSend = buildMessages({
chatHistory : [...chatHistory, userMsg], chatHistory : [...chatHistory, userMsg],
userProfile, userProfile,
scenarioRow : scenario, scenarioRow : currentScenario,
milestoneGrid, milestoneGrid,
largeSummaryCard: window.CACHED_SUMMARY || '', largeSummaryCard: window.CACHED_SUMMARY || '',
forceContext : forceCtx forceContext : forceCtx
@ -140,7 +157,7 @@ async function sendPrompt() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ body : JSON.stringify({
prompt, prompt,
scenario_id : scenario.id, // ← keep it minimal scenario_id : currentScenario?.id, // ← keep it minimal
chatHistory : messagesToSend // ← backend needs this to find userMsg chatHistory : messagesToSend // ← backend needs this to find userMsg
}) })
}); });
@ -151,7 +168,7 @@ async function sendPrompt() {
/* ④ if we got a real patch, build + forward the diff array */ /* ④ if we got a real patch, build + forward the diff array */
if (data.scenarioPatch && onScenarioPatch) { if (data.scenarioPatch && onScenarioPatch) {
onScenarioPatch(scenario.id, data.scenarioPatch); // ✅ id + patch onScenarioPatch(currentScenario.id, data.scenarioPatch); // ✅ id + patch
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -177,9 +194,10 @@ function handleKeyUp(e) {
/* ----------------------- render ----------------------- */ /* ----------------------- render ----------------------- */
const scenarioLabel = scenario const scenarioLabel =
? (scenario.scenario_title || scenario.career_name || 'Untitled Scenario') currentScenario
: 'Select a scenario'; ? currentScenario.scenario_title || currentScenario.career_name || 'Untitled Scenario'
: 'Pick a scenario';
return ( return (
<aside <aside
@ -188,12 +206,33 @@ function handleKeyUp(e) {
className className
)} )}
> >
{/* ---------- Header with a miniselector ---------- */}
<header className="p-3 border-b flex items-center gap-2 text-sm font-semibold"> <header className="p-3 border-b flex items-center gap-2 text-sm font-semibold">
{scenarioLabel} <div className="relative">
<select
value={currentScenario?.id || ''}
onChange={e => {
const sc = scenarios.find(s => s.id === e.target.value);
if (sc) {
setCurrentScenario(sc);
setChatHistory([]); // reset thread for new scenario
}
}}
className="pr-6 border rounded px-2 py-[2px] text-sm bg-white"
>
<option value="" disabled>-- select scenario --</option>
{scenarios.map(s => (
<option key={s.id} value={s.id}>
{s.scenario_title || s.career_name || 'Untitled'}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-1 top-1.5 h-4 w-4 text-gray-500" />
</div>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={!scenario} disabled={!currentScenario}
onClick={() => setForceCtx(true)} onClick={() => setForceCtx(true)}
title="Force refresh context for next turn" title="Force refresh context for next turn"
> >
@ -229,13 +268,13 @@ function handleKeyUp(e) {
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
disabled={!scenario} disabled={!currentScenario}
placeholder={scenario placeholder={currentScenario
? 'Ask about this scenario…' ? 'Ask about this scenario…'
: 'Click a scenario card first'} : 'Pick a scenario first'}
className="flex-1 resize-none border rounded px-2 py-1 text-sm disabled:bg-gray-100" className="flex-1 resize-none border rounded px-2 py-1 text-sm disabled:bg-gray-100"
/> />
<Button type="submit" disabled={!scenario || loading || !input.trim()}> <Button type="submit" disabled={!currentScenario || loading || !input.trim()}>
{loading ? '…' : 'Send'} {loading ? '…' : 'Send'}
</Button> </Button>
</form> </form>

View File

@ -144,10 +144,11 @@ export default function RetirementPlanner () {
baselineYears={baselineYears} baselineYears={baselineYears}
onClone={handleCloneScenario} onClone={handleCloneScenario}
onRemove={handleRemoveScenario} onRemove={handleRemoveScenario}
onSelect={() => { onAskAI={() => { /* ← only fires from the new button */
setSelectedScenario(sc); setSelectedScenario(sc);
openRetire({ openRetire({
scenario: sc, scenario: sc,
scenarios : scenarios,
financialProfile, financialProfile,
onScenarioPatch: applyPatch onScenarioPatch: applyPatch
}); });

View File

@ -5,7 +5,6 @@ import annotationPlugin from 'chartjs-plugin-annotation';
import moment from 'moment'; import moment from 'moment';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import InfoTooltip from "./ui/infoTooltip.js";
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import parseFloatOrZero from '../utils/ParseFloatorZero.js'; import parseFloatOrZero from '../utils/ParseFloatorZero.js';

View File

@ -1,164 +0,0 @@
// ScenarioEditWizard.js
import React, { useState, useEffect } from 'react';
import CareerOnboarding from './PremiumOnboarding/CareerOnboarding.js';
import FinancialOnboarding from './PremiumOnboarding/FinancialOnboarding.js';
import CollegeOnboarding from './PremiumOnboarding/CollegeOnboarding.js';
import ReviewPage from './PremiumOnboarding/ReviewPage.js';
import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
export default function ScenarioEditWizard({
show,
onClose,
scenarioId // or scenario object
}) {
const [step, setStep] = useState(0);
const [careerData, setCareerData] = useState({});
const [financialData, setFinancialData] = useState({});
const [collegeData, setCollegeData] = useState({});
// You might also store scenario + college IDs
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!show || !scenarioId) return;
// 1) fetch scenario => careerData
// 2) fetch financial => financialData
// 3) fetch college => collegeData
// Pre-fill the same states your Onboarding steps expect.
async function fetchExisting() {
setLoading(true);
try {
const [scenRes, finRes, colRes] = await Promise.all([
authFetch(`/api/premium/career-profile/${scenarioId}`),
authFetch(`/api/premium/financial-profile`),
authFetch(`/api/premium/college-profile?careerProfileId=${scenarioId}`)
]);
if (!scenRes.ok || !finRes.ok || !colRes.ok) {
throw new Error('Failed fetching existing scenario or financial or college.');
}
const [scenData, finData, colDataRaw] = await Promise.all([
scenRes.json(),
finRes.json(),
colRes.json()
]);
let colData = Array.isArray(colDataRaw) ? colDataRaw[0] : colDataRaw;
// Now put them into the same shape as your Onboarding step states:
setCareerData({
career_name: scenData.career_name,
college_enrollment_status: scenData.college_enrollment_status,
currently_working: scenData.currently_working,
status: scenData.status,
start_date: scenData.start_date,
planned_monthly_expenses: scenData.planned_monthly_expenses,
planned_monthly_debt_payments: scenData.planned_monthly_debt_payments,
planned_monthly_retirement_contribution: scenData.planned_monthly_retirement_contribution,
planned_monthly_emergency_contribution: scenData.planned_monthly_emergency_contribution,
planned_surplus_emergency_pct: scenData.planned_surplus_emergency_pct,
planned_surplus_retirement_pct: scenData.planned_surplus_retirement_pct,
planned_additional_income: scenData.planned_additional_income,
id: scenData.id,
// etc...
});
setFinancialData({
// your financial table fields
current_salary: finData.current_salary,
additional_income: finData.additional_income,
monthly_expenses: finData.monthly_expenses,
monthly_debt_payments: finData.monthly_debt_payments,
retirement_savings: finData.retirement_savings,
emergency_fund: finData.emergency_fund,
retirement_contribution: finData.retirement_contribution,
emergency_contribution: finData.emergency_contribution,
extra_cash_emergency_pct: finData.extra_cash_emergency_pct,
extra_cash_retirement_pct: finData.extra_cash_retirement_pct
});
setCollegeData({
// from colData
selected_school: colData.selected_school,
selected_program: colData.selected_program,
program_type: colData.program_type,
academic_calendar: colData.academic_calendar,
is_in_state: colData.is_in_state,
is_in_district: colData.is_in_district,
is_online: colData.is_online,
college_enrollment_status: colData.college_enrollment_status,
annual_financial_aid: colData.annual_financial_aid,
existing_college_debt: colData.existing_college_debt,
tuition: colData.tuition,
tuition_paid: colData.tuition_paid,
loan_deferral_until_graduation: colData.loan_deferral_until_graduation,
loan_term: colData.loan_term,
interest_rate: colData.interest_rate,
extra_payment: colData.extra_payment,
credit_hours_per_year: colData.credit_hours_per_year,
hours_completed: colData.hours_completed,
program_length: colData.program_length,
credit_hours_required: colData.credit_hours_required,
expected_graduation: colData.expected_graduation,
expected_salary: colData.expected_salary
});
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
fetchExisting();
}, [show, scenarioId]);
const nextStep = () => setStep(s => s + 1);
const prevStep = () => setStep(s => s - 1);
if (!show) return null;
if (loading) return <div className="modal">Loading existing scenario...</div>;
const steps = [
<CareerOnboarding
data={careerData}
setData={setCareerData}
nextStep={nextStep}
/>,
<FinancialOnboarding
data={{
...financialData,
currently_working: careerData.currently_working // pass along
}}
setData={setFinancialData}
nextStep={nextStep}
prevStep={prevStep}
isEditMode={true}
/>,
<CollegeOnboarding
data={{
...collegeData,
college_enrollment_status: careerData.college_enrollment_status
}}
setData={setCollegeData}
nextStep={nextStep}
prevStep={prevStep}
/>,
<ReviewPage
careerData={careerData}
financialData={financialData}
collegeData={collegeData}
onSubmit={async () => {
// same final logic from Onboarding: upsert scenario, financial, college
// Then close
onClose();
}}
onBack={prevStep}
/>
];
return (
<div className="modal-backdrop">
<div className="modal-content" style={{ padding:'1rem' }}>
{steps[step]}
</div>
</div>
);
}

View File

@ -1,48 +0,0 @@
import React from 'react';
import { ClipLoader } from 'react-spinners';
function SchoolsList({ schools, distances, tuitionData, calculatingDistances }) {
return (
<div className="schools-list">
<h2>Schools Offering Programs</h2>
{schools.length > 0 ? (
<ul>
{schools.map((school, index) => {
const matchingTuitionData = tuitionData.find(
(tuition) =>
tuition['school.name']?.toLowerCase().trim() ===
school['Institution Name']?.toLowerCase().trim()
);
const isCalculating = calculatingDistances[school['UNITID']];
return (
<li key={index}>
<strong>{school['Institution Name']}</strong>
<br />
Degree type: {school['CREDDESC'] || 'N/A'}
<br />
In-State Tuition: $
{matchingTuitionData?.['latest.cost.tuition.in_state'] || 'N/A'}
<br />
Out-of-State Tuition: $
{matchingTuitionData?.['latest.cost.tuition.out_of_state'] || 'N/A'}
<br />
Distance:{' '}
{isCalculating ? (
<span className="distance-spinner">
<ClipLoader size={15} color="#4A90E2" />
</span>
) : (
distances[school['UNITID']] || 'N/A'
)}
</li>
);
})}
</ul>
) : (
<p>No schools found for the selected program.</p>
)}
</div>
);
}
export default SchoolsList;

View File

@ -42,7 +42,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
} }
try { try {
const resp = await fetch('api/signin', { const resp = await fetch('/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}),

View File

@ -1,20 +0,0 @@
// TangentialCareers.js
import React from 'react';
const TangentialCareers = ({ careers }) => {
return (
<section className="tangential-careers">
<h2>Tangential Career Options</h2>
{careers.map((career, index) => (
<div key={index} className="career-option">
<h3>{career.title}</h3>
<p>{career.description}</p>
<p><strong>Match Score:</strong> {career.matchScore}</p>
</div>
))}
</section>
);
};
export default TangentialCareers;

View File

@ -1,23 +0,0 @@
import React from 'react';
import "../styles/legacy/ZipCodeInput.legacy.css";
export function ZipCodeInput({ zipCode, setZipCode, onZipSubmit }) {
return (
<div className="zip-code-input">
<h3>Enter Your ZIP Code</h3>
<form
onSubmit={(e) => {
e.preventDefault();
onZipSubmit(zipCode); // Call the onZipSubmit callback
}}
>
<input
type="text"
value={zipCode}
onChange={(e) => setZipCode(e.target.value)} // Update the zipCode state
placeholder="Enter ZIP code"
/>
<button type="submit">Check Distance</button>
</form>
</div>
);
}

View File

@ -1,21 +0,0 @@
// FadingPromptModal.jsx
import React from 'react';
import PromptModal from './PromptModal.js';
export default function FadingPromptModal({ open, ...props }) {
return (
<div
className={`
fixed inset-0 z-50
flex items-center justify-center
transition-opacity duration-300
${open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
`}
>
<div className="absolute inset-0 bg-gray-800 bg-opacity-50 z-10" />
<div className="relative z-20">
<PromptModal open={open} {...props} />
</div>
</div>
);
}

View File

@ -1,13 +0,0 @@
export function Badge({ className = '', children }) {
return (
<span
className={
`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold
bg-gray-200 text-gray-700 ${className}`
}
>
{children}
</span>
);
}

View File

@ -8,13 +8,3 @@ export const Card = ({ className, ...props }) => (
export const CardContent = ({ className, ...props }) => ( export const CardContent = ({ className, ...props }) => (
<div className={cn("p-4", className)} {...props} /> <div className={cn("p-4", className)} {...props} />
); );
export const CardHeader = ({ className, ...props }) => (
<div
className={cn(
"px-4 py-2 border-b flex items-center justify-between",
className
)}
{...props}
/>
);

View File

@ -1,20 +0,0 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
const Progress = ({ value, max = 100, className, ...props }) => {
return (
<ProgressPrimitive.Root
value={value}
max={max}
className="relative w-full h-4 bg-gray-200 rounded-md overflow-hidden"
{...props}
>
<ProgressPrimitive.Indicator
className="absolute top-0 left-0 h-full bg-blue-600 transition-all"
style={{ width: `${(value / max) * 100}%` }}
/>
</ProgressPrimitive.Root>
);
};
export { Progress };

View File

@ -1,9 +0,0 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
const Tabs = TabsPrimitive.Root;
const TabsList = TabsPrimitive.List;
const TabsTrigger = TabsPrimitive.Trigger;
const TabsContent = TabsPrimitive.Content;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -1,22 +1,5 @@
import axios from 'axios'; import axios from 'axios';
//fetch areas by state
export const fetchAreasByState = async (state) => {
try {
const response = await fetch(`${process.env.REACT_APP_API_URL}/Institution_data.json`);
if (response.status === 200) {
return response.data.areas; // Assume the API returns a list of areas
} else {
console.error('Failed to fetch areas:', response.status);
return [];
}
} catch (error) {
console.error('Error fetching areas:', error.message);
return [];
}
};
export async function clientGeocodeZip(zip) { export async function clientGeocodeZip(zip) {
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY; const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${apiKey}`; const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${apiKey}`;
@ -58,7 +41,7 @@ export async function fetchSchools(cipCodes) {
const cipParam = codesArray.join(','); const cipParam = codesArray.join(',');
// 3) Call your endpoint with `?cipCodes=1101,1409&state=NY` // 3) Call your endpoint with `?cipCodes=1101,1409&state=NY`
const response = await axios.get('api/schools', { const response = await axios.get('/api/schools', {
params: { params: {
cipCodes: cipParam, cipCodes: cipParam,
}, },

View File

@ -1,22 +0,0 @@
// utils/fetchCareerEnrichment.js
import axios from 'axios';
export async function fetchCareerEnrichment(socCode, area) {
// strippedSoc = remove decimals from e.g. "15-1132.00" => "15-1132"
const strippedSoc = socCode.includes('.') ? socCode.split('.')[0] : socCode;
const [cipData, jobDetailsData, economicData, salaryData] = await Promise.all([
axios.get(`api/cip/${socCode}`).catch(() => null),
axios.get(`api/onet/career-description/${socCode}`).catch(() => null),
axios.get(`api/projections/${strippedSoc}`, { params: { area } }).catch(() => null),
axios.get('api/salary', { params: { socCode: strippedSoc, area } }).catch(() => null),
]);
return {
cip: cipData?.data || null,
jobDetails: jobDetailsData?.data || null,
economic: economicData?.data || null,
salary: salaryData?.data || null,
};
}

View File

@ -34,7 +34,7 @@
// Salary // Salary
let salaryResponse; let salaryResponse;
try { try {
salaryResponse = await axios.get('api/salary', { salaryResponse = await axios.get('/api/salary', {
params: { socCode: socCode.split('.')[0], area: areaTitle }, params: { socCode: socCode.split('.')[0], area: areaTitle },
}); });
} catch (error) { } catch (error) {
@ -56,7 +56,7 @@
// Tuition // Tuition
let tuitionResponse; let tuitionResponse;
try { try {
tuitionResponse = await axios.get('api/tuition', { tuitionResponse = await axios.get('/api/tuition', {
params: { cipCode: cleanedCipCode, state: userState }, params: { cipCode: cleanedCipCode, state: userState },
}); });
} catch (error) { } catch (error) {

8
src/utils/isAllOther.js Normal file
View File

@ -0,0 +1,8 @@
// src/utils/isAllOther.js
export default function isAllOther({ title = '', socCode = '' } = {}) {
const allOtherRegex = /\bAll\s*Other\b/i; // title contains “All Other”
const residualSOC = /^\d{2}-\d{4}$/.test(socCode) &&
socCode.endsWith('99'); // e.g. 132099
return allOtherRegex.test(title) || residualSOC;
}

View File

@ -1,42 +0,0 @@
/*
ONE place that owns the math
*/
export function calcAnnualTuition({
ipedsRows, schoolRow,
programType, creditHoursPerYear,
inState, inDistrict,
}) {
if (!ipedsRows?.length || !schoolRow || !programType) return 0;
const row = ipedsRows.find(r => r.UNITID === schoolRow.UNITID);
if (!row) return 0;
const grad = [
"Master's Degree", "Doctoral Degree",
"Graduate/Professional Certificate", "First Professional Degree",
].includes(programType);
const pick = (u1,u2,u3) => inDistrict ? row[u1] : inState ? row[u2] : row[u3];
const partTime = Number( grad ? pick('HRCHG5','HRCHG6','HRCHG7')
: pick('HRCHG1','HRCHG2','HRCHG3') );
const fullTime = Number( grad ? pick('TUITION5','TUITION6','TUITION7')
: pick('TUITION1','TUITION2','TUITION3') );
const ch = Number(creditHoursPerYear) || 0;
return (ch && ch < 24 && partTime) ? partTime * ch : fullTime;
}
export function calcProgramLength({ programType, hrsPerYear, hrsCompleted=0, hrsRequired=0 }) {
if (!programType || !hrsPerYear) return '0.00';
let need = hrsRequired;
switch (programType) {
case "Associate's Degree": need = 60; break;
case "Bachelor's Degree" : need = 120; break;
case "Master's Degree" : need = 180; break;
case "Doctoral Degree" : need = 240; break;
case "First Professional Degree": need = 180; break;
}
return ((need - hrsCompleted) / hrsPerYear).toFixed(2);
}

26
unused_files.txt Normal file
View File

@ -0,0 +1,26 @@
10
11
19
4
10
11
14
183
2
20
3
30
37
42
44
48
5
51
519
520
54
6
7
72
8
87

Binary file not shown.