diff --git a/apps/live/src/app/(landing)/CabinRace/CabinRace.tsx b/apps/live/src/app/(landing)/CabinRace/CabinRace.tsx new file mode 100644 index 00000000..b762467b --- /dev/null +++ b/apps/live/src/app/(landing)/CabinRace/CabinRace.tsx @@ -0,0 +1,255 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import RibbonTitle from "@repo/ui/RibbonTitle"; +import useDevice from "@util/hooks/useDevice.ts"; +import { + CabinRaceRedAirBalloon, + CabinRaceSquiggle, +} from "../../lib/Assets/SVG"; +import CabinRaceCard from "../../components/CabinRaceComponents/CabinRaceCard.tsx"; +import { Cabin, CabinLead } from "./CabinTypes.tsx"; +import CabinRaceScoreTent from "../../components/CabinRaceComponents/CabinRaceScoreTent.tsx"; + +{ + /* Airtable */ +} +export type CabinInfoRecord = { + id: string; + createdTime: string; + fields: { + Name: string; + Points: number; + Description?: string; + CabinLeads?: string[]; + }; +}; + +export type CabinInfo = { + records: CabinInfoRecord[]; +}; + +export type CabinLeadRecord = { + id: string; + createdTime: string; + fields: { + Name: string; + src: string; + url: string; + }; +}; + +export type CabinLeadInfo = { + records: CabinLeadRecord[]; +}; + +export default function CabinRace(): JSX.Element { + const { isDesktop, isTablet, isMobile } = useDevice(); + + const [cabinData, setCabinData] = useState(null); + + async function getCabinPoints() { + const res = await fetch("/api/cabinPoints"); + const jsonData: CabinInfo = await res.json(); + const status = res.status; + + if (status == 200) { + setCabinData(jsonData); + } + } + + useEffect(() => { + const fetchCabins = async () => { + await getCabinPoints(); + }; + + void fetchCabins(); + }, []); + + const [cabinLeads, setCabinLeads] = useState([]); + + useEffect(() => { + if (!cabinData) return; + + const leadIds = cabinData.records.flatMap( + (record) => record.fields.CabinLeads ?? [], + ); + + if (!leadIds.length) return; + + const fetchLeads = async () => { + const res = await fetch(`/api/cabinLeads?ids=${leadIds.join(",")}`); + const data: CabinLeadInfo = await res.json(); + setCabinLeads(data.records); + }; + + void fetchLeads(); + }, [cabinData]); + + const fetchedCabins = useMemo(() => { + if (!cabinData) return []; + + const leadsById: Record = Object.fromEntries( + cabinLeads.map((lead) => [ + lead.id, + { + name: lead.fields.Name, + src: lead.fields.src, + url: lead.fields.url, + }, + ]), + ); + + return cabinData.records.map((record) => ({ + name: record.fields.Name, + points: record.fields.Points, + description: record.fields.Description ?? "", + cabinLeads: (record.fields.CabinLeads ?? []) + .map((id) => leadsById[id]) + .filter((lead): lead is CabinLead => Boolean(lead)), + })); + }, [cabinData, cabinLeads]); + + const cabinsByName: Record = Object.fromEntries( + fetchedCabins.map((cabin) => [cabin.name, cabin]), + ); + const cabinNames: string[] = fetchedCabins.map((cabin) => cabin.name); + const [selectedCabinName, setSelectedCabinName] = useState( + null, + ); + useEffect(() => { + if (cabinNames.length && !selectedCabinName) { + setSelectedCabinName(cabinNames[0]); + } + }, [cabinNames, selectedCabinName]); + + return ( + <> +
+ {/* Squiggle at top */} + + + {/* Ribbon Title */} +
+ +
+ + {/* Welcome Hackers Text*/} +
+

+ Welcome to the carnival, hackers! +

+
+

+ Aside from hacking away and building an amazing project, you’re + also here to have fun — and what better way to do that than with + friends? In preparation for the carnival, you’ll be grouped into a + guild of performers who are interested in the same activities as + you. +

+

+ + Complete activities together + {" "} + with your guild members to earn points and possibly win some cool + prizes! Most importantly, make some new friends who you can enjoy + the event with! All the while, you’ll be enjoying{" "} + free food, learning new + tricks from workshops, + and building your professional skills at{" "} + career events. +

+
+
+ + {/* Cabin Info Section*/} + {/* Buttons section */} +
+ {cabinNames.map((name) => { + const isSelected = selectedCabinName === name; + return ( + + ); + })} +
+ + {/* Card + balloons */} +
+ {/* Cabin card */} + {selectedCabinName && cabinsByName[selectedCabinName] && ( +
+ +
+ )} + + {/* Balloons */} + {isDesktop && ( + + )} + {isTablet && ( + + )} +
+ + +
+ + ); +} diff --git a/apps/live/src/app/(landing)/CabinRace/CabinTypes.tsx b/apps/live/src/app/(landing)/CabinRace/CabinTypes.tsx new file mode 100644 index 00000000..80af2f13 --- /dev/null +++ b/apps/live/src/app/(landing)/CabinRace/CabinTypes.tsx @@ -0,0 +1,13 @@ +// types +export interface CabinLead { + name: string; + src: string; + url: string; +} + +export interface Cabin { + name: string; + points: number; + description: string; + cabinLeads: CabinLead[]; +} diff --git a/apps/live/src/app/(landing)/page.tsx b/apps/live/src/app/(landing)/page.tsx index 7a8182bd..5d5327d6 100644 --- a/apps/live/src/app/(landing)/page.tsx +++ b/apps/live/src/app/(landing)/page.tsx @@ -11,6 +11,8 @@ import Footer from "@repo/ui/Footer"; // import ComingUp from "./ComingUp/ComingUp"; import OurTeam from "./OurTeam"; import Keynote from "./Keynote"; +import CabinRace from "./CabinRace/CabinRace.tsx"; + import SponsorFeature from "./SponsorFeature.tsx"; import CarnivalScene from "./CarnivalLanding/CarnivalScene.tsx"; export default function Page(): JSX.Element { @@ -24,6 +26,7 @@ export default function Page(): JSX.Element { + diff --git a/apps/live/src/app/api/cabinLeads/route.ts b/apps/live/src/app/api/cabinLeads/route.ts new file mode 100644 index 00000000..fe8febcd --- /dev/null +++ b/apps/live/src/app/api/cabinLeads/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BASE_URL = "https://api.airtable.com/v0"; +const TABLE_NAME = "CabinLead"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const ids = searchParams.get("ids"); + + if (!ids) { + return NextResponse.json({ records: [] }); + } + + const CABIN_BASE_ID = process.env.CABIN_BASE_ID; + + const filterFormula = `OR(${ids + .split(",") + .map((id) => `RECORD_ID()='${id}'`) + .join(",")})`; + + const airtableUrl = `${BASE_URL}/${CABIN_BASE_ID}/${TABLE_NAME}?filterByFormula=${encodeURIComponent( + filterFormula, + )}`; + + try { + const response = await fetch(airtableUrl, { + headers: { + Authorization: `Bearer ${process.env.AIRTABLE_TOKEN_ID}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("API request failed"); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json( + { error: `Request to get cabin leads failed ${err}` }, + { status: 500 }, + ); + } +} diff --git a/apps/live/src/app/components/CabinRaceComponents/CabinRaceCard.tsx b/apps/live/src/app/components/CabinRaceComponents/CabinRaceCard.tsx new file mode 100644 index 00000000..03acfbdd --- /dev/null +++ b/apps/live/src/app/components/CabinRaceComponents/CabinRaceCard.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; +import { Cabin } from "../../(landing)/CabinRace/CabinTypes.tsx"; +import useDevice from "@util/hooks/useDevice.ts"; +import { ProjectStarIcon } from "main/src/app/lib/Assets/SVG"; +import Icon from "@repo/ui/Icons/MemberIcon"; + +interface CabinCardProps { + cabinInfo: Cabin; +} + +const CabinCard: React.FC = ({ cabinInfo, ...props }) => { + const { isDesktop, isTablet, isMobile } = useDevice(); + const cabinLeads = cabinInfo.cabinLeads; + + // Determine SVG dimensions + const svgDims = isDesktop + ? { width: 665, height: 476 } + : isTablet + ? { width: 405, height: 387 } + : { width: 293, height: 430 }; + + return ( +
+ {/* SVG background */} + + {isDesktop && ( + + + + + )} + {isTablet && ( + + )} + {isMobile && ( + + + + + )} + + + {/* Overlay content absolutely inside SVG bounds */} +
+
+
+ +

