Compare commits
10 Commits
48133de297
...
2980ca3bf2
Author | SHA1 | Date | |
---|---|---|---|
2980ca3bf2 | |||
06cebb2f54 | |||
36da8a5a7f | |||
f2264eba16 | |||
465a7d686c | |||
613f79f6ee | |||
f7973ba69c | |||
ef290a96ea | |||
437a140e9a | |||
689dd85062 |
5
.env
5
.env
@ -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
|
|
@ -1,42 +0,0 @@
|
|||||||
# ─── O*NET ───────────────────────────────
|
|
||||||
ONET_USERNAME=aptivaai
|
|
||||||
ONET_PASSWORD=2296ahq
|
|
||||||
|
|
||||||
# ─── Public‐facing React build ───────────
|
|
||||||
NODE_ENV=development
|
|
||||||
REACT_APP_ENV=development
|
|
||||||
APTIVA_API_BASE=https://dev1.aptivaai.com
|
|
||||||
REACT_APP_API_URL=${APTIVA_API_BASE}
|
|
||||||
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
|
|
||||||
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
|
|
||||||
REACT_APP_OPENAI_API_KEY=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
|
|
||||||
|
|
||||||
# ─── Back-end services ───────────────────
|
|
||||||
OPENAI_API_KEY=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
|
|
||||||
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
|
|
||||||
COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
|
|
||||||
SALARY_DB=/salary_info.db
|
|
||||||
|
|
||||||
# ─── Database (premium server) ───────────
|
|
||||||
DB_HOST=34.67.180.54
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_USER=sqluser
|
|
||||||
DB_PASSWORD=ps<g+2DO-eTb2mb5
|
|
||||||
DB_NAME=user_profile_db
|
|
||||||
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
|
|
||||||
|
|
||||||
# ── Twilio (needed only by server3) ─────────────────────────
|
|
||||||
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
|
|
||||||
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
|
|
||||||
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
|
|
||||||
|
|
||||||
# ─── Anything new goes here ──────────────
|
|
||||||
JWT_SECRET=gW4QsOu4AJA4MooIUC9ld2i71VbBovzV1INsaU6ftxYPrxLIeMq6/OY61j0X2RV7
|
|
||||||
|
|
||||||
# ------------ CORS ------------
|
|
||||||
CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000
|
|
||||||
SERVER1_PORT=5000
|
|
||||||
SERVER2_PORT=5001
|
|
||||||
SERVER3_PORT=5002
|
|
||||||
|
|
||||||
IMG_TAG=20250716
|
|
42
.env.staging
42
.env.staging
@ -1,42 +0,0 @@
|
|||||||
# ─── O*NET ───────────────────────────────
|
|
||||||
ONET_USERNAME=aptivaai
|
|
||||||
ONET_PASSWORD=2296ahq
|
|
||||||
|
|
||||||
# ─── Public‐facing 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
2
.gitignore
vendored
@ -21,3 +21,5 @@ yarn-error.log*
|
|||||||
.bashrc
|
.bashrc
|
||||||
_logout
|
_logout
|
||||||
env/*.env
|
env/*.env
|
||||||
|
*.env
|
||||||
|
uploads/
|
@ -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
16
Dockerfile.server1
Normal 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"]
|
@ -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"]
|
||||||
|
|
||||||
|
@ -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,
|
@ -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 “/”
|
||||||
|
|
||||||
|
/* allow‑list for redirects to block open‑redirect 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 user’s 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('financial‑profile 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 logged‑in 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
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
@ -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
72
deploy_all.sh
Executable 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 NON‑sensitive 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 front‑end 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, non‑sensitive 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. Re‑create the stack
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Preserve only the variables docker‑compose 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"
|
@ -1,25 +1,55 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A single env‑file (.env) contains ONLY non‑secret constants.
|
||||||
|
# Every secret is exported from fetch‑secrets.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, non‑secret
|
||||||
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
|
||||||
|
|
||||||
|
101
nginx.conf
101
nginx.conf
@ -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 reverse‑proxy rules ─────
|
||||||
# 3. API reverse‑proxy rules (three prefixes = three back‑ends)
|
|
||||||
# -------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 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, user‑profile, areas, activate‑premium, …)
|
|
||||||
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
15
package-lock.json
generated
@ -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",
|
||||||
|
@ -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
6
playwright.config.js
Normal 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
12
refresh_secrets.sh
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Re‑export 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."
|
41
src/App.js
41
src/App.js
@ -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)
|
||||||
@ -405,19 +412,31 @@ const uiToolHandlers = useMemo(() => {
|
|||||||
{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 <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={
|
||||||
|
66
src/components/BillingResult.js
Normal file
66
src/components/BillingResult.js
Normal 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 round‑trip
|
||||||
|
───────────────────────────────────────────────────────── */
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -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." }]);
|
||||||
|
232
src/components/CareerProfileForm.js
Normal file
232
src/components/CareerProfileForm.js
Normal 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 re‑pick
|
||||||
|
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. Data‑Scientist 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 data‑scientist 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>
|
||||||
|
);
|
||||||
|
}
|
79
src/components/CareerProfileList.js
Normal file
79
src/components/CareerProfileList.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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}
|
||||||
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
512
src/components/CollegeProfileForm.js
Normal file
512
src/components/CollegeProfileForm.js
Normal 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 in‑state / 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 check‑boxes */}
|
||||||
|
{[
|
||||||
|
{ n:'is_in_district', l:'In District?' },
|
||||||
|
{ n:'is_in_state', l:'In‑State Tuition?' },
|
||||||
|
{ n:'is_online', l:'Program is Fully Online' },
|
||||||
|
{ n:'loan_deferral_until_graduation',
|
||||||
|
l:'Defer Loan Payments until Graduation?' }
|
||||||
|
].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 │ Program‑type */}
|
||||||
|
<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 │ Credit‑hour 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 = auto‑calculated"
|
||||||
|
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 │ Program‑length & hours‑completed */}
|
||||||
|
{(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>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
154
src/components/CollegeProfileList.js
Normal file
154
src/components/CollegeProfileList.js
Normal 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>
|
||||||
|
|
||||||
|
{/* new‑plan 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.
|
||||||
|
<button
|
||||||
|
className="text-blue-600 underline"
|
||||||
|
onClick={() =>
|
||||||
|
careerId
|
||||||
|
? navigate(`/profile/college/${careerId}/new`)
|
||||||
|
: setShowPicker(true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Create one now.
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,9 +1,19 @@
|
|||||||
// 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';
|
||||||
|
import ExpensesWizard from './ExpensesWizard.js'; // same wizard you use in onboarding
|
||||||
|
import { Button } from './ui/button.js'; // Tailwind‑based button (optional)
|
||||||
|
|
||||||
|
/* helper – clamp 0‑100 */
|
||||||
|
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 [currentSalary, setCurrentSalary] = useState('');
|
||||||
const [additionalIncome, setAdditionalIncome] = useState('');
|
const [additionalIncome, setAdditionalIncome] = useState('');
|
||||||
const [monthlyExpenses, setMonthlyExpenses] = useState('');
|
const [monthlyExpenses, setMonthlyExpenses] = useState('');
|
||||||
@ -11,42 +21,72 @@ function FinancialProfileForm() {
|
|||||||
const [retirementSavings, setRetirementSavings] = useState('');
|
const [retirementSavings, setRetirementSavings] = useState('');
|
||||||
const [emergencyFund, setEmergencyFund] = useState('');
|
const [emergencyFund, setEmergencyFund] = useState('');
|
||||||
const [retirementContribution, setRetirementContribution] = useState('');
|
const [retirementContribution, setRetirementContribution] = useState('');
|
||||||
const [monthlyEmergencyContribution, setMonthlyEmergencyContribution] = useState('');
|
const [emergencyContribution, setEmergencyContribution] = useState('');
|
||||||
const [extraCashEmergencyPct, setExtraCashEmergencyPct] = useState('');
|
const [extraCashEmergencyPct, setExtraCashEmergencyPct] = useState('50');
|
||||||
const [extraCashRetirementPct, setExtraCashRetirementPct] = useState('');
|
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
|
/* -----------------------------------------------------------
|
||||||
|
* keep the two % inputs complementary (must add to 100)
|
||||||
|
* --------------------------------------------------------- */
|
||||||
|
function handleChange(e) {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
const pct = Math.max(0, Math.min(100, Number(value) || 0)); // clamp 0‑100
|
||||||
|
|
||||||
|
if (name === 'extraCashEmergencyPct') {
|
||||||
|
setExtraCashEmergencyPct(String(pct));
|
||||||
|
setExtraCashRetirementPct(String(100 - pct));
|
||||||
|
} else if (name === 'extraCashRetirementPct') {
|
||||||
|
setExtraCashRetirementPct(String(pct));
|
||||||
|
setExtraCashEmergencyPct(String(100 - pct));
|
||||||
|
} else {
|
||||||
|
// all other numeric fields:
|
||||||
|
// allow empty string so users can clear then re‑type
|
||||||
|
const update = valSetter => valSetter(value === '' ? '' : Number(value));
|
||||||
|
switch (name) {
|
||||||
|
case 'currentSalary': update(setCurrentSalary); break;
|
||||||
|
case 'additionalIncome': update(setAdditionalIncome); break;
|
||||||
|
case 'monthlyExpenses': update(setMonthlyExpenses); break;
|
||||||
|
case 'monthlyDebtPayments': update(setMonthlyDebtPayments); break;
|
||||||
|
case 'retirementSavings': update(setRetirementSavings); break;
|
||||||
|
case 'emergencyFund': update(setEmergencyFund); break;
|
||||||
|
case 'retirementContribution': update(setRetirementContribution); break;
|
||||||
|
case 'emergencyContribution': update(setEmergencyContribution); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────────── submit ─────────────────────── */
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
|
||||||
const body = {
|
const body = {
|
||||||
current_salary: parseFloat(currentSalary) || 0,
|
current_salary: parseFloat(currentSalary) || 0,
|
||||||
additional_income: parseFloat(additionalIncome) || 0,
|
additional_income: parseFloat(additionalIncome) || 0,
|
||||||
@ -55,127 +95,125 @@ function FinancialProfileForm() {
|
|||||||
retirement_savings: parseFloat(retirementSavings) || 0,
|
retirement_savings: parseFloat(retirementSavings) || 0,
|
||||||
emergency_fund: parseFloat(emergencyFund) || 0,
|
emergency_fund: parseFloat(emergencyFund) || 0,
|
||||||
retirement_contribution: parseFloat(retirementContribution) || 0,
|
retirement_contribution: parseFloat(retirementContribution) || 0,
|
||||||
monthly_emergency_contribution: parseFloat(monthlyEmergencyContribution) || 0,
|
emergency_contribution: parseFloat(emergencyContribution) || 0,
|
||||||
extra_cash_emergency_pct: parseFloat(extraCashEmergencyPct) || 0,
|
extra_cash_emergency_pct: pct(extraCashEmergencyPct),
|
||||||
extra_cash_retirement_pct: parseFloat(extraCashRetirementPct) || 0
|
extra_cash_retirement_pct: pct(extraCashRetirementPct)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await authFetch('/api/premium/financial-profile', {
|
const res = await authFetch('/api/premium/financial-profile', {
|
||||||
method: 'POST',
|
method : 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type':'application/json' },
|
||||||
body: JSON.stringify(body)
|
body : JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
if (res.ok) {
|
alert('Financial profile saved.');
|
||||||
// show success or redirect
|
nav(-1);
|
||||||
console.log("Profile updated");
|
|
||||||
} else {
|
|
||||||
console.error("Failed to update profile:", await res.text());
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error submitting financial profile:", 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">Edit Your Financial Profile</h2>
|
||||||
|
|
||||||
<label className="block font-medium">Current Salary</label>
|
{/* salary / income */}
|
||||||
<input
|
<label className="block font-medium">Current Annual Salary</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">Additional Annual Income</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">Monthly Living Expenses</label>
|
||||||
type="number"
|
<div className="flex space-x-2 items-center">
|
||||||
|
<input type="number" className="w-full border rounded p-2"
|
||||||
value={monthlyExpenses}
|
value={monthlyExpenses}
|
||||||
onChange={(e) => setMonthlyExpenses(e.target.value)}
|
onChange={e=>setMonthlyExpenses(e.target.value)} />
|
||||||
className="w-full border rounded p-2"
|
<Button className="bg-blue-600 text-white px-3 py-2 rounded"
|
||||||
placeholder="$"
|
type="button" onClick={openWizard}>
|
||||||
/>
|
Need Help?
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="block font-medium">Monthly Debt Payments</label>
|
{/* rest of the numeric fields */}
|
||||||
<input
|
<label className="block font-medium">Monthly Debt Payments</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">Retirement Savings</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">Emergency Fund</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">Monthly Retirement Contribution</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">Monthly Emergency Contribution</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">Extra Monthly Cash Allocation (must total 100%)</h3>
|
||||||
type="number"
|
|
||||||
|
<label className="block font-medium">To Emergency Fund (%)</label>
|
||||||
|
<input type="number" className="w-full border rounded p-2"
|
||||||
|
name="extraCashEmergencyPct"
|
||||||
value={extraCashEmergencyPct}
|
value={extraCashEmergencyPct}
|
||||||
onChange={(e) => setExtraCashEmergencyPct(e.target.value)}
|
onChange={handleChange} />
|
||||||
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">To Retirement (%)</label>
|
||||||
<input
|
<input type="number" className="w-full border rounded p-2"
|
||||||
type="number"
|
name="extraCashRetirementPct"
|
||||||
value={extraCashRetirementPct}
|
value={extraCashRetirementPct}
|
||||||
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">
|
{/* action buttons */}
|
||||||
Save and Continue
|
<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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* wizard modal */}
|
||||||
|
{showExpensesWizard && (
|
||||||
|
<Modal onClose={closeWizard}>
|
||||||
|
<ExpensesWizard
|
||||||
|
onClose={closeWizard}
|
||||||
|
onExpensesCalculated={total => {
|
||||||
|
setMonthlyExpenses(total);
|
||||||
|
closeWizard();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FinancialProfileForm;
|
|
||||||
|
@ -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('');
|
||||||
|
|
||||||
// We'll store an array of impacts. Each impact is { impact_type, direction, amount, start_month, end_month }
|
|
||||||
const [impacts, setImpacts] = useState([]);
|
const [impacts, setImpacts] = useState([]);
|
||||||
|
|
||||||
// On open, if editing, fill in existing fields
|
/* ────────────── init / reset ────────────── */
|
||||||
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 {
|
} else {
|
||||||
// fetch from backend if needed
|
setTitle(''); setDescription(''); setImpacts([]);
|
||||||
// e.g. GET /api/premium/milestones/:id/impacts
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Creating a new milestone
|
|
||||||
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 yyyy‑mm‑dd
|
||||||
|
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,
|
||||||
await authFetch('api/premium/milestone-impacts', {
|
impact_type : imp.impact_type,
|
||||||
method: 'POST',
|
frequency : imp.frequency, // ONE_TIME / MONTHLY
|
||||||
headers: { 'Content-Type': 'application/json' },
|
direction : imp.direction,
|
||||||
body: JSON.stringify({
|
amount : parseFloat(imp.amount) || 0,
|
||||||
milestone_id: milestoneId,
|
start_date : imp.start_date || null,
|
||||||
impact_type: impact.impact_type,
|
end_date : imp.frequency === 'MONTHLY' && imp.end_date
|
||||||
direction: impact.direction,
|
? imp.end_date
|
||||||
amount: parseFloat(impact.amount) || 0,
|
: null
|
||||||
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();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save milestone + impacts:', err);
|
|
||||||
// Show some UI error if needed
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
await authFetch('api/premium/milestone-impacts', {
|
||||||
|
method : 'POST',
|
||||||
|
headers: { 'Content-Type':'application/json' },
|
||||||
|
body : JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(true); // ← parent will refetch
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err);
|
||||||
|
alert('Sorry, something went wrong – please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────── UI ────────────── */
|
||||||
if (!show) return null;
|
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">Financial Impacts</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">One‑time</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>
|
||||||
|
<label className="block text-sm font-semibold">Start date</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="date"
|
||||||
value={impact.start_month}
|
value={imp.start_date}
|
||||||
onChange={(e) =>
|
onChange={e => updateImpact(i, 'start_date', e.target.value)}
|
||||||
handleImpactChange(i, 'start_month', e.target.value)
|
|
||||||
}
|
|
||||||
className="border px-2 py-1 w-full"
|
className="border px-2 py-1 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End Month (for MONTHLY, can be null/blank if indefinite) */}
|
{imp.frequency === 'MONTHLY' && (
|
||||||
{impact.impact_type === 'MONTHLY' && (
|
<div>
|
||||||
<div className="mt-2">
|
<label className="block text-sm font-semibold">
|
||||||
<label className="block font-semibold">End Month (optional)</label>
|
End date (optional)
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="date"
|
||||||
value={impact.end_month || ''}
|
value={imp.end_date || ''}
|
||||||
onChange={(e) =>
|
onChange={e => updateImpact(i, 'end_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>
|
||||||
)}
|
)}
|
||||||
</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
@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from './ui/button.js';
|
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());
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error activating premium:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
if (sub.is_premium || sub.is_pro_premium) {
|
||||||
|
const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="paywall">
|
<div className="max-w-lg mx-auto p-6 text-center space-y-4">
|
||||||
<h2>Unlock AptivaAI Premium</h2>
|
<h2 className="text-xl font-semibold">Your plan: {plan}</h2>
|
||||||
<ul>
|
<p className="text-sm text-gray-600">
|
||||||
<li>✅ Personalized Career Milestone Planning</li>
|
Manage payment method, invoices or cancel anytime.
|
||||||
<li>✅ Comprehensive Financial Projections</li>
|
</p>
|
||||||
<li>✅ Resume & Interview Assistance</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<Button
|
<Button onClick={openPortal} className="w-full">
|
||||||
onClick={handleSubscribe}
|
Manage subscription
|
||||||
className="bg-green-600 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Subscribe Now
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button onClick={() => navigate(-1)}>Cancel / Go Back</Button>
|
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
|
||||||
|
Back to app
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Paywall;
|
/* ─── no active sub => show the pricing choices ──────────────── */
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto p-6 space-y-8">
|
||||||
|
<header className="text-center">
|
||||||
|
<h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Choose the plan that fits your needs – cancel anytime.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Premium tier */}
|
||||||
|
<section className="border rounded-lg p-4 space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">Premium</h3>
|
||||||
|
<ul className="text-sm list-disc list-inside space-y-1">
|
||||||
|
<li>Career milestone planning</li>
|
||||||
|
<li>Financial projections & benchmarks</li>
|
||||||
|
<li>2 × resume optimizations / week</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button onClick={() => checkout('premium', 'monthly')}>$4.99 / mo</Button>
|
||||||
|
<Button onClick={() => checkout('premium', 'annual' )}>$49 / 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 GPT‑4o usage & 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 / mo</Button>
|
||||||
|
<Button onClick={() => checkout('pro', 'annual' )}>$79 / yr</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
|
||||||
|
Cancel / Go back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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. side‑effects when route brings a new career object ── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (careerTitle) {
|
if (!navCareerObj?.title) return;
|
||||||
setSelectedCareer(careerTitle);
|
|
||||||
setData((prev) => ({
|
setCareerObj(navCareerObj);
|
||||||
|
localStorage.setItem('selectedCareer', JSON.stringify(navCareerObj));
|
||||||
|
|
||||||
|
|
||||||
|
setData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
career_name: careerTitle,
|
career_name : navCareerObj.title,
|
||||||
soc_code: socCode || ''
|
soc_code : navCareerObj.soc_code || ''
|
||||||
}));
|
}));
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [careerTitle, socCode, setData]);
|
}, [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 it’s a new goal or the one you're already in.
|
This should be the career you are <strong>striving for</strong> — whether it’s 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>
|
||||||
|
@ -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 yyyy‑mm‑dd
|
||||||
|
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 */
|
||||||
|
/* 1 year = 12 months ‑‑ preserve fractions (e.g. 1.75 y = 21 m) */
|
||||||
|
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); // yyyy‑mm‑dd
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
<>
|
||||||
|
{/* A) Enrollment date – prospective only */}
|
||||||
|
{college_enrollment_status === 'prospective_student' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block font-medium">
|
||||||
|
Anticipated Enrollment Date <Req />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
name="expected_graduation"
|
value={enrollmentDate}
|
||||||
value={expected_graduation}
|
onChange={e => {
|
||||||
onChange={handleParentFieldChange}
|
setEnrollmentDate(e.target.value);
|
||||||
|
setData(p => ({ ...p, enrollment_date: e.target.value }));
|
||||||
|
}}
|
||||||
className="w-full border rounded p-2"
|
className="w-full border rounded p-2"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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."
|
||||||
|
>ⓘ</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>
|
||||||
|
@ -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.length‑1
|
||||||
|
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}
|
||||||
/>,
|
/>,
|
||||||
|
@ -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>
|
||||||
|
@ -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 />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user has *either* premium or pro
|
/* Already premium → proceed */
|
||||||
const hasPremiumOrPro = user.is_premium || user.is_pro_premium;
|
if (user?.is_premium || user?.is_pro_premium) {
|
||||||
if (!hasPremiumOrPro) {
|
|
||||||
// Logged in but neither plan; go to paywall
|
|
||||||
return <Navigate to="/paywall" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User is logged in and has premium or pro
|
|
||||||
return children;
|
return children;
|
||||||
|
}
|
||||||
|
/* NEW: send to paywall and remember where they wanted to go */
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to="/paywall"
|
||||||
|
replace
|
||||||
|
state={{ redirectTo: loc.pathname, prevState: loc.state }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PremiumRoute;
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 */}
|
||||||
|
@ -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,
|
||||||
|
@ -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')) {
|
if (type.startsWith('SALARY')) {
|
||||||
// SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12
|
// ─── salary changes affect GROSS income ───
|
||||||
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user