Skip to content
Merged
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
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
165 changes: 89 additions & 76 deletions hooks/use-bounty-mutations.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,111 @@
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();
const queryClient = useQueryClient();

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

export function useUpdateBounty() {
const queryClient = useQueryClient();
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));
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(),
});
}
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() });
},
});
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();
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => bountiesApi.delete(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: bountyKeys.lists() });
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(),
});
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
);
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() });
},
});
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();
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() });
},
});
return useMutation({
mutationFn: (id: string) => bountiesApi.claim(id),
onSuccess: (data, id) => {
queryClient.setQueryData<Bounty>(bountyKeys.detail(id), data);

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