From 3d74dfc7004422b85afec2543ea4e8353fef11d0 Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 30 Jul 2026 03:50:52 -0700 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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" /> +
+ + +
+ {poolLoading ? ( + + + Loading entries... + + ) : ( + + {poolEntries} entries from{" "} + {entrants.length} hackers across{" "} + {eligibleStampCount} eligible stamp{eligibleStampCount === 1 ? "" : "s"} + + )} + {poolFetchedAt && !poolLoading && ( + · as of {poolFetchedAt.toLocaleTimeString()} + )} + + +
+ + {disabledReason &&

{disabledReason}

} + + setDrawOpen(false)} + hackathon={hackathon} + prize={selectedPrize} + drawPool={drawPool} + winners={winners} + /> + + ); +} diff --git a/src/components/features/raffle/raffle-prizes-card.tsx b/src/components/features/raffle/raffle-prizes-card.tsx new file mode 100644 index 0000000..4a7327e --- /dev/null +++ b/src/components/features/raffle/raffle-prizes-card.tsx @@ -0,0 +1,299 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Confirm } from "@/components/ui/confirm"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { RafflePrize, RaffleWinner } from "@/lib/firebase/types"; +import { drawnCountForPrize } from "@/lib/raffle"; +import { cn } from "@/lib/utils"; +import { deleteRafflePrize, upsertRafflePrize } from "@/services/raffle"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, Pencil, Plus, Trash2, 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().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 RafflePrizesCardProps { + hackathon: string; + prizes: RafflePrize[]; + winners: RaffleWinner[]; + className?: string; +} + +export function RafflePrizesCard({ hackathon, prizes, winners, className }: RafflePrizesCardProps) { + const [loading, setLoading] = useState(false); + const [editing, setEditing] = useState(null); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: EMPTY_FORM, + }); + + 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) => { + if (prize._id) setEditing({ id: prize._id, name: prize.name, quantity: prize.quantity }); + }; + + const cancelEdit = () => setEditing(null); + + const saveEdit = async (prize: RafflePrize) => { + 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; + } + + const alreadyDrawn = drawnCountForPrize(winners, editing.id); + if (parsed.data.quantity < alreadyDrawn) { + toast.error(`${alreadyDrawn} winners are already drawn for this prize`); + return; + } + + setLoading(true); + try { + const updated = await upsertRafflePrize( + hackathon, + { ...parsed.data, order: prize.order ?? 0 }, + editing.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 (editing?.id === prize._id) cancelEdit(); + } catch (error) { + console.error("Error deleting a raffle prize", error); + toast.error("Something went wrong deleting this prize"); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + + Prize + + + + + + + {prizes.length === 0 ? ( + + + No prizes yet. Add one below. + + + ) : ( + prizes.map((prize) => { + const drawn = drawnCountForPrize(winners, prize._id); + // bound to a local so the non-null narrowing survives into the callbacks + const draft = editing?.id === prize._id ? editing : null; + + if (draft) { + return ( + + +
+ setEditing({ ...draft, name: e.target.value })} + placeholder="Prize name" + className="h-8 flex-1" + /> + + setEditing({ ...draft, quantity: Number(e.target.value) }) + } + aria-label="Quantity" + className="h-8 w-16" + /> +
+
+ + + + + + +
+ ); + } + + return ( + + +
+ {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-prizes-dialog.tsx b/src/components/features/raffle/raffle-prizes-dialog.tsx deleted file mode 100644 index 42a0220..0000000 --- a/src/components/features/raffle/raffle-prizes-dialog.tsx +++ /dev/null @@ -1,288 +0,0 @@ -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 { drawnCountForPrize } from "@/lib/raffle"; -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().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; - hackathon: string; - prizes: RafflePrize[]; - winners: RaffleWinner[]; -} - -export function RafflePrizesDialog({ - open, - onClose, - hackathon, - prizes, - winners, -}: RafflePrizesDialogProps) { - const [loading, setLoading] = useState(false); - const [editing, setEditing] = useState(null); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: EMPTY_FORM, - }); - - 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) => { - if (prize._id) setEditing({ id: prize._id, name: prize.name, quantity: prize.quantity }); - }; - - const cancelEdit = () => setEditing(null); - - const saveEdit = async (prize: RafflePrize) => { - 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; - } - - const alreadyDrawn = drawnCountForPrize(winners, editing.id); - if (parsed.data.quantity < alreadyDrawn) { - toast.error(`${alreadyDrawn} winners are already drawn for this prize`); - return; - } - - setLoading(true); - try { - const updated = await upsertRafflePrize( - hackathon, - { ...parsed.data, order: prize.order ?? 0 }, - editing.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 (editing?.id === 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 = drawnCountForPrize(winners, prize._id); - const isEditing = editing?.id === prize._id; - - return ( -
- {isEditing && editing ? ( - <> - setEditing({ ...editing, name: e.target.value })} - placeholder="Prize name" - className="flex-1" - /> - - setEditing({ ...editing, quantity: 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-stage.tsx b/src/components/features/raffle/raffle-stage.tsx deleted file mode 100644 index 8913aca..0000000 --- a/src/components/features/raffle/raffle-stage.tsx +++ /dev/null @@ -1,345 +0,0 @@ -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 { - drawnCountForPrize, - entrantsEligibleForPrize, - nextPrizeSlot, - pickWeightedWinner, - remainingForPrize, - totalEntries, -} from "@/lib/raffle"; -import { obfuscateEmail } from "@/lib/utils"; -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"; - -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 selectedPrize = - prizes.find((prize) => prize._id === selectedPrizeId) ?? - prizes.find((prize) => remainingForPrize(prize, winners) > 0) ?? - null; - const remaining = selectedPrize ? remainingForPrize(selectedPrize, winners) : 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" - : 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` - : drawableEntries === 0 - ? `Everyone in the pool has already won "${selectedPrize.name}"` - : null; - - const startDraw = () => { - const picked = pickWeightedWinner(drawPool); - 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 = drawPool[Math.floor(Math.random() * drawPool.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 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, - }, - nextPrizeSlot(winners, selectedPrize._id), - ); - if (!logged) throw new Error("Error logging the raffle winner"); - - toast.success(`${winner.preferredName} wins ${selectedPrize.name}!`); - 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 { - 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-winner-dialog.tsx b/src/components/features/raffle/raffle-winner-dialog.tsx new file mode 100644 index 0000000..acaef1a --- /dev/null +++ b/src/components/features/raffle/raffle-winner-dialog.tsx @@ -0,0 +1,187 @@ +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from "@/components/ui/dialog"; +import type { RaffleEntrant, RafflePrize, RaffleWinner } from "@/lib/firebase/types"; +import { nextPrizeSlot, pickWeightedWinner, remainingForPrize } from "@/lib/raffle"; +import { RaffleSlotTakenError, addRaffleWinner } from "@/services/raffle"; +import { Check, Loader2, X } 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 = "drawing" | "revealed"; + +interface RaffleWinnerDialogProps { + open: boolean; + onClose: () => void; + hackathon: string; + prize: RafflePrize | null; + drawPool: RaffleEntrant[]; + winners: RaffleWinner[]; +} + +export function RaffleWinnerDialog({ + open, + onClose, + hackathon, + prize, + drawPool, + winners, +}: RaffleWinnerDialogProps) { + const [phase, setPhase] = useState("drawing"); + const [shown, setShown] = useState(null); + const [winner, setWinner] = useState(null); + const [confirming, setConfirming] = useState(false); + const timerRef = useRef | null>(null); + + // the draw effect keys off `open` alone, so it must reach these through a ref: + // `onClose` is a fresh arrow on every parent render, and a winners snapshot landing + // mid-shuffle would otherwise restart the draw and re-pick a different winner + const latest = useRef({ drawPool, onClose }); + latest.current = { drawPool, onClose }; + + useEffect(() => { + if (!open) return; + + const { drawPool: pool, onClose: close } = latest.current; + const picked = pickWeightedWinner(pool); + if (!picked) { + toast.error("There are no entries to draw from"); + close(); + return; + } + + setWinner(null); + setShown(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); + setShown(picked); + setPhase("revealed"); + return; + } + + const sample = pool[Math.floor(Math.random() * pool.length)]; + if (sample) setShown(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(); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + }; + }, [open]); + + const confirmWinner = async () => { + if (!winner || !prize?._id || confirming) return; + + // re-check against the current winners log, another organizer may have drawn since + if (remainingForPrize(prize, winners) <= 0) { + toast.error(`Every "${prize.name}" has already been drawn`); + onClose(); + return; + } + + if (winners.some((entry) => entry.prizeId === prize._id && entry.email === winner.email)) { + toast.error(`${winner.preferredName} has already won "${prize.name}"`); + onClose(); + return; + } + + setConfirming(true); + try { + const logged = await addRaffleWinner( + hackathon, + { + prizeId: prize._id, + prizeName: prize.name, + preferredName: winner.preferredName, + lastName: winner.lastName, + email: winner.email, + entryCount: winner.entries, + }, + nextPrizeSlot(winners, prize._id), + ); + if (!logged) throw new Error("Error logging the raffle winner"); + + toast.success(`${winner.preferredName} wins ${prize.name}!`); + onClose(); + } catch (error) { + if (error instanceof RaffleSlotTakenError) { + toast.error(`Another organizer just drew this "${prize.name}", draw again`); + onClose(); + return; + } + console.error("Error logging the raffle winner", error); + toast.error("Something went wrong logging this winner"); + } finally { + setConfirming(false); + } + }; + + const drawing = phase === "drawing"; + + return ( + { + if (!state && !confirming) onClose(); + }} + > + +
+ And our winner is... + +
+

{shown?.preferredName ?? "..."}

+

{shown?.lastName || " "}

+
+
+ + + + + +
+
+ ); +} diff --git a/src/components/features/raffle/raffle-winners-log.tsx b/src/components/features/raffle/raffle-winners-log.tsx new file mode 100644 index 0000000..da33af1 --- /dev/null +++ b/src/components/features/raffle/raffle-winners-log.tsx @@ -0,0 +1,135 @@ +import { PageHeader } from "@/components/graphy/typo"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Confirm } from "@/components/ui/confirm"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { RaffleWinner } from "@/lib/firebase/types"; +import { generateWinnersCSV } from "@/lib/raffle"; +import { cn, downloadCSV, obfuscateEmail } from "@/lib/utils"; +import { deleteRaffleWinner } from "@/services/raffle"; +import { Download, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +interface RaffleWinnersLogProps { + hackathon: string; + winners: RaffleWinner[]; + showEmails: boolean; + onToggleEmails: (shown: boolean) => void; + className?: string; +} + +export function RaffleWinnersLog({ + hackathon, + winners, + showEmails, + onToggleEmails, + className, +}: RaffleWinnersLogProps) { + 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 Log +
+
+ + +
+ +
+
+ + + + + + + + Prize + + + Preferred Name + + + Last Name + + + Email + + + + + + {winners.length === 0 ? ( + + + No winners drawn yet. They'll show up here as you confirm each draw. + + + ) : ( + winners.map((winner) => ( + + + {winner.prizeName} + + + {winner.preferredName} + + + {winner.lastName || "—"} + + + {showEmails ? winner.email : obfuscateEmail(winner.email)} + + + handleDelete(winner)} + > + + + + + )) + )} + +
+
+
+
+ ); +} diff --git a/src/components/features/raffle/raffle-winners-panel.tsx b/src/components/features/raffle/raffle-winners-panel.tsx deleted file mode 100644 index 8707b38..0000000 --- a/src/components/features/raffle/raffle-winners-panel.tsx +++ /dev/null @@ -1,126 +0,0 @@ -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)} - > - - - - - ))} - -
- )} -
-
- ); -} diff --git a/src/routes/_auth/stampbook/raffle.tsx b/src/routes/_auth/stampbook/raffle.tsx index f7e7617..7ac44fe 100644 --- a/src/routes/_auth/stampbook/raffle.tsx +++ b/src/routes/_auth/stampbook/raffle.tsx @@ -1,9 +1,8 @@ -import { RafflePrizesDialog } from "@/components/features/raffle/raffle-prizes-dialog"; -import { RaffleStage } from "@/components/features/raffle/raffle-stage"; +import { RaffleDrawPanel } from "@/components/features/raffle/raffle-draw-panel"; +import { RafflePrizesCard } from "@/components/features/raffle/raffle-prizes-card"; import { RaffleStampsDialog } from "@/components/features/raffle/raffle-stamps-dialog"; -import { RaffleWinnersPanel } from "@/components/features/raffle/raffle-winners-panel"; +import { RaffleWinnersLog } from "@/components/features/raffle/raffle-winners-log"; import { PageHeader } from "@/components/graphy/typo"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Select, @@ -56,7 +55,6 @@ function RafflePage() { const [poolLoading, setPoolLoading] = useState(false); const [poolFetchedAt, setPoolFetchedAt] = useState(null); const [showEmails, setShowEmails] = useState(false); - const [prizesOpen, setPrizesOpen] = useState(false); const [stampsOpen, setStampsOpen] = useState(false); const requestRef = useRef(0); @@ -100,7 +98,6 @@ function RafflePage() { }; }, [selectedHackathon]); - // fetched only on change, refreshed on demand const loadPool = useCallback(async (hackathon: string, stampIds: string[], refresh = false) => { if (!hackathon) { setEntrants([]); @@ -132,12 +129,9 @@ function RafflePage() { return ( <> -
+
- - Raffle - {selectedHackathon && {selectedHackathon}} - + Raffle
-
- +
+ Set Prizes + +
+ + loadPool(selectedHackathon, eligibleStampIds, true)} - onManagePrizes={() => setPrizesOpen(true)} onManageStamps={() => setStampsOpen(true)} - /> - setShowEmails((shown) => !shown)} + className="lg:w-[482px]" />
+ +
{selectedHackathon && ( - <> - setPrizesOpen(false)} - hackathon={selectedHackathon} - prizes={prizes} - winners={winners} - /> - setStampsOpen(false)} - hackathon={selectedHackathon} - stamps={stamps} - eligibleStampIds={eligibleStampIds} - /> - + setStampsOpen(false)} + hackathon={selectedHackathon} + stamps={stamps} + eligibleStampIds={eligibleStampIds} + /> )} ); From 77f957a365aa866df821c4f9271d2ef8f253ba1f Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Wed, 19 Aug 2026 18:02:53 -0700 Subject: [PATCH 14/15] fix: match codebase style --- .../features/stampbook/stamp-picker.tsx | 2 +- src/lib/firebase/types.ts | 4 ++ src/lib/raffle.ts | 45 +++++--------- src/lib/stamps.ts | 12 ++-- src/services/raffle.ts | 62 +++++++++---------- 5 files changed, 53 insertions(+), 72 deletions(-) diff --git a/src/components/features/stampbook/stamp-picker.tsx b/src/components/features/stampbook/stamp-picker.tsx index a2f6025..2b57525 100644 --- a/src/components/features/stampbook/stamp-picker.tsx +++ b/src/components/features/stampbook/stamp-picker.tsx @@ -14,7 +14,7 @@ interface StampPickerProps { } /** - * searchable checkbox list of a hackathon's stamps, shared by the raffle and export dialogs + * Searchable checkbox list of a hackathon's stamps, shared by the raffle and export dialogs */ export function StampPicker({ stamps, diff --git a/src/lib/firebase/types.ts b/src/lib/firebase/types.ts index 9146510..23a5ac9 100644 --- a/src/lib/firebase/types.ts +++ b/src/lib/firebase/types.ts @@ -695,6 +695,10 @@ export interface RaffleSettings { lastModified?: Timestamp; lastModifiedBy?: string; } + +/** + * Derived, never stored: one hacker in the raffle pool, built by `buildRaffleEntrants` + */ export interface RaffleEntrant { email: string; preferredName: string; diff --git a/src/lib/raffle.ts b/src/lib/raffle.ts index 6ddc7c7..2aafbaf 100644 --- a/src/lib/raffle.ts +++ b/src/lib/raffle.ts @@ -10,9 +10,8 @@ export interface ApplicantName { /** * 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 = ( @@ -50,17 +49,14 @@ export const buildRaffleEntrants = ( }; /** - * Total number of raffle entries across the pool - * @param entrants the raffle pool - * @returns the sum of every entrant's entries + * Sums every entrant's entries across the pool */ 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 + * @param entrants - the raffle pool * @returns the drawn entrant, or null when the pool has no entries */ export const pickWeightedWinner = (entrants: RaffleEntrant[]): RaffleEntrant | null => { @@ -80,31 +76,22 @@ const winnersForPrize = (winners: RaffleWinner[], prizeId?: string) => winners.filter((winner) => winner.prizeId === prizeId); /** - * How many winners have been logged for a prize - * - * @param winners every winner logged so far - * @param prizeId the prize to count winners for - * @returns the number of the prize's slots that are claimed + * Counts how many of a prize's slots are already claimed */ export const drawnCountForPrize = (winners: RaffleWinner[], prizeId?: string): number => winnersForPrize(winners, prizeId).length; /** - * How many of a prize are still left to draw - * - * @param prize the prize to count - * @param winners every winner logged so far - * @returns the number of unclaimed slots, never below zero + * Counts a prize's unclaimed slots, never below zero */ export const remainingForPrize = (prize: RafflePrize, winners: RaffleWinner[]): number => Math.max(prize.quantity - drawnCountForPrize(winners, prize._id), 0); /** * 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 + * @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 = ( @@ -124,10 +111,9 @@ export const entrantsEligibleForPrize = ( /** * 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 + * @param winners - every winner logged so far + * @param prizeId - the prize about to be drawn for + * @returns the lowest slot index not already taken */ export const nextPrizeSlot = (winners: RaffleWinner[], prizeId: string): number => { const taken = new Set(winnersForPrize(winners, prizeId).map((winner) => winner.slot)); @@ -137,15 +123,12 @@ export const nextPrizeSlot = (winners: RaffleWinner[], prizeId: string): number return slot; }; -/** - * Escapes a CSV field by quoting it and doubling any embedded quotes - */ +/** 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 + * @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/lib/stamps.ts b/src/lib/stamps.ts index ca2a5c7..b0db316 100644 --- a/src/lib/stamps.ts +++ b/src/lib/stamps.ts @@ -14,14 +14,10 @@ export const SOCIALS_NAME_FALLBACK = "User"; /** * 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 + * The portal has written this field three different ways over the years, so all are read: + * a flat array, a map keyed by hackathon document ID, and 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[] => { diff --git a/src/services/raffle.ts b/src/services/raffle.ts index f5a941f..a9d47ec 100644 --- a/src/services/raffle.ts +++ b/src/services/raffle.ts @@ -35,10 +35,10 @@ export class RaffleSlotTakenError extends Error { /** * 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 + * @param hackathon - hackathon ID + * @param callback - the function used to ingest the data + * @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 = ( @@ -65,9 +65,9 @@ export const subscribeToRafflePrizes = ( /** * 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 + * @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 ( @@ -99,8 +99,8 @@ export const upsertRafflePrize = async ( /** * Deletes a raffle prize - * @param hackathon hackathon ID - * @param id the ID of the prize to delete + * @param hackathon - hackathon ID + * @param id - the ID of the prize to delete */ export const deleteRafflePrize = async (hackathon: string, id: string) => { if (!id) return; @@ -114,10 +114,10 @@ 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 + * @param hackathon - hackathon ID + * @param callback - the function used to ingest the data + * @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 = ( @@ -143,10 +143,9 @@ export const subscribeToRaffleWinners = ( /** * 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` + * @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 */ @@ -184,8 +183,8 @@ export const addRaffleWinner = async ( /** * Deletes a logged raffle winner, for undoing a mislogged draw - * @param hackathon hackathon ID - * @param id the ID of the winner to delete + * @param hackathon - hackathon ID + * @param id - the ID of the winner to delete */ export const deleteRaffleWinner = async (hackathon: string, id: string) => { if (!id) return; @@ -199,10 +198,10 @@ 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 + * @param hackathon - hackathon ID + * @param callback - the function used to ingest the data + * @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 = ( @@ -225,8 +224,8 @@ export const subscribeToRaffleSettings = ( /** * 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 + * @param hackathon - hackathon ID + * @param eligibleStampIds - the IDs of the stamps that count as entries */ export const saveRaffleSettings = async (hackathon: string, eligibleStampIds: string[]) => { try { @@ -245,14 +244,13 @@ export const saveRaffleSettings = async (hackathon: string, eligibleStampIds: st } }; -// for efficiency, caches names for the page lifetime +// applicant names rarely change mid-event, so they are cached 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 + * @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 ( @@ -282,9 +280,9 @@ const fetchApplicantNames = async ( /** * 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 - * @param refreshNames re-reads applicant names as well as stamps, for an explicit refresh + * @param hackathon - hackathon ID + * @param eligibleStampIds - the IDs of the stamps that count as entries + * @param refreshNames - re-reads applicant names as well as stamps, for an explicit refresh * @returns the raffle pool, one entrant per hacker */ export const fetchRaffleEntrants = async ( From 55827eb66b7e3a167800cdc36c6f12d2bba17a14 Mon Sep 17 00:00:00 2001 From: Mackenzie Date: Thu, 20 Aug 2026 12:10:08 -0700 Subject: [PATCH 15/15] fix: minor style changes --- src/components/features/evaluator/applicant-list.tsx | 12 ++++-------- src/components/features/raffle/raffle-draw-panel.tsx | 5 ++--- .../features/raffle/raffle-prizes-card.tsx | 12 ++++++------ .../features/raffle/raffle-winner-dialog.tsx | 11 +++++------ .../features/raffle/raffle-winners-log.tsx | 7 +++---- .../features/stampbook/export-raffle-dialog.tsx | 6 +++--- src/lib/firebase/types.ts | 9 +++++++++ src/lib/raffle.ts | 9 +++++++-- src/lib/stamps.ts | 9 --------- src/routes/_auth/stampbook/raffle.tsx | 6 +++--- src/services/stamps.ts | 6 ++---- 11 files changed, 44 insertions(+), 48 deletions(-) diff --git a/src/components/features/evaluator/applicant-list.tsx b/src/components/features/evaluator/applicant-list.tsx index 2157bd4..96333e3 100644 --- a/src/components/features/evaluator/applicant-list.tsx +++ b/src/components/features/evaluator/applicant-list.tsx @@ -26,17 +26,13 @@ export function ApplicantList() { const filteredApplicants = useMemo(() => { let list = applicants || []; list = filterApplicantsByStatus(list, selectedStatuses); + // slice first, the filters above can hand back the provider's array untouched return filterApplicantsBySearch(list, searchTerm) .slice() .sort((a, b) => { - const aSubmissionMs = a.submission?.submittedAt?.toMillis?.() ?? 0; - const bSubmissionMs = b.submission?.submittedAt?.toMillis?.() ?? 0; - - if (aSubmissionMs !== bSubmissionMs) { - return aSubmissionMs - bSubmissionMs; - } - - return a._id.localeCompare(b._id); + const aSubmittedMs = a.submission?.submittedAt?.toMillis?.() ?? 0; + const bSubmittedMs = b.submission?.submittedAt?.toMillis?.() ?? 0; + return aSubmittedMs - bSubmittedMs || a._id.localeCompare(b._id); }); }, [applicants, searchTerm, selectedStatuses]); diff --git a/src/components/features/raffle/raffle-draw-panel.tsx b/src/components/features/raffle/raffle-draw-panel.tsx index 020dabe..e96cdea 100644 --- a/src/components/features/raffle/raffle-draw-panel.tsx +++ b/src/components/features/raffle/raffle-draw-panel.tsx @@ -1,5 +1,4 @@ import { RaffleWinnerDialog } from "@/components/features/raffle/raffle-winner-dialog"; -import { PageHeader } from "@/components/graphy/typo"; import { Button } from "@/components/ui/button"; import { Select, @@ -77,7 +76,7 @@ export function RaffleDrawPanel({ return (
- Live Raffle +

Live Raffle

Select a prize and draw a winner.

@@ -114,7 +113,7 @@ export function RaffleDrawPanel({ disabled={!!disabledReason} title={disabledReason ?? undefined} > - + Draw Winner
diff --git a/src/components/features/raffle/raffle-prizes-card.tsx b/src/components/features/raffle/raffle-prizes-card.tsx index 4a7327e..c321174 100644 --- a/src/components/features/raffle/raffle-prizes-card.tsx +++ b/src/components/features/raffle/raffle-prizes-card.tsx @@ -160,7 +160,7 @@ export function RafflePrizesCard({ hackathon, prizes, winners, className }: Raff ) : ( prizes.map((prize) => { const drawn = drawnCountForPrize(winners, prize._id); - // bound to a local so the non-null narrowing survives into the callbacks + // a local so TypeScript keeps the narrowing inside the callbacks below const draft = editing?.id === prize._id ? editing : null; if (draft) { @@ -195,7 +195,7 @@ export function RafflePrizesCard({ hackathon, prizes, winners, className }: Raff onClick={() => saveEdit(prize)} aria-label="Save prize" > - + @@ -206,7 +206,7 @@ export function RafflePrizesCard({ hackathon, prizes, winners, className }: Raff onClick={cancelEdit} aria-label="Cancel editing prize" > - + @@ -231,7 +231,7 @@ export function RafflePrizesCard({ hackathon, prizes, winners, className }: Raff onClick={() => startEdit(prize)} aria-label={`Edit ${prize.name}`} > - + @@ -246,7 +246,7 @@ export function RafflePrizesCard({ hackathon, prizes, winners, className }: Raff } onConfirm={() => onDelete(prize)} > - + @@ -289,7 +289,7 @@ export function RafflePrizesCard({ hackathon, prizes, winners, className }: Raff )} /> diff --git a/src/components/features/raffle/raffle-winner-dialog.tsx b/src/components/features/raffle/raffle-winner-dialog.tsx index acaef1a..53b095e 100644 --- a/src/components/features/raffle/raffle-winner-dialog.tsx +++ b/src/components/features/raffle/raffle-winner-dialog.tsx @@ -37,9 +37,8 @@ export function RaffleWinnerDialog({ const [confirming, setConfirming] = useState(false); const timerRef = useRef | null>(null); - // the draw effect keys off `open` alone, so it must reach these through a ref: - // `onClose` is a fresh arrow on every parent render, and a winners snapshot landing - // mid-shuffle would otherwise restart the draw and re-pick a different winner + // the draw effect keys off `open` alone, so it reads these through a ref instead: + // a re-render mid-shuffle would otherwise restart the draw and pick a different winner const latest = useRef({ drawPool, onClose }); latest.current = { drawPool, onClose }; @@ -169,14 +168,14 @@ export function RaffleWinnerDialog({ diff --git a/src/components/features/raffle/raffle-winners-log.tsx b/src/components/features/raffle/raffle-winners-log.tsx index da33af1..55e332f 100644 --- a/src/components/features/raffle/raffle-winners-log.tsx +++ b/src/components/features/raffle/raffle-winners-log.tsx @@ -1,4 +1,3 @@ -import { PageHeader } from "@/components/graphy/typo"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Confirm } from "@/components/ui/confirm"; @@ -54,7 +53,7 @@ export function RaffleWinnersLog({ return (
- Winners Log +

Winners Log

@@ -63,7 +62,7 @@ export function RaffleWinnersLog({
@@ -120,7 +119,7 @@ export function RaffleWinnersLog({ description={`${winner.preferredName} will be removed from the log for "${winner.prizeName}", freeing the prize up to be drawn again.`} onConfirm={() => handleDelete(winner)} > - + diff --git a/src/components/features/stampbook/export-raffle-dialog.tsx b/src/components/features/stampbook/export-raffle-dialog.tsx index 9f7146f..e78806c 100644 --- a/src/components/features/stampbook/export-raffle-dialog.tsx +++ b/src/components/features/stampbook/export-raffle-dialog.tsx @@ -15,9 +15,9 @@ import { SelectValue, } from "@/components/ui/select"; import { subscribeToHackathons } from "@/lib/firebase/firestore"; -import type { Hackathon, Stamp } from "@/lib/firebase/types"; +import type { Hackathon, HackerStampEntry, Stamp } from "@/lib/firebase/types"; import { downloadCSV, obfuscateEmail } from "@/lib/utils"; -import { type HackerStampEntry, fetchHackersWithStamps } from "@/services/stamps"; +import { fetchHackersWithStamps } from "@/services/stamps"; import { Download, Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -36,7 +36,7 @@ export function ExportRaffleDialog({ open, onClose, stamps }: ExportRaffleDialog const [selectedHackathon, setSelectedHackathon] = useState(""); const [selectedStampIds, setSelectedStampIds] = useState([]); const [hackathons, setHackathons] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(false); useEffect(() => { const unsub = subscribeToHackathons(setHackathons); diff --git a/src/lib/firebase/types.ts b/src/lib/firebase/types.ts index 23a5ac9..551a7d7 100644 --- a/src/lib/firebase/types.ts +++ b/src/lib/firebase/types.ts @@ -653,6 +653,15 @@ export interface Stamp { lastModifiedBy?: string; } +/** + * Derived, never stored: one stamp a hacker has collected, built by `fetchHackersWithStamps` + */ +export interface HackerStampEntry { + displayName: string; + email: string; + stampId: string; +} + /** * Sub-collection: /Hackathons/[hackathon]/RafflePrizes * diff --git a/src/lib/raffle.ts b/src/lib/raffle.ts index 2aafbaf..801a30a 100644 --- a/src/lib/raffle.ts +++ b/src/lib/raffle.ts @@ -1,5 +1,10 @@ -import type { RaffleEntrant, RafflePrize, RaffleWinner } from "@/lib/firebase/types"; -import { type HackerStampEntry, SOCIALS_NAME_FALLBACK } from "@/lib/stamps"; +import type { + HackerStampEntry, + RaffleEntrant, + RafflePrize, + RaffleWinner, +} from "@/lib/firebase/types"; +import { SOCIALS_NAME_FALLBACK } from "@/lib/stamps"; export type StampEntry = Pick; diff --git a/src/lib/stamps.ts b/src/lib/stamps.ts index b0db316..1f452b0 100644 --- a/src/lib/stamps.ts +++ b/src/lib/stamps.ts @@ -1,14 +1,5 @@ import { getHackathonType } from "@/lib/utils"; -/** - * Represents a user's collected stamp entry - */ -export interface HackerStampEntry { - displayName: string; - email: string; - stampId: string; -} - export const SOCIALS_NAME_FALLBACK = "User"; /** diff --git a/src/routes/_auth/stampbook/raffle.tsx b/src/routes/_auth/stampbook/raffle.tsx index 7ac44fe..6cd56f6 100644 --- a/src/routes/_auth/stampbook/raffle.tsx +++ b/src/routes/_auth/stampbook/raffle.tsx @@ -74,7 +74,7 @@ function RafflePage() { useEffect(() => { if (!selectedHackathon) return; - // one toast per subscription error, not one per subscription + // share one toast id so three failing subscriptions don't stack three toasts const onError = () => toast.error("Couldn't load the raffle, you may not have access to this hackathon", { id: "raffle-subscription-error", @@ -147,7 +147,7 @@ function RafflePage() { @@ -156,7 +156,7 @@ function RafflePage() {
- Set Prizes +

Set Prizes