From 4145bac8c94c30b3a2e4df10cf440e1baac602cd Mon Sep 17 00:00:00 2001 From: pntech20 Date: Sat, 23 May 2026 09:28:07 +0700 Subject: [PATCH] feat: add optimistic bounty status updates --- .../src/app/[locale]/bounties/[id]/page.tsx | 53 ++++---- .../[locale]/bounties/my-bounties/page.tsx | 13 +- frontend/src/app/[locale]/bounties/page.tsx | 7 +- .../bounties/components/BountyCard.tsx | 11 +- frontend/src/features/bounties/types.ts | 5 +- frontend/src/store/bountyStore.ts | 114 ++++++++++++++++++ 6 files changed, 167 insertions(+), 36 deletions(-) create mode 100644 frontend/src/store/bountyStore.ts diff --git a/frontend/src/app/[locale]/bounties/[id]/page.tsx b/frontend/src/app/[locale]/bounties/[id]/page.tsx index 799ac3d3..b31ab7c9 100644 --- a/frontend/src/app/[locale]/bounties/[id]/page.tsx +++ b/frontend/src/app/[locale]/bounties/[id]/page.tsx @@ -3,11 +3,11 @@ import React, { useState, use } from "react"; import Image from "next/image"; import ReactMarkdown from "react-markdown"; import { CodeBlock } from "@/components/markdown/CodeBlock"; -import { MOCK_BOUNTIES } from "@/lib/mocks/bounties"; import { StatusBadge } from "@/features/bounties/components/BountyCard"; import { SubmissionForm } from "@/features/bounties/components/SubmissionForm"; import { BountyApplicationForm } from "@/features/bounties/components/BountyApplicationForm"; import { toast, Toaster } from "sonner"; +import { useBountyStore } from "@/store/bountyStore"; import { Clock, Wallet, @@ -30,34 +30,29 @@ interface PageProps { export default function BountyDetailPage({ params }: PageProps) { const resolvedParams = use(params); const id = resolvedParams.id; - const bounty = MOCK_BOUNTIES.find((b) => b.id === id) || MOCK_BOUNTIES[0]; + const bounty = useBountyStore((state) => + state.bounties.find((b) => b.id === id) ?? state.bounties[0], + ); + const acceptSubmission = useBountyStore((state) => state.acceptSubmission); + const submitBounty = useBountyStore((state) => state.submitBounty); + const displayedStatus = bounty.optimisticStatus ?? bounty.status; const [showApplicationForm, setShowApplicationForm] = useState(false); - const [viewState, setViewState] = useState< - "idle" | "claimed" | "submitting" | "completed" - >("idle"); + const [viewState, setViewState] = useState<"idle" | "submitting">("idle"); const handleClaim = () => { - const promise = () => new Promise((resolve) => setTimeout(resolve, 1500)); - - toast.promise(promise, { - loading: "Initializing neural link to mission...", - success: () => { - setViewState("claimed"); - return "Mission Initialized. Good luck, contributor."; - }, - error: "Connection failed.", - }); + void acceptSubmission(bounty.id); }; const handleFinalSubmit = () => { - toast.success("SUBMISSION RECEIVED", { + toast("SUBMISSION RECEIVED", { description: "Your work has been encrypted and sent to the guild for review.", icon: , }); - setViewState("completed"); + setViewState("idle"); + void submitBounty(bounty.id); }; return ( @@ -86,9 +81,14 @@ export default function BountyDetailPage({ params }: PageProps) {
+ {bounty.isPending && ( + + Awaiting Stellar confirmation + + )}
{bounty.difficulty} @@ -188,16 +188,18 @@ export default function BountyDetailPage({ params }: PageProps) {
- {viewState === "idle" && ( + {displayedStatus === "Open" && viewState === "idle" && ( <> )} {viewState === "submitting" && ( setViewState("claimed")} - onSubmit={handleFinalSubmit} + onCancel={() => setViewState("idle")} + onSubmit={handleFinalSubmit} /> )} - {viewState === "completed" && ( + {displayedStatus === "Under Review" && viewState === "idle" && (
Submitted diff --git a/frontend/src/app/[locale]/bounties/my-bounties/page.tsx b/frontend/src/app/[locale]/bounties/my-bounties/page.tsx index 79b36f93..81ff1c42 100644 --- a/frontend/src/app/[locale]/bounties/my-bounties/page.tsx +++ b/frontend/src/app/[locale]/bounties/my-bounties/page.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useMemo } from "react"; -import { MOCK_BOUNTIES } from "@/lib/mocks/bounties"; import { BountyCard } from "@/features/bounties/components/BountyCard"; +import { useBountyStore } from "@/store/bountyStore"; import { LayoutDashboard, PlusCircle, @@ -20,20 +20,21 @@ type TabType = "Active" | "Completed" | "Created"; export default function MyBountiesDashboard() { const router = useRouter(); + const bounties = useBountyStore((state) => state.bounties); const [activeTab, setActiveTab] = useState("Active"); const displayData = useMemo(() => { switch (activeTab) { case "Active": - return MOCK_BOUNTIES.filter((b) => b.status === "Claimed" || b.status === "Under Review"); + return bounties.filter((b) => b.status === "Claimed" || b.status === "Under Review"); case "Completed": - return MOCK_BOUNTIES.filter((b) => b.status === "Completed"); + return bounties.filter((b) => b.status === "Completed"); case "Created": - return MOCK_BOUNTIES.slice(0, 1); + return bounties.slice(0, 1); default: return []; } - }, [activeTab]); + }, [activeTab, bounties]); return (
@@ -237,4 +238,4 @@ const ActivityItem = ({ title, desc, time, status }: { title: string; desc: stri
); -}; \ No newline at end of file +}; diff --git a/frontend/src/app/[locale]/bounties/page.tsx b/frontend/src/app/[locale]/bounties/page.tsx index 75765388..4e71bf0f 100644 --- a/frontend/src/app/[locale]/bounties/page.tsx +++ b/frontend/src/app/[locale]/bounties/page.tsx @@ -1,8 +1,8 @@ "use client"; import React, { useState, useMemo } from "react"; -import { MOCK_BOUNTIES } from "@/lib/mocks/bounties"; import { BountyCard } from "@/features/bounties/components/BountyCard"; +import { useBountyStore } from "@/store/bountyStore"; import { Search, Zap, @@ -24,13 +24,14 @@ type SortOption = "Newest" | "Highest Reward" | "Expiring Soon"; export default function MarketplacePage() { const router = useRouter(); + const bounties = useBountyStore((state) => state.bounties); const [search, setSearch] = useState(""); const [filterStatus, setFilterStatus] = useState("All"); const [sortBy, setSortBy] = useState("Newest"); const [activeCategory, setActiveCategory] = useState("All"); const filteredAndSortedBounties = useMemo(() => { - const result = MOCK_BOUNTIES.filter((bounty) => { + const result = bounties.filter((bounty) => { const matchesSearch = bounty.title .toLowerCase() .includes(search.toLowerCase()); @@ -47,7 +48,7 @@ export default function MarketplacePage() { if (sortBy === "Newest") return Number(b.id) - Number(a.id); return 0; }); - }, [search, filterStatus, sortBy]); + }, [bounties, search, filterStatus, sortBy]); return (
diff --git a/frontend/src/features/bounties/components/BountyCard.tsx b/frontend/src/features/bounties/components/BountyCard.tsx index cb7bb06c..ee7c6bed 100644 --- a/frontend/src/features/bounties/components/BountyCard.tsx +++ b/frontend/src/features/bounties/components/BountyCard.tsx @@ -11,6 +11,8 @@ interface StatusBadgeProps { } export const BountyCard = ({ bounty }: { bounty: Bounty }) => { + const status = bounty.optimisticStatus ?? bounty.status; + return ( { {bounty.guildName}
- +
+ {bounty.isPending && ( + + Pending + + )} + +
diff --git a/frontend/src/features/bounties/types.ts b/frontend/src/features/bounties/types.ts index 1f4850e2..3775bc17 100644 --- a/frontend/src/features/bounties/types.ts +++ b/frontend/src/features/bounties/types.ts @@ -15,4 +15,7 @@ export interface Bounty { deadline: string; applicants: number; tags: string[]; -} \ No newline at end of file + isPending?: boolean; + optimisticStatus?: BountyStatus; + error?: string; +} diff --git a/frontend/src/store/bountyStore.ts b/frontend/src/store/bountyStore.ts new file mode 100644 index 00000000..20d89eb3 --- /dev/null +++ b/frontend/src/store/bountyStore.ts @@ -0,0 +1,114 @@ +import { create } from "zustand"; +import { toast } from "sonner"; +import { MOCK_BOUNTIES } from "@/lib/mocks/bounties"; +import { Bounty, BountyStatus } from "@/features/bounties/types"; + +interface BountyStore { + bounties: Bounty[]; + acceptSubmission: (bountyId: string) => Promise; + submitBounty: (bountyId: string) => Promise; + resetBounties: () => void; +} + +const NETWORK_DELAY_MS = 3000; +const FAILURE_RATE = 0.2; + +const waitForMockStellarConfirmation = () => + new Promise((resolve, reject) => { + setTimeout(() => { + if (Math.random() < FAILURE_RATE) { + reject(new Error("Mock Stellar transaction failed")); + return; + } + + resolve(); + }, NETWORK_DELAY_MS); + }); + +const initialBounties = () => MOCK_BOUNTIES.map((bounty) => ({ ...bounty })); + +export const useBountyStore = create((set, get) => { + const applyOptimisticStatus = async ( + bountyId: string, + nextStatus: BountyStatus, + successMessage: string, + ) => { + const current = get().bounties.find((bounty) => bounty.id === bountyId); + + if (!current) { + toast.error("Bounty not found"); + return; + } + + const previousStatus = current.status; + + set((state) => ({ + bounties: state.bounties.map((bounty) => + bounty.id === bountyId + ? { + ...bounty, + status: nextStatus, + optimisticStatus: nextStatus, + isPending: true, + error: undefined, + } + : bounty, + ), + })); + + try { + await waitForMockStellarConfirmation(); + + set((state) => ({ + bounties: state.bounties.map((bounty) => + bounty.id === bountyId + ? { + ...bounty, + status: nextStatus, + optimisticStatus: undefined, + isPending: false, + error: undefined, + } + : bounty, + ), + })); + + toast.success(successMessage); + } catch { + set((state) => ({ + bounties: state.bounties.map((bounty) => + bounty.id === bountyId + ? { + ...bounty, + status: previousStatus, + optimisticStatus: undefined, + isPending: false, + error: "The mock Stellar transaction failed, so the status was rolled back.", + } + : bounty, + ), + })); + + toast.error("Mock Stellar confirmation failed", { + description: "Bounty status was restored to its previous state.", + }); + } + }; + + return { + bounties: initialBounties(), + acceptSubmission: (bountyId) => + applyOptimisticStatus( + bountyId, + "Claimed", + "Submission accepted after mock Stellar confirmation.", + ), + submitBounty: (bountyId) => + applyOptimisticStatus( + bountyId, + "Under Review", + "Bounty submission moved under review.", + ), + resetBounties: () => set({ bounties: initialBounties() }), + }; +});