Compare commits

..

No commits in common. "2980ca3bf2bd09e71bb3566a6a0b28e5fcc9d473" and "48133de29762356dd1d629dcc6fab24228fd5e3c" have entirely different histories.

40 changed files with 1303 additions and 2866 deletions

5
.env
View File

@ -1,5 +1,6 @@
CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000 IMG_TAG=20250716
SERVER1_PORT=5000 SERVER1_PORT=5000
SERVER2_PORT=5001 SERVER2_PORT=5001
SERVER3_PORT=5002 SERVER3_PORT=5002
IMG_TAG=202507301457 SALARY_DB=/salary_info.db
NODE_ENV=production

42
.env.development Executable file
View File

@ -0,0 +1,42 @@
# ─── O*NET ───────────────────────────────
ONET_USERNAME=aptivaai
ONET_PASSWORD=2296ahq
# ─── Publicfacing React build ───────────
NODE_ENV=development
REACT_APP_ENV=development
APTIVA_API_BASE=https://dev1.aptivaai.com
REACT_APP_API_URL=${APTIVA_API_BASE}
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
REACT_APP_OPENAI_API_KEY=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
# ─── Back-end services ───────────────────
OPENAI_API_KEY=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
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
SALARY_DB=/salary_info.db
# ─── Database (premium server) ───────────
DB_HOST=34.67.180.54
DB_PORT=3306
DB_USER=sqluser
DB_PASSWORD=ps<g+2DO-eTb2mb5
DB_NAME=user_profile_db
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
# ── Twilio (needed only by server3) ─────────────────────────
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
# ─── Anything new goes here ──────────────
JWT_SECRET=gW4QsOu4AJA4MooIUC9ld2i71VbBovzV1INsaU6ftxYPrxLIeMq6/OY61j0X2RV7
# ------------ CORS ------------
CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000
SERVER1_PORT=5000
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=20250716

42
.env.staging Normal file
View File

@ -0,0 +1,42 @@
# ─── O*NET ───────────────────────────────
ONET_USERNAME=aptivaai
ONET_PASSWORD=2296ahq
# ─── Publicfacing React build ───────────
NODE_ENV=production
REACT_APP_ENV=staging
APTIVA_API_BASE=https://staging.aptivaai.com
REACT_APP_API_URL=${APTIVA_API_BASE}
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
REACT_APP_OPENAI_API_KEY=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
# ─── Back-end services ───────────────────
OPENAI_API_KEY=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
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
SALARY_DB=/salary_info.db
# ─── Database (premium server) ───────────
DB_HOST=34.67.180.54
DB_PORT=3306
DB_USER=sqluser
DB_PASSWORD=ps<g+2DO-eTb2mb5
DB_NAME=user_profile_db
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
# ── Twilio (needed only by server3) ─────────────────────────
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
# ─── Anything new goes here ──────────────
JWT_SECRET=a35F0iFAkkdWvSjnaLzepAl/JIxPRUh4NpcGptJgry2Z3KVLX4ZcYY5KaTf7kJY0
# ------------ env/staging.env ------------
CORS_ALLOWED_ORIGINS=https://staging.aptivaai.com,http://34.61.84.49:3000,http://localhost:3000
SERVER1_PORT=5000
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=20250716

2
.gitignore vendored
View File

@ -21,5 +21,3 @@ yarn-error.log*
.bashrc .bashrc
_logout _logout
env/*.env env/*.env
*.env
uploads/

10
Dockerfile.server Normal file
View File

@ -0,0 +1,10 @@
ARG APPPORT=5000
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN apt-get update -y && apt-get install -y --no-install-recommends build-essential python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN npm ci --omit=dev --ignore-scripts
COPY . .
ENV PORT=5000
EXPOSE 5000
CMD ["node","backend/server.js"]

View File

@ -1,16 +0,0 @@
FROM node:20-bullseye AS base
WORKDIR /app
# ---- native build deps ----
RUN apt-get update -y && \
apt-get install -y --no-install-recommends \
build-essential python3 pkg-config && \
rm -rf /var/lib/apt/lists/*
# ---------------------------
COPY package*.json ./
COPY public/ /app/public/
RUN npm ci --unsafe-perm
COPY . .
CMD ["node", "backend/server1.js"]

View File

@ -1,20 +1,12 @@
# ---- Dockerfile.server3 (fixed) ------------------------------ ARG APPPORT=5002
FROM node:20-bullseye FROM node:20-slim
WORKDIR /app WORKDIR /app
# 1. native build dependencies + curl
RUN apt-get update -y && \
apt-get install -y --no-install-recommends \
build-essential python3 pkg-config curl && \
rm -rf /var/lib/apt/lists/*
# 2. node deps
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev --unsafe-perm RUN apt-get update -y \
&& apt-get install -y build-essential python3 make g++ --no-install-recommends python3 git \
# 3. static assets & source && rm -rf /var/lib/apt/lists/*
COPY public/ /app/public/ RUN npm ci --omit=dev --ignore-scripts
COPY . . COPY . .
ENV PORT=5002
CMD ["node", "backend/server3.js"] EXPOSE 5002
CMD ["node", "backend/server3.js"]

View File

@ -18,7 +18,6 @@ const __dirname = path.dirname(__filename);
const rootPath = path.resolve(__dirname, '..'); // Up one level const rootPath = path.resolve(__dirname, '..'); // Up one level
const env = process.env.NODE_ENV?.trim() || 'development'; const env = process.env.NODE_ENV?.trim() || 'development';
const stage = env === 'staging' ? 'development' : env;
const envPath = path.resolve(rootPath, `.env.${env}`); const envPath = path.resolve(rootPath, `.env.${env}`);
dotenv.config({ path: envPath }); // Load .env file dotenv.config({ path: envPath }); // Load .env file
@ -35,6 +34,7 @@ if (!JWT_SECRET) {
process.exit(1); // container exits, Docker marks it unhealthy process.exit(1); // container exits, Docker marks it unhealthy
} }
// Create a MySQL pool for user_profile data // Create a MySQL pool for user_profile data
const pool = mysql.createPool({ const pool = mysql.createPool({
host: DB_HOST, host: DB_HOST,
@ -62,6 +62,10 @@ if (!process.env.CORS_ALLOWED_ORIGINS) {
console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); // eslint-disable-line console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); // eslint-disable-line
process.exit(1); process.exit(1);
} }
if (!process.env.APTIVA_API_BASE) {
console.error('FATAL APTIVA_API_BASE is not set'); // eslint-disable-line
process.exit(1);
}
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */ /* ─── Allowed origins for CORS (comma-separated in env) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
@ -575,11 +579,9 @@ app.get('/api/areas', (req, res) => {
} }
// Use env when present (Docker), fall back for local dev // Use env when present (Docker), fall back for local dev
const salaryDbPath = const salaryDbPath =
process.env.SALARY_DB_PATH // ← preferred process.env.SALARY_DB || '/app/data/salary_info.db';
|| process.env.SALARY_DB // ← legacy
|| '/app/salary_info.db'; // final fallback
const salaryDb = new sqlite3.Database( const salaryDb = new sqlite3.Database(
salaryDbPath, salaryDbPath,
sqlite3.OPEN_READONLY, sqlite3.OPEN_READONLY,

View File

@ -19,53 +19,22 @@ import db from './config/mysqlPool.js';
import './jobs/reminderCron.js'; import './jobs/reminderCron.js';
import OpenAI from 'openai'; import OpenAI from 'openai';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import Stripe from 'stripe';
import { createReminder } from './utils/smsService.js'; import { createReminder } from './utils/smsService.js';
import { cacheSummary } from "./utils/ctxCache.js"; import { cacheSummary } from "./utils/ctxCache.js";
const rootPath = path.resolve(__dirname, '..'); // one level up const rootPath = path.resolve(__dirname, '..'); // one level up
const env = (process.env.NODE_ENV || 'production'); // production in prod const env = (process.env.NODE_ENV || 'production'); // production in prod
const stage = env === 'staging' ? 'development' : env;
const envPath = path.resolve(rootPath, `.env.${env}`); // => /app/.env.production const envPath = path.resolve(rootPath, `.env.${env}`); // => /app/.env.production
if (!process.env.FROM_SECRETS_MANAGER) { dotenv.config({ path: envPath });
dotenv.config({ path: envPath }); const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
}
const PORT = process.env.SERVER3_PORT || 5002;
const API_BASE = `http://localhost:${PORT}/api`;
/* ─── helper: canonical public origin ─────────────────────────── */
const PUBLIC_BASE = (
process.env.APTIVA_AI_BASE // ← preferred
|| process.env.REACT_APP_API_URL // ← old name, tolerated
|| ''
).replace(/\/+$/, ''); // strip trailing “/”
/* allowlist for redirects to block openredirect attacks */
const ALLOWED_REDIRECT_HOSTS = new Set([
new URL(PUBLIC_BASE || 'http://localhost').host
]);
function isSafeRedirect(url) {
try {
const u = new URL(url);
return ALLOWED_REDIRECT_HOSTS.has(u.host) && u.protocol === 'https:';
} catch { return false; }
}
const app = express(); const app = express();
const PORT = process.env.SERVER3_PORT || 5002;
const { getDocument } = pkg; const { getDocument } = pkg;
const bt = "`".repeat(3); const bt = "`".repeat(3);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { function internalFetch(req, url, opts = {}) {
apiVersion: '2024-04-10', return fetch(url, {
});
// at top of backend/server.js (do once per server codebase)
app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
function internalFetch(req, urlPath, opts = {}) {
return fetch(`${API_BASE}${urlPath}`, {
...opts, ...opts,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -75,58 +44,6 @@ function internalFetch(req, urlPath, opts = {}) {
}); });
} }
app.post('/api/premium/stripe/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WH_SECRET
);
} catch (err) {
console.error('⚠️ Bad Stripe signature', err.message);
return res.status(400).end();
}
const upFlags = async (customerId, premium, pro) => {
console.log('[Stripe] upFlags ->', { customerId, premium, pro});
await pool.query(
`UPDATE user_profile
SET is_premium = ?, is_pro_premium = ?
WHERE stripe_customer_id = ?`,
[premium, pro, customerId]
);
};
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object;
const pid = sub.items.data[0].price.id;
const tier = [process.env.STRIPE_PRICE_PRO_MONTH, process.env.STRIPE_PRICE_PRO_YEAR]
.includes(pid) ? 'pro' : 'premium';
await upFlags(sub.customer, tier === 'premium', tier === 'pro');
break;
console.log('[Stripe] flags updated', { id: sub.customer, tier });
}
case 'customer.subscription.deleted': {
const sub = event.data.object;
await upFlags(sub.customer, 0, 0);
break;
}
default:
// ignore everything else
}
res.status(200).end();
}
);
// 2) Basic middlewares // 2) Basic middlewares
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false })); app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
app.use(express.json({ limit: '5mb' })); app.use(express.json({ limit: '5mb' }));
@ -136,6 +53,10 @@ if (!process.env.CORS_ALLOWED_ORIGINS) {
console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); console.error('FATAL CORS_ALLOWED_ORIGINS is not set');
process.exit(1); process.exit(1);
} }
if (!process.env.APTIVA_API_BASE) {
console.error('FATAL APTIVA_API_BASE is not set');
process.exit(1);
}
/* ─── Allowed origins for CORS (comma-separated in env) ─────── */ /* ─── Allowed origins for CORS (comma-separated in env) ─────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
@ -194,54 +115,8 @@ const authenticatePremiumUser = (req, res, next) => {
const pool = db; const pool = db;
// at top of backend/server.js (do once per server codebase)
/** ------------------------------------------------------------------ app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
* Returns the users stripe_customer_id (or null) given req.id.
* Creates a new Stripe Customer & saves it if missing.
* ----------------------------------------------------------------- */
async function getOrCreateStripeCustomerId(req) {
// 1) look up current row
const [[row]] = await pool.query(
'SELECT stripe_customer_id FROM user_profile WHERE id = ?',
[req.id]
);
if (row?.stripe_customer_id) return row.stripe_customer_id;
// 2) create → cache → return
const customer = await stripe.customers.create({ metadata: { userId: req.id } });
await pool.query(
'UPDATE user_profile SET stripe_customer_id = ? WHERE id = ?',
[customer.id, req.id]
);
return customer.id;
}
const priceMap = {
premium: { monthly: process.env.STRIPE_PRICE_PREMIUM_MONTH,
annual : process.env.STRIPE_PRICE_PREMIUM_YEAR },
pro : { monthly: process.env.STRIPE_PRICE_PRO_MONTH,
annual : process.env.STRIPE_PRICE_PRO_YEAR }
};
app.get('/api/premium/subscription/status', authenticatePremiumUser, async (req, res) => {
try {
const [[row]] = await pool.query(
'SELECT is_premium, is_pro_premium FROM user_profile WHERE id = ?',
[req.id]
);
if (!row) return res.status(404).json({ error: 'User not found' });
return res.json({
is_premium : !!row.is_premium,
is_pro_premium : !!row.is_pro_premium
});
} catch (err) {
console.error('subscription/status error:', err);
return res.status(500).json({ error: 'DB error' });
}
});
/* ======================================================================== /* ========================================================================
* applyOps executes the milestones array inside a fenced ```ops block * applyOps executes the milestones array inside a fenced ```ops block
@ -250,10 +125,11 @@ app.get('/api/premium/subscription/status', authenticatePremiumUser, async (req,
async function applyOps(opsObj, req) { async function applyOps(opsObj, req) {
if (!opsObj?.milestones || !Array.isArray(opsObj.milestones)) return []; if (!opsObj?.milestones || !Array.isArray(opsObj.milestones)) return [];
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
const confirmations = []; const confirmations = [];
// helper for authenticated fetches that keep headers // helper for authenticated fetches that keep headers
const auth = (path, opts = {}) => internalFetch(req, path, opts); const auth = (path, opts = {}) => internalFetch(req, `${apiBase}${path}`, opts);
for (const m of opsObj.milestones) { for (const m of opsObj.milestones) {
const { op } = m || {}; const { op } = m || {};
@ -317,7 +193,8 @@ app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (re
const sql = ` const sql = `
SELECT SELECT
*, *,
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
FROM career_profiles FROM career_profiles
WHERE user_id = ? WHERE user_id = ?
ORDER BY start_date DESC ORDER BY start_date DESC
@ -337,7 +214,8 @@ app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req,
const sql = ` const sql = `
SELECT SELECT
*, *,
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
FROM career_profiles FROM career_profiles
WHERE user_id = ? WHERE user_id = ?
ORDER BY start_date ASC ORDER BY start_date ASC
@ -357,7 +235,8 @@ app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser,
const sql = ` const sql = `
SELECT SELECT
*, *,
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
FROM career_profiles FROM career_profiles
WHERE id = ? WHERE id = ?
AND user_id = ? AND user_id = ?
@ -383,6 +262,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
career_name, career_name,
status, status,
start_date, start_date,
projected_end_date,
college_enrollment_status, college_enrollment_status,
currently_working, currently_working,
career_goals, career_goals,
@ -415,6 +295,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
career_name, career_name,
status, status,
start_date, start_date,
projected_end_date,
college_enrollment_status, college_enrollment_status,
currently_working, currently_working,
career_goals, career_goals,
@ -428,10 +309,11 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
planned_surplus_retirement_pct, planned_surplus_retirement_pct,
planned_additional_income planned_additional_income
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
status = VALUES(status), status = VALUES(status),
start_date = VALUES(start_date), start_date = VALUES(start_date),
projected_end_date = VALUES(projected_end_date),
college_enrollment_status = VALUES(college_enrollment_status), college_enrollment_status = VALUES(college_enrollment_status),
currently_working = VALUES(currently_working), currently_working = VALUES(currently_working),
career_goals = VALUES(career_goals), career_goals = VALUES(career_goals),
@ -454,6 +336,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
career_name, career_name,
status || 'planned', status || 'planned',
start_date || null, start_date || null,
projected_end_date || null,
college_enrollment_status || null, college_enrollment_status || null,
currently_working || null, currently_working || null,
career_goals || null, career_goals || null,
@ -1039,9 +922,9 @@ ${econText}
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api"; const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
let aiRisk = null; let aiRisk = null;
try { try {
const aiRiskRes = await auth( const aiRiskRes = await internalFetch(
req, req,
'/premium/ai-risk-analysis', `${apiBase}/premium/ai-risk-analysis`,
{ {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
@ -1413,7 +1296,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
}; };
// Call your existing milestone endpoint // Call your existing milestone endpoint
const msRes = await auth(req, '/premium/milestone', { const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(milestoneBody) body: JSON.stringify(milestoneBody)
@ -1448,7 +1331,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
due_date: taskObj.due_date || null due_date: taskObj.due_date || null
}; };
await auth(req, '/premium/tasks', { await internalFetch(req, `${apiBase}/premium/tasks`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(taskBody) body: JSON.stringify(taskBody)
@ -1482,7 +1365,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
end_date: impObj.end_date || null end_date: impObj.end_date || null
}; };
await auth(req, '/premium/milestone-impacts', { await internalFetch(req, `${apiBase}/premium/milestone-impacts`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(impactBody) body: JSON.stringify(impactBody)
@ -1655,7 +1538,7 @@ Always end with: “AptivaAI is an educational tool not advice.”
if (payloadObj?.cloneScenario) { if (payloadObj?.cloneScenario) {
/* ------ CLONE ------ */ /* ------ CLONE ------ */
await auth(req, '/premium/career-profile/clone', { await internalFetch(req, `${apiBase}/premium/career-profile/clone`, {
method: 'POST', method: 'POST',
body : JSON.stringify(payloadObj.cloneScenario), body : JSON.stringify(payloadObj.cloneScenario),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
@ -2569,32 +2452,17 @@ app.delete('/api/premium/milestones/:milestoneId', authenticatePremiumUser, asyn
FINANCIAL PROFILES FINANCIAL PROFILES
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
// GET /api/premium/financial-profile
app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => { app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => {
try { try {
const [rows] = await pool.query( const [rows] = await pool.query(`
'SELECT * FROM financial_profiles WHERE user_id=? LIMIT 1', SELECT *
[req.id] FROM financial_profiles
); WHERE user_id = ?
`, [req.id]);
if (!rows.length) { res.json(rows[0] || {});
return res.json({ } catch (error) {
current_salary: 0, console.error('Error fetching financial profile:', error);
additional_income: 0, res.status(500).json({ error: 'Failed to fetch financial profile' });
monthly_expenses: 0,
monthly_debt_payments: 0,
retirement_savings: 0,
emergency_fund: 0,
retirement_contribution: 0,
emergency_contribution: 0,
extra_cash_emergency_pct: 50,
extra_cash_retirement_pct: 50
});
}
res.json(rows[0]);
} catch (err) {
console.error('financialprofile GET error:', err);
res.status(500).json({ error: 'DB error' });
} }
}); });
@ -2845,21 +2713,6 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res
} }
}); });
// GET every college profile for the loggedin user
app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req,res)=>{
const sql = `
SELECT cp.*,
DATE_FORMAT(cp.created_at,'%Y-%m-%d') AS created_at,
IFNULL(cpr.scenario_title, cpr.career_name) AS career_title
FROM college_profiles cp
JOIN career_profiles cpr ON cpr.id = cp.career_profile_id
WHERE cp.user_id = ?
ORDER BY cp.created_at DESC
`;
const [rows] = await pool.query(sql,[req.id]);
res.json({ collegeProfiles: rows });
});
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
AI-SUGGESTED MILESTONES AI-SUGGESTED MILESTONES
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
@ -3922,57 +3775,6 @@ app.post('/api/premium/reminders', authenticatePremiumUser, async (req, res) =>
} }
}); });
app.post('/api/premium/stripe/create-checkout-session',
authenticatePremiumUser,
async (req, res) => {
const { tier = 'premium', cycle = 'monthly', success_url, cancel_url } =
req.body || {};
const priceId = priceMap?.[tier]?.[cycle];
if (!priceId) return res.status(400).json({ error: 'Bad tier or cycle' });
const customerId = await getOrCreateStripeCustomerId(req);
const base = PUBLIC_BASE || `https://${req.headers.host}`;
const defaultSuccess = `${base}/billing?ck=success`;
const defaultCancel = `${base}/billing?ck=cancel`;
const safeSuccess = success_url && isSafeRedirect(success_url)
? success_url : defaultSuccess;
const safeCancel = cancel_url && isSafeRedirect(cancel_url)
? cancel_url : defaultCancel;
const session = await stripe.checkout.sessions.create({
mode : 'subscription',
customer : customerId,
line_items : [{ price: priceId, quantity: 1 }],
allow_promotion_codes : true,
success_url : safeSuccess,
cancel_url : safeCancel
});
res.json({ url: session.url });
}
);
app.get('/api/premium/stripe/customer-portal',
authenticatePremiumUser,
async (req, res) => {
const base = PUBLIC_BASE || `https://${req.headers.host}`;
const { return_url } = req.query;
const safeReturn = return_url && isSafeRedirect(return_url)
? return_url
: `${base}/billing`;
const cid = await getOrCreateStripeCustomerId(req); // never null now
const portal = await stripe.billingPortal.sessions.create({
customer : cid,
return_url
});
res.json({ url: portal.url });
}
);
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
FALLBACK 404 FALLBACK 404
------------------------------------------------------------------ */ ------------------------------------------------------------------ */

