diff --git a/app/(dashboard)/schedule/page.tsx b/app/(dashboard)/schedule/page.tsx
index 85bdc73..6134caa 100644
--- a/app/(dashboard)/schedule/page.tsx
+++ b/app/(dashboard)/schedule/page.tsx
@@ -5,8 +5,7 @@ import { getCurrentUser } from "@/auth";
import { ScheduleService } from "@/lib/services/schedule";
import { EmptyPage } from "@/components/EmptyPage";
import PageWrapper from "@/components/PageWrapper";
-import ScheduleGrid from "@/components/schedule/ScheduleGrid";
-import ScheduleLegend from "@/components/schedule/ScheduleLegend";
+import ScheduleView from "@/components/schedule/ScheduleView";
export const metadata: Metadata = {
title: "Schedule",
@@ -29,14 +28,12 @@ export default async function SchedulePage() {
);
}
- // Get schedule data with fallback mechanism
const {
data: scheduleData,
source,
error,
} = await ScheduleService.getSchedule();
- // Log the data source for debugging
console.info(`Schedule loaded from: ${source}`);
if (error) {
console.error("Schedule service error:", error);
@@ -44,22 +41,7 @@ export default async function SchedulePage() {
return (
-
-
-
-
Event Schedule
-
-
- All times are in Eastern Time (ET). Events and times are subject to
- change.
-
-
-
-
-
-
-
-
+
);
}
diff --git a/app/(dashboard)/shops/page.tsx b/app/(dashboard)/shops/page.tsx
new file mode 100644
index 0000000..eaa5d04
--- /dev/null
+++ b/app/(dashboard)/shops/page.tsx
@@ -0,0 +1,119 @@
+import { redirect } from "next/navigation";
+import { getCurrentUser } from "@/auth";
+import { eq } from "drizzle-orm";
+
+import { db } from "@/lib/db";
+import { shopItems, userBalance } from "@/lib/db/schema";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { EmptyPage } from "@/components/EmptyPage";
+
+export default async function ShopsPage() {
+ const currentUser = await getCurrentUser();
+
+ if (!currentUser?.id) {
+ redirect("/sign-in");
+ }
+
+ if (currentUser.role === "unassigned") {
+ return (
+
+ );
+ }
+
+ const items = await db.select().from(shopItems);
+
+ const balance = await db
+ .select()
+ .from(userBalance)
+ .where(eq(userBalance.userId, currentUser.id));
+
+ return (
+
+
+
Shop
+
+ Points: {balance.length > 0 ? balance[0].points : 0}
+
+
+
+ {items.length === 0 ? (
+
+
No items available
+
Check back later for new rewards!
+
+ ) : (
+
+ {items.map((item) => (
+
+ {item.image ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ) : (
+
+ No Image
+
+ )}
+
+
+ {item.itemName}
+
+ {item.itemDescription && (
+
+ {item.itemDescription}
+
+ )}
+
+
+ {item.purchasePrice}{" "}
+
+ pts
+
+
+
+
+
+ Purchase
+
+
+
+
+ Talk to an Organizer
+
+ To purchase the{" "}
+
+ {item.itemName}
+
+ , please find an organizer to deduct your points and
+ claim your item!
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/(dashboard)/shops/seed.ts b/app/(dashboard)/shops/seed.ts
new file mode 100644
index 0000000..ab1cecf
--- /dev/null
+++ b/app/(dashboard)/shops/seed.ts
@@ -0,0 +1,102 @@
+import { loadEnvConfig } from "@next/env";
+
+loadEnvConfig(process.cwd());
+
+const dummyItems = [
+ {
+ itemName: "Additional Stickers",
+ itemDescription: "Personalize your gear with extra high-quality stickers.",
+ purchasePrice: 25,
+ stock: undefined,
+ },
+ {
+ itemName: "Peppero",
+ itemDescription:
+ "Crunchy biscuit sticks dipped in delicious chocolate coating.",
+ purchasePrice: 25,
+ stock: 36,
+ image:
+ "https://haisue.ca/cdn/shop/files/Lotte-Pepero-White-Cookie-NEW.jpg?v=1743470116&width=1214",
+ },
+ {
+ itemName: "Monster Energy Drinks",
+ itemDescription: "Fuel your grind and keep the caffeine levels high.",
+ purchasePrice: 25,
+ stock: 73,
+ image:
+ "https://voila.ca/images-v3/2d92d19c-0354-49c0-8a91-5260ed0bf531/6544fd59-97a2-4101-b813-11c9b34b3a6e/500x500.jpg",
+ },
+ {
+ itemName: "Full size candy bar",
+ itemDescription: "A substantial sugar boost for those late-night sessions.",
+ purchasePrice: 25,
+ stock: 24,
+ },
+ {
+ itemName: "GIGANTIC Maple Syrup",
+ itemDescription: "A massive bottle of liquid gold. Only one available!",
+ purchasePrice: 25,
+ stock: 1,
+ image:
+ "https://cloudinary.images-iherb.com/image/upload/f_auto,q_auto:eco/images/now/now06948/y/57.jpg",
+ },
+ {
+ itemName: "Microwave Popcorn",
+ itemDescription: "The classic movie snack, perfect for a quick break.",
+ purchasePrice: 25,
+ stock: 44,
+ image:
+ "https://mydormstore.ca/cdn/shop/files/microwave-popcorn-8264831.png?v=1758608146",
+ },
+ {
+ itemName: "Fruit Roll Ups",
+ itemDescription: "Sweet, stretchy, and nostalgic.",
+ purchasePrice: 25,
+ stock: 210,
+ image:
+ "https://hips.hearstapps.com/vidthumb/images/delish-watermelon-fruit-roll-ups-still002-1536587662.jpg?crop=0.75xw:1xh;center,top&resize=1200:*",
+ },
+ {
+ itemName: "Sponsor Swag",
+ itemDescription: "Exclusive gear provided by our amazing event partners.",
+ purchasePrice: 150,
+ stock: undefined,
+ },
+ {
+ itemName: "Special Plushie",
+ itemDescription:
+ "A soft, cuddly companion to keep you company at your desk.",
+ purchasePrice: 300,
+ stock: 15,
+ },
+ {
+ itemName: "Camera Lego Set",
+ itemDescription:
+ "Build your own vintage-style camera with this detailed brick set.",
+ purchasePrice: 450,
+ stock: 5,
+ image: "https://toynado.ca/cdn/shop/files/31147b_grande.jpg?v=1732563639",
+ },
+];
+
+async function seed() {
+ const { db } = await import("../../../lib/db/index");
+ const { shopItems } = await import("../../../lib/db/schema");
+
+ console.log("Emptying existing shop items...");
+ await db.delete(shopItems);
+
+ console.log("Seeding dummy shop items...");
+ for (const item of dummyItems) {
+ await db.insert(shopItems).values({
+ ...item,
+ });
+ }
+
+ console.log("Seeded " + dummyItems.length + " shop items successfully!");
+ process.exit(0);
+}
+
+seed().catch((err) => {
+ console.error("Failed to seed database:", err);
+});
diff --git a/app/globals.css b/app/globals.css
index c144231..f567f63 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -219,6 +219,16 @@
}
}
+.schedule-light {
+ --color-background: #ffffff;
+ --color-backgroundMuted: #f8fafc;
+ --color-textPrimary: #1f2937;
+ --color-textSecondary: #4b5563;
+ --color-textMuted: #9ca3af;
+ --color-border: #e2e8f0;
+ --color-shadow: rgba(0, 0, 0, 0.05);
+}
+
@media (max-height: 800px) {
.sidebar-content {
@apply space-y-16;
diff --git a/components/applications/ApplicationCard.tsx b/components/applications/ApplicationCard.tsx
index 2338d7e..b006041 100644
--- a/components/applications/ApplicationCard.tsx
+++ b/components/applications/ApplicationCard.tsx
@@ -27,6 +27,8 @@ export const ApplicationCard = ({
href,
} = application;
+ const isExternalLink = href.startsWith("http");
+
return (
)}
{alreadyApplied && (
diff --git a/components/emails/ApplicationReminderEmail.tsx b/components/emails/ApplicationReminderEmail.tsx
new file mode 100644
index 0000000..32e4e0e
--- /dev/null
+++ b/components/emails/ApplicationReminderEmail.tsx
@@ -0,0 +1,287 @@
+import {
+ Body,
+ Button,
+ Container,
+ Head,
+ Heading,
+ Hr,
+ Html,
+ Link,
+ Preview,
+ Section,
+ Text,
+} from "@react-email/components";
+import { Tailwind } from "@react-email/tailwind";
+
+import { hackathonYear } from "@/config/site";
+
+interface ApplicationReminderEmailProps {
+ name: string;
+ hasDraft: boolean;
+}
+
+const ApplicationReminderEmail = ({
+ name,
+ hasDraft,
+}: ApplicationReminderEmailProps) => {
+ const previewText = hasDraft
+ ? `Hey ${name}, your Hack Canada application is saved as a draft โ just need to hit submit!`
+ : `Hey ${name}, just a friendly reminder to start your Hack Canada application.`;
+
+ return (
+
+
{previewText}
+
+
+
+
+
+
+ {hasDraft
+ ? "Your application is almost done"
+ : "Quick reminder about your application"}
+
+
+
+ Hey {name},
+
+
+ {hasDraft ? (
+ <>
+
+ We noticed you started your Hack Canada {hackathonYear}{" "}
+ hacker application but haven't submitted it yet. Your
+ progress is saved, so you can pick up right where you left
+ off.
+
+
+ It should only take a few more minutes to finish up and
+ submit.
+
+ >
+ ) : (
+ <>
+
+ You created a Hack Canada account a little while ago, but we
+ haven't received a hacker application from you yet.
+
+
+ If you're still interested in joining us for Hack
+ Canada {hackathonYear}, the application only takes about 10
+ minutes. We'd love to have you.
+
+ >
+ )}
+
+
+
+ {hasDraft ? "Finish my application" : "Go to my application"}
+
+
+
+
+ If you have any questions about the application or the event,
+ feel free to reply to this email or reach out at{" "}
+
+ hi@hackcanada.org
+
+ .
+
+
+
+ Cheers,
+
+
+ The Hack Canada Team
+
+
+
+
+
+
+
+ Website
+
+ ยท
+
+ Dashboard
+
+ ยท
+
+ Contact
+
+
+
+
+ ยฉ {hackathonYear} Hack Canada
+
+
+
+
+
+
+
+ );
+};
+
+export default ApplicationReminderEmail;
diff --git a/components/schedule/DaySelector.tsx b/components/schedule/DaySelector.tsx
index 1cf43e1..ff6e844 100644
--- a/components/schedule/DaySelector.tsx
+++ b/components/schedule/DaySelector.tsx
@@ -15,7 +15,7 @@ export function DaySelector({ selectedDay, onDayChange }: DaySelectorProps) {
className={`cursor-pointer rounded-lg px-4 py-2 font-medium transition-all ${
selectedDay === index
? "bg-primary/20 text-primary border border-primary/30"
- : "text-white/60 border border-white/10 hover:bg-white/10 hover:text-white"
+ : "text-textSecondary border border-border hover:bg-backgroundMuted hover:text-textPrimary"
}`}
>
{day}
diff --git a/components/schedule/EventsGrid.tsx b/components/schedule/EventsGrid.tsx
index 76d5c9c..b7bf52a 100644
--- a/components/schedule/EventsGrid.tsx
+++ b/components/schedule/EventsGrid.tsx
@@ -3,26 +3,31 @@ import {
EventPosition,
getEventStyle,
TIME_SLOT_HEIGHT,
+ type TimeSlot,
} from "./utils/schedule-utils";
interface EventsGridProps {
- timeSlots: string[];
+ timeSlots: TimeSlot[];
eventPositions: EventPosition[];
- selectedDay: number;
+ dayStartHour: number;
}
export function EventsGrid({
timeSlots,
eventPositions,
- selectedDay,
+ dayStartHour,
}: EventsGridProps) {
return (
-
+
{/* Time slot grid lines */}
- {timeSlots.map((time) => (
+ {timeSlots.map((slot) => (
))}
@@ -35,7 +40,7 @@ export function EventsGrid({
style={getEventStyle(
event,
{ event, column, totalColumns },
- selectedDay,
+ dayStartHour,
)}
/>
))}
diff --git a/components/schedule/ScheduleEvent.tsx b/components/schedule/ScheduleEvent.tsx
index 961fa56..0b9e73c 100644
--- a/components/schedule/ScheduleEvent.tsx
+++ b/components/schedule/ScheduleEvent.tsx
@@ -9,13 +9,22 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
+import { useScheduleLightMode } from "./schedule-theme";
-const eventTypeColors = {
- general: "bg-sky-200/75 hover:bg-sky-300",
- meals: "bg-emerald-200/75 hover:bg-emerald-300",
- ceremonies: "bg-amber-200/75 hover:bg-amber-300",
- workshops: "bg-violet-200/75 hover:bg-violet-300",
- fun: "bg-rose-200/75 hover:bg-rose-300",
+const darkEventColors = {
+ general: "bg-sky-500/40 hover:bg-sky-500/50 border-l-2 border-sky-400",
+ meals: "bg-emerald-500/40 hover:bg-emerald-500/50 border-l-2 border-emerald-400",
+ ceremonies: "bg-amber-500/40 hover:bg-amber-500/50 border-l-2 border-amber-400",
+ workshops: "bg-violet-500/40 hover:bg-violet-500/50 border-l-2 border-violet-400",
+ fun: "bg-rose-500/40 hover:bg-rose-500/50 border-l-2 border-rose-400",
+};
+
+const lightEventColors = {
+ general: "bg-sky-100 hover:bg-sky-200 border-l-2 border-sky-400 shadow-sm",
+ meals: "bg-emerald-100 hover:bg-emerald-200 border-l-2 border-emerald-400 shadow-sm",
+ ceremonies: "bg-amber-100 hover:bg-amber-200 border-l-2 border-amber-400 shadow-sm",
+ workshops: "bg-violet-100 hover:bg-violet-200 border-l-2 border-violet-400 shadow-sm",
+ fun: "bg-rose-100 hover:bg-rose-200 border-l-2 border-rose-400 shadow-sm",
};
interface ScheduleEventProps {
@@ -31,7 +40,9 @@ interface ScheduleEventProps {
}
export default function ScheduleEvent({ event, style }: ScheduleEventProps) {
- // Format time to display
+ const isLightMode = useScheduleLightMode();
+ const eventTypeColors = isLightMode ? lightEventColors : darkEventColors;
+
const startTime = new Date(event.startTime);
const endTime = new Date(event.endTime);
const formatTime = (date: Date) => {
@@ -67,7 +78,7 @@ export default function ScheduleEvent({ event, style }: ScheduleEventProps) {
{event.eventDescription && (
{event.eventDescription}
diff --git a/components/schedule/ScheduleGrid.tsx b/components/schedule/ScheduleGrid.tsx
index b4d24a5..23b93d8 100644
--- a/components/schedule/ScheduleGrid.tsx
+++ b/components/schedule/ScheduleGrid.tsx
@@ -11,6 +11,7 @@ import {
calculateEventPositions,
generateTimeSlots,
getDayEvents,
+ getDynamicDayRange,
} from "./utils/schedule-utils";
interface ScheduleGridProps {
@@ -19,8 +20,9 @@ interface ScheduleGridProps {
export default function ScheduleGrid({ schedule }: ScheduleGridProps) {
const [selectedDay, setSelectedDay] = useState(0); // 0 = Friday, 1 = Saturday, 2 = Sunday
- const timeSlots = generateTimeSlots(selectedDay);
const dayEvents = getDayEvents(schedule, selectedDay);
+ const dayRange = getDynamicDayRange(dayEvents, selectedDay);
+ const timeSlots = generateTimeSlots(dayRange);
const eventPositions = calculateEventPositions(dayEvents);
return (
@@ -38,7 +40,7 @@ export default function ScheduleGrid({ schedule }: ScheduleGridProps) {
diff --git a/components/schedule/ScheduleLegend.tsx b/components/schedule/ScheduleLegend.tsx
index fb09959..936b2fa 100644
--- a/components/schedule/ScheduleLegend.tsx
+++ b/components/schedule/ScheduleLegend.tsx
@@ -5,11 +5,11 @@ interface EventType {
}
const eventTypes: EventType[] = [
- { type: "general", label: "General", color: "bg-sky-200" },
- { type: "meals", label: "Meals", color: "bg-emerald-200" },
- { type: "ceremonies", label: "Ceremonies", color: "bg-amber-200" },
- { type: "workshops", label: "Workshops", color: "bg-violet-200" },
- { type: "fun", label: "Fun Events", color: "bg-rose-200" },
+ { type: "general", label: "General", color: "bg-sky-400" },
+ { type: "meals", label: "Meals", color: "bg-emerald-400" },
+ { type: "ceremonies", label: "Ceremonies", color: "bg-amber-400" },
+ { type: "workshops", label: "Workshops", color: "bg-violet-400" },
+ { type: "fun", label: "Fun Events", color: "bg-rose-400" },
];
export default function ScheduleLegend() {
diff --git a/components/schedule/ScheduleThemeToggle.tsx b/components/schedule/ScheduleThemeToggle.tsx
new file mode 100644
index 0000000..d6d0af1
--- /dev/null
+++ b/components/schedule/ScheduleThemeToggle.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+interface ScheduleThemeToggleProps {
+ isLightMode: boolean;
+ onToggle: () => void;
+}
+
+export function ScheduleThemeToggle({
+ isLightMode,
+ onToggle,
+}: ScheduleThemeToggleProps) {
+ return (
+
+ {/* Outer glow ring */}
+
+
+ {/* Inner glow ring */}
+
+
+ {/* Main circle */}
+
+ {/* Beaver */}
+
+ ๐ฆซ
+
+
+ {/* Sleep (dark) */}
+
+ ๐ค
+
+
+ {/* Sparkles (light) */}
+ {[
+ { top: -6, right: -4, delay: 0, size: 12 },
+ { top: 2, left: -6, delay: 120, size: 10 },
+ { bottom: -2, right: 0, delay: 240, size: 9 },
+ { bottom: -4, left: 2, delay: 360, size: 8 },
+ ].map((pos, i) => (
+
+ โจ
+
+ ))}
+
+ {/* Moon (dark) */}
+
+ ๐
+
+
+ {/* Sun (light) */}
+
+ โ๏ธ
+
+
+ {/* Star dots (dark) */}
+ {[
+ { angle: 30, dist: 24 },
+ { angle: 150, dist: 26 },
+ { angle: 260, dist: 22 },
+ ].map((dot, i) => {
+ const rad = (dot.angle * Math.PI) / 180;
+ const x = Math.cos(rad) * dot.dist;
+ const y = Math.sin(rad) * dot.dist;
+ return (
+
+ );
+ })}
+
+
+ {/* Label */}
+
+ {isLightMode ? "Light" : "Dark"}
+
+
+ );
+}
diff --git a/components/schedule/ScheduleView.tsx b/components/schedule/ScheduleView.tsx
new file mode 100644
index 0000000..2957139
--- /dev/null
+++ b/components/schedule/ScheduleView.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useState, useEffect } from "react";
+
+import type { Schedule } from "@/lib/db/schema";
+
+import { ScheduleThemeProvider } from "./schedule-theme";
+import ScheduleGrid from "./ScheduleGrid";
+import ScheduleLegend from "./ScheduleLegend";
+import { ScheduleThemeToggle } from "./ScheduleThemeToggle";
+
+interface ScheduleViewProps {
+ schedule: Schedule[];
+}
+
+const STORAGE_KEY = "schedule-theme-mode";
+
+export default function ScheduleView({ schedule }: ScheduleViewProps) {
+ const [isLightMode, setIsLightMode] = useState(() => {
+ // Only access localStorage on client-side
+ if (typeof window !== "undefined") {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ return saved === "light";
+ }
+ return false;
+ });
+
+ // Save preference to localStorage when it changes
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY, isLightMode ? "light" : "dark");
+ }, [isLightMode]);
+
+ return (
+
+
+
+
+
+
Event Schedule
+
+
+ All times are in Eastern Time (ET). Events and times are subject to
+ change.
+
+
+
setIsLightMode(!isLightMode)}
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/schedule/TimeSlotsColumn.tsx b/components/schedule/TimeSlotsColumn.tsx
index 46629d1..0b6d143 100644
--- a/components/schedule/TimeSlotsColumn.tsx
+++ b/components/schedule/TimeSlotsColumn.tsx
@@ -1,22 +1,25 @@
-import { TIME_SLOT_HEIGHT } from "./utils/schedule-utils";
+import { TIME_SLOT_HEIGHT, type TimeSlot } from "./utils/schedule-utils";
interface TimeSlotsColumnProps {
- timeSlots: string[];
+ timeSlots: TimeSlot[];
}
export function TimeSlotsColumn({ timeSlots }: TimeSlotsColumnProps) {
return (
- {timeSlots.map((time) => (
+ {timeSlots.map((slot) => (
- {/* Time label positioned under the line */}
-
- {time}
-
+ {slot.isMajor && (
+
+ {slot.label}
+
+ )}
))}
diff --git a/components/schedule/schedule-theme.tsx b/components/schedule/schedule-theme.tsx
new file mode 100644
index 0000000..e22d094
--- /dev/null
+++ b/components/schedule/schedule-theme.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+const ScheduleThemeContext = createContext(false);
+
+export const ScheduleThemeProvider = ScheduleThemeContext.Provider;
+export const useScheduleLightMode = () => useContext(ScheduleThemeContext);
diff --git a/components/schedule/utils/schedule-utils.ts b/components/schedule/utils/schedule-utils.ts
index 3a71ba7..9c969ef 100644
--- a/components/schedule/utils/schedule-utils.ts
+++ b/components/schedule/utils/schedule-utils.ts
@@ -1,31 +1,69 @@
import { Schedule } from "@/lib/db/schema";
export const DAYS = ["Friday", "Saturday", "Sunday"];
-export const TIME_SLOT_HEIGHT = 60; // Height in pixels for each 15-min slot
+export const TIME_SLOT_HEIGHT = 45; // Height in pixels for each 15-min slot
export const INTERVAL = 15; // 15-minute intervals
-// Time ranges for each day
-export const DAY_RANGES = {
- friday: { start: 16, end: 24 }, // 4 PM to 12 AM
- saturday: { start: 0, end: 24 }, // 12 AM to 12 AM
- sunday: { start: 0, end: 20 }, // 12 AM to 8 PM
-} as const;
-
-export function generateTimeSlots(selectedDay: number) {
- const slots = [];
- const range =
- selectedDay === 0
- ? DAY_RANGES.friday
- : selectedDay === 1
- ? DAY_RANGES.saturday
- : DAY_RANGES.sunday;
+export type DayRange = { start: number; end: number };
+export type TimeSlot = {
+ label: string;
+ isMajor: boolean; // true for :00 and :30, false for :15 and :45
+};
+
+// Fallback ranges used when no events exist for a day
+const FALLBACK_RANGES: Record
= {
+ friday: { start: 16, end: 24 },
+ saturday: { start: 8, end: 24 },
+ sunday: { start: 8, end: 20 },
+};
+
+export function getDynamicDayRange(
+ events: Schedule[],
+ selectedDay: number,
+): DayRange {
+ const key =
+ selectedDay === 0 ? "friday" : selectedDay === 1 ? "saturday" : "sunday";
+ const fallback = FALLBACK_RANGES[key];
+
+ if (events.length === 0) return fallback;
+
+ let earliestHour = 24;
+ let latestHour = 0;
+
+ events.forEach((event) => {
+ const start = new Date(event.startTime);
+ const end = new Date(event.endTime);
+ const startHour = start.getHours();
+ let endHour = end.getHours();
+ const endMinute = end.getMinutes();
+
+ const isSameDay = start.getDate() === end.getDate();
+ if (!isSameDay && endHour === 0 && endMinute === 0) {
+ endHour = 24;
+ }
+ if (endMinute > 0) endHour += 1;
+
+ earliestHour = Math.min(earliestHour, startHour);
+ latestHour = Math.max(latestHour, endHour);
+ });
+
+ return {
+ start: Math.max(0, earliestHour - 1),
+ end: Math.min(24, latestHour + 1),
+ };
+}
+
+export function generateTimeSlots(range: DayRange): TimeSlot[] {
+ const slots: TimeSlot[] = [];
for (let hour = range.start; hour < range.end; hour++) {
for (let minute = 0; minute < 60; minute += INTERVAL) {
- // Format hour in 12-hour format with AM/PM
const hour12 = hour % 12 || 12;
const period = hour < 12 ? "AM" : "PM";
- slots.push(`${hour12}:${minute.toString().padStart(2, "0")} ${period}`);
+ slots.push({
+ label: `${hour12}:${minute.toString().padStart(2, "0")} ${period}`,
+ isMajor: minute % 30 === 0,
+ });
}
}
return slots;
@@ -126,39 +164,29 @@ export function calculateEventPositions(events: Schedule[]): EventPosition[] {
export function getEventStyle(
event: Schedule,
position: EventPosition,
- selectedDay: number,
+ dayStartHour: number,
) {
const startDate = new Date(event.startTime);
const endDate = new Date(event.endTime);
- // Calculate start and end positions
const startHour = startDate.getHours();
const startMinute = startDate.getMinutes();
let endHour = endDate.getHours();
const endMinute = endDate.getMinutes();
- // Get the day's start hour
- const dayStartHour =
- selectedDay === 0
- ? DAY_RANGES.friday.start
- : selectedDay === 1
- ? DAY_RANGES.saturday.start
- : DAY_RANGES.sunday.start;
-
- // Handle midnight events
const isSameDay = startDate.getDate() === endDate.getDate();
if (!isSameDay && endHour === 0 && endMinute === 0) {
- // Event ends at midnight of next day, treat as end of current day
endHour = 24;
}
- // Adjust for day's start time
+ const slotsPerHour = 60 / INTERVAL;
const startSlots =
- (startHour - dayStartHour) * 4 + Math.floor(startMinute / 15);
- const endSlots = (endHour - dayStartHour) * 4 + Math.floor(endMinute / 15);
+ (startHour - dayStartHour) * slotsPerHour +
+ Math.floor(startMinute / INTERVAL);
+ const endSlots =
+ (endHour - dayStartHour) * slotsPerHour + Math.floor(endMinute / INTERVAL);
const duration = endSlots - startSlots;
- // Calculate width based on number of overlapping events
const columnWidth = 100 / Math.max(1, position.totalColumns);
const width = `${columnWidth}%`;
const left = `${position.column * columnWidth}%`;
diff --git a/config/applications.ts b/config/applications.ts
index 42382c3..d640306 100644
--- a/config/applications.ts
+++ b/config/applications.ts
@@ -1,4 +1,4 @@
-import { Gavel, Lightbulb, LucideIcon, User } from "lucide-react";
+import { HandHelping, Lightbulb, LucideIcon, User } from "lucide-react";
import { getApplicationDeadline, getCurrentPhase } from "./phases";
@@ -17,6 +17,7 @@ export type Application = {
*/
function formatDeadline(date: Date): string {
return date.toLocaleDateString("en-US", {
+ timeZone: "America/Toronto",
month: "long",
day: "numeric",
year: "numeric",
@@ -62,24 +63,35 @@ export function getApplications(): Application[] {
disabled: status !== "open",
},
{
- title: "Mentor Applications",
- href: "https://docs.google.com/forms/d/e/1FAIpQLScCS76RX3C1AvGGFOQ5J69XpoYb6rvdYQ-B0aYxS_GLaf4jmQ/viewform?usp=sf_link",
- status: "coming soon",
- deadline: "February 28th (11:59:59 PM), 2026",
+ title: "Mentor & Judge Applications",
+ href: "https://forms.gle/tUCGaGi5HgHGQtKD9",
+ status: "open",
+ deadline: "March 2nd (11:59:59 PM), 2026",
description:
+ // for mentors and judges,
"Share your wisdom and help hackers bring their ideas to life! ๐ก",
icon: Lightbulb,
- disabled: true,
+ disabled: false,
},
+ // {
+ // title: "Judge Applications",
+ // href: "https://docs.google.com/forms/d/e/1FAIpQLScCS76RX3C1AvGGFOQ5J69XpoYb6rvdYQ-B0aYxS_GLaf4jmQ/viewform?usp=sf_link",
+ // status: "coming soon",
+ // description:
+ // "Help crown the champions and discover the next big thing! โ๏ธ",
+ // deadline: "February 28th (11:59:59 PM), 2026",
+ // icon: Gavel,
+ // disabled: true,
+ // },
{
- title: "Judge Applications",
- href: "https://docs.google.com/forms/d/e/1FAIpQLScCS76RX3C1AvGGFOQ5J69XpoYb6rvdYQ-B0aYxS_GLaf4jmQ/viewform?usp=sf_link",
- status: "coming soon",
+ title: "Volunteer Applications",
+ href: "https://forms.gle/QyHgyzaiAELfNN4Q6",
+ status: "open",
description:
- "Help crown the champions and discover the next big thing! โ๏ธ",
- deadline: "February 28th (11:59:59 PM), 2026",
- icon: Gavel,
- disabled: true,
+ "Help us run an amazing event and make a difference behind the scenes!",
+ deadline: "March 2nd (11:59:59 PM), 2026",
+ icon: HandHelping,
+ disabled: false,
},
];
}
diff --git a/config/navigation.ts b/config/navigation.ts
index 8aaaa68..c56a13e 100644
--- a/config/navigation.ts
+++ b/config/navigation.ts
@@ -5,6 +5,7 @@ import {
NotebookPen,
QrCode,
User,
+ ShoppingCart,
} from "lucide-react";
export const navigation = [
@@ -33,5 +34,10 @@ export const navigation = [
href: "/qr-code",
icon: QrCode,
},
+ {
+ name: "Shop",
+ href: "/shops",
+ icon: ShoppingCart,
+ },
{ name: "Landing Page", href: "https://hackcanada.org", icon: Home },
];
diff --git a/config/phases.ts b/config/phases.ts
index 0032682..4360f70 100644
--- a/config/phases.ts
+++ b/config/phases.ts
@@ -17,7 +17,7 @@ export type HackathonPhase =
// Phase dates - keep HEAD's functional dates
export const phaseDates = {
registrationOpen: new Date("2026-02-05T00:00:00-05:00"),
- registrationClose: new Date("2026-02-22T23:59:59-05:00"),
+ registrationClose: new Date("2026-02-28T23:59:59-05:00"),
eventStart: new Date("2026-03-06T16:30:00-05:00"),
eventEnd: new Date("2026-03-08T16:00:00-05:00"),
} as const;
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index ba420c2..80459c6 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -3,6 +3,7 @@ import { sql } from "drizzle-orm";
import {
boolean,
integer,
+ jsonb,
pgTable,
text,
timestamp,
@@ -327,3 +328,45 @@ export const banners = pgTable("banner", {
export type Banner = typeof banners.$inferSelect;
export type NewBanner = typeof banners.$inferInsert;
+
+export const shopItems = pgTable("shopItem", {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ image: text("image"),
+ itemName: text("itemName").notNull(),
+ itemDescription: text("itemDescription"),
+ purchasePrice: integer("purchasePrice").notNull(),
+ stock: integer("stock"),
+});
+
+export type ShopItem = typeof shopItems.$inferSelect;
+export type NewShopItem = typeof shopItems.$inferInsert;
+
+export const pointsTransactions = pgTable("pointsTransaction", {
+ id: text("id")
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ userId: text("userId")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ createdAt: timestamp("createdAt")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ points: integer("points").notNull(),
+ referenceId: text("referenceId"), // The id of the challenge / shop item / whatever the points were for
+ metadata: jsonb("metadata"),
+});
+
+export type PointsTransaction = typeof pointsTransactions.$inferSelect;
+export type NewPointsTransaction = typeof pointsTransactions.$inferInsert;
+
+export const userBalance = pgTable("userBalance", {
+ userId: text("userId")
+ .primaryKey()
+ .references(() => users.id, { onDelete: "cascade" }),
+ points: integer("points").notNull().default(0),
+});
+
+export type UserBalance = typeof userBalance.$inferSelect;
+export type NewUserBalance = typeof userBalance.$inferInsert;
diff --git a/lib/emails/ses.ts b/lib/emails/ses.ts
index 8c964bb..251745c 100644
--- a/lib/emails/ses.ts
+++ b/lib/emails/ses.ts
@@ -1,6 +1,7 @@
import { SendEmailCommand, SESClient } from "@aws-sdk/client-ses";
import { render } from "@react-email/render";
+import ApplicationReminderEmail from "@/components/emails/ApplicationReminderEmail";
import ApplicationSubmittedEmail from "@/components/emails/ApplicationSubmittedEmail";
import HackathonPrepEmail from "@/components/emails/HackathonPrepEmail";
import ResetPasswordEmail from "@/components/emails/ResetPasswordEmail";
@@ -247,3 +248,61 @@ export const sendHackathonPrepEmail = async (data: HackathonPrepEmailProps) => {
};
}
};
+
+type ApplicationReminderEmailProps = {
+ name: string;
+ email: string;
+ hasDraft: boolean;
+};
+
+export const sendApplicationReminderEmail = async (
+ data: ApplicationReminderEmailProps,
+) => {
+ const { name, email, hasDraft } = data;
+
+ const subject = hasDraft
+ ? "Quick reminder about your Hack Canada application"
+ : "Your Hack Canada application";
+
+ const htmlBody = await render(
+ ApplicationReminderEmail({ name, hasDraft }),
+ );
+
+ const textBody = hasDraft
+ ? `Hey ${name},\n\nWe noticed you started your Hack Canada hacker application but haven't submitted it yet. Your progress is saved, so you can pick up right where you left off.\n\nIt should only take a few more minutes to finish up and submit.\n\nFinish your application: https://app.hackcanada.org/applications/hacker\n\nCheers,\nThe Hack Canada Team`
+ : `Hey ${name},\n\nYou created a Hack Canada account a little while ago, but we haven't received a hacker application from you yet.\n\nIf you're still interested in joining us, the application only takes about 10 minutes. We'd love to have you.\n\nGo to your application: https://app.hackcanada.org/applications/hacker\n\nCheers,\nThe Hack Canada Team`;
+
+ const command = new SendEmailCommand({
+ Source: `Hack Canada <${process.env.AWS_SES_NO_REPLY_EMAIL!}>` || "",
+ Destination: {
+ ToAddresses: [email],
+ },
+ Message: {
+ Body: {
+ Html: {
+ Charset: "UTF-8",
+ Data: htmlBody,
+ },
+ Text: {
+ Charset: "UTF-8",
+ Data: textBody,
+ },
+ },
+ Subject: {
+ Charset: "UTF-8",
+ Data: subject,
+ },
+ },
+ });
+
+ try {
+ await ses.send(command);
+ return { success: true };
+ } catch (error) {
+ console.error("Error sending email with SES:", error);
+ return {
+ success: false,
+ error: "Something went wrong. Email could not be sent.",
+ };
+ }
+};
diff --git a/lib/utils.ts b/lib/utils.ts
index 2635ca6..10a875d 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -69,6 +69,7 @@ export const formatOptions = (options: string[]) => {
export const formatDate = (date: string | Date) => {
return new Date(date).toLocaleString("en-US", {
+ timeZone: "America/Toronto",
month: "short",
day: "numeric",
hour: "numeric",
diff --git a/scripts/send-application-reminders.ts b/scripts/send-application-reminders.ts
new file mode 100644
index 0000000..038e76f
--- /dev/null
+++ b/scripts/send-application-reminders.ts
@@ -0,0 +1,296 @@
+#!/usr/bin/env bun
+import { loadEnvConfig } from "@next/env";
+import { render } from "@react-email/render";
+import { and, eq, isNull, or } from "drizzle-orm";
+
+import ApplicationReminderEmail from "../components/emails/ApplicationReminderEmail";
+import { db } from "../lib/db";
+import { hackerApplications, users } from "../lib/db/schema";
+import { sendApplicationReminderEmail } from "../lib/emails/ses";
+
+loadEnvConfig(process.cwd());
+
+type ReminderRecipient = {
+ id: string;
+ name: string;
+ email: string;
+ hasDraft: boolean;
+};
+
+async function getIncompleteApplicants(): Promise {
+ const results = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ submissionStatus: hackerApplications.submissionStatus,
+ })
+ .from(users)
+ .leftJoin(hackerApplications, eq(users.id, hackerApplications.userId))
+ .where(
+ and(
+ eq(users.applicationStatus, "not_applied"),
+ or(
+ isNull(hackerApplications.id),
+ eq(hackerApplications.submissionStatus, "draft"),
+ ),
+ ),
+ );
+
+ return results.map((r) => ({
+ id: r.id,
+ name: r.name,
+ email: r.email,
+ hasDraft: r.submissionStatus === "draft",
+ }));
+}
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+async function runDemo() {
+ console.log("=== DEMO MODE (dry run) ===\n");
+
+ const recipients = await getIncompleteApplicants();
+
+ const notStarted = recipients.filter((r) => !r.hasDraft);
+ const drafts = recipients.filter((r) => r.hasDraft);
+
+ console.log(`Total recipients: ${recipients.length}`);
+ console.log(` - Never started application: ${notStarted.length}`);
+ console.log(` - Started but not submitted (draft): ${drafts.length}`);
+ console.log();
+
+ if (recipients.length > 0) {
+ console.log("Sample recipients (up to 10):");
+ recipients.slice(0, 10).forEach((r, i) => {
+ console.log(
+ ` ${i + 1}. ${r.name} <${r.email}> โ ${r.hasDraft ? "draft" : "not started"}`,
+ );
+ });
+ console.log();
+ }
+
+ console.log("--- Preview: Not-Started Email ---");
+ const notStartedHtml = await render(
+ ApplicationReminderEmail({ name: "Demo User", hasDraft: false }),
+ );
+ console.log(`HTML length: ${notStartedHtml.length} characters`);
+ console.log(`Subject: Your Hack Canada application`);
+ console.log();
+
+ console.log("--- Preview: Draft Email ---");
+ const draftHtml = await render(
+ ApplicationReminderEmail({ name: "Demo User", hasDraft: true }),
+ );
+ console.log(`HTML length: ${draftHtml.length} characters`);
+ console.log(`Subject: Quick reminder about your Hack Canada application`);
+ console.log();
+
+ console.log("No emails were sent. Use --test or --send to actually send.");
+}
+
+async function runTest(testEmails: string[]) {
+ console.log("=== TEST MODE ===\n");
+ console.log(`Sending to specified test emails: ${testEmails.join(", ")}\n`);
+
+ const allRecipients = await getIncompleteApplicants();
+ const matchedRecipients = allRecipients.filter((r) =>
+ testEmails.includes(r.email),
+ );
+
+ const unmatchedEmails = testEmails.filter(
+ (e) => !allRecipients.some((r) => r.email === e),
+ );
+
+ if (unmatchedEmails.length > 0) {
+ console.log(
+ `Warning: These emails are NOT in the incomplete applicant list:`,
+ );
+ unmatchedEmails.forEach((e) => console.log(` - ${e}`));
+
+ if (!process.argv.includes("--force-test")) {
+ console.log(
+ "\nUse --force-test to send to these addresses anyway (as not-started variant).",
+ );
+
+ if (matchedRecipients.length === 0) {
+ console.log("No matched recipients found. Exiting.");
+ return;
+ }
+ } else {
+ unmatchedEmails.forEach((e) => {
+ matchedRecipients.push({
+ id: "force-test",
+ name: e.split("@")[0],
+ email: e,
+ hasDraft: false,
+ });
+ });
+ }
+ }
+
+ console.log(`\nSending to ${matchedRecipients.length} recipient(s)...\n`);
+
+ let success = 0;
+ let failed = 0;
+
+ for (const recipient of matchedRecipients) {
+ const result = await sendApplicationReminderEmail({
+ name: recipient.name,
+ email: recipient.email,
+ hasDraft: recipient.hasDraft,
+ });
+
+ if (result.success) {
+ success++;
+ console.log(
+ ` [OK] ${recipient.email} (${recipient.hasDraft ? "draft" : "not started"})`,
+ );
+ } else {
+ failed++;
+ console.log(` [FAIL] ${recipient.email} โ ${result.error}`);
+ }
+
+ await sleep(200);
+ }
+
+ console.log(`\nDone. Success: ${success}, Failed: ${failed}`);
+}
+
+async function runSend() {
+ if (!process.argv.includes("--confirm")) {
+ console.log("=== PRODUCTION SEND MODE ===\n");
+ console.log(
+ "ERROR: You must pass --confirm to actually send to all recipients.",
+ );
+ console.log(
+ "Example: NODE_ENV=production bun run scripts/send-application-reminders.ts --send --confirm",
+ );
+ console.log("\nRun with --demo first to see who would receive emails.");
+ process.exit(1);
+ }
+
+ console.log("=== PRODUCTION SEND MODE (confirmed) ===\n");
+
+ const recipients = await getIncompleteApplicants();
+
+ const notStarted = recipients.filter((r) => !r.hasDraft);
+ const drafts = recipients.filter((r) => r.hasDraft);
+
+ console.log(`Total recipients: ${recipients.length}`);
+ console.log(` - Never started: ${notStarted.length}`);
+ console.log(` - Draft: ${drafts.length}`);
+ console.log();
+
+ let success = 0;
+ let failed = 0;
+ const failedEmails: string[] = [];
+
+ for (let i = 0; i < recipients.length; i++) {
+ const recipient = recipients[i];
+
+ const result = await sendApplicationReminderEmail({
+ name: recipient.name,
+ email: recipient.email,
+ hasDraft: recipient.hasDraft,
+ });
+
+ if (result.success) {
+ success++;
+ } else {
+ failed++;
+ failedEmails.push(recipient.email);
+ }
+
+ if ((i + 1) % 25 === 0 || i === recipients.length - 1) {
+ console.log(
+ `Progress: ${i + 1}/${recipients.length} (success: ${success}, failed: ${failed})`,
+ );
+ }
+
+ await sleep(200);
+ }
+
+ console.log("\n=== SEND COMPLETE ===");
+ console.log(`Total: ${recipients.length}`);
+ console.log(`Success: ${success}`);
+ console.log(`Failed: ${failed}`);
+
+ if (failedEmails.length > 0) {
+ console.log("\nFailed emails:");
+ failedEmails.forEach((e) => console.log(` - ${e}`));
+ }
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+
+ if (args.includes("--help") || args.includes("-h")) {
+ console.log(`
+Application Reminder Email Script
+
+NOTE: Run with NODE_ENV=production to avoid React dev-mode warnings in Bun.
+
+Usage: NODE_ENV=production bun run scripts/send-application-reminders.ts [mode] [options]
+
+Modes:
+ --demo, -d Dry run โ show recipient counts, sample data, and preview email (no emails sent)
+ --test Send only to the specified comma-separated email addresses
+ --send --confirm Send to ALL incomplete applicants (requires --confirm as safety gate)
+
+Options:
+ --force-test When using --test, send to addresses even if they aren't in the incomplete applicant list
+ --help, -h Show this help message
+
+Examples:
+ NODE_ENV=production bun run scripts/send-application-reminders.ts --demo
+ NODE_ENV=production bun run scripts/send-application-reminders.ts --test you@example.com,friend@example.com
+ NODE_ENV=production bun run scripts/send-application-reminders.ts --test outsider@example.com --force-test
+ NODE_ENV=production bun run scripts/send-application-reminders.ts --send --confirm
+ `);
+ process.exit(0);
+ }
+
+ if (args.includes("--demo") || args.includes("-d")) {
+ await runDemo();
+ } else if (args.includes("--test")) {
+ const testIndex = args.indexOf("--test");
+ const emailsArg = args[testIndex + 1];
+
+ if (!emailsArg || emailsArg.startsWith("--")) {
+ console.error(
+ "Error: --test requires a comma-separated list of email addresses.",
+ );
+ console.error("Example: --test you@example.com,friend@example.com");
+ process.exit(1);
+ }
+
+ const testEmails = emailsArg.split(",").map((e) => e.trim());
+ await runTest(testEmails);
+ } else if (args.includes("--send")) {
+ await runSend();
+ } else {
+ console.log("No mode specified. Use --help for usage information.");
+ console.log(
+ "Quick start: NODE_ENV=production bun run scripts/send-application-reminders.ts --demo",
+ );
+ process.exit(1);
+ }
+
+ process.exit(0);
+}
+
+process.on("SIGINT", () => {
+ console.log("\nScript interrupted");
+ process.exit(0);
+});
+
+process.on("SIGTERM", () => {
+ console.log("\nScript terminated");
+ process.exit(0);
+});
+
+main().catch((error) => {
+ console.error("Script failed:", error);
+ process.exit(1);
+});
diff --git a/seed-shop.ts b/seed-shop.ts
new file mode 100644
index 0000000..f7fab75
--- /dev/null
+++ b/seed-shop.ts
@@ -0,0 +1,20 @@
+import { loadEnvConfig } from "@next/env";
+loadEnvConfig(process.cwd());
+
+import { db } from "./lib/db/index";
+import { shopItems } from "./lib/db/schema";
+import crypto from "crypto";
+
+async function seed() {
+ await db.insert(shopItems).values({
+ id: crypto.randomUUID(),
+ itemName: "Daedalus T-Shirt",
+ itemDescription: "A stylish t-shirt to show off your hackathon spirit.",
+ purchasePrice: 1500,
+ image: "https://via.placeholder.com/300x200"
+ });
+ console.log("Seeded shop items!");
+ process.exit();
+}
+
+seed();