diff --git a/src/components/MultiSigWithdrawalPanel.tsx b/src/components/MultiSigWithdrawalPanel.tsx
index e61b66bc..59dd2ae5 100644
--- a/src/components/MultiSigWithdrawalPanel.tsx
+++ b/src/components/MultiSigWithdrawalPanel.tsx
@@ -299,43 +299,58 @@ export default function MultiSigWithdrawalPanel({
diff --git a/src/components/ShareButtons.tsx b/src/components/ShareButtons.tsx
index 4498eca2..1ba412c1 100644
--- a/src/components/ShareButtons.tsx
+++ b/src/components/ShareButtons.tsx
@@ -9,110 +9,6 @@ interface ShareButtonsProps {
walletAddress?: string;
}
-function QRModal({
- url,
- walletAddress,
- onClose,
-}: {
- url: string;
- walletAddress?: string;
- onClose: () => void;
-}) {
- const qrBase = "https://api.qrserver.com/v1/create-qr-code/?size=200x200&ecc=M&data=";
-
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- },
- [onClose],
- );
-
- useEffect(() => {
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }, [handleKeyDown]);
-
- return (
-
-
e.stopPropagation()}
- >
-
-
-
-
-
-
-
-
QR Codes
-
-
- {/* Campaign URL QR */}
-
-
- {/* Wallet address QR */}
- {walletAddress && (
-
- )}
-
-
-
- );
-}
-
export default function ShareButtons({ url, title, walletAddress }: ShareButtonsProps) {
const { showSuccess } = useToast();
const [copied, setCopied] = useState(false);
@@ -130,7 +26,6 @@ export default function ShareButtons({ url, title, walletAddress }: ShareButtons
showSuccess("Copied!");
setTimeout(() => setCopied(false), 2000);
} catch {
- // fallback for older browsers
const el = document.createElement("textarea");
el.value = url;
document.body.appendChild(el);
@@ -157,8 +52,10 @@ export default function ShareButtons({ url, title, walletAddress }: ShareButtons
const isMobile = typeof navigator !== "undefined" && /Mobi|Android/i.test(navigator.userAgent);
+ const qrBase = "https://api.qrserver.com/v1/create-qr-code/?size=200x200&ecc=M&data=";
+
return (
- <>
+
Share:
@@ -212,7 +109,6 @@ export default function ShareButtons({ url, title, walletAddress }: ShareButtons
aria-label="Share on X"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-xs font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
>
- {/* X logo */}
@@ -227,7 +123,6 @@ export default function ShareButtons({ url, title, walletAddress }: ShareButtons
aria-label="Share on LinkedIn"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-xs font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
>
- {/* LinkedIn logo */}
@@ -237,7 +132,9 @@ export default function ShareButtons({ url, title, walletAddress }: ShareButtons
{/* QR */}
setQrOpen(true)}
+ onClick={() => setQrOpen((prev) => !prev)}
+ aria-expanded={qrOpen}
+ aria-controls="qr-panel"
aria-label="Show QR code"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-xs font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors"
>
@@ -263,8 +160,56 @@ export default function ShareButtons({ url, title, walletAddress }: ShareButtons
{qrOpen && (
-
setQrOpen(false)} />
+
+
QR Codes
+
+
+
+ {walletAddress && (
+
+ )}
+
+
)}
- >
+
);
}
diff --git a/src/components/__tests__/AnimatedProgressFill.test.tsx b/src/components/__tests__/AnimatedProgressFill.test.tsx
new file mode 100644
index 00000000..4766edef
--- /dev/null
+++ b/src/components/__tests__/AnimatedProgressFill.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/react";
+import AnimatedProgressFill from "../AnimatedProgressFill";
+
+jest.mock("framer-motion", () => ({
+ motion: {
+ div: ({ style, ...props }: React.ComponentProps<"div">) => (
+
+ ),
+ },
+ useSpring: (initial: number) => ({
+ get: () => initial,
+ set: jest.fn(),
+ jump: jest.fn(),
+ }),
+ useTransform: (val: { get: () => number }) => `${val.get()}%`,
+}));
+
+jest.mock("@/hooks/useReducedMotion", () => ({
+ useReducedMotion: jest.fn(),
+}));
+
+import { useReducedMotion } from "@/hooks/useReducedMotion";
+
+const mockUseReducedMotion = useReducedMotion as jest.MockedFunction<
+ typeof useReducedMotion
+>;
+
+describe("AnimatedProgressFill", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the final width immediately when reduced motion is preferred", () => {
+ mockUseReducedMotion.mockReturnValue(true);
+
+ render(
);
+
+ const div = document.querySelector('[style*="width: 75%"]');
+ expect(div).toBeInTheDocument();
+ });
+
+ it("renders the final width immediately when reduced motion is not preferred", () => {
+ mockUseReducedMotion.mockReturnValue(false);
+
+ render(
);
+
+ const div = screen.getByTestId("motion-div");
+ expect(div).toHaveStyle({ width: "50%" });
+ });
+
+ it("renders with aria-hidden", () => {
+ mockUseReducedMotion.mockReturnValue(false);
+
+ render(
);
+
+ expect(screen.getByTestId("motion-div")).toHaveAttribute("aria-hidden", "true");
+ });
+});
\ No newline at end of file
diff --git a/src/components/__tests__/CampaignMap.test.tsx b/src/components/__tests__/CampaignMap.test.tsx
index 8cca5145..c23fc5a1 100644
--- a/src/components/__tests__/CampaignMap.test.tsx
+++ b/src/components/__tests__/CampaignMap.test.tsx
@@ -1,6 +1,6 @@
import { render, screen, fireEvent } from "@testing-library/react";
import MapErrorBoundary from "../MapErrorBoundary";
-import { hasValidCoordinates, filterByValidCoordinates } from "../CampaignMap";
+import CampaignMap, { hasValidCoordinates, filterByValidCoordinates } from "../CampaignMap";
import { Campaign, Category } from "@/types";
// Leaflet and react-leaflet use browser APIs not available in jsdom.
@@ -245,3 +245,55 @@ describe("MapErrorBoundary", () => {
expect(screen.getByTestId("recovered")).toHaveTextContent("Recovered");
});
});
+
+// ---------------------------------------------------------------------------
+// CampaignMap loading state
+// ---------------------------------------------------------------------------
+
+describe("CampaignMap loading state", () => {
+ it("shows a loading indicator when isLoading is true", () => {
+ render(
);
+
+ expect(screen.getByText("loading")).toBeInTheDocument();
+ });
+
+ it("clears the loading state and renders the map when loading completes with valid campaigns", () => {
+ const { rerender } = render(
+
,
+ );
+
+ expect(screen.getByText("loading")).toBeInTheDocument();
+
+ rerender(
+
,
+ );
+
+ expect(screen.queryByText("loading")).not.toBeInTheDocument();
+ expect(screen.getByTestId("map")).toBeInTheDocument();
+ });
+
+ it("clears the loading state and shows empty state when loading completes with no valid campaigns", () => {
+ const { rerender } = render(
+
,
+ );
+
+ expect(screen.getByText("loading")).toBeInTheDocument();
+
+ rerender(
+
,
+ );
+
+ expect(screen.queryByText("loading")).not.toBeInTheDocument();
+ expect(screen.getByText("emptyTitle")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/__tests__/MultiSigWithdrawalPanel.test.tsx b/src/components/__tests__/MultiSigWithdrawalPanel.test.tsx
new file mode 100644
index 00000000..db842c75
--- /dev/null
+++ b/src/components/__tests__/MultiSigWithdrawalPanel.test.tsx
@@ -0,0 +1,160 @@
+import { render, screen } from "@testing-library/react";
+import MultiSigWithdrawalPanel from "../MultiSigWithdrawalPanel";
+
+jest.mock("../../hooks/useMultiSigProposals", () => ({
+ useMultiSigProposals: jest.fn(),
+}));
+
+jest.mock("../../hooks/useWriteGuard", () => ({
+ useWriteGuard: () => ({
+ invoke: jest.fn(),
+ isPending: () => false,
+ }),
+}));
+
+jest.mock("@/components/ToastProvider", () => ({
+ useToast: () => ({
+ showSuccess: jest.fn(),
+ showError: jest.fn(),
+ }),
+}));
+
+jest.mock("../../lib/contractClient", () => ({
+ withdrawFunds: jest.fn(),
+}));
+
+jest.mock("../../lib/stellar", () => ({
+ isSameAddress: (a: string, b: string) => a === b,
+}));
+
+jest.mock("../../utils/contractErrors", () => ({
+ parseContractError: (err: unknown) => String(err),
+}));
+
+jest.mock("@/lib/stellarAmount", () => ({
+ stroopsToXlmNumber: (v: bigint) => Number(v) / 10_000_000,
+}));
+
+jest.mock("@/lib/formatters", () => ({
+ formatNumber: (v: number) => v.toFixed(2),
+}));
+
+jest.mock("@/utils/explorer", () => ({
+ explorerTxUrl: (hash: string) => `https://explorer.stellar.org/tx/${hash}`,
+}));
+
+import { useMultiSigProposals } from "../../hooks/useMultiSigProposals";
+
+const mockUseMultiSigProposals = useMultiSigProposals as jest.MockedFunction<
+ typeof useMultiSigProposals
+>;
+
+const mockCampaign = {
+ id: 1,
+ creator: "GABC12345678901234567890123456789012345678901234567890",
+ title: "Test Campaign",
+ description: "A test campaign.",
+ created_at: 1_000_000,
+ status: "active" as const,
+ funding_goal: BigInt(100_000_000_000),
+ deadline: 2_000_000_000,
+ amount_raised: BigInt(200_000_000_000),
+ is_active: true,
+ funds_withdrawn: false,
+ is_cancelled: false,
+ is_verified: false,
+ category: "Learner" as const,
+ has_revenue_sharing: false,
+ revenue_share_percentage: 0,
+};
+
+function setupProposal(signers: Array<{ address: string; signedAt?: number }>) {
+ return {
+ id: "prop-1",
+ campaignId: 1,
+ proposedBy: "GABC12345678901234567890123456789012345678901234567890",
+ createdAt: 1_000_000,
+ signers,
+ requiredSignatures: 2,
+ status: "pending" as const,
+ };
+}
+
+describe("MultiSigWithdrawalPanel", () => {
+ it("renders per-signer rows with address and status", () => {
+ mockUseMultiSigProposals.mockReturnValue({
+ activeProposal: setupProposal([
+ { address: "GABC12345678901234567890123456789012345678901234567890" },
+ {
+ address: "GDEF9876543210987654321098765432109876543210987654321",
+ signedAt: 1_700_000_000,
+ },
+ ]),
+ createProposal: jest.fn(),
+ signProposal: jest.fn(),
+ cancelProposal: jest.fn(),
+ markExecuted: jest.fn(),
+ });
+
+ render(
+
,
+ );
+
+ expect(screen.getByText("you")).toBeInTheDocument();
+ });
+
+ it("visually distinguishes the current wallet signer with a highlight", () => {
+ mockUseMultiSigProposals.mockReturnValue({
+ activeProposal: setupProposal([
+ { address: "GABC12345678901234567890123456789012345678901234567890" },
+ {
+ address: "GDEF9876543210987654321098765432109876543210987654321",
+ signedAt: 1_700_000_000,
+ },
+ ]),
+ createProposal: jest.fn(),
+ signProposal: jest.fn(),
+ cancelProposal: jest.fn(),
+ markExecuted: jest.fn(),
+ });
+
+ render(
+
,
+ );
+
+ const youBadge = screen.getByText("you");
+ expect(youBadge).toBeInTheDocument();
+ });
+
+ it("renders all signer rows correctly", () => {
+ mockUseMultiSigProposals.mockReturnValue({
+ activeProposal: setupProposal([
+ { address: "GABC12345678901234567890123456789012345678901234567890" },
+ {
+ address: "GDEF9876543210987654321098765432109876543210987654321",
+ signedAt: 1_700_000_000,
+ },
+ { address: "GHI555555555555555555555555555555555555555555555555555" },
+ ]),
+ createProposal: jest.fn(),
+ signProposal: jest.fn(),
+ cancelProposal: jest.fn(),
+ markExecuted: jest.fn(),
+ });
+
+ render(
+
,
+ );
+
+ expect(screen.getByText("you")).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/src/components/__tests__/ShareButtons.test.tsx b/src/components/__tests__/ShareButtons.test.tsx
new file mode 100644
index 00000000..6b3fa0c4
--- /dev/null
+++ b/src/components/__tests__/ShareButtons.test.tsx
@@ -0,0 +1,75 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import ShareButtons from "../ShareButtons";
+
+jest.mock("@/components/ToastProvider", () => ({
+ useToast: () => ({
+ showSuccess: jest.fn(),
+ showError: jest.fn(),
+ }),
+}));
+
+describe("ShareButtons", () => {
+ it("renders both social share buttons and QR entry point from the same panel", () => {
+ render(
+
,
+ );
+
+ expect(screen.getByRole("button", { name: "Copy link" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Share on X" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Share on LinkedIn" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Show QR code" })).toBeInTheDocument();
+ });
+
+ it("toggles the QR panel inline within the share panel", async () => {
+ const user = userEvent.setup();
+ render(
+
,
+ );
+
+ const qrButton = screen.getByRole("button", { name: "Show QR code" });
+ expect(qrButton).toHaveAttribute("aria-expanded", "false");
+
+ await user.click(qrButton);
+
+ expect(qrButton).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByRole("region", { name: "QR codes" })).toBeInTheDocument();
+ expect(screen.getByText("QR Codes")).toBeInTheDocument();
+ });
+
+ it("renders wallet QR code when walletAddress is provided", async () => {
+ const user = userEvent.setup();
+ render(
+
,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Show QR code" }));
+
+ expect(screen.getByAltText("QR code for campaign URL")).toBeInTheDocument();
+ expect(
+ screen.getByAltText("QR code for contribution wallet address"),
+ ).toBeInTheDocument();
+ });
+
+ it("has accessible QR panel with aria-controls", () => {
+ render(
+
,
+ );
+
+ const qrButton = screen.getByRole("button", { name: "Show QR code" });
+ expect(qrButton).toHaveAttribute("aria-controls", "qr-panel");
+ });
+});
\ No newline at end of file