View File

@ -1,5 +1,5 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET; const SECRET_KEY = process.env.SECRET_KEY || "supersecurekey";
/** /**
* Adds `req.user = { id: <user_profile.id> }` * Adds `req.user = { id: <user_profile.id> }`
@ -10,7 +10,7 @@ export default function authenticateUser(req, res, next) {
if (!token) return res.status(401).json({ error: "Authorization token required" }); if (!token) return res.status(401).json({ error: "Authorization token required" });
try { try {
const { id } = jwt.verify(token, JWT_SECRET); const { id } = jwt.verify(token, SECRET_KEY);
req.user = { id }; // attach the id for downstream use req.user = { id }; // attach the id for downstream use
next(); next();
} catch (err) { } catch (err) {

View File

@ -1,72 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# ─────────────────────────────────────────────────────────────
# CONFIG adjust only the 4 lines below if you change projects
# ─────────────────────────────────────────────────────────────
ENV=dev # secret suffix, e.g. JWT_SECRET_staging
PROJECT=aptivaai-dev
ROOT=/home/jcoakley/aptiva-dev1-app
REG=us-central1-docker.pkg.dev/${PROJECT}/aptiva-repo
ENV_FILE="${ROOT}/.env" # ← holds NONsensitive values only
SECRETS=(
JWT_SECRET OPENAI_API_KEY ONET_USERNAME ONET_PASSWORD
STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY STRIPE_WH_SECRET STRIPE_PRICE_PREMIUM_MONTH STRIPE_PRICE_PREMIUM_YEAR STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR
DB_HOST DB_PORT DB_USER DB_PASSWORD
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID
)
cd "$ROOT"
echo "🛠 Building frontend bundle"
npm ci --silent # installs if node_modules is missing/old
npm run build
# ─────────────────────────────────────────────────────────────
# 1. Build ➔ Push ➔ Bump IMG_TAG in .env
# ─────────────────────────────────────────────────────────────
TAG=$(date -u +%Y%m%d%H%M)
echo "🔨 Building & pushing containers (tag = ${TAG})"
for svc in server1 server2 server3; do
docker build -f Dockerfile."$svc" -t "${REG}/${svc}:${TAG}" .
docker push "${REG}/${svc}:${TAG}"
done
# keep .env for static, nonsensitive keys (ports, API_BASE…)
if grep -q '^IMG_TAG=' "$ENV_FILE"; then
sed -i "s/^IMG_TAG=.*/IMG_TAG=${TAG}/" "$ENV_FILE"
else
echo "IMG_TAG=${TAG}" >> "$ENV_FILE"
fi
echo "✅ .env updated with IMG_TAG=${TAG}"
# ─────────────────────────────────────────────────────────────
# 2. Export secrets straight from Secret Manager
# (they live only in this shell, never on disk)
# ─────────────────────────────────────────────────────────────
echo "🔐 Pulling ${ENV} secrets from Secret Manager"
for S in "${SECRETS[@]}"; do
export "$S"="$(gcloud secrets versions access latest \
--secret="${S}_${ENV}" \
--project="$PROJECT")"
done
# A flag so we can see in the container env where they came from
export FROM_SECRETS_MANAGER=true
# ─────────────────────────────────────────────────────────────
# 3. Recreate the stack
# ─────────────────────────────────────────────────────────────
# Preserve only the variables dockercompose needs for expansion
preserve=IMG_TAG,FROM_SECRETS_MANAGER,REACT_APP_API_URL,$(IFS=,; echo "${SECRETS[*]}")
echo "🚀 docker compose up -d (with preserved env: $preserve)"
sudo --preserve-env="$preserve" docker compose up -d --force-recreate 2> >(grep -v 'WARN
\[0000\]
')
echo "✅ Deployment finished"

View File

@ -1,57 +1,27 @@
# ---------------------------------------------------------------------------
# A single envfile (.env) contains ONLY nonsecret constants.
# Every secret is exported from fetchsecrets.sh and injected at deploy time.
# ---------------------------------------------------------------------------
x-env: &with-env x-env: &with-env
env_file: env_file:
- .env # committed, nonsecret - ${RUNTIME_ENV_FILE:-.env.production} # default for local runs
restart: unless-stopped restart: unless-stopped
services: services:
# ───────────────────────────── server1 ─────────────────────────────
server1: server1:
<<: *with-env <<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG} image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG}
expose: ["${SERVER1_PORT}"] expose: ["${SERVER1_PORT}"]
environment:
JWT_SECRET: ${JWT_SECRET}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes:
- ./salary_info.db:/app/salary_info.db:ro
- ./user_profile.db:/app/user_profile.db
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER1_PORT}/healthz || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:${SERVER1_PORT}/healthz || exit 1"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
# ───────────────────────────── server2 ─────────────────────────────
server2: server2:
<<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG} image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG}
expose: ["${SERVER2_PORT}"] expose: ["${SERVER2_PORT}"]
environment: restart: unless-stopped
ONET_USERNAME: ${ONET_USERNAME} env_file:
ONET_PASSWORD: ${ONET_PASSWORD} - ${RUNTIME_ENV_FILE}
JWT_SECRET: ${JWT_SECRET}
OPENAI_API_KEY: ${OPENAI_API_KEY}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes: volumes:
- ./public:/app/public:ro - ./public:/app/public:ro
- ./salary_info.db:/app/salary_info.db:ro - ./salary_info.db:/app/salary_info.db:ro
- ./user_profile.db:/app/user_profile.db - ./user_profile.db:/app/user_profile.db
healthcheck: healthcheck:
@ -60,58 +30,26 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
# ───────────────────────────── server3 ─────────────────────────────
server3: server3:
<<: *with-env <<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG} image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG}
expose: ["${SERVER3_PORT}"] expose: ["${SERVER3_PORT}"]
environment:
JWT_SECRET: ${JWT_SECRET}
OPENAI_API_KEY: ${OPENAI_API_KEY}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
STRIPE_WH_SECRET: ${STRIPE_WH_SECRET}
STRIPE_PRICE_PREMIUM_MONTH: ${STRIPE_PRICE_PREMIUM_MONTH}
STRIPE_PRICE_PREMIUM_YEAR: ${STRIPE_PRICE_PREMIUM_YEAR}
STRIPE_PRICE_PRO_MONTH: ${STRIPE_PRICE_PRO_MONTH}
STRIPE_PRICE_PRO_YEAR: ${STRIPE_PRICE_PRO_YEAR}
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID}
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN}
TWILIO_MESSAGING_SERVICE_SID: ${TWILIO_MESSAGING_SERVICE_SID}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes:
- ./salary_info.db:/app/salary_info.db:ro
- ./user_profile.db:/app/user_profile.db
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
# ───────────────────────────── nginx ───────────────────────────────
nginx: nginx:
<<: *with-env <<: *with-env
image: nginx:1.25-alpine image: nginx:1.25-alpine
command: ["nginx", "-g", "daemon off;"] command: ["nginx", "-g", "daemon off;"]
depends_on: [server1, server2, server3] depends_on: [server1, server2, server3]
networks: [default, aptiva-shared] ports:
ports: ["80:80", "443:443"] - "80:80"
- "443:443"
volumes: volumes:
- ./build:/usr/share/nginx/html:ro - ./build:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro - /etc/letsencrypt:/etc/letsencrypt:ro
- ./empty:/etc/nginx/conf.d - ./empty:/etc/nginx/conf.d
networks:
default:
name: aptiva_default
aptiva-shared:
external: true

View File

