From e6d567d83914f47f3b82d70adfa8b3b634b4a11b Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 13:14:09 +0000 Subject: [PATCH] E2E bug fixes: 20 --- .build.hash | 2 +- src/components/CareerCoach.js | 90 +++- src/components/CareerPrioritiesModal.js | 120 ++++-- src/components/CareerRoadmap.js | 47 +- src/components/CollegeProfileForm.js | 71 +++- src/components/EducationalProgramsPage.js | 73 +++- src/components/MilestoneEditModal.js | 182 +++++--- .../PremiumOnboarding/CareerOnboarding.js | 8 +- .../PremiumOnboarding/CollegeOnboarding.js | 28 +- .../PremiumOnboarding/FinancialOnboarding.js | 4 +- .../PremiumOnboarding/OnboardingContainer.js | 11 +- src/components/ScenarioEditModal.js | 400 ++++++------------ src/components/SignUp.js | 121 ++++-- src/utils/FinancialProjectionService.js | 87 ++-- src/utils/getMissingFields.js | 12 +- src/utils/onboardingDraftApi.js | 68 ++- 16 files changed, 810 insertions(+), 514 deletions(-) diff --git a/.build.hash b/.build.hash index 7fc3581..716eaf2 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -3eefb2cd6c785e5815d042d108f67a87c6819a4d-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b +b632ad41cfb05900be9a667c396e66a4dfb26320-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/src/components/CareerCoach.js b/src/components/CareerCoach.js index 33a69bd..5d40440 100644 --- a/src/components/CareerCoach.js +++ b/src/components/CareerCoach.js @@ -1,8 +1,12 @@ import React, { useState, useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; import authFetch from "../utils/authFetch.js"; const isoToday = new Date().toISOString().slice(0,10); // top-level helper + + + async function ensureCoachThread() { // try to list an existing thread const r = await authFetch('/api/premium/coach/chat/threads'); @@ -21,6 +25,19 @@ async function ensureCoachThread() { return id; } +const isHiddenPrompt = (m) => { + if (!m || !m.content) return false; + const c = String(m.content); + // Heuristics that match your hidden prompts / modes + return ( + m.role === 'system' || + c.startsWith('# ⛔️') || + c.startsWith('MODE :') || + c.startsWith('MODE:') || + c.includes('"milestones"') && c.includes('"tasks"') && c.includes('"date"') && c.includes('"title"') + ); +}; + function buildInterviewPrompt(careerName, jobDescription = "") { return ` You are an expert interviewer for the role **${careerName}**. @@ -136,6 +153,7 @@ export default function CareerCoach({ onMilestonesCreated, onAiRiskFetched }) { + const location = useLocation(); /* -------------- state ---------------- */ const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -153,6 +171,45 @@ export default function CareerCoach({ if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight; }, [messages]); + // If career_name is still missing, fetch the profile once + useEffect(() => { + if (scenarioRow?.career_name || !careerProfileId) return; + let cancelled = false; + (async () => { + try { + const r = await authFetch(`/api/premium/career-profile/${careerProfileId}`); + if (!r.ok || cancelled) return; + const row = await r.json(); + if (!row?.career_name) return; + setScenarioRow(prev => ({ + ...prev, + career_name: prev?.career_name || row.career_name, + soc_code : prev?.soc_code || row.soc_code || '' + })); + } catch (_) {} + })(); + return () => { cancelled = true; }; + }, [careerProfileId, scenarioRow?.career_name, setScenarioRow]); + + // Hydrate career_name/soc_code from nav or localStorage if missing + useEffect(() => { + if (scenarioRow?.career_name) return; + let sel = null; + const navSel = + location.state?.selectedCareer ?? + location.state?.premiumOnboardingState?.selectedCareer ?? null; + if (navSel) sel = navSel; + else { + try { sel = JSON.parse(localStorage.getItem('selectedCareer') || 'null'); } catch {} + } + if (!sel) return; + setScenarioRow(prev => ({ + ...prev, + career_name: prev?.career_name || sel.title || 'this career', + soc_code : prev?.soc_code || sel.soc_code || sel.socCode || sel.code || '' + })); + }, [location.state, scenarioRow?.career_name, setScenarioRow]); + useEffect(() => { let cancelled = false; @@ -170,17 +227,24 @@ useEffect(() => { if (cancelled) return; if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) { - const data = await r3.json(); - const msgs = Array.isArray(data.messages) ? data.messages : []; - if (!cancelled) setMessages(msgs.length ? msgs : [generatePersonalizedIntro()]); - } else { - if (!cancelled) setMessages([generatePersonalizedIntro()]); - } + const data = await r3.json(); + const msgs = (Array.isArray(data.messages) ? data.messages : []).filter(m => !isHiddenPrompt(m)); + if (!cancelled) { + setMessages(msgs); // no intro here + historyLoadedRef.current = true; + } + } else { + if (!cancelled) { + setMessages([]); // no intro here + historyLoadedRef.current = true; + } + } } catch (e) { console.error("Coach thread preload failed:", e); if (!cancelled) { setThreadId(null); - setMessages([generatePersonalizedIntro()]); + setMessages([]); + historyLoadedRef.current = true; } } })(); @@ -191,9 +255,11 @@ useEffect(() => { /* -------------- intro ---------------- */ useEffect(() => { - if (!scenarioRow || !historyLoadedRef.current) return; + if (!historyLoadedRef.current) return; + if (!scenarioRow?.career_name) return; + if (!userProfile) return; // wait for profile (career_situation) setMessages(prev => (prev.length ? prev : [generatePersonalizedIntro()])); - }, [scenarioRow?.id]); + }, [historyLoadedRef.current, scenarioRow?.career_name, userProfile]); /* ---------- helpers you already had ---------- */ function buildStatusSituationMessage(status, situation, careerName) { @@ -240,7 +306,9 @@ We can refine details anytime or just jump straight to what you're most interest function generatePersonalizedIntro() { /* (unchanged body) */ - const careerName = scenarioRow?.career_name || "this career"; + const careerName = + scenarioRow?.career_name || + (() => { try { return (JSON.parse(localStorage.getItem('selectedCareer')||'null')?.title) || 'this career'; } catch { return 'this career'; } })(); const goalsText = scenarioRow?.career_goals?.trim() || null; const riskLevel = scenarioRow?.riskLevel; const riskReasoning = scenarioRow?.riskReasoning; @@ -407,7 +475,7 @@ I'm here to support you with personalized coaching. What would you like to focus className="overflow-y-auto border rounded mb-4 space-y-2" style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }} > - {messages.map((m, i) => ( + {messages.filter(m => !isHiddenPrompt(m)).map((m, i) => (
{ const [responses, setResponses] = useState({}); - useEffect(() => { - if (userProfile?.career_priorities) { - setResponses(JSON.parse(userProfile.career_priorities)); - } - }, [userProfile]); - - // Updated "interests" question: - const questions = [ - { - id: 'interests', + const QUESTIONS = [ + { id: 'interests', text: 'How important is it that your career aligns with your personal interests?', options: ['Very important', 'Somewhat important', 'Not as important'], }, - { - id: 'meaning', + { id: 'meaning', text: 'Is it important your job helps others or makes a difference?', options: ['Yes, very important', 'Somewhat important', 'Not as important'], }, - { - id: 'stability', + { id: 'stability', text: 'How important is it that your career pays well?', options: ['Very important', 'Somewhat important', 'Not as important'], }, - { - id: 'growth', + { id: 'growth', text: 'Do you want clear chances to advance and grow professionally?', options: ['Yes, very important', 'Somewhat important', 'Not as important'], }, - { - id: 'balance', + { id: 'balance', text: 'Do you prefer a job with flexible hours and time outside work?', options: ['Yes, very important', 'Somewhat important', 'Not as important'], }, - { - id: 'recognition', + { id: 'recognition', text: 'How important is it to have a career that others admire?', options: ['Very important', 'Somewhat important', 'Not as important'], }, ]; + // Map legacy keys -> current ids + const KEY_MAP = { + interests: 'interests', + impact: 'meaning', + meaning: 'meaning', + salary: 'stability', + pay: 'stability', + compensation: 'stability', + stability: 'stability', + advancement: 'growth', + growth: 'growth', + work_life_balance: 'balance', + worklife: 'balance', + balance: 'balance', + prestige: 'recognition', + recognition: 'recognition', + }; + + // Map legacy numeric scales (1–5) to current option strings + const numToLabel = (n) => { + const v = Number(n); + if (Number.isNaN(v)) return null; + if (v >= 4) return 'Very important'; + if (v === 3) return 'Somewhat important'; + return 'Not as important'; // 1–2 + }; + + const coerceToLabel = (val) => { + if (val == null) return null; + if (typeof val === 'number' || /^[0-9]+$/.test(String(val))) { + return numToLabel(val); + } + const s = String(val).trim(); + // Normalize a few common textual variants + if (/^very/i.test(s)) return 'Very important'; + if (/^some/i.test(s)) return 'Somewhat important'; + if (/^not/i.test(s)) return 'Not as important'; + if (/^yes/i.test(s)) return 'Yes, very important'; + return s; // assume already one of the options + }; + + const normalizePriorities = (raw) => { + const out = {}; + if (!raw || typeof raw !== 'object') return out; + for (const [k, v] of Object.entries(raw)) { + const id = KEY_MAP[k] || k; + const label = coerceToLabel(v); + if (label) out[id] = label; + } + return out; + }; + + useEffect(() => { + const cp = userProfile?.career_priorities; + if (!cp) return; + + let parsed; + try { + parsed = typeof cp === 'string' ? JSON.parse(cp) : cp; + } catch { + parsed = null; + } + const normalized = normalizePriorities(parsed); + // Only keep keys we actually ask + const allowed = QUESTIONS.reduce((acc, q) => { + if (normalized[q.id]) acc[q.id] = normalized[q.id]; + return acc; + }, {}); + setResponses(allowed); + }, [userProfile]); // eslint-disable-line react-hooks/exhaustive-deps + const handleSave = async () => { const payload = { firstName: userProfile.firstname, @@ -55,7 +113,7 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => { careerSituation: userProfile.career_situation || null, career_priorities: JSON.stringify(responses), }; - + try { await authFetch('/api/user-profile', { method: 'POST', @@ -68,29 +126,23 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => { } }; - const allAnswered = questions.every(q => responses[q.id]); + const allAnswered = QUESTIONS.every(q => responses[q.id]); return (

Tell us what's important to you

- {questions.map(q => ( + {QUESTIONS.map(q => (
@@ -99,9 +151,7 @@ const CareerPrioritiesModal = ({ userProfile, onClose }) => { diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index f66e0a8..51003fb 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -52,7 +52,7 @@ ChartJS.register( PointElement, Tooltip, Legend, - zoomPlugin, // πŸ‘ˆ ←–––– only if you kept the zoom config + zoomPlugin, annotationPlugin ); @@ -486,19 +486,30 @@ const zoomConfig = { zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' } }; +// Compute if any savings are negative to pick a sane baseline +const minSavings = projectionData.reduce((min, p) => { + const e = Number(p.emergencySavings ?? 0); + const r = Number(p.retirementSavings ?? 0); + const t = Number(p.totalSavings ?? 0); + return Math.min(min, e, r, t); +}, Infinity); +const hasNegSavings = Number.isFinite(minSavings) && minSavings < 0; + const xAndYScales = { - x: { - type: 'time', - time: { unit: 'month' }, - ticks: { maxRotation: 0, autoSkip: true } - }, - y: { - beginAtZero: true, - ticks: { - callback: (val) => val.toLocaleString() // comma-format big numbers - } - } -}; + x: { + type: 'time', + time: { unit: 'month' }, + ticks: { maxRotation: 0, autoSkip: true } + }, + y: { + beginAtZero: !hasNegSavings, + // give a little room below the smallest negative so the fill doesn't sit on the axis + min: hasNegSavings ? Math.floor(minSavings * 1.05) : undefined, + ticks: { + callback: (val) => val.toLocaleString() + } + } + }; /* ────────────────────────────────────────────────────────────── * ONE-TIME β€œMISSING FIELDS” GUARD @@ -1261,7 +1272,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day const emergencyData = { label: 'Emergency Savings', - data: projectionData.map((p) => p.emergencySavings), + data: projectionData.map((p) => Number(p.emergencySavings ?? 0)), borderColor: 'rgba(255, 159, 64, 1)', backgroundColor: 'rgba(255, 159, 64, 0.2)', tension: 0.4, @@ -1269,7 +1280,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day }; const retirementData = { label: 'Retirement Savings', - data: projectionData.map((p) => p.retirementSavings), + data: projectionData.map((p) => Number(p.retirementSavings ?? 0)), borderColor: 'rgba(75, 192, 192, 1)', backgroundColor: 'rgba(75, 192, 192, 0.2)', tension: 0.4, @@ -1277,7 +1288,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day }; const totalSavingsData = { label: 'Total Savings', - data: projectionData.map((p) => p.totalSavings), + data: projectionData.map((p) => Number(p.totalSavings ?? 0)), borderColor: 'rgba(54, 162, 235, 1)', backgroundColor: 'rgba(54, 162, 235, 0.2)', tension: 0.4, @@ -1285,7 +1296,7 @@ const DAILY_CLICK_LIMIT = 10; // example limit per day }; const loanBalanceData = { label: 'Loan Balance', - data: projectionData.map((p) => p.loanBalance), + data: projectionData.map((p) => Number(p.loanBalance ?? 0)), borderColor: 'rgba(255, 99, 132, 1)', backgroundColor: 'rgba(255, 99, 132, 0.2)', tension: 0.4, @@ -1510,7 +1521,7 @@ const handleMilestonesCreated = useCallback( {showMissingBanner && (

- To run your full projection, please add: + To improve your projection, please add:

{!!missingKeys.length && (
    diff --git a/src/components/CollegeProfileForm.js b/src/components/CollegeProfileForm.js index 716182f..03d4095 100644 --- a/src/components/CollegeProfileForm.js +++ b/src/components/CollegeProfileForm.js @@ -14,6 +14,12 @@ const parseFloatOrNull = v => { return Number.isFinite(n) ? n : null; }; +const fromSqlDate = (v) => { + if (!v) return ''; + // Accept "YYYY-MM-DD", "YYYY-MM-DD HH:MM:SS", or ISO; trim to date part + return String(v).slice(0, 10); +}; + function normalisePayload(draft) { const bools = [ 'is_in_state','is_in_district','is_online', @@ -122,15 +128,69 @@ const onProgramInput = (e) => { setProgSug([...new Set(sug)].slice(0, 10)); }; +// Prefill school suggestions when form loads or school changes +useEffect(() => { + const v = (form.selected_school || '').toLowerCase().trim(); + if (!v || !cipRows.length) { + setSchoolSug([]); + return; + } + const suggestions = cipRows + .filter(r => (r.INSTNM || '').toLowerCase().includes(v)) + .map(r => r.INSTNM); + setSchoolSug([...new Set(suggestions)].slice(0, 10)); +}, [form.selected_school, cipRows]); + +// Prefill program suggestions when form loads or program/school changes +useEffect(() => { + const sch = (form.selected_school || '').toLowerCase().trim(); + const q = (form.selected_program || '').toLowerCase().trim(); + if (!sch || !q || !cipRows.length) { + setProgSug([]); + return; + } + const sug = cipRows + .filter(r => + (r.INSTNM || '').toLowerCase() === sch && + (r.CIPDESC || '').toLowerCase().includes(q) + ) + .map(r => r.CIPDESC); + setProgSug([...new Set(sug)].slice(0, 10)); +}, [form.selected_school, form.selected_program, cipRows]); + useEffect(() => { if (id && id !== 'new') { (async () => { const r = await authFetch(`/api/premium/college-profile?careerProfileId=${careerId}`); - if (r.ok) setForm(await r.json()); - })(); + if (r.ok) { + const raw = await r.json(); + const normalized = { + ...raw, + enrollment_date : fromSqlDate(raw.enrollment_date), + expected_graduation : fromSqlDate(raw.expected_graduation), + is_in_state : !!raw.is_in_state, + is_in_district : !!raw.is_in_district, + is_online : !!raw.is_online, + loan_deferral_until_graduation : !!raw.loan_deferral_until_graduation, + }; + setForm(normalized); + if (normalized.tuition !== undefined && normalized.tuition !== null) { + setManualTuition(String(normalized.tuition)); + } + } + })(); + } + }, [careerId, id]); + +// 2) keep manualTuition aligned if form.tuition is updated elsewhere +useEffect(() => { + if (form.tuition !== undefined && form.tuition !== null) { + if (manualTuition.trim() === '') { + setManualTuition(String(form.tuition)); } - }, [careerId, id]); + } +}, [form.tuition]); async function handleSave(){ try{ @@ -245,6 +305,8 @@ const chosenTuition = (() => { ───────────────────────────────────────────────────────────── */ useEffect(() => { if (programLengthTouched) return; // user override + // if a program_length already exists (e.g., from API), don't overwrite it + if (form.program_length !== '' && form.program_length != null) return; // user override const chpy = parseFloat(form.credit_hours_per_year); if (!chpy || chpy <= 0) return; @@ -266,7 +328,7 @@ const chpy = parseFloat(form.credit_hours_per_year); if (creditsNeeded <= 0) return; /* 2 – yearsΒ =Β creditsΒ /Β CHPY β†’ one decimal place */ - const years = Math.ceil((creditsNeeded / chpy) * 10) / 10; + const years = Math.round((creditsNeeded / chpy) * 100) / 100; if (years !== form.program_length) { setForm(prev => ({ ...prev, program_length: years })); @@ -522,6 +584,7 @@ return ( { - const proceed = window.confirm( - 'You’re about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' - ); - if (!proceed) return; - // normalize selectedCareer and carry it forward - const sel = selectedCareer - ? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode } - : null; + // Replace your existing handleSelectSchool with this: +const handleSelectSchool = async (school) => { + const proceed = window.confirm( + 'You’re about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' + ); + if (!proceed) return; - navigate('/career-roadmap', { - state: { - premiumOnboardingState: { - selectedCareer: sel, // SOC-bearing career object - selectedSchool: school // school card just chosen - } - } - }); + // normalize the currently selected career for handoff (optional) + const sel = selectedCareer + ? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode } + : null; + + // 1) normalize college fields + const selected_school = school?.INSTNM || ''; + const selected_program = (school?.CIPDESC || '').replace(/\.\s*$/, ''); + const program_type = school?.CREDDESC || ''; + + // 2) merge into the cookie-backed draft (don’t clobber existing sections) + let draft = null; + try { draft = await loadDraft(); } catch (_) {} + const existing = draft?.data || {}; + + await saveDraft({ + id: draft?.id || null, + step: draft?.step ?? 0, + careerData: existing.careerData || {}, + financialData: existing.financialData || {}, + collegeData: { + ...(existing.collegeData || {}), + selected_school, + selected_program, + program_type, + }, + }); + + // 3) navigate (state is optional now that draft persists) + navigate('/career-roadmap', { + state: { + premiumOnboardingState: { + selectedCareer: sel, + selectedSchool: { + INSTNM: school.INSTNM, + CIPDESC: selected_program, + CREDDESC: program_type, + UNITID: school.UNITID ?? null, + }, + }, + }, + }); }; function getSearchLinks(ksaName, careerTitle) { @@ -738,7 +775,7 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({ school['INSTNM'] || 'Unnamed School' )} -

    Program: {school['CIPDESC'] || 'N/A'}

    +

    Program: {cleanCipDesc(school['CIPDESC'])}

    Degree Type: {school['CREDDESC'] || 'N/A'}

    In-State Tuition: ${school['In_state cost'] || 'N/A'}

    Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'}

    diff --git a/src/components/MilestoneEditModal.js b/src/components/MilestoneEditModal.js index 49fdd34..9c1c0d9 100644 --- a/src/components/MilestoneEditModal.js +++ b/src/components/MilestoneEditModal.js @@ -173,30 +173,60 @@ export default function MilestoneEditModal({ }); async function saveNew(){ - if(isSavingNew) return; - if(!newMilestone.title.trim()||!newMilestone.date.trim()){ - alert('Need title & date'); return; - } - setIsSavingNew(true); - const hdr = { title:newMilestone.title, description:newMilestone.description, - date:toSqlDate(newMilestone.date), career_profile_id:careerProfileId, - progress:newMilestone.progress, status:newMilestone.progress>=100?'completed':'planned', - is_universal:newMilestone.isUniversal }; - const res = await authFetch('/api/premium/milestone',{method:'POST', - headers:{'Content-Type':'application/json'},body:JSON.stringify(hdr)}); - const created = Array.isArray(await res.json())? (await res.json())[0]:await res.json(); - for(const imp of newMilestone.impacts){ - const body = { - milestone_id:created.id, impact_type:imp.impact_type, - direction:imp.impact_type==='salary'?'add':imp.direction, - amount:parseFloat(imp.amount)||0, start_date:imp.start_date||null, end_date:imp.end_date||null + if (isSavingNew) return; + if (!newMilestone.title.trim() || !newMilestone.date.trim()) { + alert('Need title & date'); return; + } + setIsSavingNew(true); + const toDate = (v) => (v ? String(v).slice(0,10) : null); + try { + const hdr = { + title: newMilestone.title, + description: newMilestone.description, + date: toDate(newMilestone.date), + career_profile_id: careerProfileId, + progress: newMilestone.progress, + status: newMilestone.progress >= 100 ? 'completed' : 'planned', + is_universal: newMilestone.isUniversal, + }; + const res = await authFetch('/api/premium/milestone', { + method: 'POST', + headers: { 'Content-Type':'application/json' }, + body: JSON.stringify(hdr) + }); + if (!res.ok) throw new Error(await res.text()); + const createdJson = await res.json(); + const created = Array.isArray(createdJson) ? createdJson[0] : createdJson; + if (!created || !created.id) throw new Error('Milestone create failed β€” no id returned'); + + // Save any non-empty impact rows + for (const imp of newMilestone.impacts) { + if (!imp) continue; + const hasAnyField = (imp.amount || imp.start_date || imp.end_date || imp.impact_type); + if (!hasAnyField) continue; + const ibody = { + milestone_id: created.id, + impact_type : imp.impact_type, + direction : imp.impact_type === 'salary' ? 'add' : imp.direction, + amount : parseFloat(imp.amount) || 0, + start_date : toDate(imp.start_date), + end_date : toDate(imp.end_date), }; - await authFetch('/api/premium/milestone-impacts',{ - method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + const ir = await authFetch('/api/premium/milestone-impacts', { + method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(ibody) + }); + if (!ir.ok) throw new Error(await ir.text()); } + await fetchMilestones(); setAddingNew(false); onClose(true); + } catch (err) { + console.error('saveNew:', err); + alert(err.message || 'Failed to save milestone'); + } finally { + setIsSavingNew(false); + } } /* ══════════════════════════════════════════════════════════════ */ @@ -204,7 +234,7 @@ export default function MilestoneEditModal({ /* ══════════════════════════════════════════════════════════════ */ return (
    -
    +
    {/* header */}
    @@ -217,7 +247,7 @@ export default function MilestoneEditModal({
    {/* body */} -
    +
    {/* EXISTING */} {milestones.map(m=>{ const open = editingId===m.id; @@ -272,7 +302,7 @@ export default function MilestoneEditModal({ {/* impacts */}
    -
    +

    Financial impacts

    @@ -282,7 +312,7 @@ export default function MilestoneEditModal({
    {d.impacts?.map((imp,idx)=>( -
    +
    {/* type */}
    @@ -296,21 +326,21 @@ export default function MilestoneEditModal({
    {/* direction – hide for salary */} - {imp.impact_type!=='salary' && ( -
    - - -
    - )} + {imp.impact_type !== 'salary' ? ( +
    + + +
    + ) : ( + // keep the grid column to prevent the next columns from collapsing +
    + )} {/* amount */} -
    - +
    +
    {/* dates */} -
    +
    updateImpact(m.id,idx,'start_date',e.target.value)} /> @@ -334,7 +364,7 @@ export default function MilestoneEditModal({ updateImpact(m.id,idx,'end_date',e.target.value)} /> @@ -345,7 +375,7 @@ export default function MilestoneEditModal({ +
    - {newMilestone.impacts.map((imp,idx)=>( -
    + {newMilestone.impacts.map((imp, idx) => ( +
    + {/* Type */}
    - {imp.impact_type!=='salary' && ( + + {/* Direction (spacer when salary) */} + {imp.impact_type !== 'salary' ? (
    + ) : ( +
    )} -
    + + {/* Amount (fixed width) */} +
    updateNewImpact(idx,'amount',e.target.value)} + onChange={(e) => updateNewImpact(idx, 'amount', e.target.value)} />
    -
    - updateNewImpact(idx,'start_date',e.target.value)} - /> - {imp.impact_type==='MONTHLY' && ( + + {/* Dates (flex) */} +
    +
    + updateNewImpact(idx,'end_date',e.target.value)} + className="input w-full min-w-[14ch] px-3" + value={imp.start_date} + onChange={(e) => updateNewImpact(idx, 'start_date', e.target.value)} /> +
    + {imp.impact_type === 'MONTHLY' && ( +
    + + updateNewImpact(idx, 'end_date', e.target.value)} + /> +
    )}
    + + {/* Remove */}
    ))} +
    {/* save row */} diff --git a/src/components/PremiumOnboarding/CareerOnboarding.js b/src/components/PremiumOnboarding/CareerOnboarding.js index 880a40c..ce8460a 100644 --- a/src/components/PremiumOnboarding/CareerOnboarding.js +++ b/src/components/PremiumOnboarding/CareerOnboarding.js @@ -79,9 +79,7 @@ function handleSubmit() { } } - const nextLabel = skipFin - ? inCollege ? 'College β†’' : 'Finish β†’' - : inCollege ? 'College β†’' : 'Financial β†’'; +const nextLabel = !skipFin ? 'Financial β†’' : (inCollege ? 'College β†’' : 'Finish β†’'); return (
    @@ -185,9 +183,9 @@ function handleSubmit() {
    - {/* Career Search */} -
    - - - {careerMatches.length > 0 && ( -
      - {careerMatches.map((c, idx) => ( -
    • handleSelectCareer(c)} - > - {c} -
    • - ))} -
    - )} -

    - Current Career: {formData.career_name || '(none)'} -

    -
    + {/* Career Search (shared component) */} +
    + + { + if (!found) return; + setFormData(prev => ({ ...prev, career_name: found.title || prev.career_name || '' })); + }}/> +

    + Selected Career: {formData.career_name || '(none)'} +

    +
    {/* Status */}
    @@ -937,13 +827,10 @@ if (formData.retirement_start_date) { max-h-48 overflow-auto mt-1 " > - {schoolSuggestions.map((sch, i) => ( -
  • handleSchoolSelect(sch)} - > - {sch} + {schoolSuggestions.map((sch, i) => ( +
  • handleSchoolSelect(sch)}> + {sch.name}
  • ))}
@@ -1216,11 +1103,6 @@ if (formData.retirement_start_date) { - {!formData.retirement_start_date && ( -

- Pick a Planned Retirement Date to run the simulation. -

- )}
{/* Show a preview if we have simulation data */} diff --git a/src/components/SignUp.js b/src/components/SignUp.js index 964d461..37b5df6 100644 --- a/src/components/SignUp.js +++ b/src/components/SignUp.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button } from './ui/button.js'; import SituationCard from './ui/SituationCard.js'; @@ -54,11 +54,16 @@ function SignUp() { const [zipcode, setZipcode] = useState(''); const [state, setState] = useState(''); const [area, setArea] = useState(''); - const [areas, setAreas] = useState([]); const [error, setError] = useState(''); const [loadingAreas, setLoadingAreas] = useState(false); const [phone, setPhone] = useState('+1'); - const [optIn, setOptIn] = useState(false); + const [optIn, setOptIn] = useState(false); + const [areas, setAreas] = useState([]); + const [areasErr, setAreasErr] = useState(''); + const areasCacheRef = useRef(new Map()); // cache: stateCode -> areas[] + const debounceRef = useRef(null); // debounce timer + const inflightRef = useRef(null); // AbortController for in-flight + const [showCareerSituations, setShowCareerSituations] = useState(false); const [selectedSituation, setSelectedSituation] = useState(null); @@ -235,6 +240,60 @@ const handleSituationConfirm = async () => { } }; +useEffect(() => { + // reset UI + setAreasErr(''); + if (!state) { setAreas([]); return; } + + // cached? instant + if (areasCacheRef.current.has(state)) { + setAreas(areasCacheRef.current.get(state)); + return; + } + + // debounce to avoid rapid refetch on quick clicks + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + // cancel previous request if any + if (inflightRef.current) inflightRef.current.abort(); + const controller = new AbortController(); + inflightRef.current = controller; + + setLoadingAreas(true); + try { + // client-side timeout race (6s) + const timeout = new Promise((_, rej) => + setTimeout(() => rej(new Error('timeout')), 6000) + ); + + const res = await Promise.race([ + fetch(`/api/areas?state=${encodeURIComponent(state)}`, { + signal: controller.signal, + }), + timeout, + ]); + + if (!res || !res.ok) throw new Error('bad_response'); + const data = await res.json(); + + // normalize, uniq, sort for UX + const list = Array.from(new Set((data.areas || []).filter(Boolean))).sort(); + areasCacheRef.current.set(state, list); // cache it + setAreas(list); + } catch (err) { + if (err.name === 'AbortError') return; // superseded by a newer request + setAreas([]); + setAreasErr('Could not load Areas. You can proceed without selecting one.'); + } finally { + if (inflightRef.current === controller) inflightRef.current = null; + setLoadingAreas(false); + } + }, 250); // 250ms debounce + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [state]); return (
@@ -341,35 +400,37 @@ return (
- + setArea(e.target.value)} - disabled={loadingAreas} - > - - {areas.map((a, i) => ( - - ))} - - {loadingAreas && ( - - Loading... - - )} + {areas.map((a, i) => ( + + ))} + + + {loadingAreas && ( + + Loading... + + )} + {areasErr && ( +

{areasErr}

+ )}