LoanRepayment reimplmementation in PreparingLanding
This commit is contained in:
parent
a8247d63b2
commit
7b810ff2de
24
src/App.js
24
src/App.js
@ -30,6 +30,9 @@ import Paywall from './components/Paywall.js';
|
||||
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
|
||||
import RetirementPlanner from './components/RetirementPlanner.js';
|
||||
import ResumeRewrite from './components/ResumeRewrite.js';
|
||||
import LoanRepaymentPage from './components/LoanRepaymentPage.js';
|
||||
|
||||
|
||||
|
||||
export const ProfileCtx = React.createContext();
|
||||
|
||||
@ -177,7 +180,8 @@ function App() {
|
||||
return (
|
||||
<ProfileCtx.Provider
|
||||
value={{ financialProfile, setFinancialProfile,
|
||||
scenario, setScenario }}
|
||||
scenario, setScenario,
|
||||
user, }}
|
||||
>
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
||||
{/* Header */}
|
||||
@ -348,6 +352,23 @@ function App() {
|
||||
>
|
||||
Financial Profile
|
||||
</Link>
|
||||
{canAccessPremium ? (
|
||||
/* Premium users go straight to the wizard */
|
||||
<Link
|
||||
to="/premium-onboarding"
|
||||
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
|
||||
>
|
||||
Premium Onboarding
|
||||
</Link>
|
||||
) : (
|
||||
/* Free users are nudged to upgrade */
|
||||
<Link
|
||||
to="/paywall"
|
||||
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
|
||||
>
|
||||
College Planning <span className="text-xs">(Premium)</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@ -431,6 +452,7 @@ function App() {
|
||||
<Route path="/profile" element={<UserProfile />} />
|
||||
<Route path="/planning" element={<PlanningLanding />} />
|
||||
<Route path="/career-explorer" element={<CareerExplorer />} />
|
||||
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
|
||||
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
||||
<Route path="/preparing" element={<PreparingLanding />} />
|
||||
|
||||
|
@ -1,223 +1,251 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
/**
|
||||
* LoanRepayment
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* If `schools.length === 0` we first show a quick-fill estimator
|
||||
* that lets a free-tier user type a school name, choose “degree type”,
|
||||
* and enter an annual-tuition guess. We then create a minimal
|
||||
* school object and push it into `schools`, after which the full
|
||||
* repayment form (your original code) appears.
|
||||
*/
|
||||
function LoanRepayment({
|
||||
schools,
|
||||
/* ORIGINAL PROPS */
|
||||
schools = [],
|
||||
salaryData,
|
||||
setResults,
|
||||
setLoading,
|
||||
setPersistedROI,
|
||||
programLength,
|
||||
|
||||
/* NEW: parent must hand us a setter so we can inject the stub */
|
||||
setSchools,
|
||||
}) {
|
||||
/* ------------------------- quick-fill state ------------------------- */
|
||||
const [scratch, setScratch] = useState({
|
||||
school: '',
|
||||
programType: '',
|
||||
tuition: '',
|
||||
});
|
||||
|
||||
/* ------------------------- calculator state ------------------------ */
|
||||
const [expectedSalary, setExpectedSalary] = useState(0);
|
||||
const [tuitionType, setTuitionType] = useState('inState'); // Tuition type: inState or outOfState
|
||||
const [interestRate, setInterestRate] = useState(5.5); // Interest rate
|
||||
const [loanTerm, setLoanTerm] = useState(10); // Loan term in years
|
||||
const [extraPayment, setExtraPayment] = useState(0); // Extra monthly payment
|
||||
const [currentSalary, setCurrentSalary] = useState(0); // Current salary input
|
||||
const [error, setError] = useState(null);
|
||||
const [tuitionType, setTuitionType] = useState('inState');
|
||||
const [interestRate, setInterestRate] = useState(5.5);
|
||||
const [loanTerm, setLoanTerm] = useState(10);
|
||||
const [extraPayment, setExtraPayment] = useState(0);
|
||||
const [currentSalary, setCurrentSalary] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
/* ------------------------- validation ------------------------------ */
|
||||
const validateInputs = () => {
|
||||
if (!schools || schools.length === 0) {
|
||||
setError('School data is missing. Loan calculations cannot proceed.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNaN(interestRate) || interestRate <= 0) {
|
||||
setError('Interest rate must be a valid number greater than 0.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNaN(loanTerm) || loanTerm <= 0) {
|
||||
setError('Loan term must be a valid number greater than 0.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNaN(extraPayment) || extraPayment < 0) {
|
||||
setError('Extra monthly payment cannot be negative.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNaN(currentSalary) || currentSalary < 0) {
|
||||
setError('Current salary must be a valid number and cannot be negative.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNaN(expectedSalary) || expectedSalary < 0) {
|
||||
setError('Expected salary must be a valid number and cannot be negative.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!schools?.length) { setError('Missing school data.'); return false; }
|
||||
if (isNaN(interestRate)||interestRate<=0) { setError('Interest rate > 0'); return false; }
|
||||
if (isNaN(loanTerm)||loanTerm<=0) { setError('Loan term > 0'); return false; }
|
||||
if (isNaN(extraPayment)||extraPayment<0) { setError('Extra pmt ≥ 0'); return false; }
|
||||
if (isNaN(currentSalary)||currentSalary<0){ setError('Current salary ≥0');return false; }
|
||||
if (isNaN(expectedSalary)||expectedSalary<0){setError('Expected salary ≥0');return false;}
|
||||
setError(null);
|
||||
return true;
|
||||
};
|
||||
|
||||
/* ------------------------- main calculation ----------------------- */
|
||||
const calculateLoanDetails = () => {
|
||||
if (!validateInputs()) return;
|
||||
setLoading?.(true);
|
||||
|
||||
setLoading(true);
|
||||
const results = schools.map((school) => {
|
||||
const programLength = Number(school.programLength);
|
||||
const tuition = tuitionType === 'inState' ? school.inState : school.outOfState;
|
||||
/* your existing repayment logic — unchanged */
|
||||
const programLen = Number(school.programLength);
|
||||
const tuition = tuitionType === 'inState' ? school.inState : school.outOfState;
|
||||
|
||||
let totalTuition = 0;
|
||||
let undergraduateYears = 0;
|
||||
let graduateYears = 0;
|
||||
let ugYears=0, gradYears=0;
|
||||
if (school.degreeType.includes('Associate')) ugYears=2;
|
||||
else if (school.degreeType.includes('Bachelor')) ugYears=4;
|
||||
else if (school.degreeType.includes('Master')) { ugYears=4; gradYears=2; }
|
||||
else if (school.degreeType.includes('First Professional')||school.degreeType.includes('Doctoral'))
|
||||
{ ugYears=4; gradYears=4; }
|
||||
else if (school.degreeType.includes('Certificate')) ugYears=1;
|
||||
else { ugYears=Math.min(programLen,4); gradYears=Math.max(programLen-4,0); }
|
||||
|
||||
// ✅ Handle Associates (2 years total, all undergrad)
|
||||
if (school.degreeType.includes("Associate")) {
|
||||
undergraduateYears = 2;
|
||||
graduateYears = 0;
|
||||
}
|
||||
// ✅ Handle Bachelor's (4 years total, all undergrad)
|
||||
else if (school.degreeType.includes("Bachelor")) {
|
||||
undergraduateYears = 4;
|
||||
graduateYears = 0;
|
||||
}
|
||||
// ✅ Handle Master's (4 undergrad + 2 graduate)
|
||||
else if (school.degreeType.includes("Master")) {
|
||||
undergraduateYears = 4;
|
||||
graduateYears = 2;
|
||||
}
|
||||
// ✅ Handle First Professional & Doctoral (4 undergrad + 4 grad)
|
||||
else if (school.degreeType.includes("First Professional") || school.degreeType.includes("Doctoral")) {
|
||||
undergraduateYears = 4;
|
||||
graduateYears = 4;
|
||||
}
|
||||
// ✅ Handle Certificate (default 1 year undergraduate)
|
||||
else if (school.degreeType.includes("Certificate")) {
|
||||
undergraduateYears = 1;
|
||||
graduateYears = 0;
|
||||
}
|
||||
// ✅ Default fallback
|
||||
else {
|
||||
undergraduateYears = Math.min(programLength, 4);
|
||||
graduateYears = Math.max(programLength - 4, 0);
|
||||
let totalTuition = ugYears*tuition;
|
||||
if (gradYears>0) {
|
||||
const gradTuit = tuitionType==='inState'?school.inStateGraduate:school.outStateGraduate;
|
||||
totalTuition += gradYears*gradTuit;
|
||||
}
|
||||
|
||||
totalTuition += undergraduateYears * tuition;
|
||||
const r = Number(interestRate)/12/100;
|
||||
const n = Number(loanTerm)*12;
|
||||
const pmtMin = totalTuition * (r*Math.pow(1+r,n))/(Math.pow(1+r,n)-1);
|
||||
const pmt = Number(pmtMin)+Number(extraPayment);
|
||||
|
||||
if (graduateYears > 0) {
|
||||
const gradTuition = tuitionType === 'inState'
|
||||
? school.inStateGraduate
|
||||
: school.outStateGraduate;
|
||||
totalTuition += graduateYears * gradTuition;
|
||||
let bal=totalTuition, months=0;
|
||||
while (bal>0 && months<n*2){
|
||||
months++;
|
||||
const interest = bal*r;
|
||||
bal -= Math.max(pmt - interest,0);
|
||||
}
|
||||
const totalCost = pmt*months;
|
||||
|
||||
// Loan calculations
|
||||
const monthlyRate = Number(interestRate) / 12 / 100;
|
||||
const loanTermMonths = Number(loanTerm) * 12;
|
||||
|
||||
const minimumMonthlyPayment = totalTuition * (monthlyRate * Math.pow(1 + monthlyRate, loanTermMonths)) /
|
||||
(Math.pow(1 + monthlyRate, loanTermMonths) - 1);
|
||||
|
||||
const extraMonthlyPayment = Number(minimumMonthlyPayment) + Number(extraPayment);
|
||||
let remainingBalance = totalTuition;
|
||||
let monthsWithExtra = 0;
|
||||
|
||||
while (remainingBalance > 0) {
|
||||
monthsWithExtra++;
|
||||
const interest = remainingBalance * monthlyRate;
|
||||
const principal = Math.max(extraMonthlyPayment - interest, 0);
|
||||
remainingBalance -= principal;
|
||||
if (monthsWithExtra > loanTermMonths * 2) break;
|
||||
}
|
||||
|
||||
const totalLoanCost = extraMonthlyPayment * monthsWithExtra;
|
||||
|
||||
// Safe Net Gain Calculation
|
||||
let salary = Number(expectedSalary) || 0;
|
||||
let netGain = (-totalLoanCost).toFixed(2);
|
||||
let monthlySalary = (0).toFixed(2);
|
||||
|
||||
if (salary > 0) {
|
||||
const currentSalaryNum = Number(currentSalary) || 0;
|
||||
const totalSalary = salary * loanTerm;
|
||||
const currentSalaryEarnings = currentSalaryNum * loanTerm * Math.pow(1.03, loanTerm);
|
||||
|
||||
if (!isNaN(totalSalary) && !isNaN(currentSalaryEarnings)) {
|
||||
netGain = (totalSalary - totalLoanCost - currentSalaryEarnings).toFixed(2);
|
||||
monthlySalary = (salary / 12).toFixed(2);
|
||||
} else {
|
||||
netGain = (-totalLoanCost).toFixed(2);
|
||||
monthlySalary = (0).toFixed(2);
|
||||
}
|
||||
} else {
|
||||
netGain = (-totalLoanCost).toFixed(2);
|
||||
monthlySalary = (0).toFixed(2);
|
||||
}
|
||||
/* safe net-gain estimate */
|
||||
const salary = Number(expectedSalary)||0;
|
||||
const cur = Number(currentSalary)||0;
|
||||
const netGain = salary
|
||||
? (salary*loanTerm - totalCost - cur*loanTerm*Math.pow(1.03,loanTerm)).toFixed(2)
|
||||
: (-totalCost).toFixed(2);
|
||||
|
||||
return {
|
||||
...school,
|
||||
totalTuition: totalTuition.toFixed(2),
|
||||
monthlyPayment: minimumMonthlyPayment.toFixed(2),
|
||||
totalMonthlyPayment: extraMonthlyPayment.toFixed(2),
|
||||
totalLoanCost: totalLoanCost.toFixed(2),
|
||||
totalTuition : totalTuition.toFixed(2),
|
||||
monthlyPayment: pmtMin.toFixed(2),
|
||||
totalMonthlyPayment: pmt.toFixed(2),
|
||||
totalLoanCost : totalCost.toFixed(2),
|
||||
netGain,
|
||||
monthlySalary,
|
||||
monthlySalary : (salary/12).toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
setResults(results);
|
||||
setLoading(false);
|
||||
setResults?.(results);
|
||||
setLoading?.(false);
|
||||
};
|
||||
|
||||
/* ================================================================= */
|
||||
/* QUICK-FILL PANEL (only when schools.length === 0) */
|
||||
/* ================================================================= */
|
||||
if (!schools || schools.length === 0) {
|
||||
const ready = scratch.tuition;
|
||||
return (
|
||||
<div className="border rounded p-6 space-y-4 max-w-md mx-auto">
|
||||
<h2 className="text-lg font-semibold text-center">
|
||||
Estimate student-loan payments
|
||||
</h2>
|
||||
|
||||
<input
|
||||
className="border rounded p-2 w-full"
|
||||
placeholder="School name *"
|
||||
value={scratch.school}
|
||||
onChange={(e)=>setScratch({...scratch,school:e.target.value})}
|
||||
/>
|
||||
|
||||
<select
|
||||
className="border rounded p-2 w-full"
|
||||
value={scratch.programType}
|
||||
onChange={(e)=>setScratch({...scratch,programType:e.target.value})}
|
||||
>
|
||||
<option value="">Degree / program type *</option>
|
||||
<option>Associate's Degree</option>
|
||||
<option>Bachelor's Degree</option>
|
||||
<option>Master's Degree</option>
|
||||
<option>Doctoral Degree</option>
|
||||
<option>Graduate/Professional Certificate</option>
|
||||
<option>First Professional Degree</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
className="border rounded p-2 w-full"
|
||||
placeholder="Estimated annual tuition *"
|
||||
value={scratch.tuition}
|
||||
onChange={(e)=>setScratch({...scratch,tuition:e.target.value})}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!ready}
|
||||
onClick={()=>{
|
||||
const t = Number(scratch.tuition);
|
||||
const stub = {
|
||||
INSTNM : scratch.school || '(self-entered)', // ← if blank
|
||||
degreeType: scratch.programType || 'Unknown', // ← if blank
|
||||
programLength: 4,
|
||||
inState: t,
|
||||
outOfState: t,
|
||||
inStateGraduate: t,
|
||||
outStateGraduate: t,
|
||||
};
|
||||
setSchools([stub]); // now main form will appear
|
||||
}}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================= */
|
||||
/* ORIGINAL DETAILED REPAYMENT FORM */
|
||||
/* ================================================================= */
|
||||
return (
|
||||
<div className="loan-repayment-container">
|
||||
<form onSubmit={(e) => { e.preventDefault(); calculateLoanDetails(); }}>
|
||||
<div className="input-group">
|
||||
<label>Tuition Type:</label>
|
||||
<select value={tuitionType} onChange={(e) => setTuitionType(e.target.value)}>
|
||||
<option value="inState">In-State</option>
|
||||
<option value="outOfState">Out-of-State</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Interest Rate:</label>
|
||||
<input type="number"
|
||||
value={interestRate}
|
||||
onChange={(e) => setInterestRate(e.target.value)}
|
||||
placeholder="Enter the interest rate"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Loan Term (years):</label>
|
||||
<input type="number"
|
||||
value={loanTerm}
|
||||
onChange={(e) => setLoanTerm(e.target.value)}
|
||||
placeholder="Enter the length of the loan repayment period"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Extra Monthly Payment:</label>
|
||||
<input type="number"
|
||||
value={extraPayment}
|
||||
onChange={(e) => setExtraPayment(e.target.value)}
|
||||
placeholder="Enter any additional monthly payment"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Current Salary:</label>
|
||||
<input type="number"
|
||||
value={currentSalary}
|
||||
onChange={(e) => setCurrentSalary(e.target.value)}
|
||||
placeholder="Enter your current salary"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>Expected Salary:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={expectedSalary}
|
||||
onChange={(e) => setExpectedSalary(e.target.value)}
|
||||
placeholder="Enter expected salary"
|
||||
/>
|
||||
<div className="loan-repayment-container max-w-xl mx-auto">
|
||||
<form onSubmit={(e)=>{e.preventDefault();calculateLoanDetails();}}
|
||||
className="space-y-4 border rounded p-6">
|
||||
{/* Tuition type */}
|
||||
<div className="input-group">
|
||||
<label className="block font-medium">Tuition Type</label>
|
||||
<select
|
||||
value={tuitionType}
|
||||
onChange={(e)=>setTuitionType(e.target.value)}
|
||||
className="border rounded p-2 w-full"
|
||||
>
|
||||
<option value="inState">In-State</option>
|
||||
<option value="outOfState">Out-of-State</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="calculate-button-container">
|
||||
<button type="submit">Calculate</button>
|
||||
|
||||
{/* Interest rate */}
|
||||
<div className="input-group">
|
||||
<label className="block font-medium">Interest Rate (%)</label>
|
||||
<input type="number"
|
||||
value={interestRate}
|
||||
onChange={(e)=>setInterestRate(e.target.value)}
|
||||
className="border rounded p-2 w-full"/>
|
||||
</div>
|
||||
|
||||
{/* Loan term */}
|
||||
<div className="input-group">
|
||||
<label className="block font-medium">Loan Term (years)</label>
|
||||
<input type="number"
|
||||
value={loanTerm}
|
||||
onChange={(e)=>setLoanTerm(e.target.value)}
|
||||
className="border rounded p-2 w-full"/>
|
||||
</div>
|
||||
|
||||
{/* Extra payment */}
|
||||
<div className="input-group">
|
||||
<label className="block font-medium">Extra Monthly Payment</label>
|
||||
<input type="number"
|
||||
value={extraPayment}
|
||||
onChange={(e)=>setExtraPayment(e.target.value)}
|
||||
className="border rounded p-2 w-full"/>
|
||||
</div>
|
||||
|
||||
{/* Current salary */}
|
||||
<div className="input-group">
|
||||
<label className="block font-medium">Current Salary</label>
|
||||
<input type="number"
|
||||
value={currentSalary}
|
||||
onChange={(e)=>setCurrentSalary(e.target.value)}
|
||||
className="border rounded p-2 w-full"/>
|
||||
</div>
|
||||
|
||||
{/* Expected salary */}
|
||||
<div className="input-group">
|
||||
<label className="block font-medium">Expected Salary</label>
|
||||
<input type="number"
|
||||
value={expectedSalary}
|
||||
onChange={(e)=>setExpectedSalary(e.target.value)}
|
||||
className="border rounded p-2 w-full"/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="text-right">
|
||||
<Button type="submit">Calculate</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
306
src/components/LoanRepaymentDrawer.js
Normal file
306
src/components/LoanRepaymentDrawer.js
Normal file
@ -0,0 +1,306 @@
|
||||
// src/components/LoanRepaymentDrawer.js
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import LoanRepayment from './LoanRepayment.js';
|
||||
import { Button } from './ui/button.js';
|
||||
import { getNearbySchools } from '../utils/getNearbySchools.js';
|
||||
import UpsellSummary from './UpsellSummary.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
/* ───────────────────────── CONSTANTS ───────────────────────── */
|
||||
const DEGREE_OPTS = [
|
||||
"Associate's Degree",
|
||||
"Bachelor's Degree",
|
||||
"Master's Degree",
|
||||
'Graduate / Professional Certificate',
|
||||
'First Professional Degree',
|
||||
'Doctoral Degree',
|
||||
];
|
||||
|
||||
/* ──────────────────────── COMPONENT ────────────────────────── */
|
||||
export default function LoanRepaymentDrawer({
|
||||
open,
|
||||
onClose,
|
||||
schools, // comes from PreparingLanding
|
||||
setSchools,
|
||||
results,
|
||||
setResults,
|
||||
user,
|
||||
cipCodes = [],
|
||||
userZip = '',
|
||||
userState='',
|
||||
}) {
|
||||
/* Hooks must always run – return null later if !open */
|
||||
/* ── Remote data for auto-suggest ─ */
|
||||
const [cipData, setCipData] = useState([]);
|
||||
const [schoolSearch, setSchoolSearch] = useState('');
|
||||
|
||||
/* ── Simple form fields ─ */
|
||||
const [degree, setDegree] = useState('');
|
||||
const [tuition, setTuition] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
/* ── When “Continue” is pressed show true calculator ─ */
|
||||
const [showCalc, setShowCalc] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
/* ▒▒▒ NEW auto-seed effect ▒▒▒ */
|
||||
useEffect(() => {
|
||||
// run once every time the drawer opens
|
||||
if (!open) return; // drawer closed
|
||||
if (schools.length) return; // already have data
|
||||
if (!cipCodes.length) return; // no career → nothing to seed
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const seed = await getNearbySchools(cipCodes, userZip, userState);
|
||||
if (seed.length) setSchools(seed);
|
||||
} catch (e) {
|
||||
console.warn('auto-seed schools failed:', e);
|
||||
}
|
||||
})();
|
||||
}, [open, schools.length, cipCodes.join('-'), userZip, userState, setSchools]);
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
FETCH CIP DATA (only once the drawer is ever opened)
|
||||
════════════════════════════════════════════════════ */
|
||||
useEffect(() => {
|
||||
if (!open || cipData.length) return;
|
||||
fetch('/cip_institution_mapping_new.json')
|
||||
.then(r => r.text())
|
||||
.then(text =>
|
||||
text
|
||||
.split('\n')
|
||||
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
||||
.filter(Boolean)
|
||||
)
|
||||
.then(arr => setCipData(arr))
|
||||
.catch(e => console.error('CIP fetch error', e));
|
||||
}, [open, cipData.length]);
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
SCHOOL AUTOCOMPLETE LIST (memoised)
|
||||
════════════════════════════════════════════════════ */
|
||||
const suggestions = useMemo(() => {
|
||||
if (!schoolSearch.trim()) return [];
|
||||
const low = schoolSearch.toLowerCase();
|
||||
const set = new Set(
|
||||
cipData
|
||||
.filter(r => r.INSTNM.toLowerCase().includes(low))
|
||||
.map(r => r.INSTNM)
|
||||
);
|
||||
return [...set].slice(0, 10);
|
||||
}, [schoolSearch, cipData]);
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
ESC --> close convenience
|
||||
════════════════════════════════════════════════════ */
|
||||
const escHandler = useCallback(e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}, [onClose]);
|
||||
useEffect(() => {
|
||||
if (open) window.addEventListener('keydown', escHandler);
|
||||
return () => window.removeEventListener('keydown', escHandler);
|
||||
}, [open, escHandler]);
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
HANDLE “CONTINUE”
|
||||
════════════════════════════════════════════════════ */
|
||||
const handleContinue = () => {
|
||||
// Tuition is the only truly required field
|
||||
if (!tuition.trim()) {
|
||||
setErr('Please enter an annual tuition estimate.');
|
||||
return;
|
||||
}
|
||||
setErr('');
|
||||
|
||||
// Build a stub “school” object so LoanRepayment
|
||||
// can work with the same shape it expects
|
||||
const stub = {
|
||||
name : schoolSearch || 'Unknown School',
|
||||
degreeType : degree || 'Unspecified',
|
||||
programLength : 4, // sensible default
|
||||
inState : parseFloat(tuition),
|
||||
outOfState : parseFloat(tuition),
|
||||
inStateGraduate : parseFloat(tuition),
|
||||
outStateGraduate: parseFloat(tuition),
|
||||
};
|
||||
|
||||
setSchools([stub]); // overwrite – single-school calc
|
||||
setShowCalc(true);
|
||||
};
|
||||
|
||||
const showUpsell = user && !user.is_premium && !user.is_pro_premium;
|
||||
|
||||
/* ════════════════════════════════════════════════════
|
||||
RENDER
|
||||
════════════════════════════════════════════════════ */
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
|
||||
<div className="h-full w-full max-w-md bg-white shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-5 py-4 border-b">
|
||||
<h2 className="font-semibold">Estimate Student-Loan Payments</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto p-6 flex-1">
|
||||
{/* Free money first */}
|
||||
<section className="text-sm mb-6">
|
||||
<h3 className="font-semibold mb-1">Free money first</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Submit the <strong>FAFSA</strong> (it’s free).</li>
|
||||
<li>Check state <strong>grant / Promise / HOPE</strong> programs.</li>
|
||||
<li>Apply for <strong>scholarships</strong> (FastWeb, Bold.org, local).</li>
|
||||
<li>Take gift-aid first, loans last.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* STEP 1: QUICK FORM */}
|
||||
{!showCalc && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={e => { e.preventDefault(); handleContinue(); }}
|
||||
>
|
||||
{/* School name (optional) */}
|
||||
<div>
|
||||
<label className="text-sm font-medium">School name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={schoolSearch}
|
||||
onChange={e => setSchoolSearch(e.target.value)}
|
||||
list="school-suggestions"
|
||||
placeholder="Start typing…"
|
||||
className="mt-1 w-full rounded border px-3 py-2 text-sm"
|
||||
/>
|
||||
<datalist id="school-suggestions">
|
||||
{suggestions.map((s, i) => (
|
||||
<option key={i} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{/* Degree */}
|
||||
<div>
|
||||
<label className="text-sm font-medium">Degree / program type</label>
|
||||
<select
|
||||
value={degree}
|
||||
onChange={e => setDegree(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{DEGREE_OPTS.map((d, i) => <option key={i} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tuition */}
|
||||
<div>
|
||||
<label className="text-sm font-medium">Estimated annual tuition *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
value={tuition}
|
||||
onChange={e => setTuition(e.target.value)}
|
||||
placeholder="e.g. 28000"
|
||||
className="mt-1 w-full rounded border px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{err && <p className="text-red-600 text-sm">{err}</p>}
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Continue →
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* STEP 2: REAL CALCULATOR */}
|
||||
{showCalc && (
|
||||
<>
|
||||
<LoanRepayment
|
||||
schools={schools}
|
||||
setSchools={setSchools}
|
||||
setResults={setResults}
|
||||
results={results}
|
||||
/>
|
||||
|
||||
{/* small separator */}
|
||||
<hr className="my-6" />
|
||||
|
||||
{/* PREMIUM CTA */}
|
||||
{showUpsell ? (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded space-y-2 text-sm">
|
||||
<p className="font-medium">
|
||||
Want to see how this loan fits into a full financial plan?
|
||||
</p>
|
||||
<p>
|
||||
Premium subscribers can model salary growth, living costs,
|
||||
retirement goals, and more.
|
||||
</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate('/paywall');
|
||||
}}
|
||||
>
|
||||
Unlock Premium Features →
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* If already premium just show a helpful note */
|
||||
<div className="bg-green-50 border-l-4 border-green-400 p-4 rounded text-sm">
|
||||
<p className="font-medium">
|
||||
You’re on Premium — open the <strong>College Planning wizard</strong> to store this tuition in your plan
|
||||
(Profile ▸ Premium Onboarding ▸ College Details).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* STEP 3 : results table */}
|
||||
{results?.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h4 className="font-semibold mb-2 text-center">
|
||||
Estimated payments
|
||||
</h4>
|
||||
|
||||
<table className="w-full text-sm border">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="p-2">School</th>
|
||||
<th className="p-2">Monthly</th>
|
||||
<th className="p-2">Total Cost</th>
|
||||
<th className="p-2">Net Gain</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="p-2">{r.name}</td>
|
||||
<td className="p-2">${r.totalMonthlyPayment}</td>
|
||||
<td className="p-2">${r.totalLoanCost}</td>
|
||||
<td className="p-2">${r.netGain}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
);
|
||||
}
|
11
src/components/LoanRepaymentPage.js
Normal file
11
src/components/LoanRepaymentPage.js
Normal file
@ -0,0 +1,11 @@
|
||||
// src/pages/LoanRepaymentPage.js
|
||||
import LoanRepayment from '../components/LoanRepayment.js';
|
||||
|
||||
export default function LoanRepaymentPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6">
|
||||
{/* Pass data via props or context exactly as LoanRepayment expects */}
|
||||
<LoanRepayment />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,68 +1,106 @@
|
||||
import React from 'react';
|
||||
// src/components/PreparingLanding.js
|
||||
import React, { useState, useCallback, useEffect, useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from './ui/button.js';
|
||||
import LoanRepaymentDrawer from './LoanRepaymentDrawer.js';
|
||||
import { ProfileCtx } from '../App.js';
|
||||
|
||||
function PreparingLanding() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user } = useContext(ProfileCtx);
|
||||
|
||||
/* ─── Drawer visibility ─────────────────────────────── */
|
||||
const [showLoan, setShowLoan] = useState(false);
|
||||
|
||||
/* ─── Stub-school state lives here; Drawer mutates it ─ */
|
||||
const [schools, setSchools] = useState([]); // [] → quick-fill form
|
||||
const [cipCodes] = useState([]); // you may hold these at page level
|
||||
const [userZip] = useState('');
|
||||
const [loanResults, setLoanResults] = useState([]);
|
||||
|
||||
|
||||
/* Esc -to-close convenience */
|
||||
const escHandler = useCallback(e => {
|
||||
if (e.key === 'Escape') setShowLoan(false);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (showLoan) window.addEventListener('keydown', escHandler);
|
||||
return () => window.removeEventListener('keydown', escHandler);
|
||||
}, [showLoan, escHandler]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6">
|
||||
<div className="max-w-3xl w-full bg-white shadow-lg rounded-lg p-8 space-y-6">
|
||||
{/* ───────────────── TITLE / INTRO ───────────────── */}
|
||||
<h1 className="text-3xl font-bold text-center">
|
||||
Preparing for Your (Next) Career
|
||||
</h1>
|
||||
<p className="text-gray-600 text-center">
|
||||
Build the right skills and plan your education so you can confidently
|
||||
enter (or transition into) your new career.
|
||||
enter—or transition into—your new career.
|
||||
</p>
|
||||
|
||||
{/* Section: Choose Skills-Based vs. Formal Education */}
|
||||
{/* ──────────────── 1) PATH CHOICE ──────────────── */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Which Path Fits You?</h2>
|
||||
<p className="text-gray-700">
|
||||
We can help you identify whether a <strong>skills-based program</strong>
|
||||
(certifications, bootcamps, on-the-job training) or a more
|
||||
<strong> formal education route</strong> (two-year or four-year college)
|
||||
is the best fit. Whichever path you choose, our AI tools will guide
|
||||
you from application to graduation.
|
||||
We can help you identify whether a
|
||||
<strong>skills-based program</strong> (certifications, bootcamps) or a
|
||||
<strong>formal education route</strong> (two- or four-year college)
|
||||
is the best fit. Whichever path you choose, AptivaAI will help you map next steps—from applying to graduating.
|
||||
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Explore Education Options (handles skill-based & formal) */}
|
||||
<Button onClick={() => navigate('/educational-programs')}>
|
||||
Plan My Education Path
|
||||
</Button>
|
||||
|
||||
{/* How to Pay button (placeholder route) */}
|
||||
<Button onClick={() => navigate('/how-to-pay')}>
|
||||
<Button onClick={() => setShowLoan(true)}>
|
||||
How to Pay for Education
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section: Tie-In to LoanRepayment or Additional Financial Planning */}
|
||||
{/* ──────────────── 2) LOAN BLURB ──────────────── */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Financing Your Future</h2>
|
||||
<p className="text-gray-700">
|
||||
Already have an idea of where you want to enroll?
|
||||
We can help you compare costs, estimate student loan repayments,
|
||||
and map out work-study or part-time opportunities.
|
||||
Our integrated <strong>LoanRepayment</strong> tools will show you
|
||||
Already have an idea of where you want to enroll? Compare costs,
|
||||
estimate student-loan repayments, and map out work-study or part-time
|
||||
opportunities. Our integrated <strong>LoanRepayment</strong> tool shows
|
||||
realistic monthly payments so you can make confident choices.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Optional: Retake Interest Inventory */}
|
||||
{/* ──────────────── 3) INTEREST INVENTORY ──────────────── */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">Still Exploring?</h2>
|
||||
<p className="text-gray-700">
|
||||
If you’d like to revisit career possibilities, feel free to retake
|
||||
our Interest Inventory to see other matching paths.
|
||||
Want to revisit career possibilities? Retake our Interest Inventory to
|
||||
see other matching paths.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/interest-inventory')}>
|
||||
Retake Interest Inventory
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ─────────────── DRAWER MOUNT ─────────────── */}
|
||||
{showLoan && (
|
||||
<LoanRepaymentDrawer
|
||||
open={showLoan}
|
||||
onClose={() => setShowLoan(false)}
|
||||
schools={schools}
|
||||
setSchools={setSchools}
|
||||
results={loanResults}
|
||||
setResults={setLoanResults}
|
||||
isPremium={user?.is_premium || user?.is_pro_premium}
|
||||
cipCodes={cipCodes}
|
||||
userZip={userZip}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
35
src/components/UpsellSummary.js
Normal file
35
src/components/UpsellSummary.js
Normal file
@ -0,0 +1,35 @@
|
||||
// UpsellSummary.jsx
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
export default function UpsellSummary({ row, onUpgrade, isPremium }) {
|
||||
if (!row) return null;
|
||||
|
||||
const niceMoney = n => '$' + Number(n).toLocaleString();
|
||||
|
||||
return (
|
||||
<div id="loan-results" className="mt-6 space-y-3 border-t pt-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
With a payment of <strong>{niceMoney(row.totalMonthlyPayment)}/mo</strong>{' '}
|
||||
you’d spend <strong>{niceMoney(row.totalLoanCost)}</strong> over the loan
|
||||
term and still net about{' '}
|
||||
<strong className="text-green-600">
|
||||
{niceMoney(row.netGain)}
|
||||
</strong>{' '}
|
||||
after pay-off.
|
||||
</p>
|
||||
|
||||
{isPremium ? (
|
||||
<Button className="w-full" onClick={onUpgrade}>
|
||||
Open Detailed ROI Planner →
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full bg-green-500 hover:bg-green-600"
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
See full ROI with Premium →
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
50
src/utils/getNearbySchools.js
Normal file
50
src/utils/getNearbySchools.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { fetchSchools, clientGeocodeZip, haversineDistance } from './apiUtils.js';
|
||||
|
||||
/**
|
||||
* Returns the N cheapest schools within a radius.
|
||||
* cipCodes – array like ['1101','5202']
|
||||
* userZip – '30303' etc (may be '')
|
||||
* userState – 'GA' etc (may be '')
|
||||
* maxDistMi – number (default 100)
|
||||
* limit – number (default 10)
|
||||
*/
|
||||
export async function getNearbySchools(
|
||||
cipCodes = [],
|
||||
userZip = '',
|
||||
userState = '',
|
||||
{ maxDistMi = 100, limit = 10 } = {}
|
||||
) {
|
||||
const raw = await fetchSchools(cipCodes);
|
||||
|
||||
let userLat=null, userLng=null;
|
||||
if (userZip) {
|
||||
try {
|
||||
const geo = await clientGeocodeZip(userZip);
|
||||
userLat = geo.lat; userLng = geo.lng;
|
||||
} catch { /* ignore – treat as no-location user */ }
|
||||
}
|
||||
|
||||
const withDist = raw.map(r => {
|
||||
const lat = r.LATITUDE ? parseFloat(r.LATITUDE) : null;
|
||||
const lon = r.LONGITUD ? parseFloat(r.LONGITUD) : null;
|
||||
const dist = (userLat && userLng && lat && lon)
|
||||
? haversineDistance(userLat,userLng,lat,lon)
|
||||
: null;
|
||||
return { ...r, distance: dist };
|
||||
});
|
||||
|
||||
let cand = withDist
|
||||
.filter(s => {
|
||||
if (s.distance===null) return true;
|
||||
return s.distance <= maxDistMi;
|
||||
})
|
||||
.sort((a,b)=>{
|
||||
const aT = parseFloat(a['In_state cost'] || Infinity);
|
||||
const bT = parseFloat(b['In_state cost'] || Infinity);
|
||||
return aT - bT;
|
||||
});
|
||||
|
||||
/* If user said “in-state only” we can filter here – omitted for brevity */
|
||||
|
||||
return cand.slice(0, limit);
|
||||
}
|
223
src/utils/ipedsTuition.js
Normal file
223
src/utils/ipedsTuition.js
Normal file
@ -0,0 +1,223 @@
|
||||
// src/components/LoanRepaymentDrawer.jsx
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './ui/button.js';
|
||||
import LoanRepayment from './LoanRepayment.js';
|
||||
|
||||
/**
|
||||
* Tiny helper so we don’t repeat className clutter.
|
||||
*/
|
||||
const Field = ({ label, children }) => (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function LoanRepaymentDrawer({ open, onClose }) {
|
||||
/**************************************************************************
|
||||
* 1 Drawer visibility
|
||||
**************************************************************************/
|
||||
if (!open) return null;
|
||||
|
||||
/**************************************************************************
|
||||
* 2 Local state for the estimator form
|
||||
**************************************************************************/
|
||||
const [form, setForm] = useState({
|
||||
schoolName: '',
|
||||
programType: '',
|
||||
annualTuition: '',
|
||||
});
|
||||
|
||||
const [step, setStep] = useState(1); // 1 = “free-money”, 2 = estimator, 3 = results
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
// Pass-throughs for <LoanRepayment/>
|
||||
const [calcResults, setCalcResults] = useState(null);
|
||||
const [calcLoading, setCalcLoading] = useState(false);
|
||||
|
||||
const onChange = (field) => (e) =>
|
||||
setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
|
||||
const formValid =
|
||||
form.programType &&
|
||||
form.annualTuition &&
|
||||
!Number.isNaN(Number(form.annualTuition)) &&
|
||||
Number(form.annualTuition) > 0;
|
||||
|
||||
/**************************************************************************
|
||||
* 3 Derived “school object” handed to <LoanRepayment/>
|
||||
**************************************************************************/
|
||||
const derivedSchool = useMemo(() => {
|
||||
if (!formValid) return null;
|
||||
|
||||
return {
|
||||
// Minimal fields LoanRepayment expects
|
||||
degreeType: form.programType,
|
||||
// If user typed nothing → “Unknown school”
|
||||
name: form.schoolName || 'Unknown school',
|
||||
programLength: 4, // we can’t know; LoanRepayment falls back if it differs
|
||||
inState: Number(form.annualTuition),
|
||||
outOfState: Number(form.annualTuition),
|
||||
// grad tuition duplicates – harmless for our purposes
|
||||
inStateGraduate: Number(form.annualTuition),
|
||||
outStateGraduate: Number(form.annualTuition),
|
||||
};
|
||||
}, [form, formValid]);
|
||||
|
||||
/**************************************************************************
|
||||
* 4 UI
|
||||
**************************************************************************/
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
|
||||
{/* drawer panel */}
|
||||
<div className="h-full w-full max-w-md bg-white shadow-xl overflow-y-auto p-6 relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* title bar */}
|
||||
<h2 className="text-lg font-semibold mb-6 text-center">
|
||||
Estimate Student-Loan Payments
|
||||
</h2>
|
||||
|
||||
{/* ───────────────── STEP 1 – free money first ───────────────── */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<p className="text-gray-700 mb-4">
|
||||
<strong>Before you borrow:</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-gray-600 space-y-2 mb-6">
|
||||
<li>
|
||||
Complete the
|
||||
<a
|
||||
href="https://studentaid.gov/h/apply-for-aid/fafsa"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
FAFSA
|
||||
</a>{' '}
|
||||
– it’s free and unlocks grants, work-study, and low-interest
|
||||
federal loans.
|
||||
</li>
|
||||
<li>Search local and departmental scholarships (they add up!).</li>
|
||||
<li>
|
||||
Compare tuition discounts for in-state, online, or employer
|
||||
tuition-reimbursement programs.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button className="w-full" onClick={() => setStep(2)}>
|
||||
Continue →
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───────────────── STEP 2 – quick estimator form ─────────────── */}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<Field label="School name (optional)">
|
||||
<input
|
||||
type="text"
|
||||
value={form.schoolName}
|
||||
onChange={onChange('schoolName')}
|
||||
placeholder="e.g. Georgia Tech"
|
||||
className="w-full border rounded p-2"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Degree / program type *">
|
||||
<select
|
||||
value={form.programType}
|
||||
onChange={onChange('programType')}
|
||||
className="w-full border rounded p-2 bg-white"
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
<option>Certificate</option>
|
||||
<option>Associate's Degree</option>
|
||||
<option>Bachelor's Degree</option>
|
||||
<option>Master's Degree</option>
|
||||
<option>First Professional Degree</option>
|
||||
<option>Doctoral Degree</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Estimated annual tuition *">
|
||||
<input
|
||||
type="number"
|
||||
value={form.annualTuition}
|
||||
onChange={onChange('annualTuition')}
|
||||
placeholder="USD"
|
||||
className="w-full border rounded p-2"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{showErrors && !formValid && (
|
||||
<p className="text-red-600 text-sm mb-4">
|
||||
Please fill in the required fields (degree type and tuition).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (!formValid) {
|
||||
setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
setShowErrors(false);
|
||||
setStep(3);
|
||||
}}
|
||||
>
|
||||
Estimate payments →
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───────────────── STEP 3 – results / LoanRepayment ──────────── */}
|
||||
{step === 3 && derivedSchool && (
|
||||
<>
|
||||
<LoanRepayment
|
||||
schools={[derivedSchool]}
|
||||
salaryData={null}
|
||||
setResults={setCalcResults}
|
||||
setLoading={setCalcLoading}
|
||||
setPersistedROI={() => {}}
|
||||
programLength={4}
|
||||
/>
|
||||
|
||||
{calcLoading && (
|
||||
<p className="text-sm text-gray-500 mt-4">Calculating…</p>
|
||||
)}
|
||||
|
||||
{calcResults && (
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<h3 className="font-semibold mb-2">Quick Summary</h3>
|
||||
<pre className="whitespace-pre-wrap text-xs bg-gray-50 p-2 rounded max-h-48 overflow-auto">
|
||||
{JSON.stringify(calcResults, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-6 w-full"
|
||||
onClick={() => {
|
||||
setCalcResults(null);
|
||||
setStep(2);
|
||||
}}
|
||||
>
|
||||
‹ Back to inputs
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
42
src/utils/tuitionCalc.js
Normal file
42
src/utils/tuitionCalc.js
Normal file
@ -0,0 +1,42 @@
|
||||
/* ───────────────────────────────────
|
||||
ONE place that owns the math
|
||||
─────────────────────────────────── */
|
||||
export function calcAnnualTuition({
|
||||
ipedsRows, schoolRow,
|
||||
programType, creditHoursPerYear,
|
||||
inState, inDistrict,
|
||||
}) {
|
||||
if (!ipedsRows?.length || !schoolRow || !programType) return 0;
|
||||
|
||||
const row = ipedsRows.find(r => r.UNITID === schoolRow.UNITID);
|
||||
if (!row) return 0;
|
||||
|
||||
const grad = [
|
||||
"Master's Degree", "Doctoral Degree",
|
||||
"Graduate/Professional Certificate", "First Professional Degree",
|
||||
].includes(programType);
|
||||
|
||||
const pick = (u1,u2,u3) => inDistrict ? row[u1] : inState ? row[u2] : row[u3];
|
||||
|
||||
const partTime = Number( grad ? pick('HRCHG5','HRCHG6','HRCHG7')
|
||||
: pick('HRCHG1','HRCHG2','HRCHG3') );
|
||||
const fullTime = Number( grad ? pick('TUITION5','TUITION6','TUITION7')
|
||||
: pick('TUITION1','TUITION2','TUITION3') );
|
||||
|
||||
const ch = Number(creditHoursPerYear) || 0;
|
||||
return (ch && ch < 24 && partTime) ? partTime * ch : fullTime;
|
||||
}
|
||||
|
||||
export function calcProgramLength({ programType, hrsPerYear, hrsCompleted=0, hrsRequired=0 }) {
|
||||
if (!programType || !hrsPerYear) return '0.00';
|
||||
let need = hrsRequired;
|
||||
|
||||
switch (programType) {
|
||||
case "Associate's Degree": need = 60; break;
|
||||
case "Bachelor's Degree" : need = 120; break;
|
||||
case "Master's Degree" : need = 180; break;
|
||||
case "Doctoral Degree" : need = 240; break;
|
||||
case "First Professional Degree": need = 180; break;
|
||||
}
|
||||
return ((need - hrsCompleted) / hrsPerYear).toFixed(2);
|
||||
}
|
Loading…
Reference in New Issue
Block a user