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
5 changes: 4 additions & 1 deletion src/app/issues/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,14 @@ export default async function IssuesPage() {
description="Check back soon — funded issues appear here once sponsors lock them in escrow."
/>
) : (
<div className="grid gap-4 md:grid-cols-2">
<>
<h2 className="sr-only">Available bounties</h2>
<div className="grid gap-4 md:grid-cols-2">
{bounties.map((bounty) => (
<BountyCard key={bounty.id} bounty={bounty} />
))}
</div>
</>
)}
</div>
);
Expand Down
32 changes: 28 additions & 4 deletions src/app/milestones/MilestoneActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -38,7 +44,13 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) {

return (
<div className="mt-4">
<Button size="sm" variant="outline" onClick={handleFund} loading={pending || connecting}>
<Button
size="sm"
variant="outline"
onClick={handleFund}
loading={pending || connecting}
aria-label={milestoneName ? `Fund milestone: ${milestoneName}` : "Fund milestone"}
>
{pending || connecting ? "Confirming in wallet..." : "Fund milestone"}
</Button>
{error && (
Expand All @@ -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");
Expand Down Expand Up @@ -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"
/>
<Button size="sm" variant="outline" onClick={handleDeposit} loading={pending || connecting}>
<Button
size="sm"
variant="outline"
onClick={handleDeposit}
loading={pending || connecting}
aria-label={poolRepo ? `Deposit to pool: ${poolRepo}` : "Deposit to pool"}
>
{pending || connecting ? "Confirming..." : "Deposit"}
</Button>
{error && (
Expand Down
4 changes: 2 additions & 2 deletions src/app/milestones/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export default async function MilestonesPage() {
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500">
{m.completedCount} of {m.issueCount} issues complete
</p>
<MilestoneFundButton milestoneId={m.id} />
<MilestoneFundButton milestoneId={m.id} milestoneName={m.name} />
</div>
);
})}
Expand Down Expand Up @@ -106,7 +106,7 @@ export default async function MilestonesPage() {
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{formatCurrency(pool.monthlyDeposit, pool.asset)} deposited monthly
</p>
<PoolDepositButton poolId={pool.id} />
<PoolDepositButton poolId={pool.id} poolRepo={pool.repo} />
</div>
))}
</div>
Expand Down
105 changes: 105 additions & 0 deletions src/components/ui/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Avatar seed="alice" />);
const img = screen.getByRole("img", { name: "alice" });
expect(img).toBeInTheDocument();
});

it("uses the src prop when provided", () => {
render(<Avatar seed="alice" src="https://example.com/alice.png" />);
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(<Avatar seed="bob" />);
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(<Avatar seed="carol" />);
const img = screen.getByRole("img", { name: "carol" });
expect(img.getAttribute("src")).toContain("dicebear.com");
});

it("does not set unoptimized for custom src URLs", () => {
render(<Avatar seed="dave" src="https://example.com/dave.png" />);
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(<Avatar seed="eve" size={48} />);
const img = screen.getByRole("img", { name: "eve" });
expect(img).toHaveAttribute("width", "48");
expect(img).toHaveAttribute("height", "48");
});

it("merges custom className", () => {
render(<Avatar seed="frank" className="test-extra" />);
const img = screen.getByRole("img", { name: "frank" });
expect(img.className).toMatch(/test-extra/);
});
});

describe("AvatarStack", () => {
it("renders all seeds when under max", () => {
render(<AvatarStack seeds={["a", "b", "c"]} max={5} />);
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(<AvatarStack seeds={["a", "b", "c"]} max={3} />);
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(<AvatarStack seeds={["a", "b", "c", "d", "e"]} max={3} />);
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(<AvatarStack seeds={seeds} />);
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(<AvatarStack seeds={["a", "b", "c", "d"]} max={2} />);
const overflow = screen.getByText("+2");
expect(overflow).toHaveAttribute("title", "c, d");
});

it("renders nothing extra for an empty seeds array", () => {
const { container } = render(<AvatarStack seeds={[]} />);
expect(container.querySelectorAll("img")).toHaveLength(0);
});
});
3 changes: 2 additions & 1 deletion src/lib/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
coerceDecimal,
coerceNonNegative,
coercePercentage,
coerceStatus,
validateTeamSplits,
} from "./utils";

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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));
Expand Down Expand Up @@ -72,6 +73,26 @@ export function formatDaysUntil(days: number | null): string {
return "Deadline passed";
}

const VALID_STATUSES: ReadonlySet<string> = new Set<BountyStatus>([
"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
Expand Down