Skip to content
Open
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
3 changes: 3 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
"Donation": {
"title": "Fund This Cause",
"confirmedTitle": "Donation Confirmed",
"closeAriaLabel": "Close",
"amountLabel": "Amount (XLM)",
"amountPlaceholder": "e.g. 10",
"percentFunded": "{percent}% funded",
"afterDonation": "After your donation: {percent}% funded",
"goalReached": "Goal reached!",
Expand All @@ -80,6 +82,7 @@
"totalLine": "Total from your wallet",
"donate": "Donate",
"donateAmount": "Donate {amount} XLM",
"feeUnavailable": "Unable to load fee — proceeding may incur platform fees.",
"platformFeeNote": "A platform fee of {feePercent} is deducted from funds when withdrawn by the creator. Your full donation goes toward the campaign total.",
"networkFeeNote": "The estimated network fee covers Stellar ledger costs for this transaction. It is separate from the platform fee.",
"waitingSignature": "Waiting for Freighter signature…",
Expand Down
3 changes: 3 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
"Donation": {
"title": "Financiar Esta Causa",
"confirmedTitle": "Donación Confirmada",
"closeAriaLabel": "Cerrar",
"amountLabel": "Monto (XLM)",
"amountPlaceholder": "ej. 10",
"percentFunded": "{percent}% financiado",
"afterDonation": "Después de tu donación: {percent}% financiado",
"goalReached": "¡Meta alcanzada!",
Expand All @@ -80,6 +82,7 @@
"totalLine": "Total desde tu wallet",
"donate": "Donar",
"donateAmount": "Donar {amount} XLM",
"feeUnavailable": "No se pudo cargar la tarifa — continuar puede implicar tarifas de plataforma.",
"platformFeeNote": "Se deduce una tarifa de plataforma del {feePercent} al retirar fondos el creador. Tu donación completa cuenta para el total de la campaña.",
"networkFeeNote": "La tarifa de red estimada cubre los costos del ledger de Stellar para esta transacción. Es independiente de la tarifa de plataforma.",
"waitingSignature": "Esperando firma de Freighter…",
Expand Down
77 changes: 61 additions & 16 deletions src/__tests__/components/DonationModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,26 @@
}),
}));

const mockUsePlatformFee = jest.fn(() => ({
platformFeeBps: 300,
isLoading: false,
isFallback: false,
isError: false,
}));

jest.mock("@/hooks/usePlatformFee", () => ({
usePlatformFee: () => ({
platformFeeBps: 300,
isLoading: false,
isFallback: false,
}),
usePlatformFee: (...args: unknown[]) => mockUsePlatformFee(...args),

Check failure on line 30 in src/__tests__/components/DonationModal.test.tsx

View workflow job for this annotation

GitHub Actions / TypeScript type-check

A spread argument must either have a tuple type or be passed to a rest parameter.
}));

