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
|
||||
SERVER2_PORT=5001
|
||||
SERVER3_PORT=5002
|
||||
SALARY_DB=/salary_info.db
|
||||
NODE_ENV=production
|
||||
IMG_TAG=202507301457
|
@ -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
|
||||
_logout
|
||||
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
|
||||
FROM node:20-slim
|
||||
# ---- Dockerfile.server3 (fixed) ------------------------------
|
||||
FROM node:20-bullseye
|
||||
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 ./
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential python3 make g++ --no-install-recommends python3 git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
RUN npm ci --omit=dev --unsafe-perm
|
||||
|
||||
# 3. static assets & source
|
||||
COPY public/ /app/public/
|
||||
COPY . .
|
||||
ENV PORT=5002
|
||||
EXPOSE 5002
|
||||
|
||||
CMD ["node", "backend/server3.js"]
|
||||
|
||||
|
@ -18,6 +18,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const rootPath = path.resolve(__dirname, '..'); // Up one level
|
||||
const env = process.env.NODE_ENV?.trim() || 'development';
|
||||
const stage = env === 'staging' ? 'development' : env;
|
||||
const envPath = path.resolve(rootPath, `.env.${env}`);
|
||||
dotenv.config({ path: envPath }); // Load .env file
|
||||
|
||||
@ -34,7 +35,6 @@ if (!JWT_SECRET) {
|
||||
process.exit(1); // container exits, Docker marks it unhealthy
|
||||
}
|
||||
|
||||
|
||||
// Create a MySQL pool for user_profile data
|
||||
const pool = mysql.createPool({
|
||||
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
|
||||
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) ──────── */
|
||||
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
|
||||
const salaryDbPath =
|
||||
process.env.SALARY_DB || '/app/data/salary_info.db';
|
||||
const salaryDbPath =
|
||||
process.env.SALARY_DB_PATH // ← preferred
|
||||
|| process.env.SALARY_DB // ← legacy
|
||||
|| '/app/salary_info.db'; // final fallback
|
||||
|
||||
const salaryDb = new sqlite3.Database(
|
||||
salaryDbPath,
|
@ -19,22 +19,53 @@ import db from './config/mysqlPool.js';
|
||||
import './jobs/reminderCron.js';
|
||||
import OpenAI from 'openai';
|
||||
import Fuse from 'fuse.js';
|
||||
import Stripe from 'stripe';
|
||||
import { createReminder } from './utils/smsService.js';
|
||||
import { cacheSummary } from "./utils/ctxCache.js";
|
||||
|
||||
const rootPath = path.resolve(__dirname, '..'); // one level up
|
||||
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
|
||||
dotenv.config({ path: envPath });
|
||||
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
|
||||
if (!process.env.FROM_SECRETS_MANAGER) {
|
||||
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 PORT = process.env.SERVER3_PORT || 5002;
|
||||
const { getDocument } = pkg;
|
||||
const bt = "`".repeat(3);
|
||||
|
||||
function internalFetch(req, url, opts = {}) {
|
||||
return fetch(url, {
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
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,
|
||||
headers: {
|
||||
"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
|
||||
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
|
||||
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');
|
||||
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) ─────── */
|
||||
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||
@ -115,8 +194,54 @@ const authenticatePremiumUser = (req, res, next) => {
|
||||
|
||||
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
|
||||
@ -125,11 +250,10 @@ app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
|
||||
async function applyOps(opsObj, req) {
|
||||
if (!opsObj?.milestones || !Array.isArray(opsObj.milestones)) return [];
|
||||
|
||||
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
|
||||
const confirmations = [];
|
||||
|
||||
// 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) {
|
||||
const { op } = m || {};
|
||||
@ -193,8 +317,7 @@ app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (re
|
||||
const sql = `
|
||||
SELECT
|
||||
*,
|
||||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
|
||||
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
|
||||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date
|
||||
FROM career_profiles
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date DESC
|
||||
@ -214,8 +337,7 @@ app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req,
|
||||
const sql = `
|
||||
SELECT
|
||||
*,
|
||||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
|
||||
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
|
||||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date
|
||||
FROM career_profiles
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date ASC
|
||||
@ -235,8 +357,7 @@ app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser,
|
||||
const sql = `
|
||||
SELECT
|
||||
*,
|
||||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
|
||||
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
|
||||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date
|
||||
FROM career_profiles
|
||||
WHERE id = ?
|
||||
AND user_id = ?
|
||||
@ -262,7 +383,6 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
career_name,
|
||||
status,
|
||||
start_date,
|
||||
projected_end_date,
|
||||
college_enrollment_status,
|
||||
currently_working,
|
||||
career_goals,
|
||||
@ -295,7 +415,6 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
career_name,
|
||||
status,
|
||||
start_date,
|
||||
projected_end_date,
|
||||
college_enrollment_status,
|
||||
currently_working,
|
||||
career_goals,
|
||||
@ -309,11 +428,10 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
planned_surplus_retirement_pct,
|
||||
planned_additional_income
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
start_date = VALUES(start_date),
|
||||
projected_end_date = VALUES(projected_end_date),
|
||||
college_enrollment_status = VALUES(college_enrollment_status),
|
||||
currently_working = VALUES(currently_working),
|
||||
career_goals = VALUES(career_goals),
|
||||
@ -336,7 +454,6 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
|
||||
career_name,
|
||||
status || 'planned',
|
||||
start_date || null,
|
||||
projected_end_date || null,
|
||||
college_enrollment_status || null,
|
||||
currently_working || null,
|
||||
career_goals || null,
|
||||
@ -922,9 +1039,9 @@ ${econText}
|
||||
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
|
||||
let aiRisk = null;
|
||||
try {
|
||||
const aiRiskRes = await internalFetch(
|
||||
const aiRiskRes = await auth(
|
||||
req,
|
||||
`${apiBase}/premium/ai-risk-analysis`,
|
||||
'/premium/ai-risk-analysis',
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@ -1296,7 +1413,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
|
||||
};
|
||||
|
||||
// Call your existing milestone endpoint
|
||||
const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, {
|
||||
const msRes = await auth(req, '/premium/milestone', {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(milestoneBody)
|
||||
@ -1331,7 +1448,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
|
||||
due_date: taskObj.due_date || null
|
||||
};
|
||||
|
||||
await internalFetch(req, `${apiBase}/premium/tasks`, {
|
||||
await auth(req, '/premium/tasks', {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(taskBody)
|
||||
@ -1365,7 +1482,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
|
||||
end_date: impObj.end_date || null
|
||||
};
|
||||
|
||||
await internalFetch(req, `${apiBase}/premium/milestone-impacts`, {
|
||||
await auth(req, '/premium/milestone-impacts', {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(impactBody)
|
||||
@ -1538,7 +1655,7 @@ Always end with: “AptivaAI is an educational tool – not advice.”
|
||||
|
||||
if (payloadObj?.cloneScenario) {
|
||||
/* ------ CLONE ------ */
|
||||
await internalFetch(req, `${apiBase}/premium/career-profile/clone`, {
|
||||
await auth(req, '/premium/career-profile/clone', {
|
||||
method: 'POST',
|
||||
body : JSON.stringify(payloadObj.cloneScenario),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
@ -2452,17 +2569,32 @@ app.delete('/api/premium/milestones/:milestoneId', authenticatePremiumUser, asyn
|
||||
FINANCIAL PROFILES
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
// GET /api/premium/financial-profile
|
||||
app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT *
|
||||
FROM financial_profiles
|
||||
WHERE user_id = ?
|
||||
`, [req.id]);
|
||||
res.json(rows[0] || {});
|
||||
} catch (error) {
|
||||
console.error('Error fetching financial profile:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch financial profile' });
|
||||
const [rows] = await pool.query(
|
||||
'SELECT * FROM financial_profiles WHERE user_id=? LIMIT 1',
|
||||
[req.id]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return res.json({
|
||||
current_salary: 0,
|
||||
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
|
||||
------------------------------------------------------------------ */
|
||||
@ -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
|
||||
------------------------------------------------------------------ */
|
||||
|
@ -1,5 +1,5 @@
|
||||
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> }`
|
||||
@ -10,7 +10,7 @@ export default function authenticateUser(req, res, next) {
|
||||
if (!token) return res.status(401).json({ error: "Authorization token required" });
|
||||
|
||||
try {
|
||||
const { id } = jwt.verify(token, SECRET_KEY);
|
||||
const { id } = jwt.verify(token, JWT_SECRET);
|
||||
req.user = { id }; // attach the id for downstream use
|
||||
next();
|
||||
} 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
|
||||
env_file:
|
||||
- ${RUNTIME_ENV_FILE:-.env.production} # default for local runs
|
||||
- .env # committed, non‑secret
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
# ───────────────────────────── server1 ─────────────────────────────
|
||||
server1:
|
||||
<<: *with-env
|
||||
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG}
|
||||
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:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER1_PORT}/healthz || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ───────────────────────────── server2 ─────────────────────────────
|
||||
server2:
|
||||
<<: *with-env
|
||||
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG}
|
||||
expose: ["${SERVER2_PORT}"]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ${RUNTIME_ENV_FILE}
|
||||
environment:
|
||||
ONET_USERNAME: ${ONET_USERNAME}
|
||||
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:
|
||||
- ./public:/app/public:ro
|
||||
- ./salary_info.db:/app/salary_info.db:ro
|
||||
@ -30,26 +60,58 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ───────────────────────────── server3 ─────────────────────────────
|
||||
server3:
|
||||
<<: *with-env
|
||||
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG}
|
||||
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:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ───────────────────────────── nginx ───────────────────────────────
|
||||
nginx:
|
||||
<<: *with-env
|
||||
image: nginx:1.25-alpine
|
||||
command: ["nginx", "-g", "daemon off;"]
|
||||
depends_on: [server1, server2, server3]
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
networks: [default, aptiva-shared]
|
||||
ports: ["80:80", "443:443"]
|
||||
volumes:
|
||||
- ./build:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./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 {
|
||||
include /etc/nginx/mime.types;
|
||||
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 backend5001 { server server2:5001; } # onet, distance, etc.
|
||||
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 {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
@ -19,19 +22,19 @@ http {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 2. Main virtual host on :443
|
||||
# -----------------------------------------------------------------------
|
||||
########################################################################
|
||||
# 2. Main virtual host (dev1.aptivaai.com) on :443
|
||||
########################################################################
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen 443 ssl;
|
||||
http2 on; # modern syntax
|
||||
server_name dev1.aptivaai.com;
|
||||
|
||||
# ---------- TLS -----------------------------------------------------
|
||||
ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# ---------- React static assets -------------------------------------
|
||||
# ───── React static assets ─────
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / {
|
||||
@ -42,13 +45,7 @@ http {
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 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.
|
||||
# ───── API reverse‑proxy rules ─────
|
||||
location ^~ /api/onet/ { proxy_pass http://backend5001; }
|
||||
location ^~ /api/chat/ { proxy_pass http://backend5001; proxy_http_version 1.1; proxy_buffering off; }
|
||||
location ^~ /api/job-zones { proxy_pass http://backend5001; }
|
||||
@ -61,23 +58,81 @@ http {
|
||||
location ^~ /api/maps/distance { 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/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; }
|
||||
|
||||
# ---------- shared proxy settings -----------------------------------
|
||||
## Add the headers *once*; they apply to every proxy_pass above.
|
||||
# shared proxy headers
|
||||
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 $scheme;
|
||||
|
||||
# ---------- error pages ---------------------------------------------
|
||||
error_page 502 503 504 /50x.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",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
@ -53,6 +52,7 @@
|
||||
"react-spinners": "^0.15.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stripe": "^14.0.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"twilio": "^5.7.1",
|
||||
@ -18715,6 +18715,19 @@
|
||||
"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": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz",
|
||||
|
@ -47,6 +47,7 @@
|
||||
"react-spinners": "^0.15.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stripe": "^14.0.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"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 UserProfile from './components/UserProfile.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 Paywall from './components/Paywall.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 ChatDrawer from './components/ChatDrawer.js';
|
||||
import ChatCtx from './contexts/ChatCtx.js';
|
||||
import BillingResult from './components/BillingResult.js';
|
||||
|
||||
|
||||
|
||||
@ -171,8 +176,10 @@ const uiToolHandlers = useMemo(() => {
|
||||
|
||||
const confirmLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('id');
|
||||
localStorage.removeItem('careerSuggestionsCache');
|
||||
localStorage.removeItem('lastSelectedCareerProfileId');
|
||||
localStorage.removeItem('selectedCareer');
|
||||
localStorage.removeItem('aiClickCount');
|
||||
localStorage.removeItem('aiClickDate');
|
||||
localStorage.removeItem('aiRecommendations');
|
||||
@ -216,7 +223,7 @@ const uiToolHandlers = useMemo(() => {
|
||||
<ProfileCtx.Provider
|
||||
value={{ financialProfile, setFinancialProfile,
|
||||
scenario, setScenario,
|
||||
user, }}
|
||||
user, setUser}}
|
||||
>
|
||||
<ChatCtx.Provider value={{ setChatSnapshot,
|
||||
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
|
||||
@ -237,7 +244,7 @@ const uiToolHandlers = useMemo(() => {
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
|
||||
<h1 className="text-lg font-semibold">
|
||||
AptivaAI - Career Guidance Platform (beta)
|
||||
AptivaAI - Career Guidance Platform
|
||||
</h1>
|
||||
|
||||
{isAuthenticated && (
|
||||
@ -358,7 +365,7 @@ const uiToolHandlers = useMemo(() => {
|
||||
)}
|
||||
onClick={() => navigate('/retirement')}
|
||||
>
|
||||
Retirement Planning
|
||||
Retirement Planning (beta)
|
||||
{!canAccessPremium && (
|
||||
<span className="text-xs ml-1 text-gray-600">
|
||||
(Premium)
|
||||
@ -405,19 +412,31 @@ const uiToolHandlers = useMemo(() => {
|
||||
{canAccessPremium ? (
|
||||
/* Premium users go straight to the wizard */
|
||||
<Link
|
||||
to="/premium-onboarding"
|
||||
to="/profile/careers"
|
||||
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
|
||||
>
|
||||
Premium Onboarding
|
||||
Career Profiles
|
||||
</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
|
||||
to="/paywall"
|
||||
to="/profile/college"
|
||||
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>
|
||||
) : (
|
||||
<span className="block px-4 py-2 text-sm text-gray-400 cursor-not-allowed">
|
||||
College Profiles (Premium)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -505,6 +524,7 @@ const uiToolHandlers = useMemo(() => {
|
||||
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
|
||||
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
||||
<Route path="/preparing" element={<PreparingLanding />} />
|
||||
<Route path="/billing" element={<BillingResult />} />
|
||||
|
||||
{/* Premium-only routes */}
|
||||
<Route
|
||||
@ -531,6 +551,11 @@ const uiToolHandlers = useMemo(() => {
|
||||
</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
|
||||
path="/financial-profile"
|
||||
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 }]);
|
||||
|
||||
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
|
||||
if (createdMilestones.length && onMilestonesCreated)
|
||||
onMilestonesCreated(createdMilestones.length);
|
||||
if (createdMilestones.length && typeof onMilestonesCreated === 'function') {
|
||||
onMilestonesCreated(); // no arg needed – just refetch
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
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 differenceInMonths from 'date-fns/differenceInMonths';
|
||||
|
||||
|
||||
import "../styles/legacy/MilestoneTimeline.legacy.css";
|
||||
|
||||
// --------------
|
||||
@ -510,8 +511,13 @@ useEffect(() => {
|
||||
const up = await authFetch('/api/user-profile');
|
||||
if (up.ok) setUserProfile(await up.json());
|
||||
|
||||
const fp = await authFetch('api/premium/financial-profile');
|
||||
if (fp.ok) setFinancialProfile(await fp.json());
|
||||
const fp = await authFetch('/api/premium/financial-profile');
|
||||
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 {
|
||||
// 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, ... }
|
||||
} catch (err) {
|
||||
// 2) If 404 => call server3
|
||||
@ -953,7 +959,6 @@ useEffect(() => {
|
||||
|
||||
// 8) Build financial projection
|
||||
async function buildProjection(milestones) {
|
||||
if (!milestones?.length) return;
|
||||
const allMilestones = milestones || [];
|
||||
try {
|
||||
setScenarioMilestones(allMilestones);
|
||||
@ -1295,6 +1300,14 @@ const fetchMilestones = useCallback(async () => {
|
||||
} // single rebuild
|
||||
}, [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 (
|
||||
<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 */}
|
||||
<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
|
||||
groups={milestoneGroups}
|
||||
onEdit={onEditMilestone}
|
||||
|
@ -26,7 +26,9 @@ const CareerSelectDropdown = ({ existingCareerProfiles, selectedCareer, onChange
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<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
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function FinancialProfileForm() {
|
||||
// We'll store the fields in local state
|
||||
import authFetch from '../utils/authFetch.js';
|
||||
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 [additionalIncome, setAdditionalIncome] = useState('');
|
||||
const [monthlyExpenses, setMonthlyExpenses] = useState('');
|
||||
@ -11,42 +21,72 @@ function FinancialProfileForm() {
|
||||
const [retirementSavings, setRetirementSavings] = useState('');
|
||||
const [emergencyFund, setEmergencyFund] = useState('');
|
||||
const [retirementContribution, setRetirementContribution] = useState('');
|
||||
const [monthlyEmergencyContribution, setMonthlyEmergencyContribution] = useState('');
|
||||
const [extraCashEmergencyPct, setExtraCashEmergencyPct] = useState('');
|
||||
const [extraCashRetirementPct, setExtraCashRetirementPct] = useState('');
|
||||
const [emergencyContribution, setEmergencyContribution] = useState('');
|
||||
const [extraCashEmergencyPct, setExtraCashEmergencyPct] = useState('50');
|
||||
const [extraCashRetirementPct, setExtraCashRetirementPct] = useState('50');
|
||||
|
||||
/* wizard modal */
|
||||
const [showExpensesWizard, setShowExpensesWizard] = useState(false);
|
||||
const openWizard = () => setShowExpensesWizard(true);
|
||||
const closeWizard = () => setShowExpensesWizard(false);
|
||||
|
||||
/* ───────────── preload existing row ───────── */
|
||||
useEffect(() => {
|
||||
// On mount, fetch the user's existing profile from the new financial_profiles table
|
||||
async function fetchProfile() {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await authFetch('/api/premium/financial-profile', {
|
||||
method: 'GET'
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// data might be an empty object if no row yet
|
||||
setCurrentSalary(data.current_salary || '');
|
||||
setAdditionalIncome(data.additional_income || '');
|
||||
setMonthlyExpenses(data.monthly_expenses || '');
|
||||
setMonthlyDebtPayments(data.monthly_debt_payments || '');
|
||||
setRetirementSavings(data.retirement_savings || '');
|
||||
setEmergencyFund(data.emergency_fund || '');
|
||||
setRetirementContribution(data.retirement_contribution || '');
|
||||
setMonthlyEmergencyContribution(data.monthly_emergency_contribution || '');
|
||||
setExtraCashEmergencyPct(data.extra_cash_emergency_pct || '');
|
||||
setExtraCashRetirementPct(data.extra_cash_retirement_pct || '');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load financial profile:", err);
|
||||
}
|
||||
}
|
||||
fetchProfile();
|
||||
const res = await authFetch('/api/premium/financial-profile');
|
||||
if (!res.ok) return;
|
||||
const d = await res.json();
|
||||
|
||||
setCurrentSalary (d.current_salary ?? '');
|
||||
setAdditionalIncome (d.additional_income ?? '');
|
||||
setMonthlyExpenses (d.monthly_expenses ?? '');
|
||||
setMonthlyDebtPayments (d.monthly_debt_payments ?? '');
|
||||
setRetirementSavings (d.retirement_savings ?? '');
|
||||
setEmergencyFund (d.emergency_fund ?? '');
|
||||
setRetirementContribution (d.retirement_contribution ?? '');
|
||||
setEmergencyContribution (d.emergency_contribution ?? '');
|
||||
setExtraCashEmergencyPct (d.extra_cash_emergency_pct ?? '');
|
||||
setExtraCashRetirementPct (d.extra_cash_retirement_pct ?? '');
|
||||
} catch (err) { console.error(err); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 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) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
|
||||
const body = {
|
||||
current_salary: parseFloat(currentSalary) || 0,
|
||||
additional_income: parseFloat(additionalIncome) || 0,
|
||||
@ -55,127 +95,125 @@ function FinancialProfileForm() {
|
||||
retirement_savings: parseFloat(retirementSavings) || 0,
|
||||
emergency_fund: parseFloat(emergencyFund) || 0,
|
||||
retirement_contribution: parseFloat(retirementContribution) || 0,
|
||||
monthly_emergency_contribution: parseFloat(monthlyEmergencyContribution) || 0,
|
||||
extra_cash_emergency_pct: parseFloat(extraCashEmergencyPct) || 0,
|
||||
extra_cash_retirement_pct: parseFloat(extraCashRetirementPct) || 0
|
||||
emergency_contribution: parseFloat(emergencyContribution) || 0,
|
||||
extra_cash_emergency_pct: pct(extraCashEmergencyPct),
|
||||
extra_cash_retirement_pct: pct(extraCashRetirementPct)
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await authFetch('/api/premium/financial-profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// show success or redirect
|
||||
console.log("Profile updated");
|
||||
} else {
|
||||
console.error("Failed to update profile:", await res.text());
|
||||
}
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
alert('Financial profile saved.');
|
||||
nav(-1);
|
||||
} catch (err) {
|
||||
console.error("Error submitting financial profile:", err);
|
||||
console.error(err);
|
||||
alert('Failed to save financial profile.');
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────── view ───────────────────────── */
|
||||
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>
|
||||
<input
|
||||
type="number"
|
||||
value={currentSalary}
|
||||
onChange={(e) => setCurrentSalary(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="$"
|
||||
/>
|
||||
{/* salary / income */}
|
||||
<label className="block font-medium">Current Annual Salary</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="currentSalary" value={currentSalary} onChange={handleChange} />
|
||||
|
||||
<label className="block font-medium">Additional Monthly Income</label>
|
||||
<input
|
||||
type="number"
|
||||
value={additionalIncome}
|
||||
onChange={(e) => setAdditionalIncome(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="$"
|
||||
/>
|
||||
<label className="block font-medium">Additional Annual Income</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="additionalIncome" value={additionalIncome} onChange={handleChange} />
|
||||
|
||||
<label className="block font-medium">Monthly Living Expenses</label>
|
||||
<input
|
||||
type="number"
|
||||
{/* expenses with wizard */}
|
||||
<label className="block font-medium">Monthly Living Expenses</label>
|
||||
<div className="flex space-x-2 items-center">
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
value={monthlyExpenses}
|
||||
onChange={(e) => setMonthlyExpenses(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="$"
|
||||
/>
|
||||
onChange={e=>setMonthlyExpenses(e.target.value)} />
|
||||
<Button className="bg-blue-600 text-white px-3 py-2 rounded"
|
||||
type="button" onClick={openWizard}>
|
||||
Need Help?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="block font-medium">Monthly Debt Payments</label>
|
||||
<input
|
||||
type="number"
|
||||
value={monthlyDebtPayments}
|
||||
onChange={(e) => setMonthlyDebtPayments(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="$"
|
||||
/>
|
||||
{/* rest of the numeric fields */}
|
||||
<label className="block font-medium">Monthly Debt Payments</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="monthlyDebtPayments" value={monthlyDebtPayments} onChange={handleChange} />
|
||||
|
||||
<label className="block font-medium">Retirement Savings</label>
|
||||
<input
|
||||
type="number"
|
||||
value={retirementSavings}
|
||||
onChange={(e) => setRetirementSavings(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="$"
|
||||
/>
|
||||
<label className="block font-medium">Retirement Savings</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="retirementSavings" value={retirementSavings} onChange={handleChange} />
|
||||
|
||||
<label className="block font-medium">Emergency Fund</label>
|
||||
<input
|
||||
type="number"
|
||||
value={emergencyFund}
|
||||
onChange={(e) => setEmergencyFund(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="$"
|
||||
/>
|
||||
<label className="block font-medium">Emergency Fund</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="emergencyFund" value={emergencyFund} onChange={handleChange} />
|
||||
|
||||
<label className="block font-medium">Monthly Retirement Contribution</label>
|
||||
<input
|
||||
type="number"
|
||||
value={retirementContribution}
|
||||
onChange={(e) => setRetirementContribution(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="$"
|
||||
/>
|
||||
<label className="block font-medium">Monthly Retirement Contribution</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="retirementContribution" value={retirementContribution} onChange={handleChange} />
|
||||
|
||||
<label className="block font-medium">Monthly Emergency Contribution</label>
|
||||
<input
|
||||
type="number"
|
||||
value={monthlyEmergencyContribution}
|
||||
onChange={(e) => setMonthlyEmergencyContribution(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="$"
|
||||
/>
|
||||
<label className="block font-medium">Monthly Emergency Contribution</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="emergencyContribution"
|
||||
value={emergencyContribution}
|
||||
onChange={handleChange} />
|
||||
|
||||
<label className="block font-medium">Extra Cash to Emergency (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
{/* allocation – kept in sync */}
|
||||
<h3 className="text-lg font-medium pt-2">Extra Monthly Cash Allocation (must total 100%)</h3>
|
||||
|
||||
<label className="block font-medium">To Emergency Fund (%)</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="extraCashEmergencyPct"
|
||||
value={extraCashEmergencyPct}
|
||||
onChange={(e) => setExtraCashEmergencyPct(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="e.g. 30"
|
||||
/>
|
||||
onChange={handleChange} />
|
||||
|
||||
<label className="block font-medium">Extra Cash to Retirement (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
<label className="block font-medium">To Retirement (%)</label>
|
||||
<input type="number" className="w-full border rounded p-2"
|
||||
name="extraCashRetirementPct"
|
||||
value={extraCashRetirementPct}
|
||||
onChange={(e) => setExtraCashRetirementPct(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
placeholder="e.g. 70"
|
||||
/>
|
||||
onChange={handleChange} />
|
||||
|
||||
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
|
||||
Save and Continue
|
||||
{/* action buttons */}
|
||||
<div className="pt-4 flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={()=>nav(-1)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold py-2 px-4 rounded"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* wizard modal */}
|
||||
{showExpensesWizard && (
|
||||
<Modal onClose={closeWizard}>
|
||||
<ExpensesWizard
|
||||
onClose={closeWizard}
|
||||
onExpensesCalculated={total => {
|
||||
setMonthlyExpenses(total);
|
||||
closeWizard();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FinancialProfileForm;
|
||||
|
@ -2,263 +2,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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,
|
||||
onClose,
|
||||
defaultScenarioId,
|
||||
scenarioId, // which scenario this milestone applies to
|
||||
editMilestone, // if editing an existing milestone, pass its data
|
||||
}) => {
|
||||
// Basic milestone fields
|
||||
scenarioId, // active scenario UUID
|
||||
editMilestone = null // pass full row when editing
|
||||
}) {
|
||||
/* ────────────── state ────────────── */
|
||||
const [title, setTitle] = 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([]);
|
||||
|
||||
// On open, if editing, fill in existing fields
|
||||
/* ────────────── init / reset ────────────── */
|
||||
useEffect(() => {
|
||||
if (!show) return; // if modal is hidden, do nothing
|
||||
if (!show) return;
|
||||
|
||||
if (editMilestone) {
|
||||
setTitle(editMilestone.title || '');
|
||||
setDescription(editMilestone.description || '');
|
||||
// If editing, you might fetch existing impacts from the server or they could be passed in
|
||||
if (editMilestone.impacts) {
|
||||
setImpacts(editMilestone.impacts);
|
||||
setImpacts(editMilestone.impacts || []);
|
||||
} else {
|
||||
// fetch from backend if needed
|
||||
// e.g. GET /api/premium/milestones/:id/impacts
|
||||
}
|
||||
} else {
|
||||
// Creating a new milestone
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setImpacts([]);
|
||||
setTitle(''); setDescription(''); setImpacts([]);
|
||||
}
|
||||
}, [show, editMilestone]);
|
||||
|
||||
// Handler: add a new blank impact
|
||||
const handleAddImpact = () => {
|
||||
setImpacts((prev) => [
|
||||
/* ────────────── helpers ────────────── */
|
||||
const addImpactRow = () =>
|
||||
setImpacts(prev => [
|
||||
...prev,
|
||||
{
|
||||
impact_type: 'ONE_TIME',
|
||||
direction: 'subtract',
|
||||
amount: 0,
|
||||
start_month: 0,
|
||||
end_month: null
|
||||
impact_type : 'cost',
|
||||
frequency : 'ONE_TIME',
|
||||
direction : 'subtract',
|
||||
amount : 0,
|
||||
start_date : '', // ISO yyyy‑mm‑dd
|
||||
end_date : '' // blank ⇒ indefinite
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// Handler: update a single impact in the array
|
||||
const handleImpactChange = (index, field, value) => {
|
||||
setImpacts((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
return updated;
|
||||
const updateImpact = (idx, field, value) =>
|
||||
setImpacts(prev => {
|
||||
const copy = [...prev];
|
||||
copy[idx] = { ...copy[idx], [field]: value };
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
// Handler: remove an impact row
|
||||
const handleRemoveImpact = (index) => {
|
||||
setImpacts((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
const removeImpact = idx =>
|
||||
setImpacts(prev => prev.filter((_, i) => i !== idx));
|
||||
|
||||
// Handler: Save everything to the server
|
||||
const handleSave = async () => {
|
||||
/* ────────────── save ────────────── */
|
||||
async function handleSave() {
|
||||
try {
|
||||
let milestoneId;
|
||||
if (editMilestone) {
|
||||
// 1) Update existing milestone
|
||||
milestoneId = editMilestone.id;
|
||||
/* 1️⃣ create OR update the milestone row */
|
||||
let milestoneId = editMilestone?.id;
|
||||
if (milestoneId) {
|
||||
await authFetch(`api/premium/milestones/${milestoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
scenario_id: scenarioId,
|
||||
// Possibly other fields
|
||||
})
|
||||
method : 'PUT',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify({ title, description })
|
||||
});
|
||||
// Then handle impacts below...
|
||||
} else {
|
||||
// 1) Create new milestone
|
||||
const res = await authFetch('api/premium/milestones', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
scenario_id: scenarioId
|
||||
career_profile_id: scenarioId
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create milestone');
|
||||
const created = await res.json();
|
||||
milestoneId = created.id; // assuming the response returns { id: newMilestoneId }
|
||||
if (!res.ok) throw new Error('Milestone create failed');
|
||||
const json = await res.json();
|
||||
milestoneId = json.id ?? json[0]?.id; // array OR obj
|
||||
}
|
||||
|
||||
// 2) For the impacts, we can do a batch approach or individual calls
|
||||
// For simplicity, let's do multiple POST calls
|
||||
for (const impact of impacts) {
|
||||
// If editing, you might do a PUT if the impact already has an id
|
||||
await authFetch('api/premium/milestone-impacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
milestone_id: milestoneId,
|
||||
impact_type: impact.impact_type,
|
||||
direction: impact.direction,
|
||||
amount: parseFloat(impact.amount) || 0,
|
||||
start_month: parseInt(impact.start_month, 10) || 0,
|
||||
end_month: impact.end_month !== null
|
||||
? parseInt(impact.end_month, 10)
|
||||
: null,
|
||||
created_at: new Date().toISOString().slice(0, 10),
|
||||
updated_at: new Date().toISOString().slice(0, 10)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Done, close modal
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save milestone + impacts:', err);
|
||||
// Show some UI error if needed
|
||||
}
|
||||
/* 2️⃣ upsert each impact (one call per row) */
|
||||
for (const imp of impacts) {
|
||||
const body = {
|
||||
milestone_id : milestoneId,
|
||||
impact_type : imp.impact_type,
|
||||
frequency : imp.frequency, // ONE_TIME / MONTHLY
|
||||
direction : imp.direction,
|
||||
amount : parseFloat(imp.amount) || 0,
|
||||
start_date : imp.start_date || null,
|
||||
end_date : imp.frequency === 'MONTHLY' && imp.end_date
|
||||
? imp.end_date
|
||||
: null
|
||||
};
|
||||
await authFetch('api/premium/milestone-impacts', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type':'application/json' },
|
||||
body : JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
onClose(true); // ← parent will refetch
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
alert('Sorry, something went wrong – please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────── UI ────────────── */
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-container">
|
||||
<div className="modal-container w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-2">
|
||||
{editMilestone ? 'Edit Milestone' : 'Add Milestone'}
|
||||
</h2>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="block font-semibold">Title</label>
|
||||
{/* basic fields */}
|
||||
<label className="block font-semibold mt-2">Title</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="block font-semibold">Description</label>
|
||||
<label className="block font-semibold mt-4">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="border w-full px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Impacts Section */}
|
||||
<h3 className="text-lg font-semibold mt-4">Financial Impacts</h3>
|
||||
{impacts.map((impact, i) => (
|
||||
<div key={i} className="border rounded p-2 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p>Impact #{i + 1}</p>
|
||||
{/* impacts */}
|
||||
<h3 className="text-lg font-semibold mt-6">Financial Impacts</h3>
|
||||
|
||||
{impacts.map((imp, i) => (
|
||||
<div key={i} className="border rounded p-3 mt-4 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Impact #{i + 1}</span>
|
||||
<button
|
||||
className="text-red-500"
|
||||
onClick={() => handleRemoveImpact(i)}
|
||||
className="text-red-600 text-sm"
|
||||
onClick={() => removeImpact(i)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Impact Type */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Type</label>
|
||||
{/* type */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Type</label>
|
||||
<select
|
||||
value={impact.impact_type}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'impact_type', e.target.value)
|
||||
}
|
||||
value={imp.impact_type}
|
||||
onChange={e => updateImpact(i, 'impact_type', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
>
|
||||
<option value="ONE_TIME">One-Time</option>
|
||||
<option value="MONTHLY">Monthly</option>
|
||||
{IMPACT_TYPES.map(t => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'salary' ? 'Salary change'
|
||||
: t === 'cost' ? 'Cost / expense'
|
||||
: t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Direction */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Direction</label>
|
||||
{/* frequency */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Frequency</label>
|
||||
<select
|
||||
value={impact.direction}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'direction', e.target.value)
|
||||
}
|
||||
value={imp.frequency}
|
||||
onChange={e => updateImpact(i, 'frequency', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
>
|
||||
<option value="add">Add (Income)</option>
|
||||
<option value="subtract">Subtract (Expense)</option>
|
||||
<option value="ONE_TIME">One‑time</option>
|
||||
<option value="MONTHLY">Monthly (recurring)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Amount</label>
|
||||
{/* direction */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Direction</label>
|
||||
<select
|
||||
value={imp.direction}
|
||||
onChange={e => updateImpact(i, 'direction', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
>
|
||||
<option value="add">Add (income)</option>
|
||||
<option value="subtract">Subtract (expense)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* amount */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Amount ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={impact.amount}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'amount', e.target.value)
|
||||
}
|
||||
value={imp.amount}
|
||||
onChange={e => updateImpact(i, 'amount', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start Month */}
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">Start Month</label>
|
||||
{/* dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">Start date</label>
|
||||
<input
|
||||
type="number"
|
||||
value={impact.start_month}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'start_month', e.target.value)
|
||||
}
|
||||
type="date"
|
||||
value={imp.start_date}
|
||||
onChange={e => updateImpact(i, 'start_date', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* End Month (for MONTHLY, can be null/blank if indefinite) */}
|
||||
{impact.impact_type === 'MONTHLY' && (
|
||||
<div className="mt-2">
|
||||
<label className="block font-semibold">End Month (optional)</label>
|
||||
{imp.frequency === 'MONTHLY' && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold">
|
||||
End date (optional)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={impact.end_month || ''}
|
||||
onChange={(e) =>
|
||||
handleImpactChange(i, 'end_month', e.target.value || null)
|
||||
}
|
||||
type="date"
|
||||
value={imp.end_date || ''}
|
||||
onChange={e => updateImpact(i, 'end_date', e.target.value)}
|
||||
className="border px-2 py-1 w-full"
|
||||
placeholder="Leave blank for indefinite"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button onClick={handleAddImpact} className="bg-gray-200 px-3 py-1 my-2">
|
||||
+ Add Impact
|
||||
<button
|
||||
onClick={addImpactRow}
|
||||
className="bg-gray-200 px-4 py-1 rounded mt-4"
|
||||
>
|
||||
+ Add impact
|
||||
</button>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex justify-end mt-4">
|
||||
<button className="mr-2" onClick={onClose}>
|
||||
{/* actions */}
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={() => onClose(false)} className="px-4 py-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="bg-blue-500 text-white px-4 py-2 rounded" onClick={handleSave}>
|
||||
Save Milestone
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 text-white px-5 py-2 rounded"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MilestoneAddModal;
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,61 +1,121 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
// src/components/Paywall.jsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
const Paywall = () => {
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
const { selectedCareer } = state || {};
|
||||
export default function Paywall() {
|
||||
const nav = useNavigate();
|
||||
const [sub, setSub] = useState(null); // null = loading
|
||||
const token = localStorage.getItem('token') || '';
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return navigate('/signin');
|
||||
/* ───────────────── fetch current subscription ─────────────── */
|
||||
useEffect(() => {
|
||||
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 {
|
||||
const res = await fetch('/api/activate-premium', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}` }
|
||||
/* ───────────────── helpers ────────────────────────────────── */
|
||||
const checkout = useCallback(async (tier, cycle) => {
|
||||
const base = window.location.origin; // https://dev1.aptivaai.com
|
||||
const res = await fetch('/api/premium/stripe/create-checkout-session', {
|
||||
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) {
|
||||
// 1) grab the fresh token / profile if the API returns it
|
||||
const { token: newToken, user } = await res.json().catch(() => ({}));
|
||||
if (newToken) localStorage.setItem('token', newToken);
|
||||
if (user) window.dispatchEvent(new Event('user-updated')); // or your context setter
|
||||
const openPortal = useCallback(async () => {
|
||||
const base = window.location.origin;
|
||||
const res = await fetch(`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
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
|
||||
navigate('/premium-onboarding', { replace: true, state: { selectedCareer } });
|
||||
} else {
|
||||
console.error('activate-premium failed:', await res.text());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error activating premium:', err);
|
||||
}
|
||||
};
|
||||
/* ───────────────── render ─────────────────────────────────── */
|
||||
if (!sub) return <p className="p-6 text-center text-sm">Loading …</p>;
|
||||
|
||||
if (sub.is_premium || sub.is_pro_premium) {
|
||||
const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium';
|
||||
|
||||
return (
|
||||
<div className="paywall">
|
||||
<h2>Unlock AptivaAI Premium</h2>
|
||||
<ul>
|
||||
<li>✅ Personalized Career Milestone Planning</li>
|
||||
<li>✅ Comprehensive Financial Projections</li>
|
||||
<li>✅ Resume & Interview Assistance</li>
|
||||
</ul>
|
||||
<div className="max-w-lg mx-auto p-6 text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold">Your plan: {plan}</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Manage payment method, invoices or cancel anytime.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleSubscribe}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Subscribe Now
|
||||
<Button onClick={openPortal} className="w-full">
|
||||
Manage subscription
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => navigate(-1)}>Cancel / Go Back</Button>
|
||||
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
|
||||
Back to app
|
||||
</Button>
|
||||
</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
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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
|
||||
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 ready = selectedCareer && currentlyWorking && collegeEnrollmentStatus;
|
||||
|
||||
|
||||
// 1) Grab the location state values, if any
|
||||
const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
|
||||
// We store local state for “are you working,” “selectedCareer,” etc.
|
||||
const location = useLocation();
|
||||
const {
|
||||
socCode,
|
||||
cipCodes,
|
||||
careerTitle, // <--- we passed this from handleSelectForEducation
|
||||
userZip,
|
||||
userState,
|
||||
} = location.state || {};
|
||||
const navCareerObj = location.state?.selectedCareer;
|
||||
const [careerObj, setCareerObj] = useState(() => {
|
||||
if (navCareerObj) return navCareerObj;
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('selectedCareer') || 'null');
|
||||
} catch { return null; }
|
||||
});
|
||||
const [currentlyWorking, setCurrentlyWorking] = useState('');
|
||||
const [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(() => {
|
||||
if (careerTitle) {
|
||||
setSelectedCareer(careerTitle);
|
||||
setData((prev) => ({
|
||||
if (!navCareerObj?.title) return;
|
||||
|
||||
setCareerObj(navCareerObj);
|
||||
localStorage.setItem('selectedCareer', JSON.stringify(navCareerObj));
|
||||
|
||||
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
career_name: careerTitle,
|
||||
soc_code: socCode || ''
|
||||
career_name : navCareerObj.title,
|
||||
soc_code : navCareerObj.soc_code || ''
|
||||
}));
|
||||
}
|
||||
}, [careerTitle, socCode, setData]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navCareerObj]); // ← run once per navigation change
|
||||
|
||||
|
||||
// Called whenever other <inputs> change
|
||||
const handleChange = (e) => {
|
||||
setData(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
};
|
||||
|
||||
// Called when user picks a career from CareerSearch and confirms it
|
||||
const handleCareerSelected = (careerObj) => {
|
||||
// e.g. { title, soc_code, cip_code, ... }
|
||||
setSelectedCareer(careerObj.title);
|
||||
/* ── 4. callbacks ─────────────────────────────────────────── */
|
||||
function handleCareerSelected(obj) {
|
||||
setCareerObj(obj);
|
||||
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 => ({
|
||||
...prev,
|
||||
career_name: careerObj.title,
|
||||
soc_code: careerObj.soc_code || '' // store SOC if needed
|
||||
career_name : selectedCareerTitle,
|
||||
college_enrollment_status : collegeStatus,
|
||||
currently_working : currentlyWorking,
|
||||
inCollege,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedCareer || !currentlyWorking || !collegeEnrollmentStatus) {
|
||||
alert('Please complete all required fields before continuing.');
|
||||
return;
|
||||
}
|
||||
const isInCollege =
|
||||
collegeEnrollmentStatus === 'currently_enrolled' ||
|
||||
collegeEnrollmentStatus === 'prospective_student';
|
||||
|
||||
// Merge local state into parent data
|
||||
setData(prevData => ({
|
||||
...prevData,
|
||||
career_name: selectedCareer,
|
||||
college_enrollment_status: collegeEnrollmentStatus,
|
||||
currently_working: currentlyWorking,
|
||||
inCollege: isInCollege,
|
||||
// fallback defaults, or use user-provided
|
||||
status: prevData.status || 'planned',
|
||||
start_date: prevData.start_date || new Date().toISOString().slice(0, 10).slice(0, 10),
|
||||
projected_end_date: prevData.projected_end_date || null
|
||||
}));
|
||||
|
||||
if (!showFinPrompt || financialReady) {
|
||||
|
||||
nextStep();
|
||||
/* — where do we go? — */
|
||||
if (skipFin && !inCollege) {
|
||||
/* user said “Skip” AND is not in college ⇒ jump to Review */
|
||||
finishNow(); // ← the helper we just injected via props
|
||||
} else {
|
||||
|
||||
nextStep(); // ordinary flow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const nextLabel = skipFin
|
||||
? inCollege ? 'College →' : 'Finish →'
|
||||
: inCollege ? 'College →' : 'Financial →';
|
||||
|
||||
|
||||
return (
|
||||
<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/> */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">
|
||||
What career are you planning to pursue? (Please select from drop-down suggestions after typing)<Req />
|
||||
Target Career <Req />
|
||||
</h3>
|
||||
<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>
|
||||
|
||||
<CareerSearch onCareerSelected={handleCareerSelected} required />
|
||||
</div>
|
||||
|
||||
{selectedCareer && (
|
||||
{selectedCareerTitle && (
|
||||
<p className="text-gray-700">
|
||||
Selected Career: <strong>{selectedCareer}</strong>
|
||||
Selected Career: <strong>{selectedCareerTitle}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -157,25 +158,14 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
/>
|
||||
</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">
|
||||
<label className="block font-medium">
|
||||
Are you currently enrolled in college or planning to enroll? <Req />
|
||||
</label>
|
||||
<select
|
||||
value={collegeEnrollmentStatus}
|
||||
value={collegeStatus}
|
||||
onChange={(e) => {
|
||||
setCollegeEnrollmentStatus(e.target.value);
|
||||
setCollegeStatus(e.target.value);
|
||||
setData(prev => ({ ...prev, college_enrollment_status: e.target.value }));
|
||||
const needsPrompt = ['currently_enrolled', 'prospective_student'].includes(e.target.value);
|
||||
setShowFinPrompt(needsPrompt);
|
||||
@ -189,7 +179,7 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
<option value="prospective_student">Planning to Enroll (Prospective)</option>
|
||||
</select>
|
||||
|
||||
{showFinPrompt && !financialReady && (
|
||||
{showFinPrompt && (
|
||||
<div className="mt-4 p-4 rounded border border-blue-300 bg-blue-50">
|
||||
<p className="text-sm mb-3">
|
||||
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 */
|
||||
setData(prev => ({ ...prev, skipFinancialStep: true }));
|
||||
|
||||
setFinancialReady(false);
|
||||
setShowFinPrompt(false); // hide the prompt, stay on page
|
||||
}}
|
||||
>
|
||||
@ -245,11 +234,11 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
onClick={handleSubmit}
|
||||
disabled={!ready}
|
||||
className={`py-2 px-4 rounded font-semibold
|
||||
${selectedCareer && currentlyWorking && collegeEnrollmentStatus
|
||||
${ready
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'}`}
|
||||
>
|
||||
Financial →
|
||||
{nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Modal from '../../components/ui/modal.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 }) {
|
||||
// CIP / iPEDS local states
|
||||
@ -11,10 +14,42 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
|
||||
const [schoolValid, setSchoolValid] = 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
|
||||
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) => (
|
||||
<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"
|
||||
@ -27,7 +62,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
// Destructure parent data
|
||||
const {
|
||||
college_enrollment_status = '',
|
||||
selected_school = '',
|
||||
selected_school = selectedSchool,
|
||||
selected_program = '',
|
||||
program_type = '',
|
||||
academic_calendar = 'semester',
|
||||
@ -53,9 +88,27 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
const [manualTuition, setManualTuition] = useState('');
|
||||
const [autoTuition, setAutoTuition] = useState(0);
|
||||
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
|
||||
@ -144,9 +197,24 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
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
|
||||
const handleSchoolChange = (e) => {
|
||||
const value = e.target.value;
|
||||
const handleSchoolChange = (eOrVal) => {
|
||||
const value =
|
||||
typeof eOrVal === 'string' ? eOrVal : eOrVal?.target?.value || '';
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
selected_school: value,
|
||||
@ -312,14 +380,49 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
const remain = Math.max(0, required - completed);
|
||||
const yrs = remain / perYear;
|
||||
|
||||
setAutoProgramLength(yrs.toFixed(2));
|
||||
setAutoProgramLength(parseFloat(yrs.toFixed(2)));
|
||||
}, [
|
||||
program_type,
|
||||
hours_completed,
|
||||
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
|
||||
const handleSubmit = () => {
|
||||
const chosenTuition = manualTuition.trim() === ''
|
||||
@ -331,6 +434,8 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
interest_rate,
|
||||
loan_term,
|
||||
tuition: chosenTuition,
|
||||
program_length: chosenProgramLength
|
||||
}));
|
||||
@ -342,11 +447,25 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
const displayedTuition = (manualTuition.trim() === '' ? autoTuition : manualTuition);
|
||||
const displayedProgramLength = (manualProgramLength.trim() === '' ? autoProgramLength : manualProgramLength);
|
||||
|
||||
const ready =
|
||||
(college_enrollment_status === 'currently_enrolled' ||
|
||||
college_enrollment_status === 'prospective_student')
|
||||
? schoolValid && programValid && program_type
|
||||
: true;
|
||||
function pickStartDate() {
|
||||
if (college_enrollment_status === 'prospective_student') {
|
||||
return enrollmentDate; // may still be ''
|
||||
}
|
||||
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 (
|
||||
<div className="max-w-md mx-auto p-6 space-y-4">
|
||||
@ -558,7 +677,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="number"
|
||||
name="existing_college_debt"
|
||||
@ -601,16 +720,53 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block font-medium">Expected Graduation {infoIcon("If you don't know the exact date, that's fine - just enter the targeted month")}</label>
|
||||
{['currently_enrolled','prospective_student'].includes(college_enrollment_status) && (
|
||||
<>
|
||||
{/* 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
|
||||
type="date"
|
||||
name="expected_graduation"
|
||||
value={expected_graduation}
|
||||
onChange={handleParentFieldChange}
|
||||
value={enrollmentDate}
|
||||
onChange={e => {
|
||||
setEnrollmentDate(e.target.value);
|
||||
setData(p => ({ ...p, enrollment_date: e.target.value }));
|
||||
}}
|
||||
className="w-full border rounded p-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* B) Expected graduation – always editable */}
|
||||
<div className="space-y-2">
|
||||
<label className="block font-medium">
|
||||
Expected Graduation Date <Req />
|
||||
{college_enrollment_status === 'prospective_student' &&
|
||||
enrollmentDate && data.program_length && (
|
||||
<span
|
||||
className="ml-1 cursor-help text-blue-600"
|
||||
title="Automatically estimated from your enrollment date and program length. Adjust if needed—actual calendars vary by institution."
|
||||
>ⓘ</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">
|
||||
<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 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
|
||||
const handleFinalSubmit = async () => {
|
||||
try {
|
||||
@ -209,6 +215,7 @@ navigate(`/career-roadmap/${finalCareerProfileId}`, {
|
||||
|
||||
<CareerOnboarding
|
||||
nextStep={nextStep}
|
||||
finishNow={finishImmediately}
|
||||
data={careerData}
|
||||
setData={setCareerData}
|
||||
/>,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
// Hypothetical Button component from your UI library
|
||||
import { Button } from '../ui/button.js'; // Adjust path if needed
|
||||
import { data } from 'autoprefixer';
|
||||
|
||||
/**
|
||||
* 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>Status:</strong> {careerData.status || '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>
|
||||
|
||||
@ -126,6 +126,8 @@ function ReviewPage({
|
||||
<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>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>Expected Graduation:</strong> {collegeData.expected_graduation || 'N/A'}</div>
|
||||
<div><strong>Expected Salary:</strong> {formatNum(collegeData.expected_salary)}</div>
|
||||
|
@ -1,21 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
function PremiumRoute({ user, children }) {
|
||||
if (!user) {
|
||||
// Not even logged in; go to sign in
|
||||
return <Navigate to="/signin" replace />;
|
||||
}
|
||||
export default function PremiumRoute({ user, children }) {
|
||||
const loc = useLocation();
|
||||
|
||||
// Check if user has *either* premium or pro
|
||||
const hasPremiumOrPro = 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
|
||||
/* Already premium → proceed */
|
||||
if (user?.is_premium || user?.is_pro_premium) {
|
||||
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.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Button onClick={() => navigate('/financial-profile')}>Update Financial Profile</Button>
|
||||
<Button onClick={() => navigate('/retirement-planner')}>Set Retirement Milestones and get AI help with planning</Button>
|
||||
<Button onClick={() => navigate('/retirement-planner')}>Compare different retirement scenarios and get AI help with planning</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -159,7 +159,6 @@ export default function ScenarioEditModal({
|
||||
career_name : safe(s.career_name),
|
||||
status : safe(s.status || 'planned'),
|
||||
start_date : safe(s.start_date),
|
||||
projected_end_date : safe(s.projected_end_date),
|
||||
retirement_start_date: safe(s.retirement_start_date),
|
||||
desired_retirement_income_monthly : safe(
|
||||
s.desired_retirement_income_monthly
|
||||
@ -498,7 +497,6 @@ async function handleSave() {
|
||||
currently_working : formData.currently_working || "no",
|
||||
status : s(formData.status),
|
||||
start_date : s(formData.start_date),
|
||||
projected_end_date : s(formData.projected_end_date),
|
||||
retirement_start_date : s(formData.retirement_start_date),
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Retirement date */}
|
||||
|
@ -51,7 +51,6 @@ export default function ScenarioEditWizard({
|
||||
currently_working: scenData.currently_working,
|
||||
status: scenData.status,
|
||||
start_date: scenData.start_date,
|
||||
projected_end_date: scenData.projected_end_date,
|
||||
planned_monthly_expenses: scenData.planned_monthly_expenses,
|
||||
planned_monthly_debt_payments: scenData.planned_monthly_debt_payments,
|
||||
planned_monthly_retirement_contribution: scenData.planned_monthly_retirement_contribution,
|
||||
|
@ -120,6 +120,7 @@ const {
|
||||
|
||||
// Student-loan config ----------------------------------------
|
||||
studentLoanAmount: _studentLoanAmount = 0,
|
||||
existing_college_debt: _existingCollegeDebt = 0,
|
||||
interestRate: _interestRate = 5,
|
||||
loanTerm: _loanTerm = 10,
|
||||
loanDeferralUntilGraduation = false,
|
||||
@ -172,6 +173,7 @@ const additionalIncome = num(_additionalIncome);
|
||||
const extraPayment = num(_extraPayment);
|
||||
|
||||
const studentLoanAmount = num(_studentLoanAmount);
|
||||
const existingCollegeDebt = num(_existingCollegeDebt);
|
||||
const interestRate = num(_interestRate);
|
||||
const loanTerm = num(_loanTerm);
|
||||
const isProgrammeActive =
|
||||
@ -316,18 +318,11 @@ function simulateDrawdown(opts){
|
||||
/***************************************************
|
||||
* 5) LOAN PAYMENT (if not deferring)
|
||||
***************************************************/
|
||||
const initialLoanPrincipal = studentLoanAmount + existingCollegeDebt;
|
||||
|
||||
let monthlyLoanPayment = loanDeferralUntilGraduation
|
||||
? 0
|
||||
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
|
||||
|
||||
// Log the initial loan info:
|
||||
console.log("Initial loan payment setup:", {
|
||||
studentLoanAmount,
|
||||
interestRate,
|
||||
loanTerm,
|
||||
loanDeferralUntilGraduation,
|
||||
monthlyLoanPayment
|
||||
});
|
||||
: calculateLoanPayment(initialLoanPrincipal, interestRate, loanTerm);
|
||||
|
||||
/***************************************************
|
||||
* 6) SETUP FOR THE SIMULATION LOOP
|
||||
@ -461,15 +456,17 @@ milestoneImpacts.forEach((rawImpact) => {
|
||||
if (!isActiveThisMonth) return; // skip to next impact
|
||||
|
||||
/* ---------- 3. Apply the impact ---------- */
|
||||
const sign = direction === 'add' ? 1 : -1;
|
||||
|
||||
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;
|
||||
salaryAdjustThisMonth += sign * monthlyDelta;
|
||||
const salarySign = direction === 'add' ? 1 : -1; // unchanged
|
||||
salaryAdjustThisMonth += salarySign * monthlyDelta;
|
||||
} else {
|
||||
// MONTHLY or ONE_TIME expenses / windfalls
|
||||
extraImpactsThisMonth += sign * amount;
|
||||
// ─── everything else is an expense or windfall ───
|
||||
// “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