From 4b262c8205014ee575cf653d1133e1ade2aed579 Mon Sep 17 00:00:00 2001 From: Greg Miller Date: Tue, 19 May 2026 20:04:17 -0400 Subject: [PATCH 01/14] feat(sanity): add field for promoted winners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add a single ‘awarding sponsor’ field on each project - the Studio enforces one winner per challenge track --- .../schemaTypes/challengeSponsors.ts | 19 +++++++ .../schemaTypes/hackathonProject.ts | 55 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 studio-hack-michigan-/schemaTypes/challengeSponsors.ts diff --git a/studio-hack-michigan-/schemaTypes/challengeSponsors.ts b/studio-hack-michigan-/schemaTypes/challengeSponsors.ts new file mode 100644 index 0000000..3ea5031 --- /dev/null +++ b/studio-hack-michigan-/schemaTypes/challengeSponsors.ts @@ -0,0 +1,19 @@ +/** + * Sponsor AI challenge tracks — one winning project per sponsor. + * Keep in sync with `src/lib/sanity/challengeSponsors.ts`. + */ +export const CHALLENGE_SPONSOR_OPTIONS = [ + {value: 'dte', title: 'DTE Energy — DTE AI Challenge'}, + {value: 'startMidwest', title: 'StartMidwest — StartMidwest AI Challenge'}, + { + value: 'aiCollectiveDetroit', + title: 'The AI Collective Detroit — AI Collective Detroit Challenge', + }, + { + value: 'compassDetroit', + title: 'Compass Detroit — Hack Michigan AI Challenge', + }, +] as const + +export type ChallengeSponsorValue = + (typeof CHALLENGE_SPONSOR_OPTIONS)[number]['value'] diff --git a/studio-hack-michigan-/schemaTypes/hackathonProject.ts b/studio-hack-michigan-/schemaTypes/hackathonProject.ts index 0370c23..d6ec198 100644 --- a/studio-hack-michigan-/schemaTypes/hackathonProject.ts +++ b/studio-hack-michigan-/schemaTypes/hackathonProject.ts @@ -1,4 +1,5 @@ import {defineField, defineType} from 'sanity' +import {CHALLENGE_SPONSOR_OPTIONS, type ChallengeSponsorValue} from './challengeSponsors' import {portableTextPlainLength} from './portableTextUtils' /** Plain-text character cap for portable text `description` — keep in sync with `src/lib/portableText.ts`. */ @@ -75,6 +76,60 @@ export const hackathonProjectType = defineType({ name: 'track', type: 'string', title: 'Hackathon Track (e.g. Best AI, Best UX)', + description: 'Optional free-text prize or theme label. Prefer Awarding sponsor for sponsor challenge winners.', + }), + defineField({ + name: 'awardingSponsor', + type: 'string', + title: 'Awarding sponsor (challenge winner)', + description: + 'Set only on the one winning project per sponsor AI challenge. Leave empty for all other submissions.', + options: { + list: CHALLENGE_SPONSOR_OPTIONS.map(({value, title}) => ({title, value})), + layout: 'radio', + }, + validation: (Rule) => + Rule.custom(async (value: ChallengeSponsorValue | undefined, context) => { + if (!value) return true + + const client = context.getClient({apiVersion: '2024-12-01'}) + const documentId = context.document?._id?.replace(/^drafts\./, '') + if (!documentId) return true + + const draftId = `drafts.${documentId}` + const existingId = await client.fetch( + `*[ + _type == "hackathonProject" && + awardingSponsor == $sponsor && + !(_id in [$documentId, $draftId]) + ][0]._id`, + {sponsor: value, documentId, draftId}, + ) + + if (existingId) { + const label = + CHALLENGE_SPONSOR_OPTIONS.find((o) => o.value === value)?.title ?? + value + return `Another project is already marked as the winner for ${label}. Clear that project first, or choose a different sponsor.` + } + + return true + }), }), ], + preview: { + select: { + title: 'title', + awardingSponsor: 'awardingSponsor', + media: 'mainImage', + }, + prepare({title, awardingSponsor, media}) { + const sponsor = CHALLENGE_SPONSOR_OPTIONS.find((o) => o.value === awardingSponsor) + return { + title, + subtitle: sponsor ? `Challenge winner · ${sponsor.title}` : undefined, + media, + } + }, + }, }) From 1a38de1957b5a134d968ac18e7292d9bba2deb4f Mon Sep 17 00:00:00 2001 From: Greg Miller Date: Tue, 19 May 2026 20:12:05 -0400 Subject: [PATCH 02/14] feat(cms): shared sponsor metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - one source of truth for challenge names, logos, and Compass - frontend and Studio share the same four challenge tracks - Compass is the org - ‘Hack Michigan AI Challenge’ is the public label --- src/lib/sanity/challengeSponsors.test.ts | 35 ++++++++++ src/lib/sanity/challengeSponsors.ts | 86 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/lib/sanity/challengeSponsors.test.ts create mode 100644 src/lib/sanity/challengeSponsors.ts diff --git a/src/lib/sanity/challengeSponsors.test.ts b/src/lib/sanity/challengeSponsors.test.ts new file mode 100644 index 0000000..1cb2736 --- /dev/null +++ b/src/lib/sanity/challengeSponsors.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + buildWinnerBySponsorMap, + challengeSponsorByKey, + normalizeChallengeSponsorKey, +} from "./challengeSponsors"; +import type { HackathonProjectPreview } from "./schemas"; + +describe("challengeSponsors", () => { + it("maps legacy hackMichigan to Compass Detroit", () => { + expect(normalizeChallengeSponsorKey("hackMichigan")).toBe("compassDetroit"); + expect(challengeSponsorByKey("hackMichigan").title).toBe("Compass Detroit"); + expect(challengeSponsorByKey("hackMichigan").challengeLabel).toBe( + "Hack Michigan AI Challenge", + ); + }); + + it("buildWinnerBySponsorMap indexes projects by normalized sponsor key", () => { + const shorewatch = { + _id: "p1", + title: "ShoreWatch", + slug: { current: "shorewatch" }, + awardingSponsor: "hackMichigan", + } as HackathonProjectPreview; + + const map = buildWinnerBySponsorMap([shorewatch]); + expect(map.get("compassDetroit")).toEqual(shorewatch); + }); + + it("resolves compassDetroit as the Hack Michigan AI Challenge sponsor", () => { + expect(challengeSponsorByKey("compassDetroit").challengeLabel).toBe( + "Hack Michigan AI Challenge", + ); + }); +}); diff --git a/src/lib/sanity/challengeSponsors.ts b/src/lib/sanity/challengeSponsors.ts new file mode 100644 index 0000000..ef1d8b4 --- /dev/null +++ b/src/lib/sanity/challengeSponsors.ts @@ -0,0 +1,86 @@ +import type { HackathonProjectPreview } from "./schemas"; + +/** + * Sponsor AI challenge tracks — one winning project per sponsor. + * Keep in sync with `studio-hack-michigan-/schemaTypes/challengeSponsors.ts`. + */ +export const CHALLENGE_SPONSOR_OPTIONS = [ + { + value: "dte", + title: "DTE Energy", + challengeLabel: "DTE AI Challenge", + logoSrc: "/images/sponsors/gold-dte-300x.webp", + logoHref: "https://www.dteenergy.com", + }, + { + value: "startMidwest", + title: "StartMidwest", + challengeLabel: "StartMidwest AI Challenge", + logoSrc: "/images/sponsors/media-startMidwest.webp", + logoHref: "https://www.start-midwest.com/", + }, + { + value: "aiCollectiveDetroit", + title: "The AI Collective Detroit", + challengeLabel: "The AI Collective Detroit Challenge", + logoSrc: "/images/sponsors/com_AI_Collective_Detroit.png", + logoHref: "https://www.aicollective.com/chapters/detroit", + logoSurface: "light" as const, + }, + { + value: "compassDetroit", + title: "Compass Detroit", + challengeLabel: "Hack Michigan AI Challenge", + logoSrc: "/images/site/compass-logo.svg", + logoHref: "https://compassdetroit.com", + }, +] as const; + +export type ChallengeSponsorKey = + (typeof CHALLENGE_SPONSOR_OPTIONS)[number]["value"]; + +/** Index Sanity winner projects by normalized `awardingSponsor` (last write wins). */ +export function buildWinnerBySponsorMap( + winners: readonly HackathonProjectPreview[], +): Map { + const map = new Map(); + for (const project of winners) { + const key = normalizeChallengeSponsorKey(project.awardingSponsor); + if (key) map.set(key, project); + } + return map; +} + +/** @deprecated Studio value before Compass was named explicitly — maps to `compassDetroit`. */ +export const CHALLENGE_SPONSOR_LEGACY_KEYS = { + hackMichigan: "compassDetroit", +} as const satisfies Record; + +export type ChallengeSponsorStoredKey = + | ChallengeSponsorKey + | keyof typeof CHALLENGE_SPONSOR_LEGACY_KEYS; + +export function normalizeChallengeSponsorKey( + key: string | undefined, +): ChallengeSponsorKey | undefined { + if (!key) return undefined; + if (key in CHALLENGE_SPONSOR_LEGACY_KEYS) { + return CHALLENGE_SPONSOR_LEGACY_KEYS[ + key as keyof typeof CHALLENGE_SPONSOR_LEGACY_KEYS + ]; + } + if (CHALLENGE_SPONSOR_OPTIONS.some((s) => s.value === key)) { + return key as ChallengeSponsorKey; + } + return undefined; +} + +export function challengeSponsorByKey( + key: ChallengeSponsorStoredKey, +): (typeof CHALLENGE_SPONSOR_OPTIONS)[number] { + const normalized = normalizeChallengeSponsorKey(key); + if (!normalized) throw new Error(`Unknown challenge sponsor: ${key}`); + const match = CHALLENGE_SPONSOR_OPTIONS.find((s) => s.value === normalized); + if (!match) throw new Error(`Unknown challenge sponsor: ${key}`); + return match; +} From 16a6295afa37117d1d7409c06dd09c59c3c0cdc2 Mon Sep 17 00:00:00 2001 From: Greg Miller Date: Tue, 19 May 2026 20:13:56 -0400 Subject: [PATCH 03/14] feat: sanity fetch layer (Astro) - Query and type the new field everywhere projects are listed - GROQ and TypeScript know about challenge winners; lists put winners first --- src/lib/sanity/fetchers.test.ts | 8 ++++++++ src/lib/sanity/fetchers.ts | 11 +++++++++++ src/lib/sanity/index.ts | 1 + src/lib/sanity/queries.ts | 9 +++++++-- src/lib/sanity/schemas.ts | 5 +++++ 5 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/lib/sanity/fetchers.test.ts b/src/lib/sanity/fetchers.test.ts index f3f1835..899c5b3 100644 --- a/src/lib/sanity/fetchers.test.ts +++ b/src/lib/sanity/fetchers.test.ts @@ -13,6 +13,7 @@ vi.mock("./client", () => ({ import { getProject, getProjectSlugs, + getChallengeWinners, getProjects, getTeam, getTeamSlugs, @@ -32,6 +33,13 @@ describe("sanity fetchers", () => { consoleErrorSpy.mockRestore(); }); + it("getChallengeWinners returns empty array when query returns null", async () => { + fetchMock.mockResolvedValueOnce(null); + + await expect(getChallengeWinners()).resolves.toEqual([]); + expect(fetchMock).toHaveBeenCalledWith(QUERIES.challengeWinners); + }); + it("getProjects returns empty array when query returns null", async () => { fetchMock.mockResolvedValueOnce(null); diff --git a/src/lib/sanity/fetchers.ts b/src/lib/sanity/fetchers.ts index af8ef14..39a3bc9 100644 --- a/src/lib/sanity/fetchers.ts +++ b/src/lib/sanity/fetchers.ts @@ -73,6 +73,17 @@ export function getProject(slug: string): Promise { ); } +/** Sponsor challenge winners (at most one project per `awardingSponsor`). */ +export function getChallengeWinners(): Promise { + return safeFetch( + "getChallengeWinners", + sanityClient + .fetch(QUERIES.challengeWinners) + .then((p) => p ?? []), + [], + ); +} + // ---- Teams ----------------------------------------------------------------- export function getTeamSlugs(): Promise { diff --git a/src/lib/sanity/index.ts b/src/lib/sanity/index.ts index 07300a5..3aa0d2a 100644 --- a/src/lib/sanity/index.ts +++ b/src/lib/sanity/index.ts @@ -5,6 +5,7 @@ * import { getProjects, urlFor, type HackathonProjectPreview } from "~/lib/sanity"; */ +export * from "./challengeSponsors"; export * from "./client"; export * from "./fetchers"; export * from "./memberSocials"; diff --git a/src/lib/sanity/queries.ts b/src/lib/sanity/queries.ts index b56dc85..dfc715e 100644 --- a/src/lib/sanity/queries.ts +++ b/src/lib/sanity/queries.ts @@ -22,6 +22,7 @@ export const projectPreviewProjection = `{ technologies, githubUrl, demoUrl, + awardingSponsor, "team": team->{ _id, name, slug } }`; @@ -34,6 +35,7 @@ export const projectDetailProjection = `{ mainImage, technologies, track, + awardingSponsor, githubUrl, demoUrl, videoUrl, @@ -48,15 +50,18 @@ export const teamDetailProjection = `{ logo, teamDescription, members, - "projects": *[_type == "hackathonProject" && references(^._id) && defined(slug.current)] ${projectPreviewProjection} + "projects": *[_type == "hackathonProject" && references(^._id) && defined(slug.current)] ${projectPreviewProjection} | order(defined(awardingSponsor) desc, awardingSponsor asc, title asc) }`; export const QUERIES = { projectSlugs: `*[_type == "hackathonProject" && defined(slug.current)] | order(slug.current asc).slug.current`, - projectList: `*[_type == "hackathonProject" && defined(slug.current)] ${projectPreviewProjection} | order(title asc)`, + projectList: `*[_type == "hackathonProject" && defined(slug.current)] ${projectPreviewProjection} | order(defined(awardingSponsor) desc, awardingSponsor asc, title asc)`, projectBySlug: `*[_type == "hackathonProject" && slug.current == $slug][0] ${projectDetailProjection}`, teamSlugs: `*[_type == "team" && defined(slug.current)] | order(slug.current asc).slug.current`, teamList: `*[_type == "team"] ${teamDetailProjection} | order(name asc)`, teamBySlug: `*[_type == "team" && slug.current == $slug][0] ${teamDetailProjection}`, + + /** One entry per sponsor that has a winner assigned in Studio. */ + challengeWinners: `*[_type == "hackathonProject" && defined(awardingSponsor)] ${projectPreviewProjection} | order(awardingSponsor asc)`, } as const; diff --git a/src/lib/sanity/schemas.ts b/src/lib/sanity/schemas.ts index 1aa91fc..a3dbafe 100644 --- a/src/lib/sanity/schemas.ts +++ b/src/lib/sanity/schemas.ts @@ -1,3 +1,5 @@ +import type { ChallengeSponsorStoredKey } from "./challengeSponsors"; + /** * Typed shapes mirroring the Sanity studio schemas at * `studio-hack-michigan-/schemaTypes/`. @@ -70,6 +72,8 @@ export interface HackathonProject { videoUrl?: string; technologies?: string[]; track?: string; + /** Set on the single winning project per sponsor AI challenge. */ + awardingSponsor?: ChallengeSponsorStoredKey; } /** @@ -86,6 +90,7 @@ export interface HackathonProjectPreview { technologies?: string[]; githubUrl?: string; demoUrl?: string; + awardingSponsor?: ChallengeSponsorStoredKey; } /** Minimal team info embedded on a project (post-dereference). */ From ba452b163b55465e61a1fac7f97305a53d56abdd Mon Sep 17 00:00:00 2001 From: Greg Miller Date: Tue, 19 May 2026 20:18:02 -0400 Subject: [PATCH 04/14] feat: static fallbacks - hardcoded slugs/titles/team for the winners - until studio is complete this shows us what we want to know --- src/data/challengeWinnerFallbacks.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/data/challengeWinnerFallbacks.ts diff --git a/src/data/challengeWinnerFallbacks.ts b/src/data/challengeWinnerFallbacks.ts new file mode 100644 index 0000000..97acf28 --- /dev/null +++ b/src/data/challengeWinnerFallbacks.ts @@ -0,0 +1,28 @@ +import type { ChallengeSponsorKey } from "../lib/sanity/challengeSponsors"; + +/** + * Published winner slugs/titles until every project is tagged in Sanity. + * Remove entries as `awardingSponsor` is set on live documents. + */ +export const CHALLENGE_WINNER_FALLBACKS: Record< + ChallengeSponsorKey, + { projectSlug: string; projectTitle: string; teamName?: string } +> = { + dte: { + projectSlug: "poleproof", + projectTitle: "PoleProof: Predicting Wind Turbine Pole Integrity", + }, + startMidwest: { + projectSlug: "trestle", + projectTitle: "Trestle — Midwest Founder Resource Discover Engine", + }, + aiCollectiveDetroit: { + projectSlug: "verify-ai-agents", + projectTitle: "Verify AI Agents", + }, + compassDetroit: { + projectSlug: "shorewatch", + projectTitle: "ShoreWatch: A Detroit Waterway Monitoring System", + teamName: "Team Dunamis", + }, +}; From c908c28a439e8e304bb86afbcb4f637758cb9a10 Mon Sep 17 00:00:00 2001 From: Greg Miller Date: Tue, 19 May 2026 20:19:26 -0400 Subject: [PATCH 05/14] feat: project page structure UI - winners section works before every winner is tagged in Sanity - projects page opens with sponsor challenge winners, then all submissions below - converted SponsorChallengeSection into component - create new SponsorWinnersSection --- src/components/SponsorChallengeSection.astro | 133 +++++ src/components/SponsorWinnersSection.astro | 511 +++++++++++++++++++ src/pages/projects/index.astro | 147 +----- 3 files changed, 658 insertions(+), 133 deletions(-) create mode 100644 src/components/SponsorChallengeSection.astro create mode 100644 src/components/SponsorWinnersSection.astro diff --git a/src/components/SponsorChallengeSection.astro b/src/components/SponsorChallengeSection.astro new file mode 100644 index 0000000..748d82b --- /dev/null +++ b/src/components/SponsorChallengeSection.astro @@ -0,0 +1,133 @@ +--- +import Button from "./primitives/Button.astro"; +import { EVENT_URLS } from "../data/event"; +--- + + + + diff --git a/src/components/SponsorWinnersSection.astro b/src/components/SponsorWinnersSection.astro new file mode 100644 index 0000000..3bca9fb --- /dev/null +++ b/src/components/SponsorWinnersSection.astro @@ -0,0 +1,511 @@ +--- +import { + buildWinnerBySponsorMap, + CHALLENGE_SPONSOR_OPTIONS, +} from "../lib/sanity/challengeSponsors"; +import { urlFor } from "../lib/sanity"; +import type { HackathonProjectPreview } from "../lib/sanity/schemas"; +import { CHALLENGE_WINNER_FALLBACKS } from "../data/challengeWinnerFallbacks"; +import { publicAssetUrl } from "../data/siteLogos"; +import { withBase } from "../data/nav"; +import MaybeLink from "./primitives/MaybeLink.astro"; + +/** `banner` = large logo block; `inline` = small mark by challenge; `project-hero` = screenshot + inline mark. */ +export type WinnerLogoStyle = "banner" | "inline" | "project-hero"; + +interface Props { + winners?: HackathonProjectPreview[]; + logoStyle?: WinnerLogoStyle; +} + +const { winners = [], logoStyle = "project-hero" } = Astro.props; +const base = import.meta.env.BASE_URL; + +const winnerBySponsor = buildWinnerBySponsorMap(winners); + +const winnerRows = CHALLENGE_SPONSOR_OPTIONS.map((sponsor) => { + const project = winnerBySponsor.get(sponsor.value); + const fallback = CHALLENGE_WINNER_FALLBACKS[sponsor.value]; + const projectSlug = project?.slug.current ?? fallback.projectSlug; + const projectTitle = project?.title ?? fallback.projectTitle; + const teamName = project?.team?.name ?? fallback.teamName; + const projectHref = withBase(base, `/projects/project/${projectSlug}/`); + + return { sponsor, project, projectHref, projectTitle, teamName }; +}); + +function sponsorLogoSrc(sponsor: (typeof CHALLENGE_SPONSOR_OPTIONS)[number]) { + return publicAssetUrl(base, sponsor.logoSrc); +} + +function sponsorLogoLight(sponsor: (typeof CHALLENGE_SPONSOR_OPTIONS)[number]) { + return "logoSurface" in sponsor && sponsor.logoSurface === "light"; +} +--- + +
+
+

Hack Michigan 2026

+

+ + Challenge winners +

+

+ After 48 hours at TechTown, eight industry tracks, and hundreds of builders + going all-in on Michigan’s future—these teams took home the + sponsor AI challenges. +

+
+ +
    + { + winnerRows.map( + ({ sponsor, project, projectHref, projectTitle, teamName }, index) => ( +
  1. +
    +
    + + Winner +
    + + { + logoStyle === "project-hero" && project?.mainImage && ( + + + + ) + } + + { + logoStyle === "banner" ? ( + + + + ) : ( +
    + + + +

    + {sponsor.challengeLabel} +

    +
    + ) + } + + { + logoStyle === "banner" && ( +

    {sponsor.challengeLabel}

    + ) + } + +

    + {projectTitle} +

    + + {teamName &&

    {teamName}

    } + + + View project + + +
    +
  2. + ), + ) + } +
+ +

+ Winners demo on the Main Stage at + + Michigan Tech Week + + — Wed, May 20 · 2:30–3:00 PM ET · Michigan Central. +

+
+ + diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro index 7d2c2d1..24d9038 100644 --- a/src/pages/projects/index.astro +++ b/src/pages/projects/index.astro @@ -1,17 +1,24 @@ --- -import { getProjects, type HackathonProjectPreview } from "../../lib/sanity"; +import { + getChallengeWinners, + getProjects, + type HackathonProjectPreview, +} from "../../lib/sanity"; import PageLayout from "../../layouts/PageLayout.astro"; import ProjectCard from "../../components/cms/ProjectCard.astro"; -import Button from "../../components/primitives/Button.astro"; -import { EVENT_URLS } from "../../data/event"; +import SponsorWinnersSection from "../../components/SponsorWinnersSection.astro"; // Don't break the build if Sanity is unreachable; fall through to the // empty-state below. Strict-mode CI flips this via `PUBLIC_SANITY_STRICT_MODE`. let projects: HackathonProjectPreview[] = []; +let challengeWinners: HackathonProjectPreview[] = []; try { - projects = await getProjects(); + [projects, challengeWinners] = await Promise.all([ + getProjects(), + getChallengeWinners(), + ]); } catch (err) { - console.error("[projects] getProjects() failed, rendering empty state:", err); + console.error("[projects] Sanity fetch failed, rendering empty state:", err); } --- @@ -28,37 +35,7 @@ try {

- +
{ @@ -93,95 +70,12 @@ try { } .page-subtitle { - font-size: 1.1rem; + font-size: var(--font-size-body); color: var(--color-text-dim); max-width: 600px; margin: 0 auto; } - .sponsor-challenge { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr); - gap: var(--space-lg); - align-items: stretch; - margin-bottom: var(--space-2xl); - padding: clamp(1.5rem, 4vw, 2.5rem); - border: 1px solid var(--color-border); - border-radius: var(--radius-xl); - background: - radial-gradient( - circle at 15% 20%, - rgb(139 92 246 / 24%), - transparent 35% - ), - var(--surface-glass-raised-bg); - box-shadow: var(--shadow-glass); - } - - .sponsor-copy { - align-self: center; - } - - .section-eyebrow { - margin: 0 0 var(--space-xs); - color: var(--color-accent-orange); - font-size: 0.82rem; - font-weight: 800; - letter-spacing: 0.14em; - text-transform: uppercase; - } - - .sponsor-copy h2, - .sponsor-panel h3 { - color: var(--color-text); - line-height: 1.1; - } - - .sponsor-copy h2 { - max-width: 12ch; - margin-bottom: var(--space-sm); - font-size: clamp(2rem, 6vw, 3.5rem); - } - - .sponsor-copy p { - max-width: 690px; - color: var(--color-text-dim); - font-size: 1.08rem; - line-height: 1.7; - } - - .sponsor-panel { - display: flex; - flex-direction: column; - gap: var(--space-sm); - padding: var(--space-md); - border: 1px solid rgb(130 177 254 / 30%); - border-radius: var(--radius-lg); - background: rgb(2 6 23 / 48%); - } - - .sponsor-panel h3 { - font-size: 1.35rem; - } - - .sponsor-panel ul { - display: grid; - gap: var(--space-xs); - margin: 0; - padding-left: 1.1rem; - color: var(--color-text-dim); - line-height: 1.55; - } - - .sponsor-panel li::marker { - color: var(--color-accent-orange); - } - - .sponsor-panel :global(.btn) { - align-self: flex-start; - margin-top: auto; - } - .project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); @@ -200,19 +94,6 @@ try { } @media (max-width: 48rem) { - .sponsor-challenge { - grid-template-columns: 1fr; - margin-bottom: var(--space-xl); - } - - .sponsor-copy h2 { - max-width: 100%; - } - - .sponsor-panel :global(.btn) { - align-self: stretch; - } - .project-grid { grid-template-columns: 1fr; } From 420e0fe70d903d897b15439925a2ca39e2e7b487 Mon Sep 17 00:00:00 2001 From: Greg Miller Date: Tue, 19 May 2026 20:22:04 -0400 Subject: [PATCH 06/14] refactor: project cards and detail - winner projects sort to the top and show a challenge ribbon on cards and detail pages --- src/components/cms/ProjectCard.astro | 72 ++++++++++++++++++++++++- src/components/cms/ProjectCard.test.ts | 15 ++++++ src/pages/projects/project/[slug].astro | 16 ++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/components/cms/ProjectCard.astro b/src/components/cms/ProjectCard.astro index edc9cd0..93a6b5c 100644 --- a/src/components/cms/ProjectCard.astro +++ b/src/components/cms/ProjectCard.astro @@ -16,6 +16,7 @@ */ import { urlFor, + challengeSponsorByKey, HACKATHON_CARD_DESCRIPTION_CHARS, portableTextToPlainText, truncatePlainText, @@ -43,14 +44,34 @@ const descriptionPreview = HACKATHON_CARD_DESCRIPTION_CHARS, ) : ""; +const challengeWinner = project.awardingSponsor + ? challengeSponsorByKey(project.awardingSponsor) + : undefined; --- { variant === "compact" ? ( + { + challengeWinner && ( +

+ + {challengeWinner.challengeLabel} winner + +

+ ) + } {project.mainImage && (
) : ( -
+
+ { + challengeWinner && ( +

+ + {challengeWinner.challengeLabel} winner + +

+ ) + } {project.mainImage && (
@@ -112,11 +152,39 @@ const descriptionPreview = } diff --git a/src/components/cms/WinnerRibbon.astro b/src/components/cms/WinnerRibbon.astro new file mode 100644 index 0000000..fd92893 --- /dev/null +++ b/src/components/cms/WinnerRibbon.astro @@ -0,0 +1,37 @@ +--- +/** + * WinnerRibbon — shared "challenge winner" ribbon strip. + * + * Used at the top of both `ProjectCard` and `WinnerCard` to display the + * sponsor challenge label. Extracted to a single component so the markup, + * gradient, and a11y attributes stay consistent (DRY). + */ +interface Props { + label: string; +} + +const { label } = Astro.props; +--- + +

+ {label} winner +

+ + diff --git a/src/lib/sanity/challengeSponsorSync.test.ts b/src/lib/sanity/challengeSponsorSync.test.ts new file mode 100644 index 0000000..396768f --- /dev/null +++ b/src/lib/sanity/challengeSponsorSync.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { CHALLENGE_SPONSOR_OPTIONS } from "./challengeSponsors"; + +describe("challenge sponsor sync: studio ↔ frontend", () => { + const studioSrc = readFileSync( + resolve("studio-hack-michigan-/schemaTypes/challengeSponsors.ts"), + "utf8", + ); + + it("every frontend sponsor value exists in the studio schema", () => { + for (const { value } of CHALLENGE_SPONSOR_OPTIONS) { + expect(studioSrc).toContain(`value: '${value}'`); + } + }); + + it("studio and frontend have the same number of sponsor entries", () => { + const studioValues = [...studioSrc.matchAll(/value:\s*'(\w+)'/g)].map( + (m) => m[1], + ); + expect(studioValues.length).toBe(CHALLENGE_SPONSOR_OPTIONS.length); + }); +}); diff --git a/src/lib/sanity/challengeSponsors.test.ts b/src/lib/sanity/challengeSponsors.test.ts index 1cb2736..af3621d 100644 --- a/src/lib/sanity/challengeSponsors.test.ts +++ b/src/lib/sanity/challengeSponsors.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { buildWinnerBySponsorMap, + CHALLENGE_SPONSOR_OPTIONS, challengeSponsorByKey, + isSponsorLogoLight, normalizeChallengeSponsorKey, } from "./challengeSponsors"; import type { HackathonProjectPreview } from "./schemas"; @@ -32,4 +34,67 @@ describe("challengeSponsors", () => { "Hack Michigan AI Challenge", ); }); + + it("normalizeChallengeSponsorKey returns undefined for undefined input", () => { + expect(normalizeChallengeSponsorKey(undefined)).toBeUndefined(); + }); + + it("normalizeChallengeSponsorKey returns undefined for unknown key", () => { + expect(normalizeChallengeSponsorKey("nonExistentSponsor")).toBeUndefined(); + }); + + it('normalizeChallengeSponsorKey returns valid key unchanged', () => { + expect(normalizeChallengeSponsorKey("dte")).toBe("dte"); + }); + + it("challengeSponsorByKey throws for unknown key", () => { + expect(() => + challengeSponsorByKey("bogus" as never), + ).toThrowError("Unknown challenge sponsor: bogus"); + }); + + it("buildWinnerBySponsorMap returns empty map for empty array", () => { + const map = buildWinnerBySponsorMap([]); + expect(map.size).toBe(0); + }); + + it("buildWinnerBySponsorMap with duplicate sponsor keys keeps last entry", () => { + const first = { + _id: "p1", + title: "First", + slug: { current: "first" }, + awardingSponsor: "dte", + } as HackathonProjectPreview; + + const second = { + _id: "p2", + title: "Second", + slug: { current: "second" }, + awardingSponsor: "dte", + } as HackathonProjectPreview; + + const map = buildWinnerBySponsorMap([first, second]); + expect(map.get("dte")).toEqual(second); + }); + + it("isSponsorLogoLight returns true for light surface, false for others", () => { + const light = CHALLENGE_SPONSOR_OPTIONS.find( + (s) => s.logoSurface === "light", + )!; + expect(isSponsorLogoLight(light)).toBe(true); + + const dark = CHALLENGE_SPONSOR_OPTIONS.find( + (s) => s.logoSurface !== "light", + )!; + expect(isSponsorLogoLight(dark)).toBe(false); + }); + + it.each(CHALLENGE_SPONSOR_OPTIONS)( + "challengeSponsorByKey resolves $value", + (option) => { + const result = challengeSponsorByKey(option.value); + expect(result).toBe(option); + }, + ); }); + diff --git a/src/lib/sanity/challengeSponsors.ts b/src/lib/sanity/challengeSponsors.ts index ef1d8b4..125e7df 100644 --- a/src/lib/sanity/challengeSponsors.ts +++ b/src/lib/sanity/challengeSponsors.ts @@ -1,9 +1,19 @@ -import type { HackathonProjectPreview } from "./schemas"; - /** * Sponsor AI challenge tracks — one winning project per sponsor. * Keep in sync with `studio-hack-michigan-/schemaTypes/challengeSponsors.ts`. */ + +/** Shape of a single challenge sponsor entry. */ +export interface ChallengeSponsor { + readonly value: string; + readonly title: string; + readonly challengeLabel: string; + readonly logoSrc: string; + readonly logoHref: string; + /** Surface the logo sits on — affects chip/wrap background. Defaults to `"dark"`. */ + readonly logoSurface?: "light" | "dark"; +} + export const CHALLENGE_SPONSOR_OPTIONS = [ { value: "dte", @@ -25,7 +35,7 @@ export const CHALLENGE_SPONSOR_OPTIONS = [ challengeLabel: "The AI Collective Detroit Challenge", logoSrc: "/images/sponsors/com_AI_Collective_Detroit.png", logoHref: "https://www.aicollective.com/chapters/detroit", - logoSurface: "light" as const, + logoSurface: "light", }, { value: "compassDetroit", @@ -34,22 +44,15 @@ export const CHALLENGE_SPONSOR_OPTIONS = [ logoSrc: "/images/site/compass-logo.svg", logoHref: "https://compassdetroit.com", }, -] as const; +] as const satisfies readonly ChallengeSponsor[]; export type ChallengeSponsorKey = (typeof CHALLENGE_SPONSOR_OPTIONS)[number]["value"]; -/** Index Sanity winner projects by normalized `awardingSponsor` (last write wins). */ -export function buildWinnerBySponsorMap( - winners: readonly HackathonProjectPreview[], -): Map { - const map = new Map(); - for (const project of winners) { - const key = normalizeChallengeSponsorKey(project.awardingSponsor); - if (key) map.set(key, project); - } - return map; -} +/** Pre-built index for O(1) sponsor lookup by key. */ +const SPONSOR_BY_KEY = new Map( + CHALLENGE_SPONSOR_OPTIONS.map((s) => [s.value, s]), +); /** @deprecated Studio value before Compass was named explicitly — maps to `compassDetroit`. */ export const CHALLENGE_SPONSOR_LEGACY_KEYS = { @@ -69,18 +72,36 @@ export function normalizeChallengeSponsorKey( key as keyof typeof CHALLENGE_SPONSOR_LEGACY_KEYS ]; } - if (CHALLENGE_SPONSOR_OPTIONS.some((s) => s.value === key)) { - return key as ChallengeSponsorKey; - } - return undefined; + return SPONSOR_BY_KEY.has(key) ? (key as ChallengeSponsorKey) : undefined; } export function challengeSponsorByKey( key: ChallengeSponsorStoredKey, ): (typeof CHALLENGE_SPONSOR_OPTIONS)[number] { const normalized = normalizeChallengeSponsorKey(key); - if (!normalized) throw new Error(`Unknown challenge sponsor: ${key}`); - const match = CHALLENGE_SPONSOR_OPTIONS.find((s) => s.value === normalized); + const match = normalized ? SPONSOR_BY_KEY.get(normalized) : undefined; if (!match) throw new Error(`Unknown challenge sponsor: ${key}`); return match; } + +/** Whether a sponsor entry has a light logo surface. */ +export function isSponsorLogoLight(sponsor: ChallengeSponsor): boolean { + return sponsor.logoSurface === "light"; +} + +/** Any object that carries an optional `awardingSponsor` key. */ +interface HasAwardingSponsor { + awardingSponsor?: string; +} + +/** Index winner objects by normalized `awardingSponsor` (last write wins). */ +export function buildWinnerBySponsorMap( + winners: readonly T[], +): Map { + const map = new Map(); + for (const project of winners) { + const key = normalizeChallengeSponsorKey(project.awardingSponsor); + if (key) map.set(key, project); + } + return map; +} diff --git a/src/lib/sanity/fetchers.ts b/src/lib/sanity/fetchers.ts index 39a3bc9..769a088 100644 --- a/src/lib/sanity/fetchers.ts +++ b/src/lib/sanity/fetchers.ts @@ -41,26 +41,23 @@ async function safeFetch( } } -// ---- Projects -------------------------------------------------------------- - -export function getProjectSlugs(): Promise { +/** Shorthand for list-fetchers that normalise Sanity's nullable arrays. */ +function fetchList(label: string, query: string): Promise { return safeFetch( - "getProjectSlugs", - sanityClient - .fetch(QUERIES.projectSlugs) - .then((s) => s ?? []), + label, + sanityClient.fetch(query).then((r) => r ?? []), [], ); } +// ---- Projects -------------------------------------------------------------- + +export function getProjectSlugs(): Promise { + return fetchList("getProjectSlugs", QUERIES.projectSlugs); +} + export function getProjects(): Promise { - return safeFetch( - "getProjects", - sanityClient - .fetch(QUERIES.projectList) - .then((p) => p ?? []), - [], - ); + return fetchList("getProjects", QUERIES.projectList); } export function getProject(slug: string): Promise { @@ -75,33 +72,20 @@ export function getProject(slug: string): Promise { /** Sponsor challenge winners (at most one project per `awardingSponsor`). */ export function getChallengeWinners(): Promise { - return safeFetch( + return fetchList( "getChallengeWinners", - sanityClient - .fetch(QUERIES.challengeWinners) - .then((p) => p ?? []), - [], + QUERIES.challengeWinners, ); } // ---- Teams ----------------------------------------------------------------- export function getTeamSlugs(): Promise { - return safeFetch( - "getTeamSlugs", - sanityClient.fetch(QUERIES.teamSlugs).then((s) => s ?? []), - [], - ); + return fetchList("getTeamSlugs", QUERIES.teamSlugs); } export function getTeams(): Promise { - return safeFetch( - "getTeams", - sanityClient - .fetch(QUERIES.teamList) - .then((t) => t ?? []), - [], - ); + return fetchList("getTeams", QUERIES.teamList); } export function getTeam(slug: string): Promise { diff --git a/src/lib/sanity/queries.ts b/src/lib/sanity/queries.ts index dfc715e..2544f94 100644 --- a/src/lib/sanity/queries.ts +++ b/src/lib/sanity/queries.ts @@ -59,7 +59,7 @@ export const QUERIES = { projectBySlug: `*[_type == "hackathonProject" && slug.current == $slug][0] ${projectDetailProjection}`, teamSlugs: `*[_type == "team" && defined(slug.current)] | order(slug.current asc).slug.current`, - teamList: `*[_type == "team"] ${teamDetailProjection} | order(name asc)`, + teamList: `*[_type == "team" && defined(slug.current)] ${teamDetailProjection} | order(name asc)`, teamBySlug: `*[_type == "team" && slug.current == $slug][0] ${teamDetailProjection}`, /** One entry per sponsor that has a winner assigned in Studio. */ diff --git a/src/lib/sanity/schemas.ts b/src/lib/sanity/schemas.ts index a3dbafe..928e4a7 100644 --- a/src/lib/sanity/schemas.ts +++ b/src/lib/sanity/schemas.ts @@ -78,20 +78,24 @@ export interface HackathonProject { /** * A reduced project shape for listing pages — only the fields the card - * renderer needs. Keep in sync with `projectPreviewProjection` in queries.ts. + * renderer needs. Derived from `HackathonProject` via `Pick` so the two + * types can never drift independently. + * + * Keep in sync with `projectPreviewProjection` in queries.ts. */ -export interface HackathonProjectPreview { - _id: string; - title: string; - slug: Slug; - team?: TeamRef; - description?: PortableTextBlock[]; - mainImage?: SanityImage; - technologies?: string[]; - githubUrl?: string; - demoUrl?: string; - awardingSponsor?: ChallengeSponsorStoredKey; -} +export type HackathonProjectPreview = Pick< + HackathonProject, + | "_id" + | "title" + | "slug" + | "team" + | "description" + | "mainImage" + | "technologies" + | "githubUrl" + | "demoUrl" + | "awardingSponsor" +>; /** Minimal team info embedded on a project (post-dereference). */ export interface TeamRef { diff --git a/src/pages/projects/index.astro b/src/pages/projects/index.astro index 24d9038..efe40e9 100644 --- a/src/pages/projects/index.astro +++ b/src/pages/projects/index.astro @@ -58,14 +58,14 @@ try { .page-header { text-align: center; - margin-bottom: 4rem; + margin-bottom: var(--space-2xl); } .page-title { font-family: var(--font-heading); font-size: clamp(2.5rem, 8vw, 4rem); font-weight: 700; - margin-bottom: 0.75rem; + margin-bottom: var(--space-xs); color: var(--color-text); } @@ -79,13 +79,13 @@ try { .project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); - gap: 2rem; + gap: var(--space-lg); } .empty-state { grid-column: 1 / -1; text-align: center; - padding: 3rem 1rem; + padding: var(--space-xl) var(--space-sm); color: var(--color-text-dim); font-size: 1.05rem; border: 1px dashed var(--color-border); diff --git a/src/pages/projects/project/[slug].astro b/src/pages/projects/project/[slug].astro index e7cdc25..6ff541b 100644 --- a/src/pages/projects/project/[slug].astro +++ b/src/pages/projects/project/[slug].astro @@ -17,18 +17,21 @@ export async function getStaticPaths() { return slugs.map((slug) => ({ params: { slug } })); } +const base = import.meta.env.BASE_URL; const slug = Astro.params.slug; -if (!slug) return Astro.redirect("/404"); +if (!slug) return Astro.redirect(withBase(base, "/404")); const project: HackathonProject | null = await getProject(slug); -if (!project) return Astro.redirect("/404"); -const base = import.meta.env.BASE_URL; +if (!project) return Astro.redirect(withBase(base, "/404")); const descriptionBlocks = project.description ?? []; const hasDescription = descriptionBlocks.length > 0; const metaDescription = truncatePlainText(portableTextToPlainText(descriptionBlocks), 160) || project.title; +const challengeLabel = project.awardingSponsor + ? challengeSponsorByKey(project.awardingSponsor).challengeLabel + : undefined; --- {" "} - • { - challengeSponsorByKey(project.awardingSponsor).challengeLabel - }{" "} + • {challengeLabel}{" "} winner ) @@ -76,6 +77,8 @@ const metaDescription = alt={project.title} loading="eager" decoding="async" + width={1200} + height={675} />
) @@ -158,12 +161,12 @@ const metaDescription =