From 3d74dfc7004422b85afec2543ea4e8353fef11d0 Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 03:50:52 -0700 Subject: [PATCH 01/11] feat: add raffle types and weighted draw helpers --- src/lib/firebase/types.ts | 48 ++++++++ src/lib/raffle.ts | 110 ++++++++++++++++++ .../{stampbook.tsx => stampbook/index.tsx} | 0 3 files changed, 158 insertions(+) create mode 100644 src/lib/raffle.ts rename src/routes/_auth/{stampbook.tsx => stampbook/index.tsx} (100%) diff --git a/src/lib/firebase/types.ts b/src/lib/firebase/types.ts index 50c8f7c..357fdc6 100644 --- a/src/lib/firebase/types.ts +++ b/src/lib/firebase/types.ts @@ -652,3 +652,51 @@ export interface Stamp { lastModified?: Timestamp; lastModifiedBy?: string; } + +/** + * Sub-collection: /Hackathons/[hackathon]/RafflePrizes + * + * A prize that can be drawn for on the raffle page, pre-populated before the event + */ +export interface RafflePrize { + _id?: string; + name: string; + quantity: number; + order?: number; + lastModified?: Timestamp; + lastModifiedBy?: string; +} + +/** + * Sub-collection: /Hackathons/[hackathon]/RaffleWinners + * + * A confirmed raffle draw + */ +export interface RaffleWinner { + _id?: string; + prizeId: string; + prizeName: string; + preferredName: string; + lastName: string; + email: string; + entryCount: number; + drawnAt?: Timestamp; + drawnBy?: string; +} + +/** + * Document: /Hackathons/[hackathon]/Raffle/settings + * + * Which stamps count as raffle entries for this hackathon + */ +export interface RaffleSettings { + eligibleStampIds: string[]; + lastModified?: Timestamp; + lastModifiedBy?: string; +} +export interface RaffleEntrant { + email: string; + preferredName: string; + lastName: string; + entries: number; +} diff --git a/src/lib/raffle.ts b/src/lib/raffle.ts new file mode 100644 index 0000000..a257a84 --- /dev/null +++ b/src/lib/raffle.ts @@ -0,0 +1,110 @@ +import type { RaffleEntrant, RaffleWinner } from "@/lib/firebase/types"; + +export interface StampEntry { + displayName: string; + email: string; +} + +export interface ApplicantName { + preferredName?: string; + lastName?: string; +} + +const SOCIALS_NAME_FALLBACK = "User"; + +/** + * Aggregates collected stamps into one entrant per hacker, where each stamp is one raffle entry + * + * @param stampEntries - one element per stamp collected, as returned by `fetchHackersWithStamps` + * @param applicantNames - lowercased email to applicant name fields, used to recover last names + * @returns the raffle pool, one entrant per hacker + */ +export const buildRaffleEntrants = ( + stampEntries: StampEntry[], + applicantNames: Map, +): RaffleEntrant[] => { + const entrants = new Map(); + + for (const entry of stampEntries) { + const email = entry.email.trim().toLowerCase(); + if (!email) continue; + + const existing = entrants.get(email); + if (existing) { + existing.entries += 1; + continue; + } + + const applicant = applicantNames.get(email); + const socialsName = entry.displayName?.trim(); + const preferredName = + socialsName && socialsName !== SOCIALS_NAME_FALLBACK + ? socialsName + : (applicant?.preferredName ?? SOCIALS_NAME_FALLBACK); + + entrants.set(email, { + email, + preferredName, + lastName: applicant?.lastName ?? "", + entries: 1, + }); + } + + return [...entrants.values()]; +}; + +/** + * Total number of raffle entries across the pool + * @param entrants - the raffle pool + * @returns the sum of every entrant's entries + */ +export const totalEntries = (entrants: RaffleEntrant[]): number => + entrants.reduce((sum, entrant) => sum + entrant.entries, 0); + +/** + * Draws one winner, weighted so that a hacker with N stamps is N times as likely to win + * + * @param entrants - the raffle pool + * @returns the drawn entrant, or null when the pool has no entries + */ +export const pickWeightedWinner = (entrants: RaffleEntrant[]): RaffleEntrant | null => { + const total = totalEntries(entrants); + if (total <= 0) return null; + + let threshold = Math.random() * total; + for (const entrant of entrants) { + threshold -= entrant.entries; + if (threshold < 0) return entrant; + } + + return entrants[entrants.length - 1] ?? null; +}; + +/** + * Escapes a CSV field by quoting it and doubling any embedded quotes + */ +const csvField = (value: string | number): string => `"${String(value).replace(/"/g, '""')}"`; + +/** + * Builds the winners log as CSV, with a header row and fully escaped fields + * + * @param winners - the winners to export, in the order they should appear + * @returns CSV content ready for `downloadCSV` + */ +export const generateWinnersCSV = (winners: RaffleWinner[]): string => { + const header = ["Prize", "Preferred Name", "Last Name", "Email", "Entries", "Drawn At"]; + const rows = winners.map((winner) => + [ + winner.prizeName, + winner.preferredName, + winner.lastName, + winner.email, + winner.entryCount, + winner.drawnAt?.toDate().toLocaleString() ?? "", + ] + .map(csvField) + .join(","), + ); + + return [header.map(csvField).join(","), ...rows].join("\n"); +}; diff --git a/src/routes/_auth/stampbook.tsx b/src/routes/_auth/stampbook/index.tsx similarity index 100% rename from src/routes/_auth/stampbook.tsx rename to src/routes/_auth/stampbook/index.tsx From ad33738eddd689fadb172907d2e99e76daf56e6a Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 13:48:39 -0700 Subject: [PATCH 02/11] feat: add raffle service for prizes winners + entry pool --- src/lib/raffle.ts | 13 ++- src/services/raffle.ts | 243 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 src/services/raffle.ts diff --git a/src/lib/raffle.ts b/src/lib/raffle.ts index a257a84..ef1833c 100644 --- a/src/lib/raffle.ts +++ b/src/lib/raffle.ts @@ -15,8 +15,8 @@ const SOCIALS_NAME_FALLBACK = "User"; /** * Aggregates collected stamps into one entrant per hacker, where each stamp is one raffle entry * - * @param stampEntries - one element per stamp collected, as returned by `fetchHackersWithStamps` - * @param applicantNames - lowercased email to applicant name fields, used to recover last names + * @param stampEntries one element per stamp collected, as returned by `fetchHackersWithStamps` + * @param applicantNames lowercased email to applicant name fields, used to recover last names * @returns the raffle pool, one entrant per hacker */ export const buildRaffleEntrants = ( @@ -55,7 +55,7 @@ export const buildRaffleEntrants = ( /** * Total number of raffle entries across the pool - * @param entrants - the raffle pool + * @param entrants the raffle pool * @returns the sum of every entrant's entries */ export const totalEntries = (entrants: RaffleEntrant[]): number => @@ -64,12 +64,13 @@ export const totalEntries = (entrants: RaffleEntrant[]): number => /** * Draws one winner, weighted so that a hacker with N stamps is N times as likely to win * - * @param entrants - the raffle pool + * @param entrants the raffle pool * @returns the drawn entrant, or null when the pool has no entries */ export const pickWeightedWinner = (entrants: RaffleEntrant[]): RaffleEntrant | null => { const total = totalEntries(entrants); - if (total <= 0) return null; + if (total <= 0) + return null; let threshold = Math.random() * total; for (const entrant of entrants) { @@ -88,7 +89,7 @@ const csvField = (value: string | number): string => `"${String(value).replace(/ /** * Builds the winners log as CSV, with a header row and fully escaped fields * - * @param winners - the winners to export, in the order they should appear + * @param winners the winners to export, in the order they should appear * @returns CSV content ready for `downloadCSV` */ export const generateWinnersCSV = (winners: RaffleWinner[]): string => { diff --git a/src/services/raffle.ts b/src/services/raffle.ts new file mode 100644 index 0000000..68fdc1e --- /dev/null +++ b/src/services/raffle.ts @@ -0,0 +1,243 @@ +import { auth, db } from "@/lib/firebase/client"; +import type { + Applicant, + RaffleEntrant, + RafflePrize, + RaffleSettings, + RaffleWinner, +} from "@/lib/firebase/types"; +import { type ApplicantName, buildRaffleEntrants } from "@/lib/raffle"; +import { fetchHackersWithStamps } from "@/services/stamps"; +import { + type DocumentReference, + Timestamp, + collection, + deleteDoc, + doc, + getDocs, + onSnapshot, + orderBy, + query, + setDoc, +} from "firebase/firestore"; + +/** + * Utility function that returns a hackathon's raffle prizes as realtime data + * @param hackathon hackathon ID + * @param callback + * @returns a function to be called on dismount + */ +export const subscribeToRafflePrizes = ( + hackathon: string, + callback: (docs: RafflePrize[]) => void, +) => + onSnapshot(query(collection(db, "Hackathons", hackathon, "RafflePrizes")), (querySnapshot) => { + const prizes: RafflePrize[] = []; + for (const prizeDoc of querySnapshot.docs) { + prizes.push({ ...(prizeDoc.data() as unknown as RafflePrize), _id: prizeDoc.id }); + } + prizes.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name)); + callback(prizes); + }); + +/** + * Utility function that updates or adds a raffle prize, depending on if an id argument is passed + * @param hackathon hackathon ID + * @param prize the prize to upsert + * @param id optional existing prize ID for updates + * @returns the upserted prize document ref + */ +export const upsertRafflePrize = async ( + hackathon: string, + prize: RafflePrize, + id?: string, +): Promise => { + try { + const prizeId = id ?? doc(collection(db, "Hackathons", hackathon, "RafflePrizes")).id; + const prizeRef = doc(db, "Hackathons", hackathon, "RafflePrizes", prizeId); + + await setDoc( + prizeRef, + { + name: prize.name, + quantity: prize.quantity, + order: prize.order ?? 0, + lastModified: Timestamp.now(), + lastModifiedBy: auth.currentUser?.email ?? "", + }, + { merge: true }, + ); + return prizeRef; + } catch (error) { + console.error("Error upserting raffle prize:", error); + return null; + } +}; + +/** + * Deletes a raffle prize + * @param hackathon hackathon ID + * @param id the ID of the prize to delete + */ +export const deleteRafflePrize = async (hackathon: string, id: string) => { + if (!id) return; + try { + await deleteDoc(doc(db, "Hackathons", hackathon, "RafflePrizes", id)); + } catch (error) { + console.error("Error deleting raffle prize:", error); + throw error; + } +}; + +/** + * Utility function that returns a hackathon's raffle winners as realtime data, newest first + * @param hackathon hackathon ID + * @param callback + * @returns a function to be called on dismount + */ +export const subscribeToRaffleWinners = ( + hackathon: string, + callback: (docs: RaffleWinner[]) => void, +) => + onSnapshot( + query(collection(db, "Hackathons", hackathon, "RaffleWinners"), orderBy("drawnAt", "desc")), + (querySnapshot) => { + const winners: RaffleWinner[] = []; + for (const winnerDoc of querySnapshot.docs) { + winners.push({ ...(winnerDoc.data() as unknown as RaffleWinner), _id: winnerDoc.id }); + } + callback(winners); + }, + ); + +/** + * Logs a confirmed raffle winner + * @param hackathon hackathon ID + * @param winner the winner to log + * @returns the created winner document ref + */ +export const addRaffleWinner = async ( + hackathon: string, + winner: RaffleWinner, +): Promise => { + try { + const winnerId = doc(collection(db, "Hackathons", hackathon, "RaffleWinners")).id; + const winnerRef = doc(db, "Hackathons", hackathon, "RaffleWinners", winnerId); + + await setDoc(winnerRef, { + prizeId: winner.prizeId, + prizeName: winner.prizeName, + preferredName: winner.preferredName, + lastName: winner.lastName, + email: winner.email, + entryCount: winner.entryCount, + drawnAt: Timestamp.now(), + drawnBy: auth.currentUser?.email ?? "", + }); + return winnerRef; + } catch (error) { + console.error("Error adding raffle winner:", error); + return null; + } +}; + +/** + * Deletes a logged raffle winner, for undoing a mislogged draw + * @param hackathon hackathon ID + * @param id the ID of the winner to delete + */ +export const deleteRaffleWinner = async (hackathon: string, id: string) => { + if (!id) return; + try { + await deleteDoc(doc(db, "Hackathons", hackathon, "RaffleWinners", id)); + } catch (error) { + console.error("Error deleting raffle winner:", error); + throw error; + } +}; + +/** + * Utility function that returns a hackathon's raffle settings as realtime data + * @param hackathon hackathon ID + * @param callback + * @returns a function to be called on dismount + */ +export const subscribeToRaffleSettings = ( + hackathon: string, + callback: (settings: RaffleSettings) => void, +) => + onSnapshot(doc(db, "Hackathons", hackathon, "Raffle", "settings"), (docSnapshot) => { + const data = docSnapshot.data() as unknown as RaffleSettings | undefined; + callback({ ...data, eligibleStampIds: data?.eligibleStampIds ?? [] }); + }); + +/** + * Saves which stamps count as raffle entries for a hackathon + * @param hackathon hackathon ID + * @param eligibleStampIds the IDs of the stamps that count as entries + */ +export const saveRaffleSettings = async (hackathon: string, eligibleStampIds: string[]) => { + try { + const record = { + lastModified: Timestamp.now(), + lastModifiedBy: auth.currentUser?.email ?? "", + }; + + await setDoc( + doc(db, "Hackathons", hackathon, "Raffle", "settings"), + { eligibleStampIds, ...record }, + { merge: true }, + ); + } catch (error) { + console.error("Error saving raffle settings:", error); + throw error; + } +}; + +/** + * Fetches the name fields we can recover for each applicant, keyed by lowercased email + * + * @param hackathon hackathon ID + * @returns a map of lowercased email to applicant name fields + */ +const fetchApplicantNames = async (hackathon: string): Promise> => { + const names = new Map(); + const snapshot = await getDocs(collection(db, "Hackathons", hackathon, "Applicants")); + + for (const applicantDoc of snapshot.docs) { + const { basicInfo } = applicantDoc.data() as unknown as Applicant; + const email = basicInfo?.email?.trim().toLowerCase(); + if (!email) continue; + + names.set(email, { + preferredName: basicInfo.preferredName || basicInfo.firstName || basicInfo.legalFirstName, + lastName: basicInfo.legalLastName || basicInfo.lastName, + }); + } + + return names; +}; + +/** + * Builds the raffle pool for a hackathon, where each eligible stamp a hacker collected is one entry + * @param hackathon hackathon ID + * @param eligibleStampIds the IDs of the stamps that count as entries + * @returns the raffle pool, one entrant per hacker + */ +export const fetchRaffleEntrants = async ( + hackathon: string, + eligibleStampIds: string[], +): Promise => { + if (eligibleStampIds.length === 0) return []; + + const [stampEntries, applicantNames] = await Promise.all([ + fetchHackersWithStamps(hackathon), + fetchApplicantNames(hackathon), + ]); + + const eligible = new Set(eligibleStampIds); + return buildRaffleEntrants( + stampEntries.filter((entry) => eligible.has(entry.stampId)), + applicantNames, + ); +}; From e9b1e1fb69d55ba8a71e2c9efa2f3579556b6f1f Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 16:31:49 -0700 Subject: [PATCH 03/11] feat: add raffle prize and eligible stamp setup dialogs --- .../features/raffle/raffle-prizes-dialog.tsx | 294 ++++++++++++++++++ .../features/raffle/raffle-stamps-dialog.tsx | 159 ++++++++++ 2 files changed, 453 insertions(+) create mode 100644 src/components/features/raffle/raffle-prizes-dialog.tsx create mode 100644 src/components/features/raffle/raffle-stamps-dialog.tsx diff --git a/src/components/features/raffle/raffle-prizes-dialog.tsx b/src/components/features/raffle/raffle-prizes-dialog.tsx new file mode 100644 index 0000000..54119f6 --- /dev/null +++ b/src/components/features/raffle/raffle-prizes-dialog.tsx @@ -0,0 +1,294 @@ +import { Button } from "@/components/ui/button"; +import { Confirm } from "@/components/ui/confirm"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import type { RafflePrize, RaffleWinner } from "@/lib/firebase/types"; +import { deleteRafflePrize, upsertRafflePrize } from "@/services/raffle"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, Pencil, Plus, X } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const EMPTY_FORM = { + name: "", + quantity: 1, +}; + +const formSchema = z.object({ + name: z.string().min(1, "Give the prize a name").max(100), + quantity: z.coerce.number().int().min(1, "At least 1 winner").max(999), +}); + +interface RafflePrizesDialogProps { + open: boolean; + onClose: () => void; + hackathon: string; + prizes: RafflePrize[]; + winners: RaffleWinner[]; +} + +export function RafflePrizesDialog({ + open, + onClose, + hackathon, + prizes, + winners, +}: RafflePrizesDialogProps) { + const [loading, setLoading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [draftName, setDraftName] = useState(""); + const [draftQuantity, setDraftQuantity] = useState(1); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: EMPTY_FORM, + }); + + const drawnCount = (prizeId?: string) => + winners.filter((winner) => winner.prizeId === prizeId).length; + + const onSubmit = async (values: z.infer) => { + if (loading) return; + setLoading(true); + try { + const created = await upsertRafflePrize(hackathon, { + name: values.name, + quantity: values.quantity, + order: prizes.length, + }); + if (!created) throw new Error("Error upserting a raffle prize"); + + form.reset(EMPTY_FORM); + toast.success(`Added "${values.name}"`); + } catch (error) { + console.error("Error adding a raffle prize", error); + toast.error("Something went wrong adding this prize"); + } finally { + setLoading(false); + } + }; + + const startEdit = (prize: RafflePrize) => { + setEditingId(prize._id ?? null); + setDraftName(prize.name); + setDraftQuantity(prize.quantity); + }; + + const cancelEdit = () => { + setEditingId(null); + setDraftName(""); + setDraftQuantity(1); + }; + + const saveEdit = async (prize: RafflePrize) => { + if (loading || !prize._id) return; + const name = draftName.trim(); + if (!name) { + toast.error("Give the prize a name"); + return; + } + + if (!Number.isFinite(draftQuantity) || draftQuantity < 1) { + toast.error("Quantity must be at least 1"); + return; + } + + const alreadyDrawn = drawnCount(prize._id); + if (draftQuantity < alreadyDrawn) { + toast.error(`${alreadyDrawn} winners are already drawn for this prize`); + return; + } + + setLoading(true); + try { + const updated = await upsertRafflePrize( + hackathon, + { name, quantity: draftQuantity, order: prize.order ?? 0 }, + prize._id, + ); + if (!updated) throw new Error("Error upserting a raffle prize"); + + toast.success("Prize updated"); + cancelEdit(); + } catch (error) { + console.error("Error editing a raffle prize", error); + toast.error("Something went wrong editing this prize"); + } finally { + setLoading(false); + } + }; + + const onDelete = async (prize: RafflePrize) => { + if (loading || !prize._id) return; + setLoading(true); + try { + await deleteRafflePrize(hackathon, prize._id); + toast.success(`Deleted "${prize.name}"`); + if (editingId === prize._id) cancelEdit(); + } catch (error) { + console.error("Error deleting a raffle prize", error); + toast.error("Something went wrong deleting this prize"); + } finally { + setLoading(false); + } + }; + + const close = () => { + cancelEdit(); + form.reset(EMPTY_FORM); + onClose(); + }; + + return ( + { + if (!state) close(); + }} + > + + + Raffle prizes + + Set these up before the event. Quantity is how many winners get drawn for the prize. + + + +
+ {prizes.length === 0 ? ( +

No prizes yet

+ ) : ( + prizes.map((prize) => { + const drawn = drawnCount(prize._id); + const isEditing = editingId === prize._id; + + return ( +
+ {isEditing ? ( + <> + setDraftName(e.target.value)} + placeholder="Prize name" + className="flex-1" + /> + setDraftQuantity(Number(e.target.value))} + className="w-20" + /> + + + + ) : ( + <> + {prize.name} + + {drawn}/{prize.quantity} drawn + + + 0 + ? `${drawn} winner${drawn === 1 ? " has" : "s have"} already been drawn for "${prize.name}". They stay in the winners log, but the prize can no longer be drawn.` + : `"${prize.name}" will be removed from the prize list.` + } + onConfirm={() => onDelete(prize)} + > + + + + )} +
+ ); + }) + )} +
+ +
+ + ( + + Prize + + + + + + )} + /> + ( + + Quantity + + + + + + )} + /> + + + +
+
+ ); +} diff --git a/src/components/features/raffle/raffle-stamps-dialog.tsx b/src/components/features/raffle/raffle-stamps-dialog.tsx new file mode 100644 index 0000000..16b051d --- /dev/null +++ b/src/components/features/raffle/raffle-stamps-dialog.tsx @@ -0,0 +1,159 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { Stamp } from "@/lib/firebase/types"; +import { cn } from "@/lib/utils"; +import { saveRaffleSettings } from "@/services/raffle"; +import { Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +interface RaffleStampsDialogProps { + open: boolean; + onClose: () => void; + hackathon: string; + stamps: Stamp[]; + eligibleStampIds: string[]; +} + +export function RaffleStampsDialog({ + open, + onClose, + hackathon, + stamps, + eligibleStampIds, +}: RaffleStampsDialogProps) { + const [selectedStampIds, setSelectedStampIds] = useState(eligibleStampIds); + const [stampSearch, setStampSearch] = useState(""); + const [loading, setLoading] = useState(false); + + // load from saved settings so in-progress selection works still + useEffect(() => { + if (open) { + setSelectedStampIds(eligibleStampIds); + setStampSearch(""); + } + }, [open, eligibleStampIds]); + + const hackathonStamps = stamps.filter((stamp) => stamp.hackathon === hackathon); + const filteredStamps = hackathonStamps.filter( + (stamp) => stamp._id && stamp.name.toLowerCase().includes(stampSearch.toLowerCase()), + ); + + const handleToggleStamp = (stampId: string) => { + setSelectedStampIds((prev) => + prev.includes(stampId) ? prev.filter((id) => id !== stampId) : [...prev, stampId], + ); + }; + + const handleSelectAll = () => { + const allFilteredIds = filteredStamps.map((s) => s._id).filter(Boolean) as string[]; + const allSelected = allFilteredIds.every((id) => selectedStampIds.includes(id)); + setSelectedStampIds(allSelected ? [] : allFilteredIds); + }; + + const handleSave = async () => { + if (loading) return; + setLoading(true); + try { + await saveRaffleSettings(hackathon, selectedStampIds); + toast.success( + `${selectedStampIds.length} stamp${selectedStampIds.length === 1 ? "" : "s"} count as entries`, + ); + onClose(); + } catch (error) { + console.error("Error saving raffle settings:", error); + toast.error("Failed to save eligible stamps"); + } finally { + setLoading(false); + } + }; + + return ( + !state && onClose()}> + + + Eligible stamps + + Every one of these stamps a hacker collected counts as one raffle entry, so collecting + more stamps means better odds. + + + +
+
+ Stamps for {hackathon} + {filteredStamps.length > 0 && ( + + )} +
+ setStampSearch(e.target.value)} + /> +
+ {filteredStamps.length === 0 ? ( +

+ No stamps found for this hackathon +

+ ) : ( +
+ {filteredStamps.map((stamp) => ( + + ))} +
+ )} +
+

+ {selectedStampIds.length} stamp{selectedStampIds.length === 1 ? "" : "s"} selected +

+ + +
+
+
+ ); +} From 28db33d0db77dfa58d8c73a7ca83e36a768c71dc Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 17:19:55 -0700 Subject: [PATCH 04/11] feat: add raffle draw stage and winners log --- .../features/raffle/raffle-stage.tsx | 310 ++++++++++++++++++ .../features/raffle/raffle-winners-panel.tsx | 126 +++++++ 2 files changed, 436 insertions(+) create mode 100644 src/components/features/raffle/raffle-stage.tsx create mode 100644 src/components/features/raffle/raffle-winners-panel.tsx diff --git a/src/components/features/raffle/raffle-stage.tsx b/src/components/features/raffle/raffle-stage.tsx new file mode 100644 index 0000000..3511922 --- /dev/null +++ b/src/components/features/raffle/raffle-stage.tsx @@ -0,0 +1,310 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { RaffleEntrant, RafflePrize, RaffleWinner } from "@/lib/firebase/types"; +import { pickWeightedWinner, totalEntries } from "@/lib/raffle"; +import { obfuscateEmail } from "@/lib/utils"; +import { addRaffleWinner } from "@/services/raffle"; +import { Loader2, RefreshCw, Settings, Sparkles, Stamp } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +const SHUFFLE_DURATION_MS = 3500; +const REDUCED_MOTION_DURATION_MS = 900; +const SHUFFLE_MIN_DELAY_MS = 60; +const SHUFFLE_MAX_DELAY_MS = 400; + +type DrawPhase = "idle" | "drawing" | "revealed"; + +const fullName = (entrant: RaffleEntrant) => + `${entrant.preferredName}${entrant.lastName ? ` ${entrant.lastName}` : ""}`; + +interface RaffleStageProps { + hackathon: string; + prizes: RafflePrize[]; + winners: RaffleWinner[]; + entrants: RaffleEntrant[]; + eligibleStampCount: number; + poolLoading: boolean; + poolFetchedAt: Date | null; + showEmails: boolean; + onRefreshPool: () => void; + onManagePrizes: () => void; + onManageStamps: () => void; +} + +export function RaffleStage({ + hackathon, + prizes, + winners, + entrants, + eligibleStampCount, + poolLoading, + poolFetchedAt, + showEmails, + onRefreshPool, + onManagePrizes, + onManageStamps, +}: RaffleStageProps) { + const [selectedPrizeId, setSelectedPrizeId] = useState(""); + const [phase, setPhase] = useState("idle"); + const [shuffleName, setShuffleName] = useState(""); + const [winner, setWinner] = useState(null); + const [confirming, setConfirming] = useState(false); + const timerRef = useRef | null>(null); + + const clearTimer = () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + }; + + useEffect( + () => () => { + if (timerRef.current) + clearTimeout(timerRef.current); + }, + [], + ); + + const drawnCount = (prizeId?: string) => + winners.filter((entry) => entry.prizeId === prizeId).length; + + const isDrawable = (prize: RafflePrize) => prize.quantity - drawnCount(prize._id) > 0; + + // allows the picker to select a prize that has already been fully drawn + const chosenPrize = prizes.find((prize) => prize._id === selectedPrizeId) ?? null; + const selectedPrize = + chosenPrize && isDrawable(chosenPrize) ? chosenPrize : (prizes.find(isDrawable) ?? null); + const remaining = selectedPrize ? selectedPrize.quantity - drawnCount(selectedPrize._id) : 0; + const poolEntries = totalEntries(entrants); + + const disabledReason = + prizes.length === 0 + ? "Add prizes before drawing" + : eligibleStampCount === 0 + ? "Choose which stamps count as entries" + : poolLoading + ? "Loading the entry pool..." + : poolEntries === 0 + ? "No entries yet — nobody has collected an eligible stamp" + : !selectedPrize + ? "Every prize has been fully drawn" + : remaining <= 0 + ? `All ${selectedPrize.quantity} of "${selectedPrize.name}" have been drawn` + : null; + + const startDraw = () => { + const picked = pickWeightedWinner(entrants); + if (!picked) { + toast.error("There are no entries to draw from"); + return; + } + + clearTimer(); + setWinner(null); + setPhase("drawing"); + + const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + const duration = reducedMotion ? REDUCED_MOTION_DURATION_MS : SHUFFLE_DURATION_MS; + const startedAt = performance.now(); + + const tick = () => { + const elapsed = performance.now() - startedAt; + if (elapsed >= duration) { + timerRef.current = null; + setWinner(picked); + setPhase("revealed"); + return; + } + + const sample = entrants[Math.floor(Math.random() * entrants.length)]; + if (sample) setShuffleName(fullName(sample)); + + // ease out cubically + const progress = elapsed / duration; + const delay = + SHUFFLE_MIN_DELAY_MS + (SHUFFLE_MAX_DELAY_MS - SHUFFLE_MIN_DELAY_MS) * progress ** 3; + timerRef.current = setTimeout(tick, delay); + }; + + tick(); + }; + + const confirmWinner = async () => { + if (!winner || !selectedPrize?._id || confirming) return; + + setConfirming(true); + try { + const logged = await addRaffleWinner(hackathon, { + prizeId: selectedPrize._id, + prizeName: selectedPrize.name, + preferredName: winner.preferredName, + lastName: winner.lastName, + email: winner.email, + entryCount: winner.entries, + }); + if (!logged) throw new Error("Error logging the raffle winner"); + + toast.success(`${winner.preferredName} wins ${selectedPrize.name}!`); + setWinner(null); + setPhase("idle"); + } catch (error) { + console.error("Error logging the raffle winner", error); + toast.error("Something went wrong logging this winner"); + } finally { + setConfirming(false); + } + }; + + const previousWins = winner + ? winners.filter((entry) => entry.email === winner.email).map((entry) => entry.prizeName) + : []; + + return ( + + +
+
+ Prize + +
+ + +
+ +
+ {poolLoading ? ( + + + Loading entries... + + ) : ( + + {poolEntries} entries from{" "} + {entrants.length} hackers across{" "} + {eligibleStampCount} eligible stamp{eligibleStampCount === 1 ? "" : "s"} + + )} + {poolFetchedAt && !poolLoading && ( + · as of {poolFetchedAt.toLocaleTimeString()} + )} + +
+ +
+ {phase === "idle" && ( + <> + +

+ {selectedPrize ? selectedPrize.name : "Raffle"} +

+

+ {disabledReason ?? `${remaining} left to draw`} +

+ + )} + + {phase === "drawing" && ( + <> +

+ Drawing for {selectedPrize?.name} +

+

{shuffleName || "..."}

+ + + )} + + {phase === "revealed" && winner && ( +
+

+ {selectedPrize?.name} +

+

{fullName(winner)}

+

+ {showEmails ? winner.email : obfuscateEmail(winner.email)} · {winner.entries} stamp + {winner.entries === 1 ? "" : "s"} +

+ {previousWins.length > 0 && ( +

+ Already won: {previousWins.join(", ")} +

+ )} +
+ )} +
+ +
+ {phase === "revealed" ? ( +
+ + +
+ ) : ( + + )} + {phase === "idle" && disabledReason && ( +

{disabledReason}

+ )} +
+
+
+ ); +} diff --git a/src/components/features/raffle/raffle-winners-panel.tsx b/src/components/features/raffle/raffle-winners-panel.tsx new file mode 100644 index 0000000..8707b38 --- /dev/null +++ b/src/components/features/raffle/raffle-winners-panel.tsx @@ -0,0 +1,126 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Confirm } from "@/components/ui/confirm"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { RaffleWinner } from "@/lib/firebase/types"; +import { generateWinnersCSV } from "@/lib/raffle"; +import { downloadCSV, obfuscateEmail } from "@/lib/utils"; +import { deleteRaffleWinner } from "@/services/raffle"; +import { Download, Eye, EyeOff, X } from "lucide-react"; +import { toast } from "sonner"; + +interface RaffleWinnersPanelProps { + hackathon: string; + winners: RaffleWinner[]; + showEmails: boolean; + onToggleEmails: () => void; +} + +export function RaffleWinnersPanel({ + hackathon, + winners, + showEmails, + onToggleEmails, +}: RaffleWinnersPanelProps) { + const handleExport = () => { + const filename = `raffle-winners-${hackathon}-${new Date().toISOString().split("T")[0]}.csv`; + downloadCSV(generateWinnersCSV(winners), filename); + toast.success(`Exported ${winners.length} winner${winners.length === 1 ? "" : "s"}`); + }; + + const handleDelete = async (winner: RaffleWinner) => { + if (!winner._id) return; + try { + await deleteRaffleWinner(hackathon, winner._id); + toast.success("Removed from the winners log"); + } catch (error) { + console.error("Error deleting raffle winner:", error); + toast.error("Failed to remove this winner"); + } + }; + + return ( + + + + Winners + {winners.length} + +
+ + +
+
+ + {winners.length === 0 ? ( +

+ No winners drawn yet. They'll show up here as you confirm each draw. +

+ ) : ( + + + + Prize + Preferred + Last + Email + + + + + {winners.map((winner) => ( + + {winner.prizeName} + + {winner.preferredName} + + {winner.lastName || "—"} + + {showEmails ? winner.email : obfuscateEmail(winner.email)} + + + handleDelete(winner)} + > + + + + + ))} + +
+ )} +
+
+ ); +} From 651eb8eaf2ce2c032bfead751f118255ae59ee6d Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 17:27:06 -0700 Subject: [PATCH 05/11] feat: run the stampbook raffle in admin --- src/routeTree.gen.ts | 83 +++++++---- src/routes/_auth/stampbook/index.tsx | 12 +- src/routes/_auth/stampbook/raffle.tsx | 195 ++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 31 deletions(-) create mode 100644 src/routes/_auth/stampbook/raffle.tsx diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3207fc3..be3e2bc 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,11 +15,12 @@ import { Route as SigninImport } from './routes/signin' import { Route as AuthRouteImport } from './routes/_auth/route' import { Route as AuthIndexImport } from './routes/_auth/index' import { Route as AuthStatusChangerImport } from './routes/_auth/status-changer' -import { Route as AuthStampbookImport } from './routes/_auth/stampbook' import { Route as AuthQueryImport } from './routes/_auth/query' import { Route as AuthFaqImport } from './routes/_auth/faq' import { Route as AuthFactotumImport } from './routes/_auth/factotum' import { Route as AuthEvaluatorImport } from './routes/_auth/evaluator' +import { Route as AuthStampbookIndexImport } from './routes/_auth/stampbook/index' +import { Route as AuthStampbookRaffleImport } from './routes/_auth/stampbook/raffle' import { Route as AuthHackathonsHackathonIdRouteImport } from './routes/_auth/hackathons/$hackathonId/route' import { Route as AuthHackathonsHackathonIdIndexImport } from './routes/_auth/hackathons/$hackathonId/index' import { Route as AuthHackathonsHackathonIdSponsorsImport } from './routes/_auth/hackathons/$hackathonId/sponsors' @@ -53,12 +54,6 @@ const AuthStatusChangerRoute = AuthStatusChangerImport.update({ getParentRoute: () => AuthRouteRoute, } as any) -const AuthStampbookRoute = AuthStampbookImport.update({ - id: '/stampbook', - path: '/stampbook', - getParentRoute: () => AuthRouteRoute, -} as any) - const AuthQueryRoute = AuthQueryImport.update({ id: '/query', path: '/query', @@ -83,6 +78,18 @@ const AuthEvaluatorRoute = AuthEvaluatorImport.update({ getParentRoute: () => AuthRouteRoute, } as any) +const AuthStampbookIndexRoute = AuthStampbookIndexImport.update({ + id: '/stampbook/', + path: '/stampbook/', + getParentRoute: () => AuthRouteRoute, +} as any) + +const AuthStampbookRaffleRoute = AuthStampbookRaffleImport.update({ + id: '/stampbook/raffle', + path: '/stampbook/raffle', + getParentRoute: () => AuthRouteRoute, +} as any) + const AuthHackathonsHackathonIdRouteRoute = AuthHackathonsHackathonIdRouteImport.update({ id: '/hackathons/$hackathonId', @@ -178,13 +185,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthQueryImport parentRoute: typeof AuthRouteImport } - '/_auth/stampbook': { - id: '/_auth/stampbook' - path: '/stampbook' - fullPath: '/stampbook' - preLoaderRoute: typeof AuthStampbookImport - parentRoute: typeof AuthRouteImport - } '/_auth/status-changer': { id: '/_auth/status-changer' path: '/status-changer' @@ -206,6 +206,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthHackathonsHackathonIdRouteImport parentRoute: typeof AuthRouteImport } + '/_auth/stampbook/raffle': { + id: '/_auth/stampbook/raffle' + path: '/stampbook/raffle' + fullPath: '/stampbook/raffle' + preLoaderRoute: typeof AuthStampbookRaffleImport + parentRoute: typeof AuthRouteImport + } + '/_auth/stampbook/': { + id: '/_auth/stampbook/' + path: '/stampbook' + fullPath: '/stampbook' + preLoaderRoute: typeof AuthStampbookIndexImport + parentRoute: typeof AuthRouteImport + } '/_auth/hackathons/$hackathonId/application': { id: '/_auth/hackathons/$hackathonId/application' path: '/application' @@ -287,10 +301,11 @@ interface AuthRouteRouteChildren { AuthFactotumRoute: typeof AuthFactotumRoute AuthFaqRoute: typeof AuthFaqRoute AuthQueryRoute: typeof AuthQueryRoute - AuthStampbookRoute: typeof AuthStampbookRoute AuthStatusChangerRoute: typeof AuthStatusChangerRoute AuthIndexRoute: typeof AuthIndexRoute AuthHackathonsHackathonIdRouteRoute: typeof AuthHackathonsHackathonIdRouteRouteWithChildren + AuthStampbookRaffleRoute: typeof AuthStampbookRaffleRoute + AuthStampbookIndexRoute: typeof AuthStampbookIndexRoute } const AuthRouteRouteChildren: AuthRouteRouteChildren = { @@ -298,11 +313,12 @@ const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthFactotumRoute: AuthFactotumRoute, AuthFaqRoute: AuthFaqRoute, AuthQueryRoute: AuthQueryRoute, - AuthStampbookRoute: AuthStampbookRoute, AuthStatusChangerRoute: AuthStatusChangerRoute, AuthIndexRoute: AuthIndexRoute, AuthHackathonsHackathonIdRouteRoute: AuthHackathonsHackathonIdRouteRouteWithChildren, + AuthStampbookRaffleRoute: AuthStampbookRaffleRoute, + AuthStampbookIndexRoute: AuthStampbookIndexRoute, } const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren( @@ -316,10 +332,11 @@ export interface FileRoutesByFullPath { '/factotum': typeof AuthFactotumRoute '/faq': typeof AuthFaqRoute '/query': typeof AuthQueryRoute - '/stampbook': typeof AuthStampbookRoute '/status-changer': typeof AuthStatusChangerRoute '/': typeof AuthIndexRoute '/hackathons/$hackathonId': typeof AuthHackathonsHackathonIdRouteRouteWithChildren + '/stampbook/raffle': typeof AuthStampbookRaffleRoute + '/stampbook': typeof AuthStampbookIndexRoute '/hackathons/$hackathonId/application': typeof AuthHackathonsHackathonIdApplicationRoute '/hackathons/$hackathonId/rewards': typeof AuthHackathonsHackathonIdRewardsRoute '/hackathons/$hackathonId/schedule': typeof AuthHackathonsHackathonIdScheduleRoute @@ -334,9 +351,10 @@ export interface FileRoutesByTo { '/factotum': typeof AuthFactotumRoute '/faq': typeof AuthFaqRoute '/query': typeof AuthQueryRoute - '/stampbook': typeof AuthStampbookRoute '/status-changer': typeof AuthStatusChangerRoute '/': typeof AuthIndexRoute + '/stampbook/raffle': typeof AuthStampbookRaffleRoute + '/stampbook': typeof AuthStampbookIndexRoute '/hackathons/$hackathonId/application': typeof AuthHackathonsHackathonIdApplicationRoute '/hackathons/$hackathonId/rewards': typeof AuthHackathonsHackathonIdRewardsRoute '/hackathons/$hackathonId/schedule': typeof AuthHackathonsHackathonIdScheduleRoute @@ -353,10 +371,11 @@ export interface FileRoutesById { '/_auth/factotum': typeof AuthFactotumRoute '/_auth/faq': typeof AuthFaqRoute '/_auth/query': typeof AuthQueryRoute - '/_auth/stampbook': typeof AuthStampbookRoute '/_auth/status-changer': typeof AuthStatusChangerRoute '/_auth/': typeof AuthIndexRoute '/_auth/hackathons/$hackathonId': typeof AuthHackathonsHackathonIdRouteRouteWithChildren + '/_auth/stampbook/raffle': typeof AuthStampbookRaffleRoute + '/_auth/stampbook/': typeof AuthStampbookIndexRoute '/_auth/hackathons/$hackathonId/application': typeof AuthHackathonsHackathonIdApplicationRoute '/_auth/hackathons/$hackathonId/rewards': typeof AuthHackathonsHackathonIdRewardsRoute '/_auth/hackathons/$hackathonId/schedule': typeof AuthHackathonsHackathonIdScheduleRoute @@ -374,10 +393,11 @@ export interface FileRouteTypes { | '/factotum' | '/faq' | '/query' - | '/stampbook' | '/status-changer' | '/' | '/hackathons/$hackathonId' + | '/stampbook/raffle' + | '/stampbook' | '/hackathons/$hackathonId/application' | '/hackathons/$hackathonId/rewards' | '/hackathons/$hackathonId/schedule' @@ -391,9 +411,10 @@ export interface FileRouteTypes { | '/factotum' | '/faq' | '/query' - | '/stampbook' | '/status-changer' | '/' + | '/stampbook/raffle' + | '/stampbook' | '/hackathons/$hackathonId/application' | '/hackathons/$hackathonId/rewards' | '/hackathons/$hackathonId/schedule' @@ -408,10 +429,11 @@ export interface FileRouteTypes { | '/_auth/factotum' | '/_auth/faq' | '/_auth/query' - | '/_auth/stampbook' | '/_auth/status-changer' | '/_auth/' | '/_auth/hackathons/$hackathonId' + | '/_auth/stampbook/raffle' + | '/_auth/stampbook/' | '/_auth/hackathons/$hackathonId/application' | '/_auth/hackathons/$hackathonId/rewards' | '/_auth/hackathons/$hackathonId/schedule' @@ -452,10 +474,11 @@ export const routeTree = rootRoute "/_auth/factotum", "/_auth/faq", "/_auth/query", - "/_auth/stampbook", "/_auth/status-changer", "/_auth/", - "/_auth/hackathons/$hackathonId" + "/_auth/hackathons/$hackathonId", + "/_auth/stampbook/raffle", + "/_auth/stampbook/" ] }, "/signin": { @@ -477,10 +500,6 @@ export const routeTree = rootRoute "filePath": "_auth/query.tsx", "parent": "/_auth" }, - "/_auth/stampbook": { - "filePath": "_auth/stampbook.tsx", - "parent": "/_auth" - }, "/_auth/status-changer": { "filePath": "_auth/status-changer.tsx", "parent": "/_auth" @@ -501,6 +520,14 @@ export const routeTree = rootRoute "/_auth/hackathons/$hackathonId/" ] }, + "/_auth/stampbook/raffle": { + "filePath": "_auth/stampbook/raffle.tsx", + "parent": "/_auth" + }, + "/_auth/stampbook/": { + "filePath": "_auth/stampbook/index.tsx", + "parent": "/_auth" + }, "/_auth/hackathons/$hackathonId/application": { "filePath": "_auth/hackathons/$hackathonId/application.tsx", "parent": "/_auth/hackathons/$hackathonId" diff --git a/src/routes/_auth/stampbook/index.tsx b/src/routes/_auth/stampbook/index.tsx index 55880ef..20a75fd 100644 --- a/src/routes/_auth/stampbook/index.tsx +++ b/src/routes/_auth/stampbook/index.tsx @@ -5,11 +5,11 @@ import { PageHeader } from "@/components/graphy/typo"; import { Button } from "@/components/ui/button"; import type { Stamp } from "@/lib/firebase/types"; import { subscribeToStamps } from "@/services/stamps"; -import { createFileRoute } from "@tanstack/react-router"; -import { Download, Plus } from "lucide-react"; +import { Link, createFileRoute } from "@tanstack/react-router"; +import { Dices, Download, Plus } from "lucide-react"; import { useEffect, useState } from "react"; -export const Route = createFileRoute("/_auth/stampbook")({ +export const Route = createFileRoute("/_auth/stampbook/")({ component: StampbookPage, }); @@ -36,6 +36,12 @@ function StampbookPage() { Export Raffle + + + + +
+ loadPool(selectedHackathon, eligibleStampIds)} + onManagePrizes={() => setPrizesOpen(true)} + onManageStamps={() => setStampsOpen(true)} + /> + setShowEmails((shown) => !shown)} + /> +
+ + + {selectedHackathon && ( + <> + setPrizesOpen(false)} + hackathon={selectedHackathon} + prizes={prizes} + winners={winners} + /> + setStampsOpen(false)} + hackathon={selectedHackathon} + stamps={stamps} + eligibleStampIds={eligibleStampIds} + /> + + )} + + ); +} From 9e5f01516b2a873c32b051ca27310f2c76496b4c Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 18:28:27 -0700 Subject: [PATCH 06/11] fix: added more raffle helper methods for prizes --- src/lib/raffle.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/lib/raffle.ts b/src/lib/raffle.ts index ef1833c..d75aa1f 100644 --- a/src/lib/raffle.ts +++ b/src/lib/raffle.ts @@ -69,8 +69,7 @@ export const totalEntries = (entrants: RaffleEntrant[]): number => */ export const pickWeightedWinner = (entrants: RaffleEntrant[]): RaffleEntrant | null => { const total = totalEntries(entrants); - if (total <= 0) - return null; + if (total <= 0) return null; let threshold = Math.random() * total; for (const entrant of entrants) { @@ -81,6 +80,48 @@ export const pickWeightedWinner = (entrants: RaffleEntrant[]): RaffleEntrant | n return entrants[entrants.length - 1] ?? null; }; +/** + * Drops the hackers who have already won this prize, so nobody wins the same prize twice + * + * @param entrants the raffle pool + * @param winners every winner logged so far + * @param prizeId the prize about to be drawn for + * @returns the entrants still eligible for this prize + */ +export const entrantsEligibleForPrize = ( + entrants: RaffleEntrant[], + winners: RaffleWinner[], + prizeId?: string, +): RaffleEntrant[] => { + if (!prizeId) return entrants; + + const alreadyWon = new Set( + winners + .filter((winner) => winner.prizeId === prizeId) + .map((winner) => winner.email.trim().toLowerCase()), + ); + + if (alreadyWon.size === 0) return entrants; + return entrants.filter((entrant) => !alreadyWon.has(entrant.email)); +}; + +/** + * Picks the slot a new winner should claim for a prize + * + * @param winners every winner logged so far + * @param prizeId the prize about to be drawn for + * @returns the slot index to claim + */ +export const nextPrizeSlot = (winners: RaffleWinner[], prizeId: string): number => { + const taken = new Set( + winners.filter((winner) => winner.prizeId === prizeId).map((winner) => winner.slot), + ); + + let slot = 0; + while (taken.has(slot)) slot++; + return slot; +}; + /** * Escapes a CSV field by quoting it and doubling any embedded quotes */ @@ -88,7 +129,7 @@ const csvField = (value: string | number): string => `"${String(value).replace(/ /** * Builds the winners log as CSV, with a header row and fully escaped fields - * + * * @param winners the winners to export, in the order they should appear * @returns CSV content ready for `downloadCSV` */ From 927c8fdf59e1f408fa2bb0c9826898f5bf8f8bec Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 18:31:08 -0700 Subject: [PATCH 07/11] fix: toast will only fail for one reason --- src/routes/_auth/stampbook/raffle.tsx | 36 ++++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/routes/_auth/stampbook/raffle.tsx b/src/routes/_auth/stampbook/raffle.tsx index 7de6c3b..34f2924 100644 --- a/src/routes/_auth/stampbook/raffle.tsx +++ b/src/routes/_auth/stampbook/raffle.tsx @@ -20,6 +20,7 @@ import type { RaffleWinner, Stamp, } from "@/lib/firebase/types"; +import { splitHackathon } from "@/lib/utils"; import { fetchRaffleEntrants, subscribeToRafflePrizes, @@ -36,12 +37,14 @@ export const Route = createFileRoute("/_auth/stampbook/raffle")({ component: RafflePage, }); -/** Hackathon IDs are name+year strings like "nwHacks2026". */ -const hackathonYear = (id: string) => Number(id.match(/\d{4}/)?.[0] ?? 0); +const hackathonYear = (id: string) => Number(splitHackathon(id)[1] ?? 0); const newestHackathonId = (hackathons: Hackathon[]) => [...hackathons].sort((a, b) => hackathonYear(b._id) - hackathonYear(a._id))[0]?._id ?? ""; +const sameStampIds = (current: string[], next: string[]) => + current.length === next.length && current.every((id, index) => id === next[index]); + function RafflePage() { const [hackathons, setHackathons] = useState([]); const [selectedHackathon, setSelectedHackathon] = useState(""); @@ -55,7 +58,6 @@ function RafflePage() { const [showEmails, setShowEmails] = useState(false); const [prizesOpen, setPrizesOpen] = useState(false); const [stampsOpen, setStampsOpen] = useState(false); - /** Guards against a slower earlier pool fetch landing after a newer one. */ const requestRef = useRef(0); useEffect(() => { @@ -74,10 +76,21 @@ function RafflePage() { useEffect(() => { if (!selectedHackathon) return; - const unsubPrizes = subscribeToRafflePrizes(selectedHackathon, setPrizes); - const unsubWinners = subscribeToRaffleWinners(selectedHackathon, setWinners); - const unsubSettings = subscribeToRaffleSettings(selectedHackathon, (settings) => - setEligibleStampIds(settings.eligibleStampIds), + // one toast per subscription error, not one per subscription + const onError = () => + toast.error("Couldn't load the raffle — you may not have access to this hackathon", { + id: "raffle-subscription-error", + }); + + const unsubPrizes = subscribeToRafflePrizes(selectedHackathon, setPrizes, onError); + const unsubWinners = subscribeToRaffleWinners(selectedHackathon, setWinners, onError); + const unsubSettings = subscribeToRaffleSettings( + selectedHackathon, + (settings) => + setEligibleStampIds((current) => + sameStampIds(current, settings.eligibleStampIds) ? current : settings.eligibleStampIds, + ), + onError, ); return () => { @@ -87,9 +100,8 @@ function RafflePage() { }; }, [selectedHackathon]); - // Reading Socials + Applicants takes a moment, so the pool is fetched once per hackathon/stamp - // change and then refreshed on demand — organizers control freshness during a live event. - const loadPool = useCallback(async (hackathon: string, stampIds: string[]) => { + // fetched only on change, refreshed on demand + const loadPool = useCallback(async (hackathon: string, stampIds: string[], refresh = false) => { if (!hackathon) { setEntrants([]); setPoolFetchedAt(null); @@ -101,7 +113,7 @@ function RafflePage() { setPoolLoading(true); try { - const pool = await fetchRaffleEntrants(hackathon, stampIds); + const pool = await fetchRaffleEntrants(hackathon, stampIds, refresh); if (requestId !== requestRef.current) return; setEntrants(pool); setPoolFetchedAt(new Date()); @@ -159,7 +171,7 @@ function RafflePage() { poolLoading={poolLoading} poolFetchedAt={poolFetchedAt} showEmails={showEmails} - onRefreshPool={() => loadPool(selectedHackathon, eligibleStampIds)} + onRefreshPool={() => loadPool(selectedHackathon, eligibleStampIds, true)} onManagePrizes={() => setPrizesOpen(true)} onManageStamps={() => setStampsOpen(true)} /> From 87e1c024adc72bcf76e3c740212f0ed3c67b4b9d Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 18:37:16 -0700 Subject: [PATCH 08/11] fix: optimized caching + error throwing --- src/lib/firebase/types.ts | 1 + src/lib/stamps.ts | 31 ++++++++++ src/services/raffle.ts | 119 +++++++++++++++++++++++++++++--------- src/services/stamps.ts | 16 +++-- 4 files changed, 131 insertions(+), 36 deletions(-) create mode 100644 src/lib/stamps.ts diff --git a/src/lib/firebase/types.ts b/src/lib/firebase/types.ts index 357fdc6..9146510 100644 --- a/src/lib/firebase/types.ts +++ b/src/lib/firebase/types.ts @@ -680,6 +680,7 @@ export interface RaffleWinner { lastName: string; email: string; entryCount: number; + slot?: number; drawnAt?: Timestamp; drawnBy?: string; } diff --git a/src/lib/stamps.ts b/src/lib/stamps.ts new file mode 100644 index 0000000..bd7f661 --- /dev/null +++ b/src/lib/stamps.ts @@ -0,0 +1,31 @@ +import { getHackathonType } from "@/lib/utils"; + +/** + * Reads the stamp IDs one Socials document has unlocked for a hackathon + * + * Interestingly, the portal has written this field a few different ways, so we read all + * the following: + * - a flat array + * - a map keyed by hackathon document ID + * - a map keyed by hackathon slug + * + * @param unlockedStamps the raw `unlockedStamps` field off a Socials document + * @param hackathonId hackathon ID + * @returns the stamp IDs this hacker has collected for the hackathon + */ +export const readUnlockedStamps = (unlockedStamps: unknown, hackathonId: string): string[] => { + if (Array.isArray(unlockedStamps)) return unlockedStamps.map(String); + if (!unlockedStamps || typeof unlockedStamps !== "object") return []; + + const byHackathon = unlockedStamps as Record; + const collected = byHackathon[hackathonId] ?? byHackathon[getHackathonType(hackathonId)]; + + if (Array.isArray(collected)) return collected.map(String); + if (collected && typeof collected === "object") { + return Object.entries(collected) + .filter(([, unlocked]) => Boolean(unlocked)) + .map(([stampId]) => stampId); + } + + return []; +}; diff --git a/src/services/raffle.ts b/src/services/raffle.ts index 68fdc1e..ef5eca5 100644 --- a/src/services/raffle.ts +++ b/src/services/raffle.ts @@ -10,6 +10,7 @@ import { type ApplicantName, buildRaffleEntrants } from "@/lib/raffle"; import { fetchHackersWithStamps } from "@/services/stamps"; import { type DocumentReference, + type FirestoreError, Timestamp, collection, deleteDoc, @@ -18,27 +19,49 @@ import { onSnapshot, orderBy, query, + runTransaction, setDoc, } from "firebase/firestore"; +/** + * Thrown when another organizer's draw claimed the prize slot first + */ +export class RaffleSlotTakenError extends Error { + constructor() { + super("This prize slot was already claimed by another draw"); + this.name = "RaffleSlotTakenError"; + } +} + /** * Utility function that returns a hackathon's raffle prizes as realtime data * @param hackathon hackathon ID * @param callback + * @param onError called when the subscription fails, so the page can say so rather than + * looking like a hackathon with no prizes set up * @returns a function to be called on dismount */ export const subscribeToRafflePrizes = ( hackathon: string, callback: (docs: RafflePrize[]) => void, + onError?: (error: FirestoreError) => void, ) => - onSnapshot(query(collection(db, "Hackathons", hackathon, "RafflePrizes")), (querySnapshot) => { - const prizes: RafflePrize[] = []; - for (const prizeDoc of querySnapshot.docs) { - prizes.push({ ...(prizeDoc.data() as unknown as RafflePrize), _id: prizeDoc.id }); - } - prizes.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name)); - callback(prizes); - }); + onSnapshot( + query(collection(db, "Hackathons", hackathon, "RafflePrizes")), + (querySnapshot) => { + const prizes: RafflePrize[] = []; + for (const prizeDoc of querySnapshot.docs) { + prizes.push({ ...(prizeDoc.data() as unknown as RafflePrize), _id: prizeDoc.id }); + } + prizes.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name)); + callback(prizes); + }, + (error) => { + console.error("Error fetching raffle prizes:", error); + callback([]); + onError?.(error); + }, + ); /** * Utility function that updates or adds a raffle prize, depending on if an id argument is passed @@ -93,11 +116,14 @@ export const deleteRafflePrize = async (hackathon: string, id: string) => { * Utility function that returns a hackathon's raffle winners as realtime data, newest first * @param hackathon hackathon ID * @param callback + * @param onError called when the subscription fails, so a draw is never run against a + * winners log that only looks empty * @returns a function to be called on dismount */ export const subscribeToRaffleWinners = ( hackathon: string, callback: (docs: RaffleWinner[]) => void, + onError?: (error: FirestoreError) => void, ) => onSnapshot( query(collection(db, "Hackathons", hackathon, "RaffleWinners"), orderBy("drawnAt", "desc")), @@ -108,34 +134,49 @@ export const subscribeToRaffleWinners = ( } callback(winners); }, + (error) => { + console.error("Error fetching raffle winners:", error); + callback([]); + onError?.(error); + }, ); /** - * Logs a confirmed raffle winner + * Logs a confirmed raffle winner into one of the prize's slots + * * @param hackathon hackathon ID * @param winner the winner to log + * @param slot the prize slot to claim, from `nextPrizeSlot` * @returns the created winner document ref + * @throws RaffleSlotTakenError when another draw already claimed the slot */ export const addRaffleWinner = async ( hackathon: string, winner: RaffleWinner, + slot: number, ): Promise => { + const winnerRef = doc(db, "Hackathons", hackathon, "RaffleWinners", `${winner.prizeId}_${slot}`); + try { - const winnerId = doc(collection(db, "Hackathons", hackathon, "RaffleWinners")).id; - const winnerRef = doc(db, "Hackathons", hackathon, "RaffleWinners", winnerId); - - await setDoc(winnerRef, { - prizeId: winner.prizeId, - prizeName: winner.prizeName, - preferredName: winner.preferredName, - lastName: winner.lastName, - email: winner.email, - entryCount: winner.entryCount, - drawnAt: Timestamp.now(), - drawnBy: auth.currentUser?.email ?? "", + await runTransaction(db, async (transaction) => { + const claimed = await transaction.get(winnerRef); + if (claimed.exists()) throw new RaffleSlotTakenError(); + + transaction.set(winnerRef, { + prizeId: winner.prizeId, + prizeName: winner.prizeName, + preferredName: winner.preferredName, + lastName: winner.lastName, + email: winner.email, + entryCount: winner.entryCount, + slot, + drawnAt: Timestamp.now(), + drawnBy: auth.currentUser?.email ?? "", + }); }); return winnerRef; } catch (error) { + if (error instanceof RaffleSlotTakenError) throw error; console.error("Error adding raffle winner:", error); return null; } @@ -160,16 +201,27 @@ export const deleteRaffleWinner = async (hackathon: string, id: string) => { * Utility function that returns a hackathon's raffle settings as realtime data * @param hackathon hackathon ID * @param callback + * @param onError called when the subscription fails, so an unreadable settings document is + * not mistaken for a raffle with no eligible stamps chosen * @returns a function to be called on dismount */ export const subscribeToRaffleSettings = ( hackathon: string, callback: (settings: RaffleSettings) => void, + onError?: (error: FirestoreError) => void, ) => - onSnapshot(doc(db, "Hackathons", hackathon, "Raffle", "settings"), (docSnapshot) => { - const data = docSnapshot.data() as unknown as RaffleSettings | undefined; - callback({ ...data, eligibleStampIds: data?.eligibleStampIds ?? [] }); - }); + onSnapshot( + doc(db, "Hackathons", hackathon, "Raffle", "settings"), + (docSnapshot) => { + const data = docSnapshot.data() as unknown as RaffleSettings | undefined; + callback({ ...data, eligibleStampIds: data?.eligibleStampIds ?? [] }); + }, + (error) => { + console.error("Error fetching raffle settings:", error); + callback({ eligibleStampIds: [] }); + onError?.(error); + }, + ); /** * Saves which stamps count as raffle entries for a hackathon @@ -194,13 +246,23 @@ export const saveRaffleSettings = async (hackathon: string, eligibleStampIds: st } }; +// for efficiency, caches names for the page lifetime +const applicantNameCache = new Map>(); + /** * Fetches the name fields we can recover for each applicant, keyed by lowercased email * * @param hackathon hackathon ID + * @param refresh re-reads the Applicants collection instead of using the cached names * @returns a map of lowercased email to applicant name fields */ -const fetchApplicantNames = async (hackathon: string): Promise> => { +const fetchApplicantNames = async ( + hackathon: string, + refresh = false, +): Promise> => { + const cached = applicantNameCache.get(hackathon); + if (cached && !refresh) return cached; + const names = new Map(); const snapshot = await getDocs(collection(db, "Hackathons", hackathon, "Applicants")); @@ -215,6 +277,7 @@ const fetchApplicantNames = async (hackathon: string): Promise => { if (eligibleStampIds.length === 0) return []; const [stampEntries, applicantNames] = await Promise.all([ fetchHackersWithStamps(hackathon), - fetchApplicantNames(hackathon), + fetchApplicantNames(hackathon, refreshNames), ]); const eligible = new Set(eligibleStampIds); diff --git a/src/services/stamps.ts b/src/services/stamps.ts index f79c86f..5535e55 100644 --- a/src/services/stamps.ts +++ b/src/services/stamps.ts @@ -8,6 +8,7 @@ import { uploadStampQR, } from "@/lib/firebase/storage"; import type { Stamp } from "@/lib/firebase/types"; +import { readUnlockedStamps } from "@/lib/stamps"; import { type DocumentReference, Timestamp, @@ -140,8 +141,10 @@ export const deleteStampQR = async (stampId: string) => { }; /** - * Fetches all hackers with unlocked stamps from the Socials collection. - * Each stamp a user has unlocked creates one entry (for nwHacks 2026 raffle weighting). + * Fetches all hackers with unlocked stamps from the Socials collection + * Each stamp a user has unlocked creates one entry + * + * @param hackathonId - hackathon ID whose stamps should be counted * @returns Array of entries where each entry represents one stamp collected by a hacker */ export const fetchHackersWithStamps = async (hackathonId: string): Promise => { @@ -152,14 +155,9 @@ export const fetchHackersWithStamps = async (hackathonId: string): Promise Date: Fri, 31 Jul 2026 11:57:37 -0700 Subject: [PATCH 09/11] fix: bugs about winning other prizes --- .../features/raffle/raffle-stage.tsx | 94 ++++++++++++++----- src/routes/_auth/stampbook/raffle.tsx | 2 +- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/components/features/raffle/raffle-stage.tsx b/src/components/features/raffle/raffle-stage.tsx index 3511922..2d1341d 100644 --- a/src/components/features/raffle/raffle-stage.tsx +++ b/src/components/features/raffle/raffle-stage.tsx @@ -8,9 +8,14 @@ import { SelectValue, } from "@/components/ui/select"; import type { RaffleEntrant, RafflePrize, RaffleWinner } from "@/lib/firebase/types"; -import { pickWeightedWinner, totalEntries } from "@/lib/raffle"; +import { + entrantsEligibleForPrize, + nextPrizeSlot, + pickWeightedWinner, + totalEntries, +} from "@/lib/raffle"; import { obfuscateEmail } from "@/lib/utils"; -import { addRaffleWinner } from "@/services/raffle"; +import { RaffleSlotTakenError, addRaffleWinner } from "@/services/raffle"; import { Loader2, RefreshCw, Settings, Sparkles, Stamp } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -63,11 +68,10 @@ export function RaffleStage({ if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = null; }; - + useEffect( () => () => { - if (timerRef.current) - clearTimeout(timerRef.current); + if (timerRef.current) clearTimeout(timerRef.current); }, [], ); @@ -75,15 +79,27 @@ export function RaffleStage({ const drawnCount = (prizeId?: string) => winners.filter((entry) => entry.prizeId === prizeId).length; - const isDrawable = (prize: RafflePrize) => prize.quantity - drawnCount(prize._id) > 0; + // the organizer's pick is the only thing that moves the picker + useEffect(() => { + if (phase !== "idle") return; + setSelectedPrizeId((current) => { + if (prizes.some((prize) => prize._id === current)) return current; + const firstDrawable = prizes.find( + (prize) => + prize.quantity - winners.filter((entry) => entry.prizeId === prize._id).length > 0, + ); + return firstDrawable?._id ?? ""; + }); + }, [prizes, winners, phase]); - // allows the picker to select a prize that has already been fully drawn - const chosenPrize = prizes.find((prize) => prize._id === selectedPrizeId) ?? null; - const selectedPrize = - chosenPrize && isDrawable(chosenPrize) ? chosenPrize : (prizes.find(isDrawable) ?? null); + const selectedPrize = prizes.find((prize) => prize._id === selectedPrizeId) ?? null; const remaining = selectedPrize ? selectedPrize.quantity - drawnCount(selectedPrize._id) : 0; const poolEntries = totalEntries(entrants); + // winning a prize does not rule a hacker out of winning other prizes + const drawPool = entrantsEligibleForPrize(entrants, winners, selectedPrize?._id); + const drawableEntries = totalEntries(drawPool); + const disabledReason = prizes.length === 0 ? "Add prizes before drawing" @@ -97,10 +113,12 @@ export function RaffleStage({ ? "Every prize has been fully drawn" : remaining <= 0 ? `All ${selectedPrize.quantity} of "${selectedPrize.name}" have been drawn` - : null; + : drawableEntries === 0 + ? `Everyone in the pool has already won "${selectedPrize.name}"` + : null; const startDraw = () => { - const picked = pickWeightedWinner(entrants); + const picked = pickWeightedWinner(drawPool); if (!picked) { toast.error("There are no entries to draw from"); return; @@ -123,7 +141,7 @@ export function RaffleStage({ return; } - const sample = entrants[Math.floor(Math.random() * entrants.length)]; + const sample = drawPool[Math.floor(Math.random() * drawPool.length)]; if (sample) setShuffleName(fullName(sample)); // ease out cubically @@ -136,25 +154,53 @@ export function RaffleStage({ tick(); }; + const dismissReveal = () => { + setWinner(null); + setPhase("idle"); + }; + const confirmWinner = async () => { if (!winner || !selectedPrize?._id || confirming) return; + // check against the current winners log + if (remaining <= 0) { + toast.error(`Every "${selectedPrize.name}" has already been drawn`); + dismissReveal(); + return; + } + + if ( + winners.some((entry) => entry.prizeId === selectedPrize._id && entry.email === winner.email) + ) { + toast.error(`${winner.preferredName} has already won "${selectedPrize.name}"`); + dismissReveal(); + return; + } + setConfirming(true); try { - const logged = await addRaffleWinner(hackathon, { - prizeId: selectedPrize._id, - prizeName: selectedPrize.name, - preferredName: winner.preferredName, - lastName: winner.lastName, - email: winner.email, - entryCount: winner.entries, - }); + const logged = await addRaffleWinner( + hackathon, + { + prizeId: selectedPrize._id, + prizeName: selectedPrize.name, + preferredName: winner.preferredName, + lastName: winner.lastName, + email: winner.email, + entryCount: winner.entries, + }, + nextPrizeSlot(winners, selectedPrize._id), + ); if (!logged) throw new Error("Error logging the raffle winner"); toast.success(`${winner.preferredName} wins ${selectedPrize.name}!`); - setWinner(null); - setPhase("idle"); + dismissReveal(); } catch (error) { + if (error instanceof RaffleSlotTakenError) { + toast.error(`Another organizer just drew this "${selectedPrize.name}" — draw again`); + dismissReveal(); + return; + } console.error("Error logging the raffle winner", error); toast.error("Something went wrong logging this winner"); } finally { @@ -173,7 +219,7 @@ export function RaffleStage({
Prize setStampSearch(e.target.value)} + -
- {filteredStamps.length === 0 ? ( -

- No stamps found for this hackathon -

- ) : ( -
- {filteredStamps.map((stamp) => ( - - ))} -
- )} -
-

- {selectedStampIds.length} stamp{selectedStampIds.length === 1 ? "" : "s"} selected -

{selectedHackathon && ( -
-
- Stamps - {filteredStamps.length > 0 && ( - - )} -
- setStampSearch(e.target.value)} - /> -
- {filteredStamps.length === 0 ? ( -

- No stamps found for this hackathon -

- ) : ( -
- {filteredStamps.map((stamp) => ( - - ))} -
- )} -
- {selectedStampIds.length > 0 && ( -

- {selectedStampIds.length} stamp{selectedStampIds.length !== 1 ? "s" : ""} selected -

- )} -
+ )} diff --git a/src/components/features/stampbook/stamp-picker.tsx b/src/components/features/stampbook/stamp-picker.tsx new file mode 100644 index 0000000..01b4dda --- /dev/null +++ b/src/components/features/stampbook/stamp-picker.tsx @@ -0,0 +1,116 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import type { Stamp } from "@/lib/firebase/types"; +import { cn } from "@/lib/utils"; +import { useId, useState } from "react"; + +interface StampPickerProps { + stamps: Stamp[]; + hackathon: string; + selectedIds: string[]; + onChange: (stampIds: string[]) => void; + label?: string; + listClassName?: string; +} + +/** + * searchable checkbox list of a hackathon's stamps, shared by the raffle and export dialogs + */ +export function StampPicker({ + stamps, + hackathon, + selectedIds, + onChange, + label = "Stamps", + listClassName = "max-h-64", +}: StampPickerProps) { + const [stampSearch, setStampSearch] = useState(""); + const checkboxId = useId(); + + const hackathonStamps = stamps.filter((stamp) => stamp.hackathon === hackathon); + const filteredStamps = hackathonStamps.filter( + (stamp) => stamp._id && stamp.name.toLowerCase().includes(stampSearch.toLowerCase()), + ); + const filteredIds = filteredStamps.map((stamp) => stamp._id).filter(Boolean) as string[]; + const allFilteredSelected = + filteredIds.length > 0 && filteredIds.every((id) => selectedIds.includes(id)); + + const handleToggleStamp = (stampId: string) => { + onChange( + selectedIds.includes(stampId) + ? selectedIds.filter((id) => id !== stampId) + : [...selectedIds, stampId], + ); + }; + + const handleSelectAll = () => { + onChange( + allFilteredSelected + ? selectedIds.filter((id) => !filteredIds.includes(id)) + : [...new Set([...selectedIds, ...filteredIds])], + ); + }; + + return ( +
+
+ {label} + {filteredStamps.length > 0 && ( + + )} +
+ setStampSearch(e.target.value)} + /> +
+ {filteredStamps.length === 0 ? ( +

+ No stamps found for this hackathon +

+ ) : ( +
+ {filteredStamps.map((stamp) => ( + + ))} +
+ )} +
+

+ {selectedIds.length} stamp{selectedIds.length === 1 ? "" : "s"} selected +

+
+ ); +} From 8deec4f1253cb5b7e0b3be64e2b79009d0d8101e Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 6 Aug 2026 13:21:31 -0700 Subject: [PATCH 11/11] fix: refactored some datatypes out + simplfiied logic --- .../features/raffle/raffle-prizes-dialog.tsx | 66 +++++++++---------- .../features/raffle/raffle-stage.tsx | 31 +++------ .../features/raffle/raffle-stamps-dialog.tsx | 20 +++--- .../stampbook/export-raffle-dialog.tsx | 1 - .../features/stampbook/stamp-picker.tsx | 11 ++-- src/lib/raffle.ts | 41 ++++++++---- src/lib/stamps.ts | 11 ++++ src/routes/_auth/stampbook/index.tsx | 6 +- src/services/raffle.ts | 11 ++-- src/services/stamps.ts | 15 ++--- 10 files changed, 107 insertions(+), 106 deletions(-) diff --git a/src/components/features/raffle/raffle-prizes-dialog.tsx b/src/components/features/raffle/raffle-prizes-dialog.tsx index 54119f6..42a0220 100644 --- a/src/components/features/raffle/raffle-prizes-dialog.tsx +++ b/src/components/features/raffle/raffle-prizes-dialog.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import type { RafflePrize, RaffleWinner } from "@/lib/firebase/types"; +import { drawnCountForPrize } from "@/lib/raffle"; import { deleteRafflePrize, upsertRafflePrize } from "@/services/raffle"; import { zodResolver } from "@hookform/resolvers/zod"; import { Check, Pencil, Plus, X } from "lucide-react"; @@ -31,10 +32,16 @@ const EMPTY_FORM = { }; const formSchema = z.object({ - name: z.string().min(1, "Give the prize a name").max(100), + name: z.string().trim().min(1, "Give the prize a name").max(100), quantity: z.coerce.number().int().min(1, "At least 1 winner").max(999), }); +interface PrizeDraft { + id: string; + name: string; + quantity: number; +} + interface RafflePrizesDialogProps { open: boolean; onClose: () => void; @@ -51,18 +58,13 @@ export function RafflePrizesDialog({ winners, }: RafflePrizesDialogProps) { const [loading, setLoading] = useState(false); - const [editingId, setEditingId] = useState(null); - const [draftName, setDraftName] = useState(""); - const [draftQuantity, setDraftQuantity] = useState(1); + const [editing, setEditing] = useState(null); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: EMPTY_FORM, }); - const drawnCount = (prizeId?: string) => - winners.filter((winner) => winner.prizeId === prizeId).length; - const onSubmit = async (values: z.infer) => { if (loading) return; setLoading(true); @@ -85,32 +87,22 @@ export function RafflePrizesDialog({ }; const startEdit = (prize: RafflePrize) => { - setEditingId(prize._id ?? null); - setDraftName(prize.name); - setDraftQuantity(prize.quantity); + if (prize._id) setEditing({ id: prize._id, name: prize.name, quantity: prize.quantity }); }; - const cancelEdit = () => { - setEditingId(null); - setDraftName(""); - setDraftQuantity(1); - }; + const cancelEdit = () => setEditing(null); const saveEdit = async (prize: RafflePrize) => { - if (loading || !prize._id) return; - const name = draftName.trim(); - if (!name) { - toast.error("Give the prize a name"); + if (loading || !editing) return; + + const parsed = formSchema.safeParse({ name: editing.name, quantity: editing.quantity }); + if (!parsed.success) { + toast.error(parsed.error.issues[0].message); return; } - if (!Number.isFinite(draftQuantity) || draftQuantity < 1) { - toast.error("Quantity must be at least 1"); - return; - } - - const alreadyDrawn = drawnCount(prize._id); - if (draftQuantity < alreadyDrawn) { + const alreadyDrawn = drawnCountForPrize(winners, editing.id); + if (parsed.data.quantity < alreadyDrawn) { toast.error(`${alreadyDrawn} winners are already drawn for this prize`); return; } @@ -119,8 +111,8 @@ export function RafflePrizesDialog({ try { const updated = await upsertRafflePrize( hackathon, - { name, quantity: draftQuantity, order: prize.order ?? 0 }, - prize._id, + { ...parsed.data, order: prize.order ?? 0 }, + editing.id, ); if (!updated) throw new Error("Error upserting a raffle prize"); @@ -140,7 +132,7 @@ export function RafflePrizesDialog({ try { await deleteRafflePrize(hackathon, prize._id); toast.success(`Deleted "${prize.name}"`); - if (editingId === prize._id) cancelEdit(); + if (editing?.id === prize._id) cancelEdit(); } catch (error) { console.error("Error deleting a raffle prize", error); toast.error("Something went wrong deleting this prize"); @@ -175,27 +167,29 @@ export function RafflePrizesDialog({

No prizes yet

) : ( prizes.map((prize) => { - const drawn = drawnCount(prize._id); - const isEditing = editingId === prize._id; + const drawn = drawnCountForPrize(winners, prize._id); + const isEditing = editing?.id === prize._id; return (
- {isEditing ? ( + {isEditing && editing ? ( <> setDraftName(e.target.value)} + value={editing.name} + onChange={(e) => setEditing({ ...editing, name: e.target.value })} placeholder="Prize name" className="flex-1" /> setDraftQuantity(Number(e.target.value))} + value={editing.quantity} + onChange={(e) => + setEditing({ ...editing, quantity: Number(e.target.value) }) + } className="w-20" />