From 4818e3421918c89bc00007104b34c45409eb99a9 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:56:51 -0700 Subject: [PATCH 01/18] =?UTF-8?q?Make=20the=20problem=20=E2=86=92=20code?= =?UTF-8?q?=20=E2=86=92=20Issues=20trail=20obvious=20on=20project=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback: visitors couldn't easily find a project's GitHub repo and its Issues. "Code & Tasks" was a collapsed accordion at the very bottom, showed static links only, and disappeared entirely when the problem statement's github field was empty — which is most hackathon-built projects, since team repos were only ever written to the team doc. - Code & Tasks is now an always-visible section directly after the project description (unique id per problem statement; header shows an "N repos ↓" anchor tag) - Repo list merges problem_statement.github with repos from the hackathon teams that built the project (enriched event fetch; team.problem_statements linkage preferred, nonprofit fallback only for teams without it); deduped by link, team repos carry "Built by {team} ({event})" attribution - Live issue data per repo (open/closed chips + top 5 open issue titles) fetched lazily through the public /api/github/issues proxy — fire-once IntersectionObserver, batched Promise.all, single setState, errors swallowed so static links always render; zero open issues shows the "write the tickets like a PM" nudge - No repos at all → "Where's the code?" card linking the event page(s) and the project's Slack channel instead of hiding the section - Onboarding: new "Anatomy of a project page" walkthrough (problem → code → plan → people) in Using the Site; /projects copy and the join-a-project FAQ updated to match the new UI Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- .../Onboarding/HowItWorksSection.js | 2 +- src/components/Onboarding/OnboardingFAQ.js | 7 +- .../Onboarding/WebsiteTourSection.js | 69 ++- .../ProblemStatement/ProblemStatement.js | 580 ++++++++++++++++-- 5 files changed, 593 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5ce35ef..f54a36f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## "Refined" design scope (civic-editorial facelift) -Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this before extending the look to more pages). Migrated so far: `/` (`src/pages/index.js`), `/projects` (`src/components/ProjectList/*`), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), `/about`, `/about/judges`, `/about/mentors` (`Mentorship` rewritten as refined sections), `/about/success-stories`, `/about/completion`, `/sponsor`, `/nonprofits` (`NonProfitList` + new calm card `NonProfitListTileRefined`; original `NonProfitListTile` stays for event pages), `/blog` (`BlogPage`; reused `News` list kept), `/onboarding` (refined chrome; wizard logic intact; **July 2026 content overhaul** — 9 steps: Welcome, Mission, How It Works [full lifecycle incl. post-hackathon Definition of Done + project-status ladder], Get Involved [hacker/mentor/judge/volunteer/nonprofit role cards], Using the Site [site map + first-steps guide + "public portfolio" band: GitHub/Slack/demo-videos/public-profile are what recruiters can review; work is documented as GitHub Issues for public credit — no issues in a repo → pull the code and write them like a PM], Slack, Introduce Yourself, FAQs, Feedback. New sections: `HowItWorksSection`/`RolesSection`/`WebsiteTourSection` in `src/components/Onboarding/`; `JudgingOverview`/`MentoringOverview`/`BuddySystem` are now orphaned — don't re-add them or their claims. FAQ answers must reflect real flows: there are NO project leads, NO commenting on projects, NO buddy system — joining a project = "Want to help?" toggle / its Slack channel / GitHub repo (write Issues like a PM if none exist) / next hackathon. **Design contract:** step content renders inside a scoped `onboardingTheme` ThemeProvider (in `pages/onboarding/index.js` — Fraunces headings, Hanken body, navy/terracotta palette, flat hairline Papers/Cards; don't reintroduce elevation shadows, hover-lifts, or rainbow chips) and every step opens with the shared `StepHeader` (renders `

`; the page masthead owns the `

