From ca4721bc799ce0e5c1cf2074bbfc65b1f3ee5f92 Mon Sep 17 00:00:00 2001 From: Kezia Date: Fri, 16 Jan 2026 21:46:20 -0800 Subject: [PATCH] feat: export raffled emails in csv format --- .../stampbook/export-raffle-dialog.tsx | 216 ++++++++++++++++++ src/lib/utils.ts | 35 +++ src/routes/_auth/stampbook.tsx | 27 ++- src/services/stamps.ts | 36 +++ 4 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 src/components/features/stampbook/export-raffle-dialog.tsx diff --git a/src/components/features/stampbook/export-raffle-dialog.tsx b/src/components/features/stampbook/export-raffle-dialog.tsx new file mode 100644 index 0000000..7c08b7d --- /dev/null +++ b/src/components/features/stampbook/export-raffle-dialog.tsx @@ -0,0 +1,216 @@ +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} 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 { Download, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +interface ExportRaffleDialogProps { + open: boolean; + onClose: () => void; + stamps: Stamp[]; +} + +function generateRaffleCSV(entries: { displayName: string; obfuscatedEmail: string }[]): string { + return entries.map((entry) => `${entry.displayName} (${entry.obfuscatedEmail})`).join("\n"); +} + +export function ExportRaffleDialog({ open, onClose, stamps }: ExportRaffleDialogProps) { + const [selectedHackathon, setSelectedHackathon] = useState(""); + 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 () => { + if (selectedStampIds.length === 0) { + toast.error("Please select at least one stamp"); + return; + } + + setLoading(true); + try { + const allUserStamps = await fetchHackersWithStamps(); + const filteredEntries = allUserStamps.filter((entry: HackerStampEntry) => + selectedStampIds.includes(entry.stampId) + ); + + const raffleEntries = filteredEntries.map((entry: HackerStampEntry) => ({ + displayName: entry.displayName, + obfuscatedEmail: obfuscateEmail(entry.email), + })); + + if (raffleEntries.length === 0) { + toast.error("No users found with the selected stamps"); + return; + } + + const csvContent = generateRaffleCSV(raffleEntries); + const filename = `raffle-${selectedHackathon}-${new Date().toISOString().split("T")[0]}.csv`; + downloadCSV(csvContent, filename); + toast.success(`Exported ${raffleEntries.length} raffle entries`); + } catch (error) { + console.error("Error exporting raffle:", error); + toast.error("Failed to export raffle data"); + } finally { + setLoading(false); + } + }; + + 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. + + + +
+
+ Hackathon + +
+ + {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/lib/utils.ts b/src/lib/utils.ts index 37c6078..aced994 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -130,4 +130,39 @@ export function getHackathonType(hackathonId: string): HackathonType { if (lower.includes("cmd-f") || lower.includes("cmdf")) return "cmd-f"; if (lower.includes("hackcamp")) return "hackcamp"; return "nwhacks"; +} + +/** + * Obfuscates an email: shows first 2 characters then replace with asterisks until '@' + * e.g., "nugget2026@gmail.com" -> "nu********@gmail.com" + */ +export function obfuscateEmail(email: string): string { + if (!email) return ""; + const atIndex = email.indexOf("@"); + if (atIndex === -1) return email; + if (atIndex <= 2) { + const asterisks = "*".repeat(Math.max(0, atIndex)); + return asterisks + email.slice(atIndex); + } + const prefix = email.slice(0, 2); + const asterisks = "*".repeat(atIndex - 2); + const domain = email.slice(atIndex); + return `${prefix}${asterisks}${domain}`; +} + +/** + * Triggers a browser download of a CSV file + * @param content - CSV content as string + * @param filename - Name for the downloaded file + */ +export function downloadCSV(content: string, filename: string): void { + const blob = new Blob([content], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); } \ No newline at end of file diff --git a/src/routes/_auth/stampbook.tsx b/src/routes/_auth/stampbook.tsx index b9a0550..55880ef 100644 --- a/src/routes/_auth/stampbook.tsx +++ b/src/routes/_auth/stampbook.tsx @@ -1,3 +1,4 @@ +import { ExportRaffleDialog } from "@/components/features/stampbook/export-raffle-dialog"; import { StampDialog } from "@/components/features/stampbook/stamp-dialog"; import { StampsTable } from "@/components/features/stampbook/stamps-table"; import { PageHeader } from "@/components/graphy/typo"; @@ -5,7 +6,7 @@ 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 { Plus } from "lucide-react"; +import { Download, Plus } from "lucide-react"; import { useEffect, useState } from "react"; export const Route = createFileRoute("/_auth/stampbook")({ @@ -14,7 +15,8 @@ export const Route = createFileRoute("/_auth/stampbook")({ function StampbookPage() { const [stamps, setStamps] = useState([]); - const [open, setOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [exportRaffleOpen, setExportRaffleOpen] = useState(false); useEffect(() => { const unsubStamps = subscribeToStamps((stamps: Stamp[]) => { @@ -29,14 +31,25 @@ function StampbookPage() {
Stampbook - +
+ + +
- setOpen(false)} /> + setExportRaffleOpen(false)} + stamps={stamps} + /> + setCreateOpen(false)} /> ); } diff --git a/src/services/stamps.ts b/src/services/stamps.ts index 92ee6d7..1f3869a 100644 --- a/src/services/stamps.ts +++ b/src/services/stamps.ts @@ -15,12 +15,22 @@ import { deleteDoc, deleteField, doc, + getDocs, onSnapshot, query, runTransaction, updateDoc, } from "firebase/firestore"; +/** + * Represents a user's collected stamp entry; used for exports. + */ +export interface HackerStampEntry { + displayName: string; + email: string; + stampId: string; +} + /** * Utility function that returns Stamps collection realtime data * @param callback - The function used to ingest the data @@ -129,3 +139,29 @@ 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). + * @returns Array of entries where each entry represents one stamp collected by a hacker + */ +export const fetchHackersWithStamps = async (): Promise => { + const socialsSnapshot = await getDocs(collection(db, "Socials")); + const entries: HackerStampEntry[] = []; + + for (const socialDoc of socialsSnapshot.docs) { + const socialData = socialDoc.data(); + const displayName = socialData.preferredName || "User"; + const email = socialData.email || ""; + const unlockedStamps: string[] = socialData.unlockedStamps || []; + + for (const stampId of unlockedStamps) { + entries.push({ + displayName, + email, + stampId: typeof stampId === "string" ? stampId : String(stampId), + }); + } + } + + return entries; +};