+ {cabinInfo.name} +

+
+ +

+ {cabinInfo.points} points +

+

+ {cabinInfo.description} +

+
+

+ {" "} + Cabin Leaders +

+
+ {cabinLeads.map((lead) => ( + + ))} +
+
+
+ ); +}; + +export default CabinCard; diff --git a/apps/live/src/app/components/CabinRaceComponents/CabinRaceScoreTent.tsx b/apps/live/src/app/components/CabinRaceComponents/CabinRaceScoreTent.tsx new file mode 100644 index 00000000..deaf7dbd --- /dev/null +++ b/apps/live/src/app/components/CabinRaceComponents/CabinRaceScoreTent.tsx @@ -0,0 +1,84 @@ +import { Cabin } from "../../(landing)/CabinRace/CabinTypes.tsx"; +import * as React from "react"; +import { CabinRaceTent, CabinRaceYellowAirBalloon } from "../../lib/Assets/SVG"; +import { ProjectStarIcon } from "main/src/app/lib/Assets/SVG"; +import ProgressBar from "./ProgressBar.tsx"; +import { CabinRaceHotAirBalloonTheme } from "../../lib/Assets/SVG/CabinRace/MiniHotAirBalloons/CabinRaceHotAirBalloonColors.tsx"; +import useDevice from "@util/hooks/useDevice.ts"; +import CabinRaceBush from "../../lib/Assets/SVG/CabinRace/CabinRaceBush.tsx"; + +type CabinSummary = Pick; + +const BALLOON_THEMES: CabinRaceHotAirBalloonTheme[] = [ + "blue", + "green", + "yellow", + "purple", + "white", + "red", +]; + +interface CabinRaceScoreTentProps { + cabinInfo: CabinSummary[]; +} + +const CabinRaceScoreTent: React.FC = ({ + cabinInfo, +}) => { + const maxPoints = 1000; + const { isMobile, isTablet, isDesktop } = useDevice(); + + return ( +
+ + {!isMobile && ( + + )} + +
+ {cabinInfo.map((cabin, index) => ( +
+ +
+

+ {cabin.name} +

+

+ {cabin.points} +

+
+ +
+ ))} +
+
+ ); +}; + +export default CabinRaceScoreTent; diff --git a/apps/live/src/app/components/CabinRaceComponents/ProgressBar.tsx b/apps/live/src/app/components/CabinRaceComponents/ProgressBar.tsx new file mode 100644 index 00000000..46a60683 --- /dev/null +++ b/apps/live/src/app/components/CabinRaceComponents/ProgressBar.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { CabinRaceMiniHotAirBalloon } from "../../lib/Assets/SVG"; +import { CabinRaceHotAirBalloonTheme } from "../../lib/Assets/SVG/CabinRace/MiniHotAirBalloons/CabinRaceHotAirBalloonColors.tsx"; +import useDevice from "@util/hooks/useDevice.ts"; + +interface ProgressBarProps { + current: number; + max: number; + color: CabinRaceHotAirBalloonTheme; +} + +const ProgressBar: React.FC = ({ current, max, color }) => { + const percent = Math.min((current / max) * 100, 100); + const { isMobile } = useDevice(); + return ( +
+ {/* Bar background */} +
+ {/* Fill */} +
+
+ + {/* Balloon */} + +
+ ); +}; + +export default ProgressBar; diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceBaseBooth.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceBaseBooth.tsx new file mode 100644 index 00000000..77cdee70 --- /dev/null +++ b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceBaseBooth.tsx @@ -0,0 +1,640 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const CabinRaceBaseBooth = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default CabinRaceBaseBooth; diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceBush.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceBush.tsx new file mode 100644 index 00000000..6b787c06 --- /dev/null +++ b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceBush.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const CabinRaceBush = (props: SVGProps) => ( + + + + + + +); +export default CabinRaceBush; diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceProgressBar.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceProgressBar.tsx new file mode 100644 index 00000000..e69de29b diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceRedAirBalloon.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceRedAirBalloon.tsx new file mode 100644 index 00000000..8e108d43 --- /dev/null +++ b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceRedAirBalloon.tsx @@ -0,0 +1,290 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const CabinRaceRedAirBalloon = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default CabinRaceRedAirBalloon; diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceSquiggle.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceSquiggle.tsx new file mode 100644 index 00000000..088c21f9 --- /dev/null +++ b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceSquiggle.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const SVGComponent = (props: SVGProps) => ( + + + + +); +export default SVGComponent; diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceTent.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceTent.tsx new file mode 100644 index 00000000..fa5588fa --- /dev/null +++ b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceTent.tsx @@ -0,0 +1,651 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const CabinRaceTent = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default CabinRaceTent; diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceYellowAirBalloon.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceYellowAirBalloon.tsx new file mode 100644 index 00000000..8671be19 --- /dev/null +++ b/apps/live/src/app/lib/Assets/SVG/CabinRace/CabinRaceYellowAirBalloon.tsx @@ -0,0 +1,282 @@ +import * as React from "react"; +import { SVGProps } from "react"; +const CabinRaceYellowAirBalloon = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default CabinRaceYellowAirBalloon; diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/MiniHotAirBalloons/CabinRaceHotAirBalloonColors.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/MiniHotAirBalloons/CabinRaceHotAirBalloonColors.tsx new file mode 100644 index 00000000..3d8a8b93 --- /dev/null +++ b/apps/live/src/app/lib/Assets/SVG/CabinRace/MiniHotAirBalloons/CabinRaceHotAirBalloonColors.tsx @@ -0,0 +1,29 @@ +export const CABIN_RACE_BALLOON_COLORS = { + blue: { + primaryColor: "#2563eb", + secondaryColor: "#93c5fd", + }, + green: { + primaryColor: "#16a34a", + secondaryColor: "#86efac", + }, + yellow: { + primaryColor: "#e74c3c", + secondaryColor: "#e74c3c", + }, + purple: { + primaryColor: "#e74c3c", + secondaryColor: "#e74c3c", + }, + white: { + primaryColor: "#e74c3c", + secondaryColor: "#e74c3c", + }, + red: { + primaryColor: "#e74c3c", + secondaryColor: "#e74c3c", + }, +} as const; + +export type CabinRaceHotAirBalloonTheme = + keyof typeof CABIN_RACE_BALLOON_COLORS; diff --git a/apps/live/src/app/lib/Assets/SVG/CabinRace/MiniHotAirBalloons/CabinRaceMiniHotAirBalloon.tsx b/apps/live/src/app/lib/Assets/SVG/CabinRace/MiniHotAirBalloons/CabinRaceMiniHotAirBalloon.tsx new file mode 100644 index 00000000..cf4cde7e --- /dev/null +++ b/apps/live/src/app/lib/Assets/SVG/CabinRace/MiniHotAirBalloons/CabinRaceMiniHotAirBalloon.tsx @@ -0,0 +1,152 @@ +import * as React from "react"; +import { SVGProps } from "react"; +import { + CABIN_RACE_BALLOON_COLORS, + CabinRaceHotAirBalloonTheme, +} from "./CabinRaceHotAirBalloonColors"; +import useDevice from "@util/hooks/useDevice.ts"; + +type SVGComponentProps = SVGProps & { + theme?: CabinRaceHotAirBalloonTheme; +}; + +const SVGComponent = ({ theme = "blue", ...props }: SVGComponentProps) => { + const { primaryColor, secondaryColor } = CABIN_RACE_BALLOON_COLORS[theme]; + const { isMobile } = useDevice(); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default SVGComponent; diff --git a/apps/live/src/app/lib/Assets/SVG/index.ts b/apps/live/src/app/lib/Assets/SVG/index.ts index e6266c2d..8a4f11e8 100644 --- a/apps/live/src/app/lib/Assets/SVG/index.ts +++ b/apps/live/src/app/lib/Assets/SVG/index.ts @@ -2,3 +2,10 @@ export { default as InfoCardCottonCandy } from "./InfoCard/InfoCardCottonCandy.t export { default as InfoCardHotDogBag } from "./InfoCard/InfoCardHotDogBag.tsx"; export { default as InfoCardPopcorn } from "./InfoCard/InfoCardPopcorn.tsx"; export { default as InfoCardIceCream } from "./InfoCard/InfoCardIceCream.tsx"; + +export { default as CabinRaceBaseBooth } from "./CabinRace/CabinRaceBaseBooth.tsx"; +export { default as CabinRaceRedAirBalloon } from "./CabinRace/CabinRaceRedAirBalloon.tsx"; +export { default as CabinRaceSquiggle } from "./CabinRace/CabinRaceSquiggle.tsx"; +export { default as CabinRaceYellowAirBalloon } from "./CabinRace/CabinRaceYellowAirBalloon.tsx"; +export { default as CabinRaceMiniHotAirBalloon } from "./CabinRace/MiniHotAirBalloons/CabinRaceMiniHotAirBalloon.tsx"; +export { default as CabinRaceTent } from "./CabinRace/CabinRaceTent.tsx"; diff --git a/packages/ui/src/Icons/MemberIcon.tsx b/packages/ui/src/Icons/MemberIcon.tsx index 45e3843e..1de25bde 100644 --- a/packages/ui/src/Icons/MemberIcon.tsx +++ b/packages/ui/src/Icons/MemberIcon.tsx @@ -11,6 +11,7 @@ type IconProps = { url?: string; isLive: boolean; isActive: boolean; + size?: number; textColor?: string; }; @@ -20,11 +21,15 @@ const Icon: React.FC = ({ url, isLive = false, isActive = false, + size = 160, textColor = "charcoalFog", }) => { return (
-
+