diff --git a/frontend/src/components/KycSubmissionForm.test.tsx b/frontend/src/components/KycSubmissionForm.test.tsx
index 72552942..b223c75e 100644
--- a/frontend/src/components/KycSubmissionForm.test.tsx
+++ b/frontend/src/components/KycSubmissionForm.test.tsx
@@ -181,4 +181,174 @@ describe("KycSubmissionForm", () => {
expect(screen.getByText("review")).toBeInTheDocument();
});
+
+ it("validates required fields before proceeding to next step", async () => {
+ render(React.createElement(KycSubmissionForm));
+
+ // Try to proceed without filling required fields
+ const nextButton = screen.getByText("next");
+ fireEvent.click(nextButton);
+
+ // Should stay on personal step (first step)
+ expect(screen.getByText("personalInfo")).toBeInTheDocument();
+
+ // Fill required fields
+ fireEvent.change(screen.getByPlaceholderText("firstName"), { target: { value: "John" } });
+ fireEvent.change(screen.getByPlaceholderText("lastName"), { target: { value: "Doe" } });
+
+ // Now proceed
+ fireEvent.click(nextButton);
+ expect(screen.getByText("addressInfo")).toBeInTheDocument();
+ });
+
+ it("handles file uploads correctly", () => {
+ render(React.createElement(KycSubmissionForm));
+
+ // Navigate to documents step
+ fireEvent.click(screen.getByText("next")); // personal
+ fireEvent.click(screen.getByText("next")); // address
+ fireEvent.click(screen.getByText("next")); // documents
+
+ const fileInput = screen.getByText("idFront") as HTMLInputElement;
+ const file = new File(["dummy content"], "test.png", { type: "image/png" });
+
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ // Verify file is selected (this would depend on component implementation)
+ expect(fileInput.files?.[0]).toBe(file);
+ });
+
+ it("displays progress indicator correctly", () => {
+ render(React.createElement(KycSubmissionForm));
+
+ // Initial step should show 1 of 4
+ const progressElements = screen.getAllByText(/\d+ of \d+/);
+ expect(progressElements.length).toBeGreaterThan(0);
+
+ // Navigate through steps
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+
+ // Should show 4 of 4 or completed state
+ expect(screen.getByText("review")).toBeInTheDocument();
+ });
+
+ it("prevents navigation beyond bounds", () => {
+ render(React.createElement(KycSubmissionForm));
+
+ // Try to go back from first step
+ const backButton = screen.getByText("back");
+ fireEvent.click(backButton);
+
+ // Should still be on first step
+ expect(screen.getByText("personalInfo")).toBeInTheDocument();
+
+ // Navigate to last step
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+
+ // Try to go next from last step
+ const nextButton = screen.getByText("next");
+ fireEvent.click(nextButton);
+
+ // Should still be on last step
+ expect(screen.getByText("review")).toBeInTheDocument();
+ });
+
+ it("shows loading state during submission", async () => {
+ (global.fetch as any).mockImplementation(() => new Promise(() => {})); // Never resolves
+
+ render(React.createElement(KycSubmissionForm));
+
+ // Navigate to review
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+
+ const submitButton = screen.getByText("submit");
+ fireEvent.click(submitButton);
+
+ // Should show loading state
+ expect(submitButton).toBeDisabled();
+ });
+
+ it("handles form reset after successful submission", async () => {
+ (global.fetch as any).mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ render(React.createElement(KycSubmissionForm));
+
+ // Fill and submit
+ fireEvent.change(screen.getByPlaceholderText("firstName"), { target: { value: "John" } });
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("submit"));
+
+ await waitFor(() => {
+ expect(screen.getByText("submitAnother")).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText("submitAnother"));
+
+ // Should reset to first step
+ expect(screen.getByText("personalInfo")).toBeInTheDocument();
+
+ // Fields should be cleared
+ const firstNameInput = screen.getByPlaceholderText("firstName") as HTMLInputElement;
+ expect(firstNameInput.value).toBe("");
+ });
+
+ it("displays field-specific validation errors", () => {
+ render(React.createElement(KycSubmissionForm));
+
+ // Try to submit with invalid email format
+ const emailInput = screen.getByPlaceholderText("email") as HTMLInputElement;
+ fireEvent.change(emailInput, { target: { value: "invalid-email" } });
+
+ // Validation should show error (component-specific logic)
+ // This test assumes the component has email validation
+ expect(emailInput.value).toBe("invalid-email");
+ });
+
+ it("handles network errors gracefully", async () => {
+ (global.fetch as any).mockRejectedValue(new Error("Network error"));
+
+ render(React.createElement(KycSubmissionForm));
+
+ // Navigate and submit
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("next"));
+ fireEvent.click(screen.getByText("submit"));
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalled();
+ });
+
+ // Should allow retry
+ const retryButton = screen.getByText("submit");
+ expect(retryButton).toBeInTheDocument();
+ });
+
+ it("maintains form state when navigating between steps", () => {
+ render(React.createElement(KycSubmissionForm));
+
+ // Fill personal info
+ const firstNameInput = screen.getByPlaceholderText("firstName") as HTMLInputElement;
+ fireEvent.change(firstNameInput, { target: { value: "John" } });
+
+ // Navigate to address
+ fireEvent.click(screen.getByText("next"));
+
+ // Navigate back
+ fireEvent.click(screen.getByText("back"));
+
+ // Value should be preserved
+ expect(firstNameInput.value).toBe("John");
+ });
});
diff --git a/frontend/src/components/PaymentSuccessAnimation.test.tsx b/frontend/src/components/PaymentSuccessAnimation.test.tsx
new file mode 100644
index 00000000..e47dd565
--- /dev/null
+++ b/frontend/src/components/PaymentSuccessAnimation.test.tsx
@@ -0,0 +1,250 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import { PaymentSuccessAnimation } from "./PaymentSuccessAnimation";
+import { NextIntlClientProvider } from "next-intl";
+
+// Mock framer-motion
+jest.mock("framer-motion", () => ({
+ motion: {
+ div: ({ children, ...props }: any) =>
{children}
,
+ button: ({ children, ...props }: any) => ,
+ },
+ AnimatePresence: ({ children }: any) => <>{children}>,
+}));
+
+// Mock canvas-confetti
+jest.mock("canvas-confetti", () => jest.fn());
+
+// Mock next-intl
+jest.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => {
+ const translations: Record = {
+ "payment.successAnnounce": "Payment successful! {amount} {asset} has been received.",
+ "common.close": "Close",
+ "payment.amountReceived": "Amount Received",
+ "payment.successMessage": "Your payment has been processed successfully.",
+ "payment.transactionId": "Transaction ID",
+ "common.continue": "Continue",
+ "payment.successHint": "Press the continue button or close button to dismiss this success message.",
+ };
+ return translations[key] || key;
+ },
+}));
+
+describe("PaymentSuccessAnimation", () => {
+ const defaultProps = {
+ show: true,
+ onComplete: jest.fn(),
+ amount: "100",
+ asset: "XLM",
+ txId: "tx123",
+ };
+
+ const renderComponent = (props = {}) => {
+ const messages = {
+ "payment.successAnnounce": "Payment successful! {amount} {asset} has been received.",
+ "common.close": "Close",
+ "payment.amountReceived": "Amount Received",
+ "payment.successMessage": "Your payment has been processed successfully.",
+ "payment.transactionId": "Transaction ID",
+ "common.continue": "Continue",
+ "payment.successHint": "Press the continue button or close button to dismiss this success message.",
+ };
+
+ return render(
+
+
+
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Rendering", () => {
+ it("renders nothing when show is false", () => {
+ renderComponent({ show: false });
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+
+ it("renders success animation when show is true", () => {
+ renderComponent();
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText("Payment Successful!")).toBeInTheDocument();
+ expect(screen.getByText("100 XLM")).toBeInTheDocument();
+ });
+
+ it("displays correct amount and asset", () => {
+ renderComponent({ amount: "500", asset: "USD" });
+ expect(screen.getByText("500 USD")).toBeInTheDocument();
+ });
+
+ it("displays transaction ID when provided", () => {
+ renderComponent();
+ expect(screen.getByText("tx123")).toBeInTheDocument();
+ });
+
+ it("does not display transaction ID when not provided", () => {
+ renderComponent({ txId: undefined });
+ expect(screen.queryByText("Transaction ID")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("has correct ARIA attributes", () => {
+ renderComponent();
+ const dialog = screen.getByRole("dialog");
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(dialog).toHaveAttribute("aria-labelledby", "payment-success-title");
+ expect(dialog).toHaveAttribute("aria-describedby", "payment-success-description");
+ });
+
+ it("has screen reader announcement", () => {
+ renderComponent();
+ const announcement = screen.getByRole("status");
+ expect(announcement).toHaveAttribute("aria-live", "assertive");
+ expect(announcement).toHaveAttribute("aria-atomic", "true");
+ expect(announcement).toHaveTextContent("Payment successful! 100 XLM has been received.");
+ });
+
+ it("has accessible close button", () => {
+ renderComponent();
+ const closeButton = screen.getByLabelText("Close");
+ expect(closeButton).toBeInTheDocument();
+ });
+
+ it("has accessible continue button", () => {
+ renderComponent();
+ const continueButton = screen.getByLabelText("Continue");
+ expect(continueButton).toBeInTheDocument();
+ });
+
+ it("has screen reader hint", () => {
+ renderComponent();
+ const hint = screen.getByText("Press the continue button or close button to dismiss this success message.");
+ expect(hint).toHaveClass("sr-only");
+ });
+ });
+
+ describe("Interactions", () => {
+ it("calls onComplete when close button is clicked", () => {
+ renderComponent();
+ const closeButton = screen.getByLabelText("Close");
+ fireEvent.click(closeButton);
+ expect(defaultProps.onComplete).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onComplete when continue button is clicked", () => {
+ renderComponent();
+ const continueButton = screen.getByLabelText("Continue");
+ fireEvent.click(continueButton);
+ expect(defaultProps.onComplete).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onComplete after animation timeout", async () => {
+ renderComponent();
+ await waitFor(() => {
+ expect(defaultProps.onComplete).toHaveBeenCalledTimes(1);
+ }, { timeout: 4100 });
+ });
+ });
+
+ describe("Confetti Animation", () => {
+ it("triggers confetti on mount when show is true", () => {
+ const mockConfetti = require("canvas-confetti");
+ renderComponent();
+ expect(mockConfetti).toHaveBeenCalled();
+ });
+
+ it("does not trigger confetti when show is false", () => {
+ const mockConfetti = require("canvas-confetti");
+ renderComponent({ show: false });
+ expect(mockConfetti).not.toHaveBeenCalled();
+ });
+
+ it("does not trigger confetti again on re-render", () => {
+ const mockConfetti = require("canvas-confetti");
+ const { rerender } = renderComponent();
+ rerender(
+
+
+
+ );
+ expect(mockConfetti).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Animation States", () => {
+ it("resets announcement state after timeout", async () => {
+ renderComponent();
+ // First render should set hasAnnounced to true
+ await waitFor(() => {
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ });
+
+ // Wait for reset timeout
+ await new Promise(resolve => setTimeout(resolve, 1100));
+
+ // Re-render to check if it would announce again
+ const { rerender } = render(
+
+
+
+ );
+
+ rerender(
+
+
+
+ );
+
+ // Should announce again after reset
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ });
+ });
+
+ describe("Props Handling", () => {
+ it("uses default values for optional props", () => {
+ renderComponent({
+ show: true,
+ onComplete: jest.fn(),
+ amount: undefined,
+ asset: undefined,
+ txId: undefined,
+ });
+ expect(screen.getByText("0 XLM")).toBeInTheDocument();
+ });
+
+ it("handles custom onComplete callback", () => {
+ const customOnComplete = jest.fn();
+ renderComponent({ onComplete: customOnComplete });
+
+ const continueButton = screen.getByLabelText("Continue");
+ fireEvent.click(continueButton);
+
+ expect(customOnComplete).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Translation Integration", () => {
+ it("uses translated text for UI elements", () => {
+ renderComponent();
+ expect(screen.getByText("Amount Received")).toBeInTheDocument();
+ expect(screen.getByText("Your payment has been processed successfully.")).toBeInTheDocument();
+ });
+
+ it("falls back to keys when translations are missing", () => {
+ const messages = {};
+ const { rerender } = render(
+
+
+
+ );
+
+ // Should still render with fallback keys
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx
index 2e8aa51a..d8f1d642 100644
--- a/frontend/src/components/ThemeToggle.tsx
+++ b/frontend/src/components/ThemeToggle.tsx
@@ -1,19 +1,48 @@
"use client";
import { useThemeState, useThemeActions } from "@/lib/theme-context";
-import { useCallback } from "react";
+import { useCallback, useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
export default function ThemeToggle() {
const { theme, resolvedTheme, isMounted, isLoading, error } = useThemeState();
const { toggleTheme, clearError } = useThemeActions();
+ const [announcement, setAnnouncement] = useState("");
const handleThemeToggle = useCallback(() => {
if (error) {
clearError();
+ setAnnouncement("Error cleared. Attempting to toggle theme.");
+ return;
}
+
+ // Announce the theme change before toggling
+ const nextTheme = getNextTheme();
+ setAnnouncement(`Switching to ${nextTheme} theme.`);
+
toggleTheme();
- }, [toggleTheme, error, clearError]);
+ }, [toggleTheme, error, clearError, theme]);
+
+ /**
+ * Get the next theme that will be activated
+ */
+ const getNextTheme = useCallback((): string => {
+ const themes = ["light", "dark", "system"];
+ const currentIndex = theme ? themes.indexOf(theme) : 0;
+ const nextIndex = (currentIndex + 1) % themes.length;
+ const nextTheme = themes[nextIndex];
+ return nextTheme === "system" ? `system (${resolvedTheme})` : nextTheme;
+ }, [theme, resolvedTheme]);
+
+ /**
+ * Announce theme changes to screen readers
+ */
+ useEffect(() => {
+ if (isMounted && !isLoading && !error) {
+ const currentThemeDesc = theme === "system" ? `system (${resolvedTheme})` : theme || "system";
+ setAnnouncement(`Current theme: ${currentThemeDesc}`);
+ }
+ }, [theme, resolvedTheme, isMounted, isLoading, error]);
if (!isMounted || isLoading) {
return (
@@ -28,28 +57,42 @@ export default function ThemeToggle() {
}
const getAriaLabel = () => {
- if (error) return "Theme toggle with error, click to retry";
- return `Current theme: ${theme || 'system'}, click to switch theme`;
+ if (error) return "Theme toggle with error, press to retry switching theme";
+ const currentThemeDesc = theme === "system" ? `system (${resolvedTheme})` : theme || "system";
+ return `Theme toggle, current theme: ${currentThemeDesc}, press to switch to next theme`;
};
const getTitle = () => {
- if (error) return `Theme error: ${error}. Click to retry.`;
+ if (error) return `Theme error: ${error}. Press to retry.`;
if (theme === 'system') return `Theme: System (${resolvedTheme})`;
return `Theme: ${theme}`;
};
return (
-