diff --git a/messages/en.json b/messages/en.json index 880565d3..365be05f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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!", @@ -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…", diff --git a/messages/es.json b/messages/es.json index 9437ba7d..68e3370a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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!", @@ -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…", diff --git a/src/__tests__/components/DonationModal.test.tsx b/src/__tests__/components/DonationModal.test.tsx index 04ca998f..a222f652 100644 --- a/src/__tests__/components/DonationModal.test.tsx +++ b/src/__tests__/components/DonationModal.test.tsx @@ -19,20 +19,26 @@ jest.mock("@/components/WalletContext", () => ({ }), })); +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), })); jest.mock("next-intl", () => ({ + useLocale: () => "en", useTranslations: () => (key: string, values?: Record) => { const map: Record = { 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!", @@ -43,6 +49,7 @@ jest.mock("next-intl", () => ({ 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…", @@ -50,6 +57,12 @@ jest.mock("next-intl", () => ({ 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; }, @@ -100,12 +113,18 @@ const defaultProps = { describe("DonationModal", () => { beforeEach(() => { jest.clearAllMocks(); + mockUsePlatformFee.mockReturnValue({ + platformFeeBps: 300, + isLoading: false, + isFallback: false, + isError: false, + }); }); it("rejects zero amounts by disabling submit", () => { render(); - 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(); @@ -114,7 +133,7 @@ describe("DonationModal", () => { it("rejects negative and non-numeric amounts", () => { render(); - 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" } }); @@ -130,17 +149,19 @@ describe("DonationModal", () => { 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(); - 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", () => { @@ -160,8 +181,8 @@ describe("DonationModal", () => { render(); - 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), { @@ -175,12 +196,36 @@ describe("DonationModal", () => { render(); - 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(); + + 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(); + + expect( + screen.queryByText("Unable to load fee — proceeding may incur platform fees."), + ).not.toBeInTheDocument(); }); }); diff --git a/src/components/DonationModal.tsx b/src/components/DonationModal.tsx index ae989acf..c98e1f52 100644 --- a/src/components/DonationModal.tsx +++ b/src/components/DonationModal.tsx @@ -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(""); @@ -351,6 +351,15 @@ export default function DonationModal({ campaign, onClose, onSuccess }: Donation )} + {isFeeError && ( +

+ ⚠️ {t("feeUnavailable")} +

+ )} +

{t("platformFeeNote", { feePercent: basisPointsToPercentage(platformFeeBps) })}

diff --git a/src/hooks/usePlatformFee.ts b/src/hooks/usePlatformFee.ts index 411b6262..665785c7 100644 --- a/src/hooks/usePlatformFee.ts +++ b/src/hooks/usePlatformFee.ts @@ -9,6 +9,7 @@ interface UsePlatformFeeResult { platformFeeBps: number; isLoading: boolean; isFallback: boolean; + isError: boolean; } export function usePlatformFee(): UsePlatformFeeResult { @@ -23,5 +24,6 @@ export function usePlatformFee(): UsePlatformFeeResult { platformFeeBps: data ?? DEFAULT_PLATFORM_FEE_BPS, isLoading, isFallback: isError || data === undefined, + isError, }; }