jest.mock("next-intl", () => ({
useLocale: () => "en",
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
const map: Record<string, string> = {
title: "Fund This Cause",
confirmedTitle: "Donation Confirmed",
closeAriaLabel: "Close",
amountLabel: "Amount (XLM)",
amountPlaceholder: "e.g. 10",
percentFunded: `${values?.percent}% funded`,
afterDonation: `After your donation: ${values?.percent}% funded`,
goalReached: "Goal reached!",
Expand All @@ -43,13 +49,20 @@
donateAmount: `Donate ${values?.amount} XLM`,
platformFeeNote: `A platform fee of ${values?.feePercent} is deducted from funds when withdrawn by the creator. Your full donation goes toward the campaign total.`,
networkFeeNote: "Network fee note",
feeUnavailable: "Unable to load fee — proceeding may incur platform fees.",
waitingSignature: "Waiting for Freighter signature…",
waitingConfirmation: "Waiting for ledger confirmation…",
submitting: "Submitting transaction to the network…",
donatedSuccess: `${values?.amount} XLM donated successfully`,
thankYou: "Thank you for supporting this cause.",
viewExplorer: "View on Stellar Explorer →",
close: "Close",
scientificNotation: "Scientific notation is not allowed.",
invalidNumber: "Please enter a valid number.",
invalidAmount: "Please enter a valid amount.",
amountMustBePositive: "Amount must be greater than zero.",
invalidNumberFormat: "Invalid number format.",
maxDecimalPlaces: "Maximum 7 decimal places allowed.",
};
return map[key] ?? key;
},
Expand Down Expand Up @@ -100,12 +113,18 @@
describe("DonationModal", () => {
beforeEach(() => {
jest.clearAllMocks();
mockUsePlatformFee.mockReturnValue({
platformFeeBps: 300,
isLoading: false,
isFallback: false,
isError: false,
});
});

it("rejects zero amounts by disabling submit", () => {
render(<DonationModal {...defaultProps} />);

const input = screen.getByLabelText("amountLabel");
const input = screen.getByLabelText("Amount (XLM)");
fireEvent.change(input, { target: { value: "0" } });

expect(screen.getByRole("button", { name: /donate/i })).toBeDisabled();
Expand All @@ -114,7 +133,7 @@
it("rejects negative and non-numeric amounts", () => {
render(<DonationModal {...defaultProps} />);

const input = screen.getByLabelText("amountLabel");
const input = screen.getByLabelText("Amount (XLM)");
const button = screen.getByRole("button", { name: /donate/i });

fireEvent.change(input, { target: { value: "-5" } });
Expand All @@ -130,17 +149,19 @@
const input = screen.getByLabelText("Amount (XLM)");
fireEvent.change(input, { target: { value: "0" } });

const error = screen.getByText("Amount must be greater than zero.");
expect(error).toHaveAttribute("id", "donation-amount-error");
expect(error).toHaveAttribute("role", "alert");
// aria-invalid is set for inline validation feedback
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAttribute("aria-describedby", "donation-amount-error");
// submit button is disabled when amount is invalid
expect(screen.getByRole("button", { name: /donate/i })).toBeDisabled();
});

it("renders the platform fee explanation", () => {
render(<DonationModal {...defaultProps} />);

expect(screen.getByText("platformFeeNote")).toBeInTheDocument();
expect(
screen.getByText(/A platform fee of .* is deducted from funds/),
).toBeInTheDocument();
});

it("shows estimated network fee and total wallet cost when amount is entered", () => {
Expand All @@ -160,8 +181,8 @@

render(<DonationModal {...defaultProps} />);

fireEvent.change(screen.getByLabelText("amountLabel"), { target: { value: "10" } });
fireEvent.click(screen.getByRole("button", { name: "donateWithAmount" }));
fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "10" } });
fireEvent.click(screen.getByRole("button", { name: /donate 10 xlm/i }));

await waitFor(() =>
expect(mockContribute).toHaveBeenCalledWith(1, CONTRIBUTOR, BigInt(100_000_000), {
Expand All @@ -175,12 +196,36 @@

render(<DonationModal {...defaultProps} />);

fireEvent.change(screen.getByLabelText("amountLabel"), { target: { value: "5" } });
fireEvent.click(screen.getByRole("button", { name: "donateWithAmount" }));
fireEvent.change(screen.getByLabelText("Amount (XLM)"), { target: { value: "5" } });
fireEvent.click(screen.getByRole("button", { name: /donate 5 xlm/i }));

await waitFor(() => {
expect(screen.queryByRole("button", { name: /donate/i })).not.toBeInTheDocument();
});
expect(screen.getByText("submittingTransaction")).toBeInTheDocument();
expect(screen.getByText("Submitting transaction to the network…")).toBeInTheDocument();
});

it("shows fee-unavailable warning when isError is true", () => {
mockUsePlatformFee.mockReturnValue({
platformFeeBps: 300,
isLoading: false,
isFallback: true,
isError: true,
});

render(<DonationModal {...defaultProps} />);

const warning = screen.getByRole("alert");
expect(warning).toHaveTextContent(
"Unable to load fee — proceeding may incur platform fees.",
);
});

it("does not show fee-unavailable warning when isError is false", () => {
render(<DonationModal {...defaultProps} />);

expect(
screen.queryByText("Unable to load fee — proceeding may incur platform fees."),
).not.toBeInTheDocument();
});
});
11 changes: 10 additions & 1 deletion src/components/DonationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function DonationModal({ campaign, onClose, onSuccess }: Donation
const tContractErrors = useTranslations("ContractErrors");
const { publicKey } = useWallet();
const { showError } = useToast();
const { platformFeeBps } = usePlatformFee();
const { platformFeeBps, isError: isFeeError } = usePlatformFee();
const estimatedNetworkFeeXlm = useMemo(() => getEstimatedContributeNetworkFeeXlm(), []);

const [amount, setAmount] = useState("");
Expand Down Expand Up @@ -351,6 +351,15 @@ export default function DonationModal({ campaign, onClose, onSuccess }: Donation
</dl>
)}

{isFeeError && (
<p
role="alert"
className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 px-3 py-2 text-xs text-amber-700 dark:text-amber-300"
>
⚠️ {t("feeUnavailable")}
</p>
)}

<p className="text-xs text-zinc-500 dark:text-zinc-400">
{t("platformFeeNote", { feePercent: basisPointsToPercentage(platformFeeBps) })}
</p>
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/usePlatformFee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface UsePlatformFeeResult {
platformFeeBps: number;
isLoading: boolean;
isFallback: boolean;
isError: boolean;
}

export function usePlatformFee(): UsePlatformFeeResult {
Expand All @@ -23,5 +24,6 @@ export function usePlatformFee(): UsePlatformFeeResult {
platformFeeBps: data ?? DEFAULT_PLATFORM_FEE_BPS,
isLoading,
isFallback: isError || data === undefined,
isError,
};
}
Loading