+
+
+ Competition Judging
+
+
+ Prize pool: ${rewardAmount}
+
+
+
+ {!isAfterDeadline ? (
+
+ Judging opens after the submission deadline.
+
+ ) : revealedSubmissions.length === 0 ? (
+
No submissions to review.
+ ) : (
+
+ {revealedSubmissions.map((submission, index) => {
+ const alreadyAwarded = awardedUserIds.has(
+ submission.participantUserId,
+ );
+ return (
+
+
+
+ Submission #{index + 1}
+
+
+ {new Date(submission.submittedAt).toLocaleString()}
+
+
+
+ {submission.workCid}
+
+
+
+
+ setPayoutBySubmission((prev) => ({
+ ...prev,
+ [submission.id]: event.target.value,
+ }))
+ }
+ disabled={state.finalized || alreadyAwarded}
+ />
+
+ setPointsBySubmission((prev) => ({
+ ...prev,
+ [submission.id]: event.target.value,
+ }))
+ }
+ disabled={state.finalized || alreadyAwarded}
+ />
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {state.finalized
+ ? "Contest finalized"
+ : "Finalizing locks further awards."}
+
+
+
+
+ );
+}
diff --git a/components/bounty/competition-status.tsx b/components/bounty/competition-status.tsx
new file mode 100644
index 0000000..31f7bcc
--- /dev/null
+++ b/components/bounty/competition-status.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useCompetitionBounty } from "@/hooks/use-competition-bounty";
+
+export function CompetitionStatus({
+ bountyId,
+ deadline,
+ maxParticipants = 5,
+}: {
+ bountyId: string;
+ deadline?: string | null;
+ maxParticipants?: number;
+}) {
+ const { state, isAfterDeadline, winnerAwards, consolationAwards } =
+ useCompetitionBounty({
+ bountyId,
+ submissionDeadline: deadline,
+ maxParticipants,
+ });
+
+ const totalSubmissions = state.submissions.length;
+
+ return (
+
+
+ Competition Status
+
+
+ {!isAfterDeadline ? (
+
+ {totalSubmissions} submissions received (hidden until deadline)
+
+ ) : (
+ <>
+
+ Reveal complete: {totalSubmissions} submissions visible
+
+ {state.submissions.length > 0 && (
+
+ {state.submissions.map((submission) => (
+
+
+ {submission.workCid}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {winnerAwards.length > 0 && (
+
+ Winner{winnerAwards.length > 1 ? "s" : ""}:{" "}
+ {winnerAwards
+ .map(
+ (award) =>
+ `${award.recipientDisplayName} ($${award.payoutAmount})`,
+ )
+ .join(", ")}
+
+ )}
+
+ {consolationAwards.length > 0 && (
+
+ Consolation:{" "}
+ {consolationAwards
+ .map(
+ (award) =>
+ `${award.recipientDisplayName} ($${award.payoutAmount})`,
+ )
+ .join(", ")}
+
+ )}
+
+ {state.finalized && (
+
+ Contest finalized. Further winner approvals are disabled.
+
+ )}
+
+ );
+}
diff --git a/components/bounty/competition-submission.tsx b/components/bounty/competition-submission.tsx
new file mode 100644
index 0000000..8511204
--- /dev/null
+++ b/components/bounty/competition-submission.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { formatDistanceToNowStrict } from "date-fns";
+import { Clock, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import { authClient } from "@/lib/auth-client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useCompetitionBounty } from "@/hooks/use-competition-bounty";
+
+type SessionUser = {
+ id: string;
+ name?: string | null;
+};
+
+export function CompetitionSubmission({
+ bountyId,
+ deadline,
+ maxParticipants = 5,
+}: {
+ bountyId: string;
+ deadline?: string | null;
+ maxParticipants?: number;
+}) {
+ const { data: session } = authClient.useSession();
+ const user = session?.user as SessionUser | undefined;
+ const [workCid, setWorkCid] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const {
+ state,
+ participantCount,
+ isAfterDeadline,
+ joinCompetition,
+ submitWork,
+ } = useCompetitionBounty({
+ bountyId,
+ submissionDeadline: deadline,
+ maxParticipants,
+ });
+
+ const displayName = user?.name?.trim() || "Contributor";
+ const isParticipant = Boolean(
+ user?.id && state.participants.some((it) => it.userId === user.id),
+ );
+ const hasSubmitted = Boolean(
+ user?.id &&
+ state.submissions.some((it) => it.participantUserId === user.id),
+ );
+ const isFull = participantCount >= maxParticipants;
+
+ const deadlineLabel = useMemo(() => {
+ if (!deadline) return "No deadline configured";
+ const asDate = new Date(deadline);
+ if (Number.isNaN(asDate.getTime())) return "No deadline configured";
+ if (isAfterDeadline) return "Submission window closed";
+ return `${formatDistanceToNowStrict(asDate)} remaining`;
+ }, [deadline, isAfterDeadline]);
+
+ const handleJoin = () => {
+ if (!user?.id) {
+ toast.error("Sign in to join this competition.");
+ return;
+ }
+ if (isFull && !isParticipant) {
+ toast.error("Competition is full.");
+ return;
+ }
+ joinCompetition({ userId: user.id, displayName });
+ toast.success("Joined competition.");
+ };
+
+ const handleSubmit = async () => {
+ if (!user?.id) {
+ toast.error("Sign in to submit work.");
+ return;
+ }
+ if (!isParticipant) {
+ toast.error("Join the competition before submitting.");
+ return;
+ }
+ if (!workCid.trim()) return;
+ setIsSubmitting(true);
+ try {
+ submitWork({
+ participantUserId: user.id,
+ participantDisplayName: displayName,
+ workCid: workCid.trim(),
+ });
+ toast.success(
+ hasSubmitted ? "Submission updated." : "Submission received.",
+ );
+ setWorkCid("");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Competition Entry
+
+
+ {participantCount}/{maxParticipants} joined
+
+
+
+
+
+ {deadlineLabel}
+
+
+
+
+
+ {state.participants.length > 0 ? (
+ state.participants.map((participant) => (
+
+ {participant.displayName}
+
+ ))
+ ) : (
+
No participants yet.
+ )}
+
+
+
+
+ {isParticipant && !isAfterDeadline && (
+
+
setWorkCid(event.target.value)}
+ />
+
+
+ Submissions remain hidden from all participants until deadline.
+
+
+ )}
+
+ {isAfterDeadline && (
+
+ Submission window closed. All entries are now revealed for judging.
+
+ )}
+
+ );
+}
diff --git a/hooks/use-competition-bounty.ts b/hooks/use-competition-bounty.ts
new file mode 100644
index 0000000..9ea91be
--- /dev/null
+++ b/hooks/use-competition-bounty.ts
@@ -0,0 +1,216 @@
+"use client";
+
+import { useMemo } from "react";
+import { useLocalStorage } from "@/hooks/use-local-storage";
+
+export type CompetitionParticipant = {
+ userId: string;
+ displayName: string;
+ joinedAt: string;
+};
+
+export type CompetitionSubmission = {
+ id: string;
+ participantUserId: string;
+ participantDisplayName: string;
+ workCid: string;
+ submittedAt: string;
+};
+
+export type CompetitionAward = {
+ id: string;
+ recipientUserId: string;
+ recipientDisplayName: string;
+ payoutAmount: number;
+ points: number;
+ isWinner: boolean;
+ createdAt: string;
+};
+
+type CompetitionState = {
+ participants: CompetitionParticipant[];
+ submissions: CompetitionSubmission[];
+ awards: CompetitionAward[];
+ finalized: boolean;
+ finalizedAt?: string;
+};
+
+const DEFAULT_MAX_PARTICIPANTS = 5;
+
+function makeId(prefix: string) {
+ return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
+}
+
+function hashString(value: string) {
+ let hash = 0;
+ for (let i = 0; i < value.length; i += 1) {
+ hash = (hash << 5) - hash + value.charCodeAt(i);
+ hash |= 0;
+ }
+ return hash;
+}
+
+export function useCompetitionBounty({
+ bountyId,
+ submissionDeadline,
+ maxParticipants = DEFAULT_MAX_PARTICIPANTS,
+}: {
+ bountyId: string;
+ submissionDeadline?: string | null;
+ maxParticipants?: number;
+}) {
+ const [state, setState] = useLocalStorage