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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ const routeParamsSchema = z.object({
issueNumber: z.coerce.number().int().positive(),
});

const approveRequestSchema = z
.object({
splitPayouts: z
.array(
z.object({
githubUsername: z.string().min(1),
amount: z.coerce.number().positive(),
prNumber: z.coerce.number().int().positive().nullable().optional(),
}),
)
.min(1)
.optional(),
})
.optional();

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ owner: string; repo: string; issueNumber: string }> },
Expand All @@ -28,11 +43,15 @@ export async function POST(
}

try {
const rawBody = await request.text();
const body = rawBody.trim().length > 0 ? approveRequestSchema.parse(JSON.parse(rawBody)) : undefined;

const result = await approveBountyPayout({
owner: routeParams.owner,
repo: routeParams.repo,
issueNumber: routeParams.issueNumber,
approvedBy: viewer.githubUsername,
splitPayouts: body?.splitPayouts,
});

return NextResponse.json({ success: true, payout: result });
Expand Down
143 changes: 138 additions & 5 deletions app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,89 @@
"use client";

import { useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";

import { approveBounty } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

type Props = {
owner: string;
repo: string;
issueNumber: number;
totalAmount: number;
payoutCandidates: Array<{
githubUsername: string;
prNumber: number | null;
}>;
};

export function ApproveButton({ owner, repo, issueNumber }: Props) {
type SplitRow = {
id: string;
githubUsername: string;
prNumber: string;
amount: string;
};

function toCents(amount: string): number {
const parsed = Number(amount);
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
}

export function ApproveButton({ owner, repo, issueNumber, totalAmount, payoutCandidates }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [successTxHash, setSuccessTxHash] = useState<string | null>(null);
const [useSplitPayout, setUseSplitPayout] = useState(false);
const [splitRows, setSplitRows] = useState<SplitRow[]>(() =>
payoutCandidates.length > 0
? payoutCandidates.map((candidate, index) => ({
id: `${candidate.githubUsername}-${candidate.prNumber ?? "no-pr"}-${index}`,
githubUsername: candidate.githubUsername,
prNumber: candidate.prNumber ? String(candidate.prNumber) : "",
amount: index === 0 ? totalAmount.toFixed(2) : "0.00",
}))
: [
{
id: "winner",
githubUsername: "",
prNumber: "",
amount: totalAmount.toFixed(2),
},
],
);

const allocatedCents = useMemo(
() => splitRows.reduce((sum, row) => sum + toCents(row.amount), 0),
[splitRows],
);
const totalCents = Math.round(totalAmount * 100);
const splitIsBalanced = allocatedCents === totalCents;

const onApprove = () => {
setError(null);
setSuccessTxHash(null);

startTransition(async () => {
try {
const response = await approveBounty({ owner, repo, issueNumber });
const splitPayouts = useSplitPayout
? splitRows
.filter((row) => toCents(row.amount) > 0)
.map((row) => ({
githubUsername: row.githubUsername,
amount: toCents(row.amount) / 100,
prNumber: row.prNumber.trim() ? Number(row.prNumber) : null,
}))
: undefined;
const response = await approveBounty({ owner, repo, issueNumber, splitPayouts });
const { payoutType, recipientEmail, recipientWallet } = response.payout;

let message = "";
if (payoutType === "wallet" && recipientWallet) {
if (response.payout.payouts && response.payout.payouts.length > 1) {
message = `Split payout sent to ${response.payout.payouts.length} recipients`;
} else if (payoutType === "wallet" && recipientWallet) {
message = `Payout sent to wallet ${recipientWallet.slice(0, 6)}...${recipientWallet.slice(-4)}`;
} else if (payoutType === "email" && recipientEmail) {
message = `Payout sent to ${recipientEmail}`;
Expand All @@ -45,13 +99,92 @@ export function ApproveButton({ owner, repo, issueNumber }: Props) {
});
};

const updateSplitRow = (id: string, patch: Partial<SplitRow>) => {
setSplitRows((rows) => rows.map((row) => row.id === id ? { ...row, ...patch } : row));
};

const addSplitRow = () => {
setSplitRows((rows) => [
...rows,
{
id: `manual-${Date.now()}`,
githubUsername: "",
prNumber: "",
amount: "0.00",
},
]);
};

const removeSplitRow = (id: string) => {
setSplitRows((rows) => rows.length > 1 ? rows.filter((row) => row.id !== id) : rows);
};

return (
<div className="rounded-2xl border border-emerald-300/30 bg-emerald-400/5 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-300/80">Maintainer Action</p>
<p className="mt-2 text-sm text-zinc-300">PR is merged and bounty is locked. Approve payout to release funds.</p>
<label className="mt-4 flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={useSplitPayout}
onChange={(event) => setUseSplitPayout(event.target.checked)}
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-emerald-400"
/>
Split payout across contributors
</label>
{useSplitPayout ? (
<div className="mt-4 space-y-3">
<div className="grid grid-cols-[1fr_72px_88px_32px] gap-2 text-xs uppercase tracking-[0.12em] text-zinc-500">
<span>GitHub</span>
<span>PR</span>
<span>USDC</span>
<span />
</div>
{splitRows.map((row) => (
<div key={row.id} className="grid grid-cols-[1fr_72px_88px_32px] gap-2">
<Input
value={row.githubUsername}
onChange={(event) => updateSplitRow(row.id, { githubUsername: event.target.value })}
placeholder="username"
className="h-9 border-zinc-700 bg-zinc-950 text-sm text-zinc-100"
/>
<Input
value={row.prNumber}
onChange={(event) => updateSplitRow(row.id, { prNumber: event.target.value.replace(/\D/g, "") })}
placeholder="#"
className="h-9 border-zinc-700 bg-zinc-950 text-sm text-zinc-100"
/>
<Input
value={row.amount}
onChange={(event) => updateSplitRow(row.id, { amount: event.target.value })}
inputMode="decimal"
className="h-9 border-zinc-700 bg-zinc-950 text-sm text-zinc-100"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeSplitRow(row.id)}
disabled={splitRows.length === 1}
className="h-9 w-8 text-zinc-400 hover:text-zinc-100"
>
x
</Button>
</div>
))}
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="outline" onClick={addSplitRow} className="border-zinc-700 bg-zinc-950 text-zinc-200">
Add recipient
</Button>
<p className={splitIsBalanced ? "text-sm text-emerald-300" : "text-sm text-amber-300"}>
${(allocatedCents / 100).toFixed(2)} / ${totalAmount.toFixed(2)}
</p>
</div>
</div>
) : null}
<Button
onClick={onApprove}
disabled={isPending}
disabled={isPending || (useSplitPayout && !splitIsBalanced)}
className="mt-4 h-10 w-full bg-emerald-400 text-black hover:bg-emerald-300"
>
{isPending ? (
Expand Down
33 changes: 33 additions & 0 deletions app/b/[owner]/[repo]/issues/[issueNumber]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,37 @@ export default async function BountyDetailPage(props: Props) {
bounty.issue_url ??
`https://github.com/${owner}/${repo}/issues/${issueNumber}`;
const nextPath = `/b/${owner}/${repo}/issues/${issueNumber}`;
const payoutCandidateMap = new Map<
string,
{ githubUsername: string; prNumber: number | null; rank: number }
>();

if (bounty.winning_pr_author) {
payoutCandidateMap.set(bounty.winning_pr_author.toLowerCase(), {
githubUsername: bounty.winning_pr_author,
prNumber: bounty.winning_pr_number,
rank: 0,
});
}

for (const event of bounty.activity) {
if (event.event_type !== "PR_COMPETING" || !event.actor_username) {
continue;
}

const key = event.actor_username.toLowerCase();
if (!payoutCandidateMap.has(key)) {
payoutCandidateMap.set(key, {
githubUsername: event.actor_username,
prNumber: event.pr_number,
rank: 1,
});
}
}

const payoutCandidates = [...payoutCandidateMap.values()]
.sort((a, b) => a.rank - b.rank || a.githubUsername.localeCompare(b.githubUsername))
.map(({ githubUsername, prNumber }) => ({ githubUsername, prNumber }));

return (
<div className="mx-auto max-w-6xl px-5 py-10 sm:px-8">
Expand Down Expand Up @@ -239,6 +270,8 @@ export default async function BountyDetailPage(props: Props) {
owner={owner}
repo={repo}
issueNumber={Number(issueNumber)}
totalAmount={bounty.total_amount}
payoutCandidates={payoutCandidates}
/>
</div>
) : null}
Expand Down
17 changes: 17 additions & 0 deletions lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ export async function approveBounty(params: {
owner: string;
repo: string;
issueNumber: number;
splitPayouts?: Array<{
githubUsername: string;
amount: number;
prNumber?: number | null;
}>;
}): Promise<{
success: boolean;
payout: {
Expand All @@ -166,12 +171,24 @@ export async function approveBounty(params: {
txHash: string | null;
transactionId: string;
approvedBy: string;
splitPayout?: boolean;
payouts?: Array<{
amount: number;
recipient: string;
payoutType: "wallet" | "email" | "unclaimed";
recipientEmail: string | null;
recipientWallet: string | null;
txHash: string | null;
transactionId: string;
}>;
};
}> {
const res = await fetch(
`${API_BASE}/api/bounty/${params.owner}/${params.repo}/issues/${params.issueNumber}/approve`,
{
method: "POST",
headers: params.splitPayouts ? { "Content-Type": "application/json" } : undefined,
body: params.splitPayouts ? JSON.stringify({ splitPayouts: params.splitPayouts }) : undefined,
},
);

Expand Down
Loading