Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
156 changes: 79 additions & 77 deletions components/bounty-detail/bounty-detail-sidebar-cta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,88 +39,90 @@ export function SidebarCTA({ bounty }: { bounty: Bounty }) {
};

return (
<div className="p-5 rounded-xl border border-gray-800 bg-background-card backdrop-blur-xl shadow-sm space-y-5">
{/* Reward */}
<div className="flex items-start justify-between gap-2">
<span className="text-xs text-gray-500 uppercase tracking-wider font-medium mt-1">
Reward
</span>
<div className="text-right">
<p className="text-2xl font-black text-primary tabular-nums leading-tight">
{bounty.rewardAmount != null
? `$${bounty.rewardAmount.toLocaleString()}`
: "TBD"}
</p>
<p className="text-[10px] text-gray-500 font-medium">
{bounty.rewardCurrency}
</p>
<div className="space-y-4">
<div className="p-5 rounded-xl border border-gray-800 bg-background-card backdrop-blur-xl shadow-sm space-y-5">
{/* Reward */}
<div className="flex items-start justify-between gap-2">
<span className="text-xs text-gray-500 uppercase tracking-wider font-medium mt-1">
Reward
</span>
<div className="text-right">
<p className="text-2xl font-black text-primary tabular-nums leading-tight">
{bounty.rewardAmount != null
? `$${bounty.rewardAmount.toLocaleString()}`
: "TBD"}
</p>
<p className="text-[10px] text-gray-500 font-medium">
{bounty.rewardCurrency}
</p>
</div>
</div>
</div>

<Separator className="bg-gray-800/60" />

{/* Meta */}
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between text-gray-400">
<span>Status</span>
<StatusBadge status={bounty.status} />
</div>
<div className="flex items-center justify-between text-gray-400">
<span>Type</span>
<TypeBadge type={bounty.type} />
<Separator className="bg-gray-800/60" />

{/* Meta */}
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between text-gray-400">
<span>Status</span>
<StatusBadge status={bounty.status} />
</div>
<div className="flex items-center justify-between text-gray-400">
<span>Type</span>
<TypeBadge type={bounty.type} />
</div>
</div>
</div>

<Separator className="bg-gray-800/60" />

{/* CTA */}
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
onClick={() =>
canAct &&
window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer")
}
>
{ctaLabel()}
</Button>

{!canAct && (
<p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center">
<AlertCircle className="size-3 shrink-0" />
This bounty is no longer accepting new submissions.
</p>
)}

{/* GitHub */}
<a
href={bounty.githubIssueUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center justify-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors py-1"
>
<Github className="size-3" />
View on GitHub
</a>

{/* Copy link */}
<button
onClick={handleCopy}
className="w-full flex items-center justify-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors py-1"
>
{copied ? (
<>
<Check className="size-3 text-emerald-400" />
<span className="text-emerald-400">Copied!</span>
</>
) : (
<>
<Copy className="size-3" />
Copy link
</>
<Separator className="bg-gray-800/60" />

{/* CTA */}
<Button
className="w-full h-11 font-bold tracking-wide"
disabled={!canAct}
size="lg"
onClick={() =>
canAct &&
window.open(bounty.githubIssueUrl, "_blank", "noopener,noreferrer")
}
>
{ctaLabel()}
</Button>

{!canAct && (
<p className="flex items-center gap-1.5 text-xs text-gray-500 justify-center text-center">
<AlertCircle className="size-3 shrink-0" />
This bounty is no longer accepting new submissions.
</p>
)}
</button>

{/* GitHub */}
<a
href={bounty.githubIssueUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center justify-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors py-1"
>
<Github className="size-3" />
View on GitHub
</a>

{/* Copy link */}
<button
onClick={handleCopy}
className="w-full flex items-center justify-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors py-1"
>
{copied ? (
<>
<Check className="size-3 text-emerald-400" />
<span className="text-emerald-400">Copied!</span>
</>
) : (
<>
<Copy className="size-3" />
Copy link
</>
)}
</button>
</div>
</div>
);
}
Expand Down
196 changes: 111 additions & 85 deletions hooks/use-bounty-mutations.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,124 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
bountiesApi,
type Bounty,
type CreateBountyInput,
type UpdateBountyInput,
type PaginatedResponse,
} from '@/lib/api';
import { bountyKeys } from './use-bounties';
bountiesApi,
type Bounty,
type CreateBountyInput,
type UpdateBountyInput,
type PaginatedResponse,
} from "@/lib/api";
import { bountyKeys } from "./use-bounties";

export function useCreateBounty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (data: CreateBountyInput) => bountiesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
const queryClient = useQueryClient();

return useMutation({
mutationFn: (data: CreateBountyInput) => bountiesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}

export function useUpdateBounty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateBountyInput }) =>
bountiesApi.update(id, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) });
const previous = queryClient.getQueryData<Bounty>(bountyKeys.detail(id));

if (previous) {
queryClient.setQueryData<Bounty>(bountyKeys.detail(id), {
...previous,
...data,
updatedAt: new Date().toISOString(),
});
}

return { previous, id };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(bountyKeys.detail(context.id), context.previous);
}
},
onSettled: (_data, _err, { id }) => {
queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateBountyInput }) =>
bountiesApi.update(id, data),
onMutate: async ({ id, data }) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) });
const previous = queryClient.getQueryData<Bounty>(bountyKeys.detail(id));

if (previous) {
queryClient.setQueryData<Bounty>(bountyKeys.detail(id), {
...previous,
...data,
updatedAt: new Date().toISOString(),
});
}

return { previous, id };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(
bountyKeys.detail(context.id),
context.previous,
);
}
},
onSettled: (_data, _err, { id }) => {
queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}

export function useDeleteBounty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => bountiesApi.delete(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.lists() });

const previousLists = queryClient.getQueriesData<PaginatedResponse<Bounty>>({
queryKey: bountyKeys.lists(),
});

queryClient.setQueriesData<PaginatedResponse<Bounty>>(
{ queryKey: bountyKeys.lists() },
(old) => old ? {
...old,
data: old.data.filter((b) => b.id !== id),
pagination: { ...old.pagination, total: old.pagination.total - 1 },
} : old
);

return { previousLists };
},
onError: (_err, _id, context) => {
context?.previousLists.forEach(([key, data]) => {
queryClient.setQueryData(key, data);
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => bountiesApi.delete(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.lists() });

const previousLists = queryClient.getQueriesData<
PaginatedResponse<Bounty>
>({
queryKey: bountyKeys.lists(),
});

queryClient.setQueriesData<PaginatedResponse<Bounty>>(
{ queryKey: bountyKeys.lists() },
(old) =>
old
? {
...old,
data: old.data.filter((b) => b.id !== id),
pagination: {
...old.pagination,
total: old.pagination.total - 1,
},
}
: old,
);

return { previousLists };
},
onError: (_err, _id, context) => {
context?.previousLists.forEach(([key, data]) => {
queryClient.setQueryData(key, data);
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}

export function useClaimBounty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => bountiesApi.claim(id),
onSuccess: (data, id) => {
queryClient.setQueryData(bountyKeys.detail(id), data);
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => bountiesApi.claim(id),
onSuccess: (data, id) => {
queryClient.setQueryData<Bounty>(bountyKeys.detail(id), (old) => {
if (!old) return old;

// If the API returned a valid object, use it
if (data) {
return data;
}

// Otherwise, safely patch the existing data with the claimed state
return {
...old,
status: "IN_PROGRESS",
};
});

// Invalidate the list view so the main bounties board updates
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
},
});
}