diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a289cb..a2a09a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,43 @@ This repository builds the **Hack Michigan (HackMI)** marketing site. It started ## [Unreleased] +## [1.1.4] - 2026-05-19 + +### Added + +- Typed Sanity fetch layer (`src/lib/sanity/fetchers.ts`): `safeFetch` wrappers, optional strict mode via `PUBLIC_SANITY_STRICT_MODE`, and `getChallengeWinners()` for sponsor challenge winners. +- **`awardingSponsor`** on hackathon projects in Sanity (radio list, one winner per sponsor) with Studio validation preventing duplicate winners (`studio-hack-michigan-/schemaTypes/hackathonProject.ts`). +- Shared sponsor-challenge metadata (`src/lib/sanity/challengeSponsors.ts`, `studio-hack-michigan-/schemaTypes/challengeSponsors.ts`) kept in sync between Studio and the site. +- **`SponsorWinnersSection`** on `/projects/` — highlights DTE, StartMidwest, AI Collective Detroit, and Compass Detroit challenge winners with logos and links to project detail pages (`src/components/SponsorWinnersSection.astro`). +- Static winner fallbacks (`src/data/challengeWinnerFallbacks.ts`) until every winning project is tagged in Sanity. +- Team **`teamDescription`** portable text (up to 1,000 plain-text characters) and wide-logo guidance on team documents; rendered on `/projects/team/[slug]/` (`studio-hack-michigan-/schemaTypes/team.ts`). +- **`hasSanityImage`** image guard and `teamLogo` URL helper in the Astro Sanity client layer. +- **`portableTextPlainLength`** utility in Studio for consistent portable-text length validation (`studio-hack-michigan-/schemaTypes/portableTextUtils.ts`). +- **Media** and **Fuel** sponsor tiers; logos for Why Not Collab (media) and Luminesso Coffee (fuel). +- AI Collective Detroit as a **community** sponsor. +- Navigation **scroll-spy** (IntersectionObserver) so active nav links track the section in view (`src/components/Navigation.astro`, `src/data/nav.ts`). +- `rel="sponsored"` on external sponsor links via `MaybeLink` (`src/components/primitives/MaybeLink.astro`). +- Shared deploy **rate-limit** module (`src/lib/deploy/rate-limit.js`). +- Vitest coverage for challenge sponsors, fetchers, team logos, `MaybeLink`, and sponsor logo data. +- Playwright visual-regression baselines for the projects grid and related UI updates. + ### Changed -- Playwright E2E specs updated to match the new `/projects/` route for the “Projects” navigation link (instead of asserting `/hackathon/`). -- Canonicalized the legacy `/hackathon/` listing URL by redirecting it to `/projects/` (detail routes under `/hackathon/*` remain unchanged). +- Hackathon browsing moved under **`/projects/`**: listing, project detail, team detail, and variants routes now live in `src/pages/projects/` (removed `src/pages/hackathon/index.astro`). +- **Vercel redirects** send `/hackathon/` and legacy `/hackathon/project/*` and `/hackathon/team/*` URLs to their `/projects/` equivalents (`vercel.json`). +- Playwright E2E specs updated for the `/projects/` “Projects” nav target (replacing `/hackathon/`). +- Refactored **project cards** and project/team detail layouts (`src/components/cms/ProjectCard.astro`, `src/pages/projects/project/[slug].astro`, `src/pages/projects/team/[slug].astro`). +- Event **schedule/agenda** aligned with the finalized three-day agenda (`src/data/eventAgenda.ts`). +- Landing-page **section order** matches navigation; dim secondary text uses a shared `--font-size-body` fluid scale (`src/styles/global.css`). +- Sponsor logo data expanded (additional tiers/logos, `rel=sponsored` behavior, tests in `src/data/sponsorLogos.test.ts`). +- Sanity Studio dependency refresh (`studio-hack-michigan-/package.json`). + +### Fixed + +- Team description validation counts **plain-text characters**, not Portable Text blocks. +- Duplicate `.team-description` wrapper and unsupported `max-width: stretch` on team pages. +- Luminesso logo **light surface** treatment and fuel-tier grid layout. +- Sponsor link UX, register URLs, mobile breakpoints, and Playwright CI routing. ## [1.1.3] - 2026-05-01 @@ -134,6 +167,7 @@ _Initial [astro-darkness](https://github.com/kpab/astro-darkness) template relea - @astrojs/rss for feed generation - @astrojs/sitemap for SEO optimization +[1.1.4]: https://github.com/Compass-Detroit/hackmi26/compare/v1.1.3...v1.1.4 [1.1.3]: https://github.com/Compass-Detroit/hackmi26/compare/v1.1.2...v1.1.3 [1.1.2]: https://github.com/Compass-Detroit/hackmi26/compare/v1.1.1...v1.1.2 [1.1.1]: https://github.com/Compass-Detroit/hackmi26/compare/v1.1.0...v1.1.1 diff --git a/README.md b/README.md index 55ee1d2..63e8776 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Marketing site and hackathon showcase for **Hack Michigan** — [hackmichigan.co ![Hack Michigan social card](public/social-card.jpg) -[![Version](https://img.shields.io/badge/version-1.1.1-blue)](https://github.com/Compass-Detroit/hackmi26/releases) +[![Version](https://img.shields.io/badge/version-1.1.4-blue)](https://github.com/Compass-Detroit/hackmi26/releases) [![Astro](https://img.shields.io/badge/Astro-5-purple)](https://astro.build/) [![Three.js](https://img.shields.io/badge/Three.js-0.160-blue)](https://threejs.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) diff --git a/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-darwin.png b/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-darwin.png index c6b4ca7..68134c7 100644 Binary files a/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-darwin.png and b/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-darwin.png differ diff --git a/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-linux.png b/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-linux.png index 390396b..6d597f1 100644 Binary files a/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-linux.png and b/e2e/visual-regression.spec.ts-snapshots/projects-grid-desktop-linux.png differ diff --git a/package-lock.json b/package-lock.json index 24908ce..8b95894 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hackmichigan", - "version": "1.1.1", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hackmichigan", - "version": "1.1.1", + "version": "1.1.4", "license": "MIT", "dependencies": { "@astrojs/mdx": "^4.0.0", diff --git a/package.json b/package.json index 7806176..0a943b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hackmichigan", "type": "module", - "version": "1.1.1", + "version": "1.1.4", "description": "Hack Michigan (HackMI) — marketing site and hackathon showcase for the Detroit tech corridor, built with Astro + Sanity.", "keywords": [ "astro", diff --git a/src/components/SponsorChallengeSection.astro b/src/components/SponsorChallengeSection.astro new file mode 100644 index 0000000..a935f35 --- /dev/null +++ b/src/components/SponsorChallengeSection.astro @@ -0,0 +1,130 @@ +--- +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..817cb41 --- /dev/null +++ b/src/components/SponsorWinnersSection.astro @@ -0,0 +1,212 @@ +--- +/** + * SponsorWinnersSection — showcase grid of challenge winners. + * + * This is a thin orchestrator: + * 1. Merges Sanity winner data with static fallbacks. + * 2. Renders a header, a `` grid, and a footnote. + * + * Individual card rendering is delegated to `WinnerCard.astro` (SRP). + */ +import { + buildWinnerBySponsorMap, + CHALLENGE_SPONSOR_OPTIONS, +} from "../lib/sanity/challengeSponsors"; +import type { HackathonProjectPreview } from "../lib/sanity/schemas"; +import { CHALLENGE_WINNER_FALLBACKS } from "../data/challengeWinnerFallbacks"; +import { withBase } from "../data/nav"; +import { EVENT } from "../data/event"; +import WinnerCard from "./cms/WinnerCard.astro"; +import type { WinnerLogoStyle } from "./cms/WinnerCard.astro"; + +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 }; +}); +--- + +
+
+

