dev1/src/App.js

725 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo } from 'react';
import {
Routes,
Route,
Navigate,
useNavigate,
useLocation,
Link,
} from 'react-router-dom';
import { Button } from './components/ui/button.js';
import { cn } from './utils/cn.js';
import PromptModal from './components/ui/PromptModal.js';
import PremiumRoute from './components/PremiumRoute.js';
import SessionExpiredHandler from './components/SessionExpiredHandler.js';
import SignInLanding from './components/SignInLanding.js';
import SignIn from './components/SignIn.js';
import SignUp from './components/SignUp.js';
import PlanningLanding from './components/PlanningLanding.js';
import CareerExplorer from './components/CareerExplorer.js';
import PreparingLanding from './components/PreparingLanding.js';
import EducationalProgramsPage from './components/EducationalProgramsPage.js';
import EnhancingLanding from './components/EnhancingLanding.js';
import RetirementLanding from './components/RetirementLanding.js';
import InterestInventory from './components/InterestInventory.js';
import UserProfile from './components/UserProfile.js';
import FinancialProfileForm from './components/FinancialProfileForm.js';
import CareerProfileList from './components/CareerProfileList.js';
import CareerProfileForm from './components/CareerProfileForm.js';
import CollegeProfileList from './components/CollegeProfileList.js';
import CollegeProfileForm from './components/CollegeProfileForm.js';
import CareerRoadmap from './components/CareerRoadmap.js';
import Paywall from './components/Paywall.js';
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
import { isOnboardingInProgress } from './utils/onboardingGuard.js';
import RetirementPlanner from './components/RetirementPlanner.js';
import ResumeRewrite from './components/ResumeRewrite.js';
import LoanRepaymentPage from './components/LoanRepaymentPage.js';
import usePageContext from './utils/usePageContext.js';
import ChatDrawer from './components/ChatDrawer.js';
import ChatCtx from './contexts/ChatCtx.js';
import BillingResult from './components/BillingResult.js';
import SupportModal from './components/SupportModal.js';
import ForgotPassword from './components/ForgotPassword.js';
import ResetPassword from './components/ResetPassword.js';
import { clearToken } from './auth/authMemory.js';
import api from './auth/apiClient.js';
import * as safeLocal from './utils/safeLocal.js';
import VerificationGate from './components/VerificationGate.js';
import Verify from './components/Verify.js';
export const ProfileCtx = React.createContext();
function ResetPasswordGate() {
const location = useLocation();
useEffect(() => {
clearToken();
try { localStorage.removeItem('id'); } catch {}
}, [location.pathname]);
return <ResetPassword />;
}
function App() {
const navigate = useNavigate();
const location = useLocation();
const { pageContext, snapshot: routeSnapshot } = usePageContext();
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerPane, setDrawerPane] = useState('support');
const [retireProps, setRetireProps] = useState(null);
const [supportOpen, setSupportOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const AUTH_HOME = '/signin-landing';
const prevPathRef = React.useRef(location.pathname);
useEffect(() => { prevPathRef.current = location.pathname; }, [location.pathname]);
const IN_OB = (p) => p.startsWith('/premium-onboarding');
/* ------------------------------------------
ChatDrawer route-aware tool handlers
------------------------------------------ */
const uiToolHandlers = useMemo(() => {
if (pageContext === "CareerExplorer") {
return {
// __tool:addCareerToComparison:{"socCode":"15-2051","careerName":"Data Scientist"}
addCareerToComparison: ({ socCode, careerName }) => {
console.log('[dispatch]', socCode, careerName);
window.dispatchEvent(
new CustomEvent("add-career", { detail: { socCode, careerName } })
);
},
// __tool:openCareerModal:{"socCode":"15-2051"}
openCareerModal: ({ socCode }) => {
window.dispatchEvent(
new CustomEvent("open-career", { detail: { socCode } })
);
}
};
}
return {}; // every other page exposes no UI tools
}, [pageContext]);
// route-change guard: only warn when LEAVING onboarding mid-flow
useEffect(() => {
const wasIn = IN_OB(prevPathRef.current);
const nowIn = IN_OB(location.pathname);
const leavingOnboarding = wasIn && !nowIn;
if (!leavingOnboarding) return;
// skip if not mid-flow or if final handoff set the suppress flag
if (!isOnboardingInProgress() || sessionStorage.getItem('suppressOnboardingGuard') === '1') return;
const ok = window.confirm(
"Onboarding is in progress. If you navigate away, your progress may not be saved. Continue?"
);
if (!ok) {
// bounce back to where the user was
navigate(prevPathRef.current, { replace: true });
} else {
// user chose to leave: clear client pointer and server draft immediately
try {
safeLocal.clearMany(['premiumOnboardingPointer', 'premiumOnboardingState']);
} catch {}
(async () => {
try { await api.delete('/api/premium/onboarding/draft'); } catch {}
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// browser close/refresh guard: only when currently on onboarding and mid-flow
useEffect(() => {
const onBeforeUnload = (e) => {
if (IN_OB(location.pathname)
&& isOnboardingInProgress()
&& sessionStorage.getItem('suppressOnboardingGuard') !== '1') {
e.preventDefault();
e.returnValue = "Onboarding is in progress. If you leave now, your progress may not be saved.";
}
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [location.pathname]);
// Retirement bot is only relevant on these pages
const canShowRetireBot =
pageContext === 'RetirementPlanner' ||
pageContext === 'RetirementLanding';
// Auth states
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [chatSnapshot, setChatSnapshot] = useState(null);
// Loading state while verifying token
const [isLoading, setIsLoading] = useState(true);
// User states
const [financialProfile, setFinancialProfile] = useState(null);
const [scenario, setScenario] = useState(null);
// Logout warning modal
const [showLogoutWarning, setShowLogoutWarning] = useState(false);
// Check if user can access premium
const canAccessPremium = user?.is_premium || user?.is_pro_premium;
const isAuthScreen = React.useMemo(() => {
const p = location.pathname;
return (
p === '/signin' ||
p === '/signup' ||
p === '/forgot-password' ||
p.startsWith('/reset-password')
);
}, [location.pathname]);
const showAuthedNav = isAuthenticated && !isAuthScreen;
// List of premium paths for your CTA logic
const premiumPaths = [
'/career-roadmap',
'/paywall',
'/financial-profile',
'/retirement-planner',
'/premium-onboarding',
'/enhancing',
'/retirement',
'/resume-optimizer',
];
const showPremiumCTA = !premiumPaths.some(p =>
location.pathname.startsWith(p)
);
// ==============================
// 1) Single Rehydrate UseEffect
// ==============================
useEffect(() => {
let cancelled = false;
if (loggingOut) return;
// Skip auth probe on all public auth routes
if (
location.pathname.startsWith('/reset-password') ||
location.pathname === '/signin' ||
location.pathname === '/signup' ||
location.pathname === '/forgot-password'
) {
try { localStorage.removeItem('id'); } catch {}
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
(async () => {
setIsLoading(true);
try {
// Fetch only the minimal fields App needs for nav/landing/support
const { data } = await api.get('/api/user-profile?fields=firstname,is_premium,is_pro_premium');
if (cancelled) return;
setUser(prev => ({
...(prev || {}),
firstname : data?.firstname || '',
is_premium : !!data?.is_premium,
is_pro_premium: !!data?.is_pro_premium,
}));
setIsAuthenticated(true);
} catch (err) {
if (cancelled) return;
clearToken();
setIsAuthenticated(false);
setUser(null);
// Only kick to /signin if youre not already on a public page
const p = location.pathname;
const onPublic =
p === '/signin' ||
p === '/signup' ||
p === '/forgot-password' ||
p.startsWith('/reset-password') ||
p === '/paywall';
if (!onPublic) navigate('/signin?session=expired', { replace: true });
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => { cancelled = true; };
// include isAuthScreen if you prefer, but this local check avoids a dep loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, navigate, loggingOut]);
// ==========================
// 2) Logout Handler + Modal
// ==========================
const handleLogoutClick = () => {
if (isOnboardingInProgress()) {
// Show a modal to confirm losing onboarding data
setShowLogoutWarning(true);
} else {
// No onboarding => just logout
confirmLogout();
}
};
const confirmLogout = async () => {
setLoggingOut(true);
// 1) Ask the server to clear the session cookie
try {
// If you created /logout (no /api prefix):
await api.post('/api/logout', {}); // axios client is withCredentials: true
// If your route is /api/signout instead, use:
// await api.post('/api/signout');
} catch (e) {
console.warn('Server logout failed (continuing client-side):', e?.message || e);
}
// 2) Clear client-side state/caches
clearToken(); // in-memory bearer (if any, for legacy flows)
safeLocal.clearMany([
'id',
'careerSuggestionsCache',
'lastSelectedCareerProfileId',
'selectedCareer',
'aiClickCount',
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
'premiumOnboardingPointer',
'financialProfile',
'selectedScenario',
]);
// 3) Reset React state
setFinancialProfile(null);
setScenario(null);
setIsAuthenticated(false);
setUser(null);
setShowLogoutWarning(false);
// 4) Back to sign-in
navigate('/signin', { replace: true });
setLoggingOut(false);
};
const cancelLogout = () => {
setShowLogoutWarning(false);
};
// ====================================
// 3) If still verifying the token, show loading
// ====================================
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<p>Loading...</p>
</div>
);
}
// =====================
// Main Render / Layout
// =====================
return (
<ProfileCtx.Provider
value={{ financialProfile, setFinancialProfile,
scenario, setScenario,
user, setUser}}
>
<ChatCtx.Provider value={{ setChatSnapshot,
openSupport: () => {
if (!isAuthenticated) return;
setDrawerPane('support'); setDrawerOpen(true);
},
openRetire : (props) => {
if (!isAuthenticated || !canShowRetireBot) return;
if (!canShowRetireBot) {
console.warn('Retirement bot disabled on this page');
return;
}
setRetireProps(props);
setDrawerPane('retire');
setDrawerOpen(true);
}
}}>
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
{/* Header */}
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
<h1 className="text-lg font-semibold">
AptivaAI - Career Guidance Platform
</h1>
{showAuthedNav && (
<>
{/* NAV MENU */}
<nav className="flex space-x-6">
{/* 1) Planning */}
<div className="relative group">
<Button
style={{ color: '#1f2937' }}
className={cn(
'bg-white',
'border border-gray-300',
'hover:bg-gray-100',
'hover:text-blue-700',
'whitespace-nowrap',
'text-xs sm:text-sm md:text-base',
'font-semibold'
)}
onClick={() => navigate('/planning')}
>
Find Your Career
</Button>
<div className="absolute top-full left-0 hidden group-hover:block bg-white border shadow-md w-48 z-50">
<Link
to="/career-explorer"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Career Explorer
</Link>
<Link
to="/interest-inventory"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Interest Inventory
</Link>
</div>
</div>
{/* 2) Preparing */}
<div className="relative group">
<Button
style={{ color: '#1f2937' }}
className={cn(
'bg-white',
'border border-gray-300',
'hover:bg-gray-100',
'hover:text-blue-700',
'whitespace-nowrap',
'text-xs sm:text-sm md:text-base',
'font-semibold'
)}
onClick={() => navigate('/preparing')}
>
Preparing & UpSkilling for Your Career
</Button>
<div className="absolute top-full left-0 hidden group-hover:block bg-white border shadow-md w-56 z-50">
<Link
to="/educational-programs"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Educational Programs
</Link>
</div>
</div>
{/* 3) Enhancing (Premium) */}
<div className="relative group">
<Button
style={{ color: '#1f2937' }}
className={cn(
'bg-white',
'border border-gray-300',
'hover:bg-gray-100',
'hover:text-blue-700',
'whitespace-nowrap',
'text-xs sm:text-sm md:text-base',
'font-semibold'
)}
onClick={() => navigate('/enhancing')}
>
Enhancing Your Career
{!canAccessPremium && (
<span className="text-xs ml-1 text-gray-600">
(Premium)
</span>
)}
</Button>
<div className="absolute top-full left-0 hidden group-hover:block bg-white border shadow-md w-56 z-50">
<Link
to="/career-roadmap"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Roadmap & AI Career Coach
</Link>
<Link
to="/resume-optimizer"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Optimize Resume
</Link>
{/* etc. */}
</div>
</div>
{/* 4) Retirement (Premium) */}
<div className="relative group">
<Button
style={{ color: '#1f2937' }}
className={cn(
'bg-white',
'border border-gray-300',
'hover:bg-gray-100',
'hover:text-blue-700',
'whitespace-nowrap',
'text-xs sm:text-sm md:text-base',
'font-semibold'
)}
onClick={() => navigate('/retirement')}
>
Retirement Planning (beta)
{!canAccessPremium && (
<span className="text-xs ml-1 text-gray-600">
(Premium)
</span>
)}
</Button>
<div className="absolute top-full left-0 hidden group-hover:block bg-white border shadow-md w-56 z-50">
{/* Additional retirement menu items */}
</div>
</div>
{/* 5) Profile */}
<div className="relative group">
<Button
style={{ color: '#1f2937' }}
className={cn(
'bg-white',
'border border-gray-300',
'hover:bg-gray-100',
'hover:text-blue-700',
'whitespace-nowrap',
'text-xs sm:text-sm md:text-base',
'font-semibold',
'min-w-0',
'max-w-[90px]',
'truncate'
)}
>
Profile
</Button>
<div className="absolute top-full left-0 hidden group-hover:block bg-white border shadow-md w-48 z-50">
<Link
to="/profile"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Account
</Link>
<Link
to="/financial-profile"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Financial Profile
</Link>
{canAccessPremium ? (
/* Premium users go straight to the wizard */
<Link
to="/profile/careers"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Career Profiles
</Link>
) : (
<span
className="block px-4 py-2 text-sm text-gray-400 cursor-not-allowed"
>
Career Profiles (Premium)
</span>
)}
{/* College Profiles (go straight to list) */}
{canAccessPremium ? (
<Link
to="/profile/college"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
College Profiles
</Link>
) : (
<span className="block px-4 py-2 text-sm text-gray-400 cursor-not-allowed">
College Profiles (Premium)
</span>
)}
</div>
</div>
</nav>
{/* LOGOUT + UPGRADE BUTTONS */}
<div className="flex items-center space-x-4 ml-4 relative z-10">
{showPremiumCTA && !canAccessPremium && (
<Button
className={cn(
'bg-green-500 hover:bg-green-600',
'max-w-fit text-center text-white',
'px-3 py-2',
'rounded',
'whitespace-nowrap',
'text-sm',
'font-semibold',
'shadow'
)}
style={{ minWidth: 0, width: 'auto' }}
onClick={() => navigate('/paywall')}
>
Upgrade to Premium
</Button>
)}
<button
type="button"
onClick={() => setSupportOpen(true)}
className="px-3 py-1 rounded hover:bg-gray-100"
>
Support
</button>
<SupportModal
open={supportOpen}
onClose={() => setSupportOpen(false)}
/>
{/* LOGOUT BUTTON */}
<button
onClick={handleLogoutClick}
disabled={loggingOut}
className="text-red-600 hover:text-red-800 bg-transparent border-none disabled:opacity-60"
>
{loggingOut ? 'Signing out…' : 'Logout'}
</button>
{/* SHOW WARNING MODAL IF needed */}
{showLogoutWarning && (
<PromptModal
open={true}
title="End Onboarding?"
message="If you sign out now, your onboarding progress will be lost. Are you sure?"
confirmText="Sign Out"
cancelText="Nevermind"
onConfirm={confirmLogout}
onCancel={cancelLogout}
/>
)}
</div>
</>
)}
</header>
{/* MAIN CONTENT */}
<main className="flex-1 p-6">
<Routes>
{/* Default */}
<Route
path="/"
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
/>
<Route path="/reset-password/:token" element={<ResetPasswordGate />} />
{/* Public (guest-only) routes */}
<Route
path="/signin"
element={
isAuthenticated ? (
<Navigate to={AUTH_HOME} replace />
) : (
<SignIn
setIsAuthenticated={setIsAuthenticated}
setUser={setUser}
setFinancialProfile={setFinancialProfile}
setScenario={setScenario}
/>
)
}
/>
<Route
path="/signup"
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <SignUp setUser={setUser} />}
/>
<Route
path="/forgot-password"
element={isAuthenticated ? <Navigate to={AUTH_HOME} replace /> : <ForgotPassword />}
/>
<Route path="/paywall" element={<Paywall />} />
<Route path="/verify" element={<Verify />} />
{/* Authenticated routes */}
{isAuthenticated && (
<>
<Route path="/signin-landing" element={<VerificationGate><SignInLanding user={user} /></VerificationGate>} />
<Route path="/interest-inventory" element={<VerificationGate><InterestInventory /></VerificationGate>} />
<Route path="/profile" element={<VerificationGate><UserProfile /></VerificationGate>} />
<Route path="/planning" element={<VerificationGate><PlanningLanding /></VerificationGate>} />
<Route path="/career-explorer" element={<VerificationGate><CareerExplorer /></VerificationGate>} />
<Route path="/loan-repayment" element={<VerificationGate><LoanRepaymentPage /></VerificationGate>} />
<Route path="/educational-programs" element={<VerificationGate><EducationalProgramsPage /></VerificationGate>} />
<Route path="/preparing" element={<VerificationGate><PreparingLanding /></VerificationGate>} />
<Route path="/billing" element={<BillingResult />} />
{/* Premium-wrapped */}
<Route path="/enhancing" element={<VerificationGate><PremiumRoute user={user}><EnhancingLanding /></PremiumRoute></VerificationGate>} />
<Route path="/retirement" element={<VerificationGate><PremiumRoute user={user}><RetirementLanding /></PremiumRoute></VerificationGate>} />
<Route path="/career-roadmap/:careerId?" element={<VerificationGate><PremiumRoute user={user}><CareerRoadmap /></PremiumRoute></VerificationGate>} />
<Route path="/profile/careers" element={<VerificationGate><CareerProfileList /></VerificationGate>} />
<Route path="/profile/careers/:id/edit" element={<VerificationGate><CareerProfileForm /></VerificationGate>} />
<Route path="/profile/college" element={<VerificationGate><CollegeProfileList /></VerificationGate>} />
<Route path="/profile/college/:careerId/:id?" element={<VerificationGate><CollegeProfileForm /></VerificationGate>} />
<Route path="/financial-profile" element={<VerificationGate><PremiumRoute user={user}><FinancialProfileForm /></PremiumRoute></VerificationGate>} />
<Route path="/retirement-planner" element={<VerificationGate><PremiumRoute user={user}><RetirementPlanner /></PremiumRoute></VerificationGate>} />
<Route path="/premium-onboarding" element={<VerificationGate><PremiumRoute user={user}><OnboardingContainer /></PremiumRoute></VerificationGate>} />
<Route path="/resume-optimizer" element={<VerificationGate><PremiumRoute user={user}><ResumeRewrite /></PremiumRoute></VerificationGate>} />
</>
)}
{/* 404 / Fallback */}
<Route
path="*"
element={<Navigate to={isAuthenticated ? AUTH_HOME : '/signin'} replace />}
/>
</Routes>
</main>
{isAuthenticated && (
<ChatDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
pane={drawerPane}
setPane={setDrawerPane}
retireProps={retireProps}
pageContext={pageContext}
snapshot={chatSnapshot}
uiToolHandlers={uiToolHandlers}
canShowRetireBot={canShowRetireBot}
/>
)}
{/* Session Handler (Optional) */}
<SessionExpiredHandler />
</div>
</ChatCtx.Provider>
</ProfileCtx.Provider>
);
}
export default App;