feat(toast): add non-invasive, dismissible donation banner

This commit is contained in:
Amruth Pillai
2026-05-11 13:50:32 +02:00
parent adfc9b527b
commit fda4e500b3
70 changed files with 1355 additions and 62 deletions
@@ -0,0 +1,139 @@
// @vitest-environment happy-dom
import type React from "react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { DonationToast } from "./donation-toast";
type ToastOptions = {
dismissible: boolean;
duration: number;
id: string;
unstyled: boolean;
};
const useCookieMock = vi.hoisted(() => ({
setDismissed: vi.fn(),
value: null as string | null,
}));
const toastMock = vi.hoisted(() => ({
toast: {
custom: vi.fn(),
dismiss: vi.fn(),
},
}));
vi.mock("@reactive-resume/ui/hooks/use-cookie", () => ({
useCookie: vi.fn(() => [useCookieMock.value, useCookieMock.setDismissed, vi.fn()] as const),
}));
vi.mock("sonner", () => ({
toast: toastMock.toast,
}));
const getCustomToast = () =>
toastMock.toast.custom.mock.calls[0] as [(toastId: string | number) => React.ReactElement, ToastOptions] | undefined;
const SHOW_TOAST_DELAY_MS = 5 * 60 * 1000;
const renderCustomToast = () => {
const customToast = getCustomToast();
if (!customToast) throw new Error("Custom toast was not rendered.");
return render(<I18nProvider i18n={i18n}>{customToast[0]("donation-toast")}</I18nProvider>);
};
describe("DonationToast", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-11T12:00:00.000Z"));
i18n.loadAndActivate({ locale: "en-US", messages: {} });
useCookieMock.value = null;
useCookieMock.setDismissed.mockClear();
toastMock.toast.custom.mockClear();
toastMock.toast.dismiss.mockClear();
vi.spyOn(window, "open").mockReturnValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("waits before showing the donation toast", () => {
render(<DonationToast />);
expect(toastMock.toast.custom).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(SHOW_TOAST_DELAY_MS - 1);
});
expect(toastMock.toast.custom).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1);
});
expect(toastMock.toast.custom).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
dismissible: false,
duration: Number.POSITIVE_INFINITY,
id: "donation-toast",
unstyled: true,
}),
);
});
it("does not show the toast after it has been dismissed", () => {
useCookieMock.value = "true";
render(<DonationToast />);
act(() => {
vi.advanceTimersByTime(SHOW_TOAST_DELAY_MS);
});
expect(toastMock.toast.custom).not.toHaveBeenCalled();
});
it("sets a 30-day dismissed cookie when dismissed", () => {
render(<DonationToast />);
act(() => {
vi.advanceTimersByTime(SHOW_TOAST_DELAY_MS);
});
renderCustomToast();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", {
expires: new Date("2026-06-10T12:05:00.000Z"),
});
expect(toastMock.toast.dismiss).toHaveBeenCalledWith("donation-toast");
});
it("sets a 30-day dismissed cookie and opens Open Collective when donated", () => {
render(<DonationToast />);
act(() => {
vi.advanceTimersByTime(SHOW_TOAST_DELAY_MS);
});
renderCustomToast();
fireEvent.click(screen.getByRole("button", { name: "Donate" }));
expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", {
expires: new Date("2026-06-10T12:05:00.000Z"),
});
expect(window.open).toHaveBeenCalledWith(
"https://opencollective.com/reactive-resume/donate",
"_blank",
"noopener,noreferrer",
);
expect(toastMock.toast.dismiss).toHaveBeenCalledWith("donation-toast");
});
});
@@ -0,0 +1,79 @@
import { Trans } from "@lingui/react/macro";
import { HandHeartIcon } from "@phosphor-icons/react";
import { useCallback } from "react";
import { toast } from "sonner";
import { useTimeout } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import { useCookie } from "@reactive-resume/ui/hooks/use-cookie";
const TOAST_ID = "donation-toast";
const SHOW_TOAST_DELAY_MS = 5 * 60 * 1000; // 5 minutes
const DISMISSED_COOKIE_NAME = "donation-toast-dismissed";
const DISMISSED_COOKIE_EXPIRES_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const getDismissedCookieExpiresAt = () => new Date(Date.now() + DISMISSED_COOKIE_EXPIRES_MS);
export function DonationToast() {
const [dismissed, setDismissed] = useCookie(DISMISSED_COOKIE_NAME);
const showToast = useCallback(() => {
if (dismissed === "true") return;
const onDonate = (t: string | number) => {
toast.dismiss(t);
setDismissed("true", { expires: getDismissedCookieExpiresAt() });
window.open("https://opencollective.com/reactive-resume/donate", "_blank", "noopener,noreferrer");
};
const onDismiss = (t: string | number) => {
toast.dismiss(t);
setDismissed("true", { expires: getDismissedCookieExpiresAt() });
};
toast.custom((t) => <DonationToastCard onDismiss={() => onDismiss(t)} onDonate={() => onDonate(t)} />, {
id: TOAST_ID,
unstyled: true,
dismissible: false,
duration: Number.POSITIVE_INFINITY,
});
}, [dismissed, setDismissed]);
useTimeout(showToast, SHOW_TOAST_DELAY_MS);
return null;
}
type DonationToastCardProps = {
onDismiss: () => void;
onDonate: () => void;
};
function DonationToastCard({ onDismiss, onDonate }: DonationToastCardProps) {
return (
<div className="w-sm rounded-md bg-popover p-4 shadow-xl">
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-amber-300 text-zinc-950">
<HandHeartIcon aria-hidden="true" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<p className="font-semibold text-sm tracking-tight">
<Trans>Please support the project</Trans>
</p>
<p className="text-pretty text-muted-foreground text-xs">
<Trans>Reactive Resume is free and open source. If it has helped you, please consider donating.</Trans>
</p>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button size="sm" variant="outline" onClick={onDismiss}>
<Trans>Dismiss</Trans>
</Button>
<Button size="sm" onClick={onDonate} className="bg-amber-300 text-zinc-950 hover:bg-amber-200">
<Trans>Donate</Trans>
</Button>
</div>
</div>
);
}