// src/pages/premium/OnboardingContainer.js import React, { useState, useEffect, useRef } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import PremiumWelcome from './PremiumWelcome.js'; import CareerOnboarding from './CareerOnboarding.js'; import FinancialOnboarding from './FinancialOnboarding.js'; import CollegeOnboarding from './CollegeOnboarding.js'; import ReviewPage from './ReviewPage.js'; import { loadDraft, saveDraft, clearDraft } from '../../utils/onboardingDraftApi.js'; import authFetch from '../../utils/authFetch.js'; import { isOnboardingInProgress } from '../../utils/onboardingGuard.js'; const POINTER_KEY = 'premiumOnboardingPointer'; export default function OnboardingContainer() { const navigate = useNavigate(); const location = useLocation(); const [step, setStep] = useState(0); const [careerData, setCareerData] = useState({}); const [financialData, setFinancialData] = useState({}); const [collegeData, setCollegeData] = useState({}); const [loaded, setLoaded] = useState(false); // pointer (safe to store) const ptrRef = useRef({ id: null, step: 0, skipFin: false, selectedCareer: null }); // ---- 1) one-time load/migrate & hydrate ----------------------- useEffect(() => { (async () => { // A) migrate any old local blob (once), then delete it // B) load pointer try { const pointer = JSON.parse(localStorage.getItem(POINTER_KEY) || 'null') || {}; ptrRef.current = { id: pointer.id || null, step: Number.isInteger(pointer.step) ? pointer.step : 0, skipFin: !!pointer.skipFin, selectedCareer: pointer.selectedCareer || JSON.parse(localStorage.getItem('selectedCareer') || 'null') }; } catch { /* ignore */ } // C) fetch draft from server (source of truth) const draft = await loadDraft(); if (draft) { ptrRef.current.id = draft.id; // ensure we have it setStep(draft.step ?? ptrRef.current.step ?? 0); const d = draft.data || {}; setCareerData(d.careerData || {}); setFinancialData(d.financialData || {}); setCollegeData(d.collegeData || {}); // 🔒 Prime autosave baselines so we DON'T post empty slices try { prevCareerJsonRef.current = JSON.stringify(d.careerData || {}); prevFinancialJsonRef.current = JSON.stringify(d.financialData || {}); prevCollegeJsonRef.current = JSON.stringify(d.collegeData || {}); } catch {} } else { // no server draft yet: seed with minimal data from pointer/local selectedCareer setStep(ptrRef.current.step || 0); if (ptrRef.current.selectedCareer?.title) { setCareerData(cd => ({ ...cd, career_name: ptrRef.current.selectedCareer.title, soc_code: ptrRef.current.selectedCareer.soc_code || '' })); } } // D) pick up any navigation state (e.g., selectedSchool) const navSchool = location.state?.selectedSchool ?? location.state?.premiumOnboardingState?.selectedSchool; if (navSchool) { setCollegeData(cd => ({ ...cd, selected_school : typeof navSchool === 'string' ? navSchool : (navSchool.INSTNM || ''), selected_program: typeof navSchool === 'object' ? (navSchool.CIPDESC || cd.selected_program || '') : cd.selected_program, program_type : typeof navSchool === 'object' ? (navSchool.CREDDESC || cd.program_type || '') : cd.program_type, })); // keep baseline in sync so autosave doesn't blast empty collegeData try { const merged = { ...(draft?.data?.collegeData || {}), selected_school : typeof navSchool === 'string' ? navSchool : (navSchool.INSTNM || ''), selected_program: typeof navSchool === 'object' ? (navSchool.CIPDESC || '') : '', program_type : typeof navSchool === 'object' ? (navSchool.CREDDESC || '') : '', }; prevCollegeJsonRef.current = JSON.stringify(merged); } catch {} } setLoaded(true); })(); }, [location.state]); // ---- 2) debounced autosave — send only changed slices ---------- const prevCareerJsonRef = useRef(''); const prevFinancialJsonRef = useRef(''); const prevCollegeJsonRef = useRef(''); useEffect(() => { if (!loaded) return; const t = setTimeout(async () => { const cj = JSON.stringify(careerData); const fj = JSON.stringify(financialData); const col = JSON.stringify(collegeData); const nonEmpty = (s) => s && s !== '{}' && s !== 'null'; const changedCareer = cj !== prevCareerJsonRef.current && nonEmpty(cj); const changedFinancial = fj !== prevFinancialJsonRef.current && nonEmpty(fj); const changedCollege = col !== prevCollegeJsonRef.current && nonEmpty(col); const somethingChanged = changedCareer || changedFinancial || changedCollege; // Always update the local pointer, but DO NOT POST an empty data object. const pointer = { id: ptrRef.current.id, step, skipFin: !!careerData.skipFinancialStep, selectedCareer: (careerData.career_name || careerData.soc_code) ? { title: careerData.career_name, soc_code: careerData.soc_code } : JSON.parse(localStorage.getItem('selectedCareer') || 'null'), }; ptrRef.current = pointer; localStorage.setItem(POINTER_KEY, JSON.stringify(pointer)); if (!somethingChanged) return; // ← prevent the `{ data:{} }` POST // Build a payload that includes only changed slices const payload = { id: ptrRef.current.id, step, data: {} }; if (changedCareer) payload.data.careerData = careerData; if (changedFinancial) payload.data.financialData = financialData; if (changedCollege) payload.data.collegeData = collegeData; const resp = await saveDraft(payload); // update baselines only for the slices we actually sent if (changedCareer) prevCareerJsonRef.current = cj; if (changedFinancial) prevFinancialJsonRef.current = fj; if (changedCollege) prevCollegeJsonRef.current = col; // keep pointer id in sync with server ptrRef.current.id = resp.id; }, 400); return () => clearTimeout(t); }, [loaded, step, careerData, financialData, collegeData]); // ---- nav helpers ------------------------------------------------ const nextStep = () => setStep((s) => s + 1); const prevStep = () => setStep((s) => Math.max(0, s - 1)); // Steps: Welcome, Career, (Financial?), College, Review => 4 or 5 total const finishImmediately = () => setStep(skipFin ? 3 : 4); // ---- final submit (unchanged + cleanup) ------------------------- async function handleFinalSubmit() { try { // 1) scenario upsert const scenarioPayload = { ...careerData, id: careerData.career_profile_id || undefined }; const scenarioRes = await authFetch('/api/premium/career-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scenarioPayload) }); if (!scenarioRes || !scenarioRes.ok) throw new Error('Failed to save (or update) career profile'); const { career_profile_id: finalId } = await scenarioRes.json(); if (!finalId) throw new Error('No career_profile_id returned by server'); // 2) financial profile const finRes = await authFetch('/api/premium/financial-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(financialData) }); if (!finRes || !finRes.ok) throw new Error('Failed to save financial profile'); // 3) college profile (conditional) if (['currently_enrolled','prospective_student'].includes(careerData.college_enrollment_status)) { const merged = { ...collegeData, career_profile_id: finalId, college_enrollment_status: careerData.college_enrollment_status, is_in_state: !!collegeData.is_in_state, is_in_district: !!collegeData.is_in_district, is_online: !!collegeData.is_online, loan_deferral_until_graduation: !!collegeData.loan_deferral_until_graduation }; // numeric normalization (your existing parse rules apply) const nums = ['existing_college_debt','extra_payment','tuition','tuition_paid','interest_rate', 'loan_term','credit_hours_per_year','credit_hours_required','hours_completed', 'program_length','expected_salary','annual_financial_aid']; nums.forEach(k => { const n = Number(merged[k]); merged[k] = Number.isFinite(n) ? n : 0; }); const colRes = await authFetch('/api/premium/college-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(merged) }); if (!colRes || !colRes.ok) throw new Error('Failed to save college profile'); } // 4) UX handoff + cleanup const picked = { code: careerData.soc_code, title: careerData.career_name }; sessionStorage.setItem('skipMissingModalFor', String(finalId)); localStorage.setItem('selectedCareer', JSON.stringify(picked)); localStorage.removeItem('lastSelectedCareerProfileId'); // 🔐 cleanup: remove server draft + pointer await clearDraft(); localStorage.removeItem(POINTER_KEY); sessionStorage.setItem('suppressOnboardingGuard', '1'); navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } }); } catch (err) { console.error('Error in final submit =>', err); alert(err.message || 'Failed to finalize onboarding.'); } } const skipFin = !!careerData.skipFinancialStep; const steps = [ , , ...(!skipFin ? [ ] : []), , ]; const safeIndex = Math.min(step, steps.length - 1); return
{loaded ? steps[safeIndex] : null}
; }