Mobile UI optimizations
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-09-22 15:27:42 +00:00
parent 35142f52bc
commit 0a57a97016
8 changed files with 308 additions and 64 deletions

View File

@ -1 +1 @@
7c4503634c1566a112e17705d07e15f792647175-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b f60aaeaf5d529bc05bbbd41815d65d86e9010dfc-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -72,6 +72,10 @@ function App() {
const [retireProps, setRetireProps] = useState(null); const [retireProps, setRetireProps] = useState(null);
const [supportOpen, setSupportOpen] = useState(false); const [supportOpen, setSupportOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false); const [loggingOut, setLoggingOut] = useState(false);
// Mobile nav
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [mobileSection, setMobileSection] = useState(null); // 'planning' | 'preparing' | 'enhancing' | 'retirement' | 'profile' | null
const toggleMobileSection = (key) => setMobileSection((k) => (k === key ? null : key));
const AUTH_HOME = '/signin-landing'; const AUTH_HOME = '/signin-landing';
@ -79,6 +83,9 @@ function App() {
const prevPathRef = React.useRef(location.pathname); const prevPathRef = React.useRef(location.pathname);
useEffect(() => { prevPathRef.current = location.pathname; }, [location.pathname]); useEffect(() => { prevPathRef.current = location.pathname; }, [location.pathname]);
// Close mobile nav on route change
useEffect(() => { setMobileNavOpen(false); }, [location.pathname]);
const IN_OB = (p) => p.startsWith('/premium-onboarding'); const IN_OB = (p) => p.startsWith('/premium-onboarding');
/* ------------------------------------------ /* ------------------------------------------
@ -323,7 +330,7 @@ const cancelLogout = () => {
// ==================================== // ====================================
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex min-h-[100dvh] items-center justify-center">
<p>Loading...</p> <p>Loading...</p>
</div> </div>
); );
@ -355,17 +362,29 @@ const cancelLogout = () => {
setDrawerOpen(true); setDrawerOpen(true);
} }
}}> }}>
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800"> <div className="flex min-h-[100dvh] flex-col bg-gray-50 text-gray-800">
{/* Header */} {/* Header */}
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative"> <header className="sticky top-0 z-50 flex items-center justify-between border-b bg-white/95 px-4 py-3 md:px-6 md:py-4 backdrop-blur supports-[backdrop-filter]:bg-white/70">
<h1 className="text-lg font-semibold"> <h1 className="text-base md:text-lg font-semibold truncate pr-3">
AptivaAI - Career Guidance Platform AptivaAI - Career Guidance Platform
</h1> </h1>
{/* Mobile hamburger */}
<button
type="button"
aria-label="Open menu"
className="md:hidden inline-flex items-center justify-center rounded-md p-2 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-600"
onClick={() => setMobileNavOpen(v => !v)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-6 w-6">
<path d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
{showAuthedNav && ( {showAuthedNav && (
<> <>
{/* NAV MENU */} {/* Desktop NAV */}
<nav className="flex space-x-6"> <nav className="hidden md:flex md:space-x-6">
{/* 1) Planning */} {/* 1) Planning */}
<div className="relative group"> <div className="relative group">
<Button <Button
@ -557,8 +576,8 @@ const cancelLogout = () => {
</div> </div>
</nav> </nav>
{/* LOGOUT + UPGRADE BUTTONS */} {/* LOGOUT + UPGRADE BUTTONS (desktop) */}
<div className="flex items-center space-x-4 ml-4 relative z-10"> <div className="hidden md:flex items-center space-x-4 ml-4 relative z-10">
{showPremiumCTA && !canAccessPremium && ( {showPremiumCTA && !canAccessPremium && (
<Button <Button
className={cn( className={cn(
@ -586,11 +605,6 @@ const cancelLogout = () => {
Support Support
</button> </button>
<SupportModal
open={supportOpen}
onClose={() => setSupportOpen(false)}
/>
{/* LOGOUT BUTTON */} {/* LOGOUT BUTTON */}
<button <button
onClick={handleLogoutClick} onClick={handleLogoutClick}
@ -617,8 +631,159 @@ const cancelLogout = () => {
)} )}
</header> </header>
{/* Mobile slide-down menu */}
{showAuthedNav && (
<div
className={cn(
"md:hidden border-b bg-white shadow-sm",
mobileNavOpen ? "block" : "hidden"
)}
>
<div className="px-4 py-3 space-y-2">
{/* Upgrade CTA (mobile) */}
{showPremiumCTA && !canAccessPremium && (
<Button
className={cn('bg-green-500 hover:bg-green-600 text-white w-full h-12')}
onClick={() => navigate('/paywall')}
>
Upgrade to Premium
</Button>
)}
{/* Primary sections (touch-friendly, no shadcn Button to avoid style overrides) */}
<button
type="button"
onClick={() => toggleMobileSection('planning')}
className="w-full h-12 px-3 inline-flex items-center justify-between rounded border text-gray-800 bg-white active:bg-gray-50"
>
<span className="text-base">Find Your Career</span>
<span className={cn("transition-transform", mobileSection==='planning' ? "rotate-180" : "")}></span>
</button>
{mobileSection === 'planning' && (
<div className="pl-3 space-y-1">
<Link
to="/planning"
className="block px-2 py-2 text-sm font-medium text-blue-700 rounded hover:bg-blue-50"
>
Find Your Career Overview
</Link>
<Link to="/career-explorer" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Career Explorer</Link>
<Link to="/interest-inventory" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Interest Inventory</Link>
</div>
)}
<button
type="button"
onClick={() => toggleMobileSection('preparing')}
className="w-full h-12 px-3 inline-flex items-center justify-between rounded border text-gray-800 bg-white active:bg-gray-50"
>
<span className="text-base">Preparing &amp; UpSkilling for Your Career</span>
<span className={cn("transition-transform", mobileSection==='preparing' ? "rotate-180" : "")}></span>
</button>
{mobileSection === 'preparing' && (
<div className="pl-3 space-y-1">
<Link
to="/preparing"
className="block px-2 py-2 text-sm font-medium text-blue-700 rounded hover:bg-blue-50"
>
Preparing Overview
</Link>
<Link to="/educational-programs" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Educational Programs</Link>
</div>
)}
<button
type="button"
onClick={() => toggleMobileSection('enhancing')}
className="w-full h-12 px-3 inline-flex items-center justify-between rounded border text-gray-800 bg-white active:bg-gray-50"
>
<span className="text-base">
Enhancing Your Career {!canAccessPremium && <span className="ml-1 text-xs text-gray-600">(Premium)</span>}
</span>
<span className={cn("transition-transform", mobileSection==='enhancing' ? "rotate-180" : "")}></span>
</button>
{mobileSection === 'enhancing' && (
<div className="pl-3 space-y-1">
<Link
to="/enhancing"
className="block px-2 py-2 text-sm font-medium text-blue-700 rounded hover:bg-blue-50"
>
Enhancing Overview {!canAccessPremium && <span className="ml-1 text-xs text-gray-500">(Premium)</span>}
</Link>
<Link to="/career-roadmap" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Roadmap &amp; AI Career Coach</Link>
<Link to="/resume-optimizer" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Optimize Resume</Link>
</div>
)}
<button
type="button"
onClick={() => toggleMobileSection('retirement')}
className="w-full h-12 px-3 inline-flex items-center justify-between rounded border text-gray-800 bg-white active:bg-gray-50"
>
<span className="text-base">
Retirement Planning (beta) {!canAccessPremium && <span className="ml-1 text-xs text-gray-600">(Premium)</span>}
</span>
<span className={cn("transition-transform", mobileSection==='retirement' ? "rotate-180" : "")}></span>
</button>
{mobileSection === 'retirement' && (
<div className="pl-3 space-y-1">
<Link
to="/retirement"
className="block px-2 py-2 text-sm font-medium text-blue-700 rounded hover:bg-blue-50"
>
Retirement Overview {!canAccessPremium && <span className="ml-1 text-xs text-gray-500">(Premium)</span>}
</Link>
</div>
)}
<button
type="button"
onClick={() => toggleMobileSection('profile')}
className="w-full h-12 px-3 inline-flex items-center justify-between rounded border text-gray-800 bg-white active:bg-gray-50"
>
<span className="text-base">Profile</span>
<span className={cn("transition-transform", mobileSection==='profile' ? "rotate-180" : "")}></span>
</button>
{mobileSection === 'profile' && (
<div className="pl-3 space-y-1">
<Link to="/profile" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Account</Link>
<Link to="/financial-profile" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Financial Profile</Link>
{canAccessPremium ? (
<Link to="/profile/careers" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">Career Profiles</Link>
) : (
<span className="block px-2 py-2 text-sm text-gray-400">Career Profiles (Premium)</span>
)}
{canAccessPremium ? (
<Link to="/profile/college" className="block px-2 py-2 text-sm text-gray-700 rounded hover:bg-gray-100">College Profiles</Link>
) : (
<span className="block px-2 py-2 text-sm text-gray-400">College Profiles (Premium)</span>
)}
</div>
)}
{/* Support + Logout */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={() => setSupportOpen(true)}
className="px-3 py-1 rounded hover:bg-gray-100"
>
Support
</button>
<button
onClick={handleLogoutClick}
disabled={loggingOut}
className="flex-1 px-3 py-3 rounded border border-red-300 text-red-600 hover:bg-red-50 disabled:opacity-60"
>
{loggingOut ? 'Signing out…' : 'Logout'}
</button>
</div>
</div>
</div>
)}
{/* MAIN CONTENT */} {/* MAIN CONTENT */}
<main className="flex-1 p-6"> <main className="flex-1 p-4 md:p-6">
<Routes> <Routes>
{/* Default */} {/* Default */}
<Route <Route
@ -696,6 +861,12 @@ const cancelLogout = () => {
</Routes> </Routes>
</main> </main>
{/* Support modal mounted once at root so it centers correctly on desktop & mobile */}
<SupportModal
open={supportOpen}
onClose={() => setSupportOpen(false)}
/>
{isAuthenticated && ( {isAuthenticated && (
<ChatDrawer <ChatDrawer
open={drawerOpen} open={drawerOpen}

View File

@ -120,6 +120,17 @@ function CareerExplorer() {
Good: 'Good - Less Strong Match', Good: 'Good - Less Strong Match',
}; };
// VISUAL-ONLY FILTER: Never mutates source, never triggers network.
const filteredSuggestions = useMemo(() => {
const src = Array.isArray(careerSuggestions) ? careerSuggestions : [];
if (!selectedJobZone && !selectedFit) return src;
return src.filter((c) => {
const zoneMatch = selectedJobZone ? String(c?.job_zone ?? '') === String(selectedJobZone) : true;
const fitMatch = selectedFit ? String(c?.fit ?? '') === String(selectedFit) : true;
return zoneMatch && fitMatch;
});
}, [careerSuggestions, selectedJobZone, selectedFit]);
// ---------- Load cache on mount (no profile call) ---------- // ---------- Load cache on mount (no profile call) ----------
useEffect(() => { useEffect(() => {
const cached = localStorage.getItem('careerSuggestionsCache'); const cached = localStorage.getItem('careerSuggestionsCache');
@ -547,7 +558,7 @@ function CareerExplorer() {
// ---- Render // ---- Render
return ( return (
<div className="career-explorer-container bg-white p-6 rounded shadow"> <div className="career-explorer-container bg-white p-4 md:p-6 rounded shadow">
{renderLoadingOverlay()} {renderLoadingOverlay()}
{showModal && ( {showModal && (
@ -588,7 +599,9 @@ function CareerExplorer() {
</div> </div>
{careerList.length ? ( {careerList.length ? (
<table className="w-full mb-4"> /* Mobile: contain + allow horizontal scroll so the table never bleeds past card */
<div className="mb-4 -mx-4 md:mx-0 overflow-x-auto">
<table className="w-full min-w-[720px]">
<thead> <thead>
<tr> <tr>
<th className="border p-2">Career</th> <th className="border p-2">Career</th>
@ -649,11 +662,16 @@ function CareerExplorer() {
<td className="border p-2">{balanceRating}</td> <td className="border p-2">{balanceRating}</td>
<td className="border p-2">{recognitionRating}</td> <td className="border p-2">{recognitionRating}</td>
<td className="border p-2 font-bold">{matchScore.toFixed(1)}%</td> <td className="border p-2 font-bold">{matchScore.toFixed(1)}%</td>
<td className="border p-2 space-x-2"> <td className="border p-2">
<Button className="bg-red-600 text-black-500" onClick={() => removeCareerFromList(career.code)}> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2">
<Button
className="bg-red-600 text-black-500 h-10 sm:h-9 px-3 text-sm w-full sm:w-auto"
onClick={() => removeCareerFromList(career.code)}
>
Remove Remove
</Button> </Button>
<Button className="bg-green-600 text-white px-2 py-1 text-xs sm:text-sm whitespace-nowrap" <Button
className="bg-green-600 text-white h-10 sm:h-9 px-3 text-sm whitespace-nowrap w-full sm:w-auto"
onClick={() => { onClick={() => {
// native browser warning before leaving Career Explorer // native browser warning before leaving Career Explorer
const ok = window.confirm( const ok = window.confirm(
@ -692,44 +710,61 @@ function CareerExplorer() {
state: { socCode: baseSoc, cipCodes: cleanedCips, careerTitle: career.title } state: { socCode: baseSoc, cipCodes: cleanedCips, careerTitle: career.title }
}); });
})(); })();
}}> }}
>
Plan your Education/Skills Plan your Education/Skills
</Button> </Button>
</div>
</td> </td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
) : (<p>No careers added to comparison.</p>)} </div>
) : (<p className="mb-4">No careers added to comparison.</p>)}
<div className="flex gap-4 mb-4"> {/* Filters: stack on mobile; compact controls */}
<select className="border px-3 py-1 rounded" value={selectedJobZone} onChange={(e) => setSelectedJobZone(e.target.value)}> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 mb-2">
<select
className="border rounded w-full sm:w-auto h-10 px-3 text-sm"
value={selectedJobZone}
onChange={(e) => setSelectedJobZone(e.target.value)}
>
<option value="">All Preparation Levels</option> <option value="">All Preparation Levels</option>
{Object.entries(jobZoneLabels).map(([zone, label]) => ( {Object.entries(jobZoneLabels).map(([zone, label]) => (
<option key={zone} value={zone}>{label}</option> <option key={zone} value={zone}>{label}</option>
))} ))}
</select> </select>
<select className="border px-3 py-1 rounded" value={selectedFit} onChange={(e) => setSelectedFit(e.target.value)}> <select
className="border rounded w-full sm:w-auto h-10 px-3 text-sm"
value={selectedFit}
onChange={(e) => setSelectedFit(e.target.value)}
>
<option value="">All Fit Levels</option> <option value="">All Fit Levels</option>
{Object.entries(fitLabels).map(([key, label]) => ( {Object.entries(fitLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option> <option key={key} value={key}>{label}</option>
))} ))}
</select> </select>
<Button onClick={handleReloadSuggestions} className="bg-green-600 text-white px-3 py-1 text-xs sm:text-sm"> <Button
onClick={handleReloadSuggestions}
className="bg-green-600 text-white h-10 px-3 text-sm w-full sm:w-auto"
>
Reload Career Suggestions Reload Career Suggestions
</Button> </Button>
<div className="flex items-center gap-1 ml-4"> </div>
{/* Warning note: separate row on mobile to avoid squish */}
<div className="mb-4 text-xs sm:text-sm text-gray-700 flex items-center gap-1">
<span className="warning-icon"></span> <span className="warning-icon"></span>
<span>= May have limited data for this career path</span> <span>= May have limited data for this career path</span>
</div> </div>
</div>
<CareerSuggestions <CareerSuggestions
careerSuggestions={careerSuggestions} careerSuggestions={filteredSuggestions}
onCareerClick={(career) => { setSelectedCareer(career); handleCareerClick(career); }} onCareerClick={(career) => { setSelectedCareer(career); handleCareerClick(career); }}
/> />

View File

@ -182,7 +182,7 @@ export default function ChatDrawer({
{/* side drawer */} {/* side drawer */}
<SheetContent <SheetContent
side="right" side="right"
className="flex h-full w-[370px] flex-col p-0 md:w-[420px]" className="flex h-full w-[88vw] max-w-[380px] flex-col p-0 sm:w-[360px] md:w-[420px]"
> >
{/* header (tabs only if retirement bot is allowed) */} {/* header (tabs only if retirement bot is allowed) */}
<div className="flex border-b"> <div className="flex border-b">

View File

@ -458,14 +458,15 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
return ( return (
<tr key={idx} className="border-b text-sm"> <tr key={idx} className="border-b text-sm">
<td className="p-2 font-medium text-gray-800"> <td className="p-2 font-medium text-gray-800">
{elementName}{' '} <span className="inline-flex items-center gap-1 align-middle">
<span className="break-words">{elementName}</span>
<span <span
title={definition} title={definition}
className="top-0 left-0 -translate-y-3/4 translate-x-1/8 ml-.5 inline-flex h-2.5 w-2.5 items-center justify-center className="shrink-0 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-bold text-white cursor-help leading-none"
rounded-full bg-blue-500 text-[0.6rem] font-bold text-white cursor-help"
> >
i i
</span> </span>
</span>
</td> </td>
<td className="p-2 text-center text-gray-800">{impStars}</td> <td className="p-2 text-center text-gray-800">{impStars}</td>
<td className="p-2 text-center text-gray-800">{lvlBars}</td> <td className="p-2 text-center text-gray-800">{lvlBars}</td>
@ -564,18 +565,16 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
<th className="p-2">Ability</th> <th className="p-2">Ability</th>
<th className="p-2 text-center">Importance</th> <th className="p-2 text-center">Importance</th>
<th className="p-2 text-center">Level</th> <th className="p-2 text-center">Level</th>
<th className="p-2 text-center"> <th className="p-2">
{/* Info icon for "Why no courses?" */} <div className="w-full flex items-center justify-center gap-1">
<span className="relative inline-block"> <span className="whitespace-nowrap">Why no courses?</span>
<span>Why no courses?</span>
<span <span
className="top-0 left-0 -translate-y-3/4 translate-x-1/8 ml-.5 inline-flex h-2.5 w-2.5 items-center justify-center className="shrink-0 inline-flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] italic text-white cursor-help leading-none"
rounded-full bg-blue-500 text-[10px] font-italics text-white cursor-help"
title="Abilities are more innate in nature, and difficult to offer courses for them." title="Abilities are more innate in nature, and difficult to offer courses for them."
> >
i i
</span> </span>
</span> </div>
</th> </th>
</tr> </tr>
</thead> </thead>

View File

@ -23,7 +23,7 @@ const InterestInventory = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [userProfile, setUserProfile] = useState(null); const [userProfile, setUserProfile] = useState(null);
const isProd = (process.env.REACT_APP_ENV_NAME || '').toLowerCase() === 'prod'; const isProd = (process.env.ENV_NAME || '').toLowerCase() === 'prod';
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -14,6 +14,7 @@ function SignInLanding({ user }) {
We blend data-backed insights with human-centered design, enhanced -not driven by- AI. Giving you practical recommendations and real-world context so you stay in the drivers seat of your career. Whether youre planning your first step, advancing your current role, or ready to pivot entirely, our platform keeps you in controlhelping you adapt, grow, and thrive on your own terms. We blend data-backed insights with human-centered design, enhanced -not driven by- AI. Giving you practical recommendations and real-world context so you stay in the drivers seat of your career. Whether youre planning your first step, advancing your current role, or ready to pivot entirely, our platform keeps you in controlhelping you adapt, grow, and thrive on your own terms.
</p> </p>
<ul className="list-disc ml-6 mb-4"> <ul className="list-disc ml-6 mb-4">
<li><strong>Planning:</strong> Just starting out? Looking for a different career that is a better fit? Explore options and figure out what careers match your interests and skills.</li> <li><strong>Planning:</strong> Just starting out? Looking for a different career that is a better fit? Explore options and figure out what careers match your interests and skills.</li>
<li><strong>Preparing:</strong> Know what you want but just not how to get there? Gain education, skills, or certifications required to start or transition.</li> <li><strong>Preparing:</strong> Know what you want but just not how to get there? Gain education, skills, or certifications required to start or transition.</li>
<li><strong>Enhancing:</strong> You've got some experience in your field but want to know how to get to the next level? Advance, seek promotions, or shift roles for an established professional.</li> <li><strong>Enhancing:</strong> You've got some experience in your field but want to know how to get to the next level? Advance, seek promotions, or shift roles for an established professional.</li>
@ -22,17 +23,18 @@ We blend data-backed insights with human-centered design, enhanced -not driven b
<p className="mb-4"> <p className="mb-4">
Where would you like to go next? Where would you like to go next?
</p> </p>
<div className="space-x-2"> {/* Mobile: stacked full-width; Desktop: original inline buttons with spacing */}
<Link to="/planning" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> <div className="flex flex-col gap-2 md:block md:space-x-2">
<Link to="/planning" className="w-full md:w-auto h-12 md:h-auto inline-flex items-center justify-center md:inline-block px-4 bg-blue-600 text-white rounded hover:bg-blue-700">
Go to Exploring Go to Exploring
</Link> </Link>
<Link to="/preparing" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> <Link to="/preparing" className="w-full md:w-auto h-12 md:h-auto inline-flex items-center justify-center md:inline-block px-4 bg-blue-600 text-white rounded hover:bg-blue-700">
Go to Preparing Go to Preparing
</Link> </Link>
<Link to="/enhancing" className="inline-block px-4 py-2 bg-green-600 text-white rounded hover:bg-blue-700"> <Link to="/enhancing" className="w-full md:w-auto h-12 md:h-auto inline-flex items-center justify-center md:inline-block px-4 bg-green-600 text-white rounded hover:bg-green-700">
Go to Enhancing Go to Enhancing
</Link> </Link>
<Link to="/retirement" className="inline-block px-4 py-2 bg-green-600 text-white rounded hover:bg-blue-700"> <Link to="/retirement" className="w-full md:w-auto h-12 md:h-auto inline-flex items-center justify-center md:inline-block px-4 bg-green-600 text-white rounded hover:bg-green-700">
Go to Retirement Go to Retirement
</Link> </Link>
</div> </div>

37
src/utils/net.js Normal file
View File

@ -0,0 +1,37 @@
// src/utils/net.js
export function computeNetState() {
if (typeof navigator === 'undefined') return { slow: false, effectiveType: '', downlink: 0 };
const nc = navigator.connection || navigator.webkitConnection || navigator.mozConnection;
const effectiveType = nc?.effectiveType || '';
const downlink = Number(nc?.downlink || 0);
const slow = ['slow-2g', '2g', '3g'].includes(effectiveType) || (downlink > 0 && downlink < 3);
return { slow, effectiveType, downlink };
}
export function getNetState() {
// Fallback keeps things safe in SSR or older browsers
return (typeof window !== 'undefined' && window.__APT_NET_STATE) || { slow: false, effectiveType: '', downlink: 0 };
}
export function setNetState(s) {
if (typeof window !== 'undefined') window.__APT_NET_STATE = s;
}
export function initNetObserver() {
const update = () => setNetState(computeNetState());
update(); // seed immediately
const nc = (typeof navigator !== 'undefined') && (navigator.connection || navigator.webkitConnection || navigator.mozConnection);
if (nc && nc.addEventListener) {
nc.addEventListener('change', update);
return () => nc.removeEventListener('change', update);
}
// Some browsers use onChange
if (nc && 'onchange' in nc) {
const handler = () => update();
nc.onchange = handler;
return () => { if (nc.onchange === handler) nc.onchange = null; };
}
// No-op cleanup
return () => {};
}