- |
+ |
No milestone submissions found.
@@ -662,10 +803,10 @@ const MilestoneQueue: React.FC = () => {
className="border-b border-white/5 hover:bg-white/3 transition-colors"
>
|
-
@@ -679,6 +820,10 @@ const MilestoneQueue: React.FC = () => {
|
|
+
+ +{milestone.peerApprovalCount} / −
+ {milestone.peerRejectionCount}
+ |
{
type="button"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
+
+ aria-label="Previous page"
+
+
className="px-3 py-1 rounded-xl border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
← Prev
@@ -741,6 +890,9 @@ const MilestoneQueue: React.FC = () => {
type="button"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
+
+ aria-label="Next page"
+
className="px-3 py-1 rounded-xl border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Next →
@@ -761,6 +913,8 @@ const MilestoneQueue: React.FC = () => {
)
}
+export default Admin
+
const UserLookup: React.FC = () => {
const [search, setSearch] = useState("")
const [submittedAddress, setSubmittedAddress] = useState(null)
@@ -1416,4 +1570,124 @@ const WikiManagement: React.FC = () => {
)
}
-export default Admin
+interface ScholarshipMetricsData {
+ active_scholarships: number
+ total_scholars: number
+ completion_rate: number
+ avg_milestones_per_scholar: number
+ dropout_rate: number
+ total_usdc_disbursed: number
+}
+
+const ScholarshipMetrics: React.FC = () => {
+ const [metrics, setMetrics] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ const fetchMetrics = async () => {
+ try {
+ const res = await fetch(`${API_BASE}/api/scholarships/metrics`)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const data = await res.json()
+ setMetrics(data)
+ } catch (err) {
+ setError("Failed to load metrics")
+ } finally {
+ setLoading(false)
+ }
+ }
+ void fetchMetrics()
+ }, [])
+
+ const chartData = metrics
+ ? [
+ {
+ name: "Completion Rate",
+ value: metrics.completion_rate,
+ fill: "#00d2ff",
+ },
+ {
+ name: "Dropout Rate",
+ value: metrics.dropout_rate,
+ fill: "#ff4d4d",
+ },
+ ]
+ : []
+
+ return (
+
+ Scholarship Program Health
+
+ {loading && (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {error && {error} }
+
+ {metrics && (
+ <>
+
+ {[
+ {
+ label: "Active Scholarships",
+ value: metrics.active_scholarships,
+ },
+ { label: "Total Scholars", value: metrics.total_scholars },
+ {
+ label: "Completion Rate",
+ value: `${metrics.completion_rate}%`,
+ },
+ {
+ label: "Avg Milestones / Scholar",
+ value: metrics.avg_milestones_per_scholar,
+ },
+ { label: "Dropout Rate", value: `${metrics.dropout_rate}%` },
+ {
+ label: "Total USDC Disbursed",
+ value: `$${(metrics.total_usdc_disbursed / 1e7).toLocaleString(undefined, { maximumFractionDigits: 2 })}`,
+ },
+ ].map(({ label, value }) => (
+
+
+ {label}
+
+ {value}
+
+ ))}
+
+
+
+ Completion vs Dropout
+
+
+
+
+ `${value}%`} />
+
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/pages/Courses.tsx b/src/pages/Courses.tsx
index 95ad1a92..07eba362 100644
--- a/src/pages/Courses.tsx
+++ b/src/pages/Courses.tsx
@@ -1,6 +1,7 @@
import { BookOpen } from "lucide-react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useSearchParams } from "react-router-dom"
+import BookmarkButton from "../components/BookmarkButton"
import { CourseFilter } from "../components/CourseFilter"
import Pagination from "../components/Pagination"
import { CourseCardSkeleton } from "../components/skeletons/CourseCardSkeleton"
@@ -209,8 +210,12 @@ const Courses: React.FC = () => {
{paginatedCourses.map((course) => (
+ {/* Bookmark toggle — hidden when wallet not connected */}
+
+
+
@@ -237,6 +242,7 @@ const Courses: React.FC = () => {
{course.track}
Open course
diff --git a/src/pages/Dao.tsx b/src/pages/Dao.tsx
index 74d07acb..227c7f7a 100644
--- a/src/pages/Dao.tsx
+++ b/src/pages/Dao.tsx
@@ -3,6 +3,8 @@ import { Link } from "react-router-dom"
import { useDelegation } from "../hooks/useDelegation"
import { useProposals } from "../hooks/useProposals"
import { useWallet } from "../hooks/useWallet"
+import { hasProposalDraft } from "../util/proposalDraft"
+import { useState, useEffect } from "react"
const GOV_DECIMALS = 7
const GOV_DIVISOR = 10 ** GOV_DECIMALS
@@ -20,6 +22,11 @@ function shortenAddress(addr: string): string {
export default function Dao() {
const { address } = useWallet()
const { proposals, votingPower, isLoading } = useProposals()
+ const [hasDraft, setHasDraft] = useState(false)
+
+ useEffect(() => {
+ setHasDraft(hasProposalDraft())
+ }, [])
const {
delegatee,
isDelegating,
@@ -237,7 +244,7 @@ export default function Dao() {
Create Proposal
+ {hasDraft && (
+
+ )}
diff --git a/src/pages/DaoProposals.tsx b/src/pages/DaoProposals.tsx
index c1ba9191..d644614f 100644
--- a/src/pages/DaoProposals.tsx
+++ b/src/pages/DaoProposals.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from "react"
import { Helmet } from "react-helmet"
import { useSearchParams } from "react-router-dom"
+import ConfirmDialog from "../components/ConfirmDialog"
import CommentSection from "../components/CommentSection"
import Pagination from "../components/Pagination"
import { NoProposalsEmptyState } from "../components/SkeletonLoader"
@@ -10,6 +11,12 @@ import {
useProposal,
useProposals,
} from "../hooks/useProposals"
+import {
+ hasProposalDraft,
+ getDraftTimestamp,
+ clearProposalDraft,
+} from "../util/proposalDraft"
+import { useToast } from "../components/Toast/ToastProvider"
type FilterType =
| "Voting Open"
@@ -18,8 +25,6 @@ type FilterType =
| "Rejected"
| "All"
-type SortType = "newest" | "most-votes" | "ending-soon"
-
const ITEMS_PER_PAGE = 5
import AddressDisplay from "../components/AddressDisplay"
@@ -52,10 +57,6 @@ const getFilterValue = (proposal: ProposalRecord): FilterType => {
const DaoProposals: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams()
const [filter, setFilter] = useState("Voting Open")
- const [sort, setSort] = useState("newest")
- const [searchQuery, setSearchQuery] = useState(
- () => searchParams.get("q") ?? "",
- )
const [now, setNow] = useState(() => Date.now())
const {
proposals,
@@ -66,7 +67,56 @@ const DaoProposals: React.FC = () => {
isLoading,
error,
refetch,
+ cancelProposal,
+ isCancelling,
} = useProposals()
+ const { showSuccess } = useToast()
+
+ const [hasDraft, setHasDraft] = useState(false)
+ const [draftTimestamp, setDraftTimestamp] = useState(null)
+
+ useEffect(() => {
+ const existingDraft = hasProposalDraft()
+ setHasDraft(existingDraft)
+ if (existingDraft) {
+ setDraftTimestamp(getDraftTimestamp())
+ }
+ }, [])
+
+ const [showDeleteDraftConfirm, setShowDeleteDraftConfirm] = useState(false)
+
+ const handleDeleteDraft = () => {
+ clearProposalDraft()
+ setHasDraft(false)
+ setDraftTimestamp(null)
+ setShowDeleteDraftConfirm(false)
+ showSuccess("Draft deleted")
+ }
+
+ const formatDraftTime = (timestamp: number | null): string => {
+ if (!timestamp) return ""
+ const date = new Date(timestamp)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+
+ if (diffMins < 1) return "just now"
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
+ return date.toLocaleDateString()
+ }
+
+ const [showCancelConfirm, setShowCancelConfirm] = useState(false)
+
+ const handleCancelProposal = async () => {
+ if (!selectedProposal) return
+ try {
+ await cancelProposal(selectedProposal.id)
+ setShowCancelConfirm(false)
+ } catch (err) {
+ console.error("Cancel proposal failed", err)
+ }
+ }
const proposalParam = searchParams.get("proposal")
const pageParam = searchParams.get("page")
@@ -78,40 +128,9 @@ const DaoProposals: React.FC = () => {
Number.isNaN(parsedPage) || parsedPage < 1 ? 1 : parsedPage
const filteredProposals = useMemo(() => {
- let result =
- filter === "All"
- ? proposals
- : proposals.filter((proposal) => getFilterValue(proposal) === filter)
-
- if (searchQuery.trim()) {
- const q = searchQuery.toLowerCase()
- result = result.filter(
- (p) =>
- p.title.toLowerCase().includes(q) ||
- p.description.toLowerCase().includes(q),
- )
- }
-
- if (sort === "newest") {
- result = [...result].sort(
- (a, b) =>
- new Date(b.createdAt ?? 0).getTime() -
- new Date(a.createdAt ?? 0).getTime(),
- )
- } else if (sort === "most-votes") {
- result = [...result].sort((a, b) =>
- Number(b.votesFor + b.votesAgainst - a.votesFor - a.votesAgainst),
- )
- } else if (sort === "ending-soon") {
- result = [...result].sort((a, b) => {
- const aDeadline = a.deadline ? new Date(a.deadline).getTime() : Infinity
- const bDeadline = b.deadline ? new Date(b.deadline).getTime() : Infinity
- return aDeadline - bDeadline
- })
- }
-
- return result
- }, [filter, proposals, searchQuery, sort])
+ if (filter === "All") return proposals
+ return proposals.filter((proposal) => getFilterValue(proposal) === filter)
+ }, [filter, proposals])
const totalPages = Math.max(
1,
@@ -196,24 +215,9 @@ const DaoProposals: React.FC = () => {
setFilter(nextFilter)
const nextParams = new URLSearchParams(searchParams)
nextParams.set("page", "1")
- if (searchQuery.trim()) nextParams.set("q", searchQuery.trim())
- else nextParams.delete("q")
setSearchParams(nextParams)
}
- const handleSearchChange = (value: string) => {
- setSearchQuery(value)
- const nextParams = new URLSearchParams(searchParams)
- nextParams.set("page", "1")
- if (value.trim()) nextParams.set("q", value.trim())
- else nextParams.delete("q")
- setSearchParams(nextParams, { replace: true })
- }
-
- const handleSortChange = (nextSort: SortType) => {
- setSort(nextSort)
- }
-
const totalVotes = selectedProposal
? selectedProposal.votesFor + selectedProposal.votesAgainst
: 0n
@@ -296,6 +300,39 @@ const DaoProposals: React.FC = () => {
+ {hasDraft && (
+
+
+
+ 📝
+
+
+
+ Unfinished Proposal Draft
+
+
+ You have a draft saved {formatDraftTime(draftTimestamp)}.
+
+
+
+
+
+
+ Continue Editing
+
+
+
+ )}
+
{(
[
@@ -321,37 +358,29 @@ const DaoProposals: React.FC = () => {
))}
-
- handleSearchChange(e.target.value)}
- aria-label="Search proposals"
- className="flex-1 px-5 py-3 rounded-full border border-white/10 bg-white/5 text-white placeholder:text-white/40 text-sm font-medium focus:outline-none focus:border-brand-cyan/40 transition-colors"
+ {showCancelConfirm && selectedProposal && (
+ void handleCancelProposal()}
+ onCancel={() => setShowCancelConfirm(false)}
+ isDestructive
/>
-
-
+ )}
-
- {filteredProposals.length} result
- {filteredProposals.length !== 1 ? "s" : ""}
-
+ {showDeleteDraftConfirm && (
+ setShowDeleteDraftConfirm(false)}
+ isDestructive
+ />
+ )}
{selectedProposal && (
@@ -375,8 +404,21 @@ const DaoProposals: React.FC = () => {
-
- {selectedProposal.displayStatus}
+
+
+ {selectedProposal.displayStatus}
+
+ {selectedProposal.authorAddress === walletAddress &&
+ selectedProposal.displayStatus === "Voting Open" && (
+
+ )}
diff --git a/src/pages/DaoPropose.test.tsx b/src/pages/DaoPropose.test.tsx
index 20dd9e24..fbf21107 100644
--- a/src/pages/DaoPropose.test.tsx
+++ b/src/pages/DaoPropose.test.tsx
@@ -7,12 +7,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
import { useToast } from "../components/Toast/ToastProvider"
import { useProposals } from "../hooks/useProposals"
import { useWallet } from "../hooks/useWallet"
+import {
+ hasProposalDraft,
+ loadProposalDraft,
+ saveProposalDraft,
+ clearProposalDraft,
+} from "../util/proposalDraft"
import DaoPropose from "./DaoPropose"
vi.mock("../hooks/useWallet", () => ({
useWallet: vi.fn(),
}))
+vi.mock("../util/proposalDraft", () => ({
+ saveProposalDraft: vi.fn(),
+ loadProposalDraft: vi.fn(),
+ clearProposalDraft: vi.fn(),
+ hasProposalDraft: vi.fn(),
+ getDraftTimestamp: vi.fn(),
+}))
+
vi.mock("../hooks/useProposals", () => ({
useProposals: vi.fn(),
}))
@@ -29,6 +43,10 @@ const mockUseWallet = vi.mocked(useWallet)
const mockUseProposals = vi.mocked(useProposals)
const mockUseToast = vi.mocked(useToast)
const mockUseNavigate = vi.mocked(useNavigate)
+const mockHasProposalDraft = vi.mocked(hasProposalDraft)
+const mockLoadProposalDraft = vi.mocked(loadProposalDraft)
+const mockSaveProposalDraft = vi.mocked(saveProposalDraft)
+const mockClearProposalDraft = vi.mocked(clearProposalDraft)
const mockNavigate = vi.fn()
@@ -349,4 +367,88 @@ describe("DaoPropose", () => {
expect(screen.getByText("Network error")).toBeInTheDocument()
})
})
+
+ describe("Draft functionality", () => {
+ it("shows restore prompt if draft exists on mount", async () => {
+ mockHasProposalDraft.mockReturnValue(true)
+ render(, { wrapper: createWrapper() })
+
+ expect(
+ screen.getByText(/You have an unsaved draft/i),
+ ).toBeInTheDocument()
+ expect(screen.getByText("Restore Draft")).toBeInTheDocument()
+ })
+
+ it("restores draft when clicking Restore Draft", async () => {
+ const user = userEvent.setup()
+ mockHasProposalDraft.mockReturnValue(true)
+ mockLoadProposalDraft.mockReturnValue({
+ title: "Saved Title",
+ description: "Saved Description",
+ type: "scholarship",
+ applicationUrl: "",
+ fundingAmount: "",
+ parameterName: "",
+ parameterValue: "",
+ parameterReason: "",
+ courseTitle: "",
+ courseDescription: "",
+ courseDuration: "",
+ courseDifficulty: "",
+ savedAt: Date.now(),
+ })
+
+ render(, { wrapper: createWrapper() })
+
+ const restoreButton = screen.getByText("Restore Draft")
+ await user.click(restoreButton)
+
+ expect(screen.getByPlaceholderText("Enter proposal title")).toHaveValue(
+ "Saved Title",
+ )
+ expect(
+ screen.getByPlaceholderText(
+ "Enter the proposal details using Markdown formatting",
+ ),
+ ).toHaveValue("Saved Description")
+ })
+
+ it("clears draft after successful submission", async () => {
+ const user = userEvent.setup()
+ mockUseProposals.mockReturnValue({
+ createProposal: vi.fn().mockResolvedValue({ proposal_id: 123 }),
+ isSubmittingProposal: false,
+ votingPower: 100n,
+ } as unknown as ReturnType)
+
+ render(, { wrapper: createWrapper() })
+
+ const titleInput = screen.getByPlaceholderText("Enter proposal title")
+ await user.type(titleInput, "Test Title")
+ const descInput = screen.getByPlaceholderText(
+ "Enter the proposal details using Markdown formatting",
+ )
+ await user.type(descInput, "Test Description")
+
+ const submitButton = screen.getByTestId("submit-proposal")
+ await user.click(submitButton)
+
+ await waitFor(() => {
+ expect(mockClearProposalDraft).toHaveBeenCalled()
+ })
+ })
+
+ it("allows deleting draft manually", async () => {
+ const user = userEvent.setup()
+ vi.spyOn(window, "confirm").mockReturnValue(true)
+ mockHasProposalDraft.mockReturnValue(true)
+
+ render(, { wrapper: createWrapper() })
+
+ const deleteButton = screen.getByText(/Discard/i)
+ await user.click(deleteButton)
+
+ expect(mockClearProposalDraft).toHaveBeenCalled()
+ })
+ })
})
diff --git a/src/pages/DaoPropose.tsx b/src/pages/DaoPropose.tsx
index f7a4cfd8..0ff066f5 100644
--- a/src/pages/DaoPropose.tsx
+++ b/src/pages/DaoPropose.tsx
@@ -1,10 +1,18 @@
-import React, { useMemo, useState } from "react"
+import React, { useEffect, useMemo, useState, useCallback, useRef } from "react"
import ReactMarkdown from "react-markdown"
import { useNavigate } from "react-router-dom"
import { useToast } from "../components/Toast/ToastProvider"
import { WalletButton } from "../components/WalletButton"
import { useProposals } from "../hooks/useProposals"
import { useWallet } from "../hooks/useWallet"
+import {
+ saveProposalDraft,
+ loadProposalDraft,
+ clearProposalDraft,
+ hasProposalDraft,
+ getDraftTimestamp,
+ ProposalDraft,
+} from "../util/proposalDraft"
type ProposalType = "scholarship" | "parameter_change" | "new_course"
@@ -69,7 +77,7 @@ const DaoPropose: React.FC = () => {
isLoadingVotingPower,
isVotingPowerError,
} = useProposals()
- const { showError } = useToast()
+ const { showError, showSuccess } = useToast()
const [activeTab, setActiveTab] = useState<"edit" | "preview">("edit")
const [submissionError, setSubmissionError] = useState(null)
const [formErrors, setFormErrors] = useState({})
@@ -78,8 +86,80 @@ const DaoPropose: React.FC = () => {
)
const [createdTxHash, setCreatedTxHash] = useState(null)
const [formData, setFormData] = useState(initialFormData)
+ const [hasDraft, setHasDraft] = useState(false)
+ const [showRestorePrompt, setShowRestorePrompt] = useState(false)
+ const [draftTimestamp, setDraftTimestamp] = useState(null)
+
+ // Check for existing draft on mount
+ useEffect(() => {
+ const existingDraft = hasProposalDraft()
+ setHasDraft(existingDraft)
+ if (existingDraft) {
+ const timestamp = getDraftTimestamp()
+ setDraftTimestamp(timestamp)
+ setShowRestorePrompt(true)
+ }
+ }, [])
+
+ // Auto-save draft with debounce
+ useEffect(() => {
+ // Only save if there's actual content
+ const hasContent =
+ formData.title.trim() ||
+ formData.description.trim() ||
+ formData.applicationUrl.trim() ||
+ formData.fundingAmount.trim() ||
+ formData.parameterName.trim() ||
+ formData.parameterValue.trim() ||
+ formData.courseTitle.trim()
+
+ if (!hasContent) return
+
+ const timeout = setTimeout(() => {
+ saveProposalDraft(formData)
+ setHasDraft(true)
+ setDraftTimestamp(Date.now())
+ }, 500)
+
+ return () => clearTimeout(timeout)
+ }, [formData])
+
+ // Handle restore draft
+ const handleRestoreDraft = () => {
+ const draft = loadProposalDraft()
+ if (draft) {
+ const { savedAt, ...draftData } = draft
+ setFormData(draftData as FormData)
+ showSuccess("Draft restored successfully")
+ }
+ setShowRestorePrompt(false)
+ }
+
+ // Handle delete draft
+ const [showDeleteDraftConfirm, setShowDeleteDraftConfirm] = useState(false)
+
+ const handleDeleteDraft = () => {
+ clearProposalDraft()
+ setHasDraft(false)
+ setDraftTimestamp(null)
+ setShowRestorePrompt(false)
+ setShowDeleteDraftConfirm(false)
+ showSuccess("Draft deleted")
+ }
- // Allow form access while loading or when the voting power API is unreachable
+ // Format draft timestamp for display
+ const formatDraftTime = (timestamp: number | null): string => {
+ if (!timestamp) return ""
+ const date = new Date(timestamp)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+
+ if (diffMins < 1) return "just now"
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`
+ return date.toLocaleDateString()
+ }
const hasMinimumBalance =
isLoadingVotingPower ||
isVotingPowerError ||
@@ -217,6 +297,10 @@ const DaoPropose: React.FC = () => {
setCreatedTxHash(created.tx_hash ?? null)
setFormData(initialFormData)
setFormErrors({})
+ // Clear draft after successful submission
+ clearProposalDraft()
+ setHasDraft(false)
+ setDraftTimestamp(null)
} catch (error) {
const message =
error instanceof Error
@@ -520,16 +604,76 @@ const DaoPropose: React.FC = () => {
return (
+ {showDeleteDraftConfirm && (
+ setShowDeleteDraftConfirm(false)}
+ isDestructive
+ />
+ )}
-
+
+
Create Proposal
-
- Submit a governance proposal to the backend API for community review
- and voting.
-
-
+ {hasDraft && (
+
+
+ Draft
+ {draftTimestamp && (
+
+ ({formatDraftTime(draftTimestamp)})
+
+ )}
+
+ )}
+
+
+ Submit a governance proposal to the backend API for community review
+ and voting.
+
+ {hasDraft && !showRestorePrompt && (
+
+ )}
+
+ {showRestorePrompt && (
+
+
+ You have an unsaved draft from{" "}
+
+ {draftTimestamp && formatDraftTime(draftTimestamp)}
+
+ . Would you like to restore it?
+
+
+
+
+
+
+ )}
|