diff --git a/src/App.js b/src/App.js index 022bdcb..a1c5e54 100644 --- a/src/App.js +++ b/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 (
{/* Header */} @@ -348,6 +352,23 @@ function App() { > Financial Profile + {canAccessPremium ? ( + /* Premium users go straight to the wizard */ + + Premium Onboarding + + ) : ( + /* Free users are nudged to upgrade */ + + College Planning (Premium) + + )}
@@ -431,6 +452,7 @@ function App() { } /> } /> } /> + }/> } /> } /> diff --git a/src/components/LoanRepayment.js b/src/components/LoanRepayment.js index 4017dbc..515a654 100644 --- a/src/components/LoanRepayment.js +++ b/src/components/LoanRepayment.js @@ -1,224 +1,252 @@ 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, -}) { - 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); + /* 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'); + 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; - - let totalTuition = 0; - let undergraduateYears = 0; - let graduateYears = 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; + /* your existing repayment logic — unchanged */ + const programLen = Number(school.programLength); + const tuition = tuitionType === 'inState' ? school.inState : school.outOfState; + + 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); } + + let totalTuition = ugYears*tuition; + if (gradYears>0) { + const gradTuit = tuitionType==='inState'?school.inStateGraduate:school.outStateGraduate; + totalTuition += gradYears*gradTuit; } - // ✅ Handle Master's (4 undergrad + 2 graduate) - else if (school.degreeType.includes("Master")) { - undergraduateYears = 4; - graduateYears = 2; + + 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); + + let bal=totalTuition, months=0; + while (bal>0 && months 0) { - const gradTuition = tuitionType === 'inState' - ? school.inStateGraduate - : school.outStateGraduate; - totalTuition += graduateYears * gradTuition; - } - - // 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); - } - + const totalCost = pmt*months; + + /* 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 ( +
+

+ Estimate student-loan payments +

