Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3d74dfc
feat: add raffle types and weighted draw helpers
dymackenzie Jul 30, 2026
ad33738
feat: add raffle service for prizes winners + entry pool
dymackenzie Jul 30, 2026
e9b1e1f
feat: add raffle prize and eligible stamp setup dialogs
dymackenzie Jul 30, 2026
28db33d
feat: add raffle draw stage and winners log
dymackenzie Jul 31, 2026
651eb8e
feat: run the stampbook raffle in admin
dymackenzie Jul 31, 2026
9e5f015
fix: added more raffle helper methods for prizes
dymackenzie Jul 31, 2026
927c8fd
fix: toast will only fail for one reason
dymackenzie Jul 31, 2026
87e1c02
fix: optimized caching + error throwing
dymackenzie Jul 31, 2026
e2f8478
fix: bugs about winning other prizes
dymackenzie Jul 31, 2026
d491951
fix: refactored stamp picker to another file
dymackenzie Aug 5, 2026
8deec4f
fix: refactored some datatypes out + simplfiied logic
dymackenzie Aug 6, 2026
9b63d4d
fix: let confirm and dialog take the variants the raffle needs
dymackenzie Aug 20, 2026
c871a1d
feat: split the raffle page into prizes, draw, and winners panels
dymackenzie Aug 20, 2026
77f957a
fix: match codebase style
dymackenzie Aug 20, 2026
55827eb
fix: minor style changes
dymackenzie Aug 20, 2026
5245ca9
merge: resolve conflicts with dev
dymackenzie Aug 20, 2026
cd3ce40
fix: toggle now hides email column
dymackenzie Sep 6, 2026
02a422d
Merge pull request #118 from nwplus/mackenzie/raffle-admin
dymackenzie Sep 6, 2026
fba3c2a
fix: split legal name question into two separate
geoff-jiang Sep 10, 2026
2c3815b
Merge pull request #120 from nwplus/geoff/split-legal-name-fields
geoff-jiang Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/components/features/hackerapp/hacker-app-main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useHackerApplication } from "@/providers/hacker-application-provider";
import { updateHackerAppSectionQuestions } from "@/services/hacker-application";
import { type SetStateAction, useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { SHOW_FORM_INPUT } from "./hacker-app-question";
import { LEGAL_NAME_FORM_INPUTS, SHOW_FORM_INPUT } from "./hacker-app-question";
import { HackerAppSection } from "./hacker-app-section";

export type HackerApplicationFormQuestions = {
Expand Down Expand Up @@ -61,6 +61,12 @@ const cleanSectionData = (data: HackerApplicationQuestion[]): HackerApplicationQ
* @returns true is valid
*/
const validateSectionData = (data: HackerApplicationQuestion[]): boolean => {
const hasFullLegalName = data.some((question) => question.type === "Full Legal Name");
const hasSplitLegalName = data.some(
(question) => question.formInput && LEGAL_NAME_FORM_INPUTS.includes(question.formInput),
);
if (hasFullLegalName && hasSplitLegalName) return false;

for (const question of data) {
// Title and type are necessary
if (!question.title || (!question.type && question.content === undefined)) return false;
Expand Down Expand Up @@ -107,9 +113,14 @@ export function HackerAppMain() {
.map((q) => q.formInput)
.filter((f): f is HackerApplicationQuestionFormInputField => f !== undefined);

const formInput = new Set(allFormInputs);
if (allQuestionTypes.includes("Full Legal Name")) {
for (const field of LEGAL_NAME_FORM_INPUTS) formInput.add(field);
}

return {
questionType: new Set(allQuestionTypes),
formInput: new Set(allFormInputs),
formInput,
};
}, [draft]);

Expand Down
21 changes: 19 additions & 2 deletions src/components/features/hackerapp/hacker-app-question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
HackerApplicationQuestion,
HackerApplicationQuestionFormInputField,
HackerApplicationQuestionType,
HackerApplicationSections,
} from "@/lib/firebase/types";
import { cn } from "@/lib/utils";
import { ChevronDown, ChevronUp, Plus, Trash } from "lucide-react";
Expand Down Expand Up @@ -45,6 +46,11 @@ const QUESTION_TYPES_UNIQUE: HackerApplicationQuestionType[] = [
"Country",
];

export const LEGAL_NAME_FORM_INPUTS: readonly HackerApplicationQuestionFormInputField[] = [
"legalFirstName",
"legalLastName",
];

// TODO: reorganize type and form input type?
const FORM_INPUT_OPTIONS: HackerApplicationQuestionFormInputField[] = [
"academicYear",
Expand All @@ -60,6 +66,8 @@ const FORM_INPUT_OPTIONS: HackerApplicationQuestionFormInputField[] = [
"haveTransExperience",
"identifyAsUnderrepresented",
"indigenousIdentification",
"legalFirstName",
"legalLastName",
"phoneNumber",
"preferredName",
"pronouns",
Expand All @@ -81,6 +89,7 @@ const SHOW_MAX_CHAR: HackerApplicationQuestionType[] = ["Long Answer"];

interface HackerAppQuestionProps {
index: number;
section: HackerApplicationSections;
question: HackerApplicationQuestion;
isContent?: boolean;
isLast?: boolean;
Expand All @@ -97,6 +106,7 @@ interface HackerAppQuestionProps {

export const HackerAppQuestion = memo(function HackerAppQuestion({
index,
section,
question,
isContent,
isLast = false,
Expand Down Expand Up @@ -137,11 +147,18 @@ export const HackerAppQuestion = memo(function HackerAppQuestion({
}
}, [index, question.options, onChange]);

const hasSplitLegalName = LEGAL_NAME_FORM_INPUTS.some((field) =>
usedFieldsRegistry.formInput.has(field),
);
const usableQuestionTypes = QUESTION_TYPES?.filter(
(qt) => !QUESTION_TYPES_UNIQUE.includes(qt) || !usedFieldsRegistry.questionType.has(qt),
(qt) =>
(qt !== "Full Legal Name" || !hasSplitLegalName) &&
(!QUESTION_TYPES_UNIQUE.includes(qt) || !usedFieldsRegistry.questionType.has(qt)),
);
const usableFormInputs = FORM_INPUT_OPTIONS?.filter(
(fi) => !usedFieldsRegistry.formInput.has(fi),
(fi) =>
!usedFieldsRegistry.formInput.has(fi) &&
(section === "BasicInfo" || !LEGAL_NAME_FORM_INPUTS.includes(fi)),
);
const isQuestionTypeDisabled = Boolean(
question.type && QUESTION_TYPES_UNIQUE.includes(question.type),
Expand Down
1 change: 1 addition & 0 deletions src/components/features/hackerapp/hacker-app-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function HackerAppSection({
<HackerAppQuestion
key={`${q._id}_${title}`}
index={i}
section={section}
question={q}
isContent={section === "Welcome"}
isLast={i === data.length - 1}
Expand Down
171 changes: 171 additions & 0 deletions src/components/features/raffle/raffle-draw-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { RaffleWinnerDialog } from "@/components/features/raffle/raffle-winner-dialog";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { RaffleEntrant, RafflePrize, RaffleWinner } from "@/lib/firebase/types";
import {
drawnCountForPrize,
entrantsEligibleForPrize,
remainingForPrize,
totalEntries,
} from "@/lib/raffle";
import { cn } from "@/lib/utils";
import { Gift, Loader2, RefreshCw, Stamp } from "lucide-react";
import { useState } from "react";

interface RaffleDrawPanelProps {
hackathon: string;
prizes: RafflePrize[];
winners: RaffleWinner[];
entrants: RaffleEntrant[];
eligibleStampCount: number;
poolLoading: boolean;
poolFetchedAt: Date | null;
onRefreshPool: () => void;
onManageStamps: () => void;
className?: string;
}

export function RaffleDrawPanel({
hackathon,
prizes,
winners,
entrants,
eligibleStampCount,
poolLoading,
poolFetchedAt,
onRefreshPool,
onManageStamps,
className,
}: RaffleDrawPanelProps) {
const [selectedPrizeId, setSelectedPrizeId] = useState<string>("");
const [drawOpen, setDrawOpen] = useState<boolean>(false);

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;

return (
<section className={cn("flex flex-col gap-4", className)}>
<h2 className="font-medium text-3xl">Live Raffle</h2>
<p className="text-lg text-muted-foreground">Select a prize and draw a winner.</p>

<div className="flex flex-wrap items-start gap-8">
<div className="flex w-[280px] max-w-full flex-col gap-4">
<p className="font-medium text-muted-foreground text-xl">1. Select Prize</p>
<Select value={selectedPrize?._id ?? ""} onValueChange={setSelectedPrizeId}>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Select a prize..." />
</SelectTrigger>
<SelectContent>
{prizes
.filter((prize) => prize._id)
.map((prize) => {
const drawn = drawnCountForPrize(winners, prize._id);
return (
<SelectItem
key={prize._id}
value={prize._id as string}
disabled={drawn >= prize.quantity}
>
{prize.name} · {drawn}/{prize.quantity} drawn
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>

<div className="flex flex-col gap-4">
<p className="font-medium text-muted-foreground text-xl">2. Draw Winner</p>
<Button
variant="outline"
onClick={() => setDrawOpen(true)}
disabled={!!disabledReason}
title={disabledReason ?? undefined}
>
<Gift className="h-4 w-4" />
Draw Winner
</Button>
</div>
</div>

<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground text-sm">
{poolLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="size-3 animate-spin" />
Loading entries...
</span>
) : (
<span>
<span className="font-medium text-foreground">{poolEntries}</span> entries from{" "}
<span className="font-medium text-foreground">{entrants.length}</span> hackers across{" "}
{eligibleStampCount} eligible stamp{eligibleStampCount === 1 ? "" : "s"}
</span>
)}
{poolFetchedAt && !poolLoading && (
<span className="text-xs">· as of {poolFetchedAt.toLocaleTimeString()}</span>
)}
<Button
variant="ghost"
size="sm"
onClick={onRefreshPool}
disabled={poolLoading}
className="h-auto px-2 py-1 text-xs"
>
<RefreshCw className="size-3" />
Refresh
</Button>
<Button
variant="ghost"
size="sm"
onClick={onManageStamps}
className="h-auto px-2 py-1 text-xs"
>
<Stamp className="size-3" />
Eligible stamps
</Button>
</div>

{disabledReason && <p className="text-muted-foreground text-xs">{disabledReason}</p>}

<RaffleWinnerDialog
open={drawOpen}
onClose={() => setDrawOpen(false)}
hackathon={hackathon}
prize={selectedPrize}
drawPool={drawPool}
winners={winners}
/>
</section>
);
}
Loading