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 OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
|
||||||
import RetirementPlanner from './components/RetirementPlanner.js';
|
import RetirementPlanner from './components/RetirementPlanner.js';
|
||||||
import ResumeRewrite from './components/ResumeRewrite.js';
|
import ResumeRewrite from './components/ResumeRewrite.js';
|
||||||
|
import LoanRepaymentPage from './components/LoanRepaymentPage.js';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const ProfileCtx = React.createContext();
|
export const ProfileCtx = React.createContext();
|
||||||
|
|
||||||
@ -177,7 +180,8 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ProfileCtx.Provider
|
<ProfileCtx.Provider
|
||||||
value={{ financialProfile, setFinancialProfile,
|
value={{ financialProfile, setFinancialProfile,
|
||||||
scenario, setScenario }}
|
scenario, setScenario,
|
||||||
|
user, }}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -348,6 +352,23 @@ function App() {
|
|||||||
>
|
>
|
||||||
Financial Profile
|
Financial Profile
|
||||||
</Link>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@ -431,6 +452,7 @@ function App() {
|
|||||||
<Route path="/profile" element={<UserProfile />} />
|
<Route path="/profile" element={<UserProfile />} />
|
||||||
<Route path="/planning" element={<PlanningLanding />} />
|
<Route path="/planning" element={<PlanningLanding />} />
|
||||||
<Route path="/career-explorer" element={<CareerExplorer />} />
|
<Route path="/career-explorer" element={<CareerExplorer />} />
|
||||||
|
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
|
||||||
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
||||||
<Route path="/preparing" element={<PreparingLanding />} />
|
<Route path="/preparing" element={<PreparingLanding />} />
|
||||||
|
|
||||||
|
@ -1,223 +1,251 @@
|
|||||||
import React, { useState } from 'react';
|
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({
|
function LoanRepayment({
|
||||||
schools,
|
/* ORIGINAL PROPS */
|
||||||
|
schools = [],
|
||||||
salaryData,
|
salaryData,
|
||||||
setResults,
|
setResults,
|
||||||
setLoading,
|
setLoading,
|
||||||
setPersistedROI,
|
setPersistedROI,
|
||||||
programLength,
|
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 [expectedSalary, setExpectedSalary] = useState(0);
|
||||||
const [tuitionType, setTuitionType] = useState('inState'); // Tuition type: inState or outOfState
|
const [tuitionType, setTuitionType] = useState('inState');
|
||||||
const [interestRate, setInterestRate] = useState(5.5); // Interest rate
|
const [interestRate, setInterestRate] = useState(5.5);
|
||||||
const [loanTerm, setLoanTerm] = useState(10); // Loan term in years
|
const [loanTerm, setLoanTerm] = useState(10);
|
||||||
const [extraPayment, setExtraPayment] = useState(0); // Extra monthly payment
|
const [extraPayment, setExtraPayment] = useState(0);
|
||||||
const [currentSalary, setCurrentSalary] = useState(0); // Current salary input
|
const [currentSalary, setCurrentSalary] = useState(0);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
/* ------------------------- validation ------------------------------ */
|
||||||
const validateInputs = () => {
|
const validateInputs = () => {
|
||||||
if (!schools || schools.length === 0) {
|
if (!schools?.length) { setError('Missing school data.'); return false; }
|
||||||
setError('School data is missing. Loan calculations cannot proceed.');
|
if (isNaN(interestRate)||interestRate<=0) { setError('Interest rate > 0'); return false; }
|
||||||
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(interestRate) || interestRate <= 0) {
|
if (isNaN(expectedSalary)||expectedSalary<0){setError('Expected salary ≥0');return false;}
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ------------------------- main calculation ----------------------- */
|
||||||
const calculateLoanDetails = () => {
|
const calculateLoanDetails = () => {
|
||||||
if (!validateInputs()) return;
|
if (!validateInputs()) return;
|
||||||
|
setLoading?.(true);
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
const results = schools.map((school) => {
|
const results = schools.map((school) => {
|
||||||
const programLength = Number(school.programLength);
|
/* your existing repayment logic — unchanged */
|
||||||
|
const programLen = Number(school.programLength);
|
||||||
const tuition = tuitionType === 'inState' ? school.inState : school.outOfState;
|
const tuition = tuitionType === 'inState' ? school.inState : school.outOfState;
|
||||||
|
|
||||||
let totalTuition = 0;
|
let ugYears=0, gradYears=0;
|
||||||
let undergraduateYears = 0;
|
if (school.degreeType.includes('Associate')) ugYears=2;
|
||||||
let graduateYears = 0;
|
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)
|
let totalTuition = ugYears*tuition;
|
||||||
if (school.degreeType.includes("Associate")) {
|
if (gradYears>0) {
|
||||||
undergraduateYears = 2;
|
const gradTuit = tuitionType==='inState'?school.inStateGraduate:school.outStateGraduate;
|
||||||
graduateYears = 0;
|
totalTuition += gradYears*gradTuit;
|
||||||
}
|
|
||||||
// ✅ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
let bal=totalTuition, months=0;
|
||||||
const gradTuition = tuitionType === 'inState'
|
while (bal>0 && months<n*2){
|
||||||
? school.inStateGraduate
|
months++;
|
||||||
: school.outStateGraduate;
|
const interest = bal*r;
|
||||||
totalTuition += graduateYears * gradTuition;
|
bal -= Math.max(pmt - interest,0);
|
||||||
}
|
}
|
||||||
|
const totalCost = pmt*months;
|
||||||
|
|
||||||
// Loan calculations
|
/* safe net-gain estimate */
|
||||||
const monthlyRate = Number(interestRate) / 12 / 100;
|
const salary = Number(expectedSalary)||0;
|
||||||
const loanTermMonths = Number(loanTerm) * 12;
|
const cur = Number(currentSalary)||0;
|
||||||
|
const netGain = salary
|
||||||
const minimumMonthlyPayment = totalTuition * (monthlyRate * Math.pow(1 + monthlyRate, loanTermMonths)) /
|
? (salary*loanTerm - totalCost - cur*loanTerm*Math.pow(1.03,loanTerm)).toFixed(2)
|
||||||
(Math.pow(1 + monthlyRate, loanTermMonths) - 1);
|
: (-totalCost).toFixed(2);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...school,
|
...school,
|
||||||
totalTuition: totalTuition.toFixed(2),
|
totalTuition : totalTuition.toFixed(2),
|
||||||
monthlyPayment: minimumMonthlyPayment.toFixed(2),
|
monthlyPayment: pmtMin.toFixed(2),
|
||||||
totalMonthlyPayment: extraMonthlyPayment.toFixed(2),
|
totalMonthlyPayment: pmt.toFixed(2),
|
||||||
totalLoanCost: totalLoanCost.toFixed(2),
|
totalLoanCost : totalCost.toFixed(2),
|
||||||
netGain,
|
netGain,
|
||||||
monthlySalary,
|
monthlySalary : (salary/12).toFixed(2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setResults(results);
|
setResults?.(results);
|
||||||
setLoading(false);
|
setLoading?.(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* QUICK-FILL PANEL (only when schools.length === 0) */
|
||||||
|
/* ================================================================= */
|
||||||
|
if (!schools || schools.length === 0) {
|
||||||
|
const ready = scratch.tuition;
|
||||||
return (
|
return (
|
||||||
<div className="loan-repayment-container">
|
<div className="border rounded p-6 space-y-4 max-w-md mx-auto">
|
||||||
<form onSubmit={(e) => { e.preventDefault(); calculateLoanDetails(); }}>
|
<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 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">
|
<div className="input-group">
|
||||||
<label>Tuition Type:</label>
|
<label className="block font-medium">Tuition Type</label>
|
||||||
<select value={tuitionType} onChange={(e) => setTuitionType(e.target.value)}>
|
<select
|
||||||
|
value={tuitionType}
|
||||||
|
onChange={(e)=>setTuitionType(e.target.value)}
|
||||||
|
className="border rounded p-2 w-full"
|
||||||
|
>
|
||||||
<option value="inState">In-State</option>
|
<option value="inState">In-State</option>
|
||||||
<option value="outOfState">Out-of-State</option>
|
<option value="outOfState">Out-of-State</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Interest rate */}
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>Interest Rate:</label>
|
<label className="block font-medium">Interest Rate (%)</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
value={interestRate}
|
value={interestRate}
|
||||||
onChange={(e) => setInterestRate(e.target.value)}
|
onChange={(e)=>setInterestRate(e.target.value)}
|
||||||
placeholder="Enter the interest rate"
|
className="border rounded p-2 w-full"/>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loan term */}
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>Loan Term (years):</label>
|
<label className="block font-medium">Loan Term (years)</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
value={loanTerm}
|
value={loanTerm}
|
||||||
onChange={(e) => setLoanTerm(e.target.value)}
|
onChange={(e)=>setLoanTerm(e.target.value)}
|
||||||
placeholder="Enter the length of the loan repayment period"
|
className="border rounded p-2 w-full"/>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Extra payment */}
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>Extra Monthly Payment:</label>
|
<label className="block font-medium">Extra Monthly Payment</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
value={extraPayment}
|
value={extraPayment}
|
||||||
onChange={(e) => setExtraPayment(e.target.value)}
|
onChange={(e)=>setExtraPayment(e.target.value)}
|
||||||
placeholder="Enter any additional monthly payment"
|
className="border rounded p-2 w-full"/>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Current salary */}
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>Current Salary:</label>
|
<label className="block font-medium">Current Salary</label>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
value={currentSalary}
|
value={currentSalary}
|
||||||
onChange={(e) => setCurrentSalary(e.target.value)}
|
onChange={(e)=>setCurrentSalary(e.target.value)}
|
||||||
placeholder="Enter your current salary"
|
className="border rounded p-2 w-full"/>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expected salary */}
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>Expected Salary:</label>
|
<label className="block font-medium">Expected Salary</label>
|
||||||
<input
|
<input type="number"
|
||||||
type="number"
|
|
||||||
value={expectedSalary}
|
value={expectedSalary}
|
||||||
onChange={(e) => setExpectedSalary(e.target.value)}
|
onChange={(e)=>setExpectedSalary(e.target.value)}
|
||||||
placeholder="Enter expected salary"
|
className="border rounded p-2 w-full"/>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="calculate-button-container">
|
|
||||||
<button type="submit">Calculate</button>
|
{/* Submit */}
|
||||||
|
<div className="text-right">
|
||||||
|
<Button type="submit">Calculate</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||||
</form>
|
</form>
|
||||||
{error && <div className="error">{error}</div>}
|
</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 { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from './ui/button.js';
|
import { Button } from './ui/button.js';
|
||||||
|
import LoanRepaymentDrawer from './LoanRepaymentDrawer.js';
|
||||||
|
import { ProfileCtx } from '../App.js';
|
||||||
|
|
||||||
function PreparingLanding() {
|
function PreparingLanding() {
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6">
|
<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">
|
<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">
|
<h1 className="text-3xl font-bold text-center">
|
||||||
Preparing for Your (Next) Career
|
Preparing for Your (Next) Career
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 text-center">
|
<p className="text-gray-600 text-center">
|
||||||
Build the right skills and plan your education so you can confidently
|
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>
|
</p>
|
||||||
|
|
||||||
{/* Section: Choose Skills-Based vs. Formal Education */}
|
{/* ──────────────── 1) PATH CHOICE ──────────────── */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold">Which Path Fits You?</h2>
|
<h2 className="text-xl font-semibold">Which Path Fits You?</h2>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
We can help you identify whether a <strong>skills-based program</strong>
|
We can help you identify whether a
|
||||||
(certifications, bootcamps, on-the-job training) or a more
|
<strong>skills-based program</strong> (certifications, bootcamps) or a
|
||||||
<strong> formal education route</strong> (two-year or four-year college)
|
<strong>formal education route</strong> (two- or four-year college)
|
||||||
is the best fit. Whichever path you choose, our AI tools will guide
|
is the best fit. Whichever path you choose, AptivaAI will help you map next steps—from applying to graduating.
|
||||||
you from application to graduation.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
{/* Explore Education Options (handles skill-based & formal) */}
|
|
||||||
<Button onClick={() => navigate('/educational-programs')}>
|
<Button onClick={() => navigate('/educational-programs')}>
|
||||||
Plan My Education Path
|
Plan My Education Path
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* How to Pay button (placeholder route) */}
|
<Button onClick={() => setShowLoan(true)}>
|
||||||
<Button onClick={() => navigate('/how-to-pay')}>
|
|
||||||
How to Pay for Education
|
How to Pay for Education
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Section: Tie-In to LoanRepayment or Additional Financial Planning */}
|
{/* ──────────────── 2) LOAN BLURB ──────────────── */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold">Financing Your Future</h2>
|
<h2 className="text-xl font-semibold">Financing Your Future</h2>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
Already have an idea of where you want to enroll?
|
Already have an idea of where you want to enroll? Compare costs,
|
||||||
We can help you compare costs, estimate student loan repayments,
|
estimate student-loan repayments, and map out work-study or part-time
|
||||||
and map out work-study or part-time opportunities.
|
opportunities. Our integrated <strong>LoanRepayment</strong> tool shows
|
||||||
Our integrated <strong>LoanRepayment</strong> tools will show you
|
|
||||||
realistic monthly payments so you can make confident choices.
|
realistic monthly payments so you can make confident choices.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Optional: Retake Interest Inventory */}
|
{/* ──────────────── 3) INTEREST INVENTORY ──────────────── */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="text-xl font-semibold">Still Exploring?</h2>
|
<h2 className="text-xl font-semibold">Still Exploring?</h2>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
If you’d like to revisit career possibilities, feel free to retake
|
Want to revisit career possibilities? Retake our Interest Inventory to
|
||||||
our Interest Inventory to see other matching paths.
|
see other matching paths.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => navigate('/interest-inventory')}>
|
<Button onClick={() => navigate('/interest-inventory')}>
|
||||||
Retake Interest Inventory
|
Retake Interest Inventory
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</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