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..42a0220 --- /dev/null +++ b/src/components/features/raffle/raffle-prizes-dialog.tsx @@ -0,0 +1,288 @@ +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 new file mode 100644 index 0000000..8913aca --- /dev/null +++ b/src/components/features/raffle/raffle-stage.tsx @@ -0,0 +1,345 @@ +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-stamps-dialog.tsx b/src/components/features/raffle/raffle-stamps-dialog.tsx new file mode 100644 index 0000000..855c563 --- /dev/null +++ b/src/components/features/raffle/raffle-stamps-dialog.tsx @@ -0,0 +1,87 @@ +import { StampPicker } from "@/components/features/stampbook/stamp-picker"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { Stamp } from "@/lib/firebase/types"; +import { saveRaffleSettings } from "@/services/raffle"; +import { Loader2 } from "lucide-react"; +import { 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 [draftStampIds, setDraftStampIds] = useState(null); + const [loading, setLoading] = useState(false); + + const selectedStampIds = draftStampIds ?? eligibleStampIds; + + const handleClose = () => { + setDraftStampIds(null); + onClose(); + }; + + 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`, + ); + handleClose(); + } catch (error) { + console.error("Error saving raffle settings:", error); + toast.error("Failed to save eligible stamps"); + } finally { + setLoading(false); + } + }; + + return ( + !state && handleClose()}> + + + Eligible stamps + + Every one of these stamps a hacker collected counts as one raffle entry, so collecting + more stamps means better odds. + + + +
+ + + +
+
+
+ ); +} 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)} + > + + + + + ))} + +
+ )} +
+
+ ); +} diff --git a/src/components/features/stampbook/export-raffle-dialog.tsx b/src/components/features/stampbook/export-raffle-dialog.tsx index 6466b52..9f7146f 100644 --- a/src/components/features/stampbook/export-raffle-dialog.tsx +++ b/src/components/features/stampbook/export-raffle-dialog.tsx @@ -1,5 +1,5 @@ +import { StampPicker } from "@/components/features/stampbook/stamp-picker"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -7,7 +7,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -17,8 +16,8 @@ import { } from "@/components/ui/select"; import { subscribeToHackathons } from "@/lib/firebase/firestore"; import type { Hackathon, Stamp } from "@/lib/firebase/types"; -import { cn, downloadCSV, obfuscateEmail } from "@/lib/utils"; -import { fetchHackersWithStamps, type HackerStampEntry } from "@/services/stamps"; +import { downloadCSV, obfuscateEmail } from "@/lib/utils"; +import { type HackerStampEntry, fetchHackersWithStamps } from "@/services/stamps"; import { Download, Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -38,34 +37,15 @@ export function ExportRaffleDialog({ open, onClose, stamps }: ExportRaffleDialog const [selectedStampIds, setSelectedStampIds] = useState([]); const [hackathons, setHackathons] = useState([]); const [loading, setLoading] = useState(false); - const [stampSearch, setStampSearch] = useState(""); useEffect(() => { const unsub = subscribeToHackathons(setHackathons); return () => unsub(); }, []); - const hackathonStamps = stamps.filter((stamp) => stamp.hackathon === selectedHackathon); - const filteredStamps = hackathonStamps.filter( - (stamp) => stamp._id && stamp.name.toLowerCase().includes(stampSearch.toLowerCase()) - ); - const handleHackathonChange = (hackathon: string) => { setSelectedHackathon(hackathon); setSelectedStampIds([]); - setStampSearch(""); - }; - - 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 handleExport = async () => { @@ -78,7 +58,7 @@ export function ExportRaffleDialog({ open, onClose, stamps }: ExportRaffleDialog try { const allUserStamps = await fetchHackersWithStamps(selectedHackathon); const filteredEntries = allUserStamps.filter((entry: HackerStampEntry) => - selectedStampIds.includes(entry.stampId) + selectedStampIds.includes(entry.stampId), ); const raffleEntries = filteredEntries.map((entry: HackerStampEntry) => ({ @@ -106,17 +86,20 @@ export function ExportRaffleDialog({ open, onClose, stamps }: ExportRaffleDialog const handleClose = () => { setSelectedHackathon(""); setSelectedStampIds([]); - setStampSearch(""); onClose(); }; return ( !state && handleClose()}> - + Export Raffle - Export a CSV of obfuscated emails for raffles. Outputs a list of name + emails, where duplicate entries correlate to number of stamps collected by a hacker. + Export a CSV of obfuscated emails for raffles. Outputs a list of name + emails, where + duplicate entries correlate to number of stamps collected by a hacker. @@ -138,67 +121,13 @@ export function ExportRaffleDialog({ open, onClose, stamps }: ExportRaffleDialog {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..a2f6025 --- /dev/null +++ b/src/components/features/stampbook/stamp-picker.tsx @@ -0,0 +1,115 @@ +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; +} + +/** + * searchable checkbox list of a hackathon's stamps, shared by the raffle and export dialogs + */ +export function StampPicker({ + stamps, + hackathon, + selectedIds, + onChange, + label = "Stamps", +}: StampPickerProps) { + const [stampSearch, setStampSearch] = useState(""); + const checkboxId = useId(); + + const search = stampSearch.toLowerCase(); + const filteredStamps = stamps.filter( + (stamp) => + stamp.hackathon === hackathon && stamp._id && stamp.name.toLowerCase().includes(search), + ); + 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 +

+
+ ); +} diff --git a/src/lib/firebase/types.ts b/src/lib/firebase/types.ts index 50c8f7c..9146510 100644 --- a/src/lib/firebase/types.ts +++ b/src/lib/firebase/types.ts @@ -652,3 +652,52 @@ 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; + slot?: 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..6ddc7c7 --- /dev/null +++ b/src/lib/raffle.ts @@ -0,0 +1,167 @@ +import type { RaffleEntrant, RafflePrize, RaffleWinner } from "@/lib/firebase/types"; +import { type HackerStampEntry, SOCIALS_NAME_FALLBACK } from "@/lib/stamps"; + +export type StampEntry = Pick; + +export interface ApplicantName { + preferredName?: string; + lastName?: string; +} + +/** + * 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; +}; + +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 + */ +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 + */ +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 + * @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( + winnersForPrize(winners, 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(winnersForPrize(winners, 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 + */ +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/lib/stamps.ts b/src/lib/stamps.ts new file mode 100644 index 0000000..ca2a5c7 --- /dev/null +++ b/src/lib/stamps.ts @@ -0,0 +1,42 @@ +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"; + +/** + * 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/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.tsx b/src/routes/_auth/stampbook/index.tsx similarity index 77% rename from src/routes/_auth/stampbook.tsx rename to src/routes/_auth/stampbook/index.tsx index 55880ef..9da8021 100644 --- a/src/routes/_auth/stampbook.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, true)} + 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} + /> + + )} + + ); +} diff --git a/src/services/raffle.ts b/src/services/raffle.ts new file mode 100644 index 0000000..f5a941f --- /dev/null +++ b/src/services/raffle.ts @@ -0,0 +1,307 @@ +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, + type FirestoreError, + Timestamp, + collection, + deleteDoc, + doc, + getDocs, + 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); + }, + (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 + * @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 + * @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")), + (querySnapshot) => { + const winners: RaffleWinner[] = []; + for (const winnerDoc of querySnapshot.docs) { + winners.push({ ...(winnerDoc.data() as unknown as RaffleWinner), _id: winnerDoc.id }); + } + callback(winners); + }, + (error) => { + console.error("Error fetching raffle winners:", error); + callback([]); + onError?.(error); + }, + ); + +/** + * 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 { + 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; + } +}; + +/** + * 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 + * @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 ?? [] }); + }, + (error) => { + console.error("Error fetching raffle settings:", error); + callback({ eligibleStampIds: [] }); + onError?.(error); + }, + ); + +/** + * 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 { + await setDoc( + doc(db, "Hackathons", hackathon, "Raffle", "settings"), + { + eligibleStampIds, + lastModified: Timestamp.now(), + lastModifiedBy: auth.currentUser?.email ?? "", + }, + { merge: true }, + ); + } catch (error) { + console.error("Error saving raffle settings:", error); + throw error; + } +}; + +// 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, + 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")); + + 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, + }); + } + + applicantNameCache.set(hackathon, names); + 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 + * @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 ( + hackathon: string, + eligibleStampIds: string[], + refreshNames = false, +): Promise => { + if (eligibleStampIds.length === 0) return []; + + const [stampEntries, applicantNames] = await Promise.all([ + fetchHackersWithStamps(hackathon), + fetchApplicantNames(hackathon, refreshNames), + ]); + + const eligible = new Set(eligibleStampIds); + return buildRaffleEntrants( + stampEntries.filter((entry) => eligible.has(entry.stampId)), + applicantNames, + ); +}; diff --git a/src/services/stamps.ts b/src/services/stamps.ts index f79c86f..f7cc133 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 { type HackerStampEntry, SOCIALS_NAME_FALLBACK, readUnlockedStamps } from "@/lib/stamps"; import { type DocumentReference, Timestamp, @@ -22,14 +23,7 @@ import { updateDoc, } from "firebase/firestore"; -/** - * Represents a user's collected stamp entry; used for exports. - */ -export interface HackerStampEntry { - displayName: string; - email: string; - stampId: string; -} +export type { HackerStampEntry }; /** * Utility function that returns Stamps collection realtime data @@ -140,8 +134,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 => { @@ -150,16 +146,11 @@ export const fetchHackersWithStamps = async (hackathonId: string): Promise