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";
+}
+---
+
+
+
+
+
+ {
+ winnerRows.map(
+ ({ sponsor, project, projectHref, projectTitle, teamName }, index) => (
+ -
+
+
+
+ {String(index + 1).padStart(2, "0")}
+
+ Winner
+
+
+ {
+ logoStyle === "project-hero" && project?.mainImage && (
+
+
+
+ )
+ }
+
+ {
+ logoStyle === "banner" ? (
+
+
+
+ ) : (
+
+ )
+ }
+
+ {
+ logoStyle === "banner" && (
+ {sponsor.challengeLabel}
+ )
+ }
+
+
+
+ {teamName && {teamName}
}
+
+
+ View project
+ →
+
+
+
+ ),
+ )
+ }
+
+
+
+
+
+
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 =