diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..19cf0a4 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-maskable.png b/public/icon-maskable.png new file mode 100644 index 0000000..a116e2b Binary files /dev/null and b/public/icon-maskable.png differ diff --git a/src/app/issues/[id]/opengraph-image.tsx b/src/app/issues/[id]/opengraph-image.tsx new file mode 100644 index 0000000..27f18c8 --- /dev/null +++ b/src/app/issues/[id]/opengraph-image.tsx @@ -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( +
+
+
+ M +
+ MergeFi +
+
+
+ {repo} +
+
+ {title.length > 100 ? `${title.slice(0, 97)}...` : title} +
+
+
+
+ {reward} +
+
+ {status} +
+
+
, + size, + ); +} diff --git a/src/app/manifest.test.ts b/src/app/manifest.test.ts index e68eaee..ee196cd 100644 --- a/src/app/manifest.test.ts +++ b/src/app/manifest.test.ts @@ -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" }), ]), }); diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 616bbbc..caf2d1b 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -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", diff --git a/src/components/bounty/ClaimButton.tsx b/src/components/bounty/ClaimButton.tsx index 3e11d4c..2be80ee 100644 --- a/src/components/bounty/ClaimButton.tsx +++ b/src/components/bounty/ClaimButton.tsx @@ -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 ( +
+

You're working on this

+

+ Open a pull request that says Fixes #{bounty?.issueNumber ?? fallbackBounty?.issueNumber} in + its description. The reward is released automatically when it merges. +

+
+ ); + } return (

diff --git a/src/hooks/useClaimRace.ts b/src/hooks/useClaimRace.ts index 599ad4d..ba20a67 100644 --- a/src/hooks/useClaimRace.ts +++ b/src/hooks/useClaimRace.ts @@ -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 { @@ -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; @@ -35,7 +40,8 @@ export function useClaimRace(bountyId: string, onClaimSuccess?: (bounty: Bounty) setIsClaiming(true); try { - const raw = await apiPost(`/bounties/${bountyId}/claim`, {}); + const raw = await apiPost(`/bounties/${bountyId}/claim`, {}, idempotencyKeyRef.current); + idempotencyKeyRef.current = generateIdempotencyKey(); const bounty = adaptBounty(raw); const result: ClaimResult = { @@ -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 = {