diff --git a/src/app/issues/page.tsx b/src/app/issues/page.tsx index 3ae26c8..cf40540 100644 --- a/src/app/issues/page.tsx +++ b/src/app/issues/page.tsx @@ -48,11 +48,14 @@ export default async function IssuesPage() { description="Check back soon — funded issues appear here once sponsors lock them in escrow." /> ) : ( -
+ <> +

Available bounties

+
{bounties.map((bounty) => ( ))}
+ )}
); diff --git a/src/app/milestones/MilestoneActions.tsx b/src/app/milestones/MilestoneActions.tsx index 978f9a6..297c2da 100644 --- a/src/app/milestones/MilestoneActions.tsx +++ b/src/app/milestones/MilestoneActions.tsx @@ -6,7 +6,13 @@ import { Button } from "@/components/ui/Button"; import { useWallet } from "@/context/WalletContext"; import { apiPost, ApiRequestError } from "@/lib/api"; -export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) { +export function MilestoneFundButton({ + milestoneId, + milestoneName, +}: { + milestoneId: string; + milestoneName?: string; +}) { const router = useRouter(); const { address, connect, connecting, getError: getWalletError } = useWallet(); const [pending, setPending] = useState(false); @@ -38,7 +44,13 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) { return (
- {error && ( @@ -50,7 +62,13 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) { ); } -export function PoolDepositButton({ poolId }: { poolId: string }) { +export function PoolDepositButton({ + poolId, + poolRepo, +}: { + poolId: string; + poolRepo?: string; +}) { const router = useRouter(); const { address, connect, connecting, getError: getWalletError } = useWallet(); const [amount, setAmount] = useState("100"); @@ -97,7 +115,13 @@ export function PoolDepositButton({ poolId }: { poolId: string }) { onChange={(e) => setAmount(e.target.value)} className="w-24 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-900 focus:border-indigo-400 focus:outline-none dark:border-slate-800 dark:bg-slate-900 dark:text-white" /> - {error && ( diff --git a/src/app/milestones/page.tsx b/src/app/milestones/page.tsx index c25f8f2..6783ccc 100644 --- a/src/app/milestones/page.tsx +++ b/src/app/milestones/page.tsx @@ -74,7 +74,7 @@ export default async function MilestonesPage() {

{m.completedCount} of {m.issueCount} issues complete

- +
); })} @@ -106,7 +106,7 @@ export default async function MilestonesPage() {

{formatCurrency(pool.monthlyDeposit, pool.asset)} deposited monthly

- + ))} diff --git a/src/components/ui/Avatar.test.tsx b/src/components/ui/Avatar.test.tsx new file mode 100644 index 0000000..9cfaf40 --- /dev/null +++ b/src/components/ui/Avatar.test.tsx @@ -0,0 +1,105 @@ +/** + * Avatar.test.tsx + * + * Covers Avatar render logic (src vs fallback URL, unoptimized flag) and + * AvatarStack overflow counting (under-max, at-max, over-max) — #278. + */ + +import { render, screen } from "@testing-library/react"; +import { Avatar, AvatarStack } from "./Avatar"; + +describe("Avatar", () => { + it("renders with the given seed as alt text", () => { + render(); + const img = screen.getByRole("img", { name: "alice" }); + expect(img).toBeInTheDocument(); + }); + + it("uses the src prop when provided", () => { + render(); + const img = screen.getByRole("img", { name: "alice" }); + // Next.js Image rewrites the src through its optimization pipeline + expect(img.getAttribute("src")).toContain("example.com%2Falice.png"); + }); + + it("falls back to a dicebear URL when no src is provided", () => { + render(); + const img = screen.getByRole("img", { name: "bob" }); + const src = img.getAttribute("src") ?? ""; + expect(src).toContain("api.dicebear.com"); + expect(src).toContain("seed=bob"); + }); + + it("uses dicebear URL for unoptimized external images", () => { + render(); + const img = screen.getByRole("img", { name: "carol" }); + expect(img.getAttribute("src")).toContain("dicebear.com"); + }); + + it("does not set unoptimized for custom src URLs", () => { + render(); + const img = screen.getByRole("img", { name: "dave" }); + // Next.js Image rewrites src through optimization; just verify it's present + expect(img.getAttribute("src")).toContain("example.com"); + }); + + it("applies custom size dimensions", () => { + render(); + const img = screen.getByRole("img", { name: "eve" }); + expect(img).toHaveAttribute("width", "48"); + expect(img).toHaveAttribute("height", "48"); + }); + + it("merges custom className", () => { + render(); + const img = screen.getByRole("img", { name: "frank" }); + expect(img.className).toMatch(/test-extra/); + }); +}); + +describe("AvatarStack", () => { + it("renders all seeds when under max", () => { + render(); + expect(screen.getByRole("img", { name: "a" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "b" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "c" })).toBeInTheDocument(); + expect(screen.queryByText("+")).not.toBeInTheDocument(); + }); + + it("renders exactly max avatars when seeds length equals max", () => { + render(); + expect(screen.getByRole("img", { name: "a" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "b" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "c" })).toBeInTheDocument(); + expect(screen.queryByText("+")).not.toBeInTheDocument(); + }); + + it("shows overflow count when seeds exceed max", () => { + render(); + expect(screen.getByRole("img", { name: "a" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "b" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "c" })).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "d" })).not.toBeInTheDocument(); + expect(screen.getByText("+2")).toBeInTheDocument(); + }); + + it("defaults max to 5", () => { + const seeds = ["a", "b", "c", "d", "e", "f", "g"]; + render(); + expect(screen.getByRole("img", { name: "a" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "e" })).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "f" })).not.toBeInTheDocument(); + expect(screen.getByText("+2")).toBeInTheDocument(); + }); + + it("includes hidden seed names in the overflow title attribute", () => { + render(); + const overflow = screen.getByText("+2"); + expect(overflow).toHaveAttribute("title", "c, d"); + }); + + it("renders nothing extra for an empty seeds array", () => { + const { container } = render(); + expect(container.querySelectorAll("img")).toHaveLength(0); + }); +}); diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index 9d6571a..5f8b2b1 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -10,6 +10,7 @@ import { coerceDecimal, coerceNonNegative, coercePercentage, + coerceStatus, validateTeamSplits, } from "./utils"; @@ -111,7 +112,7 @@ export function adaptBounty(raw: RawBounty): Bounty & { teamSplitsValid?: { vali reward: coerceNonNegative(raw.amount), asset: raw.asset, difficulty: raw.difficulty, - status: raw.status, + status: coerceStatus(raw.status), // A null deadline means the bounty is genuinely open-ended, not "due // now" — pass it through as-is rather than fabricating a "now" // timestamp that would make daysUntil() render it as already expired diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e779510..f2fd77a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; +import type { BountyStatus } from "@/types"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -72,6 +73,26 @@ export function formatDaysUntil(days: number | null): string { return "Deadline passed"; } +const VALID_STATUSES: ReadonlySet = new Set([ + "open", + "funded", + "claimed", + "in_review", + "merged", + "paid", + "refunded", + "expired", +]); + +/** + * Validate that a raw status string is a known BountyStatus. Returns the + * validated status or falls back to "open" for unrecognized values (#279). + */ +export function coerceStatus(value: string | null | undefined): BountyStatus { + if (value && VALID_STATUSES.has(value)) return value as BountyStatus; + return "open"; +} + export function validateTeamSplits( splits: Array<{ percentage: string | number }>, tolerance = 0.01