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
Binary file added public/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icon-maskable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
93 changes: 93 additions & 0 deletions src/app/issues/[id]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ImageResponse } from "next/og";
import { fetchBounty } from "@/lib/api";
import { mockBounties } from "@/lib/mock-data";
import { formatCurrency } from "@/lib/utils";

export const alt = "MergeFi bounty";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function BountyOpenGraphImage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const { data: bounty } = await fetchBounty(
id,
mockBounties.find((b) => b.id === id),
);

const title = bounty?.title ?? "Bounty not found";
const repo = bounty ? `${bounty.org}/${bounty.repo}` : "MergeFi";
const reward = bounty ? formatCurrency(bounty.reward, bounty.asset) : "";
const status = bounty ? bounty.status.replace(/_/g, " ").toUpperCase() : "";

return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
backgroundColor: "#0a0a0f",
color: "#ffffff",
padding: "72px",
fontFamily: "Arial",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "18px" }}>
<div
style={{
display: "flex",
width: "44px",
height: "44px",
alignItems: "center",
justifyContent: "center",
borderRadius: "12px",
backgroundColor: "#a3e635",
color: "#0a0a0f",
fontSize: "27px",
fontWeight: 700,
}}
>
M
</div>
<span style={{ fontSize: "28px", fontWeight: 700 }}>MergeFi</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div style={{ display: "flex", color: "#a1a1aa", fontSize: "28px" }}>
{repo}
</div>
<div
style={{
display: "flex",
fontSize: "56px",
fontWeight: 700,
lineHeight: 1.1,
maxHeight: "190px",
overflow: "hidden",
}}
>
{title.length > 100 ? `${title.slice(0, 97)}...` : title}
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", color: "#a3e635", fontSize: "48px", fontWeight: 700 }}>
{reward}
</div>
<div style={{ display: "flex", color: "#71717a", fontSize: "24px" }}>
{status}
</div>
</div>
</div>,
size,
);
}
4 changes: 3 additions & 1 deletion src/app/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ describe("web app manifest", () => {
start_url: "/",
display: "standalone",
background_color: "#0a0a0f",
theme_color: "#fbfbfd",
theme_color: "#0a0a0f",
icons: expect.arrayContaining([
expect.objectContaining({ src: "/icon-192.png", sizes: "192x192" }),
expect.objectContaining({ src: "/icon.png", sizes: "512x512" }),
expect.objectContaining({ src: "/icon-maskable.png", purpose: "maskable" }),
expect.objectContaining({ src: "/apple-icon.png", sizes: "180x180" }),
]),
});
Expand Down
14 changes: 13 additions & 1 deletion src/app/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,26 @@ export default function manifest(): MetadataRoute.Manifest {
start_url: "/",
display: "standalone",
background_color: "#0a0a0f",
theme_color: "#fbfbfd",
theme_color: "#0a0a0f",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icon.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icon-maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "/apple-icon.png",
sizes: "180x180",
Expand Down
15 changes: 15 additions & 0 deletions src/components/bounty/ClaimButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@ export function ClaimButton({
const needsPayoutWallet = !!user && !user.stellarAddress;

if (status === 'claimed') {
const claimedById = bounty?.claimedById ?? fallbackBounty?.claimedById;
if (user && claimedById && user.id === claimedById) {
return (
<div
className={`p-4 bg-green-50 border border-green-200 rounded-lg ${className}`}
role="status"
>
<h4 className="text-green-700 font-semibold">You&apos;re working on this</h4>
<p className="text-green-600 text-sm mt-1">
Open a pull request that says <code>Fixes #{bounty?.issueNumber ?? fallbackBounty?.issueNumber}</code> in
its description. The reward is released automatically when it merges.
</p>
</div>
);
}
return (
<div className={`p-4 bg-gray-50 border border-gray-200 rounded-lg ${className}`}>
<p className="text-gray-600">
Expand Down
9 changes: 8 additions & 1 deletion src/hooks/useClaimRace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { apiPost, fetchBounty } from '@/lib/api';
import { adaptBounty, type RawBounty } from '@/lib/adapters';
import { generateIdempotencyKey } from '@/lib/utils';
import type { Bounty, ClaimResult } from '@/types/bounty';

interface ApiError {
Expand All @@ -16,6 +17,10 @@ export function useClaimRace(bountyId: string, onClaimSuccess?: (bounty: Bounty)
// skip state updates once unmounted. Set to true in the effect body too so
// React Strict Mode's simulated unmount/remount doesn't leave it false.
const isMountedRef = useRef(true);
// POST /bounties/:id/claim requires an Idempotency-Key header. One key per
// claim intent: reused when a failed request is retried, rotated once the
// claim has a definitive outcome.
const idempotencyKeyRef = useRef(generateIdempotencyKey());

useEffect(() => {
isMountedRef.current = true;
Expand All @@ -35,7 +40,8 @@ export function useClaimRace(bountyId: string, onClaimSuccess?: (bounty: Bounty)
setIsClaiming(true);

try {
const raw = await apiPost<RawBounty>(`/bounties/${bountyId}/claim`, {});
const raw = await apiPost<RawBounty>(`/bounties/${bountyId}/claim`, {}, idempotencyKeyRef.current);
idempotencyKeyRef.current = generateIdempotencyKey();

const bounty = adaptBounty(raw);
const result: ClaimResult = {
Expand All @@ -55,6 +61,7 @@ export function useClaimRace(bountyId: string, onClaimSuccess?: (bounty: Bounty)
apiError?.message?.toLowerCase().includes('claimed by another user');

if (isAlreadyClaimed) {
idempotencyKeyRef.current = generateIdempotencyKey();
try {
const updatedResult = await fetchBounty(bountyId, undefined);
const result: ClaimResult = {
Expand Down