Skip to content
Open
33 changes: 23 additions & 10 deletions src/components/escrow/EscrowFundedBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { useState } from "react";
import { StellarTxLink } from "./StellarTxLink";
import { formatAmount, getEscrowFundedBannerStorageKey } from "./types";

interface EscrowFundedBannerProps {
escrowId: string;
adoptionId: string;
petName: string;
amount: number;
currency?: string;
txHash?: string;
}

export function EscrowFundedBanner({
escrowId,
adoptionId,
petName,
amount,
currency,
txHash,
}: EscrowFundedBannerProps) {
const storageKey = getEscrowFundedBannerStorageKey(escrowId);
const storageKey = getEscrowFundedBannerStorageKey(adoptionId);
const [dismissed, setDismissed] = useState(
() => sessionStorage.getItem(storageKey) === "true",
);
Expand All @@ -28,20 +33,28 @@ export function EscrowFundedBanner({

return (
<div
className="flex items-start justify-between gap-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 text-emerald-950"
className="animate-slide-down flex items-start justify-between gap-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 text-emerald-950 shadow-sm transition-all"
role="alert"
aria-live="polite"
data-testid="escrow-funded-banner"
>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em]">
<div className="flex-1">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">
Escrow funded
</p>
<p className="mt-2 text-lg font-semibold">
{formatAmount(amount, currency)} is secured and ready for settlement.
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-2 text-lg font-semibold">
<span>{petName}'s adoption fee of {formatAmount(amount, currency)} is secured.</span>
{txHash && (
<div className="inline-flex items-center gap-1 border-l border-emerald-200 pl-2 ml-1">
<span className="text-sm font-normal text-emerald-700">Tx:</span>
<StellarTxLink txHash={txHash} />
</div>
)}
</div>
</div>
<button
aria-label="Dismiss funded banner"
className="rounded-full border border-emerald-300 px-3 py-1 text-sm font-semibold"
className="shrink-0 rounded-xl border border-emerald-200 bg-white px-4 py-2 text-sm font-bold text-emerald-700 hover:bg-emerald-100 hover:border-emerald-300 transition-colors shadow-sm"
onClick={dismiss}
type="button"
>
Expand Down
66 changes: 66 additions & 0 deletions src/components/escrow/__tests__/EscrowFundedBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EscrowFundedBanner, getEscrowFundedBannerStorageKey } from "../EscrowFundedBanner";

Check failure on line 3 in src/components/escrow/__tests__/EscrowFundedBanner.test.tsx

View workflow job for this annotation

GitHub Actions / validate

Module '"../EscrowFundedBanner"' declares 'getEscrowFundedBannerStorageKey' locally, but it is not exported.

describe("EscrowFundedBanner", () => {
const props = {
adoptionId: "adoption-123",
petName: "Max",
amount: 100,
currency: "USDC",
txHash: "tx-hash-456",
};

beforeEach(() => {
sessionStorage.clear();
vi.clearAllMocks();
});

it("renders with pet name and amount", () => {
render(<EscrowFundedBanner {...props} />);

expect(screen.getByText(/Max's adoption fee of USDC 100.00 is secured/i)).toBeDefined();
expect(screen.getByRole("alert")).toBeDefined();
expect(screen.getByTestId("stellar-tx-link")).toBeDefined();
});

it("hides banner and sets sessionStorage when dismissed", () => {
render(<EscrowFundedBanner {...props} />);

const dismissButton = screen.getByRole("button", { name: /dismiss/i });
fireEvent.click(dismissButton);

expect(screen.queryByTestId("escrow-funded-banner")).toBeNull();
expect(sessionStorage.getItem(getEscrowFundedBannerStorageKey(props.adoptionId))).toBe("true");
});

it("does not render if already dismissed in sessionStorage", () => {
sessionStorage.setItem(getEscrowFundedBannerStorageKey(props.adoptionId), "true");
render(<EscrowFundedBanner {...props} />);

expect(screen.queryByTestId("escrow-funded-banner")).toBeNull();
});

it("re-renders when adoptionId changes even if previous was dismissed", () => {
const { rerender } = render(<EscrowFundedBanner {...props} />);

// Dismiss first
fireEvent.click(screen.getByRole("button", { name: /dismiss/i }));
expect(screen.queryByTestId("escrow-funded-banner")).toBeNull();

// Change adoptionId
const newProps = { ...props, adoptionId: "adoption-789", petName: "Bella" };
rerender(<EscrowFundedBanner {...newProps} />);

expect(screen.getByTestId("escrow-funded-banner")).toBeDefined();
expect(screen.getByText(/Bella's adoption fee/i)).toBeDefined();
});

it("has correct accessibility attributes", () => {
render(<EscrowFundedBanner {...props} />);

const banner = screen.getByTestId("escrow-funded-banner");
expect(banner.getAttribute("role")).toBe("alert");
expect(banner.getAttribute("aria-live")).toBe("polite");
});
});
24 changes: 16 additions & 8 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@

@theme {
--animate-shimmer: shimmer 1.5s infinite linear;
--animate-slide-down: slide-down 0.5s ease-out;

@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

--animate-notification-bell: notification-bell 0.55s ease-out;
@keyframes notification-bell {
0%, 100% { transform: rotate(0deg); }
18% { transform: rotate(-14deg); }
36% { transform: rotate(12deg); }
54% { transform: rotate(-8deg); }
72% { transform: rotate(5deg); }
}
@keyframes slide-down {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}

--animate-notification-bell: notification-bell 0.55s ease-out;

@keyframes notification-bell {
0%, 100% { transform: rotate(0deg); }
18% { transform: rotate(-14deg); }
36% { transform: rotate(12deg); }
54% { transform: rotate(-8deg); }
72% { transform: rotate(5deg); }
}
}


Expand Down
10 changes: 4 additions & 6 deletions src/pages/SettlementSummaryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
const description = propSummary?.description || (adoptionId ? `Adoption #${adoptionId}` : "");

return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gray-50">
<div className="max-w-3xl mx-auto px-4 py-8 space-y-6">
{/* ── Header ── */}
<div>
Expand All @@ -115,7 +115,7 @@
{/* ── Actions / Banners ── */}
{escrowStatus === "FUNDED" && propSummary && (
<EscrowFundedBanner
escrowId={propSummary.escrow.escrowId}

Check failure on line 118 in src/pages/SettlementSummaryPage.tsx

View workflow job for this annotation

GitHub Actions / validate

Type '{ escrowId: string; amount: number; currency: string | undefined; }' is not assignable to type 'IntrinsicAttributes & EscrowFundedBannerProps'.
amount={propSummary.escrow.amount}
currency={propSummary.escrow.currency}
/>
Expand Down Expand Up @@ -274,17 +274,15 @@
/** Internal helper for the retry logic to keep the main component cleaner. */
function RetryButton({ adoptionId }: { adoptionId: string }) {
const retryMutation = useRetrySettlement(adoptionId);

return (
<button
type="button"
onClick={() => retryMutation.mutateRetrySettlement()}
disabled={retryMutation.isPending}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500
focus-visible:ring-offset-2"
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
>
{retryMutation.isPending ? "Retrying…" : "Retry Settlement"}
</button>
);
}
}
Loading