Compare commits

...

10 Commits

40 changed files with 2876 additions and 1313 deletions

5
.env
View File

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

View File

@ -1,42 +0,0 @@
# ─── 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

View File

@ -1,42 +0,0 @@
# ─── 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,3 +21,5 @@ yarn-error.log*
.bashrc .bashrc
_logout _logout
env/*.env env/*.env
*.env
uploads/

View File

@ -1,10 +0,0 @@
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"]

16
Dockerfile.server1 Normal file
View File

@ -0,0 +1,16 @@
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,12 +1,20 @@
ARG APPPORT=5002 # ---- Dockerfile.server3 (fixed) ------------------------------
FROM node:20-slim FROM node:20-bullseye
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 apt-get update -y \ RUN npm ci --omit=dev --unsafe-perm
&& apt-get install -y build-essential python3 make g++ --no-install-recommends python3 git \
&& rm -rf /var/lib/apt/lists/* # 3. static assets & source
RUN npm ci --omit=dev --ignore-scripts COPY public/ /app/public/
COPY . . COPY . .
ENV PORT=5002
EXPOSE 5002
CMD ["node", "backend/server3.js"] CMD ["node", "backend/server3.js"]

View File

@ -18,6 +18,7 @@ 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
@ -34,7 +35,6 @@ 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,10 +62,6 @@ 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
@ -579,8 +575,10 @@ 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 || '/app/data/salary_info.db'; process.env.SALARY_DB_PATH // ← preferred
|| process.env.SALARY_DB // ← legacy
|| '/app/salary_info.db'; // final fallback
const salaryDb = new sqlite3.Database( const salaryDb = new sqlite3.Database(
salaryDbPath, salaryDbPath,

View File

@ -19,22 +19,53 @@ 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
dotenv.config({ path: envPath }); if (!process.env.FROM_SECRETS_MANAGER) {
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api"; dotenv.config({ path: envPath });
}
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);
function internalFetch(req, url, opts = {}) { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
return fetch(url, { apiVersion: '2024-04-10',
});
// 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",
@ -44,6 +75,58 @@ function internalFetch(req, url, 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' }));
@ -53,10 +136,6 @@ 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
@ -115,8 +194,54 @@ 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
@ -125,11 +250,10 @@ app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
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, `${apiBase}${path}`, opts); const auth = (path, opts = {}) => internalFetch(req, path, opts);
for (const m of opsObj.milestones) { for (const m of opsObj.milestones) {
const { op } = m || {}; const { op } = m || {};
@ -193,8 +317,7 @@ 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
@ -214,8 +337,7 @@ 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
@ -235,8 +357,7 @@ 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 = ?
@ -262,7 +383,6 @@ 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,
@ -295,7 +415,6 @@ 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,
@ -309,11 +428,10 @@ 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),
@ -336,7 +454,6 @@ 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,
@ -922,9 +1039,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 internalFetch( const aiRiskRes = await auth(
req, req,
`${apiBase}/premium/ai-risk-analysis`, '/premium/ai-risk-analysis',
{ {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
@ -1296,7 +1413,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
}; };
// Call your existing milestone endpoint // Call your existing milestone endpoint
const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, { const msRes = await auth(req, '/premium/milestone', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(milestoneBody) body: JSON.stringify(milestoneBody)
@ -1331,7 +1448,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
due_date: taskObj.due_date || null due_date: taskObj.due_date || null
}; };
await internalFetch(req, `${apiBase}/premium/tasks`, { await auth(req, '/premium/tasks', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(taskBody) body: JSON.stringify(taskBody)
@ -1365,7 +1482,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
end_date: impObj.end_date || null end_date: impObj.end_date || null
}; };
await internalFetch(req, `${apiBase}/premium/milestone-impacts`, { await auth(req, '/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)
@ -1538,7 +1655,7 @@ Always end with: “AptivaAI is an educational tool not advice.”
if (payloadObj?.cloneScenario) { if (payloadObj?.cloneScenario) {
/* ------ CLONE ------ */ /* ------ CLONE ------ */
await internalFetch(req, `${apiBase}/premium/career-profile/clone`, { await auth(req, '/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' }
@ -2452,17 +2569,32 @@ 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 * 'SELECT * FROM financial_profiles WHERE user_id=? LIMIT 1',
FROM financial_profiles [req.id]
WHERE user_id = ? );
`, [req.id]);
res.json(rows[0] || {}); if (!rows.length) {
} catch (error) { return res.json({
console.error('Error fetching financial profile:', error); current_salary: 0,
res.status(500).json({ error: 'Failed to fetch financial profile' }); additional_income: 0,
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' });
} }
}); });
@ -2713,6 +2845,21 @@ 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
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
@ -3775,6 +3922,57 @@ 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 SECRET_KEY = process.env.SECRET_KEY || "supersecurekey"; const JWT_SECRET = process.env.JWT_SECRET;
/** /**
* 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, SECRET_KEY); const { id } = jwt.verify(token, JWT_SECRET);
req.user = { id }; // attach the id for downstream use req.user = { id }; // attach the id for downstream use
next(); next();
} catch (err) { } catch (err) {

72
deploy_all.sh Executable file
View File

@ -0,0 +1,72 @@
#!/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,25 +1,55 @@
# ---------------------------------------------------------------------------
# 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:
- ${RUNTIME_ENV_FILE:-.env.production} # default for local runs - .env # committed, nonsecret
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}"]
restart: unless-stopped environment:
env_file: ONET_USERNAME: ${ONET_USERNAME}
- ${RUNTIME_ENV_FILE} ONET_PASSWORD: ${ONET_PASSWORD}
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
@ -30,26 +60,58 @@ 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]
ports: networks: [default, aptiva-shared]
- "80:80" ports: ["80:80", "443:443"]
- "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,15 +3,18 @@ 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 (one line to edit per container) ---------- # ───────────── upstreams to Docker services ─────────────
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 # 1. HTTP  HTTPS redirect for the main site
# ----------------------------------------------------------------------- ########################################################################
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@ -19,19 +22,19 @@ http {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
# ----------------------------------------------------------------------- ########################################################################
# 2. Main virtual host on :443 # 2. Main virtual host (dev1.aptivaai.com) on :443
# ----------------------------------------------------------------------- ########################################################################
server { server {
listen 443 ssl http2; listen 443 ssl;
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 / {
@ -42,13 +45,7 @@ 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; }
@ -61,23 +58,81 @@ 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 settings ----------------------------------- # shared proxy headers
## Add the headers *once*; they apply to every proxy_pass above. proxy_set_header Host $host;
proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
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,7 +7,6 @@
"": { "": {
"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",
@ -53,6 +52,7 @@
"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,6 +18715,19 @@
"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,6 +47,7 @@
"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",

6
playwright.config.js Normal file
View File

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

12
refresh_secrets.sh Executable file
View File

@ -0,0 +1,12 @@
#!/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,6 +25,10 @@ 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';
@ -34,6 +38,7 @@ 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';
@ -171,8 +176,10 @@ 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');
@ -216,7 +223,7 @@ const uiToolHandlers = useMemo(() => {
<ProfileCtx.Provider <ProfileCtx.Provider
value={{ financialProfile, setFinancialProfile, value={{ financialProfile, setFinancialProfile,
scenario, setScenario, scenario, setScenario,
user, }} user, setUser}}
> >
<ChatCtx.Provider value={{ setChatSnapshot, <ChatCtx.Provider value={{ setChatSnapshot,
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); }, openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
@ -237,7 +244,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 (beta) AptivaAI - Career Guidance Platform
</h1> </h1>
{isAuthenticated && ( {isAuthenticated && (
@ -358,7 +365,7 @@ const uiToolHandlers = useMemo(() => {
)} )}
onClick={() => navigate('/retirement')} onClick={() => navigate('/retirement')}
> >
Retirement Planning Retirement Planning (beta)
{!canAccessPremium && ( {!canAccessPremium && (
<span className="text-xs ml-1 text-gray-600"> <span className="text-xs ml-1 text-gray-600">
(Premium) (Premium)
@ -404,20 +411,32 @@ const uiToolHandlers = useMemo(() => {
</Link> </Link>
{canAccessPremium ? ( {canAccessPremium ? (
/* Premium users go straight to the wizard */ /* Premium users go straight to the wizard */
<Link <Link
to="/premium-onboarding" to="/profile/careers"
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"
> >
Premium Onboarding Career Profiles
</Link> </Link>
) : ( ) : (
/* Free users are nudged to upgrade */ <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="/paywall" to="/profile/college"
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 Planning&nbsp;<span className="text-xs">(Premium)</span> College Profiles
</Link> </Link>
) : (
<span className="block px-4 py-2 text-sm text-gray-400 cursor-not-allowed">
College Profiles (Premium)
</span>
)} )}
</div> </div>
</div> </div>
@ -505,6 +524,7 @@ 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
@ -531,6 +551,11 @@ 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

@ -0,0 +1,66 @@
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,8 +240,9 @@ 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 && onMilestonesCreated) if (createdMilestones.length && typeof onMilestonesCreated === 'function') {
onMilestonesCreated(createdMilestones.length); onMilestonesCreated(); // no arg needed just refetch
}
} 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

@ -0,0 +1,232 @@
// 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

@ -0,0 +1,79 @@
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,6 +37,7 @@ import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
import InfoTooltip from "./ui/infoTooltip.js"; import InfoTooltip from "./ui/infoTooltip.js";
import differenceInMonths from 'date-fns/differenceInMonths'; import differenceInMonths from 'date-fns/differenceInMonths';
import "../styles/legacy/MilestoneTimeline.legacy.css"; import "../styles/legacy/MilestoneTimeline.legacy.css";
// -------------- // --------------
@ -510,8 +511,13 @@ 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.ok) setFinancialProfile(await fp.json()); if (fp.status === 404) {
// user skipped onboarding treat as empty object
setFinancialProfile({});
} else if (fp.ok) {
setFinancialProfile(await fp.json());
}
})(); })();
}, []); }, []);
@ -822,7 +828,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
@ -953,7 +959,6 @@ 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);
@ -1295,6 +1300,14 @@ 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">
@ -1524,7 +1537,10 @@ const fetchMilestones = useCallback(async () => {
{/* 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">Milestones</h4> <h4 className="text-lg font-semibold mb-2">
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,7 +26,9 @@ const CareerSelectDropdown = ({ existingCareerProfiles, selectedCareer, onChange
return ( return (
<div className="career-select-dropdown"> <div className="career-select-dropdown">
<label>Select Career Path:</label> <label className="block font-medium mb-1">
Select the career this college plan belongs to:
</label>
{loading ? ( {loading ? (
<p>Loading career paths...</p> <p>Loading career paths...</p>
) : ( ) : (

View File

@ -0,0 +1,512 @@
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

@ -0,0 +1,154 @@
// 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,181 +1,219 @@
// FinancialProfileForm.js // FinancialProfileForm.js
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import authFetch from '../utils/authFetch.js'; import { useNavigate } from 'react-router-dom';
function FinancialProfileForm() { import authFetch from '../utils/authFetch.js';
// We'll store the fields in local state import Modal from './ui/modal.js';
const [currentSalary, setCurrentSalary] = useState(''); import ExpensesWizard from './ExpensesWizard.js'; // same wizard you use in onboarding
const [additionalIncome, setAdditionalIncome] = useState(''); import { Button } from './ui/button.js'; // Tailwindbased button (optional)
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(() => {
// On mount, fetch the user's existing profile from the new financial_profiles table (async () => {
async function fetchProfile() {
try { try {
const res = await authFetch('/api/premium/financial-profile', { const res = await authFetch('/api/premium/financial-profile');
method: 'GET' if (!res.ok) return;
}); const d = await res.json();
if (res.ok) {
const data = await res.json(); setCurrentSalary (d.current_salary ?? '');
// data might be an empty object if no row yet setAdditionalIncome (d.additional_income ?? '');
setCurrentSalary(data.current_salary || ''); setMonthlyExpenses (d.monthly_expenses ?? '');
setAdditionalIncome(data.additional_income || ''); setMonthlyDebtPayments (d.monthly_debt_payments ?? '');
setMonthlyExpenses(data.monthly_expenses || ''); setRetirementSavings (d.retirement_savings ?? '');
setMonthlyDebtPayments(data.monthly_debt_payments || ''); setEmergencyFund (d.emergency_fund ?? '');
setRetirementSavings(data.retirement_savings || ''); setRetirementContribution (d.retirement_contribution ?? '');
setEmergencyFund(data.emergency_fund || ''); setEmergencyContribution (d.emergency_contribution ?? '');
setRetirementContribution(data.retirement_contribution || ''); setExtraCashEmergencyPct (d.extra_cash_emergency_pct ?? '');
setMonthlyEmergencyContribution(data.monthly_emergency_contribution || ''); setExtraCashRetirementPct (d.extra_cash_retirement_pct ?? '');
setExtraCashEmergencyPct(data.extra_cash_emergency_pct || ''); } catch (err) { console.error(err); }
setExtraCashRetirementPct(data.extra_cash_retirement_pct || ''); })();
}
} catch (err) {
console.error("Failed to load financial profile:", err);
}
}
fetchProfile();
}, []); }, []);
// Submit form updates => POST to the same endpoint /* -----------------------------------------------------------
async function handleSubmit(e) { * keep the two % inputs complementary (must add to 100)
e.preventDefault(); * --------------------------------------------------------- */
try { function handleChange(e) {
const body = { const { name, value } = e.target;
current_salary: parseFloat(currentSalary) || 0, const pct = Math.max(0, Math.min(100, Number(value) || 0)); // clamp 0100
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', { if (name === 'extraCashEmergencyPct') {
method: 'POST', setExtraCashEmergencyPct(String(pct));
headers: { 'Content-Type': 'application/json' }, setExtraCashRetirementPct(String(100 - pct));
body: JSON.stringify(body) } else if (name === 'extraCashRetirementPct') {
}); setExtraCashRetirementPct(String(pct));
setExtraCashEmergencyPct(String(100 - pct));
if (res.ok) { } else {
// show success or redirect // all other numeric fields:
console.log("Profile updated"); // allow empty string so users can clear then retype
} else { const update = valSetter => valSetter(value === '' ? '' : Number(value));
console.error("Failed to update profile:", await res.text()); 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;
} }
} catch (err) {
console.error("Error submitting financial profile:", err);
} }
} }
/* ───────────── submit ─────────────────────── */
async function handleSubmit(e) {
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 {
const res = await authFetch('/api/premium/financial-profile', {
method : 'POST',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify(body)
});
if (!res.ok) throw new Error(await res.text());
alert('Financial profile saved.');
nav(-1);
} catch (err) {
console.error(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"> <>
<h2 className="text-xl font-semibold">Edit Your Financial Profile</h2> <form
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>
<label className="block font-medium">Current Salary</label> {/* salary / income */}
<input <label className="block font-medium">CurrentAnnualSalary</label>
type="number" <input type="number" className="w-full border rounded p-2"
value={currentSalary} name="currentSalary" value={currentSalary} onChange={handleChange} />
onChange={(e) => setCurrentSalary(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">Additional Monthly Income</label> <label className="block font-medium">AdditionalAnnualIncome</label>
<input <input type="number" className="w-full border rounded p-2"
type="number" name="additionalIncome" value={additionalIncome} onChange={handleChange} />
value={additionalIncome}
onChange={(e) => setAdditionalIncome(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">Monthly Living Expenses</label> {/* expenses with wizard */}
<input <label className="block font-medium">MonthlyLivingExpenses</label>
type="number" <div className="flex space-x-2 items-center">
value={monthlyExpenses} <input type="number" className="w-full border rounded p-2"
onChange={(e) => setMonthlyExpenses(e.target.value)} value={monthlyExpenses}
className="w-full border rounded p-2" onChange={e=>setMonthlyExpenses(e.target.value)} />
placeholder="$" <Button className="bg-blue-600 text-white px-3 py-2 rounded"
/> type="button" onClick={openWizard}>
Need Help?
</Button>
</div>
<label className="block font-medium">Monthly Debt Payments</label> {/* rest of the numeric fields */}
<input <label className="block font-medium">MonthlyDebtPayments</label>
type="number" <input type="number" className="w-full border rounded p-2"
value={monthlyDebtPayments} name="monthlyDebtPayments" value={monthlyDebtPayments} onChange={handleChange} />
onChange={(e) => setMonthlyDebtPayments(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">Retirement Savings</label> <label className="block font-medium">RetirementSavings</label>
<input <input type="number" className="w-full border rounded p-2"
type="number" name="retirementSavings" value={retirementSavings} onChange={handleChange} />
value={retirementSavings}
onChange={(e) => setRetirementSavings(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">Emergency Fund</label> <label className="block font-medium">EmergencyFund</label>
<input <input type="number" className="w-full border rounded p-2"
type="number" name="emergencyFund" value={emergencyFund} onChange={handleChange} />
value={emergencyFund}
onChange={(e) => setEmergencyFund(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">Monthly Retirement Contribution</label> <label className="block font-medium">MonthlyRetirementContribution</label>
<input <input type="number" className="w-full border rounded p-2"
type="number" name="retirementContribution" value={retirementContribution} onChange={handleChange} />
value={retirementContribution}
onChange={(e) => setRetirementContribution(e.target.value)}
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">Monthly Emergency Contribution</label> <label className="block font-medium">MonthlyEmergencyContribution</label>
<input <input type="number" className="w-full border rounded p-2"
type="number" name="emergencyContribution"
value={monthlyEmergencyContribution} value={emergencyContribution}
onChange={(e) => setMonthlyEmergencyContribution(e.target.value)} onChange={handleChange} />
className="w-full border rounded p-2"
placeholder="$"
/>
<label className="block font-medium">Extra Cash to Emergency (%)</label> {/* allocation kept in sync */}
<input <h3 className="text-lg font-medium pt-2">ExtraMonthlyCashAllocation (must total100%)</h3>
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">Extra Cash to Retirement (%)</label> <label className="block font-medium">ToEmergencyFund(%)</label>
<input <input type="number" className="w-full border rounded p-2"
type="number" name="extraCashEmergencyPct"
value={extraCashRetirementPct} value={extraCashEmergencyPct}
onChange={(e) => setExtraCashRetirementPct(e.target.value)} onChange={handleChange} />
className="w-full border rounded p-2"
placeholder="e.g. 70"
/>
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded"> <label className="block font-medium">ToRetirement(%)</label>
Save and Continue <input type="number" className="w-full border rounded p-2"
</button> name="extraCashRetirementPct"
</form> value={extraCashRetirementPct}
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,263 +2,257 @@
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,
defaultScenarioId, scenarioId, // active scenario UUID
scenarioId, // which scenario this milestone applies to editMilestone = null // pass full row when editing
editMilestone, // if editing an existing milestone, pass its data }) {
}) => { /* ────────────── state ────────────── */
// Basic milestone fields const [title, setTitle] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [impacts, setImpacts] = useState([]);
// We'll store an array of impacts. Each impact is { impact_type, direction, amount, start_month, end_month } /* ────────────── init / reset ────────────── */
const [impacts, setImpacts] = useState([]);
// On open, if editing, fill in existing fields
useEffect(() => { useEffect(() => {
if (!show) return; // if modal is hidden, do nothing if (!show) return;
if (editMilestone) { if (editMilestone) {
setTitle(editMilestone.title || ''); setTitle(editMilestone.title || '');
setDescription(editMilestone.description || ''); setDescription(editMilestone.description || '');
// If editing, you might fetch existing impacts from the server or they could be passed in setImpacts(editMilestone.impacts || []);
if (editMilestone.impacts) {
setImpacts(editMilestone.impacts);
} else {
// fetch from backend if needed
// e.g. GET /api/premium/milestones/:id/impacts
}
} else { } else {
// Creating a new milestone setTitle(''); setDescription(''); setImpacts([]);
setTitle('');
setDescription('');
setImpacts([]);
} }
}, [show, editMilestone]); }, [show, editMilestone]);
// Handler: add a new blank impact /* ────────────── helpers ────────────── */
const handleAddImpact = () => { const addImpactRow = () =>
setImpacts((prev) => [ setImpacts(prev => [
...prev, ...prev,
{ {
impact_type: 'ONE_TIME', impact_type : 'cost',
direction: 'subtract', frequency : 'ONE_TIME',
amount: 0, direction : 'subtract',
start_month: 0, amount : 0,
end_month: null start_date : '', // ISO yyyymmdd
end_date : '' // blank ⇒ indefinite
} }
]); ]);
};
// Handler: update a single impact in the array const updateImpact = (idx, field, value) =>
const handleImpactChange = (index, field, value) => { setImpacts(prev => {
setImpacts((prev) => { const copy = [...prev];
const updated = [...prev]; copy[idx] = { ...copy[idx], [field]: value };
updated[index] = { ...updated[index], [field]: value }; return copy;
return updated;
}); });
};
// Handler: remove an impact row const removeImpact = idx =>
const handleRemoveImpact = (index) => { setImpacts(prev => prev.filter((_, i) => i !== idx));
setImpacts((prev) => prev.filter((_, i) => i !== index));
};
// Handler: Save everything to the server /* ────────────── save ────────────── */
const handleSave = async () => { async function handleSave() {
try { try {
let milestoneId; /* 1⃣ create OR update the milestone row */
if (editMilestone) { let milestoneId = editMilestone?.id;
// 1) Update existing milestone if (milestoneId) {
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({ body : JSON.stringify({ title, description })
title,
description,
scenario_id: scenarioId,
// Possibly other fields
})
}); });
// Then handle impacts below...
} else { } else {
// 1) Create new milestone
const res = await authFetch('api/premium/milestones', { const res = await authFetch('api/premium/milestones', {
method: 'POST', method : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type':'application/json' },
body: JSON.stringify({ body : JSON.stringify({
title, title,
description, description,
scenario_id: scenarioId career_profile_id: scenarioId
}) })
}); });
if (!res.ok) throw new Error('Failed to create milestone'); if (!res.ok) throw new Error('Milestone create failed');
const created = await res.json(); const json = await res.json();
milestoneId = created.id; // assuming the response returns { id: newMilestoneId } milestoneId = json.id ?? json[0]?.id; // array OR obj
} }
// 2) For the impacts, we can do a batch approach or individual calls /* 2⃣ upsert each impact (one call per row) */
// For simplicity, let's do multiple POST calls for (const imp of impacts) {
for (const impact of impacts) { const body = {
// If editing, you might do a PUT if the impact already has an id milestone_id : milestoneId,
impact_type : imp.impact_type,
frequency : imp.frequency, // ONE_TIME / MONTHLY
direction : imp.direction,
amount : parseFloat(imp.amount) || 0,
start_date : imp.start_date || null,
end_date : imp.frequency === 'MONTHLY' && imp.end_date
? imp.end_date
: null
};
await authFetch('api/premium/milestone-impacts', { await authFetch('api/premium/milestone-impacts', {
method: 'POST', method : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type':'application/json' },
body: JSON.stringify({ body : JSON.stringify(body)
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)
})
}); });
} }
// Done, close modal onClose(true); // ← parent will refetch
onClose();
} catch (err) { } catch (err) {
console.error('Failed to save milestone + impacts:', err); console.error('Save failed:', err);
// Show some UI error if needed alert('Sorry, something went wrong please try again.');
} }
}; }
/* ────────────── UI ────────────── */
if (!show) return null; if (!show) return null;
return ( return (
<div className="modal-backdrop"> <div className="modal-backdrop">
<div className="modal-container"> <div className="modal-container w-full max-w-lg">
<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>
<div className="mb-3"> {/* basic fields */}
<label className="block font-semibold">Title</label> <label className="block font-semibold mt-2">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>
<div className="mb-3"> <label className="block font-semibold mt-4">Description</label>
<label className="block font-semibold">Description</label> <textarea
<textarea value={description}
value={description} onChange={e => setDescription(e.target.value)}
onChange={(e) => setDescription(e.target.value)} rows={3}
className="border w-full px-2 py-1" className="border w-full px-2 py-1"
/> />
</div>
{/* Impacts Section */} {/* impacts */}
<h3 className="text-lg font-semibold mt-4">Financial Impacts</h3> <h3 className="text-lg font-semibold mt-6">FinancialImpacts</h3>
{impacts.map((impact, i) => (
<div key={i} className="border rounded p-2 my-2"> {impacts.map((imp, i) => (
<div className="flex items-center justify-between"> <div key={i} className="border rounded p-3 mt-4 space-y-2">
<p>Impact #{i + 1}</p> <div className="flex justify-between items-center">
<span className="font-medium">Impact #{i + 1}</span>
<button <button
className="text-red-500" className="text-red-600 text-sm"
onClick={() => handleRemoveImpact(i)} onClick={() => removeImpact(i)}
> >
Remove Remove
</button> </button>
</div> </div>
{/* Impact Type */} {/* type */}
<div className="mt-2"> <div>
<label className="block font-semibold">Type</label> <label className="block text-sm font-semibold">Type</label>
<select <select
value={impact.impact_type} value={imp.impact_type}
onChange={(e) => onChange={e => updateImpact(i, 'impact_type', e.target.value)}
handleImpactChange(i, 'impact_type', e.target.value) className="border px-2 py-1 w-full"
}
> >
<option value="ONE_TIME">One-Time</option> {IMPACT_TYPES.map(t => (
<option value="MONTHLY">Monthly</option> <option key={t} value={t}>
{t === 'salary' ? 'Salary change'
: t === 'cost' ? 'Cost / expense'
: t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select> </select>
</div> </div>
{/* Direction */} {/* frequency */}
<div className="mt-2"> <div>
<label className="block font-semibold">Direction</label> <label className="block text-sm font-semibold">Frequency</label>
<select <select
value={impact.direction} value={imp.frequency}
onChange={(e) => onChange={e => updateImpact(i, 'frequency', e.target.value)}
handleImpactChange(i, 'direction', e.target.value) className="border px-2 py-1 w-full"
}
> >
<option value="add">Add (Income)</option> <option value="ONE_TIME">Onetime</option>
<option value="subtract">Subtract (Expense)</option> <option value="MONTHLY">Monthly (recurring)</option>
</select> </select>
</div> </div>
{/* Amount */} {/* direction */}
<div className="mt-2"> <div>
<label className="block font-semibold">Amount</label> <label className="block text-sm font-semibold">Direction</label>
<select
value={imp.direction}
onChange={e => updateImpact(i, 'direction', e.target.value)}
className="border px-2 py-1 w-full"
>
<option value="add">Add (income)</option>
<option value="subtract">Subtract (expense)</option>
</select>
</div>
{/* amount */}
<div>
<label className="block text-sm font-semibold">Amount ($)</label>
<input <input
type="number" type="number"
value={impact.amount} value={imp.amount}
onChange={(e) => onChange={e => updateImpact(i, 'amount', e.target.value)}
handleImpactChange(i, 'amount', e.target.value)
}
className="border px-2 py-1 w-full" className="border px-2 py-1 w-full"
/> />
</div> </div>
{/* Start Month */} {/* dates */}
<div className="mt-2"> <div className="grid grid-cols-2 gap-4">
<label className="block font-semibold">Start Month</label> <div>
<input <label className="block text-sm font-semibold">Start date</label>
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="number" type="date"
value={impact.end_month || ''} value={imp.start_date}
onChange={(e) => onChange={e => updateImpact(i, 'start_date', e.target.value)}
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 onClick={handleAddImpact} className="bg-gray-200 px-3 py-1 my-2"> <button
+ Add Impact onClick={addImpactRow}
className="bg-gray-200 px-4 py-1 rounded mt-4"
>
+ Add impact
</button> </button>
{/* Modal Actions */} {/* actions */}
<div className="flex justify-end mt-4"> <div className="flex justify-end gap-3 mt-6">
<button className="mr-2" onClick={onClose}> <button onClick={() => onClose(false)} className="px-4 py-2">
Cancel Cancel
</button> </button>
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleSave}> <button
Save Milestone onClick={handleSave}
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,61 +1,121 @@
import React from 'react'; // src/components/Paywall.jsx
import { useLocation, useNavigate } from 'react-router-dom'; import { useEffect, useState, useCallback } from 'react';
import { Button } from './ui/button.js'; import { useNavigate } from 'react-router-dom';
import { Button } from './ui/button.js';
const Paywall = () => { export default function Paywall() {
const navigate = useNavigate(); const nav = useNavigate();
const { state } = useLocation(); const [sub, setSub] = useState(null); // null = loading
const { selectedCareer } = state || {}; const token = localStorage.getItem('token') || '';
const handleSubscribe = async () => { /* ───────────────── fetch current subscription ─────────────── */
const token = localStorage.getItem('token'); useEffect(() => {
if (!token) return navigate('/signin'); fetch('/api/premium/subscription/status', {
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]);
try { /* ───────────────── helpers ────────────────────────────────── */
const res = await fetch('/api/activate-premium', { const checkout = useCallback(async (tier, cycle) => {
method: 'POST', const base = window.location.origin; // https://dev1.aptivaai.com
headers: { 'Content-Type': 'application/json', const res = await fetch('/api/premium/stripe/create-checkout-session', {
'Authorization': `Bearer ${token}` } method : 'POST',
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());
if (res.status === 401) return navigate('/signin-landing'); const { url } = await res.json();
window.location.href = url; // redirect to Stripe
}, [token]);
if (res.ok) { const openPortal = useCallback(async () => {
// 1) grab the fresh token / profile if the API returns it const base = window.location.origin;
const { token: newToken, user } = await res.json().catch(() => ({})); const res = await fetch(`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`, {
if (newToken) localStorage.setItem('token', newToken); headers: { Authorization: `Bearer ${token}` }
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]);
// 2) give the auth context time to update, then push /* ───────────────── render ─────────────────────────────────── */
navigate('/premium-onboarding', { replace: true, state: { selectedCareer } }); if (!sub) return <p className="p-6 text-center text-sm">Loading</p>;
} else {
console.error('activate-premium failed:', await res.text()); if (sub.is_premium || sub.is_pro_premium) {
} const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium';
} catch (err) {
console.error('Error activating premium:', err); return (
<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="paywall"> <div className="max-w-lg mx-auto p-6 space-y-8">
<h2>Unlock AptivaAI Premium</h2> <header className="text-center">
<ul> <h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2>
<li> Personalized Career Milestone Planning</li> <p className="text-sm text-gray-600">
<li> Comprehensive Financial Projections</li> Choose the plan that fits your needs cancel anytime.
<li> Resume & Interview Assistance</li> </p>
</ul> </header>
<Button {/* Premium tier */}
onClick={handleSubscribe} <section className="border rounded-lg p-4 space-y-4">
className="bg-green-600 hover:bg-green-700" <h3 className="text-lg font-medium">Premium</h3>
> <ul className="text-sm list-disc list-inside space-y-1">
Subscribe Now <li>Career milestone planning</li>
</Button> <li>Financial projections &amp; benchmarks</li>
<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,91 +1,92 @@
// 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 CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
// We store local state for “are you working,” “selectedCareer,” etc.
const [currentlyWorking, setCurrentlyWorking] = useState('');
const [selectedCareer, setSelectedCareer] = useState('');
const [collegeEnrollmentStatus, setCollegeEnrollmentStatus] = useState('');
const [showFinPrompt, setShowFinPrompt] = useState(false);
const [financialReady, setFinancialReady] = useState(false); // persisted later if you wish
const Req = () => <span className="text-red-600 ml-0.5">*</span>; const Req = () => <span className="text-red-600 ml-0.5">*</span>;
const ready = selectedCareer && currentlyWorking && collegeEnrollmentStatus;
const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
// 1) Grab the location state values, if any // We store local state for “are you working,” “selectedCareer,” etc.
const location = useLocation(); const location = useLocation();
const { const navCareerObj = location.state?.selectedCareer;
socCode, const [careerObj, setCareerObj] = useState(() => {
cipCodes, if (navCareerObj) return navCareerObj;
careerTitle, // <--- we passed this from handleSelectForEducation try {
userZip, return JSON.parse(localStorage.getItem('selectedCareer') || 'null');
userState, } catch { return null; }
} = location.state || {}; });
const [currentlyWorking, setCurrentlyWorking] = useState('');
const [collegeStatus, setCollegeStatus] = useState('');
const [showFinPrompt, setShowFinPrompt] = useState(false);
// 2) On mount, see if location.state has a careerTitle and update local states if needed /* ── 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
/* ── 3. sideeffects when route brings a new career object ── */
useEffect(() => { useEffect(() => {
if (careerTitle) { if (!navCareerObj?.title) return;
setSelectedCareer(careerTitle);
setData((prev) => ({ setCareerObj(navCareerObj);
...prev, localStorage.setItem('selectedCareer', JSON.stringify(navCareerObj));
career_name: careerTitle,
soc_code: socCode || ''
})); setData(prev => ({
} ...prev,
}, [careerTitle, socCode, setData]); career_name : navCareerObj.title,
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 }));
}; };
// Called when user picks a career from CareerSearch and confirms it /* ── 4. callbacks ─────────────────────────────────────────── */
const handleCareerSelected = (careerObj) => { function handleCareerSelected(obj) {
// e.g. { title, soc_code, cip_code, ... } setCareerObj(obj);
setSelectedCareer(careerObj.title); localStorage.setItem('selectedCareer', JSON.stringify(obj));
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: careerObj.title, career_name : selectedCareerTitle,
soc_code: careerObj.soc_code || '' // store SOC if needed college_enrollment_status : collegeStatus,
currently_working : currentlyWorking,
inCollege,
})); }));
}; /*where do we go? — */
if (skipFin && !inCollege) {
const handleSubmit = () => { /* user said “Skip” AND is not in college ⇒ jump to Review */
if (!selectedCareer || !currentlyWorking || !collegeEnrollmentStatus) { finishNow(); // ← the helper we just injected via props
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">
@ -116,18 +117,18 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
{/* 2) Replace old local “Search for Career” with <CareerSearch/> */} {/* 2) Replace old local “Search for Career” with <CareerSearch/> */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-medium"> <h3 className="font-medium">
What career are you planning to pursue? (Please select from drop-down suggestions after typing)<Req /> Target Career <Req />
</h3> </h3>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
This should be your <strong>target career path</strong> whether its a new goal or the one you're already in. This should be the career you are <strong>striving for</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>
{selectedCareer && ( {selectedCareerTitle && (
<p className="text-gray-700"> <p className="text-gray-700">
Selected Career: <strong>{selectedCareer}</strong> Selected Career: <strong>{selectedCareerTitle}</strong>
</p> </p>
)} )}
@ -157,25 +158,14 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
/> />
</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={collegeEnrollmentStatus} value={collegeStatus}
onChange={(e) => { onChange={(e) => {
setCollegeEnrollmentStatus(e.target.value); setCollegeStatus(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);
@ -189,7 +179,7 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
<option value="prospective_student">Planning to Enroll (Prospective)</option> <option value="prospective_student">Planning to Enroll (Prospective)</option>
</select> </select>
{showFinPrompt && !financialReady && ( {showFinPrompt && (
<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 />
@ -214,7 +204,6 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
/* 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
}} }}
> >
@ -245,11 +234,11 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
onClick={handleSubmit} onClick={handleSubmit}
disabled={!ready} disabled={!ready}
className={`py-2 px-4 rounded font-semibold className={`py-2 px-4 rounded font-semibold
${selectedCareer && currentlyWorking && collegeEnrollmentStatus ${ready
? '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'}`}
> >
Financial {nextLabel}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,9 @@
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
@ -11,10 +14,42 @@ 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
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-white text-xs cursor-help" className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-white text-xs cursor-help"
@ -27,7 +62,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
// Destructure parent data // Destructure parent data
const { const {
college_enrollment_status = '', college_enrollment_status = '',
selected_school = '', selected_school = selectedSchool,
selected_program = '', selected_program = '',
program_type = '', program_type = '',
academic_calendar = 'semester', academic_calendar = 'semester',
@ -53,9 +88,27 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
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.00'); const [autoProgramLength, setAutoProgramLength] = useState(0);
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
@ -144,9 +197,24 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
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 = (e) => { const handleSchoolChange = (eOrVal) => {
const value = e.target.value; const value =
typeof eOrVal === 'string' ? eOrVal : eOrVal?.target?.value || '';
setData(prev => ({ setData(prev => ({
...prev, ...prev,
selected_school: value, selected_school: value,
@ -312,14 +380,49 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
const remain = Math.max(0, required - completed); const remain = Math.max(0, required - completed);
const yrs = remain / perYear; const yrs = remain / perYear;
setAutoProgramLength(yrs.toFixed(2)); setAutoProgramLength(parseFloat(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() === ''
@ -331,6 +434,8 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
setData(prev => ({ setData(prev => ({
...prev, ...prev,
interest_rate,
loan_term,
tuition: chosenTuition, tuition: chosenTuition,
program_length: chosenProgramLength program_length: chosenProgramLength
})); }));
@ -342,11 +447,25 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
const displayedTuition = (manualTuition.trim() === '' ? autoTuition : manualTuition); const displayedTuition = (manualTuition.trim() === '' ? autoTuition : manualTuition);
const displayedProgramLength = (manualProgramLength.trim() === '' ? autoProgramLength : manualProgramLength); const displayedProgramLength = (manualProgramLength.trim() === '' ? autoProgramLength : manualProgramLength);
const ready = function pickStartDate() {
(college_enrollment_status === 'currently_enrolled' || if (college_enrollment_status === 'prospective_student') {
college_enrollment_status === 'prospective_student') return enrollmentDate; // may still be ''
? schoolValid && programValid && program_type }
: true; if (college_enrollment_status === 'currently_enrolled') {
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">
@ -558,7 +677,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
</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 here, 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, but detailed forecasts require detailed inputs.")}</label>
<input <input
type="number" type="number"
name="existing_college_debt" name="existing_college_debt"
@ -601,16 +720,53 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
</div> </div>
)} )}
<div className="space-y-1"> {['currently_enrolled','prospective_student'].includes(college_enrollment_status) && (
<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> <>
<input {/* A) Enrollment date prospective only */}
type="date" {college_enrollment_status === 'prospective_student' && (
name="expected_graduation" <div className="space-y-2">
value={expected_graduation} <label className="block font-medium">
onChange={handleParentFieldChange} Anticipated Enrollment Date <Req />
className="w-full border rounded p-2" </label>
/> <input
</div> type="date"
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,6 +86,12 @@ 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 {
@ -209,6 +215,7 @@ navigate(`/career-roadmap/${finalCareerProfileId}`, {
<CareerOnboarding <CareerOnboarding
nextStep={nextStep} nextStep={nextStep}
finishNow={finishImmediately}
data={careerData} data={careerData}
setData={setCareerData} setData={setCareerData}
/>, />,

View File

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

View File

@ -15,8 +15,7 @@ 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('/financial-profile')}>Update Financial Profile</Button> <Button onClick={() => navigate('/retirement-planner')}>Compare different retirement scenarios and get AI help with planning</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,7 +159,6 @@ 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
@ -498,7 +497,6 @@ 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),
@ -687,18 +685,6 @@ 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,7 +51,6 @@ 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,6 +120,7 @@ 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,
@ -172,6 +173,7 @@ 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 =
@ -316,18 +318,11 @@ 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(studentLoanAmount, interestRate, loanTerm); : calculateLoanPayment(initialLoanPrincipal, 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
@ -461,15 +456,17 @@ 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 ---------- */
const sign = direction === 'add' ? 1 : -1; if (type.startsWith('SALARY')) {
// ─── 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;
salaryAdjustThisMonth += sign * monthlyDelta; const salarySign = direction === 'add' ? 1 : -1; // unchanged
salaryAdjustThisMonth += salarySign * monthlyDelta;
} else { } else {
// MONTHLY or ONE_TIME expenses / windfalls // ─── everything else is an expense or windfall ───
extraImpactsThisMonth += sign * amount; // “Add” ⇒ money coming *in* ⇒ LOWER expenses
// “Subtract” ⇒ money going *out* ⇒ HIGHER expenses
const expenseSign = direction === 'add' ? -1 : 1;
extraImpactsThisMonth += expenseSign * amount;
} }
}); });

Binary file not shown.