{EVENT.name}

+

+ + 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. + +
  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/components/cms/ProjectCard.astro b/src/components/cms/ProjectCard.astro index edc9cd0..80cb44d 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, @@ -23,6 +24,7 @@ import { } from "../../lib/sanity"; import { withBase } from "../../data/nav"; import TeamBadge from "./TeamBadge.astro"; +import WinnerRibbon from "./WinnerRibbon.astro"; interface Props { project: HackathonProjectPreview; @@ -43,19 +45,31 @@ const descriptionPreview = HACKATHON_CARD_DESCRIPTION_CHARS, ) : ""; +const challengeWinner = project.awardingSponsor + ? challengeSponsorByKey(project.awardingSponsor) + : undefined; --- { variant === "compact" ? ( + {challengeWinner && ( + + )} {project.mainImage && ( {project.title} )}
@@ -63,7 +77,17 @@ const descriptionPreview =
) : ( -
+
+ {challengeWinner && ( + + )} {project.mainImage && (
@@ -71,6 +95,7 @@ const descriptionPreview = src={urlFor(project.mainImage).width(600).url()} alt={project.title} loading="lazy" + decoding="async" />
)} @@ -117,6 +142,13 @@ const descriptionPreview = overflow: hidden; } + .project-card--winner { + border-color: var(--color-winner-glow); + box-shadow: + var(--shadow-glass), + 0 0 0 1px rgb(245 158 11 / 12%); + } + .image-wrapper img { width: 100%; aspect-ratio: 16/9; @@ -124,7 +156,7 @@ const descriptionPreview = } .content { - padding: 1.5rem; + padding: var(--space-md); } .title-link { @@ -136,9 +168,9 @@ const descriptionPreview = color: var(--color-primary); } - h2 { + .project-card h2 { font-size: 1.5rem; - margin-bottom: 0.75rem; + margin-bottom: var(--space-xs); transition: color var(--duration-fast) ease; } @@ -146,7 +178,7 @@ const descriptionPreview = color: var(--color-text-dim); font-size: 0.95rem; line-height: 1.6; - margin-bottom: 1.5rem; + margin-bottom: var(--space-md); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; @@ -154,12 +186,12 @@ const descriptionPreview = } .tag-list { - margin-bottom: 1.5rem; + margin-bottom: var(--space-md); } .links { display: flex; - gap: 1rem; + gap: var(--space-sm); } .links a { @@ -190,7 +222,7 @@ const descriptionPreview = } .compact-info { - padding: 1rem; + padding: var(--space-sm); } .compact-info h3 { diff --git a/src/components/cms/ProjectCard.test.ts b/src/components/cms/ProjectCard.test.ts index f1f79a1..b0b1fb4 100644 --- a/src/components/cms/ProjectCard.test.ts +++ b/src/components/cms/ProjectCard.test.ts @@ -82,6 +82,21 @@ describe("ProjectCard", () => { }); }); + it("renders challenge winner ribbon at top of full card", async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ProjectCard, { + props: { + project: { ...baseProject, awardingSponsor: "dte" as const }, + }, + }); + const $ = cheerio.load(html); + + expect($("article.project-card--winner").length).toBe(1); + expect($(".winner-ribbon").first().text()).toContain("DTE AI Challenge"); + expect($("article .winner-ribbon").length).toBe(1); + expect($(".card-link").prev(".winner-ribbon").length).toBe(1); + }); + it("renders compact variant as a single clickable card", async () => { const container = await AstroContainer.create(); const html = await container.renderToString(ProjectCard, { @@ -93,4 +108,37 @@ describe("ProjectCard", () => { expect($("article.project-card").length).toBe(0); expect($(".compact-info h3").text()).toBe("MittenHarvest AI"); }); + + it("renders winner ribbon in compact variant when awardingSponsor is set", async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ProjectCard, { + props: { + project: { ...baseProject, awardingSponsor: "dte" as const }, + variant: "compact", + }, + }); + const $ = cheerio.load(html); + + expect($(".winner-ribbon").length).toBe(1); + expect($(".winner-ribbon").text()).toContain("DTE AI Challenge"); + }); + + it("renders main image with lazy loading in full card", async () => { + const container = await AstroContainer.create(); + const html = await container.renderToString(ProjectCard, { + props: { + project: { + ...baseProject, + mainImage: { + _type: "image", + asset: { _ref: "image-abc-200x200-png" }, + }, + }, + }, + }); + const $ = cheerio.load(html); + + expect($("img").length).toBeGreaterThanOrEqual(1); + expect($("img").attr("loading")).toBe("lazy"); + }); }); diff --git a/src/components/cms/WinnerCard.astro b/src/components/cms/WinnerCard.astro new file mode 100644 index 0000000..acfa369 --- /dev/null +++ b/src/components/cms/WinnerCard.astro @@ -0,0 +1,362 @@ +--- +/** + * WinnerCard — individual challenge winner card for the winners grid. + * + * Extracted from `SponsorWinnersSection` (SRP) so the section component + * is a thin orchestrator and each card is independently testable. + * + * Supports three `logoStyle` modes: + * - `"project-hero"` (default) — project screenshot + small sponsor chip + * - `"banner"` — large centered sponsor logo block + * - `"inline"` — small sponsor mark inline with challenge label + */ +import { urlFor } from "../../lib/sanity"; +import { + isSponsorLogoLight, + type ChallengeSponsor, +} from "../../lib/sanity/challengeSponsors"; +import type { HackathonProjectPreview } from "../../lib/sanity/schemas"; +import { publicAssetUrl } from "../../data/siteLogos"; +import MaybeLink from "../primitives/MaybeLink.astro"; + +export type WinnerLogoStyle = "banner" | "inline" | "project-hero"; + +interface Props { + sponsor: ChallengeSponsor; + project?: HackathonProjectPreview; + projectHref: string; + projectTitle: string; + teamName?: string; + logoStyle?: WinnerLogoStyle; + index: number; +} + +const { + sponsor, + project, + projectHref, + projectTitle, + teamName, + logoStyle = "project-hero", + index, +} = Astro.props; + +const base = import.meta.env.BASE_URL; +const logoSrc = publicAssetUrl(base, sponsor.logoSrc); +const isLight = isSponsorLogoLight(sponsor); +const hasMedia = logoStyle === "project-hero" && !!project?.mainImage; +--- + +
+ + 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/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", + }, +}; 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 new file mode 100644 index 0000000..b191642 --- /dev/null +++ b/src/lib/sanity/challengeSponsors.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + buildWinnerBySponsorMap, + CHALLENGE_SPONSOR_OPTIONS, + challengeSponsorByKey, + isSponsorLogoLight, + 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", + ); + }); + + 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)).toThrow( + "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) => isSponsorLogoLight(s))!; + expect(isSponsorLogoLight(light)).toBe(true); + + const dark = CHALLENGE_SPONSOR_OPTIONS.find((s) => !isSponsorLogoLight(s))!; + 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 new file mode 100644 index 0000000..15918a3 --- /dev/null +++ b/src/lib/sanity/challengeSponsors.ts @@ -0,0 +1,108 @@ +/** + * 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", + 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", + }, + { + value: "compassDetroit", + title: "Compass Detroit", + challengeLabel: "Hack Michigan AI Challenge", + logoSrc: "/images/site/compass-logo.svg", + logoHref: "https://compassdetroit.com", + }, +] as const satisfies readonly ChallengeSponsor[]; + +export type ChallengeSponsorKey = + (typeof CHALLENGE_SPONSOR_OPTIONS)[number]["value"]; + +/** Pre-built index for O(1) sponsor lookup by key. */ +const SPONSOR_BY_KEY = new Map< + string, + (typeof CHALLENGE_SPONSOR_OPTIONS)[number] +>(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 = { + 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 + ]; + } + return SPONSOR_BY_KEY.has(key) ? (key as ChallengeSponsorKey) : undefined; +} + +export function challengeSponsorByKey( + key: ChallengeSponsorStoredKey, +): (typeof CHALLENGE_SPONSOR_OPTIONS)[number] { + const normalized = normalizeChallengeSponsorKey(key); + 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.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..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 { @@ -73,24 +70,22 @@ export function getProject(slug: string): Promise { ); } +/** Sponsor challenge winners (at most one project per `awardingSponsor`). */ +export function getChallengeWinners(): Promise { + return fetchList( + "getChallengeWinners", + 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/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..2544f94 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)`, + 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. */ + 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..928e4a7 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,23 +72,30 @@ export interface HackathonProject { videoUrl?: string; technologies?: string[]; track?: string; + /** Set on the single winning project per sponsor AI challenge. */ + awardingSponsor?: ChallengeSponsorStoredKey; } /** * 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; -} +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 7d2c2d1..efe40e9 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 {

- +
{ @@ -81,117 +58,34 @@ 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); } .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)); - 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); @@ -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; } diff --git a/src/pages/projects/project/[slug].astro b/src/pages/projects/project/[slug].astro index bde43e1..c81fdf6 100644 --- a/src/pages/projects/project/[slug].astro +++ b/src/pages/projects/project/[slug].astro @@ -1,5 +1,6 @@ --- import { + challengeSponsorByKey, getProject, getProjectSlugs, urlFor, @@ -16,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; --- ) } + { + challengeLabel && ( + • {challengeLabel} winner + ) + } {project.track && • {project.track}}

{project.title}

@@ -64,6 +73,8 @@ const metaDescription = alt={project.title} loading="eager" decoding="async" + width={1200} + height={675} /> ) @@ -146,12 +157,12 @@ const metaDescription =