`). Slack step leads with the two-account gotcha (an ohack.dev login [usually Google] does NOT create a Slack account — join via `/signup`) and real channel deep links: #introductions C01EY49JV8U, #ask-a-mentor C01E5CGDQ74, #random C06BRHRS5BQ, plus per-project #npo-* channels; never reference #help/#team-formation/#buddy-matching/#project-matching — they don't exist), `/signup` (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed `join_slack_1.png`, navy CTA band; primary CTA is now a real `` — dropped the JS `router.push`/`window.open` branch; `handleSignupClick` only fires the `CompleteRegistration` GA event), `/volunteer`, `/about/hackers`, `/about/process` (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), `/praise` (refined chrome; `PraiseBoard` feed kept), `/hack/code-of-conduct` (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), `/profile/[userid]` public (`PublicProfile`), and a full refined pass on the own `/profile` editor (`Profile.js` — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, `PanelHeader` on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, `.ohx-card` Giveaway Entries; form logic untouched), `/myfeedback` (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, `.ohx-card` entries + `.ohx-tag` chips) and `/feedback/[userid]` (`GiveFeedback`, `ssr:false`: hero + `Section`-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native `.ohx-btn` submit; also fixed a pre-existing hooks-order bug where `if (!user)` returned before `useEffect`), `/hack/[event_id]/manageteam` (full rewrite: `RefinedRoot`+`RefinedFonts`, editorial masthead, `.ohx-card` gating panels, calm `--surface-2` panels for auth/apply gates; split `error` → `teamsError`+`formError`; lazy Slack/nonprofit fetches gated by `activeStep`/`showNewTeamForm` + `slackFetchedRef`/`nonprofitFetchedRef`; `onTeamUpdated` callback propagated to `TeamStatusPanel` so DevPost/demo label updates without reload; `fetchMyTeams()` after submit + scroll to `#team-hub`; `noindex` meta; no PII logs; `active_days=365` for Slack fetch), and `/hack/[event_id]/team/[team_id]` (full rewrite: editorial masthead + hairline `.ohx-card` sections + a **deep-linkable sticky Table of Contents** — sections are `
` with hover `#` copy-link anchors (keyboard accessible via `&:focus-visible`), the TOC lists only present sections (`aria-current="location"`) and tracks the active one via `IntersectionObserver`; embedded `MentorTeamPanel`/`TeamCompletionChecklist` keep their own styling in anchored `headed={false}` wrappers). **Key invariants on this page:** (1) Status labels come from `TEAM_STATUS_OPTIONS.find()` via `statusLabel()` — never render raw enum strings like `NONPROFIT_SELECTED`; winning statuses get a 🏆 prefix from `getWinningStatus()`; `INACTIVE` skips the status tag since the dot already conveys it. (2) `SectionBlock` is **module-scope** (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver `setActiveId` tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives `copiedId`/`onCopyLink` as props. (3) `parseLocalDate` from `src/lib/dateUtils.js` is used for all event window checks (`eventHasStarted`, `eventEnded`) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses `useTeamMembership(eventId, teamId)` from `src/hooks/use-team-membership.js` — shared by the page and passed as `isOnTeam`/`membershipChecked` props to `TeamCompletionChecklist` (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render `selected_nonprofit_id` text — only show when `nonprofitData.name` is available; `getStaticProps` fetches `{ name, description }` into `nonprofitData`; a "Nonprofit partner" section card links to `/nonprofit/`. (6) `awards[]` rendered as `ohx-tag--accent` chips after the status tag. (7) All GitHub repos in `github_links[]` rendered (handles both string and `{link,name}` shapes). (8) `getStaticProps` rethrows network errors (ISR keeps the last good version) and returns `notFound` only on genuine 404. **Harmonized (not full rewrite):** `/hack` index (keeps its bespoke finder + `HackPageNav`; hero/CTAs/Support band recolored via inline `RX` tokens). The upcoming/current event cards were refined too: `HackathonList` full-mode heading → Fraunces eyebrow; `EventFeature` full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since `/hack` isn't a `RefinedRoot`). `ImpactMetrics` (shared with the archive) → warm `--surface-2` tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow `color`-prop numbers). `/hack/[event_id]` got a **masthead-led refined pass**: body wrapped in `` (it's now the `
`; all section IDs + `TableOfContents` + `FloatingNavigation` preserved), `HackathonHeader` rebuilt into a scope-independent editorial masthead (inline styles w/ `var(--x, fallback)` so `/agenda` + `/census` also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the **event-only** sub-components: `NonprofitList` (calm clickable cards + navy view-toggle), `EventConstraints`, `DonationProgress` (navy rings), `EventCountdown` (navy countdown card replacing the gradient + flat timeline), `TeamList`/`TeamCard` (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — `TeamList` is event-page-only despite earlier substring matches in admin/event-teams; **profile avatars load lazily per-card via IntersectionObserver with `rootMargin: "200px"`, tracked by `fetchedTeamProfilesRef` Set** — do NOT restore the eager `fetchTeamMemberProfiles(teams)` call on mount that fired N parallel requests for all 30+ cards at once). **Backend C7 enrichment:** `get_single_hackathon_event` (`services/hackathons_service.py`) now calls `_enrich_teams_users_batch` after converting team DocumentReferences — one `db.get_all` across all members of all teams, deduped, so the frontend receives `users[]` already as `{id, user_id, name, nickname, profile_image}` objects. `HackathonResults.js`/`TeamList.js` handle both the old id-string and new object shapes (backwards-compatible). The list endpoint `get_hackathon_list` is NOT enriched — only the single-event getter., and `HackathonLeaderboard` (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). **Phase 3 (done) finished the event page**: `EventLinks` (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), `InteractiveFAQ` (flat hairline accordions), `HackathonResults` (calm `--surface-2` frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), `VolunteerList` (`PersonCard` → flat hairline). The **shared** ones (`InteractiveFAQ`, `HackathonResults`) use **inline CSS-var fallbacks** (`var(--ink,#16181D)`) so they render refined on their non-`RefinedRoot` routes too (verified on `/hack/[event_id]/results` + `OnboardingFAQ`) — no per-route gating. A final chrome pass refined `TableOfContents` (navy active pill, was magenta), `FloatingNavigation` (navy FAB), `VolunteerList` (navy "Learn more" + navy availability chips; in-person stays green), and `MentorAvailability` (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: `/hack/request` (host-an-event page + `HackathonRequestForm`) and `/office-hours`. **`/hack/request` is student-first** — org-type selectable cards default to **university** (was corporate); the **$5k budget minimum was removed** (slider min `$0`, never blocks submit, reframed as optional funding); the donation-% ask is **corporate-only** (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm `--surface-2` cards; everything navy/terracotta via a scoped MUI `ThemeProvider` (`createTheme(base, …)`). **`formData` keys are preserved** — `HackathonRequestDetailDialog`, the edit page `/hack/request/[request_id]`, and the backend all depend on them. **Scroll gotcha:** step nav now `scrollIntoView`s a `formTopRef` on the form's Paper (`scrollMarginTop:88`), NOT `window.scrollTo(0,0)` (which overshot to page-top now that the form sits below the hero). `/office-hours` now renders its JSON-LD, uses the idempotent `initFacebookPixel` (was a raw `ReactPixel.init` — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: `/nonprofit/[nonprofit_id]` (`NonProfit.js` — editorial masthead, meta row as `.ohx-tag`/`.ohx-link`s, projects list, canonical www fix) and `/project/[project_id]` (`Project.js` — `RefinedRoot` chrome, soft breadcrumb, canonical). Both share `ProblemStatement.js` which was fully restyled (purple gradient hero → calm `--surface-2` band; gradient chips → `.ohx-tag`; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline `.ohx-card`; gradient CTAs → `.ohx-btn`). **Key invariant:** `ProblemStatement` is ONLY ever inside a `RefinedRoot`, so it may use scoped `.ohx-*` classes directly (no inline `var(--x, fallback)` needed). Pass `headingLevel="h2"` from the nonprofit page (N cards, nonprofit name is the `

`) and `headingLevel="h1"` from the project page (project title is the `

`). The "Want to help?" toggle is hidden when `status === "production"` (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; `maintenance` status keeps the toggle. `styles/nonprofit/styles.js` is now orphaned — leave in place. +Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this before extending the look to more pages). Migrated so far: `/` (`src/pages/index.js`), `/projects` (`src/components/ProjectList/*`), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), `/about`, `/about/judges`, `/about/mentors` (`Mentorship` rewritten as refined sections), `/about/success-stories`, `/about/completion`, `/sponsor`, `/nonprofits` (`NonProfitList` + new calm card `NonProfitListTileRefined`; original `NonProfitListTile` stays for event pages), `/blog` (`BlogPage`; reused `News` list kept), `/onboarding` (refined chrome; wizard logic intact; **July 2026 content overhaul** — 9 steps: Welcome, Mission, How It Works [full lifecycle incl. post-hackathon Definition of Done + project-status ladder], Get Involved [hacker/mentor/judge/volunteer/nonprofit role cards], Using the Site [site map + first-steps guide + "Anatomy of a project page" numbered walkthrough (problem → code → plan → people; mirrors ProblemStatement.js section order — keep in sync) + "public portfolio" band: GitHub/Slack/demo-videos/public-profile are what recruiters can review; work is documented as GitHub Issues for public credit — no issues in a repo → pull the code and write them like a PM], Slack, Introduce Yourself, FAQs, Feedback. New sections: `HowItWorksSection`/`RolesSection`/`WebsiteTourSection` in `src/components/Onboarding/`; `JudgingOverview`/`MentoringOverview`/`BuddySystem` are now orphaned — don't re-add them or their claims. FAQ answers must reflect real flows: there are NO project leads, NO commenting on projects, NO buddy system — joining a project = "Want to help?" toggle / its Slack channel / GitHub repo (write Issues like a PM if none exist) / next hackathon. **Design contract:** step content renders inside a scoped `onboardingTheme` ThemeProvider (in `pages/onboarding/index.js` — Fraunces headings, Hanken body, navy/terracotta palette, flat hairline Papers/Cards; don't reintroduce elevation shadows, hover-lifts, or rainbow chips) and every step opens with the shared `StepHeader` (renders `

`; the page masthead owns the `

`). Slack step leads with the two-account gotcha (an ohack.dev login [usually Google] does NOT create a Slack account — join via `/signup`) and real channel deep links: #introductions C01EY49JV8U, #ask-a-mentor C01E5CGDQ74, #random C06BRHRS5BQ, plus per-project #npo-* channels; never reference #help/#team-formation/#buddy-matching/#project-matching — they don't exist), `/signup` (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed `join_slack_1.png`, navy CTA band; primary CTA is now a real `` — dropped the JS `router.push`/`window.open` branch; `handleSignupClick` only fires the `CompleteRegistration` GA event), `/volunteer`, `/about/hackers`, `/about/process` (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), `/praise` (refined chrome; `PraiseBoard` feed kept), `/hack/code-of-conduct` (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), `/profile/[userid]` public (`PublicProfile`), and a full refined pass on the own `/profile` editor (`Profile.js` — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, `PanelHeader` on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, `.ohx-card` Giveaway Entries; form logic untouched), `/myfeedback` (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, `.ohx-card` entries + `.ohx-tag` chips) and `/feedback/[userid]` (`GiveFeedback`, `ssr:false`: hero + `Section`-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native `.ohx-btn` submit; also fixed a pre-existing hooks-order bug where `if (!user)` returned before `useEffect`), `/hack/[event_id]/manageteam` (full rewrite: `RefinedRoot`+`RefinedFonts`, editorial masthead, `.ohx-card` gating panels, calm `--surface-2` panels for auth/apply gates; split `error` → `teamsError`+`formError`; lazy Slack/nonprofit fetches gated by `activeStep`/`showNewTeamForm` + `slackFetchedRef`/`nonprofitFetchedRef`; `onTeamUpdated` callback propagated to `TeamStatusPanel` so DevPost/demo label updates without reload; `fetchMyTeams()` after submit + scroll to `#team-hub`; `noindex` meta; no PII logs; `active_days=365` for Slack fetch), and `/hack/[event_id]/team/[team_id]` (full rewrite: editorial masthead + hairline `.ohx-card` sections + a **deep-linkable sticky Table of Contents** — sections are `
` with hover `#` copy-link anchors (keyboard accessible via `&:focus-visible`), the TOC lists only present sections (`aria-current="location"`) and tracks the active one via `IntersectionObserver`; embedded `MentorTeamPanel`/`TeamCompletionChecklist` keep their own styling in anchored `headed={false}` wrappers). **Key invariants on this page:** (1) Status labels come from `TEAM_STATUS_OPTIONS.find()` via `statusLabel()` — never render raw enum strings like `NONPROFIT_SELECTED`; winning statuses get a 🏆 prefix from `getWinningStatus()`; `INACTIVE` skips the status tag since the dot already conveys it. (2) `SectionBlock` is **module-scope** (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver `setActiveId` tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives `copiedId`/`onCopyLink` as props. (3) `parseLocalDate` from `src/lib/dateUtils.js` is used for all event window checks (`eventHasStarted`, `eventEnded`) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses `useTeamMembership(eventId, teamId)` from `src/hooks/use-team-membership.js` — shared by the page and passed as `isOnTeam`/`membershipChecked` props to `TeamCompletionChecklist` (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render `selected_nonprofit_id` text — only show when `nonprofitData.name` is available; `getStaticProps` fetches `{ name, description }` into `nonprofitData`; a "Nonprofit partner" section card links to `/nonprofit/`. (6) `awards[]` rendered as `ohx-tag--accent` chips after the status tag. (7) All GitHub repos in `github_links[]` rendered (handles both string and `{link,name}` shapes). (8) `getStaticProps` rethrows network errors (ISR keeps the last good version) and returns `notFound` only on genuine 404. **Harmonized (not full rewrite):** `/hack` index (keeps its bespoke finder + `HackPageNav`; hero/CTAs/Support band recolored via inline `RX` tokens). The upcoming/current event cards were refined too: `HackathonList` full-mode heading → Fraunces eyebrow; `EventFeature` full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since `/hack` isn't a `RefinedRoot`). `ImpactMetrics` (shared with the archive) → warm `--surface-2` tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow `color`-prop numbers). `/hack/[event_id]` got a **masthead-led refined pass**: body wrapped in `` (it's now the `
`; all section IDs + `TableOfContents` + `FloatingNavigation` preserved), `HackathonHeader` rebuilt into a scope-independent editorial masthead (inline styles w/ `var(--x, fallback)` so `/agenda` + `/census` also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the **event-only** sub-components: `NonprofitList` (calm clickable cards + navy view-toggle), `EventConstraints`, `DonationProgress` (navy rings), `EventCountdown` (navy countdown card replacing the gradient + flat timeline), `TeamList`/`TeamCard` (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — `TeamList` is event-page-only despite earlier substring matches in admin/event-teams; **profile avatars load lazily per-card via IntersectionObserver with `rootMargin: "200px"`, tracked by `fetchedTeamProfilesRef` Set** — do NOT restore the eager `fetchTeamMemberProfiles(teams)` call on mount that fired N parallel requests for all 30+ cards at once). **Backend C7 enrichment:** `get_single_hackathon_event` (`services/hackathons_service.py`) now calls `_enrich_teams_users_batch` after converting team DocumentReferences — one `db.get_all` across all members of all teams, deduped, so the frontend receives `users[]` already as `{id, user_id, name, nickname, profile_image}` objects. `HackathonResults.js`/`TeamList.js` handle both the old id-string and new object shapes (backwards-compatible). The list endpoint `get_hackathon_list` is NOT enriched — only the single-event getter., and `HackathonLeaderboard` (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). **Phase 3 (done) finished the event page**: `EventLinks` (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), `InteractiveFAQ` (flat hairline accordions), `HackathonResults` (calm `--surface-2` frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), `VolunteerList` (`PersonCard` → flat hairline). The **shared** ones (`InteractiveFAQ`, `HackathonResults`) use **inline CSS-var fallbacks** (`var(--ink,#16181D)`) so they render refined on their non-`RefinedRoot` routes too (verified on `/hack/[event_id]/results` + `OnboardingFAQ`) — no per-route gating. A final chrome pass refined `TableOfContents` (navy active pill, was magenta), `FloatingNavigation` (navy FAB), `VolunteerList` (navy "Learn more" + navy availability chips; in-person stays green), and `MentorAvailability` (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: `/hack/request` (host-an-event page + `HackathonRequestForm`) and `/office-hours`. **`/hack/request` is student-first** — org-type selectable cards default to **university** (was corporate); the **$5k budget minimum was removed** (slider min `$0`, never blocks submit, reframed as optional funding); the donation-% ask is **corporate-only** (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm `--surface-2` cards; everything navy/terracotta via a scoped MUI `ThemeProvider` (`createTheme(base, …)`). **`formData` keys are preserved** — `HackathonRequestDetailDialog`, the edit page `/hack/request/[request_id]`, and the backend all depend on them. **Scroll gotcha:** step nav now `scrollIntoView`s a `formTopRef` on the form's Paper (`scrollMarginTop:88`), NOT `window.scrollTo(0,0)` (which overshot to page-top now that the form sits below the hero). `/office-hours` now renders its JSON-LD, uses the idempotent `initFacebookPixel` (was a raw `ReactPixel.init` — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: `/nonprofit/[nonprofit_id]` (`NonProfit.js` — editorial masthead, meta row as `.ohx-tag`/`.ohx-link`s, projects list, canonical www fix) and `/project/[project_id]` (`Project.js` — `RefinedRoot` chrome, soft breadcrumb, canonical). Both share `ProblemStatement.js` which was fully restyled (purple gradient hero → calm `--surface-2` band; gradient chips → `.ohx-tag`; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline `.ohx-card`; gradient CTAs → `.ohx-btn`). **Key invariant:** `ProblemStatement` is ONLY ever inside a `RefinedRoot`, so it may use scoped `.ohx-*` classes directly (no inline `var(--x, fallback)` needed). Pass `headingLevel="h2"` from the nonprofit page (N cards, nonprofit name is the `

`) and `headingLevel="h1"` from the project page (project title is the `

`). The "Want to help?" toggle is hidden when `status === "production"` (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; `maintenance` status keeps the toggle. **"Code & Tasks" (July 2026 discoverability fix):** it is an ALWAYS-VISIBLE section (`id="code-and-tasks-"` — unique per problem statement because nonprofit pages render N cards; `scrollMarginTop: 96`) directly after the Project Description — never move it back into the collapsed accordion trio (burying it was the bug; only References/Events stay accordions). Repo list = `problem_statement.github` (handles legacy string shape) MERGED with repos from hackathon teams in the already-fetched event data (`team.problem_statements` includes the ps id; `selected_nonprofit_id`-vs-`resolvedNonprofits` fallback applies ONLY to teams with no `problem_statements` — avoids claiming a sibling project's repos on multi-project nonprofits), deduped by `normalizeRepoLink`; team repos render under a "from hackathon teams" label with "Built by {team} ({event})" attribution, and a duplicate just enriches the project-level card. Live issue data (counts + top-5 open titles per repo) is fetched through the public backend proxy `GET /api/github/issues?org&repo&state=all` ONLY once the section scrolls near (fire-once IntersectionObserver, `rootMargin: 200px`) — batched Promise.all + `issuesRequestedRef` dedupe + ONE setState (never per-repo), errors swallowed (static links still render); backend caches this route 10 min. `RepoCard` + repo-link helpers are **module-scope** (SectionBlock remount lesson). When no repos exist at all, render the "Where's the code?" `--surface-2` card (event-page links + `slack_channel` button) — never hide the section silently. Header top row shows an `#code-and-tasks` anchor `.ohx-tag` when repos exist. Backend fix-forward: `approve_team` (`api/teams/teams_service.py::_link_repo_to_problem_statements`) now also appends the created repo to the linked problem statement(s)' `github` array (team `problem_statements` refs preferred; single-PS-nonprofit fallback; ambiguous → skip + log) and clears `get_single_problem_statement_old`'s cache. `styles/nonprofit/styles.js` is now orphaned — leave in place. See `docs/refined-design-system.md`. Pattern for new pages: keep `Head`/`getStaticProps`/schema verbatim, add ``, wrap body in ``, rebuild sections with `.ohx-*` classes, one `

`, preserve GA. diff --git a/src/components/Onboarding/HowItWorksSection.js b/src/components/Onboarding/HowItWorksSection.js index 0524591..1424680 100644 --- a/src/components/Onboarding/HowItWorksSection.js +++ b/src/components/Onboarding/HowItWorksSection.js @@ -27,7 +27,7 @@ const stages = [ { title: 'Problems become projects', description: - 'Each selected challenge is published as a project on ohack.dev/projects with a description, the skills needed, a GitHub repository — where the work to be done is documented as public GitHub Issues — and its own Slack channel where the work is coordinated.' + 'Each selected challenge is published as a project on ohack.dev/projects with a description, the skills needed, a "Code & Tasks" section listing its GitHub repositories — where the work to be done is documented as public GitHub Issues — and its own Slack channel where the work is coordinated.' }, { title: 'The hackathon weekend', diff --git a/src/components/Onboarding/OnboardingFAQ.js b/src/components/Onboarding/OnboardingFAQ.js index c82e972..b657330 100644 --- a/src/components/Onboarding/OnboardingFAQ.js +++ b/src/components/Onboarding/OnboardingFAQ.js @@ -199,9 +199,10 @@ const OnboardingFAQ = () => {
  • - Check the GitHub repo - Every project page has a "Code & Tasks" section with a - Code and an Issues button for each repository. Reading the README and open issues is the fastest way - to find something concrete to pick up. + Check the GitHub repo - Every project page has a "Code & Tasks" section right + below the description, listing each repository — including repos built by teams at past hackathons — + with open-issue counts and the top open tickets shown right on the page. Reading the README and open + issues is the fastest way to find something concrete to pick up.
  • diff --git a/src/components/Onboarding/WebsiteTourSection.js b/src/components/Onboarding/WebsiteTourSection.js index 4a876df..bc7b23b 100644 --- a/src/components/Onboarding/WebsiteTourSection.js +++ b/src/components/Onboarding/WebsiteTourSection.js @@ -60,7 +60,7 @@ const destinations = [ path: '/projects', title: 'Projects', description: - 'Every project we\'ve scoped for a nonprofit, searchable and filterable by status. Each project page shows the description, GitHub repo, and the project\'s Slack channel. To get involved with a project that needs help, sign in and slide its "Want to help?" toggle, then join its Slack channel.' + 'Every project we\'ve scoped for a nonprofit, searchable and filterable by status. Each project page walks you from the problem description to the "Code & Tasks" section — every GitHub repo with its open Issues — plus the project\'s Slack channel. To get involved with a project that needs help, sign in and slide its "Want to help?" toggle, then join its Slack channel.' }, { icon: , @@ -104,6 +104,31 @@ const destinations = [ } ]; +// Mirrors the actual layout of /project/ (ProblemStatement.js) — +// keep in sync if that page's section order changes +const projectPageAnatomy = [ + { + title: 'The problem', + description: + 'The project description at the top explains what the nonprofit needs and why it matters. Reference documents below it (slides, requirement docs, demo videos) fill in the full story.' + }, + { + title: 'The code', + description: + 'The "Code & Tasks" section lists every GitHub repository for the project — including repos built by teams at past hackathons. Hit the Code button and start with the README.' + }, + { + title: 'The plan', + description: + 'Each repo\'s GitHub Issues are the work to be done — open-issue counts and the top open tickets show right on the project page. Pick one that fits your skills. No issues in a repo yet? Pull the code, run it, and write them yourself like a product manager — that\'s a real, visible contribution.' + }, + { + title: 'The people', + description: + 'Every project has a Slack channel (#npo-…) where the work is coordinated — join it and say hello. And if the project needs help, slide the "Want to help?" toggle so the community knows you\'re on board.' + } +]; + const portfolioSurfaces = [ { icon: , @@ -233,6 +258,48 @@ const WebsiteTourSection = () => { schedule and countdowns, reach mentors, and — after judging — see the results. + {/* Anatomy of a project page */} + + + Anatomy of a project page + + + The fastest way to understand any project on{' '} + ohack.dev/projects: read it + top to bottom as the problem, the code, the plan, and the people. + + {projectPageAnatomy.map((part, index) => ( + + + {index + 1} + + + + {part.title} + + + {part.description} + + + + ))} + + {/* Work in public / portfolio */} + (link || "").trim().replace(/\/+$/, "").toLowerCase(); + +const parseGithubRepo = (link) => { + const match = /github\.com\/([^/]+)\/([^/#?]+)/i.exec(link || ""); + return match + ? { org: match[1], repo: match[2].replace(/\.git$/i, "") } + : null; +}; + +const repoNameFromLink = (link) => { + const gh = parseGithubRepo(link); + return gh ? gh.repo : link; +}; + +// One repository card: name, team attribution, live issue chips, top open +// issues, and the Code / Issues deep-links. Module scope so it never remounts +// on parent state ticks (issue data arriving, help toggle, etc.). +const RepoCard = ({ repo, issueData, onLinkClick }) => { + const repoButtonStyle = { + fontSize: "0.82rem", + padding: "0.5em 0.9em", + display: "inline-flex", + alignItems: "center", + gap: 4, + }; + + return ( +
    +
    0 ? 4 : 10, + }} + > +

    + {repo.name} +

    + {issueData && ( + + {issueData.open} open + {issueData.closed > 0 ? ` · ${issueData.closed} closed` : ""} + + )} +
    + + {repo.builtBy.length > 0 && ( +

    + Built by{" "} + {repo.builtBy.map((b) => `${b.team} (${b.event})`).join(", ")} +

    + )} + +
    + + {issueData?.topOpen?.length > 0 && ( + + )} + + {issueData && issueData.open === 0 && ( +

    + No open issues yet — that's your opening. Pull the code, run it, and + write the first tickets like a product manager. Closed tickets are + public credit for the work. +

    + )} +
    + ); +}; + export default function ProblemStatement({ problem_statement_id, user, @@ -129,6 +266,17 @@ export default function ProblemStatement({ const [helperProfiles, setHelperProfiles] = useState({}); const [isCheckingHelperStatus, setIsCheckingHelperStatus] = useState(false); + // Code & Tasks: lazy live GitHub issue data, keyed by normalized repo link + const [codeSectionVisible, setCodeSectionVisible] = useState(false); + const [repoIssueData, setRepoIssueData] = useState({}); + const issuesRequestedRef = useRef(new Set()); + const codeSectionRef = useRef(null); + // Enriched event data (full team docs) for team-repo derivation — the + // page's own event fetch uses the by-doc-id route whose teams[] are bare + // id strings, so we need the enriched by-event_id getter for github_links + const [teamRepoEvents, setTeamRepoEvents] = useState([]); + const [teamRepoEventsChecked, setTeamRepoEventsChecked] = useState(false); + const handleSectionToggle = (section) => { setExpandedSection(expandedSection === section ? null : section); }; @@ -265,6 +413,217 @@ export default function ProblemStatement({ } }, [problem_statement, user, profile, helperProfiles, isCheckingHelperStatus]); + // Fetch enriched event data (with full team docs) once the by-id events + // have loaded and given us the event_id slugs. The enriched endpoint is + // backend-cached (10-min TTL) and shared with the event page, so this is + // usually a cache hit. + useEffect(() => { + if (!problem_statement) return undefined; + if (!problem_statement.events?.length) { + setTeamRepoEventsChecked(true); + return undefined; + } + if (!hackathonEventsLoaded && !hackathonEventsError) return undefined; + + const slugs = [ + ...new Set( + hackathonEvents.map((event) => event?.event_id).filter(Boolean) + ), + ]; + if (slugs.length === 0) { + setTeamRepoEventsChecked(true); + return undefined; + } + + let cancelled = false; + Promise.all( + slugs.map(async (slug) => { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/hackathon/${encodeURIComponent(slug)}` + ); + if (!res.ok) return null; + return await res.json(); + } catch (error) { + return null; + } + }) + ).then((results) => { + if (cancelled) return; + setTeamRepoEvents(results.filter((r) => r && Array.isArray(r.teams))); + setTeamRepoEventsChecked(true); + }); + + return () => { + cancelled = true; + }; + }, [ + problem_statement, + hackathonEvents, + hackathonEventsLoaded, + hackathonEventsError, + ]); + + // Merge repos from the problem statement itself with repos from the + // hackathon teams that built it. Team linkage prefers the team's own + // problem_statements list; the nonprofit fallback only applies to teams + // with no problem_statements data (avoids claiming repos from a sibling + // project of a multi-project nonprofit). Dedupe by normalized link — a + // team repo matching a project-level repo just adds attribution. + const codeRepos = useMemo(() => { + if (!problem_statement) return []; + const map = new Map(); + + const rawGithub = problem_statement.github; + const projectLevel = Array.isArray(rawGithub) + ? rawGithub + : typeof rawGithub === "string" && rawGithub.trim() + ? [{ link: rawGithub.trim() }] + : []; + projectLevel.forEach((entry) => { + const link = typeof entry === "string" ? entry : entry?.link; + if (!link) return; + const key = normalizeRepoLink(link); + if (!map.has(key)) { + map.set(key, { + name: + (typeof entry === "object" && entry?.name) || + repoNameFromLink(link), + link, + isProjectRepo: true, + builtBy: [], + }); + } + }); + + const npoIds = new Set(resolvedNonprofits.map((n) => n.id)); + teamRepoEvents.forEach((event) => { + (event?.teams || []).forEach((team) => { + if (!team) return; + const psIds = (team.problem_statements || []) + .map((p) => (typeof p === "string" ? p : p?.id)) + .filter(Boolean); + const matches = + psIds.length > 0 + ? psIds.includes(problem_statement.id) + : team.selected_nonprofit_id && + npoIds.has(team.selected_nonprofit_id); + if (!matches) return; + (team.github_links || []).forEach((gl) => { + const link = typeof gl === "string" ? gl : gl?.link; + if (!link) return; + const key = normalizeRepoLink(link); + const attribution = { + team: team.name, + event: event.title || event.event_id, + }; + if (map.has(key)) { + const existing = map.get(key); + if ( + team.name && + !existing.builtBy.some((b) => b.team === team.name) + ) { + existing.builtBy.push(attribution); + } + } else { + map.set(key, { + name: + (typeof gl === "object" && gl?.name) || repoNameFromLink(link), + link, + isProjectRepo: false, + builtBy: team.name ? [attribution] : [], + }); + } + }); + }); + }); + + return Array.from(map.values()); + }, [problem_statement, teamRepoEvents, resolvedNonprofits]); + + // Only fetch issue data once the Code & Tasks section scrolls near + // (same fire-once IntersectionObserver pattern as TeamList's TeamCard) + useEffect(() => { + if (codeSectionVisible) return undefined; + const node = codeSectionRef.current; + if (!node) return undefined; + if (typeof IntersectionObserver === "undefined") { + setCodeSectionVisible(true); + return undefined; + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setCodeSectionVisible(true); + observer.disconnect(); + } + }, + { rootMargin: "200px" } + ); + observer.observe(node); + return () => observer.disconnect(); + }, [codeSectionVisible, problem_statement]); + + // Batched issue fetch through the backend proxy — dedupe via ref, + // Promise.all, ONE setState (never per-repo setState) + useEffect(() => { + if (!codeSectionVisible || codeRepos.length === 0) return undefined; + const pending = codeRepos.filter((repo) => { + const gh = parseGithubRepo(repo.link); + return gh && !issuesRequestedRef.current.has(normalizeRepoLink(repo.link)); + }); + if (pending.length === 0) return undefined; + pending.forEach((repo) => + issuesRequestedRef.current.add(normalizeRepoLink(repo.link)) + ); + + let cancelled = false; + Promise.all( + pending.map(async (repo) => { + const gh = parseGithubRepo(repo.link); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/github/issues?org=${encodeURIComponent( + gh.org + )}&repo=${encodeURIComponent(gh.repo)}&state=all` + ); + if (!res.ok) return null; + const data = await res.json(); + if (!data?.success || !Array.isArray(data.issues)) return null; + const openIssues = data.issues.filter( + (issue) => issue.state === "open" + ); + return [ + normalizeRepoLink(repo.link), + { + open: openIssues.length, + closed: data.issues.length - openIssues.length, + topOpen: openIssues.slice(0, 5).map((issue) => ({ + number: issue.issue_number, + title: issue.title, + })), + }, + ]; + } catch (error) { + return null; // swallow — static links still render + } + }) + ).then((entries) => { + if (cancelled) return; + const valid = entries.filter(Boolean); + if (valid.length > 0) { + setRepoIssueData((prev) => ({ + ...prev, + ...Object.fromEntries(valid), + })); + } + }); + + return () => { + cancelled = true; + }; + }, [codeSectionVisible, codeRepos]); + const handleChange = (panel) => (event, isExpanded) => { const params = { action_name: isExpanded ? "open" : "close", @@ -438,6 +797,27 @@ export default function ProblemStatement({ // Live projects don't need new volunteers; maintenance-status projects still do. const isProduction = problem_statement.status === "production"; + // Code & Tasks tiers: project-level repos are canonical; team-built repos + // from hackathons follow under their own quiet label + const projectRepos = codeRepos.filter((repo) => repo.isProjectRepo); + const teamRepos = codeRepos.filter((repo) => !repo.isProjectRepo); + + // Unique per problem statement — nonprofit pages render one + // ProblemStatement per project, so a fixed id would collide + const codeSectionId = `code-and-tasks-${problem_statement.id}`; + + const handleRepoLinkClick = (kind, repoLink) => { + trackEvent({ + action: "project_repo_click", + params: { + kind, + repo: repoLink, + problem_statement_id: problem_statement?.id, + npo_id: effectiveNpoId, + }, + }); + }; + // Dynamic heading element driven by headingLevel prop const TitleTag = headingLevel; @@ -721,6 +1101,18 @@ export default function ProblemStatement({ Since {problem_statement.first_thought_of} )} + {codeRepos.length > 0 && ( + + + {codeRepos.length} repo{codeRepos.length === 1 ? "" : "s"} ↓ + + )} @@ -847,6 +1239,132 @@ export default function ProblemStatement({ + {/* Code & Tasks — always visible, right after the problem: where the + code lives and what work remains (GitHub Issues). Never collapse + or hide this section — burying it was the discoverability bug. */} +
    +

    + Code & Tasks +

    + {codeRepos.length > 0 ? ( + <> +

    + The remaining work is tracked as public GitHub Issues. Open a + repo, read the README, then pick an open issue — closed issues + are public credit for your contribution. +

    + {projectRepos.length > 0 && ( + + {projectRepos.map((repo) => ( + + + + ))} + + )} + {teamRepos.length > 0 && ( + <> +

    0 ? "18px 0 10px" : "0 0 10px", + fontSize: "0.66rem", + }} + > + {projectRepos.length > 0 + ? "More repos from hackathon teams" + : "Built by teams at our hackathons"} +

    + + {teamRepos.map((repo) => ( + + + + ))} + + + )} + + ) : ( +
    + {problem_statement.events?.length > 0 && + !teamRepoEventsChecked ? ( +

    + Checking hackathon teams for code repositories… +

    + ) : ( + <> +

    + Where’s the code? +

    +

    + No repository is linked to this project yet. Code from + hackathon builds usually lives with the team that made it — + check the event page + {hackathonEvents.length === 1 ? "" : "s"} below, or ask in + the project’s Slack channel. +

    +
    + {hackathonEvents.map((event) => ( + + + {event.title || event.event_id} + + ))} + {problem_statement.slack_channel && ( + + + {problem_statement.slack_channel} + + )} +
    + + )} +
    + )} +
    + {/* Help toggle — hidden on production projects, except for existing helpers so they can still toggle themselves off */} {(!isProduction || help_checked === "checked") && ( @@ -880,66 +1398,6 @@ export default function ProblemStatement({ )} - {problem_statement.github?.length > 0 && - renderSection( - "github", - , - `Code & Tasks (${problem_statement.github.length} repos)`, - - {problem_statement.github.map((repo, index) => ( - -
    -

    - {repo.name} -

    - -
    -
    - ))} -
    - )} - {renderSection( "events", , From a6f68acb5effe7ae8fed65c29189f079a4794066 Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:03:55 -0700 Subject: [PATCH 02/18] Facelift the Events & Teams section into refined event cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old section rendered the event location + type as the heading (a street address), rainbow-era typography, and a blue MUI DevPost button — and its teams list was dead code: the teams state feeding EventTeams was never populated, so visitors never saw a single team. - Event cards now lead with event.title, a quiet Past/Upcoming tag, a compact date range (parseLocalDate), location + description as muted meta, and constraint tags ("Teams of 2–5", "Max 3 teams per project") - Ghost Event page / DevPost buttons replace the blue contained button - New read-only teams list (team name, member first names, "Team page →" link) fed by teamsByEvent from the same enriched-event memo that derives team repos — visible to everyone, no login gate - Removed the dead interactive join/leave plumbing from ProblemStatement (teams/userDetails state, per-user fetch effect, useTeams handlers); event-teams.js and event-team.js are now orphaned — team joining lives on the event page / manageteam flow Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- src/components/Events/Events.js | 410 ++++++++++-------- .../ProblemStatement/ProblemStatement.js | 80 +--- 3 files changed, 243 insertions(+), 249 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f54a36f..c8b5a5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## "Refined" design scope (civic-editorial facelift) -Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this before extending the look to more pages). Migrated so far: `/` (`src/pages/index.js`), `/projects` (`src/components/ProjectList/*`), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), `/about`, `/about/judges`, `/about/mentors` (`Mentorship` rewritten as refined sections), `/about/success-stories`, `/about/completion`, `/sponsor`, `/nonprofits` (`NonProfitList` + new calm card `NonProfitListTileRefined`; original `NonProfitListTile` stays for event pages), `/blog` (`BlogPage`; reused `News` list kept), `/onboarding` (refined chrome; wizard logic intact; **July 2026 content overhaul** — 9 steps: Welcome, Mission, How It Works [full lifecycle incl. post-hackathon Definition of Done + project-status ladder], Get Involved [hacker/mentor/judge/volunteer/nonprofit role cards], Using the Site [site map + first-steps guide + "Anatomy of a project page" numbered walkthrough (problem → code → plan → people; mirrors ProblemStatement.js section order — keep in sync) + "public portfolio" band: GitHub/Slack/demo-videos/public-profile are what recruiters can review; work is documented as GitHub Issues for public credit — no issues in a repo → pull the code and write them like a PM], Slack, Introduce Yourself, FAQs, Feedback. New sections: `HowItWorksSection`/`RolesSection`/`WebsiteTourSection` in `src/components/Onboarding/`; `JudgingOverview`/`MentoringOverview`/`BuddySystem` are now orphaned — don't re-add them or their claims. FAQ answers must reflect real flows: there are NO project leads, NO commenting on projects, NO buddy system — joining a project = "Want to help?" toggle / its Slack channel / GitHub repo (write Issues like a PM if none exist) / next hackathon. **Design contract:** step content renders inside a scoped `onboardingTheme` ThemeProvider (in `pages/onboarding/index.js` — Fraunces headings, Hanken body, navy/terracotta palette, flat hairline Papers/Cards; don't reintroduce elevation shadows, hover-lifts, or rainbow chips) and every step opens with the shared `StepHeader` (renders `

    `; the page masthead owns the `

    `). Slack step leads with the two-account gotcha (an ohack.dev login [usually Google] does NOT create a Slack account — join via `/signup`) and real channel deep links: #introductions C01EY49JV8U, #ask-a-mentor C01E5CGDQ74, #random C06BRHRS5BQ, plus per-project #npo-* channels; never reference #help/#team-formation/#buddy-matching/#project-matching — they don't exist), `/signup` (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed `join_slack_1.png`, navy CTA band; primary CTA is now a real `` — dropped the JS `router.push`/`window.open` branch; `handleSignupClick` only fires the `CompleteRegistration` GA event), `/volunteer`, `/about/hackers`, `/about/process` (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), `/praise` (refined chrome; `PraiseBoard` feed kept), `/hack/code-of-conduct` (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), `/profile/[userid]` public (`PublicProfile`), and a full refined pass on the own `/profile` editor (`Profile.js` — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, `PanelHeader` on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, `.ohx-card` Giveaway Entries; form logic untouched), `/myfeedback` (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, `.ohx-card` entries + `.ohx-tag` chips) and `/feedback/[userid]` (`GiveFeedback`, `ssr:false`: hero + `Section`-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native `.ohx-btn` submit; also fixed a pre-existing hooks-order bug where `if (!user)` returned before `useEffect`), `/hack/[event_id]/manageteam` (full rewrite: `RefinedRoot`+`RefinedFonts`, editorial masthead, `.ohx-card` gating panels, calm `--surface-2` panels for auth/apply gates; split `error` → `teamsError`+`formError`; lazy Slack/nonprofit fetches gated by `activeStep`/`showNewTeamForm` + `slackFetchedRef`/`nonprofitFetchedRef`; `onTeamUpdated` callback propagated to `TeamStatusPanel` so DevPost/demo label updates without reload; `fetchMyTeams()` after submit + scroll to `#team-hub`; `noindex` meta; no PII logs; `active_days=365` for Slack fetch), and `/hack/[event_id]/team/[team_id]` (full rewrite: editorial masthead + hairline `.ohx-card` sections + a **deep-linkable sticky Table of Contents** — sections are `
    ` with hover `#` copy-link anchors (keyboard accessible via `&:focus-visible`), the TOC lists only present sections (`aria-current="location"`) and tracks the active one via `IntersectionObserver`; embedded `MentorTeamPanel`/`TeamCompletionChecklist` keep their own styling in anchored `headed={false}` wrappers). **Key invariants on this page:** (1) Status labels come from `TEAM_STATUS_OPTIONS.find()` via `statusLabel()` — never render raw enum strings like `NONPROFIT_SELECTED`; winning statuses get a 🏆 prefix from `getWinningStatus()`; `INACTIVE` skips the status tag since the dot already conveys it. (2) `SectionBlock` is **module-scope** (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver `setActiveId` tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives `copiedId`/`onCopyLink` as props. (3) `parseLocalDate` from `src/lib/dateUtils.js` is used for all event window checks (`eventHasStarted`, `eventEnded`) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses `useTeamMembership(eventId, teamId)` from `src/hooks/use-team-membership.js` — shared by the page and passed as `isOnTeam`/`membershipChecked` props to `TeamCompletionChecklist` (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render `selected_nonprofit_id` text — only show when `nonprofitData.name` is available; `getStaticProps` fetches `{ name, description }` into `nonprofitData`; a "Nonprofit partner" section card links to `/nonprofit/`. (6) `awards[]` rendered as `ohx-tag--accent` chips after the status tag. (7) All GitHub repos in `github_links[]` rendered (handles both string and `{link,name}` shapes). (8) `getStaticProps` rethrows network errors (ISR keeps the last good version) and returns `notFound` only on genuine 404. **Harmonized (not full rewrite):** `/hack` index (keeps its bespoke finder + `HackPageNav`; hero/CTAs/Support band recolored via inline `RX` tokens). The upcoming/current event cards were refined too: `HackathonList` full-mode heading → Fraunces eyebrow; `EventFeature` full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since `/hack` isn't a `RefinedRoot`). `ImpactMetrics` (shared with the archive) → warm `--surface-2` tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow `color`-prop numbers). `/hack/[event_id]` got a **masthead-led refined pass**: body wrapped in `` (it's now the `
    `; all section IDs + `TableOfContents` + `FloatingNavigation` preserved), `HackathonHeader` rebuilt into a scope-independent editorial masthead (inline styles w/ `var(--x, fallback)` so `/agenda` + `/census` also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the **event-only** sub-components: `NonprofitList` (calm clickable cards + navy view-toggle), `EventConstraints`, `DonationProgress` (navy rings), `EventCountdown` (navy countdown card replacing the gradient + flat timeline), `TeamList`/`TeamCard` (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — `TeamList` is event-page-only despite earlier substring matches in admin/event-teams; **profile avatars load lazily per-card via IntersectionObserver with `rootMargin: "200px"`, tracked by `fetchedTeamProfilesRef` Set** — do NOT restore the eager `fetchTeamMemberProfiles(teams)` call on mount that fired N parallel requests for all 30+ cards at once). **Backend C7 enrichment:** `get_single_hackathon_event` (`services/hackathons_service.py`) now calls `_enrich_teams_users_batch` after converting team DocumentReferences — one `db.get_all` across all members of all teams, deduped, so the frontend receives `users[]` already as `{id, user_id, name, nickname, profile_image}` objects. `HackathonResults.js`/`TeamList.js` handle both the old id-string and new object shapes (backwards-compatible). The list endpoint `get_hackathon_list` is NOT enriched — only the single-event getter., and `HackathonLeaderboard` (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). **Phase 3 (done) finished the event page**: `EventLinks` (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), `InteractiveFAQ` (flat hairline accordions), `HackathonResults` (calm `--surface-2` frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), `VolunteerList` (`PersonCard` → flat hairline). The **shared** ones (`InteractiveFAQ`, `HackathonResults`) use **inline CSS-var fallbacks** (`var(--ink,#16181D)`) so they render refined on their non-`RefinedRoot` routes too (verified on `/hack/[event_id]/results` + `OnboardingFAQ`) — no per-route gating. A final chrome pass refined `TableOfContents` (navy active pill, was magenta), `FloatingNavigation` (navy FAB), `VolunteerList` (navy "Learn more" + navy availability chips; in-person stays green), and `MentorAvailability` (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: `/hack/request` (host-an-event page + `HackathonRequestForm`) and `/office-hours`. **`/hack/request` is student-first** — org-type selectable cards default to **university** (was corporate); the **$5k budget minimum was removed** (slider min `$0`, never blocks submit, reframed as optional funding); the donation-% ask is **corporate-only** (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm `--surface-2` cards; everything navy/terracotta via a scoped MUI `ThemeProvider` (`createTheme(base, …)`). **`formData` keys are preserved** — `HackathonRequestDetailDialog`, the edit page `/hack/request/[request_id]`, and the backend all depend on them. **Scroll gotcha:** step nav now `scrollIntoView`s a `formTopRef` on the form's Paper (`scrollMarginTop:88`), NOT `window.scrollTo(0,0)` (which overshot to page-top now that the form sits below the hero). `/office-hours` now renders its JSON-LD, uses the idempotent `initFacebookPixel` (was a raw `ReactPixel.init` — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: `/nonprofit/[nonprofit_id]` (`NonProfit.js` — editorial masthead, meta row as `.ohx-tag`/`.ohx-link`s, projects list, canonical www fix) and `/project/[project_id]` (`Project.js` — `RefinedRoot` chrome, soft breadcrumb, canonical). Both share `ProblemStatement.js` which was fully restyled (purple gradient hero → calm `--surface-2` band; gradient chips → `.ohx-tag`; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline `.ohx-card`; gradient CTAs → `.ohx-btn`). **Key invariant:** `ProblemStatement` is ONLY ever inside a `RefinedRoot`, so it may use scoped `.ohx-*` classes directly (no inline `var(--x, fallback)` needed). Pass `headingLevel="h2"` from the nonprofit page (N cards, nonprofit name is the `

    `) and `headingLevel="h1"` from the project page (project title is the `

    `). The "Want to help?" toggle is hidden when `status === "production"` (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; `maintenance` status keeps the toggle. **"Code & Tasks" (July 2026 discoverability fix):** it is an ALWAYS-VISIBLE section (`id="code-and-tasks-"` — unique per problem statement because nonprofit pages render N cards; `scrollMarginTop: 96`) directly after the Project Description — never move it back into the collapsed accordion trio (burying it was the bug; only References/Events stay accordions). Repo list = `problem_statement.github` (handles legacy string shape) MERGED with repos from hackathon teams in the already-fetched event data (`team.problem_statements` includes the ps id; `selected_nonprofit_id`-vs-`resolvedNonprofits` fallback applies ONLY to teams with no `problem_statements` — avoids claiming a sibling project's repos on multi-project nonprofits), deduped by `normalizeRepoLink`; team repos render under a "from hackathon teams" label with "Built by {team} ({event})" attribution, and a duplicate just enriches the project-level card. Live issue data (counts + top-5 open titles per repo) is fetched through the public backend proxy `GET /api/github/issues?org&repo&state=all` ONLY once the section scrolls near (fire-once IntersectionObserver, `rootMargin: 200px`) — batched Promise.all + `issuesRequestedRef` dedupe + ONE setState (never per-repo), errors swallowed (static links still render); backend caches this route 10 min. `RepoCard` + repo-link helpers are **module-scope** (SectionBlock remount lesson). When no repos exist at all, render the "Where's the code?" `--surface-2` card (event-page links + `slack_channel` button) — never hide the section silently. Header top row shows an `#code-and-tasks` anchor `.ohx-tag` when repos exist. Backend fix-forward: `approve_team` (`api/teams/teams_service.py::_link_repo_to_problem_statements`) now also appends the created repo to the linked problem statement(s)' `github` array (team `problem_statements` refs preferred; single-PS-nonprofit fallback; ambiguous → skip + log) and clears `get_single_problem_statement_old`'s cache. `styles/nonprofit/styles.js` is now orphaned — leave in place. +Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this before extending the look to more pages). Migrated so far: `/` (`src/pages/index.js`), `/projects` (`src/components/ProjectList/*`), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), `/about`, `/about/judges`, `/about/mentors` (`Mentorship` rewritten as refined sections), `/about/success-stories`, `/about/completion`, `/sponsor`, `/nonprofits` (`NonProfitList` + new calm card `NonProfitListTileRefined`; original `NonProfitListTile` stays for event pages), `/blog` (`BlogPage`; reused `News` list kept), `/onboarding` (refined chrome; wizard logic intact; **July 2026 content overhaul** — 9 steps: Welcome, Mission, How It Works [full lifecycle incl. post-hackathon Definition of Done + project-status ladder], Get Involved [hacker/mentor/judge/volunteer/nonprofit role cards], Using the Site [site map + first-steps guide + "Anatomy of a project page" numbered walkthrough (problem → code → plan → people; mirrors ProblemStatement.js section order — keep in sync) + "public portfolio" band: GitHub/Slack/demo-videos/public-profile are what recruiters can review; work is documented as GitHub Issues for public credit — no issues in a repo → pull the code and write them like a PM], Slack, Introduce Yourself, FAQs, Feedback. New sections: `HowItWorksSection`/`RolesSection`/`WebsiteTourSection` in `src/components/Onboarding/`; `JudgingOverview`/`MentoringOverview`/`BuddySystem` are now orphaned — don't re-add them or their claims. FAQ answers must reflect real flows: there are NO project leads, NO commenting on projects, NO buddy system — joining a project = "Want to help?" toggle / its Slack channel / GitHub repo (write Issues like a PM if none exist) / next hackathon. **Design contract:** step content renders inside a scoped `onboardingTheme` ThemeProvider (in `pages/onboarding/index.js` — Fraunces headings, Hanken body, navy/terracotta palette, flat hairline Papers/Cards; don't reintroduce elevation shadows, hover-lifts, or rainbow chips) and every step opens with the shared `StepHeader` (renders `

    `; the page masthead owns the `

    `). Slack step leads with the two-account gotcha (an ohack.dev login [usually Google] does NOT create a Slack account — join via `/signup`) and real channel deep links: #introductions C01EY49JV8U, #ask-a-mentor C01E5CGDQ74, #random C06BRHRS5BQ, plus per-project #npo-* channels; never reference #help/#team-formation/#buddy-matching/#project-matching — they don't exist), `/signup` (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed `join_slack_1.png`, navy CTA band; primary CTA is now a real `` — dropped the JS `router.push`/`window.open` branch; `handleSignupClick` only fires the `CompleteRegistration` GA event), `/volunteer`, `/about/hackers`, `/about/process` (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), `/praise` (refined chrome; `PraiseBoard` feed kept), `/hack/code-of-conduct` (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), `/profile/[userid]` public (`PublicProfile`), and a full refined pass on the own `/profile` editor (`Profile.js` — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, `PanelHeader` on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, `.ohx-card` Giveaway Entries; form logic untouched), `/myfeedback` (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, `.ohx-card` entries + `.ohx-tag` chips) and `/feedback/[userid]` (`GiveFeedback`, `ssr:false`: hero + `Section`-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native `.ohx-btn` submit; also fixed a pre-existing hooks-order bug where `if (!user)` returned before `useEffect`), `/hack/[event_id]/manageteam` (full rewrite: `RefinedRoot`+`RefinedFonts`, editorial masthead, `.ohx-card` gating panels, calm `--surface-2` panels for auth/apply gates; split `error` → `teamsError`+`formError`; lazy Slack/nonprofit fetches gated by `activeStep`/`showNewTeamForm` + `slackFetchedRef`/`nonprofitFetchedRef`; `onTeamUpdated` callback propagated to `TeamStatusPanel` so DevPost/demo label updates without reload; `fetchMyTeams()` after submit + scroll to `#team-hub`; `noindex` meta; no PII logs; `active_days=365` for Slack fetch), and `/hack/[event_id]/team/[team_id]` (full rewrite: editorial masthead + hairline `.ohx-card` sections + a **deep-linkable sticky Table of Contents** — sections are `
    ` with hover `#` copy-link anchors (keyboard accessible via `&:focus-visible`), the TOC lists only present sections (`aria-current="location"`) and tracks the active one via `IntersectionObserver`; embedded `MentorTeamPanel`/`TeamCompletionChecklist` keep their own styling in anchored `headed={false}` wrappers). **Key invariants on this page:** (1) Status labels come from `TEAM_STATUS_OPTIONS.find()` via `statusLabel()` — never render raw enum strings like `NONPROFIT_SELECTED`; winning statuses get a 🏆 prefix from `getWinningStatus()`; `INACTIVE` skips the status tag since the dot already conveys it. (2) `SectionBlock` is **module-scope** (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver `setActiveId` tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives `copiedId`/`onCopyLink` as props. (3) `parseLocalDate` from `src/lib/dateUtils.js` is used for all event window checks (`eventHasStarted`, `eventEnded`) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses `useTeamMembership(eventId, teamId)` from `src/hooks/use-team-membership.js` — shared by the page and passed as `isOnTeam`/`membershipChecked` props to `TeamCompletionChecklist` (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render `selected_nonprofit_id` text — only show when `nonprofitData.name` is available; `getStaticProps` fetches `{ name, description }` into `nonprofitData`; a "Nonprofit partner" section card links to `/nonprofit/`. (6) `awards[]` rendered as `ohx-tag--accent` chips after the status tag. (7) All GitHub repos in `github_links[]` rendered (handles both string and `{link,name}` shapes). (8) `getStaticProps` rethrows network errors (ISR keeps the last good version) and returns `notFound` only on genuine 404. **Harmonized (not full rewrite):** `/hack` index (keeps its bespoke finder + `HackPageNav`; hero/CTAs/Support band recolored via inline `RX` tokens). The upcoming/current event cards were refined too: `HackathonList` full-mode heading → Fraunces eyebrow; `EventFeature` full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since `/hack` isn't a `RefinedRoot`). `ImpactMetrics` (shared with the archive) → warm `--surface-2` tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow `color`-prop numbers). `/hack/[event_id]` got a **masthead-led refined pass**: body wrapped in `` (it's now the `
    `; all section IDs + `TableOfContents` + `FloatingNavigation` preserved), `HackathonHeader` rebuilt into a scope-independent editorial masthead (inline styles w/ `var(--x, fallback)` so `/agenda` + `/census` also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the **event-only** sub-components: `NonprofitList` (calm clickable cards + navy view-toggle), `EventConstraints`, `DonationProgress` (navy rings), `EventCountdown` (navy countdown card replacing the gradient + flat timeline), `TeamList`/`TeamCard` (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — `TeamList` is event-page-only despite earlier substring matches in admin/event-teams; **profile avatars load lazily per-card via IntersectionObserver with `rootMargin: "200px"`, tracked by `fetchedTeamProfilesRef` Set** — do NOT restore the eager `fetchTeamMemberProfiles(teams)` call on mount that fired N parallel requests for all 30+ cards at once). **Backend C7 enrichment:** `get_single_hackathon_event` (`services/hackathons_service.py`) now calls `_enrich_teams_users_batch` after converting team DocumentReferences — one `db.get_all` across all members of all teams, deduped, so the frontend receives `users[]` already as `{id, user_id, name, nickname, profile_image}` objects. `HackathonResults.js`/`TeamList.js` handle both the old id-string and new object shapes (backwards-compatible). The list endpoint `get_hackathon_list` is NOT enriched — only the single-event getter., and `HackathonLeaderboard` (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). **Phase 3 (done) finished the event page**: `EventLinks` (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), `InteractiveFAQ` (flat hairline accordions), `HackathonResults` (calm `--surface-2` frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), `VolunteerList` (`PersonCard` → flat hairline). The **shared** ones (`InteractiveFAQ`, `HackathonResults`) use **inline CSS-var fallbacks** (`var(--ink,#16181D)`) so they render refined on their non-`RefinedRoot` routes too (verified on `/hack/[event_id]/results` + `OnboardingFAQ`) — no per-route gating. A final chrome pass refined `TableOfContents` (navy active pill, was magenta), `FloatingNavigation` (navy FAB), `VolunteerList` (navy "Learn more" + navy availability chips; in-person stays green), and `MentorAvailability` (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: `/hack/request` (host-an-event page + `HackathonRequestForm`) and `/office-hours`. **`/hack/request` is student-first** — org-type selectable cards default to **university** (was corporate); the **$5k budget minimum was removed** (slider min `$0`, never blocks submit, reframed as optional funding); the donation-% ask is **corporate-only** (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm `--surface-2` cards; everything navy/terracotta via a scoped MUI `ThemeProvider` (`createTheme(base, …)`). **`formData` keys are preserved** — `HackathonRequestDetailDialog`, the edit page `/hack/request/[request_id]`, and the backend all depend on them. **Scroll gotcha:** step nav now `scrollIntoView`s a `formTopRef` on the form's Paper (`scrollMarginTop:88`), NOT `window.scrollTo(0,0)` (which overshot to page-top now that the form sits below the hero). `/office-hours` now renders its JSON-LD, uses the idempotent `initFacebookPixel` (was a raw `ReactPixel.init` — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: `/nonprofit/[nonprofit_id]` (`NonProfit.js` — editorial masthead, meta row as `.ohx-tag`/`.ohx-link`s, projects list, canonical www fix) and `/project/[project_id]` (`Project.js` — `RefinedRoot` chrome, soft breadcrumb, canonical). Both share `ProblemStatement.js` which was fully restyled (purple gradient hero → calm `--surface-2` band; gradient chips → `.ohx-tag`; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline `.ohx-card`; gradient CTAs → `.ohx-btn`). **Key invariant:** `ProblemStatement` is ONLY ever inside a `RefinedRoot`, so it may use scoped `.ohx-*` classes directly (no inline `var(--x, fallback)` needed). Pass `headingLevel="h2"` from the nonprofit page (N cards, nonprofit name is the `

    `) and `headingLevel="h1"` from the project page (project title is the `

    `). The "Want to help?" toggle is hidden when `status === "production"` (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; `maintenance` status keeps the toggle. **"Code & Tasks" (July 2026 discoverability fix):** it is an ALWAYS-VISIBLE section (`id="code-and-tasks-"` — unique per problem statement because nonprofit pages render N cards; `scrollMarginTop: 96`) directly after the Project Description — never move it back into the collapsed accordion trio (burying it was the bug; only References/Events stay accordions). Repo list = `problem_statement.github` (handles legacy string shape) MERGED with repos from hackathon teams in the already-fetched event data (`team.problem_statements` includes the ps id; `selected_nonprofit_id`-vs-`resolvedNonprofits` fallback applies ONLY to teams with no `problem_statements` — avoids claiming a sibling project's repos on multi-project nonprofits), deduped by `normalizeRepoLink`; team repos render under a "from hackathon teams" label with "Built by {team} ({event})" attribution, and a duplicate just enriches the project-level card. Live issue data (counts + top-5 open titles per repo) is fetched through the public backend proxy `GET /api/github/issues?org&repo&state=all` ONLY once the section scrolls near (fire-once IntersectionObserver, `rootMargin: 200px`) — batched Promise.all + `issuesRequestedRef` dedupe + ONE setState (never per-repo), errors swallowed (static links still render); backend caches this route 10 min. `RepoCard` + repo-link helpers are **module-scope** (SectionBlock remount lesson). When no repos exist at all, render the "Where's the code?" `--surface-2` card (event-page links + `slack_channel` button) — never hide the section silently. Header top row shows an `#code-and-tasks` anchor `.ohx-tag` when repos exist. Backend fix-forward: `approve_team` (`api/teams/teams_service.py::_link_repo_to_problem_statements`) now also appends the created repo to the linked problem statement(s)' `github` array (team `problem_statements` refs preferred; single-PS-nonprofit fallback; ambiguous → skip + log) and clears `get_single_problem_statement_old`'s cache. **Events & Teams section:** `src/components/Events/Events.js` was rewritten as refined event cards — title is `event.title` (NOT `{location} {type}` — that rendered a street address as the heading), Past/Upcoming `.ohx-tag`, compact date range via `parseLocalDate`, location + description as muted meta, constraint `.ohx-tag`s, ghost Event-page/DevPost buttons, plus a **read-only** teams list (name, member first-names, link to `/hack//team/`) fed by `teamsByEvent` from the same ProblemStatement memo that derives team repos. The old interactive join/leave path was dead (its `teams` state was never populated) and was removed from ProblemStatement along with the per-user `userDetails` fetch — `event-teams.js` and `event-team.js` are now orphaned; don't re-add join/leave here (team joining lives on the event page / manageteam). `styles/nonprofit/styles.js` is now orphaned — leave in place. See `docs/refined-design-system.md`. Pattern for new pages: keep `Head`/`getStaticProps`/schema verbatim, add ``, wrap body in ``, rebuild sections with `.ohx-*` classes, one `

    `, preserve GA. diff --git a/src/components/Events/Events.js b/src/components/Events/Events.js index 4786fca..b4d7f11 100644 --- a/src/components/Events/Events.js +++ b/src/components/Events/Events.js @@ -1,189 +1,239 @@ -import EventTeams from "../event-teams"; -import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; -import Stack from "@mui/material/Stack"; -import { Grid, Typography } from "@mui/material"; import Link from "next/link"; -import EventBusyIcon from '@mui/icons-material/EventBusy'; -import Button from "@mui/material/Button"; -import { EventLink } from "../HackathonList/styles"; +import EventIcon from "@mui/icons-material/Event"; +import GroupIcon from "@mui/icons-material/Group"; +import LaunchIcon from "@mui/icons-material/Launch"; import { parseLocalDate } from "../../lib/dateUtils"; -export default function Events({ - events, - teams, - userDetails, - user, - problemStatementId, - onTeamLeave, - onTeamJoin, - isHelping, -}) { - - const isEventStartDateOlderThanToday = (event) => { - return new Date(event.end_date) < new Date(); - } - - // Sort events by event.start_date with the newest on the top - events.sort((a, b) => new Date(b.start_date) - new Date(a.start_date)); - +// Renders inside ProblemStatement, which is always inside a RefinedRoot — +// scoped .ohx-* classes are safe here (no var fallbacks needed). - var eventsResult = "We haven't hacked on this yet!" - if (events && events.length > 0) { - eventsResult = events.map((event) => { - var devPostPostfixString = ""; +const eventHasEnded = (event) => parseLocalDate(event.end_date) < new Date(); - if (event.devpost_url && event.devpost_url.includes("devpost")) { - devPostPostfixString = "on DevPost"; - } - - return ( - +// Compact, readable date range: "Oct 12–13, 2024" / "Oct 30 – Nov 2, 2024" +const formatDateRange = (startStr, endStr) => { + const start = parseLocalDate(startStr); + const end = parseLocalDate(endStr); + const short = { month: "short", day: "numeric" }; + const sameYear = start.getFullYear() === end.getFullYear(); + const sameMonth = sameYear && start.getMonth() === end.getMonth(); + if (sameMonth && start.getDate() === end.getDate()) { + return `${start.toLocaleDateString("en-US", short)}, ${end.getFullYear()}`; + } + if (sameMonth) { + return `${start.toLocaleDateString("en-US", short)}–${end.getDate()}, ${end.getFullYear()}`; + } + if (sameYear) { + return `${start.toLocaleDateString("en-US", short)} – ${end.toLocaleDateString("en-US", short)}, ${end.getFullYear()}`; + } + const full = { month: "short", day: "numeric", year: "numeric" }; + return `${start.toLocaleDateString("en-US", full)} – ${end.toLocaleDateString("en-US", full)}`; +}; + +const memberName = (member) => + typeof member === "string" + ? null + : member?.nickname || member?.name || null; + +const smallButtonStyle = { + fontSize: "0.82rem", + padding: "0.5em 0.9em", + display: "inline-flex", + alignItems: "center", + gap: 4, +}; + +const TeamRow = ({ team, eventStringId }) => { + const members = Array.isArray(team.users) ? team.users : []; + const names = members.map(memberName).filter(Boolean); + const shown = names.slice(0, 4); + const extra = members.length - shown.length; + const membersLabel = + shown.length > 0 + ? `${shown.join(", ")}${extra > 0 ? ` +${extra} more` : ""}` + : `${members.length} member${members.length === 1 ? "" : "s"}`; + + return ( +
  • +
    + + {team.name} + + {members.length > 0 && ( + + {membersLabel} + + )} +
    + + Team page → + +
  • + ); +}; + +export default function Events({ events, teamsByEvent = {} }) { + if (!events || events.length === 0) { + return ( +

    + This project hasn’t been worked on at an event yet —{" "} + + join an upcoming hackathon + {" "} + and pick it with your team. +

    + ); + } - { isEventStartDateOlderThanToday(event) && - - - Past Event: {event.location} {event.type} - - - } - { !isEventStartDateOlderThanToday(event) && - - - {event.location} {event.type} - + // Newest event first + const sorted = [...events].sort( + (a, b) => parseLocalDate(b.start_date) - parseLocalDate(a.start_date) + ); + + return ( +
    + {sorted.map((event) => { + const past = eventHasEnded(event); + const title = + event.title || `${event.location || ""} ${event.type || "event"}`.trim(); + const teams = teamsByEvent[event.event_id] || []; + const constraints = event.constraints || {}; + const teamSizeTag = + constraints.min_people_per_team && constraints.max_people_per_team + ? `Teams of ${constraints.min_people_per_team}–${constraints.max_people_per_team}` + : null; + + return ( +
    +
    + + {past ? "Past" : "Upcoming"} {event.type || "event"} + + + + {formatDateRange(event.start_date, event.end_date)} + +
    + + + {title} - } - - - {event.description} - - - - - - { - // Convert start_date to readible format - parseLocalDate(event.start_date).toLocaleDateString("en-US", { - weekday: "short", - year: "numeric", - month: "long", - day: "numeric", - }) - } - {" "} - - { - // Convert end_date to readible format - parseLocalDate(event.end_date).toLocaleDateString("en-US", { - weekday: "short", - year: "numeric", - month: "long", - day: "numeric", - }) - } - - - { - /* Can't tell if this is helpful or too noisy, so commenting this out for now - - // Print out links if they exist - event.links && event.links.length > 0 && ( - - { - - {event.links.map((link) => { - return ( - - - - - - ); - })} - - } - - ) - */ - } - - - { - // Print constraints if they exist - event.constraints && ( - - { - // If constraints.max_teams_per_problem is not null, then display the max number of teams per problem - event.constraints.max_teams_per_problem && ( - - Max teams per problem: {event.constraints.max_teams_per_problem} - - ) - } - - { - // If constraints.min_people_per_team is not null, then display the min number of people per team - event.constraints.min_people_per_team && ( - - Minimum people per team: {event.constraints.min_people_per_team} - - ) - } - - { - // If constraints.max_people_per_team is not null, then display the max number of people per team - event.constraints.max_people_per_team && ( - - Max people per team: {event.constraints.max_people_per_team} - - ) - } - - ) - } - - - - { event.devpost_url && - { - // If event.start_date is in the past then show the button as View on DevPost instead - event.start_date < new Date().toISOString() ? ( - - ) : ( - - ) - } - - } - - - - event.teams.includes(team.id))} - user={user} - isEventStartDateOlderThanToday={isEventStartDateOlderThanToday(event)} - userDetails={userDetails} - problemStatementId={problemStatementId} - eventId={event.id} - eventStringId={event.event_id} - constraints={event.constraints} - onTeamLeave={onTeamLeave} - onTeamJoin={onTeamJoin} - isHelping={isHelping} - /> - - - ); - }); - } - return eventsResult; + {event.location && ( +

    + {event.location} +

    + )} + + {event.description && ( +

    + {event.description} +

    + )} + + {(teamSizeTag || constraints.max_teams_per_problem) && ( +
    + {teamSizeTag && {teamSizeTag}} + {constraints.max_teams_per_problem && ( + + Max {constraints.max_teams_per_problem} team + {constraints.max_teams_per_problem === 1 ? "" : "s"} per project + + )} +
    + )} + +
    + + {teams.length > 0 && ( +
    +

    + + {teams.length} team{teams.length === 1 ? "" : "s"} worked on this + project here +

    +
      + {teams.map((team) => ( + + ))} +
    +
    + )} +
    + ); + })} +
    + ); } diff --git a/src/components/ProblemStatement/ProblemStatement.js b/src/components/ProblemStatement/ProblemStatement.js index 1ea1c02..1d9a2cb 100644 --- a/src/components/ProblemStatement/ProblemStatement.js +++ b/src/components/ProblemStatement/ProblemStatement.js @@ -29,7 +29,6 @@ import ReactMarkdown from "react-markdown"; // Imported Components import useProfileApi from "../../hooks/use-profile-api"; import ProjectProgress from "../ProjectProgress/ProjectProgress"; -import useTeams from "../../hooks/use-teams"; import SkillSet from "../skill-set"; import CopyToClipboardButton from "../buttons/CopyToClipboardButton"; import useProblemstatements from "../../hooks/use-problem-statements"; @@ -239,7 +238,6 @@ export default function ProblemStatement({ const { redirectToLoginPage } = useRedirectFunctions(); const { problem_statement } = useProblemstatements(problem_statement_id); const { handle_get_hackathon_id } = useHackathonEvents(); - const { handle_join_team, handle_unjoin_a_team } = useTeams(); const { nonprofits: resolvedNonprofits } = useProjectNonprofit( problem_statement_id, @@ -248,13 +246,8 @@ export default function ProblemStatement({ const effectiveNpoId = npo_id || resolvedNonprofits[0]?.id; const [hackathonEvents, setHackathonEvents] = useState([]); - const [teamSuggestions, setTeamSuggestions] = useState(null); const [hackathonEventsLoaded, setHackathonEventsLoaded] = useState(false); const [hackathonEventsError, setHackathonEventsError] = useState(false); - const [teams, setTeams] = useState([]); - const [userDetails, setUserDetails] = useState(null); - const [userLoaded, setUserLoaded] = useState(false); - const [userError, setUserError] = useState(false); const [open, setOpen] = useState(false); const [openUnhelp, setOpenUnhelp] = useState(false); const [help_checked, setHelpedChecked] = useState(""); @@ -262,7 +255,7 @@ export default function ProblemStatement({ const [expanded, setExpanded] = useState("Events"); const [tabValue, setTabValue] = useState("Events"); const [expandedSection, setExpandedSection] = useState("references"); - const { get_user_by_id, profile, handle_help_toggle } = useProfileApi(); + const { profile, handle_help_toggle } = useProfileApi(); const [helperProfiles, setHelperProfiles] = useState({}); const [isCheckingHelperStatus, setIsCheckingHelperStatus] = useState(false); @@ -316,32 +309,6 @@ export default function ProblemStatement({ } }, [problem_statement_id, problem_statement?.events]); - useEffect(() => { - if (teams?.length > 0) { - const userDetailsMap = {}; - const promises = []; - - teams.forEach((team) => { - team.users?.forEach((user_id) => { - promises.push( - get_user_by_id(user_id, (user) => { - userDetailsMap[user_id] = user; - }) - ); - }); - }); - - Promise.all(promises) - .then(() => { - setUserDetails(userDetailsMap); - setUserLoaded(true); - }) - .catch(() => { - setUserError(true); - }); - } - }, [teams]); - useEffect(() => { if (problem_statement?.helping?.length > 0) { setIsCheckingHelperStatus(true); @@ -470,9 +437,12 @@ export default function ProblemStatement({ // with no problem_statements data (avoids claiming repos from a sibling // project of a multi-project nonprofit). Dedupe by normalized link — a // team repo matching a project-level repo just adds attribution. - const codeRepos = useMemo(() => { - if (!problem_statement) return []; + // Also groups the matched teams per event for the Events & Teams section. + const { repos: codeRepos, teamsByEvent: projectTeamsByEvent } = + useMemo(() => { + if (!problem_statement) return { repos: [], teamsByEvent: {} }; const map = new Map(); + const teamsByEvent = {}; const rawGithub = problem_statement.github; const projectLevel = Array.isArray(rawGithub) @@ -509,6 +479,10 @@ export default function ProblemStatement({ : team.selected_nonprofit_id && npoIds.has(team.selected_nonprofit_id); if (!matches) return; + if (event.event_id) { + if (!teamsByEvent[event.event_id]) teamsByEvent[event.event_id] = []; + teamsByEvent[event.event_id].push(team); + } (team.github_links || []).forEach((gl) => { const link = typeof gl === "string" ? gl : gl?.link; if (!link) return; @@ -538,7 +512,7 @@ export default function ProblemStatement({ }); }); - return Array.from(map.values()); + return { repos: Array.from(map.values()), teamsByEvent }; }, [problem_statement, teamRepoEvents, resolvedNonprofits]); // Only fetch issue data once the Code & Tasks section scrolls near @@ -667,23 +641,6 @@ export default function ProblemStatement({ } }; - const handleLeavingTeam = (teamId) => { - handle_unjoin_a_team(teamId, handleTeamLeavingResponse); - trackEvent({ action: "team_left", params: { team_id: teamId } }); - }; - - const handleJoiningTeam = (teamId) => { - handle_join_team(teamId, handleTeamLeavingResponse); - trackEvent({ action: "team_joined", params: { team_id: teamId } }); - }; - - const handleTeamLeavingResponse = () => { - trackEvent({ - action: "Team Left", - params: { category: "Team", label: "Team" }, - }); - }; - const handleClose = (helperType) => { trackEvent({ action: "Helping: User Finalized Start Helping", @@ -767,13 +724,6 @@ export default function ProblemStatement({ ); } - const teamCounter = teams.filter((team) => - team.problem_statements?.includes(problem_statement_id) - ).length; - const teamText = `There ${teamCounter === 1 ? "is" : "are"} ${teamCounter} team${ - teamCounter === 1 ? "" : "s" - } working on this`; - let countOfHackers = 0; let countOfMentors = 0; if (problem_statement.helping?.length > 0) { @@ -1405,14 +1355,8 @@ export default function ProblemStatement({ hackathonEventsLoaded ? ( ) : (

    Date: Thu, 23 Jul 2026 09:25:40 -0700 Subject: [PATCH 03/18] Facelift Reference Documents into refined row-cards Replaces the full-width outlined uppercase MUI buttons with calm hairline rows: navy kind icon, sentence-case document name, a kind tag (GitHub / Slides / Document / Video / Link), and an open-in-new indicator. Google Slides links (docs.google.com/presentation) are now classified as Slides instead of Document. Video references keep their inline VideoDisplay embed. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- src/components/ReferenceItem/ReferenceItem.js | 133 +++++++++--------- 2 files changed, 68 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c8b5a5a..bdc812d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## "Refined" design scope (civic-editorial facelift) -Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this before extending the look to more pages). Migrated so far: `/` (`src/pages/index.js`), `/projects` (`src/components/ProjectList/*`), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), `/about`, `/about/judges`, `/about/mentors` (`Mentorship` rewritten as refined sections), `/about/success-stories`, `/about/completion`, `/sponsor`, `/nonprofits` (`NonProfitList` + new calm card `NonProfitListTileRefined`; original `NonProfitListTile` stays for event pages), `/blog` (`BlogPage`; reused `News` list kept), `/onboarding` (refined chrome; wizard logic intact; **July 2026 content overhaul** — 9 steps: Welcome, Mission, How It Works [full lifecycle incl. post-hackathon Definition of Done + project-status ladder], Get Involved [hacker/mentor/judge/volunteer/nonprofit role cards], Using the Site [site map + first-steps guide + "Anatomy of a project page" numbered walkthrough (problem → code → plan → people; mirrors ProblemStatement.js section order — keep in sync) + "public portfolio" band: GitHub/Slack/demo-videos/public-profile are what recruiters can review; work is documented as GitHub Issues for public credit — no issues in a repo → pull the code and write them like a PM], Slack, Introduce Yourself, FAQs, Feedback. New sections: `HowItWorksSection`/`RolesSection`/`WebsiteTourSection` in `src/components/Onboarding/`; `JudgingOverview`/`MentoringOverview`/`BuddySystem` are now orphaned — don't re-add them or their claims. FAQ answers must reflect real flows: there are NO project leads, NO commenting on projects, NO buddy system — joining a project = "Want to help?" toggle / its Slack channel / GitHub repo (write Issues like a PM if none exist) / next hackathon. **Design contract:** step content renders inside a scoped `onboardingTheme` ThemeProvider (in `pages/onboarding/index.js` — Fraunces headings, Hanken body, navy/terracotta palette, flat hairline Papers/Cards; don't reintroduce elevation shadows, hover-lifts, or rainbow chips) and every step opens with the shared `StepHeader` (renders `

    `; the page masthead owns the `

    `). Slack step leads with the two-account gotcha (an ohack.dev login [usually Google] does NOT create a Slack account — join via `/signup`) and real channel deep links: #introductions C01EY49JV8U, #ask-a-mentor C01E5CGDQ74, #random C06BRHRS5BQ, plus per-project #npo-* channels; never reference #help/#team-formation/#buddy-matching/#project-matching — they don't exist), `/signup` (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed `join_slack_1.png`, navy CTA band; primary CTA is now a real `` — dropped the JS `router.push`/`window.open` branch; `handleSignupClick` only fires the `CompleteRegistration` GA event), `/volunteer`, `/about/hackers`, `/about/process` (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), `/praise` (refined chrome; `PraiseBoard` feed kept), `/hack/code-of-conduct` (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), `/profile/[userid]` public (`PublicProfile`), and a full refined pass on the own `/profile` editor (`Profile.js` — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, `PanelHeader` on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, `.ohx-card` Giveaway Entries; form logic untouched), `/myfeedback` (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, `.ohx-card` entries + `.ohx-tag` chips) and `/feedback/[userid]` (`GiveFeedback`, `ssr:false`: hero + `Section`-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native `.ohx-btn` submit; also fixed a pre-existing hooks-order bug where `if (!user)` returned before `useEffect`), `/hack/[event_id]/manageteam` (full rewrite: `RefinedRoot`+`RefinedFonts`, editorial masthead, `.ohx-card` gating panels, calm `--surface-2` panels for auth/apply gates; split `error` → `teamsError`+`formError`; lazy Slack/nonprofit fetches gated by `activeStep`/`showNewTeamForm` + `slackFetchedRef`/`nonprofitFetchedRef`; `onTeamUpdated` callback propagated to `TeamStatusPanel` so DevPost/demo label updates without reload; `fetchMyTeams()` after submit + scroll to `#team-hub`; `noindex` meta; no PII logs; `active_days=365` for Slack fetch), and `/hack/[event_id]/team/[team_id]` (full rewrite: editorial masthead + hairline `.ohx-card` sections + a **deep-linkable sticky Table of Contents** — sections are `
    ` with hover `#` copy-link anchors (keyboard accessible via `&:focus-visible`), the TOC lists only present sections (`aria-current="location"`) and tracks the active one via `IntersectionObserver`; embedded `MentorTeamPanel`/`TeamCompletionChecklist` keep their own styling in anchored `headed={false}` wrappers). **Key invariants on this page:** (1) Status labels come from `TEAM_STATUS_OPTIONS.find()` via `statusLabel()` — never render raw enum strings like `NONPROFIT_SELECTED`; winning statuses get a 🏆 prefix from `getWinningStatus()`; `INACTIVE` skips the status tag since the dot already conveys it. (2) `SectionBlock` is **module-scope** (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver `setActiveId` tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives `copiedId`/`onCopyLink` as props. (3) `parseLocalDate` from `src/lib/dateUtils.js` is used for all event window checks (`eventHasStarted`, `eventEnded`) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses `useTeamMembership(eventId, teamId)` from `src/hooks/use-team-membership.js` — shared by the page and passed as `isOnTeam`/`membershipChecked` props to `TeamCompletionChecklist` (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render `selected_nonprofit_id` text — only show when `nonprofitData.name` is available; `getStaticProps` fetches `{ name, description }` into `nonprofitData`; a "Nonprofit partner" section card links to `/nonprofit/`. (6) `awards[]` rendered as `ohx-tag--accent` chips after the status tag. (7) All GitHub repos in `github_links[]` rendered (handles both string and `{link,name}` shapes). (8) `getStaticProps` rethrows network errors (ISR keeps the last good version) and returns `notFound` only on genuine 404. **Harmonized (not full rewrite):** `/hack` index (keeps its bespoke finder + `HackPageNav`; hero/CTAs/Support band recolored via inline `RX` tokens). The upcoming/current event cards were refined too: `HackathonList` full-mode heading → Fraunces eyebrow; `EventFeature` full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since `/hack` isn't a `RefinedRoot`). `ImpactMetrics` (shared with the archive) → warm `--surface-2` tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow `color`-prop numbers). `/hack/[event_id]` got a **masthead-led refined pass**: body wrapped in `` (it's now the `
    `; all section IDs + `TableOfContents` + `FloatingNavigation` preserved), `HackathonHeader` rebuilt into a scope-independent editorial masthead (inline styles w/ `var(--x, fallback)` so `/agenda` + `/census` also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the **event-only** sub-components: `NonprofitList` (calm clickable cards + navy view-toggle), `EventConstraints`, `DonationProgress` (navy rings), `EventCountdown` (navy countdown card replacing the gradient + flat timeline), `TeamList`/`TeamCard` (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — `TeamList` is event-page-only despite earlier substring matches in admin/event-teams; **profile avatars load lazily per-card via IntersectionObserver with `rootMargin: "200px"`, tracked by `fetchedTeamProfilesRef` Set** — do NOT restore the eager `fetchTeamMemberProfiles(teams)` call on mount that fired N parallel requests for all 30+ cards at once). **Backend C7 enrichment:** `get_single_hackathon_event` (`services/hackathons_service.py`) now calls `_enrich_teams_users_batch` after converting team DocumentReferences — one `db.get_all` across all members of all teams, deduped, so the frontend receives `users[]` already as `{id, user_id, name, nickname, profile_image}` objects. `HackathonResults.js`/`TeamList.js` handle both the old id-string and new object shapes (backwards-compatible). The list endpoint `get_hackathon_list` is NOT enriched — only the single-event getter., and `HackathonLeaderboard` (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). **Phase 3 (done) finished the event page**: `EventLinks` (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), `InteractiveFAQ` (flat hairline accordions), `HackathonResults` (calm `--surface-2` frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), `VolunteerList` (`PersonCard` → flat hairline). The **shared** ones (`InteractiveFAQ`, `HackathonResults`) use **inline CSS-var fallbacks** (`var(--ink,#16181D)`) so they render refined on their non-`RefinedRoot` routes too (verified on `/hack/[event_id]/results` + `OnboardingFAQ`) — no per-route gating. A final chrome pass refined `TableOfContents` (navy active pill, was magenta), `FloatingNavigation` (navy FAB), `VolunteerList` (navy "Learn more" + navy availability chips; in-person stays green), and `MentorAvailability` (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: `/hack/request` (host-an-event page + `HackathonRequestForm`) and `/office-hours`. **`/hack/request` is student-first** — org-type selectable cards default to **university** (was corporate); the **$5k budget minimum was removed** (slider min `$0`, never blocks submit, reframed as optional funding); the donation-% ask is **corporate-only** (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm `--surface-2` cards; everything navy/terracotta via a scoped MUI `ThemeProvider` (`createTheme(base, …)`). **`formData` keys are preserved** — `HackathonRequestDetailDialog`, the edit page `/hack/request/[request_id]`, and the backend all depend on them. **Scroll gotcha:** step nav now `scrollIntoView`s a `formTopRef` on the form's Paper (`scrollMarginTop:88`), NOT `window.scrollTo(0,0)` (which overshot to page-top now that the form sits below the hero). `/office-hours` now renders its JSON-LD, uses the idempotent `initFacebookPixel` (was a raw `ReactPixel.init` — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: `/nonprofit/[nonprofit_id]` (`NonProfit.js` — editorial masthead, meta row as `.ohx-tag`/`.ohx-link`s, projects list, canonical www fix) and `/project/[project_id]` (`Project.js` — `RefinedRoot` chrome, soft breadcrumb, canonical). Both share `ProblemStatement.js` which was fully restyled (purple gradient hero → calm `--surface-2` band; gradient chips → `.ohx-tag`; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline `.ohx-card`; gradient CTAs → `.ohx-btn`). **Key invariant:** `ProblemStatement` is ONLY ever inside a `RefinedRoot`, so it may use scoped `.ohx-*` classes directly (no inline `var(--x, fallback)` needed). Pass `headingLevel="h2"` from the nonprofit page (N cards, nonprofit name is the `

    `) and `headingLevel="h1"` from the project page (project title is the `

    `). The "Want to help?" toggle is hidden when `status === "production"` (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; `maintenance` status keeps the toggle. **"Code & Tasks" (July 2026 discoverability fix):** it is an ALWAYS-VISIBLE section (`id="code-and-tasks-"` — unique per problem statement because nonprofit pages render N cards; `scrollMarginTop: 96`) directly after the Project Description — never move it back into the collapsed accordion trio (burying it was the bug; only References/Events stay accordions). Repo list = `problem_statement.github` (handles legacy string shape) MERGED with repos from hackathon teams in the already-fetched event data (`team.problem_statements` includes the ps id; `selected_nonprofit_id`-vs-`resolvedNonprofits` fallback applies ONLY to teams with no `problem_statements` — avoids claiming a sibling project's repos on multi-project nonprofits), deduped by `normalizeRepoLink`; team repos render under a "from hackathon teams" label with "Built by {team} ({event})" attribution, and a duplicate just enriches the project-level card. Live issue data (counts + top-5 open titles per repo) is fetched through the public backend proxy `GET /api/github/issues?org&repo&state=all` ONLY once the section scrolls near (fire-once IntersectionObserver, `rootMargin: 200px`) — batched Promise.all + `issuesRequestedRef` dedupe + ONE setState (never per-repo), errors swallowed (static links still render); backend caches this route 10 min. `RepoCard` + repo-link helpers are **module-scope** (SectionBlock remount lesson). When no repos exist at all, render the "Where's the code?" `--surface-2` card (event-page links + `slack_channel` button) — never hide the section silently. Header top row shows an `#code-and-tasks` anchor `.ohx-tag` when repos exist. Backend fix-forward: `approve_team` (`api/teams/teams_service.py::_link_repo_to_problem_statements`) now also appends the created repo to the linked problem statement(s)' `github` array (team `problem_statements` refs preferred; single-PS-nonprofit fallback; ambiguous → skip + log) and clears `get_single_problem_statement_old`'s cache. **Events & Teams section:** `src/components/Events/Events.js` was rewritten as refined event cards — title is `event.title` (NOT `{location} {type}` — that rendered a street address as the heading), Past/Upcoming `.ohx-tag`, compact date range via `parseLocalDate`, location + description as muted meta, constraint `.ohx-tag`s, ghost Event-page/DevPost buttons, plus a **read-only** teams list (name, member first-names, link to `/hack//team/`) fed by `teamsByEvent` from the same ProblemStatement memo that derives team repos. The old interactive join/leave path was dead (its `teams` state was never populated) and was removed from ProblemStatement along with the per-user `userDetails` fetch — `event-teams.js` and `event-team.js` are now orphaned; don't re-add join/leave here (team joining lives on the event page / manageteam). `styles/nonprofit/styles.js` is now orphaned — leave in place. +Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this before extending the look to more pages). Migrated so far: `/` (`src/pages/index.js`), `/projects` (`src/components/ProjectList/*`), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), `/about`, `/about/judges`, `/about/mentors` (`Mentorship` rewritten as refined sections), `/about/success-stories`, `/about/completion`, `/sponsor`, `/nonprofits` (`NonProfitList` + new calm card `NonProfitListTileRefined`; original `NonProfitListTile` stays for event pages), `/blog` (`BlogPage`; reused `News` list kept), `/onboarding` (refined chrome; wizard logic intact; **July 2026 content overhaul** — 9 steps: Welcome, Mission, How It Works [full lifecycle incl. post-hackathon Definition of Done + project-status ladder], Get Involved [hacker/mentor/judge/volunteer/nonprofit role cards], Using the Site [site map + first-steps guide + "Anatomy of a project page" numbered walkthrough (problem → code → plan → people; mirrors ProblemStatement.js section order — keep in sync) + "public portfolio" band: GitHub/Slack/demo-videos/public-profile are what recruiters can review; work is documented as GitHub Issues for public credit — no issues in a repo → pull the code and write them like a PM], Slack, Introduce Yourself, FAQs, Feedback. New sections: `HowItWorksSection`/`RolesSection`/`WebsiteTourSection` in `src/components/Onboarding/`; `JudgingOverview`/`MentoringOverview`/`BuddySystem` are now orphaned — don't re-add them or their claims. FAQ answers must reflect real flows: there are NO project leads, NO commenting on projects, NO buddy system — joining a project = "Want to help?" toggle / its Slack channel / GitHub repo (write Issues like a PM if none exist) / next hackathon. **Design contract:** step content renders inside a scoped `onboardingTheme` ThemeProvider (in `pages/onboarding/index.js` — Fraunces headings, Hanken body, navy/terracotta palette, flat hairline Papers/Cards; don't reintroduce elevation shadows, hover-lifts, or rainbow chips) and every step opens with the shared `StepHeader` (renders `

    `; the page masthead owns the `

    `). Slack step leads with the two-account gotcha (an ohack.dev login [usually Google] does NOT create a Slack account — join via `/signup`) and real channel deep links: #introductions C01EY49JV8U, #ask-a-mentor C01E5CGDQ74, #random C06BRHRS5BQ, plus per-project #npo-* channels; never reference #help/#team-formation/#buddy-matching/#project-matching — they don't exist), `/signup` (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed `join_slack_1.png`, navy CTA band; primary CTA is now a real `` — dropped the JS `router.push`/`window.open` branch; `handleSignupClick` only fires the `CompleteRegistration` GA event), `/volunteer`, `/about/hackers`, `/about/process` (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), `/praise` (refined chrome; `PraiseBoard` feed kept), `/hack/code-of-conduct` (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), `/profile/[userid]` public (`PublicProfile`), and a full refined pass on the own `/profile` editor (`Profile.js` — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, `PanelHeader` on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, `.ohx-card` Giveaway Entries; form logic untouched), `/myfeedback` (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, `.ohx-card` entries + `.ohx-tag` chips) and `/feedback/[userid]` (`GiveFeedback`, `ssr:false`: hero + `Section`-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native `.ohx-btn` submit; also fixed a pre-existing hooks-order bug where `if (!user)` returned before `useEffect`), `/hack/[event_id]/manageteam` (full rewrite: `RefinedRoot`+`RefinedFonts`, editorial masthead, `.ohx-card` gating panels, calm `--surface-2` panels for auth/apply gates; split `error` → `teamsError`+`formError`; lazy Slack/nonprofit fetches gated by `activeStep`/`showNewTeamForm` + `slackFetchedRef`/`nonprofitFetchedRef`; `onTeamUpdated` callback propagated to `TeamStatusPanel` so DevPost/demo label updates without reload; `fetchMyTeams()` after submit + scroll to `#team-hub`; `noindex` meta; no PII logs; `active_days=365` for Slack fetch), and `/hack/[event_id]/team/[team_id]` (full rewrite: editorial masthead + hairline `.ohx-card` sections + a **deep-linkable sticky Table of Contents** — sections are `
    ` with hover `#` copy-link anchors (keyboard accessible via `&:focus-visible`), the TOC lists only present sections (`aria-current="location"`) and tracks the active one via `IntersectionObserver`; embedded `MentorTeamPanel`/`TeamCompletionChecklist` keep their own styling in anchored `headed={false}` wrappers). **Key invariants on this page:** (1) Status labels come from `TEAM_STATUS_OPTIONS.find()` via `statusLabel()` — never render raw enum strings like `NONPROFIT_SELECTED`; winning statuses get a 🏆 prefix from `getWinningStatus()`; `INACTIVE` skips the status tag since the dot already conveys it. (2) `SectionBlock` is **module-scope** (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver `setActiveId` tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives `copiedId`/`onCopyLink` as props. (3) `parseLocalDate` from `src/lib/dateUtils.js` is used for all event window checks (`eventHasStarted`, `eventEnded`) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses `useTeamMembership(eventId, teamId)` from `src/hooks/use-team-membership.js` — shared by the page and passed as `isOnTeam`/`membershipChecked` props to `TeamCompletionChecklist` (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render `selected_nonprofit_id` text — only show when `nonprofitData.name` is available; `getStaticProps` fetches `{ name, description }` into `nonprofitData`; a "Nonprofit partner" section card links to `/nonprofit/`. (6) `awards[]` rendered as `ohx-tag--accent` chips after the status tag. (7) All GitHub repos in `github_links[]` rendered (handles both string and `{link,name}` shapes). (8) `getStaticProps` rethrows network errors (ISR keeps the last good version) and returns `notFound` only on genuine 404. **Harmonized (not full rewrite):** `/hack` index (keeps its bespoke finder + `HackPageNav`; hero/CTAs/Support band recolored via inline `RX` tokens). The upcoming/current event cards were refined too: `HackathonList` full-mode heading → Fraunces eyebrow; `EventFeature` full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since `/hack` isn't a `RefinedRoot`). `ImpactMetrics` (shared with the archive) → warm `--surface-2` tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow `color`-prop numbers). `/hack/[event_id]` got a **masthead-led refined pass**: body wrapped in `` (it's now the `
    `; all section IDs + `TableOfContents` + `FloatingNavigation` preserved), `HackathonHeader` rebuilt into a scope-independent editorial masthead (inline styles w/ `var(--x, fallback)` so `/agenda` + `/census` also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the **event-only** sub-components: `NonprofitList` (calm clickable cards + navy view-toggle), `EventConstraints`, `DonationProgress` (navy rings), `EventCountdown` (navy countdown card replacing the gradient + flat timeline), `TeamList`/`TeamCard` (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — `TeamList` is event-page-only despite earlier substring matches in admin/event-teams; **profile avatars load lazily per-card via IntersectionObserver with `rootMargin: "200px"`, tracked by `fetchedTeamProfilesRef` Set** — do NOT restore the eager `fetchTeamMemberProfiles(teams)` call on mount that fired N parallel requests for all 30+ cards at once). **Backend C7 enrichment:** `get_single_hackathon_event` (`services/hackathons_service.py`) now calls `_enrich_teams_users_batch` after converting team DocumentReferences — one `db.get_all` across all members of all teams, deduped, so the frontend receives `users[]` already as `{id, user_id, name, nickname, profile_image}` objects. `HackathonResults.js`/`TeamList.js` handle both the old id-string and new object shapes (backwards-compatible). The list endpoint `get_hackathon_list` is NOT enriched — only the single-event getter., and `HackathonLeaderboard` (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). **Phase 3 (done) finished the event page**: `EventLinks` (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), `InteractiveFAQ` (flat hairline accordions), `HackathonResults` (calm `--surface-2` frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), `VolunteerList` (`PersonCard` → flat hairline). The **shared** ones (`InteractiveFAQ`, `HackathonResults`) use **inline CSS-var fallbacks** (`var(--ink,#16181D)`) so they render refined on their non-`RefinedRoot` routes too (verified on `/hack/[event_id]/results` + `OnboardingFAQ`) — no per-route gating. A final chrome pass refined `TableOfContents` (navy active pill, was magenta), `FloatingNavigation` (navy FAB), `VolunteerList` (navy "Learn more" + navy availability chips; in-person stays green), and `MentorAvailability` (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: `/hack/request` (host-an-event page + `HackathonRequestForm`) and `/office-hours`. **`/hack/request` is student-first** — org-type selectable cards default to **university** (was corporate); the **$5k budget minimum was removed** (slider min `$0`, never blocks submit, reframed as optional funding); the donation-% ask is **corporate-only** (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm `--surface-2` cards; everything navy/terracotta via a scoped MUI `ThemeProvider` (`createTheme(base, …)`). **`formData` keys are preserved** — `HackathonRequestDetailDialog`, the edit page `/hack/request/[request_id]`, and the backend all depend on them. **Scroll gotcha:** step nav now `scrollIntoView`s a `formTopRef` on the form's Paper (`scrollMarginTop:88`), NOT `window.scrollTo(0,0)` (which overshot to page-top now that the form sits below the hero). `/office-hours` now renders its JSON-LD, uses the idempotent `initFacebookPixel` (was a raw `ReactPixel.init` — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: `/nonprofit/[nonprofit_id]` (`NonProfit.js` — editorial masthead, meta row as `.ohx-tag`/`.ohx-link`s, projects list, canonical www fix) and `/project/[project_id]` (`Project.js` — `RefinedRoot` chrome, soft breadcrumb, canonical). Both share `ProblemStatement.js` which was fully restyled (purple gradient hero → calm `--surface-2` band; gradient chips → `.ohx-tag`; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline `.ohx-card`; gradient CTAs → `.ohx-btn`). **Key invariant:** `ProblemStatement` is ONLY ever inside a `RefinedRoot`, so it may use scoped `.ohx-*` classes directly (no inline `var(--x, fallback)` needed). Pass `headingLevel="h2"` from the nonprofit page (N cards, nonprofit name is the `

    `) and `headingLevel="h1"` from the project page (project title is the `

    `). The "Want to help?" toggle is hidden when `status === "production"` (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; `maintenance` status keeps the toggle. **"Code & Tasks" (July 2026 discoverability fix):** it is an ALWAYS-VISIBLE section (`id="code-and-tasks-"` — unique per problem statement because nonprofit pages render N cards; `scrollMarginTop: 96`) directly after the Project Description — never move it back into the collapsed accordion trio (burying it was the bug; only References/Events stay accordions). Repo list = `problem_statement.github` (handles legacy string shape) MERGED with repos from hackathon teams in the already-fetched event data (`team.problem_statements` includes the ps id; `selected_nonprofit_id`-vs-`resolvedNonprofits` fallback applies ONLY to teams with no `problem_statements` — avoids claiming a sibling project's repos on multi-project nonprofits), deduped by `normalizeRepoLink`; team repos render under a "from hackathon teams" label with "Built by {team} ({event})" attribution, and a duplicate just enriches the project-level card. Live issue data (counts + top-5 open titles per repo) is fetched through the public backend proxy `GET /api/github/issues?org&repo&state=all` ONLY once the section scrolls near (fire-once IntersectionObserver, `rootMargin: 200px`) — batched Promise.all + `issuesRequestedRef` dedupe + ONE setState (never per-repo), errors swallowed (static links still render); backend caches this route 10 min. `RepoCard` + repo-link helpers are **module-scope** (SectionBlock remount lesson). When no repos exist at all, render the "Where's the code?" `--surface-2` card (event-page links + `slack_channel` button) — never hide the section silently. Header top row shows an `#code-and-tasks` anchor `.ohx-tag` when repos exist. Backend fix-forward: `approve_team` (`api/teams/teams_service.py::_link_repo_to_problem_statements`) now also appends the created repo to the linked problem statement(s)' `github` array (team `problem_statements` refs preferred; single-PS-nonprofit fallback; ambiguous → skip + log) and clears `get_single_problem_statement_old`'s cache. **Events & Teams section:** `src/components/Events/Events.js` was rewritten as refined event cards — title is `event.title` (NOT `{location} {type}` — that rendered a street address as the heading), Past/Upcoming `.ohx-tag`, compact date range via `parseLocalDate`, location + description as muted meta, constraint `.ohx-tag`s, ghost Event-page/DevPost buttons, plus a **read-only** teams list (name, member first-names, link to `/hack//team/`) fed by `teamsByEvent` from the same ProblemStatement memo that derives team repos. The old interactive join/leave path was dead (its `teams` state was never populated) and was removed from ProblemStatement along with the per-user `userDetails` fetch — `event-teams.js` and `event-team.js` are now orphaned; don't re-add join/leave here (team joining lives on the event page / manageteam). **Reference Documents:** `ReferenceItem.js` restyled from outlined MUI buttons to hairline row-cards (`ohx-card--hover` `` rows: navy kind icon + sentence-case name + kind `.ohx-tag` [GitHub/Slides/Document/Video/Link] + open-in-new indicator; `docs.google.com/presentation` now classified as Slides); video references keep their `VideoDisplay` embed. Only ProblemStatement consumes it, so `.ohx-*` classes are safe there. `styles/nonprofit/styles.js` is now orphaned — leave in place. See `docs/refined-design-system.md`. Pattern for new pages: keep `Head`/`getStaticProps`/schema verbatim, add ``, wrap body in ``, rebuild sections with `.ohx-*` classes, one `

    `, preserve GA. diff --git a/src/components/ReferenceItem/ReferenceItem.js b/src/components/ReferenceItem/ReferenceItem.js index 93340a0..f374077 100644 --- a/src/components/ReferenceItem/ReferenceItem.js +++ b/src/components/ReferenceItem/ReferenceItem.js @@ -1,89 +1,90 @@ -import React from 'react'; -import Button from "@mui/material/Button"; +import React from "react"; import ArticleIcon from "@mui/icons-material/Article"; -import SmartDisplayIcon from '@mui/icons-material/SmartDisplay'; -import CoPresentIcon from '@mui/icons-material/CoPresent'; -import GitHubIcon from '@mui/icons-material/GitHub'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import SmartDisplayIcon from "@mui/icons-material/SmartDisplay"; +import CoPresentIcon from "@mui/icons-material/CoPresent"; +import GitHubIcon from "@mui/icons-material/GitHub"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import VideoDisplay from "../VideoDisplay/VideoDisplay"; -function getIconForReferenceLinkOrName(link, name) { - const documentExtensions = [".pdf", ".doc", ".docx"]; - for (const extension of documentExtensions) { - if (link.endsWith(extension)) { - return ; - } - } +// Only ever rendered inside ProblemStatement (which is always inside a +// RefinedRoot), so scoped .ohx-* classes are safe here. - const documentPlatforms = ["docs.google.com"]; - for (const platform of documentPlatforms) { - if (link.includes(platform)) { - return ; - } - } +const getReferenceMeta = (link = "", name = "") => { + const l = link.toLowerCase(); + const n = name.toLowerCase(); - // If YouTube is in the name or video is in the name - const videoIconText = ["youtube", "video"]; - const videoIconLink = ["youtube.com", "vimeo.com"]; - if (videoIconText.some((text) => name.toLowerCase().includes(text)) || videoIconLink.some((text) => link.includes(text))) { - return ; + if (l.includes("github.com")) return { Icon: GitHubIcon, kind: "GitHub" }; + if ( + l.endsWith(".pptx") || + l.includes("slides.google.com") || + l.includes("docs.google.com/presentation") + ) { + return { Icon: CoPresentIcon, kind: "Slides" }; } - - // If .pptx or slides.google.com, use a presentation icon - const presentationExtensions = [".pptx"]; - for (const extension of presentationExtensions) { - if (link.endsWith(extension)) { - return ; - } + if ( + [".pdf", ".doc", ".docx"].some((ext) => l.endsWith(ext)) || + l.includes("docs.google.com") + ) { + return { Icon: ArticleIcon, kind: "Document" }; } - - const presentationPlatforms = ["slides.google.com"]; - for (const platform of presentationPlatforms) { - if (link.includes(platform)) { - return ; - } - } - - // Handle github name or link - const githubPlatforms = ["github.com"]; - for (const platform of githubPlatforms) { - if (link.includes(platform)) { - return ; - } + if ( + ["youtube", "video"].some((text) => n.includes(text)) || + ["youtube.com", "vimeo.com"].some((host) => l.includes(host)) + ) { + return { Icon: SmartDisplayIcon, kind: "Video" }; } + return { Icon: OpenInNewIcon, kind: "Link" }; +}; - // Default to a link icon - return ; -} +const isVideoUrl = (url) => + url && + (url.includes("youtube.com") || + url.includes("youtu.be") || + url.includes("vimeo.com") || + url.includes("loom.com") || + url.includes("drive.google.com/file")); const ReferenceItem = ({ reference }) => { - const isVideoUrl = (url) => { - return url && ( - url.includes('youtube.com') || - url.includes('youtu.be') || - url.includes('vimeo.com') || - url.includes('loom.com') || - url.includes('drive.google.com/file') - ); - }; - if (reference.link && isVideoUrl(reference.link)) { return ; } + const { Icon, kind } = getReferenceMeta(reference.link, reference.name); + return ( - + + + {reference.name} + + + {kind} + + + ); }; From db51553e86498e248f6c170dfdde8e8d40a3580d Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:59:41 -0700 Subject: [PATCH 04/18] Add paused project status + redesign the status ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New shared catalog src/lib/projectStatus.js: single source of truth for the five ladder stages (concept → hackathon → post-hackathon → production → maintenance) plus the new off-ramp "paused" status, with labels, short blurbs, and full descriptions. - ProjectProgress rewritten as a refined stepper rail (done/current/ upcoming nodes, hairline connectors) with a visible one-line explanation of the current stage — replaces the cryptic "concept hack post-hack prod maint" chips and their Heroku-era tooltip copy. Paused mutes the rail and explains that work is on hold. - ProblemStatement: paused projects show a quiet "Paused" tag and stop recruiting (help toggle hidden except for existing helpers, mirroring production); maintenance now shows "Live" + "Welcomes Help" instead of reading as unfinished. - Projects list: Maintenance/Paused filter chips, paused sinks in relevance/needs-help sorts, never featured, card footer says "On hold". - Onboarding ladder now renders from the same catalog + paused footnote. - Admin problems: status free-text field replaced with a Select of canonical statuses (legacy values preserved), unified chip colors. - Project page SEO meta uses the human status label. - Removed orphaned duplicate project-progress.js and unused styles.js. No backend change needed: status is stored as a free-form string and already passes through create/update. Co-Authored-By: Claude Fable 5 --- .../Onboarding/HowItWorksSection.js | 22 +- .../ProblemStatement/ProblemStatement.js | 59 ++++- .../FeaturedProjects/FeaturedProjects.js | 10 +- src/components/ProjectList/ProjectCard.js | 17 +- src/components/ProjectList/ProjectList.js | 19 +- .../ProjectProgress/ProjectProgress.js | 246 ++++++++++++------ src/components/ProjectProgress/styles.js | 7 - src/components/project-progress.js | 88 ------- src/lib/projectStatus.js | 87 +++++++ src/pages/admin/problems/index.js | 80 +++--- src/pages/project/[project_id].js | 6 +- 11 files changed, 389 insertions(+), 252 deletions(-) delete mode 100644 src/components/ProjectProgress/styles.js delete mode 100644 src/components/project-progress.js create mode 100644 src/lib/projectStatus.js diff --git a/src/components/Onboarding/HowItWorksSection.js b/src/components/Onboarding/HowItWorksSection.js index 1424680..7d16ca2 100644 --- a/src/components/Onboarding/HowItWorksSection.js +++ b/src/components/Onboarding/HowItWorksSection.js @@ -14,6 +14,7 @@ import TimelineIcon from '@mui/icons-material/Timeline'; import FactCheckIcon from '@mui/icons-material/FactCheck'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import StepHeader from './StepHeader'; +import { PROJECT_LADDER, PAUSED_STATUS } from '../../lib/projectStatus'; const NAVY = '#1B3A6B'; @@ -62,14 +63,12 @@ const definitionOfDone = [ 'Open-sourced under the MIT license' ]; -// Matches the status ladder shown on every project page (ProjectProgress). -const projectStatuses = [ - { label: 'Concept', description: 'An idea no one has built yet — needs a team' }, - { label: 'Hackathon', description: 'Worked on at one or more hackathons' }, - { label: 'Post-Hackathon', description: 'Development continuing after the event' }, - { label: 'Production', description: 'Live and being used by the nonprofit' }, - { label: 'Maintenance', description: 'Live, receiving patches and enhancements' } -]; +// Matches the status ladder shown on every project page (ProjectProgress) — +// both render from the shared catalog in src/lib/projectStatus.js. +const projectStatuses = PROJECT_LADDER.map(({ label, blurb }) => ({ + label, + description: blurb +})); /** * HowItWorksSection @@ -198,6 +197,13 @@ const HowItWorksSection = () => { ))} + + You may also see a project marked{' '} + + {PAUSED_STATUS.label} + + {' '}— {PAUSED_STATUS.description.charAt(0).toLowerCase() + PAUSED_STATUS.description.slice(1)} + diff --git a/src/components/ProblemStatement/ProblemStatement.js b/src/components/ProblemStatement/ProblemStatement.js index 1d9a2cb..49f24e4 100644 --- a/src/components/ProblemStatement/ProblemStatement.js +++ b/src/components/ProblemStatement/ProblemStatement.js @@ -4,6 +4,7 @@ import React, { useState, useEffect, useMemo, useRef } from "react"; import Tooltip from "@mui/material/Tooltip"; import BuildIcon from "@mui/icons-material/Build"; import WorkspacePremiumIcon from "@mui/icons-material/WorkspacePremium"; +import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline"; import TagIcon from "@mui/icons-material/Tag"; import Switch from "@mui/material/Switch"; import Stack from "@mui/material/Stack"; @@ -36,6 +37,11 @@ import useHackathonEvents from "../../hooks/use-hackathon-events"; import useProjectNonprofit from "../../hooks/use-project-nonprofit"; import { useRedirectFunctions } from "@propelauth/react"; import { trackEvent, initFacebookPixel } from "../../lib/ga"; +import { + isPausedStatus, + isLiveStatus, + acceptsNewHelpers, +} from "../../lib/projectStatus"; import Events from "../Events/Events"; import ReferenceItem from "../ReferenceItem/ReferenceItem"; import { HelpDialog, UnhelpDialog } from "../HelpDialog/HelpDialog"; @@ -744,8 +750,10 @@ export default function ProblemStatement({ const copyProjectLink = "project/" + problem_statement.id; - // Live projects don't need new volunteers; maintenance-status projects still do. - const isProduction = problem_statement.status === "production"; + // Live projects don't need new volunteers; maintenance-status projects still + // do. Paused projects have no active need, so they don't recruit either. + const isPaused = isPausedStatus(problem_statement.status); + const offerHelpToggle = acceptsNewHelpers(problem_statement.status); // Code & Tasks tiers: project-level repos are canonical; team-built repos // from hackathons follow under their own quiet label @@ -772,22 +780,55 @@ export default function ProblemStatement({ const TitleTag = headingLevel; const renderStatus = () => { - if (problem_statement.status === "production") { + if (isPaused) { return ( - - Live + Paused ); } + if (isLiveStatus(problem_statement.status)) { + const maintained = problem_statement.status === "maintenance"; + return ( + + + + + Live + + + {maintained && ( + + + + Welcomes Help + + + )} + + ); + } return ( - {/* Help toggle — hidden on production projects, except for existing - helpers so they can still toggle themselves off */} - {(!isProduction || help_checked === "checked") && ( + {/* Help toggle — hidden on production and paused projects, except for + existing helpers so they can still toggle themselves off */} + {(offerHelpToggle || help_checked === "checked") && (
    {renderHelpToggle()}
    )} diff --git a/src/components/ProjectList/FeaturedProjects/FeaturedProjects.js b/src/components/ProjectList/FeaturedProjects/FeaturedProjects.js index 4838620..ee6de7e 100644 --- a/src/components/ProjectList/FeaturedProjects/FeaturedProjects.js +++ b/src/components/ProjectList/FeaturedProjects/FeaturedProjects.js @@ -1,5 +1,6 @@ import NextLink from "next/link"; import Image from "next/image"; +import { PROJECT_STATUS_LABELS } from "../../../lib/projectStatus"; // Quiet 3-up featured strip: a photo, a title, a one-line teaser, and a single // status tag. (Previously each featured card also stacked GitHub/helpers/skill @@ -20,13 +21,6 @@ const FEATURED_IMAGES = [ "https://cdn.ohack.dev/ohack.dev/2024_hackathon_6.webp", ]; -const STATUS_LABEL = { - concept: "Concept", - hackathon: "Hackathon", - "post-hackathon": "Post-hackathon", - production: "Production", -}; - function hashCode(str = "") { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -57,7 +51,7 @@ export default function FeaturedProjects({ projects }) { > {projects.map((project, i) => { const img = FEATURED_IMAGES[Math.abs(hashCode(project.id)) % FEATURED_IMAGES.length]; - const status = STATUS_LABEL[project.status] || project.status; + const status = PROJECT_STATUS_LABELS[project.status] || project.status; return ( scope. -const STATUS_LABEL = { - concept: "Concept", - hackathon: "Hackathon", - "post-hackathon": "Post-hackathon", - production: "Production", -}; - function getGithubUrl(project) { if (!project.github) return null; if (typeof project.github === "string") return project.github; @@ -42,8 +36,9 @@ export default function ProjectCard({ project, hackathons }) { return hackathons.find((h) => project.events.includes(h.id)) || null; }, [project.events, hackathons]); - const statusLabel = STATUS_LABEL[project.status] || project.status; + const statusLabel = PROJECT_STATUS_LABELS[project.status] || project.status; const isProduction = project.status === "production"; + const isPaused = isPausedStatus(project.status); return (
    - {helpers > 0 ? `${helpers} helper${helpers === 1 ? "" : "s"}` : "Needs helpers"} + {helpers > 0 + ? `${helpers} helper${helpers === 1 ? "" : "s"}` + : isPaused + ? "On hold" + : "Needs helpers"} {relatedHackathon?.location ? ` · ${relatedHackathon.location}` : ""}
    diff --git a/src/components/ProjectList/ProjectList.js b/src/components/ProjectList/ProjectList.js index 5db056d..7f17dcf 100644 --- a/src/components/ProjectList/ProjectList.js +++ b/src/components/ProjectList/ProjectList.js @@ -3,15 +3,13 @@ import { Pagination, Box } from "@mui/material"; import ProjectCard from "./ProjectCard"; import FeaturedProjects from "./FeaturedProjects/FeaturedProjects"; import { RefinedRoot, Eyebrow, Arrow } from "../design/refined"; +import { ALL_PROJECT_STATUSES, isPausedStatus } from "../../lib/projectStatus"; const PROJECTS_PER_PAGE = 9; const STATUS_FILTERS = [ { value: null, label: "All" }, - { value: "concept", label: "Concept" }, - { value: "hackathon", label: "Hackathon" }, - { value: "post-hackathon", label: "Post-hackathon" }, - { value: "production", label: "Production" }, + ...ALL_PROJECT_STATUSES.map(({ value, label }) => ({ value, label })), ]; const SORT_OPTIONS = [ @@ -44,6 +42,12 @@ export default function ProjectList({ initialProjects = [], events }) { const sorted = useMemo(() => { return [...filtered].sort((a, b) => { + // Paused projects aren't asking for work, so they sink below active + // ones in the help-oriented sorts (unless the Paused filter is on). + if (sortBy === "rank" || sortBy === "needHelp") { + const pausedDelta = isPausedStatus(a.status) - isPausedStatus(b.status); + if (pausedDelta !== 0) return pausedDelta; + } switch (sortBy) { case "newest": return new Date(b.created_at || 0) - new Date(a.created_at || 0); @@ -68,7 +72,12 @@ export default function ProjectList({ initialProjects = [], events }) { const explicit = initialProjects.filter((p) => p.featured); if (explicit.length >= 3) return explicit.slice(0, 3); return [...initialProjects] - .filter((p) => p.status !== "production" && p.status !== "post-hackathon") + .filter( + (p) => + p.status !== "production" && + p.status !== "post-hackathon" && + !isPausedStatus(p.status) + ) .sort((a, b) => (b.rank || 0) - (a.rank || 0)) .slice(0, 3); }, [initialProjects]); diff --git a/src/components/ProjectProgress/ProjectProgress.js b/src/components/ProjectProgress/ProjectProgress.js index afaa700..4b122b1 100644 --- a/src/components/ProjectProgress/ProjectProgress.js +++ b/src/components/ProjectProgress/ProjectProgress.js @@ -1,94 +1,168 @@ -import CheckCircleOutlineOutlinedIcon from "@mui/icons-material/CheckCircleOutlineOutlined"; -import PendingOutlinedIcon from "@mui/icons-material/PendingOutlined"; import Tooltip from "@mui/material/Tooltip"; -import { TagChip } from "./styles"; +import PauseCircleOutlineIcon from "@mui/icons-material/PauseCircleOutline"; +import { + PROJECT_LADDER, + PAUSED_STATUS, + ladderIndex, + isPausedStatus, +} from "../../lib/projectStatus"; -export default function ProjectProgress({ state }) { - const stateMapping = { - concept: [2, 0, 0, 0, 0], - hackathon: [1, 2, 0, 0, 0], - "post-hackathon": [1, 1, 2, 0, 0], - production: [1, 1, 1, 2, 0], - maintenance: [1, 1, 1, 1, 2], - }; +// Refined status rail for the project page header. Renders the five-stage +// build journey as a quiet stepper (done → current → upcoming) plus a visible +// one-line explanation of the current stage — tooltips alone were too easy to +// miss, especially on touch. `paused` is not a rung: the whole rail mutes and +// the caption explains that work is on hold. +// +// Only ever rendered inside a (via ProblemStatement), so refined +// CSS vars are available without fallbacks. - const states = [ - "concept", - "hack", - "post-hack", - "prod", - "maint", - ]; +const DOT = { + width: 9, + height: 9, + borderRadius: "50%", + flexShrink: 0, + boxSizing: "border-box", +}; - const description = [ - "Concept: This idea has not yet had anyone work on it, we need your help to make this a reality.", - "Hackathon: Project was worked on in at least one hackathon, top finisher also designated.", - "Post-Hackathon: Coding continued after the hackathon. We don't have anything available for public in a production-ready system yet. Capstone projects: We work with university senior capstone (ASU, ASU Poly, UArizona, GCU) teams to advance our mission. These teams have completed their capstone work. ", - "Production: Application is using Heroku (or similar) for ease of deployment, data import/export with CSV is supported. This is the ultimate goal of any Opportunity Hack project.", - "Maintenance: Post-production security patching, feature enhancements.", - ]; +export default function ProjectProgress({ state }) { + const paused = isPausedStatus(state); + const currentIndex = paused ? -1 : ladderIndex(state); + const currentMeta = paused + ? PAUSED_STATUS + : PROJECT_LADDER[currentIndex] || null; - const progressToRender = () => { - const statusList = stateMapping[state]; + return ( +
    +
    +
      + {PROJECT_LADDER.map((stage, i) => { + const done = currentIndex > i; + const current = currentIndex === i; + const last = i === PROJECT_LADDER.length - 1; - return states.map((name, index) => { - if (statusList && statusList[index] === 2) { - return ( - {description[index]} - } - arrow - > - } - /> - - ); - } else if (statusList && statusList[index] === 1) { - return ( - {description[index]} - } - arrow - > - } - /> - - ); - } else { - return ( - {description[index]} - } - arrow - > - } - /> - - ); - } - }); - }; + return ( +
    1. + + {stage.label}: {stage.description} + + } + > + + + + {!last && ( +
    2. + ); + })} +
    +
    - return
    {progressToRender()}
    ; + {currentMeta && ( +

    + + {paused && ( + + )} + {currentMeta.label} + + {" — "} + {currentMeta.description} +

    + )} +
    + ); } diff --git a/src/components/ProjectProgress/styles.js b/src/components/ProjectProgress/styles.js deleted file mode 100644 index 2282171..0000000 --- a/src/components/ProjectProgress/styles.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Chip } from "@mui/material"; -import { styled } from "@mui/material"; - -export const TagChip = styled(Chip) ({ - marginRight: "0.5rem", - marginBottom: "0.5rem" -}) \ No newline at end of file diff --git a/src/components/project-progress.js b/src/components/project-progress.js deleted file mode 100644 index 9a27659..0000000 --- a/src/components/project-progress.js +++ /dev/null @@ -1,88 +0,0 @@ -import Chip from "@mui/material/Chip"; -import CheckCircleOutlineOutlinedIcon from "@mui/icons-material/CheckCircleOutlineOutlined"; -import PendingOutlinedIcon from "@mui/icons-material/PendingOutlined"; -import Tooltip from "@mui/material/Tooltip"; - -export default function ProjectProgress({ state }) { - const stateMapping = { - concept: [2, 0, 0, 0, 0], - hackathon: [1, 2, 0, 0, 0], - "post-hackathon": [1, 1, 2, 0, 0], - production: [1, 1, 1, 2, 0], - maintenance: [1, 1, 1, 1, 2], - }; - - const states = [ - "concept", - "hackathon", - "post-hackathon", - "production", - "maintenance", - ]; - - const description = [ - "This idea has not yet had anyone work on it, we need your help to make this a reality.", - "Project was worked on in at least one hackathon, top finisher also designated.", - "Coding continued after the hackathon. We don't have anything available for public in a production-ready system yet. Capstone projects: We work with university senior capstone (ASU, ASU Poly, UArizona, GCU) teams to advance our mission. These teams have completed their capstone work. ", - "Application is using Heroku (or similar) for ease of deployment, data import/export with CSV is supported. This is the ultimate goal of any Opportunity Hack project.", - "Post-production security patching, feature enhancements.", - ]; - - const progressToRender = () => { - const statusList = stateMapping[state]; - - return states.map((name, index) => { - if (statusList[index] === 2) { - return ( - {description[index]} - } - > - } - /> - - ); - } else if (statusList[index] === 1) { - return ( - {description[index]} - } - > - } - /> - - ); - } else { - return ( - {description[index]} - } - > - } - /> - - ); - } - }); - }; - - return
    {progressToRender()}
    ; -} diff --git a/src/lib/projectStatus.js b/src/lib/projectStatus.js new file mode 100644 index 0000000..ad0210d --- /dev/null +++ b/src/lib/projectStatus.js @@ -0,0 +1,87 @@ +// Canonical project (problem statement) status catalog — the single source of +// truth for status values, labels, and explanations across the site +// (ProjectProgress, ProblemStatement, ProjectList/ProjectCard, onboarding, +// admin). The backend stores status as a free-form string, so this list is +// what keeps every surface consistent. + +// The build journey, in ladder order. `paused` is intentionally NOT a rung — +// it's a parked state a project can enter from any stage, not a stage itself. +export const PROJECT_LADDER = [ + { + value: "concept", + label: "Concept", + blurb: "An idea no one has built yet — needs a team", + description: + "An idea waiting for its first team. Nobody has written code yet, which makes this the easiest place to jump in and shape the whole project.", + }, + { + value: "hackathon", + label: "Hackathon", + blurb: "Worked on at one or more hackathons", + description: + "Teams have worked on this at one or more Opportunity Hack events, so prototype code exists in GitHub — a starting point, not a finished product.", + }, + { + value: "post-hackathon", + label: "Post-hackathon", + blurb: "Development continuing after the event", + description: + "Development continued after the event — often by a winning team or a university capstone group — but nothing is live for the nonprofit yet.", + }, + { + value: "production", + label: "Production", + blurb: "Live and being used by the nonprofit", + description: + "Deployed and in real use by the nonprofit. This is the goal of every Opportunity Hack project.", + }, + { + value: "maintenance", + label: "Maintenance", + blurb: "Live, receiving patches and enhancements", + description: + "Live and being cared for: security patches, bug fixes, and small enhancements. Contributors are still welcome here.", + }, +]; + +// Off-ramp state: work is intentionally on hold. Distinct from the ladder so +// nothing implies "one step past maintenance". +export const PAUSED_STATUS = { + value: "paused", + label: "Paused", + blurb: "Work is intentionally on hold", + description: + "Work on this project is intentionally on hold — the nonprofit's need changed or something outside the team's control has to happen first. Check the project's Slack channel before starting new work.", +}; + +export const ALL_PROJECT_STATUSES = [...PROJECT_LADDER, PAUSED_STATUS]; + +// value -> display label, for quick tag rendering +export const PROJECT_STATUS_LABELS = Object.fromEntries( + ALL_PROJECT_STATUSES.map((s) => [s.value, s.label]) +); + +export function getProjectStatusMeta(status) { + return ALL_PROJECT_STATUSES.find((s) => s.value === status) || null; +} + +// Index of a status on the ladder; -1 for paused/unknown +export function ladderIndex(status) { + return PROJECT_LADDER.findIndex((s) => s.value === status); +} + +export function isPausedStatus(status) { + return status === PAUSED_STATUS.value; +} + +// Live for the nonprofit (production or maintenance) +export function isLiveStatus(status) { + return status === "production" || status === "maintenance"; +} + +// Should the "Want to help?" affordance be offered to NEW volunteers? +// Production projects don't need new volunteers; paused projects have no +// active need. Maintenance projects still welcome help. +export function acceptsNewHelpers(status) { + return status !== "production" && !isPausedStatus(status); +} diff --git a/src/pages/admin/problems/index.js b/src/pages/admin/problems/index.js index 9c98956..c1a57a6 100644 --- a/src/pages/admin/problems/index.js +++ b/src/pages/admin/problems/index.js @@ -45,10 +45,23 @@ import { } from "@mui/material"; import { Add as AddIcon, Edit as EditIcon, Link as LinkIcon, Save as SaveIcon, Delete as DeleteIcon, Event as EventIcon, Close as CloseIcon, GitHub as GitHubIcon, People as PeopleIcon, Description as DescriptionIcon, Chat as ChatIcon, OpenInNew as OpenInNewIcon } from "@mui/icons-material"; import AdminPage from "../../../components/admin/AdminPage"; +import { ALL_PROJECT_STATUSES } from "../../../lib/projectStatus"; import LinkManagement from "../../../components/admin/LinkManagement"; import useNonprofit from "../../../hooks/use-nonprofit"; import useHackathonEvents from "../../../hooks/use-hackathon-events"; +// One chip color scheme for both the card and table views +const statusChipColor = (status) => + status === "production" || status === "maintenance" + ? "success" + : status === "hackathon" + ? "info" + : status === "post-hackathon" + ? "warning" + : status === "active" + ? "primary" + : "default"; + const AdminProblemsPage = () => { const { accessToken, userClass } = useAuthInfo(); const [problems, setProblems] = useState(Array.isArray(null) ? null : []); @@ -642,13 +655,7 @@ const AdminProblemsPage = () => { @@ -919,17 +926,7 @@ const AdminProblemsPage = () => { @@ -1403,18 +1400,41 @@ const AdminProblemsPage = () => { )} - - setEditingProblem((prev) => ({ - ...prev, - status: e.target.value, - })) - } - margin="normal" - /> + + Status + + import("../../components/Project/Project"), { ssr: false @@ -51,7 +52,8 @@ export async function getStaticProps({ params = {} } = {}) { var title = nonprofitNames ? `Project: ${ps.title} — ${nonprofitNames}` : "Project: " + ps.title; - var metaDescription = (nonprofitNames ? `For ${nonprofitNames}. ` : "") + ps.status + ": " + ps.description + " "; + const statusLabel = PROJECT_STATUS_LABELS[ps.status] || ps.status || "Project"; + var metaDescription = (nonprofitNames ? `For ${nonprofitNames}. ` : "") + statusLabel + ": " + ps.description + " "; var countOfhelpingMentors = 0; var countOfhelpingHackers = 0; @@ -132,7 +134,7 @@ export async function getStaticProps({ params = {} } = {}) { }, { property: "twitter:data1", - value: ps.status, + value: statusLabel, key: "twitterdata1", }, { From a2f0a8fb21f048d5657d29cac1ff5965e7b2dacd Mon Sep 17 00:00:00 2001 From: Greg V <6913307+gregv@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:08:30 -0700 Subject: [PATCH 05/18] Document the shared project-status catalog in CLAUDE.md Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index bdc812d..f46b9cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,10 @@ The homepage and projects page were reimagined away from the old busy/multi-grad `src/components/Navbar/Navbar.js` + `styles.js` were restyled from the solid-blue MUI AppBar to a light, frosted "civic editorial" bar so it's cohesive with the refined pages everywhere: `backgroundColor: rgba(251,250,246,0.82)` + `backdropFilter: blur` + `borderBottom: 1px solid #E7E1D4`, `elevation={0}`, ink (`#16181D`) links, hover → brand navy `#1B3A6B`. Load button is a squared navy button (no more 1.5rem rounded pill). Links are sentence-case (`textTransform: none`) — keep the page-link ` + +
    +
    + ) : ( +
    + +
    + setLinkDraft(e.target.value)} + sx={{ minWidth: 300, flex: "1 1 300px" }} + /> + +
    +
    + )} + + {progress !== null && ( +
    + +

    Uploading… {progress}%

    +
    + )} + {error &&

    {error}

    } + + + + ); +} diff --git a/src/components/Profile/Portfolio/CertificateWallSection.jsx b/src/components/Profile/Portfolio/CertificateWallSection.jsx new file mode 100644 index 0000000..f27fa7f --- /dev/null +++ b/src/components/Profile/Portfolio/CertificateWallSection.jsx @@ -0,0 +1,71 @@ +import React from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { normalizeImageUrl } from "../../../lib/imageUtils"; + +/** + * Wall of certificate PNGs (all 1024×1024 squares on cdn.ohack.dev). + * Git-fame GitHub certs link to their verifiable /cert/ page; + * heart certificates open the image itself. + */ +export default function CertificateWallSection({ certificates }) { + const githubCerts = certificates?.github_certificates || []; + const heartCerts = certificates?.heart_certificates || []; + if (githubCerts.length === 0 && heartCerts.length === 0) return null; + + const tiles = [ + ...githubCerts.map((cert) => ({ + key: `gh-${cert.file_id}`, + image: normalizeImageUrl(cert.certificate_url), + href: cert.file_id ? `/cert/${cert.file_id}` : normalizeImageUrl(cert.certificate_url), + external: !cert.file_id, + tag: "GitHub", + caption: cert.repository_url ? cert.repository_url.split("/").slice(-1)[0] : null, + })), + ...heartCerts.map((cert, i) => ({ + key: `heart-${i}`, + image: normalizeImageUrl(cert.url), + href: normalizeImageUrl(cert.url), + external: true, + tag: "Hearts", + caption: Array.isArray(cert.reasons) && cert.reasons.length ? cert.reasons.join(", ").replace(/_/g, " ") : null, + })), + ].filter((t) => t.image); + + return ( +
    + {tiles.map((tile) => { + const frame = ( + <> + + {`${tile.tag} + + + {tile.tag} + {tile.caption && ( + {tile.caption} + )} + + + ); + + return tile.external ? ( + + {frame} + + ) : ( + + {frame} + + ); + })} +
    + ); +} diff --git a/src/components/Profile/Portfolio/CustomLinksEditor.jsx b/src/components/Profile/Portfolio/CustomLinksEditor.jsx new file mode 100644 index 0000000..fec7924 --- /dev/null +++ b/src/components/Profile/Portfolio/CustomLinksEditor.jsx @@ -0,0 +1,107 @@ +import React, { useEffect, useRef, useState } from "react"; +import { TextField, IconButton, Button, Tooltip } from "@mui/material"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; + +const MAX_LINKS = 10; + +/** + * Editor for the user's own links ({label, url} rows, up/down reorder). + * Saves the whole array (debounced 1.5s) via update_profile_metadata — + * the backend sanitizes/validates each URL. + */ +export default function CustomLinksEditor({ initialLinks, onSave }) { + const [links, setLinks] = useState(initialLinks || []); + const debounceRef = useRef(null); + const hydratedRef = useRef(false); + + // Hydrate once from the async-loaded profile; don't clobber in-progress edits + useEffect(() => { + if (!hydratedRef.current && Array.isArray(initialLinks) && initialLinks.length) { + setLinks(initialLinks); + hydratedRef.current = true; + } + }, [initialLinks]); + + const scheduleSave = (next) => { + setLinks(next); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + // Only persist rows with a URL; https:// is auto-prefixed server-side + onSave(next.filter((l) => (l.url || "").trim())); + }, 1500); + }; + + const updateRow = (i, patch) => { + const next = links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)); + scheduleSave(next); + }; + + const removeRow = (i) => scheduleSave(links.filter((_, idx) => idx !== i)); + + const moveRow = (i, dir) => { + const j = i + dir; + if (j < 0 || j >= links.length) return; + const next = [...links]; + [next[i], next[j]] = [next[j], next[i]]; + scheduleSave(next); + }; + + const addRow = () => { + if (links.length >= MAX_LINKS) return; + setLinks([...links, { label: "", url: "" }]); + }; + + return ( +
    + {links.map((link, i) => ( +
    + updateRow(i, { label: e.target.value.slice(0, 40) })} + sx={{ width: 160 }} + /> + updateRow(i, { url: e.target.value })} + placeholder="https://your-site.dev" + sx={{ minWidth: 220, flex: "1 1 220px" }} + /> + + + moveRow(i, -1)} disabled={i === 0} aria-label="Move link up"> + + + + + + + moveRow(i, 1)} disabled={i === links.length - 1} aria-label="Move link down"> + + + + + + removeRow(i)} aria-label="Remove link"> + + + +
    + ))} + + {links.length < MAX_LINKS && ( + + )} +

    + Your personal site, LinkedIn projects, Devpost profile — anywhere you want employers to look next. +

    +
    + ); +} diff --git a/src/components/Profile/Portfolio/CustomLinksRow.jsx b/src/components/Profile/Portfolio/CustomLinksRow.jsx new file mode 100644 index 0000000..a2a4ac3 --- /dev/null +++ b/src/components/Profile/Portfolio/CustomLinksRow.jsx @@ -0,0 +1,32 @@ +import React from "react"; +import LaunchIcon from "@mui/icons-material/Launch"; + +function domainOf(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch (err) { + return url; + } +} + +/** The user's own links (portfolio site, socials) as quiet .ohx-links. */ +export default function CustomLinksRow({ links }) { + if (!Array.isArray(links) || links.length === 0) return null; + return ( +
    + {links.map((link, i) => ( + + {link.label || domainOf(link.url)} + + + ))} +
    + ); +} diff --git a/src/components/Profile/Portfolio/GitHubStatsSection.jsx b/src/components/Profile/Portfolio/GitHubStatsSection.jsx new file mode 100644 index 0000000..7402f85 --- /dev/null +++ b/src/components/Profile/Portfolio/GitHubStatsSection.jsx @@ -0,0 +1,123 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import GitHubIcon from "@mui/icons-material/GitHub"; + +/** + * GitHub contributions in refined chrome: Fraunces stat tiles + hairline + * per-repo rows. Data comes from the SSR portfolio payload when the user has + * opted in; otherwise (older payloads) it lazy-fetches once the section + * scrolls near (fire-once IntersectionObserver, rootMargin 200px — the + * ProblemStatement "Code & Tasks" pattern). Space is reserved via minHeight. + */ + +function aggregate(history) { + const totals = { commits: 0, pullRequests: 0, issues: 0, reviews: 0 }; + const repoMap = new Map(); + for (const item of history || []) { + const commits = item.commits || 0; + const prs = item.pull_requests?.total || 0; + const issues = item.issues?.total || 0; + const reviews = item.reviews || 0; + totals.commits += commits; + totals.pullRequests += prs; + totals.issues += issues; + totals.reviews += reviews; + + const key = `${item.org_name}/${item.repo_name}`; + const repo = repoMap.get(key) || { org: item.org_name, repo: item.repo_name, commits: 0, pullRequests: 0, issues: 0, reviews: 0 }; + repo.commits += commits; + repo.pullRequests += prs; + repo.issues += issues; + repo.reviews += reviews; + repoMap.set(key, repo); + } + const repos = Array.from(repoMap.values()).sort( + (a, b) => (b.commits + b.pullRequests) - (a.commits + a.pullRequests) + ); + return { totals, repos }; +} + +function StatTile({ value, label }) { + return ( +
    +
    {value}
    +
    {label}
    +
    + ); +} + +export default function GitHubStatsSection({ username, initialHistory }) { + const [history, setHistory] = useState(initialHistory || null); + const [loading, setLoading] = useState(false); + const containerRef = useRef(null); + const requestedRef = useRef(false); + + // Lazy fallback fetch only when the payload didn't include github_history + useEffect(() => { + if (history || !username || typeof IntersectionObserver === "undefined") return undefined; + const node = containerRef.current; + if (!node) return undefined; + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((e) => e.isIntersecting) || requestedRef.current) return; + requestedRef.current = true; + observer.disconnect(); + setLoading(true); + fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/messages/profile/github/${encodeURIComponent(username)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setHistory(data?.github_history || [])) + .catch(() => setHistory([])) + .finally(() => setLoading(false)); + }, + { rootMargin: "200px" } + ); + observer.observe(node); + return () => observer.disconnect(); + }, [history, username]); + + const { totals, repos } = useMemo(() => aggregate(history), [history]); + const isEmpty = Array.isArray(history) && history.length === 0; + + return ( +
    + {!history || loading ? ( +

    Loading contributions…

    + ) : isEmpty ? ( +

    No public GitHub activity yet.

    + ) : ( + <> +
    + + + + +
    +
    + {repos.map((repo, i) => ( +
    + + + {repo.org}/{repo.repo} + + + {repo.commits} commit{repo.commits === 1 ? "" : "s"} + {repo.pullRequests > 0 ? ` · ${repo.pullRequests} PR${repo.pullRequests === 1 ? "" : "s"}` : ""} + {repo.issues > 0 ? ` · ${repo.issues} issue${repo.issues === 1 ? "" : "s"}` : ""} + +
    + ))} +
    + + )} +
    + ); +} diff --git a/src/components/Profile/Portfolio/PortfolioHero.jsx b/src/components/Profile/Portfolio/PortfolioHero.jsx new file mode 100644 index 0000000..c87800e --- /dev/null +++ b/src/components/Profile/Portfolio/PortfolioHero.jsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { Avatar } from "@mui/material"; +import VerifiedUserIcon from "@mui/icons-material/VerifiedUser"; +import FavoriteIcon from "@mui/icons-material/Favorite"; +import { useAuthInfo } from "@propelauth/react"; + +import { Eyebrow, Arrow } from "../../design/refined"; +import { ROLE_LABELS, DEFAULT_AVATAR } from "../../../lib/portfolioMeta"; +import { getTierForHearts } from "../../../lib/heartTiers"; +import ShareBar from "./ShareBar"; +import CustomLinksRow from "./CustomLinksRow"; + +/** + * Portfolio masthead: avatar, name, headline, repeat-participation + + * hearts-tier tags, one primary CTA, quiet share row, and the user's own + * links. Owner sees a client-only "edit your portfolio" banner. + */ +export default function PortfolioHero({ + profile, + hackathons, + isPublic, + feedbackUrl, + sharePath, + heroTags, +}) { + const { isLoggedIn, accessToken } = useAuthInfo(); + const [isOwner, setIsOwner] = useState(false); + + // Owner detection is a client-side enhancement only: compare the viewer's + // own db id (authed endpoint) against this profile's id. Never SSR'd. + useEffect(() => { + if (!isLoggedIn || !accessToken || !profile?.id) return; + let cancelled = false; + fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/users/profile`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled && data?.id && data.id === profile.id) { + setIsOwner(true); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [isLoggedIn, accessToken, profile?.id]); + + const name = profile?.name || "Anonymous user"; + const headline = profile?.headline; + const role = isPublic("role") && profile?.role ? ROLE_LABELS[profile.role] || profile.role : null; + + const hackathonCount = Array.isArray(hackathons) ? hackathons.length : 0; + const mentorCount = (hackathons || []).filter((h) => (h.roles || []).includes("Mentor")).length; + const judgeCount = (hackathons || []).filter((h) => (h.roles || []).includes("Judge")).length; + + const heartsTotal = isPublic("hearts") ? profile?.hearts?.total || 0 : 0; + const tier = heartsTotal > 0 ? getTierForHearts(heartsTotal) : null; + + return ( +
    + Opportunity Hack portfolio + + {isOwner && ( +
    + This is you. + + Edit your portfolio + +
    + )} + +
    +
    + +
    +
    +

    {name}

    + +
    + {headline ? ( +

    {headline}

    + ) : ( +

    {profile?.nickname || "Community member"}

    + )} +
    + {role && {role}} + {hackathonCount > 0 && ( + + {hackathonCount}× hackathon{hackathonCount === 1 ? "" : "s"} + + )} + {mentorCount > 1 && {mentorCount}× mentor} + {judgeCount > 1 && {judgeCount}× judge} + {tier && ( + + + {tier.name} · {heartsTotal} heart{heartsTotal === 1 ? "" : "s"} + + )} + {heroTags} +
    +
    +
    + +
    + Send feedback + +
    + + {isPublic("portfolio_links") && } +
    +
    + ); +} diff --git a/src/components/Profile/Portfolio/PortfolioPreviewCard.jsx b/src/components/Profile/Portfolio/PortfolioPreviewCard.jsx new file mode 100644 index 0000000..ab78ecb --- /dev/null +++ b/src/components/Profile/Portfolio/PortfolioPreviewCard.jsx @@ -0,0 +1,55 @@ +import React from "react"; +import LaunchIcon from "@mui/icons-material/Launch"; +import { + buildTitle, + buildDescription, + buildOgImage, + canonicalPathForProfile, +} from "../../../lib/portfolioMeta"; +import { Arrow } from "../../design/refined"; + +/** + * WYSIWYG unfurl preview — built with the SAME portfolioMeta helpers the SSR + * pages use, so what's shown here is exactly what LinkedIn/Slack render. + */ +export default function PortfolioPreviewCard({ profile, visibility }) { + const previewProfile = { ...profile, profile_visibility: visibility }; + const title = buildTitle(previewProfile); + const description = buildDescription(previewProfile); + const { image } = buildOgImage(previewProfile); + const path = canonicalPathForProfile(previewProfile); + + return ( +
    +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + +
    +
    www.ohack.dev
    +
    {title}
    +
    {description}
    +
    +
    +

    + How your link previews when shared on LinkedIn, Slack, or X. +

    + + View my portfolio + + +
    + ); +} diff --git a/src/components/Profile/Portfolio/PortfolioTab.jsx b/src/components/Profile/Portfolio/PortfolioTab.jsx new file mode 100644 index 0000000..f2886bb --- /dev/null +++ b/src/components/Profile/Portfolio/PortfolioTab.jsx @@ -0,0 +1,280 @@ +import React, { useEffect, useRef, useState } from "react"; +import { TextField, Radio, RadioGroup, FormControlLabel, Skeleton } from "@mui/material"; +import { useAuthInfo } from "@propelauth/react"; + +import PrivacyToggle from "../../PrivacyToggle/PrivacyToggle"; +import SlugClaimField from "./SlugClaimField"; +import BioVideoUpload from "./BioVideoUpload"; +import CustomLinksEditor from "./CustomLinksEditor"; +import PortfolioPreviewCard from "./PortfolioPreviewCard"; +import { trackEvent } from "../../../lib/ga"; + +const API = process.env.NEXT_PUBLIC_API_SERVER_URL; + +function SectionCard({ title, hint, action, children }) { + return ( +
    +
    +

    {title}

    + {action} +
    + {hint &&

    {hint}

    } + {children} +
    + ); +} + +// The sections a visitor can see on the public portfolio, with their privacy keys +const SECTION_TOGGLES = [ + { field: "bio", label: "Bio & headline" }, + { field: "bio_video_url", label: "Bio video" }, + { field: "portfolio_links", label: "My links" }, + { field: "teams", label: "Teams & demo videos" }, + { field: "certificates", label: "Certificates" }, + { field: "github_history", label: "GitHub contributions" }, + { field: "hearts", label: "Hearts & tier" }, + { field: "hackathon_history", label: "Hackathon history" }, + { field: "badges", label: "Badges" }, + { field: "praises", label: "Praises" }, +]; + +/** + * The Portfolio editor tab (Profile.js tab index 6, #portfolio). + * Composition: visibility → preview → URL claim → headline/bio → + * bio video → links → what-shows checklist. + */ +export default function PortfolioTab({ + profile, + update_profile_metadata, + privacySettings, + togglePrivacySetting, + privacyLoading, + isLoading, +}) { + const { accessToken } = useAuthInfo(); + + const [slug, setSlug] = useState(profile?.profile_slug || null); + const [visibility, setVisibility] = useState(profile?.profile_visibility || "private"); + const [visibilityError, setVisibilityError] = useState(null); + const [headline, setHeadline] = useState(""); + const [bio, setBio] = useState(""); + const [bioVideoUrl, setBioVideoUrl] = useState(""); + const headlineTimer = useRef(null); + const bioTimer = useRef(null); + + useEffect(() => { + if (!profile) return; + setSlug(profile.profile_slug || null); + setVisibility(profile.profile_visibility || "private"); + setHeadline(profile.headline || ""); + setBio(profile.bio || ""); + setBioVideoUrl(profile.bio_video_url || ""); + }, [profile]); + + const handleVisibilityChange = async (event) => { + const next = event.target.value; + const previous = visibility; + setVisibility(next); + setVisibilityError(null); + try { + const res = await fetch(`${API}/api/users/profile/visibility`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ visibility: next }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setVisibility(previous); + setVisibilityError(data.error || "Could not update visibility"); + return; + } + trackEvent({ action: next === "public" ? "portfolio_set_public" : "portfolio_set_private", params: { page: "profile" } }); + } catch (err) { + setVisibility(previous); + setVisibilityError("Network error — try again"); + } + }; + + const handleHeadline = (event) => { + const next = event.target.value.slice(0, 80); + setHeadline(next); + if (headlineTimer.current) clearTimeout(headlineTimer.current); + headlineTimer.current = setTimeout(() => { + update_profile_metadata({ headline: next }, () => {}); + }, 2000); + }; + + const handleBio = (event) => { + const next = event.target.value.slice(0, 2000); + setBio(next); + if (bioTimer.current) clearTimeout(bioTimer.current); + bioTimer.current = setTimeout(() => { + update_profile_metadata({ bio: next }, () => {}); + }, 2000); + }; + + if (isLoading) { + return ; + } + + const previewProfile = { ...profile, headline, bio, bio_video_url: bioVideoUrl, profile_slug: slug }; + + return ( +
    + + + } + label={ + + Private + + Only people with your link see it. Not listed in search engines. + + + } + sx={{ mb: 1, alignItems: "flex-start" }} + /> + } + disabled={!slug} + label={ + + Public & searchable + + {slug + ? "Anyone can find your portfolio — indexed by Google and listed in our sitemap." + : "Claim your URL below first, then you can go public."} + + + } + sx={{ alignItems: "flex-start" }} + /> + +

    + Your portfolio is currently{" "} + {visibility === "public" ? "public & searchable" : "private"}. +

    + {visibilityError && ( +

    {visibilityError}

    + )} +
    + + + + + + + { + setSlug(next); + trackEvent({ action: "portfolio_slug_claimed", params: { page: "profile" } }); + }} + /> + + + + } + > + + + + + + } + > + + + + + } + > + update_profile_metadata({ portfolio_links: links }, () => {})} + /> + + + +
    + {SECTION_TOGGLES.map(({ field, label }) => ( +
    + {label} + +
    + ))} +
    +
    +
    + ); +} diff --git a/src/components/Profile/Portfolio/ShareBar.jsx b/src/components/Profile/Portfolio/ShareBar.jsx new file mode 100644 index 0000000..8df6483 --- /dev/null +++ b/src/components/Profile/Portfolio/ShareBar.jsx @@ -0,0 +1,38 @@ +import React, { useState } from "react"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import CheckIcon from "@mui/icons-material/Check"; +import LinkedInShareButton from "../../share/LinkedInShareButton"; +import { buildAbsoluteUrl } from "../../../utils/socialShare"; + +/** + * Copy-link + LinkedIn share, rendered as quiet ghost buttons so the page's + * single primary CTA (Send feedback) stays primary. Renders inside + * RefinedRoot — .ohx-* classes are safe. + */ +export default function ShareBar({ path, name }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(buildAbsoluteUrl(path)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + // Clipboard unavailable (http, permissions) — quietly no-op + } + }; + + return ( + <> + + + + ); +} diff --git a/src/components/Profile/Portfolio/SlugClaimField.jsx b/src/components/Profile/Portfolio/SlugClaimField.jsx new file mode 100644 index 0000000..22b02b9 --- /dev/null +++ b/src/components/Profile/Portfolio/SlugClaimField.jsx @@ -0,0 +1,162 @@ +import React, { useEffect, useRef, useState } from "react"; +import { TextField, InputAdornment, CircularProgress, Button, Tooltip, IconButton } from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import CancelIcon from "@mui/icons-material/Cancel"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; + +const API = process.env.NEXT_PUBLIC_API_SERVER_URL; + +// Mirror backend services/user_slug_service.py normalization +const normalize = (raw) => (raw || "").toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 30); + +/** + * Claim / change the vanity portfolio URL (ohack.dev/u/). + * Live availability check (500ms debounce) + an explicit Claim button — + * slugs are semi-permanent (old ones stay as aliases), so no auto-save. + */ +export default function SlugClaimField({ accessToken, currentSlug, onClaimed }) { + const [value, setValue] = useState(currentSlug || ""); + const [check, setCheck] = useState(null); // {available, valid, reason} + const [checking, setChecking] = useState(false); + const [claiming, setClaiming] = useState(false); + const [message, setMessage] = useState(null); // {severity, text} + const [copied, setCopied] = useState(false); + const debounceRef = useRef(null); + + useEffect(() => { + setValue(currentSlug || ""); + }, [currentSlug]); + + const runCheck = (slug) => { + if (!slug || slug === currentSlug) { + setCheck(null); + setChecking(false); + return; + } + setChecking(true); + fetch(`${API}/api/users/profile/slug/check/${encodeURIComponent(slug)}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setCheck(data)) + .catch(() => setCheck(null)) + .finally(() => setChecking(false)); + }; + + const handleChange = (e) => { + const slug = normalize(e.target.value); + setValue(slug); + setMessage(null); + setCheck(null); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => runCheck(slug), 500); + }; + + const handleClaim = async () => { + setClaiming(true); + setMessage(null); + try { + const res = await fetch(`${API}/api/users/profile/slug`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ slug: value }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + setMessage({ severity: "success", text: currentSlug ? "URL updated — your old link keeps working as an alias." : "URL claimed!" }); + setCheck(null); + if (onClaimed) onClaimed(data.slug || value); + } else { + setMessage({ severity: "error", text: data.error || "Could not claim that URL" }); + } + } catch (err) { + setMessage({ severity: "error", text: "Network error — try again" }); + } finally { + setClaiming(false); + } + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(`https://www.ohack.dev/u/${currentSlug}`); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + /* no-op */ + } + }; + + const dirty = value && value !== currentSlug; + const canClaim = dirty && !checking && check?.available; + + let helper = "3–30 characters: lowercase letters, numbers, hyphens."; + if (checking) helper = "Checking availability…"; + else if (dirty && check) { + helper = check.available ? "Available!" : check.reason || "Not available"; + } else if (currentSlug) { + helper = "Changing your URL keeps the old one working as an alias (once per day)."; + } + + return ( +
    +
    + ohack.dev/u/, + endAdornment: ( + + {checking ? ( + + ) : dirty && check ? ( + check.available ? ( + + ) : ( + + ) + ) : null} + + ), + }} + /> + +
    + + {message && ( +

    + {message.text} +

    + )} + + {currentSlug && !dirty && ( +

    + Your portfolio lives at{" "} + + ohack.dev/u/{currentSlug} + + + + + + +

    + )} +
    + ); +} diff --git a/src/components/Profile/Portfolio/TeamsShowcaseSection.jsx b/src/components/Profile/Portfolio/TeamsShowcaseSection.jsx new file mode 100644 index 0000000..91144f7 --- /dev/null +++ b/src/components/Profile/Portfolio/TeamsShowcaseSection.jsx @@ -0,0 +1,115 @@ +import React from "react"; +import Link from "next/link"; +import GitHubIcon from "@mui/icons-material/GitHub"; +import LaunchIcon from "@mui/icons-material/Launch"; +import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; + +import LiteVideoThumbnail from "../../VideoDisplay/LiteVideoThumbnail"; +import { parseLocalDate } from "../../../lib/dateUtils"; + +function formatEventDate(dateStr) { + const d = parseLocalDate(dateStr); + if (!d) return null; + return d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); +} + +function repoEntries(githubLinks) { + const entries = []; + for (const item of githubLinks || []) { + if (!item) continue; + if (typeof item === "string") { + entries.push({ link: item, name: item.split("/").slice(-1)[0] || "Repository" }); + } else if (item.link) { + entries.push({ link: item.link, name: item.name || item.link.split("/").slice(-1)[0] }); + } + } + return entries; +} + +// Winning-ish statuses get a quiet trophy marker even without awards[] +const WINNING_STATUSES = new Set([ + "FOUNDING_ENGINEERS", "CATEGORY_WINNER", "COMPLETION_SUPPORT", + "DEPLOYED", "NONPROFIT_SIGNOFF", +]); + +/** + * The user's hackathon teams — demo videos, repos, DevPost, awards. + * Demo thumbnails are LiteVideoThumbnail facades; playback happens in the + * single page-level Dialog owned by PublicProfile (zero iframes at load). + */ +export default function TeamsShowcaseSection({ teams, onPlayVideo }) { + if (!Array.isArray(teams) || teams.length === 0) return null; + + return ( +
    + {teams.map((team) => { + const event = team.event || {}; + const eventDate = formatEventDate(event.start_date); + const repos = repoEntries(team.github_links); + const awards = Array.isArray(team.awards) ? team.awards : []; + const isWinner = awards.length > 0 || WINNING_STATUSES.has(team.status); + + return ( +
    +
    +
    + + {event.title || "Opportunity Hack"}{eventDate ? ` · ${eventDate}` : ""} + +

    + {team.name || "Team"} +

    + + {(isWinner || awards.length > 0) && ( +
    + {awards.length > 0 ? ( + awards.map((award) => ( + + {award} + + )) + ) : ( + + Winning team + + )} +
    + )} + +
    + {repos.map((repo) => ( + + {repo.name} + + ))} + {team.devpost_link && ( + + DevPost + + )} + {event.event_id && ( + + Event page + + )} +
    +
    + + {team.demo_video_url && ( +
    + onPlayVideo(team.demo_video_url, `${team.name || "Team"} demo`)} + /> +
    + )} +
    +
    + ); + })} +
    + ); +} diff --git a/src/components/Profile/Profile.js b/src/components/Profile/Profile.js index c5d877d..a182b3f 100644 --- a/src/components/Profile/Profile.js +++ b/src/components/Profile/Profile.js @@ -32,6 +32,8 @@ import GitHubIcon from '@mui/icons-material/GitHub'; import CardGiftcardIcon from '@mui/icons-material/CardGiftcard'; import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism'; import LocalShippingIcon from '@mui/icons-material/LocalShipping'; +import LanguageIcon from '@mui/icons-material/Language'; +import PortfolioTab from './Portfolio/PortfolioTab'; import ShareableGitHubContributions from './ShareableGitHubContributions'; import RaffleEntries from './RaffleEntries'; import CertificatesSection from './Sections/CertificatesSection'; @@ -178,6 +180,7 @@ export default function Profile() { const hashTabMap = { '#basic': 0, '#impact': 1, '#github': 2, '#swag': 3, '#volunteer': 4, '#giveaways': 5, + '#portfolio': 6, }; if (router.asPath.includes('#')) { const hash = `#${router.asPath.split('#')[1]}`; @@ -187,9 +190,9 @@ export default function Profile() { const handleTabChange = (event, newValue) => { setActiveTab(newValue); - const tabNames = ['basic', 'impact', 'github', 'swag', 'volunteer', 'giveaways']; + const tabNames = ['basic', 'impact', 'github', 'swag', 'volunteer', 'giveaways', 'portfolio']; trackEvent({ action: 'profile_tab_change', params: { event_label: tabNames[newValue], page: 'profile' } }); - const tabHashMap = { 0: 'basic', 1: 'impact', 2: 'github', 3: 'swag', 4: 'volunteer', 5: 'giveaways' }; + const tabHashMap = { 0: 'basic', 1: 'impact', 2: 'github', 3: 'swag', 4: 'volunteer', 5: 'giveaways', 6: 'portfolio' }; router.push(`/profile#${tabHashMap[newValue]}`, undefined, { shallow: true }); }; @@ -515,6 +518,7 @@ export default function Profile() { } label={isMobile ? undefined : "Swag & Shipping"} title="Swag & Shipping" {...a11yProps(3)} /> } label={isMobile ? undefined : "Volunteer History"} title="Volunteer History" {...a11yProps(4)} /> } label={isMobile ? undefined : "Giveaway Entries"} title="Giveaway Entries" {...a11yProps(5)} /> + } label={isMobile ? undefined : "Portfolio"} title="Portfolio" {...a11yProps(6)} />
    @@ -851,6 +855,22 @@ export default function Profile() { + {/* Tab 6 — Portfolio */} + + + One shareable page with your demo videos, certificates, GitHub work, praise, and hearts — + private by default, yours to publish. + + + + diff --git a/src/components/Profile/PublicProfile.js b/src/components/Profile/PublicProfile.js index 3e8917d..807fb24 100644 --- a/src/components/Profile/PublicProfile.js +++ b/src/components/Profile/PublicProfile.js @@ -1,9 +1,8 @@ -import React from "react"; -import { useRouter } from "next/router"; +import React, { useState, useCallback } from "react"; import Head from "next/head"; import Link from "next/link"; -import { Box, CircularProgress, Avatar } from "@mui/material"; -import VerifiedUserIcon from "@mui/icons-material/VerifiedUser"; +import { Box, CircularProgress, Dialog, DialogContent, IconButton } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; import GitHubIcon from "@mui/icons-material/GitHub"; import LinkedInIcon from "@mui/icons-material/LinkedIn"; import InstagramIcon from "@mui/icons-material/Instagram"; @@ -14,8 +13,14 @@ import BadgesSection from "./Sections/BadgesSection"; import PraisesSection from "./Sections/PraisesSection"; import FeedbackSection from "./Sections/FeedbackSection"; import HeartsExplainer from "./Sections/HeartsExplainer"; -import LinkedInShareButton from "../share/LinkedInShareButton"; +import PortfolioHero from "./Portfolio/PortfolioHero"; +import BioVideoSection from "./Portfolio/BioVideoSection"; +import TeamsShowcaseSection from "./Portfolio/TeamsShowcaseSection"; +import GitHubStatsSection from "./Portfolio/GitHubStatsSection"; +import CertificateWallSection from "./Portfolio/CertificateWallSection"; +import VideoDisplay from "../VideoDisplay/VideoDisplay"; import { RefinedRoot, RefinedFonts, Eyebrow, Arrow } from "../design/refined"; +import { canonicalPathForProfile } from "../../lib/portfolioMeta"; const educationLabels = { in_college: "In College", @@ -25,21 +30,11 @@ const educationLabels = { in_middle_school: "In Middle School", }; -const roleLabels = { - hacker_in_school: "Hacker (In School)", - hacker_pro: "Hacker (Professional)", - mentor: "Mentor", - volunteer: "Volunteer", - judge: "Judge", - nonprofit: "Nonprofit", - sponsor: "Sponsor", - organizer: "Organizer", -}; - -// Refined section frame (eyebrow + heading + body in one quiet card) -function PanelSection({ eyebrow, title, children }) { +// Refined section frame (eyebrow + heading + body in one quiet card). +// Anchored ids make sections deep-linkable (#featured-work etc.). +function PanelSection({ id, eyebrow, title, children }) { return ( -
    +
    {eyebrow && {eyebrow}}

    {title}

    {children} @@ -59,10 +54,13 @@ function AboutRow({ icon, label, children }) { ); } -const PublicProfile = () => { - const router = useRouter(); - const { userid } = router.query; - +/** + * The public portfolio page body. Server-rendered on /u/[slug] and + * /profile/[userid] (initialData comes from getServerSideProps); falls back + * to client fetching when SSR had no data. Empty or private sections skip + * silently — an employer-facing portfolio must read tight. + */ +const PublicProfile = ({ userid, initialData = null }) => { const { profile, badges, @@ -73,9 +71,14 @@ const PublicProfile = () => { privacySettings, isLoading, error, - } = usePublicProfile(userid); + } = usePublicProfile(userid, { initialData }); + + // The single page-level video player (CWV rule: never an iframe per card) + const [videoDialog, setVideoDialog] = useState(null); + const openVideo = useCallback((url, title) => setVideoDialog({ url, title }), []); + const closeVideo = useCallback(() => setVideoDialog(null), []); - const profilePath = userid ? `/profile/${userid}` : "/"; + const sharePath = canonicalPathForProfile(profile, userid); const isPublic = (field) => privacySettings?.[field] === "public"; if (isLoading) { @@ -124,41 +127,28 @@ const PublicProfile = () => { ); + const showBio = isPublic("bio") && profile?.bio; + const showBioVideo = isPublic("bio_video_url") && profile?.bio_video_url; + const showAbout = showBio || showBioVideo || aboutFields.length > 0 || (isPublic("expertise") && profile?.expertise?.length > 0); + const teams = isPublic("teams") ? profile?.teams || [] : []; + const showGitHub = isPublic("github_history") && (profile?.github_history?.length > 0 || profile?.github); + const showCertificates = isPublic("certificates") && profile?.certificates && + ((profile.certificates.github_certificates || []).length > 0 || (profile.certificates.heart_certificates || []).length > 0); + return ( <> - Profile for {profile?.name || userid} — Opportunity Hack Developer Portal - {/* HEADER */} -
    - Community profile -
    -
    - -
    -
    -

    {profile?.name || "Anonymous user"}

    - -
    -

    {profile?.nickname || "Community member"}

    - {profile?.role && isPublic("role") && ( - {roleLabels[profile.role] || profile.role} - )} -
    -
    -
    - Send feedback - -
    -
    -
    + {/* BODY */}
    @@ -174,13 +164,23 @@ const PublicProfile = () => { )} - {(aboutFields.length > 0 || (isPublic("expertise") && profile?.expertise?.length > 0)) && ( - -
    - {aboutFields} -
    + {showAbout && ( + + {showBio && ( +

    + {profile.bio} +

    + )} + {showBioVideo && ( + + )} + {aboutFields.length > 0 && ( +
    + {aboutFields} +
    + )} {isPublic("expertise") && profile?.expertise?.length > 0 && ( -
    +
    Areas of expertise
    {profile.expertise.map((s) => {s})} @@ -190,29 +190,53 @@ const PublicProfile = () => { )} + {teams.length > 0 && ( + +

    + Hackathon teams, demo videos, and the code behind them. +

    + +
    + )} + + {showGitHub && ( + + + + )} + + {showCertificates && ( + +

    + Verifiable recognition for shipped contributions. +

    + +
    + )} + {isPublic("badges") && badges?.length > 0 && ( - - + + )} {isPublic("hackathon_history") && hackathons?.length > 0 && ( - +

    Participation, mentoring, and judging at Opportunity Hack events.

    - +
    )} {isPublic("praises") && (praisesCount > 0 || praisesRecent?.length > 0) && ( - - + + )} {(isPublic("what") || isPublic("how")) && ( - + @@ -230,14 +254,22 @@ const PublicProfile = () => {

    Want to get involved with Opportunity Hack?

    Become a volunteer - View current projects + View current projects
    - -
    + + {/* One page-level demo-video player — thumbnails everywhere are facades */} + + + + + + {videoDialog && } + + ); }; diff --git a/src/components/Profile/Sections/HackathonsSection.jsx b/src/components/Profile/Sections/HackathonsSection.jsx index 0123328..b9fcede 100644 --- a/src/components/Profile/Sections/HackathonsSection.jsx +++ b/src/components/Profile/Sections/HackathonsSection.jsx @@ -75,7 +75,16 @@ function HackathonCard({ hackathon, mode, profileUrl }) { {hackathon.title && ( - {hackathon.title} + {hackathon.event_id ? ( + + {hackathon.title} + + ) : ( + hackathon.title + )} )} {dateLabel && ( diff --git a/src/components/VideoDisplay/LiteVideoThumbnail.js b/src/components/VideoDisplay/LiteVideoThumbnail.js index e454fd0..ab90057 100644 --- a/src/components/VideoDisplay/LiteVideoThumbnail.js +++ b/src/components/VideoDisplay/LiteVideoThumbnail.js @@ -9,6 +9,7 @@ const YOUTUBE_REGEX = const VIMEO_REGEX = /vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/(?:[^\/]*)\/videos\/|album\/(?:\d+)\/video\/|video\/|)(\d+)/; const LOOM_REGEX = /loom\.com\/(?:share|embed)\//; const DRIVE_REGEX = /drive\.google\.com\/file\/d\//; +const RAW_VIDEO_REGEX = /\.(mp4|webm|mov)(\?|#|$)/i; const providerLabel = (url) => { if (!url) return null; @@ -16,6 +17,7 @@ const providerLabel = (url) => { if (VIMEO_REGEX.test(url)) return "Vimeo"; if (LOOM_REGEX.test(url)) return "Loom"; if (DRIVE_REGEX.test(url)) return "Google Drive"; + if (RAW_VIDEO_REGEX.test(url)) return "Video"; return null; }; @@ -25,12 +27,15 @@ const LiteVideoThumbnail = ({ width = 320, height = 180, label = "Watch demo", + posterUrl = null, }) => { if (!url) return null; const youtubeMatch = url.match(YOUTUBE_REGEX); const youtubeId = youtubeMatch ? youtubeMatch[1] : null; const provider = providerLabel(url); + // A supplied poster (e.g. for raw mp4s) renders exactly like a YouTube thumb + const posterSrc = posterUrl || (youtubeId ? `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg` : null); const buttonStyles = { position: "relative", @@ -69,13 +74,13 @@ const LiteVideoThumbnail = ({ width: "100%", paddingBottom: "56.25%", height: 0, - bgcolor: youtubeId ? "transparent" : "grey.900", + bgcolor: posterSrc ? "transparent" : "grey.900", }} > - {youtubeId ? ( + {posterSrc ? ( { const getVideoConfig = (url) => { if (!url) return null; @@ -66,6 +69,11 @@ const VideoDisplay = ({ url, title }) => { }; } + // Raw video file — play with a native