diff --git a/components/bounty-detail/bounty-detail-submissions-card.tsx b/components/bounty-detail/bounty-detail-submissions-card.tsx
index 8fdd99e..8d5992c 100644
--- a/components/bounty-detail/bounty-detail-submissions-card.tsx
+++ b/components/bounty-detail/bounty-detail-submissions-card.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect, useRef } from "react";
import { Loader2, DollarSign } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -21,6 +21,7 @@ import {
useMarkSubmissionPaid,
} from "@/hooks/use-submission-mutations";
import { authClient } from "@/lib/auth-client";
+import { useSubmissionDraft } from "@/hooks/use-submission-draft";
interface ExtendedUser {
id: string;
@@ -44,6 +45,7 @@ export function BountyDetailSubmissionsCard({
}: BountyDetailSubmissionsCardProps) {
const { data: session } = authClient.useSession();
const submissions = bounty.submissions || [];
+ const { draft, clearDraft, autoSave } = useSubmissionDraft(bounty.id);
const [submitDialogOpen, setSubmitDialogOpen] = useState(false);
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
@@ -64,6 +66,24 @@ export function BountyDetailSubmissionsCard({
const reviewSubmission = useReviewSubmission();
const markSubmissionPaid = useMarkSubmissionPaid();
+ const hasHydratedDraft = useRef(false);
+
+ // Load draft on mount
+ useEffect(() => {
+ if (draft?.formData) {
+ setPrUrl(draft.formData.githubPullRequestUrl);
+ setSubmitComments(draft.formData.comments);
+ }
+ hasHydratedDraft.current = true;
+ }, [draft]);
+
+ // Auto-save on form changes
+ useEffect(() => {
+ if (!hasHydratedDraft.current) return;
+ const cleanup = autoSave({ githubPullRequestUrl: prUrl, comments: submitComments });
+ return cleanup;
+ }, [prUrl, submitComments, autoSave]);
+
const isOrgMember =
(session?.user as ExtendedUser)?.organizations?.includes(
bounty.organizationId,
@@ -77,6 +97,7 @@ export function BountyDetailSubmissionsCard({
githubPullRequestUrl: prUrl,
comments: submitComments.trim() || undefined,
});
+ clearDraft();
} catch (err) {
// Replace with toast or error UI as needed
console.error("Submit PR failed:", err);
@@ -161,6 +182,11 @@ export function BountyDetailSubmissionsCard({
Submit Pull Request
Submit your GitHub pull request URL.
+ {draft && (
+
+ Draft restored from {new Date(draft.updatedAt).toLocaleString()}
+
+ )}
diff --git a/docs/SUBMISSION_DRAFTS_EXAMPLE.tsx b/docs/SUBMISSION_DRAFTS_EXAMPLE.tsx
new file mode 100644
index 0000000..cb5c256
--- /dev/null
+++ b/docs/SUBMISSION_DRAFTS_EXAMPLE.tsx
@@ -0,0 +1,68 @@
+/**
+ * Submission Draft System - Quick Start Example
+ *
+ * This example shows how to integrate the submission draft system
+ * into any form component.
+ */
+
+import { useState, useEffect } from "react";
+import { useSubmissionDraft } from "@/hooks/use-submission-draft";
+
+export function SubmissionFormExample({ bountyId }: { bountyId: string }) {
+ const { draft, clearDraft, autoSave } = useSubmissionDraft(bountyId);
+
+ const [prUrl, setPrUrl] = useState(draft?.formData.githubPullRequestUrl || "");
+ const [comments, setComments] = useState(draft?.formData.comments || "");
+
+ // 2. Auto-save when form changes
+ useEffect(() => {
+ const cleanup = autoSave({
+ githubPullRequestUrl: prUrl,
+ comments
+ });
+ return cleanup;
+ }, [prUrl, comments, autoSave]);
+
+ // 3. Clear draft on successful submit
+ const handleSubmit = async () => {
+ // Your submit logic here
+ await submitToAPI({ prUrl, comments });
+
+ // Clear draft after success
+ clearDraft();
+
+ // Reset form
+ setPrUrl("");
+ setComments("");
+ };
+
+ return (
+
+ );
+}
+
+// Mock submit function
+async function submitToAPI(data: { prUrl: string; comments: string }) {
+ console.log("Submitting:", data);
+}
diff --git a/hooks/__tests__/use-submission-draft.test.ts b/hooks/__tests__/use-submission-draft.test.ts
new file mode 100644
index 0000000..8754752
--- /dev/null
+++ b/hooks/__tests__/use-submission-draft.test.ts
@@ -0,0 +1,85 @@
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { useSubmissionDraft } from "../use-submission-draft";
+
+describe("useSubmissionDraft", () => {
+ const bountyId = "test-bounty-123";
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("should initialize with no draft", () => {
+ const { result } = renderHook(() => useSubmissionDraft(bountyId));
+ expect(result.current.draft).toBeNull();
+ });
+
+ it("should save draft", () => {
+ const { result } = renderHook(() => useSubmissionDraft(bountyId));
+ const formData = {
+ githubPullRequestUrl: "https://github.com/test/pr/1",
+ comments: "Test comment",
+ };
+
+ act(() => {
+ result.current.saveDraft(formData);
+ });
+
+ expect(result.current.draft).not.toBeNull();
+ expect(result.current.draft?.formData).toEqual(formData);
+ expect(result.current.draft?.bountyId).toBe(bountyId);
+ });
+
+ it("should clear draft", () => {
+ const { result } = renderHook(() => useSubmissionDraft(bountyId));
+ const formData = {
+ githubPullRequestUrl: "https://github.com/test/pr/1",
+ comments: "Test comment",
+ };
+
+ act(() => {
+ result.current.saveDraft(formData);
+ });
+
+ expect(result.current.draft).not.toBeNull();
+
+ act(() => {
+ result.current.clearDraft();
+ });
+
+ expect(result.current.draft).toBeNull();
+ });
+
+ it("should auto-save after delay", async () => {
+ const { result } = renderHook(() => useSubmissionDraft(bountyId));
+ const formData = {
+ githubPullRequestUrl: "https://github.com/test/pr/1",
+ comments: "Auto-saved comment",
+ };
+
+ act(() => {
+ result.current.autoSave(formData);
+ });
+
+ await waitFor(
+ () => {
+ expect(result.current.draft?.formData).toEqual(formData);
+ },
+ { timeout: 1500 }
+ );
+ });
+
+ it("should persist draft across hook instances", () => {
+ const { result: result1 } = renderHook(() => useSubmissionDraft(bountyId));
+ const formData = {
+ githubPullRequestUrl: "https://github.com/test/pr/1",
+ comments: "Persisted comment",
+ };
+
+ act(() => {
+ result1.current.saveDraft(formData);
+ });
+
+ const { result: result2 } = renderHook(() => useSubmissionDraft(bountyId));
+ expect(result2.current.draft?.formData).toEqual(formData);
+ });
+});
diff --git a/hooks/use-submission-draft.ts b/hooks/use-submission-draft.ts
new file mode 100644
index 0000000..a3574ad
--- /dev/null
+++ b/hooks/use-submission-draft.ts
@@ -0,0 +1,47 @@
+import { useCallback } from "react";
+import { useLocalStorage } from "./use-local-storage";
+import type { SubmissionDraft, SubmissionForm } from "@/types/submission-draft";
+
+const DRAFT_KEY_PREFIX = "submission_draft_";
+const AUTO_SAVE_DELAY = 1000;
+
+export function useSubmissionDraft(bountyId: string) {
+ const draftKey = `${DRAFT_KEY_PREFIX}${bountyId}`;
+ const [draft, setDraft] = useLocalStorage(draftKey, null);
+
+ const saveDraft = useCallback(
+ (formData: SubmissionForm) => {
+ const newDraft: SubmissionDraft = {
+ id: `draft_${bountyId}_${Date.now()}`,
+ bountyId,
+ formData,
+ updatedAt: new Date().toISOString(),
+ };
+ setDraft(newDraft);
+ },
+ [bountyId, setDraft]
+ );
+
+ const clearDraft = useCallback(() => {
+ setDraft(null);
+ }, [setDraft]);
+
+ const autoSave = useCallback(
+ (formData: SubmissionForm) => {
+ const timer = setTimeout(() => {
+ if (formData.githubPullRequestUrl || formData.comments) {
+ saveDraft(formData);
+ }
+ }, AUTO_SAVE_DELAY);
+ return () => clearTimeout(timer);
+ },
+ [saveDraft]
+ );
+
+ return {
+ draft,
+ saveDraft,
+ clearDraft,
+ autoSave,
+ };
+}
diff --git a/types/submission-draft.ts b/types/submission-draft.ts
new file mode 100644
index 0000000..c95e362
--- /dev/null
+++ b/types/submission-draft.ts
@@ -0,0 +1,11 @@
+export interface SubmissionForm {
+ githubPullRequestUrl: string;
+ comments: string;
+}
+
+export interface SubmissionDraft {
+ id: string;
+ bountyId: string;
+ formData: SubmissionForm;
+ updatedAt: string;
+}