Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 28 additions & 25 deletions frontend/src/app/[locale]/bounties/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: <CheckCircle2 className="text-violet-500" size={18} />,
});
setViewState("completed");
setViewState("idle");
void submitBounty(bounty.id);
};

return (
Expand Down Expand Up @@ -86,9 +81,14 @@ export default function BountyDetailPage({ params }: PageProps) {
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
status={
viewState === "completed" ? "Under Review" : bounty.status
viewState === "submitting" ? "Claimed" : displayedStatus
}
/>
{bounty.isPending && (
<span className="text-[10px] font-mono text-amber-400 uppercase tracking-widest">
Awaiting Stellar confirmation
</span>
)}
<div className="h-1 w-1 bg-slate-700 rounded-full" />
<span className="text-[10px] font-mono text-violet-500 uppercase tracking-widest bg-violet-500/10 px-2 py-1 rounded">
{bounty.difficulty}
Expand Down Expand Up @@ -188,16 +188,18 @@ export default function BountyDetailPage({ params }: PageProps) {
</div>

<div className="space-y-3">
{viewState === "idle" && (
{displayedStatus === "Open" && viewState === "idle" && (
<>
<button
onClick={handleClaim}
disabled={bounty.isPending}
className="w-full bg-white text-black py-5 rounded-2xl font-black uppercase tracking-widest hover:bg-violet-500 transition-all active:scale-95 shadow-[0_0_30px_rgba(255,255,255,0.1)]"
>
Initialize Mission
{bounty.isPending ? "Confirming..." : "Initialize Mission"}
</button>
<button
onClick={() => setShowApplicationForm(true)}
disabled={bounty.isPending}
className="w-full py-4 bg-violet-500/10 border border-violet-500/30 text-violet-400 rounded-2xl font-black uppercase tracking-widest hover:bg-violet-500/20 transition-all flex items-center justify-center gap-2"
>
<Send size={14} />
Expand All @@ -206,23 +208,24 @@ export default function BountyDetailPage({ params }: PageProps) {
</>
)}

{viewState === "claimed" && (
{displayedStatus === "Claimed" && viewState === "idle" && (
<button
onClick={() => setViewState("submitting")}
disabled={bounty.isPending}
className="w-full bg-violet-500 text-black py-5 rounded-2xl font-black uppercase tracking-widest transition-all hover:bg-violet-400"
>
Upload Submission
{bounty.isPending ? "Confirming..." : "Upload Submission"}
</button>
)}

{viewState === "submitting" && (
<SubmissionForm
onCancel={() => setViewState("claimed")}
onSubmit={handleFinalSubmit}
onCancel={() => setViewState("idle")}
onSubmit={handleFinalSubmit}
/>
)}

{viewState === "completed" && (
{displayedStatus === "Under Review" && viewState === "idle" && (
<div className="w-full bg-white/5 border border-slate-800/10 text-violet-500 py-5 rounded-2xl font-black uppercase tracking-widest text-center flex items-center justify-center gap-2">
<CheckCircle2 size={18} />
Submitted
Expand Down
13 changes: 7 additions & 6 deletions frontend/src/app/[locale]/bounties/my-bounties/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<TabType>("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 (
<div className="min-h-screen bg-slate-950 text-white p-4 md:p-8 lg:p-12 selection:bg-violet-500/30">
Expand Down Expand Up @@ -237,4 +238,4 @@ const ActivityItem = ({ title, desc, time, status }: { title: string; desc: stri
</div>
</div>
);
};
};
7 changes: 4 additions & 3 deletions frontend/src/app/[locale]/bounties/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<SortOption>("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());
Expand All @@ -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 (
<div className="min-h-screen w-full bg-slate-950 text-white selection:bg-violet-500/30">
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/features/bounties/components/BountyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface StatusBadgeProps {
}

export const BountyCard = ({ bounty }: { bounty: Bounty }) => {
const status = bounty.optimisticStatus ?? bounty.status;

return (
<motion.div
whileHover={{ y: -4 }}
Expand All @@ -31,7 +33,14 @@ export const BountyCard = ({ bounty }: { bounty: Bounty }) => {
{bounty.guildName}
</span>
</div>
<StatusBadge status={bounty.status} />
<div className="flex items-center gap-2">
{bounty.isPending && (
<span className="text-[8px] font-black uppercase tracking-widest text-amber-400">
Pending
</span>
)}
<StatusBadge status={status} />
</div>
</div>

<Link href={`/bounties/${bounty.id}`}>
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/features/bounties/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ export interface Bounty {
deadline: string;
applicants: number;
tags: string[];
}
isPending?: boolean;
optimisticStatus?: BountyStatus;
error?: string;
}
114 changes: 114 additions & 0 deletions frontend/src/store/bountyStore.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
submitBounty: (bountyId: string) => Promise<void>;
resetBounties: () => void;
}

const NETWORK_DELAY_MS = 3000;
const FAILURE_RATE = 0.2;

const waitForMockStellarConfirmation = () =>
new Promise<void>((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<BountyStore>((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() }),
};
});