UI updates for premium routes

This commit is contained in:
Josh 2025-05-01 16:50:18 +00:00
parent 9404135915
commit ce53afb3d1
8 changed files with 258 additions and 251 deletions

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import PremiumRoute from './components/PremiumRoute.js';
import SessionExpiredHandler from './components/SessionExpiredHandler.js';
@ -19,23 +19,69 @@ function App() {
const location = useLocation();
// Track whether user is authenticated
const [isAuthenticated, setIsAuthenticated] = useState(() => {
return !!localStorage.getItem('token');
});
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Track the user object, including is_premium
const [user, setUser] = useState(null);
// We hide the "Upgrade to Premium" CTA if we're already on certain premium routes
// We might also track a "loading" state so we don't redirect prematurely
const [isLoading, setIsLoading] = useState(true);
// Hide the "Upgrade to Premium" CTA on certain premium routes
const premiumPaths = [
'/milestone-tracker',
'/paywall',
'/financial-profile',
'/multi-scenario',
'/premium-onboarding'
'/premium-onboarding',
];
const showPremiumCTA = !premiumPaths.includes(location.pathname);
// On first mount, rehydrate user if there's a token
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
setIsLoading(false);
return; // No token => not authenticated
}
// If we have a token, let's validate it and fetch user data
fetch('https://dev1.aptivaai.com/api/user-profile', {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((res) => {
if (!res.ok) {
// e.g. 401 means token invalid/expired
throw new Error('Token invalid or expired');
}
return res.json();
})
.then((profile) => {
// We have a valid user profile -> set states
setUser(profile);
setIsAuthenticated(true);
})
.catch((err) => {
console.error(err);
// If invalid, remove the token to avoid loops
localStorage.removeItem('token');
})
.finally(() => {
setIsLoading(false);
});
}, []);
if (isLoading) {
// Optionally show a loading spinner or placeholder
return (
<div className="flex h-screen items-center justify-center">
<p>Loading...</p>
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
{/* Header */}
@ -65,13 +111,13 @@ function App() {
element={
<SignIn
setIsAuthenticated={setIsAuthenticated}
setUser={setUser} // We pass setUser so SignIn can store user details
setUser={setUser}
/>
}
/>
<Route path="/signup" element={<SignUp />} />
{/* Paywall - open to everyone for subscription */}
{/* Paywall (accessible to all) */}
<Route path="/paywall" element={<Paywall />} />
{/* Authenticated routes */}

View File

@ -1,72 +1,85 @@
// src/components/AISuggestedMilestones.js
import React, { useEffect, useState } from 'react';
import { Button } from './ui/button.js';
const AISuggestedMilestones = ({ userId, career, careerPathId, authFetch, activeView, projectionData }) => {
const [suggestedMilestones, setSuggestedMilestones] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
// Show a warning if projectionData is not an array
useEffect(() => {
if (!Array.isArray(projectionData)) {
console.warn('⚠️ projectionData is not an array:', projectionData);
return;
}
console.log('📊 projectionData sample:', projectionData.slice(0, 3));
}, [projectionData]);
// Generate AI-based suggestions from projectionData
useEffect(() => {
if (!career || !Array.isArray(projectionData)) return;
// Dynamically suggest milestones based on projection data
const suggested = [];
// Find salary or savings growth points from projectionData:
// Example: if retirement crosses $50k or if loans are paid off, we suggest a milestone
projectionData.forEach((monthData, index) => {
if (index === 0) return; // Skip first month for comparison
if (index === 0) return;
const prevMonth = projectionData[index - 1];
// Example logic: suggest milestones when retirement savings hit certain thresholds
if (monthData.totalRetirementSavings >= 50000 && prevMonth.totalRetirementSavings < 50000) {
// Retirement crossing 50k
if (
monthData.totalRetirementSavings >= 50000 &&
prevMonth.totalRetirementSavings < 50000
) {
suggested.push({
title: `Reach $50k Retirement Savings`,
date: monthData.month + '-01',
progress: 0,
progress: 0
});
}
// Milestone when loan is paid off
// Loan paid off
if (monthData.loanBalance <= 0 && prevMonth.loanBalance > 0) {
suggested.push({
title: `Student Loans Paid Off`,
date: monthData.month + '-01',
progress: 0,
progress: 0
});
}
});
// Career-based suggestions still possible (add explicitly if desired)
// Career-based suggestions
suggested.push(
{ title: `Entry-Level ${career}`, date: projectionData[6]?.month + '-01' || '2025-06-01', progress: 0 },
{ title: `Mid-Level ${career}`, date: projectionData[24]?.month + '-01' || '2027-01-01', progress: 0 },
{ title: `Senior-Level ${career}`, date: projectionData[60]?.month + '-01' || '2030-01-01', progress: 0 }
{
title: `Entry-Level ${career}`,
date: projectionData[6]?.month + '-01' || '2025-06-01',
progress: 0
},
{
title: `Mid-Level ${career}`,
date: projectionData[24]?.month + '-01' || '2027-01-01',
progress: 0
},
{
title: `Senior-Level ${career}`,
date: projectionData[60]?.month + '-01' || '2030-01-01',
progress: 0
}
);
setSuggestedMilestones(suggested);
setSelected([]);
}, [career, projectionData]);
// Toggle selection of a milestone
const toggleSelect = (index) => {
setSelected(prev =>
prev.includes(index) ? prev.filter(i => i !== index) : [...prev, index]
setSelected((prev) =>
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
);
};
// Confirm the selected items => POST them as new milestones
const confirmSelectedMilestones = async () => {
const milestonesToSend = selected.map(index => {
const milestonesToSend = selected.map((index) => {
const m = suggestedMilestones[index];
return {
title: m.title,
@ -74,30 +87,30 @@ const AISuggestedMilestones = ({ userId, career, careerPathId, authFetch, active
date: m.date,
progress: m.progress,
milestone_type: activeView || 'Career',
career_path_id: careerPathId,
career_path_id: careerPathId
};
});
try {
setLoading(true);
const res = await authFetch(`/api/premium/milestone`, {
method: 'POST',
body: JSON.stringify({ milestones: milestonesToSend }),
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error('Failed to save selected milestones');
const data = await res.json();
console.log('Confirmed milestones:', data);
setSelected([]); // Clear selection
window.location.reload();
window.location.reload(); // Re-fetch or reload as needed
} catch (error) {
console.error('Error saving selected milestones:', error);
} finally {
setLoading(false);
}
};
if (!suggestedMilestones.length) return null;
@ -116,9 +129,12 @@ const AISuggestedMilestones = ({ userId, career, careerPathId, authFetch, active
</li>
))}
</ul>
<button onClick={confirmSelectedMilestones} disabled={loading || selected.length === 0}>
<Button
onClick={confirmSelectedMilestones}
disabled={loading || selected.length === 0}
>
{loading ? 'Saving...' : 'Confirm Selected'}
</button>
</Button>
</div>
);
};

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Button } from './ui/button.js';
const CareerSearch = ({ onCareerSelected }) => {
const [careerObjects, setCareerObjects] = useState([]);
@ -24,38 +25,30 @@ const CareerSearch = ({ onCareerSelected }) => {
for (let k = 0; k < careersList.length; k++) {
const c = careersList[k];
// If there's a title and soc_code, store the first we encounter for that title.
if (c.title && c.soc_code && c.cip_code !== undefined) {
if (!uniqueByTitle.has(c.title)) {
// Add it if we haven't seen this exact title yet
uniqueByTitle.set(c.title, {
title: c.title,
soc_code: c.soc_code,
cip_code: c.cip_code,
cip_code: c.cip_code
});
}
// If you truly only want to keep the first occurrence,
// just do nothing if we see the same title again.
}
}
}
}
// Convert Map to array
const dedupedArr = [...uniqueByTitle.values()];
setCareerObjects(dedupedArr);
} catch (error) {
console.error('Error loading or parsing career_clusters.json:', error);
}
};
fetchCareerData();
}, []);
// Called when user clicks "Confirm New Career"
const handleConfirmCareer = () => {
// Find the full object by exact title match
// find the full object by exact title match
const foundObj = careerObjects.find(
(obj) => obj.title.toLowerCase() === searchInput.toLowerCase()
);
@ -83,9 +76,9 @@ const CareerSearch = ({ onCareerSelected }) => {
))}
</datalist>
<button onClick={handleConfirmCareer} style={{ marginLeft: '8px' }}>
<Button onClick={handleConfirmCareer} style={{ marginLeft: '8px' }}>
Confirm
</button>
</Button>
</div>
);
};

View File

@ -1,5 +1,5 @@
// src/components/MilestoneTimeline.js
import React, { useEffect, useState, useCallback } from 'react';
import { Button } from './ui/button.js';
const today = new Date();
@ -12,7 +12,6 @@ export default function MilestoneTimeline({
}) {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
// "new or edit" milestone form data
const [newMilestone, setNewMilestone] = useState({
title: '',
description: '',
@ -23,7 +22,6 @@ export default function MilestoneTimeline({
isUniversal: 0
});
const [impactsToDelete, setImpactsToDelete] = useState([]);
const [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null);
@ -36,33 +34,23 @@ export default function MilestoneTimeline({
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
// ------------------------------------------------------------------
// 1) HELPER FUNCTIONS (defined above usage)
// 1) HELPER: Add or remove an impact from newMilestone
// ------------------------------------------------------------------
// Insert a new blank impact
function addNewImpact() {
setNewMilestone((prev) => ({
...prev,
impacts: [
...prev.impacts,
{
impact_type: 'ONE_TIME',
direction: 'subtract',
amount: 0,
start_date: '',
end_date: ''
}
{ impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }
]
}));
}
// Remove an impact from newMilestone.impacts
function removeImpact(idx) {
setNewMilestone((prev) => {
const newImpacts = [...prev.impacts];
const removed = newImpacts[idx];
if (removed && removed.id) {
// queue for DB DELETE
setImpactsToDelete((old) => [...old, removed.id]);
}
newImpacts.splice(idx, 1);
@ -70,7 +58,6 @@ export default function MilestoneTimeline({
});
}
// Update a specific impact property
function updateImpact(idx, field, value) {
setNewMilestone((prev) => {
const newImpacts = [...prev.impacts];
@ -92,10 +79,9 @@ export default function MilestoneTimeline({
}
const data = await res.json();
if (!data.milestones) {
console.warn('No milestones field in response:', data);
console.warn('No milestones in response:', data);
return;
}
const categorized = { Career: [], Financial: [] };
data.milestones.forEach((m) => {
if (categorized[m.milestone_type]) {
@ -104,7 +90,6 @@ export default function MilestoneTimeline({
console.warn(`Unknown milestone type: ${m.milestone_type}`);
}
});
setMilestones(categorized);
} catch (err) {
console.error('Failed to fetch milestones:', err);
@ -116,7 +101,7 @@ export default function MilestoneTimeline({
}, [fetchMilestones]);
// ------------------------------------------------------------------
// 3) Load scenarios for copy wizard
// 3) Load Scenarios for copy wizard
// ------------------------------------------------------------------
useEffect(() => {
async function loadScenarios() {
@ -139,7 +124,6 @@ export default function MilestoneTimeline({
async function handleEditMilestone(m) {
try {
setImpactsToDelete([]);
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
if (!res.ok) {
console.error('Failed to fetch milestone impacts. Status:', res.status);
@ -270,7 +254,7 @@ export default function MilestoneTimeline({
}
}
// Optionally re-fetch or update local
// Re-fetch or update local
await fetchMilestones();
// reset form
@ -360,7 +344,6 @@ export default function MilestoneTimeline({
// Brute force page refresh
window.location.reload();
onClose();
} catch (err) {
console.error('Error copying milestone:', err);
@ -371,7 +354,9 @@ export default function MilestoneTimeline({
<div className="modal-backdrop">
<div className="modal-container">
<h3>Copy Milestone to Other Scenarios</h3>
<p>Milestone: <strong>{milestone.title}</strong></p>
<p>
Milestone: <strong>{milestone.title}</strong>
</p>
{scenarios.map((s) => (
<div key={s.id}>
@ -387,8 +372,10 @@ export default function MilestoneTimeline({
))}
<div style={{ marginTop: '1rem' }}>
<button onClick={onClose} style={{ marginRight: '0.5rem' }}>Cancel</button>
<button onClick={handleCopy}>Copy</button>
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
Cancel
</Button>
<Button onClick={handleCopy}>Copy</Button>
</div>
</div>
</div>
@ -425,17 +412,16 @@ export default function MilestoneTimeline({
// normal => single scenario
await deleteSingleMilestone(m);
}
// done => brute force
window.location.reload();
}
async function deleteSingleMilestone(m) {
try {
const delRes = await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' });
const delRes = await authFetch(`/api/premium/milestones/${m.id}`, {
method: 'DELETE'
});
if (!delRes.ok) {
console.error('Failed to delete single milestone:', delRes.status);
return;
}
} catch (err) {
console.error('Error removing milestone from scenario:', err);
@ -464,17 +450,17 @@ export default function MilestoneTimeline({
<div className="milestone-timeline">
<div className="view-selector">
{['Career', 'Financial'].map((view) => (
<button
<Button
key={view}
className={activeView === view ? 'active' : ''}
onClick={() => setActiveView(view)}
>
{view}
</button>
</Button>
))}
</div>
<button
<Button
onClick={() => {
if (showForm) {
// Cancel form
@ -496,11 +482,11 @@ export default function MilestoneTimeline({
}}
>
{showForm ? 'Cancel' : '+ New Milestone'}
</button>
</Button>
{/* CREATE/EDIT FORM */}
{showForm && (
<div className="form">
{/* Title / Desc / Date / Progress */}
<input
type="text"
placeholder="Title"
@ -515,11 +501,8 @@ export default function MilestoneTimeline({
/>
<input
type="date"
placeholder="Milestone Date"
value={newMilestone.date}
onChange={(e) =>
setNewMilestone((prev) => ({ ...prev, date: e.target.value }))
}
onChange={(e) => setNewMilestone({ ...newMilestone, date: e.target.value })}
/>
<input
type="number"
@ -531,7 +514,6 @@ export default function MilestoneTimeline({
}}
/>
{/* If Financial => newSalary + impacts */}
{activeView === 'Financial' && (
<div>
<input
@ -587,6 +569,7 @@ export default function MilestoneTimeline({
onChange={(e) => updateImpact(idx, 'start_date', e.target.value)}
/>
</div>
{imp.impact_type === 'MONTHLY' && (
<div>
<label>End Date (blank if indefinite): </label>
@ -598,18 +581,14 @@ export default function MilestoneTimeline({
</div>
)}
<button
className="text-red-500 mt-2"
onClick={() => removeImpact(idx)}
>
<Button className="text-red-500 mt-2" onClick={() => removeImpact(idx)}>
Remove Impact
</button>
</Button>
</div>
))}
<button onClick={addNewImpact} className="bg-gray-200 px-2 py-1 mt-2">
<Button onClick={addNewImpact} className="bg-gray-200 px-2 py-1 mt-2">
+ Add Impact
</button>
</Button>
</div>
</div>
)}
@ -631,16 +610,15 @@ export default function MilestoneTimeline({
</label>
</div>
<button onClick={saveMilestone} style={{ marginTop: '1rem' }}>
<Button onClick={saveMilestone} style={{ marginTop: '1rem' }}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</button>
</Button>
</div>
)}
{/* Actual timeline */}
{/* TIMELINE VISUAL */}
<div className="milestone-timeline-container">
<div className="milestone-timeline-line" />
{milestones[activeView].map((m) => {
const leftPos = calcPosition(m.date);
return (
@ -649,10 +627,7 @@ export default function MilestoneTimeline({
className="milestone-timeline-post"
style={{ left: `${leftPos}%` }}
>
<div
className="milestone-timeline-dot"
onClick={() => handleEditMilestone(m)}
/>
<div className="milestone-timeline-dot" onClick={() => handleEditMilestone(m)} />
<div className="milestone-content">
<div className="title">{m.title}</div>
{m.description && <p>{m.description}</p>}
@ -662,6 +637,7 @@ export default function MilestoneTimeline({
</div>
<div className="date">{m.date}</div>
{/* Tasks */}
{m.tasks && m.tasks.length > 0 && (
<ul>
{m.tasks.map((t) => (
@ -674,29 +650,26 @@ export default function MilestoneTimeline({
</ul>
)}
<button
<Button
onClick={() => {
setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' });
}}
>
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</button>
</Button>
<div style={{ marginTop: '0.5rem' }}>
<button onClick={() => handleEditMilestone(m)}>Edit</button>
<button
style={{ marginLeft: '0.5rem' }}
onClick={() => setCopyWizardMilestone(m)}
>
<Button onClick={() => handleEditMilestone(m)}>Edit</Button>
<Button style={{ marginLeft: '0.5rem' }} onClick={() => setCopyWizardMilestone(m)}>
Copy
</button>
<button
style={{ marginLeft: '0.5rem', color: 'red' }}
</Button>
<Button
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
onClick={() => handleDeleteMilestone(m)}
>
Delete
</button>
</Button>
</div>
{showTaskForm === m.id && (
@ -718,7 +691,7 @@ export default function MilestoneTimeline({
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
/>
<button onClick={() => addTask(m.id)}>Save Task</button>
<Button onClick={() => addTask(m.id)}>Save Task</Button>
</div>
)}
</div>

View File

@ -21,17 +21,9 @@
margin-bottom: 20px;
}
.view-selector button {
padding: 10px 15px;
border: none;
cursor: pointer;
background: #4caf50;
color: white;
border-radius: 5px;
}
.view-selector button.active {
background: #2e7d32;
background: #0703e2;
}
.timeline-container {

View File

@ -14,7 +14,7 @@ import {
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { Filler } from 'chart.js';
import { Button } from './ui/button.js';
import authFetch from '../utils/authFetch.js';
import CareerSelectDropdown from './CareerSelectDropdown.js';
import CareerSearch from './CareerSearch.js';
@ -520,7 +520,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
{/* Confirm new career scenario */}
{pendingCareerForModal && (
<button
<Button
onClick={() => {
// Example action
console.log('User confirmed new career path:', pendingCareerForModal);
@ -529,7 +529,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
className="bg-blue-500 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
>
Confirm Career Change to {pendingCareerForModal}
</button>
</Button>
)}
</div>
);

View File

@ -3,6 +3,7 @@
import React, { useEffect, useState } from 'react';
import authFetch from '../utils/authFetch.js';
import ScenarioContainer from './ScenarioContainer.js';
import { Button } from './ui/button.js';
/**
* MultiScenarioView
@ -248,19 +249,11 @@ export default function MultiScenarioView() {
{/* Add Scenario button */}
<div style={{ alignSelf: 'flex-start' }}>
<button
<Button
onClick={handleAddScenario}
style={{
padding: '0.5rem 1rem',
height: 'auto',
backgroundColor: '#76b900',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
+ Add Scenario
</button>
</Button>
</div>
</div>
);

View File

@ -1,48 +1,45 @@
// src/components/ScenarioContainer.js
import React, { useState, useEffect, useCallback } from 'react';
import { Line } from 'react-chartjs-2';
import { Chart as ChartJS } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { Button } from './ui/button.js'; // <-- Universal Button
import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import AISuggestedMilestones from './AISuggestedMilestones.js';
import ScenarioEditModal from './ScenarioEditModal.js';
// Register the annotation plugin (though we won't use it for milestone markers).
// Register the annotation plugin
ChartJS.register(annotationPlugin);
/**
* ScenarioContainer
* -----------------
* This component:
* - Renders a <select> to pick from all scenarios, or uses the 'scenario' prop if set
* - Fetches the collegeProfile for that scenario
* - Fetches all milestones, plus their tasks and impacts
* - Builds a "mergedProfile" like in single-scenario code, including:
* -- Overridden fields from the scenario
* -- The user's global financialProfile
* -- milestoneImpacts from all milestones
* -- The "inCollege" flag, etc.
* - Calls simulateFinancialProjection(mergedProfile)
* - Displays the results (chart, loan payoff, final retirement)
* - Provides Milestone CRUD
* - "Clone" / "Remove" scenario methods are delegated to parent
* 1) Lets the user pick a scenario (via <select>), or uses the provided `scenario` prop.
* 2) Loads the collegeProfile + milestones/impacts for that scenario.
* 3) Merges scenario + user financial data + milestone impacts runs `simulateFinancialProjection`.
* 4) Shows a chart of net savings / retirement / loan balances over time.
* 5) Allows milestone CRUD (create, edit, delete, copy).
* 6) Offers Clone / Delete scenario callbacks from the parent.
*/
export default function ScenarioContainer({
scenario, // The scenario row from career_paths
financialProfile, // The users overall financial snapshot
onRemove,
onClone,
onEdit
scenario, // The scenario row from career_paths
financialProfile, // The users overall financial snapshot
onRemove, // Callback for deleting scenario
onClone, // Callback for cloning scenario
onEdit // (Optional) If you want a scenario editing callback
}) {
/*************************************************************
* 1) SCENARIO DROPDOWN: allScenarios, localScenario, etc.
* 1) SCENARIO DROPDOWN: Load, store, and let user pick
*************************************************************/
const [allScenarios, setAllScenarios] = useState([]);
// We keep a local copy of scenario so you can switch from dropdown
// We keep a local copy of `scenario` so user can switch from the dropdown
const [localScenario, setLocalScenario] = useState(scenario || null);
// A) On mount, fetch all scenarios to populate the dropdown
// (A) On mount, fetch all scenarios to populate the <select>
useEffect(() => {
async function loadScenarios() {
try {
@ -59,17 +56,20 @@ export default function ScenarioContainer({
loadScenarios();
}, []);
// B) If the parent changes the scenario prop => update local
// (B) If parent changes the `scenario` prop, update local
useEffect(() => {
setLocalScenario(scenario || null);
}, [scenario]);
// C) scenario <select> handler
// (C) <select> handler for picking a scenario from dropdown
function handleScenarioSelect(e) {
const chosenId = e.target.value;
const found = allScenarios.find((s) => s.id === chosenId);
if (found) setLocalScenario(found);
else setLocalScenario(null);
if (found) {
setLocalScenario(found);
} else {
setLocalScenario(null);
}
}
/*************************************************************
@ -79,15 +79,17 @@ export default function ScenarioContainer({
const [milestones, setMilestones] = useState([]);
const [impactsByMilestone, setImpactsByMilestone] = useState({});
// We'll also track a scenario edit modal
const [showEditModal, setShowEditModal] = useState(false);
const [editingScenarioData, setEditingScenarioData] = useState({
scenario: null,
collegeProfile: null
});
// (A) Load the college profile for localScenario
// (A) Load the college profile for the selected scenario
useEffect(() => {
if (!localScenario?.id) {
// if no scenario selected, clear the collegeProfile
setCollegeProfile(null);
return;
}
@ -97,7 +99,7 @@ export default function ScenarioContainer({
const res = await authFetch(url);
if (res.ok) {
const data = await res.json();
// Might be an object or array
// in some setups, the endpoint returns an array, in others an object
setCollegeProfile(Array.isArray(data) ? data[0] || {} : data);
} else {
setCollegeProfile({});
@ -110,7 +112,7 @@ export default function ScenarioContainer({
loadCollegeProfile();
}, [localScenario]);
// (B) Load milestones
// (B) Load milestones for localScenario (and each milestones impacts)
const fetchMilestones = useCallback(async () => {
if (!localScenario?.id) {
setMilestones([]);
@ -160,7 +162,7 @@ export default function ScenarioContainer({
// Wait until we have localScenario + collegeProfile + financialProfile
if (!financialProfile || !localScenario?.id || !collegeProfile) return;
// Gather all milestoneImpacts into one array for the aggregator
// Gather all milestoneImpacts
let allImpacts = [];
Object.keys(impactsByMilestone).forEach((mId) => {
allImpacts = allImpacts.concat(impactsByMilestone[mId]);
@ -168,9 +170,9 @@ export default function ScenarioContainer({
const simYears = parseInt(simulationYearsInput, 10) || 20;
// Build the mergedProfile exactly like single-scenario aggregator
// Build mergedProfile from scenario + user financial + college + milestone
const mergedProfile = {
// Base user financial
// base user data
currentSalary: financialProfile.current_salary || 0,
monthlyExpenses:
@ -178,7 +180,6 @@ export default function ScenarioContainer({
monthlyDebtPayments:
localScenario.planned_monthly_debt_payments ?? financialProfile.monthly_debt_payments ?? 0,
// Overridden savings from scenario or fallback to financialProfile
retirementSavings: financialProfile.retirement_savings ?? 0,
emergencySavings: financialProfile.emergency_fund ?? 0,
@ -203,7 +204,7 @@ export default function ScenarioContainer({
additionalIncome:
localScenario.planned_additional_income ?? financialProfile.additional_income ?? 0,
// College data
// college-related
studentLoanAmount: collegeProfile.existing_college_debt || 0,
interestRate: collegeProfile.interest_rate || 5,
loanTerm: collegeProfile.loan_term || 10,
@ -213,7 +214,6 @@ export default function ScenarioContainer({
calculatedTuition: collegeProfile.tuition || 0,
extraPayment: collegeProfile.extra_payment || 0,
// Are we in college?
inCollege:
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student',
@ -225,18 +225,18 @@ export default function ScenarioContainer({
programLength: collegeProfile.program_length || 0,
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary || 0,
// Scenario start date
// scenario date + simulation horizon
startDate: localScenario.start_date || new Date().toISOString(),
simulationYears: simYears,
// MILESTONE IMPACTS
// milestone impacts
milestoneImpacts: allImpacts
};
// Run the simulation
const { projectionData, loanPaidOffMonth } = simulateFinancialProjection(mergedProfile);
// Optionally compute a "cumulativeNetSavings" to display
// Optionally add a "cumulativeNetSavings" for display
let cumulative = mergedProfile.emergencySavings || 0;
const finalData = projectionData.map((monthRow) => {
cumulative += (monthRow.netSavings || 0);
@ -245,7 +245,6 @@ export default function ScenarioContainer({
setProjectionData(finalData);
setLoanPaidOffMonth(loanPaidOffMonth);
}, [
financialProfile,
localScenario,
@ -264,16 +263,17 @@ export default function ScenarioContainer({
}
/*************************************************************
* 4) CHART: lines + milestone markers
* 4) CHART: build data, handle milestone markers
*************************************************************/
// Create arrays from projectionData
// x-axis labels (e.g. "2025-01", "2025-02", etc.)
const chartLabels = projectionData.map((p) => p.month);
// We'll show netSavings, retirementSavings, and loanBalance
// dataset arrays
const netSavingsData = projectionData.map((p) => p.cumulativeNetSavings || 0);
const retData = projectionData.map((p) => p.retirementSavings || 0);
const loanData = projectionData.map((p) => p.loanBalance || 0);
// Build a "milestone points" array to place markers on the chart
// milestone markers => we find index by matching YYYY-MM
function getLabelIndexForMilestone(m) {
if (!m.date) return -1;
const short = m.date.slice(0, 7); // "YYYY-MM"
@ -284,11 +284,7 @@ export default function ScenarioContainer({
.map((m) => {
const xIndex = getLabelIndexForMilestone(m);
if (xIndex < 0) return null;
return {
x: xIndex,
y: 0, // place them at 0 or some other reference
milestoneObj: m
};
return { x: xIndex, y: 0, milestoneObj: m };
})
.filter(Boolean);
@ -314,7 +310,6 @@ export default function ScenarioContainer({
fill: false
},
{
// The milestone dataset for clickable markers
label: 'Milestones',
data: milestonePoints,
showLine: false,
@ -360,7 +355,7 @@ export default function ScenarioContainer({
};
/*************************************************************
* 5) MILESTONE CRUD: same as your code (Add, Copy, etc.)
* 5) MILESTONE CRUD
*************************************************************/
const [showForm, setShowForm] = useState(false);
const [editingMilestone, setEditingMilestone] = useState(null);
@ -397,12 +392,12 @@ export default function ScenarioContainer({
setShowForm(true);
}
// edit an existing milestone => fetch impacts
async function handleEditMilestone(m) {
if (!localScenario?.id) return;
setEditingMilestone(m);
setImpactsToDelete([]);
// fetch impacts for this milestone
try {
const impRes = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
if (impRes.ok) {
@ -436,16 +431,11 @@ export default function ScenarioContainer({
...prev,
impacts: [
...prev.impacts,
{
impact_type: 'ONE_TIME',
direction: 'subtract',
amount: 0,
start_date: '',
end_date: ''
}
{ impact_type: 'ONE_TIME', direction: 'subtract', amount: 0, start_date: '', end_date: '' }
]
}));
}
function removeImpact(idx) {
setNewMilestone((prev) => {
const copy = [...prev.impacts];
@ -457,6 +447,7 @@ export default function ScenarioContainer({
return { ...prev, impacts: copy };
});
}
function updateImpact(idx, field, value) {
setNewMilestone((prev) => {
const copy = [...prev.impacts];
@ -465,6 +456,7 @@ export default function ScenarioContainer({
});
}
// create or update milestone
async function saveMilestone() {
if (!localScenario?.id) return;
@ -474,7 +466,7 @@ export default function ScenarioContainer({
const method = editingMilestone ? 'PUT' : 'POST';
const payload = {
milestone_type: 'Financial', // or "Career" if needed
milestone_type: 'Financial', // or "Career" if your scenario is about career
title: newMilestone.title,
description: newMilestone.description,
date: newMilestone.date,
@ -513,14 +505,14 @@ export default function ScenarioContainer({
end_date: imp.end_date || null
};
if (imp.id) {
// PUT
// update existing
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(impPayload)
});
} else {
// POST
// create new
await authFetch('/api/premium/milestone-impacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -529,6 +521,7 @@ export default function ScenarioContainer({
}
}
// done => re-fetch
await fetchMilestones();
// reset form
@ -550,6 +543,7 @@ export default function ScenarioContainer({
}
}
// delete milestone => if universal, ask user if removing from all or just this scenario
async function handleDeleteMilestone(m) {
if (m.is_universal === 1) {
const userChoice = window.confirm(
@ -569,6 +563,7 @@ export default function ScenarioContainer({
}
await fetchMilestones();
}
async function deleteSingleMilestone(m) {
try {
await authFetch(`/api/premium/milestones/${m.id}`, { method: 'DELETE' });
@ -577,6 +572,7 @@ export default function ScenarioContainer({
}
}
// tasks
async function addTask(milestoneId) {
try {
const payload = {
@ -602,11 +598,9 @@ export default function ScenarioContainer({
}
}
// Instead of immediately calling `onEdit(scenario)`, we handle locally:
// scenario-level editing
function handleEditScenario() {
if (!localScenario) return;
// We'll store the scenario and also fetch the collegeProfile if needed
// or we can just reuse the existing `collegeProfile` state.
setEditingScenarioData({
scenario: localScenario,
collegeProfile
@ -614,14 +608,12 @@ export default function ScenarioContainer({
setShowEditModal(true);
}
// Scenario-level
function handleDeleteScenario() {
if (localScenario) onRemove(localScenario.id);
}
function handleCloneScenario() {
if (localScenario) onClone(localScenario);
}
/*************************************************************
* 6) COPY WIZARD
@ -649,7 +641,7 @@ export default function ScenarioContainer({
});
if (!res.ok) throw new Error('Failed to copy milestone');
onClose();
// Optionally reload the page or call fetchMilestones again
// Optionally reload
window.location.reload();
} catch (err) {
console.error('Error copying milestone:', err);
@ -696,10 +688,10 @@ export default function ScenarioContainer({
))}
<div style={{ marginTop: '1rem' }}>
<button onClick={onClose} style={{ marginRight: '0.5rem' }}>
<Button onClick={onClose} style={{ marginRight: '0.5rem' }}>
Cancel
</button>
<button onClick={handleCopy}>Copy</button>
</Button>
<Button onClick={handleCopy}>Copy</Button>
</div>
</div>
</div>
@ -761,11 +753,11 @@ export default function ScenarioContainer({
</div>
)}
<button onClick={handleNewMilestone} style={{ marginTop: '0.5rem' }}>
<Button onClick={handleNewMilestone} style={{ marginTop: '0.5rem' }}>
+ New Milestone
</button>
</Button>
{/* AI-Suggested Milestones (unchanged) */}
{/* AI-Suggested Milestones */}
<AISuggestedMilestones
career={localScenario.career_name || localScenario.scenario_title || ''}
careerPathId={localScenario.id}
@ -775,17 +767,16 @@ export default function ScenarioContainer({
/>
<div style={{ marginTop: '0.5rem' }}>
{/* Instead of calling onEdit(localScenario), show local modal */}
<button onClick={handleEditScenario}>Edit</button>
<button onClick={handleCloneScenario} style={{ marginLeft: '0.5rem' }}>
<Button onClick={handleEditScenario}>Edit</Button>
<Button onClick={handleCloneScenario} style={{ marginLeft: '0.5rem' }}>
Clone
</button>
<button
</Button>
<Button
onClick={handleDeleteScenario}
style={{ marginLeft: '0.5rem', color: 'red' }}
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
>
Delete
</button>
</Button>
</div>
{/* The inline form for milestone creation/edit */}
@ -821,7 +812,7 @@ export default function ScenarioContainer({
progress: parseInt(e.target.value || '0', 10)
}))
}
/>
/>
{/* Impacts sub-form */}
<div style={{ border: '1px solid #ccc', padding: '1rem', marginTop: '1rem' }}>
@ -878,22 +869,22 @@ export default function ScenarioContainer({
/>
</div>
)}
<button
<Button
style={{ marginLeft: '0.5rem', color: 'red' }}
onClick={() => removeImpact(idx)}
>
Remove
</button>
</Button>
</div>
))}
<button onClick={addNewImpact}>+ Add Impact</button>
<Button onClick={addNewImpact}>+ Add Impact</Button>
</div>
<div style={{ marginTop: '1rem' }}>
<button onClick={saveMilestone}>
<Button onClick={saveMilestone}>
{editingMilestone ? 'Update' : 'Add'} Milestone
</button>
<button
</Button>
<Button
style={{ marginLeft: '0.5rem' }}
onClick={() => {
setShowForm(false);
@ -911,7 +902,7 @@ export default function ScenarioContainer({
}}
>
Cancel
</button>
</Button>
</div>
</div>
)}
@ -943,29 +934,32 @@ export default function ScenarioContainer({
</ul>
)}
<button
<Button
onClick={() => {
setShowTaskForm(showTaskForm === m.id ? null : m.id);
setNewTask({ title: '', description: '', due_date: '' });
}}
>
{showTaskForm === m.id ? 'Cancel Task' : 'Add Task'}
</button>
<button style={{ marginLeft: '0.5rem' }} onClick={() => handleEditMilestone(m)}>
</Button>
<Button
style={{ marginLeft: '0.5rem' }}
onClick={() => handleEditMilestone(m)}
>
Edit
</button>
<button
</Button>
<Button
style={{ marginLeft: '0.5rem' }}
onClick={() => setCopyWizardMilestone(m)}
>
Copy
</button>
<button
style={{ marginLeft: '0.5rem', color: 'red' }}
</Button>
<Button
style={{ marginLeft: '0.5rem', background: 'red', color: 'black' }}
onClick={() => handleDeleteMilestone(m)}
>
Delete
</button>
</Button>
{/* Task form */}
{showTaskForm === m.id && (
@ -987,20 +981,20 @@ export default function ScenarioContainer({
value={newTask.due_date}
onChange={(e) => setNewTask({ ...newTask, due_date: e.target.value })}
/>
<button onClick={() => addTask(m.id)}>Save Task</button>
<Button onClick={() => addTask(m.id)}>Save Task</Button>
</div>
)}
</div>
);
})}
{/* (B) RENDER THE EDIT MODAL IF showEditModal */}
<ScenarioEditModal
show={showEditModal}
onClose={() => setShowEditModal(false)}
scenario={editingScenarioData.scenario}
collegeProfile={editingScenarioData.collegeProfile}
/>
{/* (B) Show the scenario edit modal if needed */}
<ScenarioEditModal
show={showEditModal}
onClose={() => setShowEditModal(false)}
scenario={editingScenarioData.scenario}
collegeProfile={editingScenarioData.collegeProfile}
/>
{/* The copy wizard if copying a milestone */}
{copyWizardMilestone && (