diff --git a/CLAUDE.md b/CLAUDE.md index 45edc319..ff3c9cdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,16 @@ Full spec + rollout checklist: **`docs/refined-design-system.md`** (read this be See `docs/refined-design-system.md`. Pattern for new pages: keep `Head`/`getStaticProps`/schema verbatim, wrap body in `` (or the `RefinedPage` shell below — fonts load globally, do NOT add ``), rebuild sections with `.ohx-*` classes, one `

`, preserve GA. -**Refined pages batch (Oct 2026):** `src/components/design/RefinedPage.js` is the shell for simple content pages — `RefinedPage` (RefinedRoot + `.ohx-wrap` section that clears the 64px NavBar; `narrow` for `.ohx-narrow`, `as="article"`), `PageMasthead` (eyebrow → the ONE `

` with optional `italic` accent phrase → `.ohx-lead`), `BackLink`, `Prose` (`.ohx-prose` — Fraunces h2/h3, 70ch measure, accent list markers, navy underlined links; added to `refined.js`), `LinkRow` (one primary `.ohx-btn` + soft `.ohx-link`s) and `CtaBand` (navy closing band). Migrated with it: `/404` (random sleeping image in a reserved 4:3 card; GA `404_error` kept), `/contact` (form logic, reCAPTCHA, `?type=` prefill and the mentor/judge read-first Dialog untouched; MUI fields under `refinedFormTheme` + `refinedFieldSx`; submit is a native `.ohx-btn--primary`), `/feedback` (index), `/about/why` + `/about/why/[title]` (the `whyPages[slug].Content` MUI bodies render inside `Prose`, whose `.ohx-prose .MuiTypography-*` rules restyle their `h4`→Fraunces / `body1`→17px; the 10 `src/components/content/*Content.js` files now use `component="h2"` and no inline 14px), `/about/privacy-policy` (`LAST_UPDATED` constant = the date the policy TEXT last changed — it used to print today's date), `/hack/liability-waiver`, `/hack/photo-release`, `/praise/[id]`, `/hack/[event_id]/results` + `/hack/results` (chrome only; `HackathonResults`/`HackathonFunnel` unchanged), and the four `/about/success-stories/` case studies, which are now plain `story` data objects rendered by `src/components/SuccessStories/SuccessStoryLayout.js` (owns the Head: canonical `www`, fixed `og:url` — they pointed at a non-existent `/success-stories/…` path — Article + BreadcrumbList JSON-LD via `serializeJsonLd`, CDN `og:image`s). **Still not refined** (next batches): the SEO pillar pages (`/coding-for-nonprofits`, `/hackathon-for-social-good`, `/hackathon-judge-opportunities`, `/hackathon-judging-criteria`, `/volunteer/professional-development-volunteering`, `/about/hackers/software-engineering-career-path`, `/about/locations/asu-tempe-arizona`), the tutorial pages (`/about/judges/overview`, `/about/mentors/overview`, `/judge/overview`), `/about/hearts` + `/community-champions` (700-line components), `/blog/[blog_id]`, `/cert`, `/hack/[event_id]/{media,agenda}`, `/store/*`, `/signup2`, `/unsubscribe`; intentionally bespoke: `/12-years-of-social-good`, `/about/style-guide`, print pages (`/hack/flier`, `/hack/[event_id]/print-timeline`), the planning board (own theme), redirect stubs. +**Refined pages batch (Oct 2026):** `src/components/design/RefinedPage.js` is the shell for simple content pages — `RefinedPage` (RefinedRoot + `.ohx-wrap` section that clears the 64px NavBar; `narrow` for `.ohx-narrow`, `as="article"`), `PageMasthead` (eyebrow → the ONE `

` with optional `italic` accent phrase → `.ohx-lead`), `BackLink`, `Prose` (`.ohx-prose` — Fraunces h2/h3, 70ch measure, accent list markers, navy underlined links; added to `refined.js`), `LinkRow` (one primary `.ohx-btn` + soft `.ohx-link`s) and `CtaBand` (navy closing band). Migrated with it: `/404` (random sleeping image in a reserved 4:3 card; GA `404_error` kept), `/contact` (form logic, reCAPTCHA, `?type=` prefill and the mentor/judge read-first Dialog untouched; MUI fields under `refinedFormTheme` + `refinedFieldSx`; submit is a native `.ohx-btn--primary`), `/feedback` (index), `/about/why` + `/about/why/[title]` (the `whyPages[slug].Content` MUI bodies render inside `Prose`, whose `.ohx-prose .MuiTypography-*` rules restyle their `h4`→Fraunces / `body1`→17px; the 10 `src/components/content/*Content.js` files now use `component="h2"` and no inline 14px), `/about/privacy-policy` (`LAST_UPDATED` constant = the date the policy TEXT last changed — it used to print today's date), `/hack/liability-waiver`, `/hack/photo-release`, `/praise/[id]`, `/hack/[event_id]/results` + `/hack/results` (chrome only; `HackathonResults`/`HackathonFunnel` unchanged), and the four `/about/success-stories/` case studies, which are now plain `story` data objects rendered by `src/components/SuccessStories/SuccessStoryLayout.js` (owns the Head: canonical `www`, fixed `og:url` — they pointed at a non-existent `/success-stories/…` path — Article + BreadcrumbList JSON-LD via `serializeJsonLd`, CDN `og:image`s). **Still not refined**: `/signup2`, `/unsubscribe`, `/census` (renders the shared `HackathonHeader` outside a `RefinedRoot`) — everything else from the old list shipped in batch 3 (see "Refined batch 3" below); intentionally bespoke: `/12-years-of-social-good`, `/about/style-guide`, print pages (`/hack/flier`, `/hack/[event_id]/print-timeline`), the planning board (own theme), redirect stubs. + +## Refined batch 3 (Oct 2026) — pillar, tutorial, hearts, blog, cert, media/agenda, store + +- **SEO pillar pages** (`/coding-for-nonprofits`, `/hackathon-for-social-good`, `/hackathon-judging-criteria`, `/hackathon-judge-opportunities`, `/volunteer/professional-development-volunteering`, `/about/hackers/software-engineering-career-path`, `/about/locations/asu-tempe-arizona`) follow the `recruit-tech-talent` pattern; `getStaticProps` output is unchanged (byte-identical on the first three). `FAQ_ITEMS` / rubric data are module-scope and feed BOTH the `
` accordions and the FAQPage JSON-LD — edit the array, not the schema. Fixed on the way: PDV + career-path canonicals pointed at `opportunityhack.org`; PDV's dead `/hackathon` CTA → `/hack`; career-path had no GA/OG/JSON-LD before. `/about/locations/asu-tempe-arizona` is a venue/logistics guide (no `useHackathonEvents`); its section ids `venue/transportation/parking/hotels/local-area` are share-link targets; Hayden Library photos live on `tours.asu.edu` (not in `remotePatterns`) so they stay raw `` with dimensions. +- **Video tutorial routes** (`/about/judges/overview`, `/about/mentors/overview`, `/judge/overview`) render ONE module-scope `VideoGuidePage` template (named export from `src/pages/about/judges/overview.js`) from a per-route config (video, highlights, CTA band, resources, optional quick reference); per-route `getStaticProps`/OG/VideoObject verbatim. **GA gotcha fixed here and on `/about/judges`, `/about/hearts`:** `trackEvent` takes `({action, params})` — the positional `trackEvent("name", label)` form sent `undefined` as the event name. Stale VideoObject durations / `playerType: "HTML5 Flash"` were left as-is. +- **`/about/hearts` + `/community-champions`** are SSR now (page shells import statically; only the Instagram embed is `dynamic ssr:false`). Tiers + categories come ONLY from `src/lib/heartTiers.js`; `RewardStructure.js` `REWARD_LEVELS` is the single copy of the scoring criteria. Leaderboard `userId` is the Firestore doc id (`/profile/`); rank = position in the unfiltered `limit=50` payload. +- **`/blog/[blog_id]`:** `SingleNews.js` is the article body and renders ONLY inside `SingleBlogPost`'s `RefinedPage` (`.ohx-*` safe); tags render once in `SingleBlogPost` (`tags[]` wins, hashtag/channel extraction fallback); authored markdown headings are demoted one level so the title stays the only `

`. **`/cert` + `/cert/[cert_id]`:** ISR/OG contract untouched; `CertInfoIndex` now gates its fetch on `cert_id` (it fetched `/api/certificates/undefined` during fallback) and has a not-found state; dead `Certificate.js`/`styles.js` were deleted. +- **`/hack/[event_id]/media` + `/agenda`** use `Shell` + `TeamBreadcrumbs` and `fetchForStaticProps` (media is `fallback:"blocking"` — it used to prerender every event at build time; agenda stays `fallback:true` per the getStaticPaths contract test, with an `isFallback` state). Agenda formats every time in the event timezone via `Intl` and keeps `now` null until mount — don't reintroduce date-fns `format` in host tz or render the clock during SSR. +- **Store** (`/store`, `/cart`, `/success`, `/products/[id]`) is fully refined. `CartItemList` calls `onRemoveItem(id, selectedVariations)` — the old `cartKey` lookup matched nothing, so variation items couldn't be removed. `products/[id]` has no `getStaticProps` (Product JSON-LD/OG are client-only, pre-existing); its "Added to cart" confirmation is an inline `role="status"` strip whose timeout effect sits ABOVE the early returns. **Catalog images `/images/store/*.png` never existed in the repo** (404 in prod) — thumbs fall back to a Fraunces initial via `onError`; add the PNGs or fix `store-products.json`. Store JSON-LD goes through `serializeJsonLd`. The homepage and projects page were reimagined away from the old busy/multi-gradient look into a calm "civic editorial" system. Shared tokens + utility classes live in `src/components/design/refined.js`: diff --git a/docs/refined-design-system.md b/docs/refined-design-system.md index cf190a44..6360be18 100644 --- a/docs/refined-design-system.md +++ b/docs/refined-design-system.md @@ -125,6 +125,12 @@ visually, disable cache via CDP (`Network.setCacheDisabled` + - [x] `/` (homepage) - [x] `/projects` - [x] Oct 2026 batch via `RefinedPage`: `/404`, `/contact`, `/feedback`, `/about/why` (+ `[title]`), `/about/privacy-policy`, `/hack/liability-waiver`, `/hack/photo-release`, `/praise/[id]`, `/hack/[event_id]/results`, `/hack/results`, the four `/about/success-stories/` case studies (`SuccessStoryLayout`) +- [x] Batch 3 (Oct 2026) — SEO pillar pages `/coding-for-nonprofits`, `/hackathon-for-social-good`, `/hackathon-judging-criteria`, `/hackathon-judge-opportunities`, `/volunteer/professional-development-volunteering`, `/about/hackers/software-engineering-career-path`, `/about/locations/asu-tempe-arizona` (recruit-tech-talent pattern: hero + Stat row, alternating bands, data-array cards, native `
` FAQ single-sourced into FAQPage JSON-LD, navy band, primary+ghost+link closers; `getStaticProps` output unchanged) +- [x] Batch 3 — video tutorials `/about/judges/overview`, `/about/mentors/overview`, `/judge/overview` share one `VideoGuidePage` template (masthead + 16:9 `.ohx-card` video + ghost Share w/ inline status, `--surface-2` highlight band, navy CTA band, resource tiles; hover copy-link section anchors kept) +- [x] Batch 3 — `/about/hearts` + `/community-champions` (both SSR now; hearts = editorial hero + tier ladder from `heartTiers.js` + `HEART_CATEGORIES` cards + `
` scoring guide + `CtaBand`; champions = framed hero photo, native search + toggle-tag tier filter, hairline ``/grid with tier-dot tags — no avatar wall, no tier backgrounds — plus skeleton/error/empty states) +- [x] Batch 3 — `/blog/[blog_id]` (`RefinedPage as="article"`, `PageMasthead` h1, `Prose` markdown w/ demoted headings, one tag strip, related-posts card, `CtaBand`) and `/cert` + `/cert/[cert_id]` (masthead, framed 1:1 certificate, `Stat` tiles, one primary Download + soft share links) +- [x] Batch 3 — `/hack/[event_id]/media` + `/agenda` (`Shell` + `TeamBreadcrumbs`; media: carousel in a hairline card w/ navy controls + reserved 16:9, `.ohx-card--hover` coverage tiles, reserved Instagram embeds; agenda: shared `HackathonHeader` masthead + day-grouped hairline timeline in the event tz, sticky info/QR aside, print-only QR strip) +- [x] Batch 3 — `/store`, `/store/cart`, `/store/success`, `/store/products/[id]` (masthead + `--surface-2` browse band w/ native search + `aria-pressed` category tags, `ProductCard` grid w/ reserved 4:3 image box + `onError` initial fallback; cart = hairline rows + sticky order-summary card w/ ONE primary Checkout; product page = two-column masthead, native `
+ + + + + + + + + + + + + + {entries.map((entry, i) => { + const last = i === entries.length - 1; + const cell = last ? { ...tdStyle, borderBottom: "none" } : tdStyle; + return ( + + + + + + ); + })} + +
+ + + Rank + + + Champion + + Hearts +
+ {rankById.get(entry.userId)} + + + + + + {displayName(entry)} + + + + + + + + {formatHearts(entry.totalHearts)} + + ♥ + +
+ ); +} + +function ChampionGrid({ entries, rankById }) { + return ( +
    + {entries.map((entry) => ( +
  • + + + #{rankById.get(entry.userId)} + + + + {displayName(entry)} + + + + {formatHearts(entry.totalHearts)} + + heart{entry.totalHearts === 1 ? "" : "s"} + + + +
  • + ))} +
+ ); +} + +function ListSkeleton() { + return ( +
+ + Loading community champions… + + {Array.from({ length: SKELETON_ROWS }).map((_, i) => ( + + ))} +
+ ); +} + +function StatusCard({ title, body, action }) { + return ( +
+

+ {title} +

+ {body && ( +

+ {body} +

+ )} + {action &&
{action}
} +
+ ); +} export default function CommunityChampions() { const { apiServerUrl } = useEnv(); const [leaders, setLeaders] = useState([]); + const [status, setStatus] = useState("loading"); // loading | ready | error + const [reloadKey, setReloadKey] = useState(0); const [search, setSearch] = useState(""); const [tierFilter, setTierFilter] = useState("all"); const [viewMode, setViewMode] = useState("table"); @@ -41,7 +425,7 @@ export default function CommunityChampions() { }); }, []); - const handleViewModeChange = (_, val) => { + const handleViewModeChange = (val) => { if (!val || val === viewMode) return; setViewMode(val); trackEvent({ @@ -52,38 +436,50 @@ export default function CommunityChampions() { useEffect(() => { const controller = new AbortController(); + setStatus("loading"); fetch(`${apiServerUrl}/api/hearts/leaderboard?limit=50`, { signal: controller.signal, }) - .then((res) => (res.ok ? res.json() : Promise.reject())) - .then((data) => setLeaders(data.leaderboard || [])) - .catch(() => {}); + .then((res) => + res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)), + ) + .then((data) => { + setLeaders(data.leaderboard || []); + setStatus("ready"); + }) + .catch((err) => { + if (err?.name === "AbortError") return; + setStatus("error"); + }); return () => controller.abort(); - }, [apiServerUrl]); + }, [apiServerUrl, reloadKey]); + + // Rank by position in the full (hearts-desc) leaderboard, so filtering + // never renumbers people. + const rankById = useMemo( + () => new Map(leaders.map((entry, i) => [entry.userId, i + 1])), + [leaders], + ); const filtered = useMemo(() => { let result = leaders; if (search.trim()) { const q = search.toLowerCase(); result = result.filter((entry) => - (entry.name || "").toLowerCase().includes(q) + (entry.name || "").toLowerCase().includes(q), ); } if (tierFilter !== "all") { - result = result.filter((entry) => { - const tier = getTierForHearts(entry.totalHearts); - return tier && tier.name === tierFilter; - }); + result = result.filter((entry) => tierOf(entry)?.name === tierFilter); } return result; }, [leaders, search, tierFilter]); - // Count champions per tier for the filter chips + // Count champions per tier for the filter tags const tierCounts = useMemo(() => { const counts = {}; leaders.forEach((entry) => { - const tier = getTierForHearts(entry.totalHearts); - const name = tier ? tier.name : "Newcomer"; + const name = tierNameOf(entry); counts[name] = (counts[name] || 0) + 1; }); return counts; @@ -104,553 +500,312 @@ export default function CommunityChampions() { item: { "@type": "Person", name: entry.name, - url: `https://ohack.dev/profile/${entry.userId}`, + url: `https://www.ohack.dev/profile/${entry.userId}`, ...(entry.profileImage && { image: entry.profileImage }), }, })), }; }, [leaders]); + const isFiltering = Boolean(search.trim()) || tierFilter !== "all"; + + let listBody; + if (status === "loading") { + listBody = ; + } else if (status === "error") { + listBody = ( + setReloadKey((k) => k + 1)} + > + Try again + + } + /> + ); + } else if (leaders.length === 0) { + listBody = ( + + ); + } else if (filtered.length === 0) { + listBody = ( + { + setSearch(""); + setTierFilter("all"); + }} + > + Clear filters + + } + /> + ); + } else if (viewMode === "table") { + listBody = ; + } else { + listBody = ( +
+ +
+ ); + } + return ( <> {structuredData && (