Pulled in selected values for premium onboarding, added Expected Graduation Date calculation

This commit is contained in:
Josh 2025-07-21 17:04:11 +00:00
parent f00bc75bb9
commit b96da9f7dd
13 changed files with 339 additions and 137 deletions

View File

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

16
Dockerfile.server1 Normal file
View File

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

View File

@ -580,7 +580,9 @@ 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';
process.env.SALARY_DB_PATH // ← preferred
|| process.env.SALARY_DB // ← legacy
|| '/app/salary_info.db'; // final fallback
const salaryDb = new sqlite3.Database(
salaryDbPath,

View File

@ -2452,18 +2452,30 @@ app.delete('/api/premium/milestones/:milestoneId', authenticatePremiumUser, asyn
FINANCIAL PROFILES
------------------------------------------------------------------ */
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' });
// GET /api/premium/financial-profile
app.get('/api/premium/financial-profile', auth, (req, res) => {
const uid = req.userId;
db.query('SELECT * FROM financial_profile WHERE user_id=?', [uid],
(err, rows) => {
if (err) return res.status(500).json({ error:'DB error' });
if (!rows.length) {
// ←———— send a benign default instead of 404
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]);
});
});
app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => {

View File

@ -8,6 +8,11 @@ services:
<<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG}
expose: ["${SERVER1_PORT}"]
environment:
SALARY_DB_PATH: /app/salary_info.db
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

View File

@ -171,8 +171,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');

View File

@ -511,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());
}
})();
}, []);

View File

@ -5,7 +5,12 @@ import { Button } from './ui/button.js';
const Paywall = () => {
const navigate = useNavigate();
const { state } = useLocation();
const { selectedCareer } = state || {};
const {
redirectTo = '/premium-onboarding', // wizard by default
prevState = {}, // any custom state we passed
selectedCareer
} = state || {};
const handleSubscribe = async () => {
const token = localStorage.getItem('token');
@ -27,7 +32,7 @@ const Paywall = () => {
if (user) window.dispatchEvent(new Event('user-updated')); // or your context setter
// 2) give the auth context time to update, then push
navigate('/premium-onboarding', { replace: true, state: { selectedCareer } });
navigate(redirectTo, { replace: true, state: prevState });
} else {
console.error('activate-premium failed:', await res.text());
}

View File

@ -1,26 +1,38 @@
// 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 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. 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
const {
socCode,
cipCodes,
@ -29,63 +41,59 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
userState,
} = location.state || {};
// 2) On mount, see if location.state has a careerTitle and update local states if needed
/* ── 3. sideeffects 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
}));
};
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,
career_name : selectedCareerTitle,
college_enrollment_status : collegeStatus,
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
inCollege,
}));
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 +124,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 its a new goal or the one you're already in.
This should be the career you are <strong>striving for</strong> whether its a new goal or the one you're already in.
</p>
<CareerSearch onCareerSelected={handleCareerSelected} required />
</div>
{selectedCareer && (
{selectedCareerTitle && (
<p className="text-gray-700">
Selected Career: <strong>{selectedCareer}</strong>
Selected Career: <strong>{selectedCareerTitle}</strong>
</p>
)}
@ -173,9 +181,9 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
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 +197,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 +222,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 +252,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>

View File

@ -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 yyyymmdd
if (!startISO || !len) return; // nothing to do yet
const start = new Date(startISO);
/* naïve add assuming program_length is years; *
* adjust if you store months instead */
/* 1year = 12months preserve fractions (e.g. 1.75y = 21m) */
const monthsToAdd = Math.round(len * 12);
const estGrad = new Date(start); // clone
estGrad.setMonth(estGrad.getMonth() + monthsToAdd);
const gradISO = firstOfNextMonth(estGrad);
setExpectedGraduation(gradISO);
setData(prev => ({ ...prev, expected_graduation: gradISO }));
}, [college_enrollment_status,
enrollmentDate,
manualProgramLength,
autoProgramLength,
setData]);
// final handleSubmit => we store chosen tuition + program_length, then move on
const handleSubmit = () => {
const chosenTuition = manualTuition.trim() === ''
@ -342,11 +445,25 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
const displayedTuition = (manualTuition.trim() === '' ? autoTuition : manualTuition);
const displayedProgramLength = (manualProgramLength.trim() === '' ? autoProgramLength : manualProgramLength);
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); // yyyymmdd
}
const ready =
(college_enrollment_status === 'currently_enrolled' ||
college_enrollment_status === 'prospective_student')
? schoolValid && programValid && program_type
: true;
(!inSchool || expectedGraduation) && // grad date iff in school
selected_school && program_type;
return (
<div className="max-w-md mx-auto p-6 space-y-4">
@ -601,16 +718,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."
>&#9432;</span>
)}
</label>
<input
type="date"
value={expectedGraduation}
onChange={e => {
setExpectedGraduation(e.target.value);
setData(p => ({ ...p, expected_graduation: e.target.value }));
}}
className="w-full border rounded p-2"
required
/>
</div>
</>
)}
<div className="space-y-1">
<label className="block font-medium">Loan Interest Rate (%) {infoIcon("These can vary, enter the best blended rate you can approximate if you have multiple.")}</label>

View File

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

View File

@ -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;
}
export default PremiumRoute;
/* NEW: send to paywall and remember where they wanted to go */
return (
<Navigate
to="/paywall"
replace
state={{ redirectTo: loc.pathname, prevState: loc.state }}
/>
);
}

Binary file not shown.