@ -3,18 +3,15 @@ events {}
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
resolver 127.0.0.11 ipv6=off;
# ───────────── upstreams to Docker services ───────────── # ------------------ upstreams (one line to edit per container) ----------
upstream backend5000 { server server1:5000; } # auth & free upstream backend5000 { server server1:5000; } # auth & free
upstream backend5001 { server server2:5001; } # onet, distance, etc. upstream backend5001 { server server2:5001; } # onet, distance, etc.
upstream backend5002 { server server3:5002; } # premium upstream backend5002 { server server3:5002; } # premium
upstream gitea_backend { server gitea:3000; } # gitea service (shared network)
upstream woodpecker_backend { server woodpecker-server:8000; }
######################################################################## # -----------------------------------------------------------------------
# 1. HTTP  HTTPS redirect for the main site # 1. HTTP HTTPS redirect
######################################################################## # -----------------------------------------------------------------------
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@ -22,19 +19,19 @@ http {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
######################################################################## # -----------------------------------------------------------------------
# 2. Main virtual host (dev1.aptivaai.com) on :443 # 2. Main virtual host on :443
######################################################################## # -----------------------------------------------------------------------
server { server {
listen 443 ssl; listen 443 ssl http2;
http2 on; # modern syntax
server_name dev1.aptivaai.com; server_name dev1.aptivaai.com;
# ---------- TLS -----------------------------------------------------
ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
# ───── React static assets ───── # ---------- React static assets -------------------------------------
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location / { location / {
@ -45,7 +42,13 @@ http {
access_log off; access_log off;
} }
# ───── API reverseproxy rules ───── # -------------------------------------------------------------------
# 3. API reverseproxy rules (three prefixes = three backends)
# -------------------------------------------------------------------
## 3A server2 career, maps, onet, salary, etc.
## Anything that *starts* with /api/onet/ OR any one of the paths
## you previously enumerated now lives here.
location ^~ /api/onet/ { proxy_pass http://backend5001; } location ^~ /api/onet/ { proxy_pass http://backend5001; }
location ^~ /api/chat/ { proxy_pass http://backend5001; proxy_http_version 1.1; proxy_buffering off; } location ^~ /api/chat/ { proxy_pass http://backend5001; proxy_http_version 1.1; proxy_buffering off; }
location ^~ /api/job-zones { proxy_pass http://backend5001; } location ^~ /api/job-zones { proxy_pass http://backend5001; }
@ -58,81 +61,23 @@ http {
location ^~ /api/maps/distance { proxy_pass http://backend5001; } location ^~ /api/maps/distance { proxy_pass http://backend5001; }
location ^~ /api/schools { proxy_pass http://backend5001; } location ^~ /api/schools { proxy_pass http://backend5001; }
## 3B server3 premium & public assets handled by server3
location ^~ /api/premium/ { proxy_pass http://backend5002; } location ^~ /api/premium/ { proxy_pass http://backend5002; }
location ^~ /api/public/ { proxy_pass http://backend5002; } location ^~ /api/public/ { proxy_pass http://backend5002; }
## 3C server1 everything else beginning with /api/
## (register, signin, userprofile, areas, activatepremium, …)
location ^~ /api/ { proxy_pass http://backend5000; } location ^~ /api/ { proxy_pass http://backend5000; }
# shared proxy headers # ---------- shared proxy settings -----------------------------------
proxy_set_header Host $host; ## Add the headers *once*; they apply to every proxy_pass above.
proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# ---------- error pages ---------------------------------------------
error_page 502 503 504 /50x.html; error_page 502 503 504 /50x.html;
location = /50x.html { root /usr/share/nginx/html; } location = /50x.html { root /usr/share/nginx/html; }
} }
########################################################################
# 3. Gitea virtual host (HTTPS) gitea.dev1.aptivaai.com
########################################################################
server {
listen 443 ssl;
http2 on;
server_name gitea.dev1.aptivaai.com;
ssl_certificate /etc/letsencrypt/live/gitea.dev1.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gitea.dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://gitea_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
########################################################################
# 4. Gitea HTTP  HTTPS redirect
########################################################################
server {
listen 80;
server_name gitea.dev1.aptivaai.com;
return 301 https://$host$request_uri;
}
########################################################################
# 5. Woodpecker CI (HTTPS) ci.dev1.aptivaai.com
########################################################################
server {
listen 443 ssl;
http2 on;
server_name ci.dev1.aptivaai.com;
ssl_certificate /etc/letsencrypt/live/ci.dev1.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ci.dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://woodpecker_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
########################################################################
# 6. Woodpecker HTTP  HTTPS redirect
########################################################################
server {
listen 80;
server_name ci.dev1.aptivaai.com;
return 301 https://$host$request_uri;
}
} }

15
package-lock.json generated
View File

@ -7,6 +7,7 @@
"": { "": {
"name": "aptiva-dev1-app", "name": "aptiva-dev1-app",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.0.0", "@radix-ui/react-dialog": "^1.0.0",
@ -52,7 +53,6 @@
"react-spinners": "^0.15.0", "react-spinners": "^0.15.0",
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"stripe": "^14.0.0",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"twilio": "^5.7.1", "twilio": "^5.7.1",
@ -18715,19 +18715,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/stripe": {
"version": "14.25.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
"license": "MIT",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/style-loader": { "node_modules/style-loader": {
"version": "3.3.4", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz",

View File

@ -47,7 +47,6 @@
"react-spinners": "^0.15.0", "react-spinners": "^0.15.0",
"sqlite": "^5.1.1", "sqlite": "^5.1.1",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"stripe": "^14.0.0",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"twilio": "^5.7.1", "twilio": "^5.7.1",

View File

@ -1,6 +0,0 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: 'tests',
projects:[ {name:'chromium', use:{browserName:'chromium'}} ],
timeout: 30000,
});

View File

@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Reexport secrets from Secret Manager
echo "🔐 Pulling latest secrets…"
source /home/jcoakley/aptiva-dev1-app/fetch-secrets.sh # same array as deploy_all.sh
# Restart only the application stack so env changes propagate
cd /home/jcoakley/aptiva-dev1-app
docker compose up -d --no-build --no-deps server1 server2 server3
echo "✅ Secrets injected; containers unchanged."

View File

@ -25,10 +25,6 @@ import InterestInventory from './components/InterestInventory.js';
import Dashboard from './components/Dashboard.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 CareerProfileForm from './components/CareerProfileForm.js';
import CollegeProfileList from './components/CollegeProfileList.js';
import CollegeProfileForm from './components/CollegeProfileForm.js';
import CareerRoadmap from './components/CareerRoadmap.js'; import CareerRoadmap from './components/CareerRoadmap.js';
import Paywall from './components/Paywall.js'; import Paywall from './components/Paywall.js';
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js'; import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
@ -38,7 +34,6 @@ import LoanRepaymentPage from './components/LoanRepaymentPage.js';
import usePageContext from './utils/usePageContext.js'; import usePageContext from './utils/usePageContext.js';
import ChatDrawer from './components/ChatDrawer.js'; import ChatDrawer from './components/ChatDrawer.js';
import ChatCtx from './contexts/ChatCtx.js'; import ChatCtx from './contexts/ChatCtx.js';
import BillingResult from './components/BillingResult.js';
@ -176,10 +171,8 @@ const uiToolHandlers = useMemo(() => {
const confirmLogout = () => { const confirmLogout = () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('id');
localStorage.removeItem('careerSuggestionsCache'); localStorage.removeItem('careerSuggestionsCache');
localStorage.removeItem('lastSelectedCareerProfileId'); localStorage.removeItem('lastSelectedCareerProfileId');
localStorage.removeItem('selectedCareer');
localStorage.removeItem('aiClickCount'); localStorage.removeItem('aiClickCount');
localStorage.removeItem('aiClickDate'); localStorage.removeItem('aiClickDate');
localStorage.removeItem('aiRecommendations'); localStorage.removeItem('aiRecommendations');
@ -223,7 +216,7 @@ const uiToolHandlers = useMemo(() => {
<ProfileCtx.Provider <ProfileCtx.Provider
value={{ financialProfile, setFinancialProfile, value={{ financialProfile, setFinancialProfile,
scenario, setScenario, scenario, setScenario,
user, setUser}} user, }}
> >
<ChatCtx.Provider value={{ setChatSnapshot, <ChatCtx.Provider value={{ setChatSnapshot,
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); }, openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
@ -244,7 +237,7 @@ const uiToolHandlers = useMemo(() => {
{/* 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">
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
AptivaAI - Career Guidance Platform AptivaAI - Career Guidance Platform (beta)
</h1> </h1>
{isAuthenticated && ( {isAuthenticated && (
@ -365,7 +358,7 @@ const uiToolHandlers = useMemo(() => {
)} )}
onClick={() => navigate('/retirement')} onClick={() => navigate('/retirement')}
> >
Retirement Planning (beta) Retirement Planning
{!canAccessPremium && ( {!canAccessPremium && (
<span className="text-xs ml-1 text-gray-600"> <span className="text-xs ml-1 text-gray-600">
(Premium) (Premium)
@ -411,32 +404,20 @@ const uiToolHandlers = useMemo(() => {
</Link> </Link>
{canAccessPremium ? ( {canAccessPremium ? (
/* Premium users go straight to the wizard */ /* Premium users go straight to the wizard */
<Link
to="/profile/careers"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Career Profiles
</Link>
) : (
<span
className="block px-4 py-2 text-sm text-gray-400 cursor-not-allowed"
>
Career Profiles (Premium)
</span>
)}
{/* College Profiles (go straight to list) */}
{canAccessPremium ? (
<Link <Link
to="/profile/college" to="/premium-onboarding"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700" className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
> >
College Profiles Premium Onboarding
</Link> </Link>
) : ( ) : (
<span className="block px-4 py-2 text-sm text-gray-400 cursor-not-allowed"> /* Free users are nudged to upgrade */
College Profiles (Premium) <Link
</span> to="/paywall"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
College Planning&nbsp;<span className="text-xs">(Premium)</span>
</Link>
)} )}
</div> </div>
</div> </div>
@ -524,7 +505,6 @@ const uiToolHandlers = useMemo(() => {
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/> <Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
<Route path="/educational-programs" element={<EducationalProgramsPage />} /> <Route path="/educational-programs" element={<EducationalProgramsPage />} />
<Route path="/preparing" element={<PreparingLanding />} /> <Route path="/preparing" element={<PreparingLanding />} />
<Route path="/billing" element={<BillingResult />} />
{/* Premium-only routes */} {/* Premium-only routes */}
<Route <Route
@ -551,11 +531,6 @@ const uiToolHandlers = useMemo(() => {
</PremiumRoute> </PremiumRoute>
} }
/> />
<Route path="/profile/careers" element={<CareerProfileList />} />
<Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} />
<Route path="/profile/college/" element={<CollegeProfileList />} />
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
<Route <Route
path="/financial-profile" path="/financial-profile"
element={ element={

View File

@ -1,66 +0,0 @@
import { useEffect, useState, useContext } from 'react';
import { useLocation, Link } from 'react-router-dom';
import { Button } from './ui/button.js';
import { ProfileCtx } from '../App.js'; // <- exported at very top of App.jsx
export default function BillingResult() {
const { setUser } = useContext(ProfileCtx) || {};
const q = new URLSearchParams(useLocation().search);
const outcome = q.get('ck'); // 'success' | 'cancel' | null
const [loading, setLoading] = useState(true);
/*
1) Ask the API for the latest user profile (flags, etc.)
will be fast because JWT is already cached
*/
useEffect(() => {
const token = localStorage.getItem('token') || '';
fetch('/api/user-profile', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.ok ? r.json() : null)
.then(profile => { if (profile && setUser) setUser(profile); })
.finally(() => setLoading(false));
}, [setUser]);
/*
2) UX while waiting for that roundtrip
*/
if (loading) {
return <p className="p-8 text-center">Checking your subscription</p>;
}
/*
3) Success Stripe completed the checkout flow
*/
if (outcome === 'success') {
return (
<div className="max-w-md mx-auto p-8 text-center space-y-6">
<h1 className="text-2xl font-semibold">🎉 Subscription activated!</h1>
<p className="text-gray-600">
Premium features have been unlocked on your account.
</p>
<Button asChild className="w-full">
<Link to="/premium-onboarding">Set up Premium Features</Link>
</Button>
<Button variant="secondary" asChild className="w-full">
<Link to="/profile">Go to my account</Link>
</Button>
</div>
);
}
/*
4) Cancelled user backed out of Stripe
*/
return (
<div className="max-w-md mx-auto p-8 text-center space-y-6">
<h1 className="text-2xl font-semibold">Subscription cancelled</h1>
<p className="text-gray-600">No changes were made to your account.</p>
<Button asChild className="w-full">
<Link to="/paywall">Back to pricing</Link>
</Button>
</div>
);
}

View File

@ -240,9 +240,8 @@ I'm here to support you with personalized coaching. What would you like to focus
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]); setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData); if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
if (createdMilestones.length && typeof onMilestonesCreated === 'function') { if (createdMilestones.length && onMilestonesCreated)
onMilestonesCreated(); // no arg needed just refetch onMilestonesCreated(createdMilestones.length);
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]); setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);

View File

@ -1,232 +0,0 @@
// CareerProfileForm.js
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import authFetch from '../utils/authFetch.js';
import CareerSearch from './CareerSearch.js'; // ← same component as onboarding
export default function CareerProfileForm() {
const { id } = useParams(); // "new" or an existing uuid
const nav = useNavigate();
/* ---------- 1. local state ---------- */
const [form, setForm] = useState({
scenario_title : '',
career_name : '',
soc_code : '',
status : 'current',
start_date : '',
retirement_start_date : '',
college_enrollment_status : '',
career_goals : '',
desired_retirement_income_monthly : ''
});
const [careerLocked, setCareerLocked] = useState(id !== 'new'); // lock unless new
/* ---------- 2. helpers ---------- */
const handleChange = e =>
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
const handleCareerSelected = obj => {
// obj = { title, soc_code, … }
setForm(prev => ({
...prev,
career_name : obj.title,
soc_code : obj.soc_code
}));
setCareerLocked(true);
};
const unlockCareer = () => {
// allow user to repick
setCareerLocked(false);
setForm(prev => ({ ...prev, career_name: '', soc_code: '' }));
};
/* ---------- 3. load an existing row (edit mode) ---------- */
useEffect(() => {
if (id === 'new') return;
(async () => {
const res = await authFetch(`/api/premium/career-profile/${id}`);
if (!res.ok) return;
const d = await res.json();
setForm(prev => ({
...prev,
scenario_title : d.scenario_title ?? '',
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 ?? '',
college_enrollment_status : d.college_enrollment_status ?? '',
career_goals : d.career_goals ?? '',
desired_retirement_income_monthly :
d.desired_retirement_income_monthly ?? ''
}));
})();
}, [id]);
/* ---------- 4. save ---------- */
async function save() {
if (!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,
id: id === 'new' ? undefined : id // upsert
})
});
if (!res.ok) throw new Error(await res.text());
nav(-1);
} catch (err) {
console.error(err);
alert(err.message);
}
}
/* ---------- 5. render ---------- */
return (
<div className="max-w-lg mx-auto space-y-4">
<h2 className="text-2xl font-semibold">
{id === 'new' ? 'New' : 'Edit'} Career Profile
</h2>
{/* Scenario title */}
<label className="block">
<span className="font-medium">Scenario Title</span>
<input
name="scenario_title"
className="mt-1 w-full border rounded p-2"
placeholder="e.g. DataScientist Plan"
value={form.scenario_title}
onChange={handleChange}
/>
</label>
{/* Career picker (locked vs editable) */}
<label className="block font-medium">Career *</label>
{careerLocked ? (
<div className="flex items-center space-x-2">
<input
className="flex-1 border rounded p-2 bg-gray-100"
value={form.career_name}
disabled
/>
<button
type="button"
className="text-blue-600 underline text-sm"
onClick={unlockCareer}
>
Change
</button>
</div>
) : (
<CareerSearch onCareerSelected={handleCareerSelected} required />
)}
{/* Status */}
<label className="block">
<span className="font-medium">Status</span>
<select
name="status"
className="mt-1 w-full border rounded p-2"
value={form.status}
onChange={handleChange}
>
<option value="current">current</option>
<option value="future">future</option>
<option value="retired">retired</option>
</select>
</label>
{/* Dates */}
<label className="block">
<span className="font-medium">Start Date</span>
<input
type="date"
name="start_date"
className="mt-1 w-full border rounded p-2"
value={form.start_date}
onChange={handleChange}
/>
</label>
<label className="block">
<span className="font-medium">Retirement Start Date</span>
<input
type="date"
name="retirement_start_date"
className="mt-1 w-full border rounded p-2"
value={form.retirement_start_date}
onChange={handleChange}
/>
</label>
{/* College status */}
<label className="block">
<span className="font-medium">College Enrollment Status</span>
<select
name="college_enrollment_status"
className="mt-1 w-full border rounded p-2"
value={form.college_enrollment_status}
onChange={handleChange}
>
<option value="">-- select --</option>
<option value="not_applicable">Not Applicable</option>
<option value="prospective_student">Prospective Student</option>
<option value="currently_enrolled">Currently Enrolled</option>
<option value="completed">Completed</option>
</select>
</label>
{/* Career goals */}
<label className="block">
<span className="font-medium">Career Goals</span>
<textarea
rows={3}
name="career_goals"
className="mt-1 w-full border rounded p-2"
placeholder="e.g. Become a senior datascientist in five years…"
value={form.career_goals}
onChange={handleChange}
/>
</label>
{/* Desired retirement income */}
<label className="block">
<span className="font-medium">Desired Retirement Income / Month ($)</span>
<input
type="number"
name="desired_retirement_income_monthly"
className="mt-1 w-full border rounded p-2"
placeholder="e.g. 6000"
value={form.desired_retirement_income_monthly}
onChange={handleChange}
/>
</label>
{/* Action buttons */}
<div className="pt-4 flex justify-between">
<button
type="button"
onClick={() => nav(-1)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-2 px-4 rounded"
>
Back
</button>
<button
type="button"
onClick={save}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
>
Save
</button>
</div>
</div>
);
}

View File

@ -1,79 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
export default function CareerProfileList() {
const [rows, setRows] = useState([]);
const nav = useNavigate();
const token = localStorage.getItem('token');
useEffect(() => {
fetch('/api/premium/career-profile/all', {
headers: { Authorization: `Bearer ${token}` }
})
.then(r => r.json())
.then(d => setRows(d.careerProfiles || []));
}, [token]);
async function remove(id) {
if (!window.confirm('Delete this career profile?')) return;
await fetch(`/api/premium/career-profile/${id}`, {
method : 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
setRows(rows.filter(r => r.id !== id));
}
return (
<div className="max-w-4xl mx-auto space-y-4">
<h2 className="text-2xl font-semibold">Career Profiles</h2>
<button
onClick={() => nav('/profile/careers/new/edit')}
className="px-3 py-2 bg-blue-600 text-white rounded"
>
+ New Career Profile
</button>
<table className="w-full border mt-4 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="p-2 text-left">Title</th>
<th className="p-2 text-left">Status</th>
<th className="p-2">Start</th>
<th className="p-2"></th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.id} className="border-t">
<td className="p-2">{r.scenario_title || r.career_name}</td>
<td className="p-2">{r.status}</td>
<td className="p-2">{r.start_date}</td>
<td className="p-2 space-x-2">
<Link
to={`/profile/careers/${r.id}/edit`}
className="underline text-blue-600"
>
edit
</Link>
<button
onClick={() => remove(r.id)}
className="text-red-600 underline"
>
delete
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="p-4 text-center text-gray-500">
No career profiles yet
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@ -37,7 +37,6 @@ import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
import InfoTooltip from "./ui/infoTooltip.js"; import InfoTooltip from "./ui/infoTooltip.js";
import differenceInMonths from 'date-fns/differenceInMonths'; import differenceInMonths from 'date-fns/differenceInMonths';
import "../styles/legacy/MilestoneTimeline.legacy.css"; import "../styles/legacy/MilestoneTimeline.legacy.css";
// -------------- // --------------
@ -511,13 +510,8 @@ useEffect(() => {
const up = await authFetch('/api/user-profile'); const up = await authFetch('/api/user-profile');
if (up.ok) setUserProfile(await up.json()); if (up.ok) setUserProfile(await up.json());
const fp = await authFetch('/api/premium/financial-profile'); const fp = await authFetch('api/premium/financial-profile');
if (fp.status === 404) { if (fp.ok) setFinancialProfile(await fp.json());
// user skipped onboarding treat as empty object
setFinancialProfile({});
} else if (fp.ok) {
setFinancialProfile(await fp.json());
}
})(); })();
}, []); }, []);
@ -828,7 +822,7 @@ async function fetchAiRisk(socCode, careerName, description, tasks) {
try { try {
// 1) Check server2 for existing entry // 1) Check server2 for existing entry
const localRiskRes = await axios.get(`api/ai-risk/${socCode}`); const localRiskRes = await axios.get('api/ai-risk/${socCode}');
aiRisk = localRiskRes.data; // { socCode, riskLevel, ... } aiRisk = localRiskRes.data; // { socCode, riskLevel, ... }
} catch (err) { } catch (err) {
// 2) If 404 => call server3 // 2) If 404 => call server3
@ -959,6 +953,7 @@ useEffect(() => {
// 8) Build financial projection // 8) Build financial projection
async function buildProjection(milestones) { async function buildProjection(milestones) {
if (!milestones?.length) return;
const allMilestones = milestones || []; const allMilestones = milestones || [];
try { try {
setScenarioMilestones(allMilestones); setScenarioMilestones(allMilestones);
@ -1300,14 +1295,6 @@ const fetchMilestones = useCallback(async () => {
} // single rebuild } // single rebuild
}, [financialProfile, scenarioRow, careerProfileId]); // ← NOTICE: no buildProjection here }, [financialProfile, scenarioRow, careerProfileId]); // ← NOTICE: no buildProjection here
const handleMilestonesCreated = useCallback(
(count = 0) => {
// optional toast
if (count) console.log(`💾 ${count} milestone(s) saved refreshing list…`);
fetchMilestones();
},
[fetchMilestones]
);
return ( return (
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4"> <div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
@ -1537,10 +1524,7 @@ const handleMilestonesCreated = useCallback(
{/* Milestones stacked list under chart */} {/* Milestones stacked list under chart */}
<div className="mt-4 bg-white p-4 rounded shadow"> <div className="mt-4 bg-white p-4 rounded shadow">
<h4 className="text-lg font-semibold mb-2"> <h4 className="text-lg font-semibold mb-2">Milestones</h4>
Milestones
<InfoTooltip message="Milestones are career or life events—promotions, relocations, degree completions, etc.—that may change your income or spending. They feed directly into the financial projection if they have a financial impact." />
</h4>
<MilestonePanel <MilestonePanel
groups={milestoneGroups} groups={milestoneGroups}
onEdit={onEditMilestone} onEdit={onEditMilestone}

View File

@ -26,9 +26,7 @@ const CareerSelectDropdown = ({ existingCareerProfiles, selectedCareer, onChange
return ( return (
<div className="career-select-dropdown"> <div className="career-select-dropdown">
<label className="block font-medium mb-1"> <label>Select Career Path:</label>
Select the career this college plan belongs to:
</label>
{loading ? ( {loading ? (
<p>Loading career paths...</p> <p>Loading career paths...</p>
) : ( ) : (

View File

@ -1,512 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import authFetch from '../utils/authFetch.js';
/** -----------------------------------------------------------
* Ensure numerics are sent as numbers and booleans as 0 / 1
* mirrors the logic you use in OnboardingContainer
* ---------------------------------------------------------- */
const parseFloatOrNull = v => {
if (v === '' || v == null) return null;
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
};
function normalisePayload(draft) {
const bools = [
'is_in_state','is_in_district','is_online',
'loan_deferral_until_graduation'
];
const nums = [
'annual_financial_aid','existing_college_debt','interest_rate','loan_term',
'extra_payment','expected_salary','credit_hours_per_year','hours_completed',
'credit_hours_required','program_length','tuition','tuition_paid'
];
const dates = ['enrollment_date', 'expected_graduation'];
const out = { ...draft };
bools.forEach(k => { out[k] = draft[k] ? 1 : 0; });
nums .forEach(k => { out[k] = parseFloatOrNull(draft[k]) ?? 0; });
dates.forEach(k => { out[k] = toMySqlDate(draft[k]); });
delete out.created_at;
delete out.updated_at;
return out;
}
const toMySqlDate = iso => {
if (!iso) return null;
return iso.replace('T', ' ').slice(0, 19);
};
export default function CollegeProfileForm() {
const { careerId, id } = useParams(); // id optional
const nav = useNavigate();
const token = localStorage.getItem('token');
const [cipRows, setCipRows] = useState([]);
const [schoolSug, setSchoolSug] = useState([]);
const [progSug, setProgSug] = useState([]);
const [types, setTypes] = useState([]);
const [ipeds, setIpeds] = useState([]);
const [schoolValid, setSchoolValid] = useState(true);
const [programValid, setProgramValid] = useState(true);
const schoolData = cipRows;
const [form, setForm] = useState({
career_profile_id : careerId,
selected_school : '',
selected_program : '',
program_type : '',
annual_financial_aid : 0,
tuition : 0,
interest_rate : 5.5,
loan_term : 10
});
const [manualTuition, setManualTuition] = useState(
form.tuition ? String(form.tuition) : ''
);
const [autoTuition, setAutoTuition] = useState(0);
// ---------- handlers (inside component) ----------
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
setForm((prev) => {
const draft = { ...prev };
if (type === 'checkbox') {
draft[name] = checked;
} else if (
[
'interest_rate','loan_term','extra_payment','expected_salary',
'annual_financial_aid','existing_college_debt','credit_hours_per_year',
'hours_completed','credit_hours_required','tuition','tuition_paid',
'program_length'
].includes(name)
) {
draft[name] = value === '' ? '' : parseFloat(value);
} else {
draft[name] = value;
}
return draft;
});
};
const onSchoolInput = (e) => {
handleFieldChange(e);
const v = e.target.value.toLowerCase();
const suggestions = cipRows
.filter((r) => r.INSTNM.toLowerCase().includes(v))
.map((r) => r.INSTNM);
setSchoolSug([...new Set(suggestions)].slice(0, 10));
};
const onProgramInput = (e) => {
handleFieldChange(e);
if (!form.selected_school) return;
const v = e.target.value.toLowerCase();
const sug = cipRows
.filter(
(r) =>
r.INSTNM.toLowerCase() === form.selected_school.toLowerCase() &&
r.CIPDESC.toLowerCase().includes(v)
)
.map((r) => r.CIPDESC);
setProgSug([...new Set(sug)].slice(0, 10));
};
useEffect(() => {
if (id && id !== 'new') {
fetch(`/api/premium/college-profile?careerProfileId=${careerId}`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(r => r.json())
.then(setForm);
}
}, [careerId, id, token]);
async function handleSave(){
try{
const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId });
const res = await authFetch('/api/premium/college-profile',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)
});
if(!res.ok) throw new Error(await res.text());
alert('Saved!');
setForm(p => ({ ...p, tuition: chosenTuition }));
setManualTuition(String(chosenTuition));
nav(-1);
}catch(err){ console.error(err); alert(err.message);}
}
/* LOAD iPEDS ----------------------------- */
useEffect(() => {
fetch('/ic2023_ay.csv')
.then(r => r.text())
.then(text => {
const rows = text.split('\n').map(l => l.split(','));
const headers = rows[0];
const parsed = rows.slice(1).map(r =>
Object.fromEntries(r.map((v,i)=>[headers[i], v]))
);
setIpeds(parsed); // you already declared setIpeds
})
.catch(err => console.error('iPEDS load failed', err));
}, []);
useEffect(() => { fetch('/cip_institution_mapping_new.json')
.then(r=>r.text()).then(t => setCipRows(
t.split('\n').map(l=>{try{return JSON.parse(l)}catch{ return null }})
.filter(Boolean)
));
fetch('/ic2023_ay.csv')
.then(r=>r.text()).then(csv=>{/* identical to CollegeOnboarding */});
},[]);
useEffect(()=>{
if(!form.selected_school || !form.selected_program) { setTypes([]); return; }
const t = cipRows.filter(r =>
r.INSTNM.toLowerCase()===form.selected_school.toLowerCase() &&
r.CIPDESC===form.selected_program)
.map(r=>r.CREDDESC);
setTypes([...new Set(t)]);
},[form.selected_school, form.selected_program, cipRows]);
useEffect(() => {
if (!ipeds.length) return;
if (!form.selected_school ||
!form.program_type ||
!form.credit_hours_per_year) return;
/* 1 ─ locate UNITID */
const sch = cipRows.find(
r => r.INSTNM.toLowerCase() === form.selected_school.toLowerCase()
);
if (!sch) return;
const unitId = sch.UNITID;
const row = ipeds.find(r => r.UNITID === unitId);
if (!row) return;
/* 2 ─ decide instate / district buckets */
const grad = [
"Master's Degree","Doctoral Degree",
"Graduate/Professional Certificate","First Professional Degree"
].includes(form.program_type);
const pick = (codeInDist, codeInState, codeOut) => {
if (form.is_in_district) return row[codeInDist];
else if (form.is_in_state) return row[codeInState];
else return row[codeOut];
};
const partTime = grad
? pick('HRCHG5','HRCHG6','HRCHG7')
: pick('HRCHG1','HRCHG2','HRCHG3');
const fullTime = grad
? pick('TUITION5','TUITION6','TUITION7')
: pick('TUITION1','TUITION2','TUITION3');
const chpy = parseFloat(form.credit_hours_per_year) || 0;
const est = chpy && chpy < 24
? parseFloat(partTime || 0) * chpy
: parseFloat(fullTime || 0);
setAutoTuition(Math.round(est));
}, [
ipeds,
cipRows,
form.selected_school,
form.program_type,
form.credit_hours_per_year,
form.is_in_state,
form.is_in_district
]);
const handleManualTuitionChange = e => setManualTuition(e.target.value);
const chosenTuition = manualTuition.trim() === ''
? autoTuition
: parseFloat(manualTuition);
return (
<div className="max-w-md mx-auto p-6 space-y-4">
<h2 className="text-2xl font-semibold">
{id === 'new' ? 'New' : 'Edit'} College Plan
</h2>
<div className="space-y-4">
{/* 1 │ Location / modality checkboxes */}
{[
{ n:'is_in_district', l:'In District?' },
{ n:'is_in_state', l:'InState Tuition?' },
{ n:'is_online', l:'Program is Fully Online' },
{ n:'loan_deferral_until_graduation',
l:'Defer Loan Payments untilGraduation?' }
].map(({n,l}) => (
<div key={n} className="flex items-center space-x-2">
<input type="checkbox" name={n}
className="h-4 w-4"
checked={!!form[n]}
onChange={handleFieldChange}/>
<label className="font-medium">{l}</label>
</div>
))}
{/* 2 │ School picker */}
<div className="space-y-1">
<label className="block font-medium">
School Name * (choose from list)
</label>
<input
name="selected_school"
value={form.selected_school}
onChange={onSchoolInput}
onBlur={() => {
const ok = cipRows.some(
r => r.INSTNM.toLowerCase() === form.selected_school.toLowerCase()
);
setSchoolValid(ok);
if (!ok) alert('Please pick a school from the list.');
}}
list="school-suggestions"
placeholder="Start typing and choose…"
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
required
/>
<datalist id="school-suggestions">
{schoolSug.map((s,i)=>(
<option key={i} value={s} />
))}
</datalist>
</div>
{/* 3 │ Program picker */}
<div className="space-y-1">
<label className="block font-medium">
Major / Program * (choose from list)
</label>
<input
name="selected_program"
value={form.selected_program}
onChange={onProgramInput}
onBlur={() => {
const ok =
form.selected_school && // need a school first
cipRows.some(
r =>
r.INSTNM.toLowerCase() === form.selected_school.toLowerCase() &&
r.CIPDESC.toLowerCase() === form.selected_program.toLowerCase()
);
setProgramValid(ok);
if (!ok) alert('Please pick a program from the list.');
}}
list="program-suggestions"
placeholder="Start typing and choose…"
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
required
/>
<datalist id="program-suggestions">
{progSug.map((p,i)=>(
<option key={i} value={p} />
))}
</datalist>
</div>
{/* 4 │ Programtype */}
<div className="space-y-1">
<label className="block font-medium">Degree Type *</label>
<select
name="program_type"
value={form.program_type}
onChange={handleFieldChange}
className="w-full border rounded p-2"
required
>
<option value="">Select Program Type</option>
{types.map((t,i)=><option key={i} value={t}>{t}</option>)}
</select>
</div>
{/* 5 │ Academic calendar */}
<div className="space-y-1">
<label className="block font-medium">Academic Calendar</label>
<select
name="academic_calendar"
value={form.academic_calendar || 'semester'}
onChange={handleFieldChange}
className="w-full border rounded p-2"
>
<option value="semester">Semester</option>
<option value="quarter">Quarter</option>
<option value="trimester">Trimester</option>
<option value="other">Other</option>
</select>
</div>
{/* 6 │ Credithour fields (conditionally rendered) */}
{(form.program_type === 'Graduate/Professional Certificate' ||
form.program_type === 'First Professional Degree' ||
form.program_type === 'Doctoral Degree' ||
form.program_type === 'Undergraduate Certificate or Diploma') && (
<div className="space-y-1">
<label className="block font-medium">Credit Hours Required</label>
<input
type="number"
name="credit_hours_required"
value={form.credit_hours_required}
onChange={handleFieldChange}
className="w-full border rounded p-2"
/>
</div>
)}
<div className="space-y-1">
<label className="block font-medium">Credit Hours Per Year</label>
<input
type="number"
name="credit_hours_per_year"
value={form.credit_hours_per_year}
onChange={handleFieldChange}
className="w-full border rounded p-2"
/>
</div>
{/* 7 │ Tuition & aid */}
<div className="space-y-1">
<label className="block font-medium">Yearly Tuition</label>
<input
type="number"
value={
manualTuition.trim() === '' ? autoTuition : manualTuition
}
onChange={handleManualTuitionChange}
placeholder="Blank = autocalculated"
className="w-full border rounded p-2"
/>
</div>
<span className="font-medium">Annual Aid</span>
<input
type="number"
name="annual_financial_aid"
value={form.annual_financial_aid}
onChange={handleFieldChange}
className="mt-1 w-full border rounded p-2"
/>
{/* 8 │ Existing debt */}
<div className="space-y-1">
<label className="block font-medium">Existing College Debt</label>
<input
type="number"
name="existing_college_debt"
value={form.existing_college_debt}
onChange={handleFieldChange}
className="w-full border rounded p-2"
/>
</div>
{/* 9 │ Programlength & hourscompleted */}
{(form.college_enrollment_status === 'currently_enrolled' ||
form.college_enrollment_status === 'prospective_student') && (
<>
<div className="space-y-1">
<label className="block font-medium">Program Length (years)</label>
<input
type="number"
name="program_length"
value={form.program_length}
onChange={handleFieldChange}
className="w-full border rounded p-2"
/>
</div>
{form.college_enrollment_status === 'currently_enrolled' && (
<div className="space-y-1">
<label className="block font-medium">Hours Completed</label>
<input
type="number"
name="hours_completed"
value={form.hours_completed}
onChange={handleFieldChange}
className="w-full border rounded p-2"
/>
</div>
)}
</>
)}
{/* 10 │ Interest, term, extra payment, salary */}
<div className="flex space-x-4">
<label className="block flex-1">
<span className="font-medium">Interest %</span>
<input
type="number"
name="interest_rate"
value={form.interest_rate}
onChange={handleFieldChange}
className="mt-1 w-full border rounded p-2"
/>
</label>
<label className="block flex-1">
<span className="font-medium">Loan Term (years)</span>
<input
type="number"
name="loan_term"
value={form.loan_term}
onChange={handleFieldChange}
className="mt-1 w-full border rounded p-2"
/>
</label>
</div>
<div className="space-y-1">
<label className="block font-medium">Extra Monthly Payment</label>
<input
type="number"
name="extra_payment"
value={form.extra_payment}
onChange={handleFieldChange}
className="w-full border rounded p-2"
/>
</div>
<div className="space-y-1">
<label className="block font-medium">Expected Salary After Graduation</label>
<input
type="number"
name="expected_salary"
value={form.expected_salary}
onChange={handleFieldChange}
className="w-full border rounded p-2"
/>
</div>
</div>
{/* 11 │ Action buttons */}
<div className="pt-4">
<button
onClick={() => nav(-1)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-2 px-4 rounded mr-3"
>
 Back
</button>
<button
onClick={handleSave}
disabled={!schoolValid || !programValid}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
>
Save
</button>
</div>
</div>
);
}

View File

@ -1,154 +0,0 @@
// src/components/CollegeProfileList.js
import React, { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import CareerSelectDropdown from "./CareerSelectDropdown.js";
import authFetch from "../utils/authFetch.js";
export default function CollegeProfileList() {
const { careerId } = useParams(); // may be undefined
const navigate = useNavigate();
const token = localStorage.getItem("token");
/* ───────── existing lists ───────── */
const [rows, setRows] = useState([]);
const [careerRows, setCareerRows] = useState([]);
/* ───────── ui state ───────── */
const [showPicker, setShowPicker] = useState(false);
const [loadingCareers, setLoadingCareers] = useState(true);
/* ───────── load college plans ───────── */
useEffect(() => {
fetch("/api/premium/college-profile/all", {
headers: { Authorization: `Bearer ${token}` }
})
.then((r) => r.json())
.then((d) => setRows(d.collegeProfiles || []));
}, [token]);
/* ───────── load career profiles for the picker ───────── */
useEffect(() => {
(async () => {
try {
const res = await authFetch("/api/premium/career-profile/all");
const data = await res.json();
setCareerRows(data.careerProfiles || []);
} catch (err) {
console.error("Career profiles load failed:", err);
} finally {
setLoadingCareers(false);
}
})();
}, []);
/* ───────── delete helper ───────── */
async function handleDelete(id) {
if (!window.confirm("Delete this college plan?")) return;
try {
await fetch(`/api/premium/college-profile/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
});
setRows((r) => r.filter((row) => row.id !== id));
} catch (err) {
console.error("Delete failed:", err);
alert("Could not delete see console.");
}
}
return (
<div className="max-w-5xl mx-auto space-y-6">
{/* ───────── header row ───────── */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">College Plans</h2>
{/* newplan button & inline picker */}
{!showPicker ? (
<button
onClick={() => setShowPicker(true)}
className="px-3 py-2 bg-blue-600 text-white rounded"
>
+ New College Plan
</button>
) : (
<div className="p-4 border rounded bg-gray-50 max-w-md">
<CareerSelectDropdown
existingCareerProfiles={careerRows}
selectedCareer={null}
loading={loadingCareers}
authFetch={authFetch}
onChange={(careerObj) => {
if (!careerObj?.id) return;
navigate(`/profile/college/${careerObj.id}/new`);
}}
/>
<div className="mt-2 text-right">
<button
onClick={() => setShowPicker(false)}
className="text-sm text-gray-600 underline"
>
cancel
</button>
</div>
</div>
)}
</div>
{/* ───────── table of existing college plans ───────── */}
<table className="w-full border text-sm">
<thead className="bg-gray-100">
<tr>
<th className="p-2 text-left">Career</th>
<th className="p-2 text-left">School</th>
<th className="p-2 text-left">Program</th>
<th className="p-2">Created</th>
<th className="p-2"></th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-t">
<td className="p-2">{r.career_title}</td>
<td className="p-2">{r.selected_school}</td>
<td className="p-2">{r.selected_program}</td>
<td className="p-2">{r.created_at?.slice(0, 10)}</td>
<td className="p-2 space-x-2 whitespace-nowrap">
<Link
to={`/profile/college/${r.career_profile_id}/${r.id}`}
className="underline text-blue-600"
>
edit
</Link>
<button
onClick={() => handleDelete(r.id)}
className="underline text-red-600"
>
delete
</button>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
No college profiles yet.&nbsp;
<button
className="text-blue-600 underline"
onClick={() =>
careerId
? navigate(`/profile/college/${careerId}/new`)
: setShowPicker(true)
}
>
Create one now.
</button>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@ -1,219 +1,181 @@
// FinancialProfileForm.js // FinancialProfileForm.js
import React, { useEffect, useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import authFetch from '../utils/authFetch.js';
import authFetch from '../utils/authFetch.js'; function FinancialProfileForm() {
import Modal from './ui/modal.js'; // We'll store the fields in local state
import ExpensesWizard from './ExpensesWizard.js'; // same wizard you use in onboarding const [currentSalary, setCurrentSalary] = useState('');
import { Button } from './ui/button.js'; // Tailwindbased button (optional) const [additionalIncome, setAdditionalIncome] = useState('');
const [monthlyExpenses, setMonthlyExpenses] = useState('');
const [monthlyDebtPayments, setMonthlyDebtPayments] = useState('');
const [retirementSavings, setRetirementSavings] = useState('');
const [emergencyFund, setEmergencyFund] = useState('');
const [retirementContribution, setRetirementContribution] = useState('');
const [monthlyEmergencyContribution, setMonthlyEmergencyContribution] = useState('');
const [extraCashEmergencyPct, setExtraCashEmergencyPct] = useState('');
const [extraCashRetirementPct, setExtraCashRetirementPct] = useState('');
/* helper clamp 0100 */
const pct = v => Math.min(Math.max(parseFloat(v) || 0, 0), 100);
export default function FinancialProfileForm() {
const nav = useNavigate();
/* ─────────────── local state ─────────────── */
const [currentSalary, setCurrentSalary] = useState('');
const [additionalIncome, setAdditionalIncome] = useState('');
const [monthlyExpenses, setMonthlyExpenses] = useState('');
const [monthlyDebtPayments, setMonthlyDebtPayments] = useState('');
const [retirementSavings, setRetirementSavings] = useState('');
const [emergencyFund, setEmergencyFund] = useState('');
const [retirementContribution, setRetirementContribution] = useState('');
const [emergencyContribution, setEmergencyContribution] = useState('');
const [extraCashEmergencyPct, setExtraCashEmergencyPct] = useState('50');
const [extraCashRetirementPct, setExtraCashRetirementPct] = useState('50');
/* wizard modal */
const [showExpensesWizard, setShowExpensesWizard] = useState(false);
const openWizard = () => setShowExpensesWizard(true);
const closeWizard = () => setShowExpensesWizard(false);
/* ───────────── preload existing row ───────── */
useEffect(() => { useEffect(() => {
(async () => { // On mount, fetch the user's existing profile from the new financial_profiles table
async function fetchProfile() {
try { try {
const res = await authFetch('/api/premium/financial-profile'); const res = await authFetch('/api/premium/financial-profile', {
if (!res.ok) return; method: 'GET'
const d = await res.json(); });
if (res.ok) {
setCurrentSalary (d.current_salary ?? ''); const data = await res.json();
setAdditionalIncome (d.additional_income ?? ''); // data might be an empty object if no row yet
setMonthlyExpenses (d.monthly_expenses ?? ''); setCurrentSalary(data.current_salary || '');
setMonthlyDebtPayments (d.monthly_debt_payments ?? ''); setAdditionalIncome(data.additional_income || '');
setRetirementSavings (d.retirement_savings ?? ''); setMonthlyExpenses(data.monthly_expenses || '');
setEmergencyFund (d.emergency_fund ?? ''); setMonthlyDebtPayments(data.monthly_debt_payments || '');
setRetirementContribution (d.retirement_contribution ?? ''); setRetirementSavings(data.retirement_savings || '');
setEmergencyContribution (d.emergency_contribution ?? ''); setEmergencyFund(data.emergency_fund || '');
setExtraCashEmergencyPct (d.extra_cash_emergency_pct ?? ''); setRetirementContribution(data.retirement_contribution || '');
setExtraCashRetirementPct (d.extra_cash_retirement_pct ?? ''); setMonthlyEmergencyContribution(data.monthly_emergency_contribution || '');
} catch (err) { console.error(err); } setExtraCashEmergencyPct(data.extra_cash_emergency_pct || '');
})(); setExtraCashRetirementPct(data.extra_cash_retirement_pct || '');
}, []); }
} catch (err) {
/* ----------------------------------------------------------- console.error("Failed to load financial profile:", err);
* keep the two % inputs complementary (must add to 100)
* --------------------------------------------------------- */
function handleChange(e) {
const { name, value } = e.target;
const pct = Math.max(0, Math.min(100, Number(value) || 0)); // clamp 0100
if (name === 'extraCashEmergencyPct') {
setExtraCashEmergencyPct(String(pct));
setExtraCashRetirementPct(String(100 - pct));
} else if (name === 'extraCashRetirementPct') {
setExtraCashRetirementPct(String(pct));
setExtraCashEmergencyPct(String(100 - pct));
} else {
// all other numeric fields:
// allow empty string so users can clear then retype
const update = valSetter => valSetter(value === '' ? '' : Number(value));
switch (name) {
case 'currentSalary': update(setCurrentSalary); break;
case 'additionalIncome': update(setAdditionalIncome); break;
case 'monthlyExpenses': update(setMonthlyExpenses); break;
case 'monthlyDebtPayments': update(setMonthlyDebtPayments); break;
case 'retirementSavings': update(setRetirementSavings); break;
case 'emergencyFund': update(setEmergencyFund); break;
case 'retirementContribution': update(setRetirementContribution); break;
case 'emergencyContribution': update(setEmergencyContribution); break;
default: break;
} }
} }
} fetchProfile();
}, []);
/* ───────────── submit ─────────────────────── */ // Submit form updates => POST to the same endpoint
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
const body = {
current_salary: parseFloat(currentSalary) || 0,
additional_income: parseFloat(additionalIncome) || 0,
monthly_expenses: parseFloat(monthlyExpenses) || 0,
monthly_debt_payments: parseFloat(monthlyDebtPayments) || 0,
retirement_savings: parseFloat(retirementSavings) || 0,
emergency_fund: parseFloat(emergencyFund) || 0,
retirement_contribution: parseFloat(retirementContribution) || 0,
emergency_contribution: parseFloat(emergencyContribution) || 0,
extra_cash_emergency_pct: pct(extraCashEmergencyPct),
extra_cash_retirement_pct: pct(extraCashRetirementPct)
};
try { try {
const body = {
current_salary: parseFloat(currentSalary) || 0,
additional_income: parseFloat(additionalIncome) || 0,
monthly_expenses: parseFloat(monthlyExpenses) || 0,
monthly_debt_payments: parseFloat(monthlyDebtPayments) || 0,
retirement_savings: parseFloat(retirementSavings) || 0,
emergency_fund: parseFloat(emergencyFund) || 0,
retirement_contribution: parseFloat(retirementContribution) || 0,
monthly_emergency_contribution: parseFloat(monthlyEmergencyContribution) || 0,
extra_cash_emergency_pct: parseFloat(extraCashEmergencyPct) || 0,
extra_cash_retirement_pct: parseFloat(extraCashRetirementPct) || 0
};
const res = await authFetch('/api/premium/financial-profile', { const res = await authFetch('/api/premium/financial-profile', {
method : 'POST', method: 'POST',
headers: { 'Content-Type':'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(body) body: JSON.stringify(body)
}); });
if (!res.ok) throw new Error(await res.text());
alert('Financial profile saved.'); if (res.ok) {
nav(-1); // show success or redirect
console.log("Profile updated");
} else {
console.error("Failed to update profile:", await res.text());
}
} catch (err) { } catch (err) {
console.error(err); console.error("Error submitting financial profile:", err);
alert('Failed to save financial profile.');
} }
} }
/* ───────────── view ───────────────────────── */
return ( return (
<> <form onSubmit={handleSubmit} className="max-w-2xl mx-auto p-4 space-y-4 bg-white shadow rounded">
<form <h2 className="text-xl font-semibold">Edit Your Financial Profile</h2>
onSubmit={handleSubmit}
className="max-w-2xl mx-auto p-6 space-y-4 bg-white shadow rounded"
>
<h2 className="text-xl font-semibold">EditYourFinancialProfile</h2>
{/* salary / income */} <label className="block font-medium">Current Salary</label>
<label className="block font-medium">CurrentAnnualSalary</label> <input
<input type="number" className="w-full border rounded p-2" type="number"
name="currentSalary" value={currentSalary} onChange={handleChange} /> value={currentSalary}
onChange={(e) => setCurrentSalary(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">AdditionalAnnualIncome</label> <label className="block font-medium">Additional Monthly Income</label>
<input type="number" className="w-full border rounded p-2" <input
name="additionalIncome" value={additionalIncome} onChange={handleChange} /> type="number"
value={additionalIncome}
onChange={(e) => setAdditionalIncome(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
{/* expenses with wizard */} <label className="block font-medium">Monthly Living Expenses</label>
<label className="block font-medium">MonthlyLivingExpenses</label> <input
<div className="flex space-x-2 items-center"> type="number"
<input type="number" className="w-full border rounded p-2" value={monthlyExpenses}
value={monthlyExpenses} onChange={(e) => setMonthlyExpenses(e.target.value)}
onChange={e=>setMonthlyExpenses(e.target.value)} /> className="w-full border rounded p-2"
<Button className="bg-blue-600 text-white px-3 py-2 rounded" placeholder="$"
type="button" onClick={openWizard}> />
Need Help?
</Button>
</div>
{/* rest of the numeric fields */} <label className="block font-medium">Monthly Debt Payments</label>
<label className="block font-medium">MonthlyDebtPayments</label> <input
<input type="number" className="w-full border rounded p-2" type="number"
name="monthlyDebtPayments" value={monthlyDebtPayments} onChange={handleChange} /> value={monthlyDebtPayments}
onChange={(e) => setMonthlyDebtPayments(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">RetirementSavings</label> <label className="block font-medium">Retirement Savings</label>
<input type="number" className="w-full border rounded p-2" <input
name="retirementSavings" value={retirementSavings} onChange={handleChange} /> type="number"
value={retirementSavings}
onChange={(e) => setRetirementSavings(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">EmergencyFund</label> <label className="block font-medium">Emergency Fund</label>
<input type="number" className="w-full border rounded p-2" <input
name="emergencyFund" value={emergencyFund} onChange={handleChange} /> type="number"
value={emergencyFund}
onChange={(e) => setEmergencyFund(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">MonthlyRetirementContribution</label> <label className="block font-medium">Monthly Retirement Contribution</label>
<input type="number" className="w-full border rounded p-2" <input
name="retirementContribution" value={retirementContribution} onChange={handleChange} /> type="number"
value={retirementContribution}
onChange={(e) => setRetirementContribution(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">MonthlyEmergencyContribution</label> <label className="block font-medium">Monthly Emergency Contribution</label>
<input type="number" className="w-full border rounded p-2" <input
name="emergencyContribution" type="number"
value={emergencyContribution} value={monthlyEmergencyContribution}
onChange={handleChange} /> onChange={(e) => setMonthlyEmergencyContribution(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
{/* allocation kept in sync */} <label className="block font-medium">Extra Cash to Emergency (%)</label>
<h3 className="text-lg font-medium pt-2">ExtraMonthlyCashAllocation (must total100%)</h3> <input
type="number"
value={extraCashEmergencyPct}
onChange={(e) => setExtraCashEmergencyPct(e.target.value)}
className="w-full border rounded p-2"
placeholder="e.g. 30"
/>
<label className="block font-medium">ToEmergencyFund(%)</label> <label className="block font-medium">Extra Cash to Retirement (%)</label>
<input type="number" className="w-full border rounded p-2" <input
name="extraCashEmergencyPct" type="number"
value={extraCashEmergencyPct} value={extraCashRetirementPct}
onChange={handleChange} /> onChange={(e) => setExtraCashRetirementPct(e.target.value)}
className="w-full border rounded p-2"
placeholder="e.g. 70"
/>
<label className="block font-medium">ToRetirement(%)</label> <button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
<input type="number" className="w-full border rounded p-2" Save and Continue
name="extraCashRetirementPct" </button>
value={extraCashRetirementPct} </form>
onChange={handleChange} />
{/* action buttons */}
<div className="pt-4 flex justify-between">
<button
type="button"
onClick={()=>nav(-1)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-2 px-4 rounded"
>
Back
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
>
Save
</button>
</div>
</form>
{/* wizard modal */}
{showExpensesWizard && (
<Modal onClose={closeWizard}>
<ExpensesWizard
onClose={closeWizard}
onExpensesCalculated={total => {
setMonthlyExpenses(total);
closeWizard();
}}
/>
</Modal>
)}
</>
); );
} }
export default FinancialProfileForm;

View File

@ -2,257 +2,263 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
/* const MilestoneAddModal = ({
CONSTANTS
*/
const IMPACT_TYPES = ['salary', 'cost', 'tuition', 'note'];
const FREQ_OPTIONS = ['ONE_TIME', 'MONTHLY'];
export default function MilestoneAddModal({
show, show,
onClose, onClose,
scenarioId, // active scenario UUID defaultScenarioId,
editMilestone = null // pass full row when editing scenarioId, // which scenario this milestone applies to
}) { editMilestone, // if editing an existing milestone, pass its data
/* ────────────── state ────────────── */ }) => {
const [title, setTitle] = useState(''); // Basic milestone fields
const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [impacts, setImpacts] = useState([]);
/* ────────────── init / reset ────────────── */ // We'll store an array of impacts. Each impact is { impact_type, direction, amount, start_month, end_month }
const [impacts, setImpacts] = useState([]);
// On open, if editing, fill in existing fields
useEffect(() => { useEffect(() => {
if (!show) return; if (!show) return; // if modal is hidden, do nothing
if (editMilestone) { if (editMilestone) {
setTitle(editMilestone.title || ''); setTitle(editMilestone.title || '');
setDescription(editMilestone.description || ''); setDescription(editMilestone.description || '');
setImpacts(editMilestone.impacts || []); // If editing, you might fetch existing impacts from the server or they could be passed in
if (editMilestone.impacts) {
setImpacts(editMilestone.impacts);
} else {
// fetch from backend if needed
// e.g. GET /api/premium/milestones/:id/impacts
}
} else { } else {
setTitle(''); setDescription(''); setImpacts([]); // Creating a new milestone
setTitle('');
setDescription('');
setImpacts([]);
} }
}, [show, editMilestone]); }, [show, editMilestone]);
/* ────────────── helpers ────────────── */ // Handler: add a new blank impact
const addImpactRow = () => const handleAddImpact = () => {
setImpacts(prev => [ setImpacts((prev) => [
...prev, ...prev,
{ {
impact_type : 'cost', impact_type: 'ONE_TIME',
frequency : 'ONE_TIME', direction: 'subtract',
direction : 'subtract', amount: 0,
amount : 0, start_month: 0,
start_date : '', // ISO yyyymmdd end_month: null
end_date : '' // blank ⇒ indefinite
} }
]); ]);
};
const updateImpact = (idx, field, value) => // Handler: update a single impact in the array
setImpacts(prev => { const handleImpactChange = (index, field, value) => {
const copy = [...prev]; setImpacts((prev) => {
copy[idx] = { ...copy[idx], [field]: value }; const updated = [...prev];
return copy; updated[index] = { ...updated[index], [field]: value };
return updated;
}); });
};
const removeImpact = idx => // Handler: remove an impact row
setImpacts(prev => prev.filter((_, i) => i !== idx)); const handleRemoveImpact = (index) => {
setImpacts((prev) => prev.filter((_, i) => i !== index));
};
/* ────────────── save ────────────── */ // Handler: Save everything to the server
async function handleSave() { const handleSave = async () => {
try { try {
/* 1⃣ create OR update the milestone row */ let milestoneId;
let milestoneId = editMilestone?.id; if (editMilestone) {
if (milestoneId) { // 1) Update existing milestone
milestoneId = editMilestone.id;
await authFetch(`api/premium/milestones/${milestoneId}`, { await authFetch(`api/premium/milestones/${milestoneId}`, {
method : 'PUT', method: 'PUT',
headers: { 'Content-Type':'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ title, description }) body: JSON.stringify({
});
} else {
const res = await authFetch('api/premium/milestones', {
method : 'POST',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify({
title, title,
description, description,
career_profile_id: scenarioId scenario_id: scenarioId,
// Possibly other fields
}) })
}); });
if (!res.ok) throw new Error('Milestone create failed'); // Then handle impacts below...
const json = await res.json(); } else {
milestoneId = json.id ?? json[0]?.id; // array OR obj // 1) Create new milestone
const res = await authFetch('api/premium/milestones', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
scenario_id: scenarioId
})
});
if (!res.ok) throw new Error('Failed to create milestone');
const created = await res.json();
milestoneId = created.id; // assuming the response returns { id: newMilestoneId }
} }
/* 2⃣ upsert each impact (one call per row) */ // 2) For the impacts, we can do a batch approach or individual calls
for (const imp of impacts) { // For simplicity, let's do multiple POST calls
const body = { for (const impact of impacts) {
milestone_id : milestoneId, // If editing, you might do a PUT if the impact already has an id
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', { await authFetch('api/premium/milestone-impacts', {
method : 'POST', method: 'POST',
headers: { 'Content-Type':'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(body) body: JSON.stringify({
milestone_id: milestoneId,
impact_type: impact.impact_type,
direction: impact.direction,
amount: parseFloat(impact.amount) || 0,
start_month: parseInt(impact.start_month, 10) || 0,
end_month: impact.end_month !== null
? parseInt(impact.end_month, 10)
: null,
created_at: new Date().toISOString().slice(0, 10),
updated_at: new Date().toISOString().slice(0, 10)
})
}); });
} }
onClose(true); // ← parent will refetch // Done, close modal
onClose();
} catch (err) { } catch (err) {
console.error('Save failed:', err); console.error('Failed to save milestone + impacts:', err);
alert('Sorry, something went wrong please try again.'); // Show some UI error if needed
} }
} };
/* ────────────── UI ────────────── */
if (!show) return null; if (!show) return null;
return ( return (
<div className="modal-backdrop"> <div className="modal-backdrop">
<div className="modal-container w-full max-w-lg"> <div className="modal-container">
<h2 className="text-xl font-bold mb-2"> <h2 className="text-xl font-bold mb-2">
{editMilestone ? 'Edit Milestone' : 'Add Milestone'} {editMilestone ? 'Edit Milestone' : 'Add Milestone'}
</h2> </h2>
{/* basic fields */} <div className="mb-3">
<label className="block font-semibold mt-2">Title</label> <label className="block font-semibold">Title</label>
<input <input
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
className="border w-full px-2 py-1" className="border w-full px-2 py-1"
/> />
</div>
<label className="block font-semibold mt-4">Description</label> <div className="mb-3">
<textarea <label className="block font-semibold">Description</label>
value={description} <textarea
onChange={e => setDescription(e.target.value)} value={description}
rows={3} onChange={(e) => setDescription(e.target.value)}
className="border w-full px-2 py-1" className="border w-full px-2 py-1"
/> />
</div>
{/* impacts */} {/* Impacts Section */}
<h3 className="text-lg font-semibold mt-6">FinancialImpacts</h3> <h3 className="text-lg font-semibold mt-4">Financial Impacts</h3>
{impacts.map((impact, i) => (
{impacts.map((imp, i) => ( <div key={i} className="border rounded p-2 my-2">
<div key={i} className="border rounded p-3 mt-4 space-y-2"> <div className="flex items-center justify-between">
<div className="flex justify-between items-center"> <p>Impact #{i + 1}</p>
<span className="font-medium">Impact #{i + 1}</span>
<button <button
className="text-red-600 text-sm" className="text-red-500"
onClick={() => removeImpact(i)} onClick={() => handleRemoveImpact(i)}
> >
Remove Remove
</button> </button>
</div> </div>
{/* type */} {/* Impact Type */}
<div> <div className="mt-2">
<label className="block text-sm font-semibold">Type</label> <label className="block font-semibold">Type</label>
<select <select
value={imp.impact_type} value={impact.impact_type}
onChange={e => updateImpact(i, 'impact_type', e.target.value)} onChange={(e) =>
className="border px-2 py-1 w-full" handleImpactChange(i, 'impact_type', e.target.value)
}
> >
{IMPACT_TYPES.map(t => ( <option value="ONE_TIME">One-Time</option>
<option key={t} value={t}> <option value="MONTHLY">Monthly</option>
{t === 'salary' ? 'Salary change'
: t === 'cost' ? 'Cost / expense'
: t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select> </select>
</div> </div>
{/* frequency */} {/* Direction */}
<div> <div className="mt-2">
<label className="block text-sm font-semibold">Frequency</label> <label className="block font-semibold">Direction</label>
<select <select
value={imp.frequency} value={impact.direction}
onChange={e => updateImpact(i, 'frequency', e.target.value)} onChange={(e) =>
className="border px-2 py-1 w-full" handleImpactChange(i, 'direction', e.target.value)
}
> >
<option value="ONE_TIME">Onetime</option> <option value="add">Add (Income)</option>
<option value="MONTHLY">Monthly (recurring)</option> <option value="subtract">Subtract (Expense)</option>
</select> </select>
</div> </div>
{/* direction */} {/* Amount */}
<div> <div className="mt-2">
<label className="block text-sm font-semibold">Direction</label> <label className="block font-semibold">Amount</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 <input
type="number" type="number"
value={imp.amount} value={impact.amount}
onChange={e => updateImpact(i, 'amount', e.target.value)} onChange={(e) =>
handleImpactChange(i, 'amount', e.target.value)
}
className="border px-2 py-1 w-full" className="border px-2 py-1 w-full"
/> />
</div> </div>
{/* dates */} {/* Start Month */}
<div className="grid grid-cols-2 gap-4"> <div className="mt-2">
<div> <label className="block font-semibold">Start Month</label>
<label className="block text-sm font-semibold">Start date</label> <input
type="number"
value={impact.start_month}
onChange={(e) =>
handleImpactChange(i, 'start_month', e.target.value)
}
className="border px-2 py-1 w-full"
/>
</div>
{/* End Month (for MONTHLY, can be null/blank if indefinite) */}
{impact.impact_type === 'MONTHLY' && (
<div className="mt-2">
<label className="block font-semibold">End Month (optional)</label>
<input <input
type="date" type="number"
value={imp.start_date} value={impact.end_month || ''}
onChange={e => updateImpact(i, 'start_date', e.target.value)} onChange={(e) =>
handleImpactChange(i, 'end_month', e.target.value || null)
}
className="border px-2 py-1 w-full" className="border px-2 py-1 w-full"
placeholder="Leave blank for indefinite"
/> />
</div> </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> </div>
))} ))}
<button <button onClick={handleAddImpact} className="bg-gray-200 px-3 py-1 my-2">
onClick={addImpactRow} + Add Impact
className="bg-gray-200 px-4 py-1 rounded mt-4"
>
+ Add impact
</button> </button>
{/* actions */} {/* Modal Actions */}
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end mt-4">
<button onClick={() => onClose(false)} className="px-4 py-2"> <button className="mr-2" onClick={onClose}>
Cancel Cancel
</button> </button>
<button <button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleSave}>
onClick={handleSave} Save Milestone
className="bg-blue-600 text-white px-5 py-2 rounded"
>
Save
</button> </button>
</div> </div>
</div> </div>
</div> </div>
); );
} };
export default MilestoneAddModal;

File diff suppressed because it is too large Load Diff

View File

@ -1,121 +1,61 @@
// src/components/Paywall.jsx import React from 'react';
import { useEffect, useState, useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom'; import { Button } from './ui/button.js';
import { Button } from './ui/button.js';
export default function Paywall() { const Paywall = () => {
const nav = useNavigate(); const navigate = useNavigate();
const [sub, setSub] = useState(null); // null = loading const { state } = useLocation();
const token = localStorage.getItem('token') || ''; const { selectedCareer } = state || {};
/* ───────────────── fetch current subscription ─────────────── */ const handleSubscribe = async () => {
useEffect(() => { const token = localStorage.getItem('token');
fetch('/api/premium/subscription/status', { if (!token) return navigate('/signin');
headers: { Authorization: `Bearer ${token}` }
})
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(setSub)
.catch(() => setSub({ is_premium:0, is_pro_premium:0 }));
}, [token]);
/* ───────────────── helpers ────────────────────────────────── */ try {
const checkout = useCallback(async (tier, cycle) => { const res = await fetch('/api/activate-premium', {
const base = window.location.origin; // https://dev1.aptivaai.com method: 'POST',
const res = await fetch('/api/premium/stripe/create-checkout-session', { headers: { 'Content-Type': 'application/json',
method : 'POST', 'Authorization': `Bearer ${token}` }
headers: {
'Content-Type': 'application/json',
Authorization : `Bearer ${token}`
},
body: JSON.stringify({
tier,
cycle,
success_url: `${base}/billing?ck=success`,
cancel_url : `${base}/billing?ck=cancel`
})
}); });
if (!res.ok) return console.error('Checkout failed', await res.text());
const { url } = await res.json(); if (res.status === 401) return navigate('/signin-landing');
window.location.href = url; // redirect to Stripe
}, [token]);
const openPortal = useCallback(async () => { if (res.ok) {
const base = window.location.origin; // 1) grab the fresh token / profile if the API returns it
const res = await fetch(`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`, { const { token: newToken, user } = await res.json().catch(() => ({}));
headers: { Authorization: `Bearer ${token}` } if (newToken) localStorage.setItem('token', newToken);
}); if (user) window.dispatchEvent(new Event('user-updated')); // or your context setter
if (!res.ok) return console.error('Portal error', await res.text());
window.location.href = (await res.json()).url;
}, [token]);
/* ───────────────── render ─────────────────────────────────── */ // 2) give the auth context time to update, then push
if (!sub) return <p className="p-6 text-center text-sm">Loading</p>; navigate('/premium-onboarding', { replace: true, state: { selectedCareer } });
} else {
if (sub.is_premium || sub.is_pro_premium) { console.error('activate-premium failed:', await res.text());
const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium'; }
} catch (err) {
return ( console.error('Error activating premium:', err);
<div className="max-w-lg mx-auto p-6 text-center space-y-4">
<h2 className="text-xl font-semibold">Your plan: {plan}</h2>
<p className="text-sm text-gray-600">
Manage payment method, invoices or cancel anytime.
</p>
<Button onClick={openPortal} className="w-full">
Manage subscription
</Button>
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
Back to app
</Button>
</div>
);
} }
};
/* ─── no active sub => show the pricing choices ──────────────── */
return ( return (
<div className="max-w-lg mx-auto p-6 space-y-8"> <div className="paywall">
<header className="text-center"> <h2>Unlock AptivaAI Premium</h2>
<h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2> <ul>
<p className="text-sm text-gray-600"> <li> Personalized Career Milestone Planning</li>
Choose the plan that fits your needs cancel anytime. <li> Comprehensive Financial Projections</li>
</p> <li> Resume & Interview Assistance</li>
</header> </ul>
{/* Premium tier */} <Button
<section className="border rounded-lg p-4 space-y-4"> onClick={handleSubscribe}
<h3 className="text-lg font-medium">Premium</h3> className="bg-green-600 hover:bg-green-700"
<ul className="text-sm list-disc list-inside space-y-1"> >
<li>Career milestone planning</li> Subscribe Now
<li>Financial projections &amp; benchmarks</li> </Button>
<li>2×resume optimizations / week</li>
</ul> <Button onClick={() => navigate(-1)}>Cancel / Go Back</Button>
<div className="grid grid-cols-2 gap-3">
<Button onClick={() => checkout('premium', 'monthly')}>$4.99&nbsp;/mo</Button>
<Button onClick={() => checkout('premium', 'annual' )}>$49&nbsp;/yr</Button>
</div>
</section>
{/* Pro tier */}
<section className="border rounded-lg p-4 space-y-4">
<h3 className="text-lg font-medium">Pro Premium</h3>
<ul className="text-sm list-disc list-inside space-y-1">
<li>Everything in Premium</li>
<li>Priority GPT4o usage &amp; higher rate limits</li>
<li>5×resume optimizations / week</li>
</ul>
<div className="grid grid-cols-2 gap-3">
<Button onClick={() => checkout('pro', 'monthly')}>$7.99&nbsp;/mo</Button>
<Button onClick={() => checkout('pro', 'annual' )}>$79&nbsp;/yr</Button>
</div>
</section>
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
Cancel / Go back
</Button>
</div> </div>
); );
} };
export default Paywall;

View File

@ -1,92 +1,91 @@
// CareerOnboarding.js // CareerOnboarding.js
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import axios from 'axios';
import { Input } from '../ui/input.js'; // Ensure path matches your structure
import authFetch from '../../utils/authFetch.js';
// 1) Import your CareerSearch component // 1) Import your CareerSearch component
import CareerSearch from '../CareerSearch.js'; // adjust path as necessary import CareerSearch from '../CareerSearch.js'; // adjust path as necessary
const Req = () => <span className="text-red-600 ml-0.5">*</span>; const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
// We store local state for “are you working,” “selectedCareer,” etc. // We store local state for “are you working,” “selectedCareer,” etc.
const location = useLocation();
const navCareerObj = location.state?.selectedCareer;
const [careerObj, setCareerObj] = useState(() => {
if (navCareerObj) return navCareerObj;
try {
return JSON.parse(localStorage.getItem('selectedCareer') || 'null');
} catch { return null; }
});
const [currentlyWorking, setCurrentlyWorking] = useState(''); const [currentlyWorking, setCurrentlyWorking] = useState('');
const [collegeStatus, setCollegeStatus] = useState(''); const [selectedCareer, setSelectedCareer] = useState('');
const [collegeEnrollmentStatus, setCollegeEnrollmentStatus] = useState('');
const [showFinPrompt, setShowFinPrompt] = useState(false); const [showFinPrompt, setShowFinPrompt] = useState(false);
const [financialReady, setFinancialReady] = useState(false); // persisted later if you wish
const Req = () => <span className="text-red-600 ml-0.5">*</span>;
const ready = selectedCareer && currentlyWorking && collegeEnrollmentStatus;
/* ── 2. derived helpers ───────────────────────────────────── */
const selectedCareerTitle = careerObj?.title || '';
const ready =
selectedCareerTitle &&
currentlyWorking &&
collegeStatus;
const inCollege = ['currently_enrolled', 'prospective_student']
.includes(collegeStatus);
const skipFin = !!data.skipFinancialStep;
// 1) Grab the location state values, if any // 1) Grab the location state values, if any
const location = useLocation();
const {
socCode,
cipCodes,
careerTitle, // <--- we passed this from handleSelectForEducation
userZip,
userState,
} = location.state || {};
/* ── 3. sideeffects when route brings a new career object ── */ // 2) On mount, see if location.state has a careerTitle and update local states if needed
useEffect(() => { useEffect(() => {
if (!navCareerObj?.title) return; if (careerTitle) {
setSelectedCareer(careerTitle);
setCareerObj(navCareerObj); setData((prev) => ({
localStorage.setItem('selectedCareer', JSON.stringify(navCareerObj)); ...prev,
career_name: careerTitle,
soc_code: socCode || ''
setData(prev => ({ }));
...prev, }
career_name : navCareerObj.title, }, [careerTitle, socCode, setData]);
soc_code : navCareerObj.soc_code || ''
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navCareerObj]); // ← run once per navigation change
// Called whenever other <inputs> change // Called whenever other <inputs> change
const handleChange = (e) => { const handleChange = (e) => {
setData(prev => ({ ...prev, [e.target.name]: e.target.value })); setData(prev => ({ ...prev, [e.target.name]: e.target.value }));
}; };
/* ── 4. callbacks ─────────────────────────────────────────── */ // Called when user picks a career from CareerSearch and confirms it
function handleCareerSelected(obj) { const handleCareerSelected = (careerObj) => {
setCareerObj(obj); // e.g. { title, soc_code, cip_code, ... }
localStorage.setItem('selectedCareer', JSON.stringify(obj)); setSelectedCareer(careerObj.title);
setData(prev => ({ ...prev, career_name: obj.title, soc_code: obj.soc_code || '' }));
}
function handleSubmit() {
if (!ready) return alert('Fill all required fields.');
const inCollege = ['currently_enrolled', 'prospective_student'].includes(collegeStatus);
setData(prev => ({ setData(prev => ({
...prev, ...prev,
career_name : selectedCareerTitle, career_name: careerObj.title,
college_enrollment_status : collegeStatus, soc_code: careerObj.soc_code || '' // store SOC if needed
currently_working : currentlyWorking,
inCollege,
})); }));
/*where do we go? — */ };
if (skipFin && !inCollege) {
/* user said “Skip” AND is not in college ⇒ jump to Review */ const handleSubmit = () => {
finishNow(); // ← the helper we just injected via props if (!selectedCareer || !currentlyWorking || !collegeEnrollmentStatus) {
alert('Please complete all required fields before continuing.');
return;
}
const isInCollege =
collegeEnrollmentStatus === 'currently_enrolled' ||
collegeEnrollmentStatus === 'prospective_student';
// Merge local state into parent data
setData(prevData => ({
...prevData,
career_name: selectedCareer,
college_enrollment_status: collegeEnrollmentStatus,
currently_working: currentlyWorking,
inCollege: isInCollege,
// fallback defaults, or use user-provided
status: prevData.status || 'planned',
start_date: prevData.start_date || new Date().toISOString().slice(0, 10).slice(0, 10),
projected_end_date: prevData.projected_end_date || null
}));
if (!showFinPrompt || financialReady) {
nextStep();
} else { } else {
nextStep(); // ordinary flow
} }
} };
const nextLabel = skipFin
? inCollege ? 'College →' : 'Finish →'
: inCollege ? 'College →' : 'Financial →';
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">
@ -117,18 +116,18 @@ function handleSubmit() {
{/* 2) Replace old local “Search for Career” with <CareerSearch/> */} {/* 2) Replace old local “Search for Career” with <CareerSearch/> */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-medium"> <h3 className="font-medium">
Target Career <Req /> What career are you planning to pursue? (Please select from drop-down suggestions after typing)<Req />
</h3> </h3>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
This should be the career you are <strong>striving for</strong> whether its a new goal or the one you're already in. This should be your <strong>target career path</strong> whether its a new goal or the one you're already in.
</p> </p>
<CareerSearch onCareerSelected={handleCareerSelected} required /> <CareerSearch onCareerSelected={handleCareerSelected} required />
</div> </div>
{selectedCareerTitle && ( {selectedCareer && (
<p className="text-gray-700"> <p className="text-gray-700">
Selected Career: <strong>{selectedCareerTitle}</strong> Selected Career: <strong>{selectedCareer}</strong>
</p> </p>
)} )}
@ -158,14 +157,25 @@ function handleSubmit() {
/> />
</div> </div>
<div className="space-y-2">
<label className="block font-medium">Projected End Date (optional):</label>
<input
name="projected_end_date"
type="date"
onChange={handleChange}
value={data.projected_end_date || ''}
className="w-full border rounded p-2"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="block font-medium"> <label className="block font-medium">
Are you currently enrolled in college or planning to enroll? <Req /> Are you currently enrolled in college or planning to enroll? <Req />
</label> </label>
<select <select
value={collegeStatus} value={collegeEnrollmentStatus}
onChange={(e) => { onChange={(e) => {
setCollegeStatus(e.target.value); setCollegeEnrollmentStatus(e.target.value);
setData(prev => ({ ...prev, college_enrollment_status: e.target.value })); setData(prev => ({ ...prev, college_enrollment_status: e.target.value }));
const needsPrompt = ['currently_enrolled', 'prospective_student'].includes(e.target.value); const needsPrompt = ['currently_enrolled', 'prospective_student'].includes(e.target.value);
setShowFinPrompt(needsPrompt); setShowFinPrompt(needsPrompt);
@ -179,7 +189,7 @@ function handleSubmit() {
<option value="prospective_student">Planning to Enroll (Prospective)</option> <option value="prospective_student">Planning to Enroll (Prospective)</option>
</select> </select>
{showFinPrompt && ( {showFinPrompt && !financialReady && (
<div className="mt-4 p-4 rounded border border-blue-300 bg-blue-50"> <div className="mt-4 p-4 rounded border border-blue-300 bg-blue-50">
<p className="text-sm mb-3"> <p className="text-sm mb-3">
We can give you step-by-step milestones right away. <br /> We can give you step-by-step milestones right away. <br />
@ -204,6 +214,7 @@ function handleSubmit() {
/* mark intent to skip the finance step */ /* mark intent to skip the finance step */
setData(prev => ({ ...prev, skipFinancialStep: true })); setData(prev => ({ ...prev, skipFinancialStep: true }));
setFinancialReady(false);
setShowFinPrompt(false); // hide the prompt, stay on page setShowFinPrompt(false); // hide the prompt, stay on page
}} }}
> >
@ -234,11 +245,11 @@ function handleSubmit() {
onClick={handleSubmit} onClick={handleSubmit}
disabled={!ready} disabled={!ready}
className={`py-2 px-4 rounded font-semibold className={`py-2 px-4 rounded font-semibold
${ready ${selectedCareer && currentlyWorking && collegeEnrollmentStatus
? 'bg-blue-500 hover:bg-blue-600 text-white' ? 'bg-blue-500 hover:bg-blue-600 text-white'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'}`} : 'bg-gray-300 text-gray-500 cursor-not-allowed'}`}
> >
{nextLabel} Financial
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,9 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Modal from '../../components/ui/modal.js'; import Modal from '../../components/ui/modal.js';
import FinancialAidWizard from '../../components/FinancialAidWizard.js'; import FinancialAidWizard from '../../components/FinancialAidWizard.js';
import { useLocation } from 'react-router-dom';
const Req = () => <span className="text-red-600 ml-0.5">*</span>;
function CollegeOnboarding({ nextStep, prevStep, data, setData }) { function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
// CIP / iPEDS local states // CIP / iPEDS local states
@ -14,41 +11,9 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
const [availableProgramTypes, setAvailableProgramTypes] = useState([]); const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
const [schoolValid, setSchoolValid] = useState(false); const [schoolValid, setSchoolValid] = useState(false);
const [programValid, setProgramValid] = useState(false); const [programValid, setProgramValid] = useState(false);
const [enrollmentDate, setEnrollmentDate] = useState(
data.enrollment_date || '' // carry forward if the user goes back
);
const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || '');
// Show/hide the financial aid wizard // Show/hide the financial aid wizard
const [showAidWizard, setShowAidWizard] = useState(false); const [showAidWizard, setShowAidWizard] = useState(false);
const location = useLocation();
const navSelectedSchoolRaw = location.state?.selectedSchool;
const navSelectedSchool = toSchoolName(navSelectedSchoolRaw);
function dehydrate(schObj) {
if (!schObj || typeof schObj !== 'object') return null;
/* keep only the fields you really need */
const { INSTNM, CIPDESC, CREDDESC, ...rest } = schObj;
return { INSTNM, CIPDESC, CREDDESC, ...rest };
}
const [selectedSchool, setSelectedSchool] = useState(() =>
dehydrate(navSelectedSchool) ||
dehydrate(JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}'
).collegeData?.selectedSchool)
);
function toSchoolName(objOrStr) {
if (!objOrStr) return '';
if (typeof objOrStr === 'object') return objOrStr.INSTNM || '';
return objOrStr; // already a string
}
const infoIcon = (msg) => ( const infoIcon = (msg) => (
<span <span
@ -62,7 +27,7 @@ function toSchoolName(objOrStr) {
// Destructure parent data // Destructure parent data
const { const {
college_enrollment_status = '', college_enrollment_status = '',
selected_school = selectedSchool, selected_school = '',
selected_program = '', selected_program = '',
program_type = '', program_type = '',
academic_calendar = 'semester', academic_calendar = 'semester',
@ -88,28 +53,10 @@ function toSchoolName(objOrStr) {
const [manualTuition, setManualTuition] = useState(''); const [manualTuition, setManualTuition] = useState('');
const [autoTuition, setAutoTuition] = useState(0); const [autoTuition, setAutoTuition] = useState(0);
const [manualProgramLength, setManualProgramLength] = useState(''); const [manualProgramLength, setManualProgramLength] = useState('');
const [autoProgramLength, setAutoProgramLength] = useState(0); const [autoProgramLength, setAutoProgramLength] = useState('0.00');
const inSchool = ['currently_enrolled','prospective_student']
.includes(college_enrollment_status);
useEffect(() => {
if (selectedSchool) {
setData(prev => ({
...prev,
selected_school : selectedSchool.INSTNM,
selected_program: selectedSchool.CIPDESC || prev.selected_program,
program_type : selectedSchool.CREDDESC || prev.program_type
}));
}
}, [selectedSchool, setData]);
useEffect(() => {
if (data.expected_graduation && !expectedGraduation)
setExpectedGraduation(data.expected_graduation);
}, [data.expected_graduation]);
/** /**
* handleParentFieldChange * handleParentFieldChange
* If user leaves numeric fields blank, store '' in local state, not 0. * If user leaves numeric fields blank, store '' in local state, not 0.
@ -197,24 +144,9 @@ useEffect(() => {
fetchIpedsData(); fetchIpedsData();
}, []); }, []);
useEffect(() => {
if (college_enrollment_status !== 'prospective_student') return;
const lenYears = Number(data.program_length || '');
if (!enrollmentDate || !lenYears) return;
const start = new Date(enrollmentDate);
const est = new Date(start.getFullYear() + lenYears, start.getMonth(), start.getDate());
const iso = firstOfNextMonth(est);
setExpectedGraduation(iso);
setData(prev => ({ ...prev, expected_graduation: iso }));
}, [college_enrollment_status, enrollmentDate, data.program_length, setData]);
// School Name // School Name
const handleSchoolChange = (eOrVal) => { const handleSchoolChange = (e) => {
const value = const value = e.target.value;
typeof eOrVal === 'string' ? eOrVal : eOrVal?.target?.value || '';
setData(prev => ({ setData(prev => ({
...prev, ...prev,
selected_school: value, selected_school: value,
@ -380,49 +312,14 @@ useEffect(() => {
const remain = Math.max(0, required - completed); const remain = Math.max(0, required - completed);
const yrs = remain / perYear; const yrs = remain / perYear;
setAutoProgramLength(parseFloat(yrs.toFixed(2))); setAutoProgramLength(yrs.toFixed(2));
}, [ }, [
program_type, program_type,
hours_completed, hours_completed,
credit_hours_per_year, credit_hours_per_year,
credit_hours_required, credit_hours_required
]); ]);
/* ------------------------------------------------------------------ */
/* Whenever the user changes enrollmentDate OR programLength */
/* (program_length is already in parent data), compute grad date. */
/* ------------------------------------------------------------------ */
useEffect(() => {
/* decide which “length” the user is looking at right now */
const lenRaw =
manualProgramLength.trim() !== ''
? manualProgramLength
: autoProgramLength;
const len = parseFloat(lenRaw); // years (may be fractional)
const startISO = pickStartDate(); // '' or yyyymmdd
if (!startISO || !len) return; // nothing to do yet
const start = new Date(startISO);
/* naïve add assuming program_length is years; *
* adjust if you store months instead */
/* 1year = 12months preserve fractions (e.g. 1.75y = 21m) */
const monthsToAdd = Math.round(len * 12);
const estGrad = new Date(start); // clone
estGrad.setMonth(estGrad.getMonth() + monthsToAdd);
const gradISO = firstOfNextMonth(estGrad);
setExpectedGraduation(gradISO);
setData(prev => ({ ...prev, expected_graduation: gradISO }));
}, [college_enrollment_status,
enrollmentDate,
manualProgramLength,
autoProgramLength,
setData]);
// final handleSubmit => we store chosen tuition + program_length, then move on // final handleSubmit => we store chosen tuition + program_length, then move on
const handleSubmit = () => { const handleSubmit = () => {
const chosenTuition = manualTuition.trim() === '' const chosenTuition = manualTuition.trim() === ''
@ -434,8 +331,6 @@ useEffect(() => {
setData(prev => ({ setData(prev => ({
...prev, ...prev,
interest_rate,
loan_term,
tuition: chosenTuition, tuition: chosenTuition,
program_length: chosenProgramLength program_length: chosenProgramLength
})); }));
@ -447,26 +342,12 @@ useEffect(() => {
const displayedTuition = (manualTuition.trim() === '' ? autoTuition : manualTuition); const displayedTuition = (manualTuition.trim() === '' ? autoTuition : manualTuition);
const displayedProgramLength = (manualProgramLength.trim() === '' ? autoProgramLength : manualProgramLength); const displayedProgramLength = (manualProgramLength.trim() === '' ? autoProgramLength : manualProgramLength);
function pickStartDate() { const ready =
if (college_enrollment_status === 'prospective_student') { (college_enrollment_status === 'currently_enrolled' ||
return enrollmentDate; // may still be '' college_enrollment_status === 'prospective_student')
} ? schoolValid && programValid && program_type
if (college_enrollment_status === 'currently_enrolled') { : true;
return firstOfNextMonth(new Date()); // today → 1st next month
}
return ''; // anybody else
}
function firstOfNextMonth(dateObj) {
return new Date(dateObj.getFullYear(), dateObj.getMonth() + 1, 1)
.toISOString()
.slice(0, 10); // yyyymmdd
}
const ready =
(!inSchool || expectedGraduation) && // grad date iff in school
selected_school && program_type;
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">College Details</h2> <h2 className="text-2xl font-semibold">College Details</h2>
@ -677,7 +558,7 @@ const ready =
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="block font-medium">Existing College Loan Debt {infoIcon("If you have existing student loans, enter the value here. Estimates are just fine, but detailed forecasts require detailed inputs.")}</label> <label className="block font-medium">Existing College Loan Debt {infoIcon("If you have existing student loans, enter the value here. Estimates are just fine here, but detailed forecasts require detailed inputs.")}</label>
<input <input
type="number" type="number"
name="existing_college_debt" name="existing_college_debt"
@ -720,53 +601,16 @@ const ready =
</div> </div>
)} )}
{['currently_enrolled','prospective_student'].includes(college_enrollment_status) && ( <div className="space-y-1">
<> <label className="block font-medium">Expected Graduation {infoIcon("If you don't know the exact date, that's fine - just enter the targeted month")}</label>
{/* A) Enrollment date prospective only */} <input
{college_enrollment_status === 'prospective_student' && ( type="date"
<div className="space-y-2"> name="expected_graduation"
<label className="block font-medium"> value={expected_graduation}
Anticipated Enrollment Date <Req /> onChange={handleParentFieldChange}
</label> className="w-full border rounded p-2"
<input />
type="date" </div>
value={enrollmentDate}
onChange={e => {
setEnrollmentDate(e.target.value);
setData(p => ({ ...p, enrollment_date: e.target.value }));
}}
className="w-full border rounded p-2"
required
/>
</div>
)}
{/* B) Expected graduation always editable */}
<div className="space-y-2">
<label className="block font-medium">
Expected Graduation Date <Req />
{college_enrollment_status === 'prospective_student' &&
enrollmentDate && data.program_length && (
<span
className="ml-1 cursor-help text-blue-600"
title="Automatically estimated from your enrollment date and program length. Adjust if needed—actual calendars vary by institution."
>&#9432;</span>
)}
</label>
<input
type="date"
value={expectedGraduation}
onChange={e => {
setExpectedGraduation(e.target.value);
setData(p => ({ ...p, expected_graduation: e.target.value }));
}}
className="w-full border rounded p-2"
required
/>
</div>
</>
)}
<div className="space-y-1"> <div className="space-y-1">
<label className="block font-medium">Loan Interest Rate (%) {infoIcon("These can vary, enter the best blended rate you can approximate if you have multiple.")}</label> <label className="block font-medium">Loan Interest Rate (%) {infoIcon("These can vary, enter the best blended rate you can approximate if you have multiple.")}</label>

View File

@ -86,12 +86,6 @@ const OnboardingContainer = () => {
console.log('Current careerData:', careerData); console.log('Current careerData:', careerData);
console.log('Current collegeData:', collegeData); console.log('Current collegeData:', collegeData);
function finishImmediately() {
// The review page is the last item in the steps array ⇒ index = onboardingSteps.length1
setStep(onboardingSteps.length - 1);
}
// 4. Final “all done” submission // 4. Final “all done” submission
const handleFinalSubmit = async () => { const handleFinalSubmit = async () => {
try { try {
@ -215,7 +209,6 @@ navigate(`/career-roadmap/${finalCareerProfileId}`, {
<CareerOnboarding <CareerOnboarding
nextStep={nextStep} nextStep={nextStep}
finishNow={finishImmediately}
data={careerData} data={careerData}
setData={setCareerData} setData={setCareerData}
/>, />,

View File

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
// Hypothetical Button component from your UI library // Hypothetical Button component from your UI library
import { Button } from '../ui/button.js'; // Adjust path if needed import { Button } from '../ui/button.js'; // Adjust path if needed
import { data } from 'autoprefixer';
/** /**
* Helper to format numeric fields for display. * Helper to format numeric fields for display.
@ -48,6 +47,7 @@ function ReviewPage({
<div><strong>College enrollment Status:</strong> {careerData.college_enrollment_status || 'N/A'}</div> <div><strong>College enrollment Status:</strong> {careerData.college_enrollment_status || 'N/A'}</div>
<div><strong>Status:</strong> {careerData.status || 'N/A'}</div> <div><strong>Status:</strong> {careerData.status || 'N/A'}</div>
<div><strong>Start Date:</strong> {careerData.start_date || 'N/A'}</div> <div><strong>Start Date:</strong> {careerData.start_date || 'N/A'}</div>
<div><strong>Projected End Date:</strong> {careerData.projected_end_date || 'N/A'}</div>
<div><strong>Career Goals:</strong> {careerData.career_goals || 'N/A'}</div> <div><strong>Career Goals:</strong> {careerData.career_goals || 'N/A'}</div>
</div> </div>
@ -126,8 +126,6 @@ function ReviewPage({
<div><strong>Loan Deferral Until Graduation?:</strong> {formatYesNo(collegeData.loan_deferral_until_graduation)}</div> <div><strong>Loan Deferral Until Graduation?:</strong> {formatYesNo(collegeData.loan_deferral_until_graduation)}</div>
<div><strong>Annual Financial Aid:</strong> {formatNum(collegeData.annual_financial_aid)}</div> <div><strong>Annual Financial Aid:</strong> {formatNum(collegeData.annual_financial_aid)}</div>
<div><strong>Existing College Debt:</strong> {formatNum(collegeData.existing_college_debt)}</div> <div><strong>Existing College Debt:</strong> {formatNum(collegeData.existing_college_debt)}</div>
<div><strong>Loan Interest Rate:</strong>{formatNum(data.interest_rate)}</div>
<div><strong>Loan Term (yrs):</strong>{formatNum(data.loan_term)}</div>
<div><strong>Extra Monthly Payment:</strong> {formatNum(collegeData.extra_payment)}</div> <div><strong>Extra Monthly Payment:</strong> {formatNum(collegeData.extra_payment)}</div>
<div><strong>Expected Graduation:</strong> {collegeData.expected_graduation || 'N/A'}</div> <div><strong>Expected Graduation:</strong> {collegeData.expected_graduation || 'N/A'}</div>
<div><strong>Expected Salary:</strong> {formatNum(collegeData.expected_salary)}</div> <div><strong>Expected Salary:</strong> {formatNum(collegeData.expected_salary)}</div>

View File

@ -1,18 +1,21 @@
import { Navigate, useLocation } from 'react-router-dom'; import React from 'react';
import { Navigate } from 'react-router-dom';
export default function PremiumRoute({ user, children }) { function PremiumRoute({ user, children }) {
const loc = useLocation(); if (!user) {
// Not even logged in; go to sign in
/* Already premium → proceed */ return <Navigate to="/signin" replace />;
if (user?.is_premium || user?.is_pro_premium) {
return children;
} }
/* NEW: send to paywall and remember where they wanted to go */
return ( // Check if user has *either* premium or pro
<Navigate const hasPremiumOrPro = user.is_premium || user.is_pro_premium;
to="/paywall" if (!hasPremiumOrPro) {
replace // Logged in but neither plan; go to paywall
state={{ redirectTo: loc.pathname, prevState: loc.state }} return <Navigate to="/paywall" replace />;
/> }
);
} // User is logged in and has premium or pro
return children;
}
export default PremiumRoute;

View File

@ -15,7 +15,8 @@ function RetirementLanding() {
Plan strategically and financially for retirement. AptivaAI provides you with clear financial projections, milestone tracking, and scenario analysis for a secure future. Plan strategically and financially for retirement. AptivaAI provides you with clear financial projections, milestone tracking, and scenario analysis for a secure future.
</p> </p>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<Button onClick={() => navigate('/retirement-planner')}>Compare different retirement scenarios and get AI help with planning</Button> <Button onClick={() => navigate('/financial-profile')}>Update Financial Profile</Button>
<Button onClick={() => navigate('/retirement-planner')}>Set Retirement Milestones and get AI help with planning</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -159,6 +159,7 @@ export default function ScenarioEditModal({
career_name : safe(s.career_name), career_name : safe(s.career_name),
status : safe(s.status || 'planned'), status : safe(s.status || 'planned'),
start_date : safe(s.start_date), start_date : safe(s.start_date),
projected_end_date : safe(s.projected_end_date),
retirement_start_date: safe(s.retirement_start_date), retirement_start_date: safe(s.retirement_start_date),
desired_retirement_income_monthly : safe( desired_retirement_income_monthly : safe(
s.desired_retirement_income_monthly s.desired_retirement_income_monthly
@ -497,6 +498,7 @@ async function handleSave() {
currently_working : formData.currently_working || "no", currently_working : formData.currently_working || "no",
status : s(formData.status), status : s(formData.status),
start_date : s(formData.start_date), start_date : s(formData.start_date),
projected_end_date : s(formData.projected_end_date),
retirement_start_date : s(formData.retirement_start_date), retirement_start_date : s(formData.retirement_start_date),
desired_retirement_income_monthly : n(formData.desired_retirement_income_monthly), desired_retirement_income_monthly : n(formData.desired_retirement_income_monthly),
@ -685,6 +687,18 @@ if (formData.retirement_start_date) {
className="border border-gray-300 rounded p-2 w-full" className="border border-gray-300 rounded p-2 w-full"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Projected End Date
</label>
<input
type="date"
name="projected_end_date"
value={formData.projected_end_date || ''}
onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full"
/>
</div>
</div> </div>
{/* Retirement date */} {/* Retirement date */}

View File

@ -51,6 +51,7 @@ export default function ScenarioEditWizard({
currently_working: scenData.currently_working, currently_working: scenData.currently_working,
status: scenData.status, status: scenData.status,
start_date: scenData.start_date, start_date: scenData.start_date,
projected_end_date: scenData.projected_end_date,
planned_monthly_expenses: scenData.planned_monthly_expenses, planned_monthly_expenses: scenData.planned_monthly_expenses,
planned_monthly_debt_payments: scenData.planned_monthly_debt_payments, planned_monthly_debt_payments: scenData.planned_monthly_debt_payments,
planned_monthly_retirement_contribution: scenData.planned_monthly_retirement_contribution, planned_monthly_retirement_contribution: scenData.planned_monthly_retirement_contribution,

View File

@ -120,7 +120,6 @@ const {
// Student-loan config ---------------------------------------- // Student-loan config ----------------------------------------
studentLoanAmount: _studentLoanAmount = 0, studentLoanAmount: _studentLoanAmount = 0,
existing_college_debt: _existingCollegeDebt = 0,
interestRate: _interestRate = 5, interestRate: _interestRate = 5,
loanTerm: _loanTerm = 10, loanTerm: _loanTerm = 10,
loanDeferralUntilGraduation = false, loanDeferralUntilGraduation = false,
@ -173,7 +172,6 @@ const additionalIncome = num(_additionalIncome);
const extraPayment = num(_extraPayment); const extraPayment = num(_extraPayment);
const studentLoanAmount = num(_studentLoanAmount); const studentLoanAmount = num(_studentLoanAmount);
const existingCollegeDebt = num(_existingCollegeDebt);
const interestRate = num(_interestRate); const interestRate = num(_interestRate);
const loanTerm = num(_loanTerm); const loanTerm = num(_loanTerm);
const isProgrammeActive = const isProgrammeActive =
@ -318,11 +316,18 @@ function simulateDrawdown(opts){
/*************************************************** /***************************************************
* 5) LOAN PAYMENT (if not deferring) * 5) LOAN PAYMENT (if not deferring)
***************************************************/ ***************************************************/
const initialLoanPrincipal = studentLoanAmount + existingCollegeDebt;
let monthlyLoanPayment = loanDeferralUntilGraduation let monthlyLoanPayment = loanDeferralUntilGraduation
? 0 ? 0
: calculateLoanPayment(initialLoanPrincipal, interestRate, loanTerm); : calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
// Log the initial loan info:
console.log("Initial loan payment setup:", {
studentLoanAmount,
interestRate,
loanTerm,
loanDeferralUntilGraduation,
monthlyLoanPayment
});
/*************************************************** /***************************************************
* 6) SETUP FOR THE SIMULATION LOOP * 6) SETUP FOR THE SIMULATION LOOP
@ -456,17 +461,15 @@ milestoneImpacts.forEach((rawImpact) => {
if (!isActiveThisMonth) return; // skip to next impact if (!isActiveThisMonth) return; // skip to next impact
/* ---------- 3. Apply the impact ---------- */ /* ---------- 3. Apply the impact ---------- */
if (type.startsWith('SALARY')) { const sign = direction === 'add' ? 1 : -1;
// ─── salary changes affect GROSS income ───
if (type.startsWith('SALARY')) {
// SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12
const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount; const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount;
const salarySign = direction === 'add' ? 1 : -1; // unchanged salaryAdjustThisMonth += sign * monthlyDelta;
salaryAdjustThisMonth += salarySign * monthlyDelta;
} else { } else {
// ─── everything else is an expense or windfall ─── // MONTHLY or ONE_TIME expenses / windfalls
// “Add” ⇒ money coming *in* ⇒ LOWER expenses extraImpactsThisMonth += sign * amount;
// “Subtract” ⇒ money going *out* ⇒ HIGHER expenses
const expenseSign = direction === 'add' ? -1 : 1;
extraImpactsThisMonth += expenseSign * amount;
} }
}); });

Binary file not shown.