diff --git a/.env b/.env index 83eade4..f50d404 100644 --- a/.env +++ b/.env @@ -2,4 +2,4 @@ CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http:// SERVER1_PORT=5000 SERVER2_PORT=5001 SERVER3_PORT=5002 -IMG_TAG=202508011207 \ No newline at end of file +IMG_TAG=66721ee-202508031720 \ No newline at end of file diff --git a/backend/server3.js b/backend/server3.js index 6c5cbea..6360885 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -75,6 +75,7 @@ function internalFetch(req, urlPath, opts = {}) { }); } +const auth = (req, urlPath, opts = {}) => internalFetch(req, urlPath, opts); app.post('/api/premium/stripe/webhook', express.raw({ type: 'application/json' }), diff --git a/backend/utils/apiUtils.js b/backend/utils/apiUtils.js index 4d26c15..2c6cdf2 100644 --- a/backend/utils/apiUtils.js +++ b/backend/utils/apiUtils.js @@ -10,78 +10,3 @@ const BASE = ( process.env.REACT_APP_API_URL || '' ).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 []; - } -} diff --git a/backend/utils/chatFreeEndpoint.js b/backend/utils/chatFreeEndpoint.js index f4c4474..5b12aa5 100644 --- a/backend/utils/chatFreeEndpoint.js +++ b/backend/utils/chatFreeEndpoint.js @@ -184,12 +184,20 @@ export default function chatFreeEndpoint( authenticateUser, async (req, res) => { try { - res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "no-cache", - Connection : "keep-alive", - "X-Accel-Buffering": "no" - }); + const headers = { + // 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", + "X-Accel-Buffering": "no" // disables Nginx/ALB buffering +}; + +// “Connection” is allowed **only** on HTTP/1.x +if (req.httpVersionMajor < 2) { + headers.Connection = "keep-alive"; +} + +res.writeHead(200, headers); res.flushHeaders?.(); const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); }; diff --git a/backend/utils/opsEngine.js b/backend/utils/opsEngine.js deleted file mode 100644 index 95aceb3..0000000 --- a/backend/utils/opsEngine.js +++ /dev/null @@ -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; -} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..74d6ce5 --- /dev/null +++ b/eslint.config.js @@ -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" } + } +]; diff --git a/package-lock.json b/package-lock.json index b60a214..d09b2b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,12 +63,21 @@ "devDependencies": { "@babel/parser": "^7.28.0", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@eslint/js": "^9.32.0", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", "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", + "globals": "^16.3.0", "postcss": "^8.5.3", - "tailwindcss": "^3.4.17" + "tailwindcss": "^3.4.17", + "typescript-eslint": "^8.38.0" } }, "node_modules/@alloc/quick-lru": { @@ -150,9 +159,9 @@ } }, "node_modules/@babel/eslint-parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.27.5.tgz", - "integrity": "sha512-HLkYQfRICudzcOtjGwkPvGc5nF1b4ljLZh1IRDj50lRZ718NAKVgQpIAUX8bfg6u/yuSKY3L7E0YzIV+OxrB8Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.0.tgz", + "integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==", "license": "MIT", "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", @@ -1069,6 +1078,15 @@ "@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": { "version": "7.27.1", "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_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": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", @@ -2452,12 +2479,16 @@ } }, "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==", + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", + "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "dev": true, "license": "MIT", "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": { @@ -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": { "version": "5.62.0", "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" } }, + "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": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", @@ -8495,6 +8579,48 @@ "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": { "version": "7.0.1", "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" } }, + "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -10191,12 +10326,16 @@ } }, "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==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globalthis": { @@ -19470,6 +19609,19 @@ "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", "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": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -19724,6 +19876,275 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 13b6175..bab33ee 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "xlsx": "^0.18.5" }, "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", "start": "react-scripts start --host 0.0.0.0", "build": "react-scripts build", "test": "react-scripts test", @@ -87,11 +89,20 @@ "devDependencies": { "@babel/parser": "^7.28.0", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@eslint/js": "^9.32.0", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", "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", + "globals": "^16.3.0", "postcss": "^8.5.3", - "tailwindcss": "^3.4.17" + "tailwindcss": "^3.4.17", + "typescript-eslint": "^8.38.0" } } diff --git a/parseLine.js b/parseLine.js deleted file mode 100644 index fff089a..0000000 --- a/parseLine.js +++ /dev/null @@ -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; diff --git a/src/App.js b/src/App.js index 314fd94..6f83a23 100644 --- a/src/App.js +++ b/src/App.js @@ -22,7 +22,6 @@ import EducationalProgramsPage from './components/EducationalProgramsPage.js'; import EnhancingLanding from './components/EnhancingLanding.js'; import RetirementLanding from './components/RetirementLanding.js'; import InterestInventory from './components/InterestInventory.js'; -import Dashboard from './components/Dashboard.js'; import UserProfile from './components/UserProfile.js'; import FinancialProfileForm from './components/FinancialProfileForm.js'; import CareerProfileList from './components/CareerProfileList.js'; @@ -78,6 +77,10 @@ const uiToolHandlers = useMemo(() => { return {}; // every other page exposes no UI tools }, [pageContext]); +// Retirement bot is only relevant on these pages +const canShowRetireBot = + pageContext === 'RetirementPlanner' || + pageContext === 'RetirementLanding'; // Auth states const [isAuthenticated, setIsAuthenticated] = useState(false); @@ -228,18 +231,16 @@ const uiToolHandlers = useMemo(() => { { setDrawerPane('support'); setDrawerOpen(true); }, openRetire : (props) => { - setRetireProps(props); - setDrawerPane('retire'); - setDrawerOpen(true); - - if (pageContext === 'RetirementPlanner' || pageContext === 'RetirementLanding') { - setRetireProps(props); - setDrawerPane('retire'); - setDrawerOpen(true); - } else { - console.warn('Retirement bot disabled on this page'); - } - }}}> + if (!canShowRetireBot) { + console.warn('Retirement bot disabled on this page'); + return; + } + + setRetireProps(props); + setDrawerPane('retire'); + setDrawerOpen(true); + } +}}>
{/* Header */}
@@ -517,7 +518,6 @@ const uiToolHandlers = useMemo(() => { <> }/> } /> - } /> } /> } /> } /> @@ -608,6 +608,7 @@ const uiToolHandlers = useMemo(() => { pageContext={pageContext} snapshot={chatSnapshot} uiToolHandlers={uiToolHandlers} + canShowRetireBot={canShowRetireBot} /> {/* Session Handler (Optional) */} diff --git a/src/components/AISuggestedMilestones.js b/src/components/AISuggestedMilestones.js deleted file mode 100644 index 5967a61..0000000 --- a/src/components/AISuggestedMilestones.js +++ /dev/null @@ -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 ( -
-
- Generating AI-suggested milestones... -
- ); - } - - if (!suggestedMilestones.length) return null; - - return ( -
-
-

AI-Suggested Milestones

- -
- -
    - {suggestedMilestones.map((m, i) => ( -
  • - toggleSelect(i)} - /> - {m.title} – {m.date} -
  • - ))} -
- -
- ); - -}; - -export default AISuggestedMilestones; diff --git a/src/components/BillingResult.js b/src/components/BillingResult.js index bad1c3f..a371b35 100644 --- a/src/components/BillingResult.js +++ b/src/components/BillingResult.js @@ -40,11 +40,11 @@ export default function BillingResult() {

); @@ -59,7 +59,7 @@ export default function BillingResult() {

No changes were made to your account.

); diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 5a1632b..23c1561 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -1,5 +1,5 @@ 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 CareerSuggestions from './CareerSuggestions.js'; diff --git a/src/components/CareerModal.js b/src/components/CareerModal.js index edf0504..344249d 100644 --- a/src/components/CareerModal.js +++ b/src/components/CareerModal.js @@ -1,5 +1,7 @@ import React, { useState, useEffect } from 'react'; 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 }) {
+ + {isAllOther(career) && ( +
+ +

+ You've selected an "umbrella" field that covers a wide range of careers—many + 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 day‑to‑day tasks for this “all‑other” 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! +

+
+)} + {/* Title row */}
diff --git a/src/components/CareerProfileForm.js b/src/components/CareerProfileForm.js index 82e830c..842669a 100644 --- a/src/components/CareerProfileForm.js +++ b/src/components/CareerProfileForm.js @@ -56,8 +56,8 @@ export default function CareerProfileForm() { career_name : d.career_name ?? '', soc_code : d.soc_code ?? '', status : d.status ?? 'current', - start_date : d.start_date ?? '', - retirement_start_date : d.retirement_start_date ?? '', + start_date : (d.start_date || '').slice(0, 10), // ← trim + retirement_start_date : (d.retirement_start_date || '').slice(0, 10), college_enrollment_status : d.college_enrollment_status ?? '', career_goals : d.career_goals ?? '', desired_retirement_income_monthly : @@ -68,16 +68,18 @@ export default function CareerProfileForm() { /* ---------- 4. save ---------- */ async function save() { - if (!form.soc_code) { - alert('Please pick a valid career from the list first.'); - return; - } + if (!careerLocked && !form.soc_code) { + alert('Please pick a valid career from the list first.'); + return; + } try { const res = await authFetch('/api/premium/career-profile', { method : 'POST', headers : { 'Content-Type': 'application/json' }, body : JSON.stringify({ ...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 }) }); diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index 6927ef1..49a7253 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -693,7 +693,7 @@ useEffect(() => { (async function init () { /* 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; const { careerProfiles=[] } = await r.json(); setExistingCareerProfiles(careerProfiles); @@ -777,7 +777,7 @@ useEffect(() => { const refetchScenario = useCallback(async () => { 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()); }, [careerProfileId]); @@ -835,7 +835,7 @@ try { if (err.response && err.response.status === 404) { try { // 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, careerName, jobDescription: description, @@ -869,7 +869,7 @@ try { } // 3) Store in server2 - await axios.post('api/ai-risk', storePayload); + await axios.post('/api/ai-risk', storePayload); // Construct final object for usage here aiRisk = { diff --git a/src/components/CareerSuggestions.js b/src/components/CareerSuggestions.js index 9d4c32f..8b93e8d 100644 --- a/src/components/CareerSuggestions.js +++ b/src/components/CareerSuggestions.js @@ -1,23 +1,63 @@ +// src/components/CareerSuggestions.js 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 = [], onCareerClick, }) { return ( -
- {careerSuggestions.map((career) => ( - - ))} +
+ {careerSuggestions.map((career) => { + const isLimited = career.limitedData; + + return ( + + ); + })}
); } - -export default CareerSuggestions; diff --git a/src/components/ChatDrawer.js b/src/components/ChatDrawer.js index d22ddad..ce0bd8c 100644 --- a/src/components/ChatDrawer.js +++ b/src/components/ChatDrawer.js @@ -22,6 +22,7 @@ export default function ChatDrawer({ pane: controlledPane = 'support', setPane: setControlledPane, retireProps = null, // { scenario, financialProfile, … } + canShowRetireBot }) { /* ─────────────────────────── internal / fallback state ───────── */ const [openLocal, setOpenLocal] = useState(false); @@ -59,6 +60,13 @@ export default function ChatDrawer({ return [...prev, { role: 'assistant', content: chunk }]; }); + + useEffect(() => { + if (!canShowRetireBot && pane === 'retire') { + setPane('support'); + } + }, [canShowRetireBot, pane, setPane]); + /* ───────────────────────── send support-bot prompt ───────────── */ async function sendPrompt() { const text = prompt.trim(); @@ -125,49 +133,63 @@ export default function ChatDrawer({ } }; - /* ──────────────────────────── UI ─────────────────────────────── */ + /* ---------- render ---------- */ return ( - {/* floating FAB */} - + {/* floating action button */} + - {/* side-drawer */} + {/* side drawer */} - {/* header – tab switch */} -
- {[ - { id: 'support', label: 'Aptiva Support' }, - { id: 'retire', label: 'Retirement Helper' }, - ].map((tab) => ( + {/* header (tabs only if retirement bot is allowed) */} +
+ + + {canShowRetireBot && ( - ))} + )}
- {/* body – conditional panes */} + {/* body */} {pane === 'support' ? ( - /* ─────────── Support bot ─────────── */ + /* ── Support bot pane ── */ <>
{messages.map((m, i) => (
{m.content} @@ -200,8 +218,13 @@ export default function ChatDrawer({ setPrompt(e.target.value)} - onKeyDown={handleKeyDown} placeholder="Ask me anything…" + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendPrompt(); + } + }} className="flex-1" />
) : retireProps ? ( - /* ───────── Retirement helper ─────── */ + /* ── Retirement helper pane ── */ ) : ( + /* failsafe (retire tab opened before selecting a scenario) */
Select a scenario in  Retirement Planner @@ -222,4 +246,4 @@ export default function ChatDrawer({ ); -} +} \ No newline at end of file diff --git a/src/components/CollegeProfileForm.js b/src/components/CollegeProfileForm.js index 2894bc2..bf235ec 100644 --- a/src/components/CollegeProfileForm.js +++ b/src/components/CollegeProfileForm.js @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; 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 [schoolValid, setSchoolValid] = useState(true); const [programValid, setProgramValid] = useState(true); + const [autoGradDate, setAutoGradDate] = useState(''); + const [graduationTouched, setGraduationTouched] = useState(false); + const [programLengthTouched, setProgramLengthTouched] = useState(false); const schoolData = cipRows; @@ -78,7 +82,7 @@ const handleFieldChange = (e) => { setForm((prev) => { const draft = { ...prev }; if (type === 'checkbox') { - draft[name] = checked; + draft[name] = checked; } else if ( [ 'interest_rate','loan_term','extra_payment','expected_salary', @@ -88,6 +92,7 @@ const handleFieldChange = (e) => { ].includes(name) ) { draft[name] = value === '' ? '' : parseFloat(value); + if (name === 'program_length') setProgramLengthTouched(true); } else { draft[name] = value; } @@ -178,7 +183,6 @@ useEffect(()=>{ setTypes([...new Set(t)]); },[form.selected_school, form.selected_program, cipRows]); - useEffect(() => { if (!ipeds.length) return; if (!form.selected_school || @@ -235,6 +239,73 @@ const chosenTuition = manualTuition.trim() === '' ? autoTuition : parseFloat(manualTuition); + /* ──────────────────────────────────────────────────────────── + Auto‑calculate PROGRAM LENGTH when the user hasn’t 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 doesn’t 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 (

@@ -378,6 +449,36 @@ return ( />

+ + {/* ──────────────── Dates ──────────────── */} + {form.college_enrollment_status === 'prospective_student' && ( +
+ + +
+ )} + +
+ + { + handleFieldChange(e); + setGraduationTouched(true); + }} + className="w-full border rounded p-2" + required + /> +
+ {/* 7 │ Tuition & aid */}
diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js deleted file mode 100644 index 4e1aded..0000000 --- a/src/components/Dashboard.js +++ /dev/null @@ -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 ( -
-
-
-
-
-

- {progress}% — Loading Career Suggestions... -

-
-
- ); - }; - - // ============= Popout Panel Setup ============= - const memoizedPopoutPanel = useMemo(() => { - return ( - setSelectedCareer(null)} - loading={loading} - error={error} - userState={userState} - results={results} - updateChatbotContext={updateChatbotContext} - /> - ); - }, [ - selectedCareer, - careerDetails, - schools, - salaryData, - economicProjections, - tuitionData, - loading, - error, - userState, - results, - updateChatbotContext, - ]); - - return ( -
- {showSessionExpiredModal && ( -
-
-

Session Expired

-

Your session has expired or is invalid.

-
- - -
-
-
- )} - - {renderLoadingOverlay()} - -
- {/* ====== 1) The new CareerSearch bar ====== */} - - {/* Existing filters + suggestions */} -
-
- - - -
- { - console.log('[Dashboard] onCareerSelected =>', careerObj); - // Set the "pendingCareerForModal" so our useEffect fires - setPendingCareerForModal(careerObj); - }} - /> -
-
- - -
- - {/* RIASEC Container */} -
-
-

RIASEC Scores

- -
-
-

RIASEC Personality Descriptions

- {riaSecDescriptions.length > 0 ? ( -
    - {riaSecDescriptions.map((desc, index) => ( -
  • - {riaSecScores[index]?.area}: {desc} -
  • - ))} -
- ) : ( -

Loading descriptions...

- )} -
-
-
- - {/* The PopoutPanel */} - {memoizedPopoutPanel} - - {/* Chatbot */} -
- {careerSuggestions.length > 0 ? ( - - ) : ( -

Loading Chatbot...

- )} -
- -
-

- Career results and RIASEC scores are provided by - - {' '} - O*Net - - , in conjunction with the - - {' '} - Bureau of Labor Statistics - - , and the - - {' '} - National Center for Education Statistics (NCES) - - . -

-
-
- ); -} - -export default Dashboard; diff --git a/src/components/EditableCareerGoals.js b/src/components/EditableCareerGoals.js deleted file mode 100644 index be5e439..0000000 --- a/src/components/EditableCareerGoals.js +++ /dev/null @@ -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 ( -
-
-

Your Career Goals

- {!editing && ( - - )} -
- - {editing ? ( - <> -