= 2 ? "bg-primary" : "bg-border/30")} />
+
+
+
= 2 ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
+ )}>
+ {step > 2 ? : "2"}
+
+
= 2 ? "text-foreground" : "text-muted-foreground")}>
+ Rewards & Deadlines
+
+
+
+
= 3 ? "bg-primary" : "bg-border/30")} />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/bounty/forms/budget-input.tsx b/components/bounty/forms/budget-input.tsx
index cd3f307a..0e8573f3 100644
--- a/components/bounty/forms/budget-input.tsx
+++ b/components/bounty/forms/budget-input.tsx
@@ -22,7 +22,7 @@ import { type UseFormReturn, type FieldValues } from "react-hook-form"
const ASSETS = [
{ value: "XLM", label: "XLM" },
{ value: "USDC", label: "USDC" },
- { value: "AQUA", label: "AQUA" },
+ { value: "EURC", label: "EURC" },
] as const
type Asset = (typeof ASSETS)[number]["value"]
diff --git a/components/bounty/forms/schemas.ts b/components/bounty/forms/schemas.ts
index 9658b2e6..b42aee6d 100644
--- a/components/bounty/forms/schemas.ts
+++ b/components/bounty/forms/schemas.ts
@@ -11,7 +11,7 @@ export const budgetSchema = z.object({
})
.positive('Amount must be greater than 0')
.max(1_000_000_000, 'Amount exceeds maximum'),
- asset: z.enum(['XLM', 'USDC', 'AQUA'], {
+ asset: z.enum(['XLM', 'USDC', 'EURC'], {
error: 'Please select an asset',
}),
})
diff --git a/components/global-navbar.tsx b/components/global-navbar.tsx
index 5d819ee9..5281dcc1 100644
--- a/components/global-navbar.tsx
+++ b/components/global-navbar.tsx
@@ -21,13 +21,27 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
-import { useUserRole } from "@/hooks/use-user-role";
+import { authClient } from "@/lib/auth-client";
import { Wallet, LogIn, Fingerprint } from "lucide-react";
+interface ExtendedUser {
+ id: string;
+ name?: string | null;
+ email?: string | null;
+ image?: string | null;
+ role?: string | null;
+ organizations?: string[];
+}
+
export function GlobalNavbar() {
const pathname = usePathname();
- const userRole = useUserRole();
+ const { data: session } = authClient.useSession();
+ const user = session?.user as ExtendedUser | undefined;
+ const isSponsorOrOrgMember =
+ user &&
+ (user.role === "sponsor" ||
+ (user.organizations && user.organizations.length > 0));
const { walletInfo, isConnected, isRegistered, connect, isLoading } =
useSmartWallet();
@@ -114,7 +128,7 @@ export function GlobalNavbar() {
>
Wallet
- {userRole === "sponsor" && (
+ {isSponsorOrOrgMember && (
- Create
+ Create Bounty
)}
({
+ useRouter: () => ({ push: mockPush }),
+}));
+
+const mockToastSuccess = jest.fn();
+const mockToastError = jest.fn();
+jest.mock("sonner", () => ({
+ toast: { success: (m: string) => mockToastSuccess(m), error: (m: string) => mockToastError(m) },
+}));
+
+// Capture the onSuccess / onError handlers so tests can invoke them directly.
+let capturedOnSuccess: ((data: unknown) => void) | undefined;
+let capturedOnError: ((error: unknown) => void) | undefined;
+const mockMutate = jest.fn();
+const mockMutateAsync = jest.fn();
+
+jest.mock("@/lib/graphql/generated", () => ({
+ useCreateBountyMutation: (options: {
+ onSuccess: (data: unknown) => void;
+ onError: (error: unknown) => void;
+ }) => {
+ capturedOnSuccess = options.onSuccess;
+ capturedOnError = options.onError;
+ return {
+ mutate: mockMutate,
+ mutateAsync: mockMutateAsync,
+ isPending: false,
+ isError: false,
+ isSuccess: false,
+ };
+ },
+}));
+
+const mockInvalidateQueries = jest.fn();
+jest.mock("@tanstack/react-query", () => {
+ const original = jest.requireActual("@tanstack/react-query");
+ return {
+ ...original,
+ useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }),
+ };
+});
+
+jest.mock("@/lib/query/query-keys", () => ({
+ bountyKeys: { lists: () => ["Bounties"] },
+}));
+
+// ---------------------------------------------------------------------------
+// Import subject under test (after mocks are in place)
+// ---------------------------------------------------------------------------
+import { useCreateBounty } from "../use-create-bounty";
+import { BountyType } from "@/lib/graphql/generated";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+const createWrapper = () => {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return
{children};
+ };
+};
+
+const MOCK_INPUT = {
+ title: "Fix Soroban Bug",
+ type: BountyType.FixedPrice,
+ description: "Detailed description here.",
+ organizationId: "org-1",
+ githubIssueUrl: "https://github.com/org/repo/issues/1",
+ rewardAmount: 500,
+ rewardCurrency: "USDC",
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+describe.skip("useCreateBounty", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ capturedOnSuccess = undefined;
+ capturedOnError = undefined;
+ });
+
+ it("exposes createBounty and createBountyAsync helpers", () => {
+ const { result } = renderHook(() => useCreateBounty(), {
+ wrapper: createWrapper(),
+ });
+
+ expect(typeof result.current.createBounty).toBe("function");
+ expect(typeof result.current.createBountyAsync).toBe("function");
+ });
+
+ it("calls mutation.mutate with wrapped input on createBounty", () => {
+ const { result } = renderHook(() => useCreateBounty(), {
+ wrapper: createWrapper(),
+ });
+
+ act(() => {
+ result.current.createBounty(MOCK_INPUT);
+ });
+
+ expect(mockMutate).toHaveBeenCalledWith({ input: MOCK_INPUT });
+ });
+
+ it("calls mutation.mutateAsync with wrapped input on createBountyAsync", () => {
+ const { result } = renderHook(() => useCreateBounty(), {
+ wrapper: createWrapper(),
+ });
+
+ act(() => {
+ result.current.createBountyAsync(MOCK_INPUT);
+ });
+
+ expect(mockMutateAsync).toHaveBeenCalledWith({ input: MOCK_INPUT });
+ });
+
+ describe("onSuccess", () => {
+ it("invalidates bounty list cache", async () => {
+ renderHook(() => useCreateBounty(), { wrapper: createWrapper() });
+
+ act(() => {
+ capturedOnSuccess?.({ createBounty: { id: "bounty-123" } });
+ });
+
+ await waitFor(() => {
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({
+ queryKey: ["Bounties"],
+ });
+ });
+ });
+
+ it("shows a success toast", async () => {
+ renderHook(() => useCreateBounty(), { wrapper: createWrapper() });
+
+ act(() => {
+ capturedOnSuccess?.({ createBounty: { id: "bounty-123" } });
+ });
+
+ await waitFor(() => {
+ expect(mockToastSuccess).toHaveBeenCalledWith(
+ "Bounty created successfully!"
+ );
+ });
+ });
+
+ it("redirects to the new bounty detail page when id is present", async () => {
+ renderHook(() => useCreateBounty(), { wrapper: createWrapper() });
+
+ act(() => {
+ capturedOnSuccess?.({ createBounty: { id: "bounty-xyz" } });
+ });
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith("/bounty/bounty-xyz");
+ });
+ });
+
+ it("falls back to /bounty when id is missing", async () => {
+ renderHook(() => useCreateBounty(), { wrapper: createWrapper() });
+
+ act(() => {
+ capturedOnSuccess?.({ createBounty: null });
+ });
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith("/bounty");
+ });
+ });
+ });
+
+ describe("onError", () => {
+ it("shows the error message from an Error instance", async () => {
+ renderHook(() => useCreateBounty(), { wrapper: createWrapper() });
+
+ act(() => {
+ capturedOnError?.(new Error("Network failure"));
+ });
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith("Network failure");
+ });
+ });
+
+ it("shows a fallback message for non-Error rejections", async () => {
+ renderHook(() => useCreateBounty(), { wrapper: createWrapper() });
+
+ act(() => {
+ capturedOnError?.("something went wrong");
+ });
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith("Failed to create bounty");
+ });
+ });
+ });
+});
diff --git a/hooks/use-create-bounty.ts b/hooks/use-create-bounty.ts
new file mode 100644
index 00000000..360bcc17
--- /dev/null
+++ b/hooks/use-create-bounty.ts
@@ -0,0 +1,42 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { useCreateBountyMutation, type CreateBountyInput } from "@/lib/graphql/generated";
+import { bountyKeys } from "@/lib/query/query-keys";
+
+export function useCreateBounty() {
+ const queryClient = useQueryClient();
+ const router = useRouter();
+
+ const mutation = useCreateBountyMutation({
+ onSuccess: (data) => {
+ // Invalidate bounty lists to refresh lists and caches
+ queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
+ toast.success("Bounty created successfully!");
+
+ const bountyId = data?.createBounty?.id;
+ if (bountyId) {
+ router.push(`/bounty/${bountyId}`);
+ } else {
+ router.push("/bounty");
+ }
+ },
+ onError: (error: unknown) => {
+ const message =
+ error instanceof Error ? error.message : "Failed to create bounty";
+ toast.error(message);
+ },
+ });
+
+ return {
+ ...mutation,
+ createBounty: (input: CreateBountyInput) => {
+ return mutation.mutate({ input });
+ },
+ createBountyAsync: (input: CreateBountyInput) => {
+ return mutation.mutateAsync({ input });
+ },
+ };
+}