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 (
-
);
})}
@@ -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