diff --git a/apps/site/src/app/(home)/page.tsx b/apps/site/src/app/(home)/page.tsx
index 6b731b05..10c7cf66 100644
--- a/apps/site/src/app/(home)/page.tsx
+++ b/apps/site/src/app/(home)/page.tsx
@@ -7,12 +7,14 @@ import FAQ from "./sections/FAQ";
import Clubs from "./sections/Clubs/Clubs";
import styles from "./page.module.scss";
+import Countdown from "./sections/Countdown";
const Home = () => {
return (
+
diff --git a/apps/site/src/app/(home)/sections/Countdown/Countdown.module.scss b/apps/site/src/app/(home)/sections/Countdown/Countdown.module.scss
new file mode 100644
index 00000000..9f033ed6
--- /dev/null
+++ b/apps/site/src/app/(home)/sections/Countdown/Countdown.module.scss
@@ -0,0 +1,133 @@
+@use "bootstrap-utils" as bootstrap;
+@use "zothacks-theme" as theme;
+
+.paraboloid {
+ position: relative;
+ width: 100%;
+}
+
+.countdownWrapper {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ position: relative;
+}
+
+.countdownText {
+ top: 45px;
+ left: 20px;
+ position: absolute;
+ min-width: 150px;
+ transform: translateX(-50%);
+ text-align: center;
+}
+
+.countdownMaterial {
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+@media only screen and (max-width: 400px) {
+ .clockPos {
+ margin-top: 45%;
+ }
+ .preCountdown {
+ margin-top: 50%;
+ }
+ .endText {
+ margin-top: 50%;
+ }
+}
+
+@media only screen and (min-width: 401px) and (max-width: 600px) {
+ .clockPos {
+ margin-top: 30%;
+ }
+ .preCountdown {
+ margin-top: 40%;
+ }
+ .endText {
+ margin-top: 40%;
+ }
+}
+
+@media only screen and (min-width: 601px) and (max-width: 1000px) {
+ .clockPos {
+ margin-top: 20%;
+ }
+ .preCountdown {
+ margin-top: 20%;
+ }
+ .endText {
+ margin-top: 20%;
+ }
+}
+
+@media only screen and (min-width: 1001px) {
+ .preCountdown {
+ margin-top: 15%;
+ }
+ .endText {
+ margin-top: 10%;
+ }
+}
+
+.clockPos {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
+
+.boat {
+ position: absolute;
+ transform: translate(-50%, 40%);
+}
+
+.outerCircle {
+ background-color: theme.$white;
+ border: 5px solid #1a1840;
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1;
+ bottom: 0;
+ position: absolute;
+ bottom: -20px;
+}
+
+.innerCircle {
+ background-color: #bd5a5a;
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+}
+
+.countdownTextTop {
+ position: absolute;
+ top: -170px;
+ width: 150px;
+ height: 170px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+ p {
+ margin: 0;
+ padding: 0;
+ }
+}
+
+.progressContainer {
+ height: 200px;
+ width: 60%;
+}
diff --git a/apps/site/src/app/(home)/sections/Countdown/Countdown.tsx b/apps/site/src/app/(home)/sections/Countdown/Countdown.tsx
new file mode 100644
index 00000000..45c25d82
--- /dev/null
+++ b/apps/site/src/app/(home)/sections/Countdown/Countdown.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useState } from "react";
+
+import useWindow from "./useWindow";
+import CountdownClock from "./CountdownClock";
+
+import bg_map from "@/assets/images/maps/countdown.svg";
+import boat from "@/assets/icons/boat.svg";
+
+import styles from "./Countdown.module.scss";
+
+interface CountdownProps {
+ schedule: {
+ title: string;
+ location?: string | undefined;
+ virtual?: string | undefined;
+ startTime: Date;
+ endTime: Date;
+ organization?: string | undefined;
+ hosts?: string[] | undefined;
+ description: any;
+ }[];
+}
+
+const Countdown: React.FC
= ({ schedule }) => {
+ const hackStartTime = new Date("2024-11-02T10:00:00"); // TBD, zothacks start time
+ const hackEndTime = new Date("2024-11-02T22:00:00"); // TBD, zothacks end time
+
+ const [curTime, setCurTime] = useState(new Date());
+
+ useEffect(() => {
+ const i = setInterval(() => {
+ setCurTime(new Date());
+ }, 1000);
+
+ return () => clearInterval(i);
+ }, []);
+
+ const ended = schedule.filter((el) => el.endTime >= curTime);
+
+ const before =
+ ended.length > 0
+ ? ended[0]
+ : {
+ title: "That's a wrap~",
+ location: "",
+ startTime: new Date(0),
+ endTime: new Date(0),
+ };
+
+ const after =
+ ended.length > 1
+ ? ended[1]
+ : {
+ title: "That's a wrap~",
+ location: "",
+ startTime: new Date(0),
+ endTime: new Date(0),
+ };
+
+ const percentageCrossed =
+ before.endTime.getTime() > 0
+ ? curTime.getTime() < before.startTime.getTime()
+ ? 0
+ : 100 -
+ ((before.endTime.getTime() - curTime.getTime()) /
+ (before.endTime.getTime() - before.startTime.getTime())) *
+ 100
+ : 100;
+
+ const [w] = useWindow();
+
+ const totalLines = Math.floor(w / 66) > 7 ? Math.floor(w / 66) : 7;
+
+ const totals = Array(totalLines + 1).fill(0);
+
+ function returnPosition(num: number) {
+ // this is the parabola -(0.2x - 1.5)^2 + 2.25
+ let step = (20 / totalLines) * num;
+ let prop_y = (-((0.15 * step - 1.5) * (0.15 * step - 1.5)) + 2.25) / 2.25;
+ return [`${step * 5}%`, `${prop_y * 100}%`];
+ }
+
+ function returnRotation(num: number) {
+ // this is the derivative of the aforementioned parabola turned into degrees of rotation
+ let step = (15 / totalLines) * num;
+ return Math.atan(-(2 * step - 15) / 25);
+ }
+
+ if (curTime > hackEndTime) {
+ return (
+
+
+
+
Hacking has ended!
+
+
+ );
+ }
+
+ return (
+
+
+ {w > 0 && (
+
+ {curTime >= hackStartTime ? (
+
+
+ {totals.map((_, i) => (
+
percentageCrossed / 100 ? "#DB9F42" : "#78cae3"}`,
+ width: "18px",
+ height: "5px",
+ borderRadius: "6px",
+ transform: `rotate(${-((returnRotation(i) * 180) / Math.PI)}deg)`,
+ }}
+ >
+ ))}
+
+
+ {before.location && w <= 800 ? (
+
{before.location}
+ ) : null}
+ {before.startTime.getTime() && w <= 800 ? (
+
{`${before.startTime.getHours() % 12 == 0 ? 12 : before.startTime.getHours() % 12}${before.startTime.getHours() == 11 ? " am" : ""}-${before.endTime.getHours() % 12 == 0 ? 12 : before.endTime.getHours() % 12} ${before.endTime.getHours() < 12 ? "am" : "pm"}`}
+ ) : null}
+
+
+
+
{before.title}
+ {before.location && w > 800 ? (
+
{before.location}
+ ) : null}
+ {before.startTime.getTime() && w > 800 ? (
+
{`${before.startTime.getHours() % 12 == 0 ? 12 : before.startTime.getHours() % 12}${before.startTime.getHours() == 11 ? " am" : ""}-${before.endTime.getHours() % 12 == 0 ? 12 : before.endTime.getHours() % 12} ${before.endTime.getHours() < 12 ? "am" : "pm"}`}
+ ) : null}
+
+
+
+
+
+ {after.location && w <= 800 ? (
+
{after.location}
+ ) : null}
+ {after.startTime.getTime() && w <= 800 ? (
+
{`${after.startTime.getHours() % 12 == 0 ? 12 : after.startTime.getHours() % 12}${after.startTime.getHours() == 11 ? " am" : ""}-${after.endTime.getHours() % 12 == 0 ? 12 : after.endTime.getHours() % 12} ${after.endTime.getHours() < 12 ? "am" : "pm"}`}
+ ) : null}
+
+
+
+
{after.title}
+ {after.location && w > 800 ?
{after.location}
: null}
+ {after.startTime.getTime() && w > 800 ? (
+
{`${after.startTime.getHours() % 12 == 0 ? 12 : after.startTime.getHours() % 12}${after.startTime.getHours() == 11 ? " am" : ""}-${after.endTime.getHours() % 12 == 0 ? 12 : after.endTime.getHours() % 12} ${after.endTime.getHours() < 12 ? "am" : "pm"}`}
+ ) : null}
+
+
+
+
+
+
+
+ hackStartTime ? hackEndTime : hackStartTime
+ }
+ isHackingStarted={curTime >= hackStartTime}
+ />
+
+
+ ) : (
+
+ hackStartTime ? hackEndTime : hackStartTime
+ }
+ isHackingStarted={curTime >= hackStartTime}
+ />
+
+ )}
+
+ )}
+
+ );
+};
+
+export default Countdown;
diff --git a/apps/site/src/app/(home)/sections/Countdown/CountdownClock.module.scss b/apps/site/src/app/(home)/sections/Countdown/CountdownClock.module.scss
new file mode 100644
index 00000000..ae02935a
--- /dev/null
+++ b/apps/site/src/app/(home)/sections/Countdown/CountdownClock.module.scss
@@ -0,0 +1,76 @@
+@use "bootstrap-utils" as bootstrap;
+
+.countdown {
+ margin: 0;
+ opacity: 0;
+ transition: opacity 225ms cubic-bezier(0.32, 0, 0.67, 0);
+
+ span {
+ text-align: center;
+
+ @media screen and (max-width: 1000px) {
+ &.time {
+ gap: 10px 2rem;
+ }
+ }
+
+ @media screen and (min-width: 1001px) {
+ &.time {
+ gap: 10px 3rem;
+ }
+ }
+
+ &.time {
+ display: flex;
+ flex-wrap: wrap;
+ @include bootstrap.font-size(6rem);
+ line-height: 100%;
+ justify-content: center;
+ min-width: 260px;
+
+ .timePortion {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 110px;
+ }
+
+ .timeBlock {
+ display: flex;
+ gap: 10px;
+ }
+
+ .timeText {
+ font-size: 22px;
+ width: 100%;
+ text-align: center;
+ }
+
+ .number {
+ font-weight: 600;
+ display: inline-block;
+ text-align: center;
+ background: #efc588;
+ font-size: 60px;
+ padding: 5px;
+ min-width: 50px;
+ border-radius: 7px;
+ min-height: 30px;
+ }
+
+ .colon {
+ opacity: 0.75;
+ }
+ }
+
+ &.caption {
+ display: block;
+ @include bootstrap.font-size(2.5rem);
+ font-weight: bold;
+ }
+ }
+}
+
+.loaded {
+ opacity: 1;
+}
diff --git a/apps/site/src/app/(home)/sections/Countdown/CountdownClock.tsx b/apps/site/src/app/(home)/sections/Countdown/CountdownClock.tsx
new file mode 100644
index 00000000..6f09bd70
--- /dev/null
+++ b/apps/site/src/app/(home)/sections/Countdown/CountdownClock.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+
+import clsx from "clsx";
+import styles from "./CountdownClock.module.scss";
+
+interface CountdownProps {
+ countdownTo: Date;
+ isHackingStarted: boolean;
+}
+
+const CountdownClock: React.FC = ({
+ countdownTo,
+ isHackingStarted,
+}) => {
+ const [remainingSeconds, setRemainingSeconds] = useState(NaN);
+
+ useEffect(() => {
+ setRemainingSeconds(
+ Math.max(0, (countdownTo.valueOf() - new Date().valueOf()) / 1000),
+ );
+ const interval = setInterval(() => {
+ setRemainingSeconds((r) => Math.max(0, r - 1));
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [countdownTo]);
+
+ return (
+
+
+ {!isHackingStarted && (
+
+
+ {Math.floor(remainingSeconds / (60 * 60 * 24))
+ .toString()
+ .padStart(2, "0")
+ .split("")
+ .map((el, i) => {
+ return (
+
+ {el}
+
+ );
+ })}
+
+ Days
+
+ )}
+
+
+ {isHackingStarted ? (
+ <>
+ {Math.floor(remainingSeconds / (60 * 60))
+ .toString()
+ .split("")
+ .map((el, i) => {
+ return (
+
+ {el}
+
+ );
+ })}
+ >
+ ) : (
+ <>
+
+ {`${Math.floor((remainingSeconds / 3600) % 24) < 10 ? "0" : Math.floor(Math.floor((remainingSeconds / 3600) % 24) / 10)}`}
+
+
+ {`${(Math.floor((remainingSeconds / 3600) % 24) % 10).toString()}`}
+
+ >
+ )}
+
+ Hours
+
+
+
+
+ {`${Math.floor((remainingSeconds / 60) % 60) < 10 ? "0" : Math.floor(((remainingSeconds / 60) % 60) / 10)}`}
+
+
+ {`${Math.floor((remainingSeconds / 60) % 60) % 10}`}
+
+
+ Minutes
+
+ {isHackingStarted && (
+
+
+
+ {`${Math.floor(remainingSeconds % 60) < 10 ? "0" : Math.floor((remainingSeconds % 60) / 10)}`}
+
+
+ {`${Math.floor(remainingSeconds % 60) % 10}`}
+
+
+ Seconds
+
+ )}
+
+
+ {isHackingStarted && !isNaN(remainingSeconds)
+ ? "Until Hacking Ends"
+ : "Until Hacking Begins"}
+
+
+ );
+};
+
+export default CountdownClock;
diff --git a/apps/site/src/app/(home)/sections/Countdown/Scheduling.tsx b/apps/site/src/app/(home)/sections/Countdown/Scheduling.tsx
new file mode 100644
index 00000000..532b4b8e
--- /dev/null
+++ b/apps/site/src/app/(home)/sections/Countdown/Scheduling.tsx
@@ -0,0 +1,18 @@
+import Countdown from "./Countdown";
+import { getSchedule } from "./getSchedule";
+
+export default async function CountdownSchedule() {
+ const schedule = (await getSchedule()).flat().map((el) => {
+ return {
+ title: el.title,
+ startTime: el.startTime,
+ endTime: el.endTime,
+ location: el.location,
+ virtual: el.virtual,
+ organization: el.organization,
+ hosts: el.hosts,
+ description: el.description,
+ };
+ });
+ return ;
+}
diff --git a/apps/site/src/app/(home)/sections/Countdown/getSchedule.ts b/apps/site/src/app/(home)/sections/Countdown/getSchedule.ts
new file mode 100644
index 00000000..52a11c98
--- /dev/null
+++ b/apps/site/src/app/(home)/sections/Countdown/getSchedule.ts
@@ -0,0 +1,64 @@
+import { z } from "zod";
+import { cache } from "react";
+import { client } from "@/lib/sanity/client";
+import { SanityDocument } from "@/lib/sanity/types";
+import { formatInTimeZone } from "date-fns-tz";
+
+const Events = z.array(
+ SanityDocument.extend({
+ _type: z.literal("event"),
+ title: z.string(),
+ location: z.string().optional(),
+ virtual: z.string().url().optional(),
+ startTime: z
+ .string()
+ .datetime()
+ .transform((time) => new Date(time)),
+ endTime: z
+ .string()
+ .datetime()
+ .transform((time) => new Date(time)),
+ organization: z.string().optional(),
+ hosts: z.array(z.string()).optional(),
+ description: z.array(
+ z.object({
+ _key: z.string(),
+ markDefs: z.array(
+ z.object({
+ _type: z.string(),
+ href: z.optional(z.string()),
+ _key: z.string(),
+ }),
+ ),
+ children: z.array(
+ z.object({
+ text: z.string(),
+ _key: z.string(),
+ _type: z.literal("span"),
+ marks: z.array(z.string()),
+ }),
+ ),
+ _type: z.literal("block"),
+ style: z.literal("normal"),
+ }),
+ ),
+ }),
+);
+
+export const getSchedule = cache(async () => {
+ const events = Events.parse(
+ await client.fetch("*[_type == 'event'] | order(startTime asc)"),
+ );
+ const eventsByDay = new Map>();
+
+ events.forEach((event) => {
+ const key = formatInTimeZone(
+ new Date(event.startTime),
+ "America/Los_Angeles",
+ "MM/dd/yyyy",
+ );
+ eventsByDay.set(key, [...(eventsByDay.get(key) ?? []), event]);
+ });
+
+ return Array.from(eventsByDay.values());
+});
diff --git a/apps/site/src/app/(home)/sections/Countdown/index.ts b/apps/site/src/app/(home)/sections/Countdown/index.ts
new file mode 100644
index 00000000..a380a4b8
--- /dev/null
+++ b/apps/site/src/app/(home)/sections/Countdown/index.ts
@@ -0,0 +1 @@
+export { default } from "./Scheduling";
diff --git a/apps/site/src/app/(home)/sections/Countdown/useWindow.tsx b/apps/site/src/app/(home)/sections/Countdown/useWindow.tsx
new file mode 100644
index 00000000..d67fa2d9
--- /dev/null
+++ b/apps/site/src/app/(home)/sections/Countdown/useWindow.tsx
@@ -0,0 +1,14 @@
+import { useEffect, useState } from "react";
+
+export default function useWindow() {
+ const [size, setSize] = useState([0, 0]);
+ useEffect(() => {
+ function setWindowSize() {
+ setSize([window.innerWidth, window.innerHeight]);
+ }
+ setWindowSize();
+ window.addEventListener("resize", setWindowSize);
+ return () => window.removeEventListener("resize", setWindowSize);
+ }, []);
+ return size;
+}
diff --git a/apps/site/src/assets/icons/boat.svg b/apps/site/src/assets/icons/boat.svg
new file mode 100644
index 00000000..5e52dd32
--- /dev/null
+++ b/apps/site/src/assets/icons/boat.svg
@@ -0,0 +1,15 @@
+
diff --git a/apps/site/src/assets/images/maps/countdown.svg b/apps/site/src/assets/images/maps/countdown.svg
new file mode 100644
index 00000000..7aa12673
--- /dev/null
+++ b/apps/site/src/assets/images/maps/countdown.svg
@@ -0,0 +1,17 @@
+