+ + setScratch({...scratch,school:e.target.value})} + /> + + + + setScratch({...scratch,tuition:e.target.value})} + /> + + +
+ ); + } + + /* ================================================================= */ + /* ORIGINAL DETAILED REPAYMENT FORM */ + /* ================================================================= */ return ( -
-
{ e.preventDefault(); calculateLoanDetails(); }}> -
- - -
-
- - setInterestRate(e.target.value)} - placeholder="Enter the interest rate" - /> -
-
- - setLoanTerm(e.target.value)} - placeholder="Enter the length of the loan repayment period" - /> -
-
- - setExtraPayment(e.target.value)} - placeholder="Enter any additional monthly payment" - /> -
-
- - setCurrentSalary(e.target.value)} - placeholder="Enter your current salary" - /> -
-
- - setExpectedSalary(e.target.value)} - placeholder="Enter expected salary" - /> +
+ {e.preventDefault();calculateLoanDetails();}} + className="space-y-4 border rounded p-6"> + {/* Tuition type */} +
+ +
-
- + + {/* Interest rate */} +
+ + setInterestRate(e.target.value)} + className="border rounded p-2 w-full"/> +
+ + {/* Loan term */} +
+ + setLoanTerm(e.target.value)} + className="border rounded p-2 w-full"/> +
+ + {/* Extra payment */} +
+ + setExtraPayment(e.target.value)} + className="border rounded p-2 w-full"/> +
+ + {/* Current salary */} +
+ + setCurrentSalary(e.target.value)} + className="border rounded p-2 w-full"/> +
+ + {/* Expected salary */} +
+ + setExpectedSalary(e.target.value)} + className="border rounded p-2 w-full"/> +
+ + {/* Submit */} +
+ +
+ + {error &&
{error}
} +
- - {error &&
{error}
} -
); } -export default LoanRepayment; \ No newline at end of file +export default LoanRepayment; diff --git a/src/components/LoanRepaymentDrawer.js b/src/components/LoanRepaymentDrawer.js new file mode 100644 index 0000000..8261a9d --- /dev/null +++ b/src/components/LoanRepaymentDrawer.js @@ -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 ( +
+
+ {/* Header */} +
+

Estimate Student-Loan Payments

+ +
+ + {/* Content */} +
+ {/* Free money first */} +
+

Free money first

+
    +
  1. Submit the FAFSA (it’s free).
  2. +
  3. Check state grant / Promise / HOPE programs.
  4. +
  5. Apply for scholarships (FastWeb, Bold.org, local).
  6. +
  7. Take gift-aid first, loans last.
  8. +
+
+ + {/* STEP 1: QUICK FORM */} + {!showCalc && ( +
{ e.preventDefault(); handleContinue(); }} + > + {/* School name (optional) */} +
+ + setSchoolSearch(e.target.value)} + list="school-suggestions" + placeholder="Start typing…" + className="mt-1 w-full rounded border px-3 py-2 text-sm" + /> + + {suggestions.map((s, i) => ( + +
+ + {/* Degree */} +
+ + +
+ + {/* Tuition */} +
+ + setTuition(e.target.value)} + placeholder="e.g. 28000" + className="mt-1 w-full rounded border px-3 py-2 text-sm" + /> +
+ + {/* Error */} + {err &&

{err}

} + + +
+ )} + + {/* STEP 2: REAL CALCULATOR */} + {showCalc && ( + <> + + + {/* small separator */} +
+ + {/* PREMIUM CTA */} + {showUpsell ? ( +
+

+ Want to see how this loan fits into a full financial plan? +

+

+ Premium subscribers can model salary growth, living costs, + retirement goals, and more. +

+ +
+ ) : ( + /* If already premium just show a helpful note */ +
+

+ You’re on Premium — open the ​College Planning wizard to store this tuition in your plan + (Profile ▸ Premium Onboarding ▸ College Details). +

+
+ )} + + )} + + {/* STEP 3 : results table */} + {results?.length > 0 && ( +
+

+ Estimated payments +

+ + + + + + + + + + + + {results.map((r, i) => ( + + + + + + + ))} + +
SchoolMonthlyTotal CostNet Gain
{r.name}${r.totalMonthlyPayment}${r.totalLoanCost}${r.netGain}
+
+ )} +
+
+
+ + + ); +} diff --git a/src/components/LoanRepaymentPage.js b/src/components/LoanRepaymentPage.js new file mode 100644 index 0000000..796bde2 --- /dev/null +++ b/src/components/LoanRepaymentPage.js @@ -0,0 +1,11 @@ +// src/pages/LoanRepaymentPage.js +import LoanRepayment from '../components/LoanRepayment.js'; + +export default function LoanRepaymentPage() { + return ( +
+ {/* Pass data via props or context exactly as LoanRepayment expects */} + +
+ ); +} diff --git a/src/components/PreparingLanding.js b/src/components/PreparingLanding.js index 9cfe823..50158a6 100644 --- a/src/components/PreparingLanding.js +++ b/src/components/PreparingLanding.js @@ -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 (
+ {/* ───────────────── TITLE / INTRO ───────────────── */}

Preparing for Your (Next) Career

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.

- {/* Section: Choose Skills-Based vs. Formal Education */} + {/* ──────────────── 1) PATH CHOICE ──────────────── */}

Which Path Fits You?

- We can help you identify whether a skills-based program - (certifications, bootcamps, on-the-job training) or a more - formal education route (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  + skills-based program (certifications, bootcamps) or a  + formal education route (two- or four-year college) + is the best fit. Whichever path you choose, AptivaAI will help you map next steps—from applying to graduating. +

- {/* Explore Education Options (handles skill-based & formal) */} - {/* How to Pay button (placeholder route) */} -
- {/* Section: Tie-In to LoanRepayment or Additional Financial Planning */} + {/* ──────────────── 2) LOAN BLURB ──────────────── */}

Financing Your Future

- 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 LoanRepayment 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 LoanRepayment tool shows realistic monthly payments so you can make confident choices.

- {/* Optional: Retake Interest Inventory */} + {/* ──────────────── 3) INTEREST INVENTORY ──────────────── */}

Still Exploring?

- 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.

+ + {/* ─────────────── DRAWER MOUNT ─────────────── */} + {showLoan && ( + setShowLoan(false)} + schools={schools} + setSchools={setSchools} + results={loanResults} + setResults={setLoanResults} + isPremium={user?.is_premium || user?.is_pro_premium} + cipCodes={cipCodes} + userZip={userZip} + user={user} + /> + )}
); } diff --git a/src/components/UpsellSummary.js b/src/components/UpsellSummary.js new file mode 100644 index 0000000..c1d28c8 --- /dev/null +++ b/src/components/UpsellSummary.js @@ -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 ( +
+

+ With a payment of {niceMoney(row.totalMonthlyPayment)}/mo{' '} + you’d spend {niceMoney(row.totalLoanCost)} over the loan + term and still net about{' '} + + {niceMoney(row.netGain)} + {' '} + after pay-off. +

+ + {isPremium ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/utils/getNearbySchools.js b/src/utils/getNearbySchools.js new file mode 100644 index 0000000..82b810e --- /dev/null +++ b/src/utils/getNearbySchools.js @@ -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); +} diff --git a/src/utils/ipedsTuition.js b/src/utils/ipedsTuition.js new file mode 100644 index 0000000..54ad419 --- /dev/null +++ b/src/utils/ipedsTuition.js @@ -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 }) => ( +
+ + {children} +
+); + +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 + 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 + **************************************************************************/ + 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 ( +
+ {/* drawer panel */} +
+ + + {/* title bar */} +

+ Estimate Student-Loan Payments +

+ + {/* ───────────────── STEP 1 – free money first ───────────────── */} + {step === 1 && ( + <> +

+ Before you borrow: +

+
    +
  • + Complete the  + + FAFSA + {' '} + – it’s free and unlocks grants, work-study, and low-interest + federal loans. +
  • +
  • Search local and departmental scholarships (they add up!).
  • +
  • + Compare tuition discounts for in-state, online, or employer + tuition-reimbursement programs. +
  • +
+ + + + )} + + {/* ───────────────── STEP 2 – quick estimator form ─────────────── */} + {step === 2 && ( + <> + + + + + + + + + + + + + {showErrors && !formValid && ( +

+ Please fill in the required fields (degree type and tuition). +

+ )} + + + + )} + + {/* ───────────────── STEP 3 – results / LoanRepayment ──────────── */} + {step === 3 && derivedSchool && ( + <> + {}} + programLength={4} + /> + + {calcLoading && ( +

Calculating…

+ )} + + {calcResults && ( +
+

Quick Summary

+
+                  {JSON.stringify(calcResults, null, 2)}
+                
+
+ )} + + + + )} +
+
+ ); +} diff --git a/src/utils/tuitionCalc.js b/src/utils/tuitionCalc.js new file mode 100644 index 0000000..fa4b9ec --- /dev/null +++ b/src/utils/tuitionCalc.js @@ -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); +}