Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ VITE_TURNSTILE_SITE_KEY=
# PostHog error tracking. Unset means errors stay in the console.
VITE_POSTHOG_KEY=
VITE_POSTHOG_HOST=https://us.i.posthog.com

# Dashboard modules that are built but not currently offered. Set to "true" to
# show them; anything else (including absent) keeps them hidden.
VITE_FEATURE_KNOWLEDGE_LIBRARY=
VITE_FEATURE_ESSAYS=
11 changes: 11 additions & 0 deletions web/src/components/ApplicationTrackingModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ export default function ApplicationTrackingModule({ open, onClose, onEditIncome,
// (clicking a logo in Discover's list strip). Any other tab change clears it.
const [statusSchool, setStatusSchool] = useState<string | null>(null)
const switchTab = useCallback((t: TabId) => { setStatusSchool(null); setTab(t) }, [])

// The module stays mounted while closed — it holds the college list and its
// checklist — so without this it reopens wherever it was left. Someone who
// last looked at Discover came back to Discover, days later, with no memory
// of having gone there. Adjusted on the open transition rather than in an
// effect, so the first paint is already the right tab.
const [wasOpen, setWasOpen] = useState(open)
if (open !== wasOpen) {
setWasOpen(open)
if (open) { setTab('status'); setStatusSchool(null) }
}
const openInStatus = useCallback((collegeId: string | null) => { setStatusSchool(collegeId); setTab('status') }, [])
// Dismissal persists via mark_intro_seen, but that round-trips through a
// profile refresh; track it locally so the banner goes away on the click.
Expand Down
115 changes: 82 additions & 33 deletions web/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import type { Demographics } from '../types/user'
import FinancialAidModule from './FinancialAidModule'
import ApplicationTrackingModule from './ApplicationTrackingModule'
import FeeWaiverNotice from './FeeWaiverNotice'
import { FEATURES, moduleEnabled } from '../lib/features'
import { useApplications } from '../contexts/ApplicationsContext'
import { moduleStatusLines } from '../lib/moduleStatus'
import { feeWaiverEligibility, shouldShowFeeWaiverNotice, FEE_WAIVER_NOTICE_KEY } from '../lib/feeWaivers'
import EssaysModule from './EssaysModule'
import KnowledgeLibraryModule from './KnowledgeLibraryModule'
Expand Down Expand Up @@ -95,11 +98,31 @@ interface Props {
onSignOut?: () => void
}

const MODULES: { key: string; sub: string; color: string; emoji: string }[] = [
{ key: 'Knowledge Library', sub: 'Start Here', color: '#3F5BA9', emoji: '📚' },
{ key: 'Financial Aid', sub: 'Scholarship Hunt', color: '#C47A12', emoji: '💰' },
{ key: 'College Essays', sub: 'Drafting Season', color: '#1D7FC4', emoji: '🪶' },
{ key: 'Application Tracking', sub: 'Building Your List', color: '#7048C8', emoji: '📋' },
/**
* Every module the dashboard knows. What it offers is this, filtered by the
* feature flags — see lib/features.ts.
*
* `blurb` says what the module is for, in the student's terms. The old cards
* carried only a two-word tagline ("Scholarship Hunt"), which reads as
* decoration rather than an answer to "what is this and why would I open it".
*/
const MODULES: { key: string; sub: string; blurb: string; color: string; emoji: string }[] = [
{
key: 'Knowledge Library', sub: 'Start Here', color: '#3F5BA9', emoji: '📚',
blurb: 'What actually matters in getting into college and paying for it, and where to do each part.',
},
{
key: 'Financial Aid', sub: 'Scholarship Hunt', color: '#C47A12', emoji: '💰',
blurb: 'Find scholarships you qualify for, track what you have applied to, and work out what a college will really cost.',
},
{
key: 'College Essays', sub: 'Drafting Season', color: '#1D7FC4', emoji: '🪶',
blurb: 'Draft every essay in one place, with word counts and status, so nothing lives in a document you cannot find later.',
},
{
key: 'Application Tracking', sub: 'Building Your List', color: '#7048C8', emoji: '📋',
blurb: 'Build your college list, keep every deadline and requirement together, and see where each application stands.',
},
]


Expand Down Expand Up @@ -207,7 +230,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P
// Next-due deadline per module card, derived from the student's college list.
const {
events: deadlineEvents, failed: deadlinesFailed, toggleDone, addOwn, removeOwn, correctDate,
setNote,
setNote, trackedScholarships,
} = useDeadlineEvents(startIdx, {
// Entering or leaving a module refetches, so a scholarship added in one
// shows up without the panel ever going dark.
Expand All @@ -217,6 +240,13 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P
// never learns the two are the same thing.
onNotice: toast.info,
})
const { apps } = useApplications()
const moduleStatus = useMemo(() => moduleStatusLines({
colleges: apps.length,
submitted: apps.filter((a) => a.status === 'submitted').length,
scholarships: trackedScholarships,
}), [apps, trackedScholarships])

// One clock for every dated view on this page, so the week strip, the panel
// and the module chips can't disagree about which day is today.
const now = useMemo(() => new Date(), [])
Expand Down Expand Up @@ -255,7 +285,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P

// Sort modules by need (lower answer = higher priority)
// TODO: use shared constants for module key mapping
const sorted = [...MODULES].sort((a, b) => {
const sorted = MODULES.filter((m) => moduleEnabled(m.key)).sort((a, b) => {
const aScore = answers[a.key.toLowerCase().replace(/ /g, '-')] ?? 2
const bScore = answers[b.key.toLowerCase().replace(/ /g, '-')] ?? 2
return aScore - bScore
Expand Down Expand Up @@ -377,7 +407,7 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.06, duration: 0.5, ease: EASE_OUT }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
whileHover={{ y: -2, transition: { duration: 0.2 } }}
onClick={() => {
if (mod.key === 'Financial Aid' && !profile?.settings?.intros_seen?.includes('fafsa')) {
setShowFafsaIntro(true)
Expand All @@ -386,28 +416,43 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P
}
}}
>
<div className="dash-module-banner" style={{ background: mod.color }}>
<span className="dash-module-emoji">{mod.emoji}</span>
</div>
<span
className="dash-module-tile"
style={{ background: mod.color }}
aria-hidden="true"
>
{mod.emoji}
</span>
<div className="dash-module-body">
<div className="dash-module-name-row">
<h3 className="dash-module-name">{mod.key}</h3>
<h3 className="dash-module-name">{mod.key}</h3>
<p className="dash-module-sub">{mod.blurb}</p>
{/* What is in there, and what is next. The pill used to
sit beside the title in the module's own colour,
which made the loudest thing on the row a date the
panel to the right already lists. */}
<div className="dash-module-meta">
{moduleStatus[mod.key] && <span>{moduleStatus[mod.key]}</span>}
{moduleStatus[mod.key] && nextDueByModule[mod.key] && (
<span className="dash-module-sep" aria-hidden="true">·</span>
)}
{nextDueByModule[mod.key] && (
<button
className="dash-module-due"
style={{ color: nextDueByModule[mod.key]!.color, background: nextDueByModule[mod.key]!.color + '18', borderColor: nextDueByModule[mod.key]!.color + '33' }}
title={`Next due: ${nextDueByModule[mod.key]!.title} — ${nextDueByModule[mod.key]!.dateDisplay} · open in calendar`}
onClick={(e) => { e.stopPropagation(); setPage('calendar') }}
>
⏰ <span className="dash-due-date">{nextDueByModule[mod.key]!.date.toLocaleString('default', { month: 'short', day: 'numeric' })}</span><span className="dash-due-sep"> · </span>{nextDueByModule[mod.key]!.shortTitle}{nextDueByModule[mod.key]!.estimated ? ' · est.' : ''}
Next{' '}
<span className="dash-due-date">
{nextDueByModule[mod.key]!.date.toLocaleString('default', { month: 'short', day: 'numeric' })}
</span>
<span className="dash-due-sep"> · </span>
{nextDueByModule[mod.key]!.shortTitle}
{nextDueByModule[mod.key]!.estimated ? ' · est.' : ''}
</button>
)}
</div>
<p className="dash-module-sub">{mod.sub}</p>
<div className="dash-module-footer">
<span className="dash-module-open">Open →</span>
</div>
</div>
<span className="dash-module-open" aria-hidden="true">Open →</span>
</motion.div>
))}
</div>
Expand Down Expand Up @@ -599,13 +644,15 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P
/>
</ModuleErrorBoundary>

<ModuleErrorBoundary onClose={() => setOpenModule(null)}>
<KnowledgeLibraryModule
open={openModule === 'Knowledge Library'}
onClose={() => setOpenModule(null)}
onOpenModule={(key) => setOpenModule(key)}
/>
</ModuleErrorBoundary>
{FEATURES.knowledgeLibrary && (
<ModuleErrorBoundary onClose={() => setOpenModule(null)}>
<KnowledgeLibraryModule
open={openModule === 'Knowledge Library'}
onClose={() => setOpenModule(null)}
onOpenModule={(key) => setOpenModule(key)}
/>
</ModuleErrorBoundary>
)}

<ModuleErrorBoundary onClose={() => setOpenModule(null)}>
<ApplicationTrackingModule
Expand All @@ -616,13 +663,15 @@ export default function Dashboard({ startIdx, answers, firstName, onSignOut }: P
/>
</ModuleErrorBoundary>

<ModuleErrorBoundary onClose={() => setOpenModule(null)}>
<EssaysModule
open={openModule === 'College Essays'}
onClose={() => setOpenModule(null)}
aside={deadlineRail}
/>
</ModuleErrorBoundary>
{FEATURES.essays && (
<ModuleErrorBoundary onClose={() => setOpenModule(null)}>
<EssaysModule
open={openModule === 'College Essays'}
onClose={() => setOpenModule(null)}
aside={deadlineRail}
/>
</ModuleErrorBoundary>
)}

{/* FAFSA intro → definition → module chain */}
<AnimatePresence>
Expand Down
18 changes: 13 additions & 5 deletions web/src/components/FeeWaiverNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ interface Props {
onDismiss: () => void
}

/** One line instead of three. The dashboard's job is to get a student to the
* week ahead; the full case for waivers is made inside the module, where
* there is room for it and a reason to be reading it. */
const SHORT = {
likely: 'Fees run $50–$90 a school, and waivers cover them in full.',
unknown: 'Add your household income and we can tell you whether you qualify.',
} as const

const COPY = {
likely: {
title: 'You likely qualify for application fee waivers',
Expand Down Expand Up @@ -64,9 +72,9 @@ export default function FeeWaiverNotice({ eligibility, variant, onPrimary, onDis
border: `1px solid ${C.border}`,
borderLeft: `3px solid ${MC}`,
borderRadius: 14,
padding: '14px 16px',
padding: variant === 'dashboard' ? '11px 14px' : '14px 16px',
boxShadow: C.shadow1,
marginBottom: variant === 'dashboard' ? 20 : 16,
marginBottom: variant === 'dashboard' ? 14 : 16,
}}
>
<span aria-hidden="true" style={{ fontSize: 18, lineHeight: '22px' }}>
Expand All @@ -90,20 +98,20 @@ export default function FeeWaiverNotice({ eligibility, variant, onPrimary, onDis
</h2>
<p
style={{
margin: '5px 0 0',
margin: '4px 0 0',
fontFamily: "'Outfit',sans-serif",
fontSize: 13,
lineHeight: 1.5,
color: C.textMuted,
}}
>
{copy.body}
{variant === 'dashboard' ? SHORT[eligibility] : copy.body}
</p>
<button
type="button"
onClick={onPrimary}
style={{
marginTop: 10,
marginTop: variant === 'dashboard' ? 8 : 10,
padding: '7px 13px',
background: MC,
color: '#fff',
Expand Down
9 changes: 9 additions & 0 deletions web/src/components/FinancialAidModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2529,6 +2529,15 @@ export default function FinancialAidModule({ open, onClose, year = 11, aside }:

const isNarrow = useIsNarrow()
const [tab, setTab] = useState<TabId>('overview')
// Reopens on Overview rather than wherever it was left. The module stays
// mounted while closed, so the tab would otherwise survive from a visit the
// student no longer remembers. Adjusted on the open transition, not in an
// effect, so the first paint is already Overview.
const [wasOpen, setWasOpen] = useState(open)
if (open !== wasOpen) {
setWasOpen(open)
if (open) setTab('overview')
}
const [progress, setProgress] = useState<ChecklistProgressMap>({})
const [progressError, setProgressError] = useState<string | null>(null)
const [userDemoTags, setUserDemoTags] = useState<string[]>([])
Expand Down
Loading
Loading