diff --git a/src/__tests__/waitlist-page.test.tsx b/src/__tests__/waitlist-page.test.tsx
new file mode 100644
index 00000000..17ef274a
--- /dev/null
+++ b/src/__tests__/waitlist-page.test.tsx
@@ -0,0 +1,178 @@
+/**
+ * Tests for /waitlist/page.tsx
+ * Issue: successful join, failed join with toast, required-field validation
+ */
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import toast from 'react-hot-toast';
+import { waitlistApi } from '@/lib/api';
+import WaitlistPage from '@/app/waitlist/page';
+
+// ─── Mocks ──────────────────────────────────────────────────────────────────
+
+jest.mock('@/lib/api', () => ({
+ waitlistApi: {
+ join: jest.fn(),
+ },
+}));
+
+jest.mock('react-hot-toast', () => ({
+ __esModule: true,
+ default: {
+ error: jest.fn(),
+ success: jest.fn(),
+ },
+ error: jest.fn(),
+ success: jest.fn(),
+}));
+
+jest.mock('next/link', () => ({
+ __esModule: true,
+ default: ({ children, href }: { children: React.ReactNode; href: string }) => (
+ {children}
+ ),
+}));
+
+const mockJoin = waitlistApi.join as jest.MockedFunction;
+const mockToastError = toast.error as jest.MockedFunction;
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+/** Fill the required email field and submit the form */
+async function fillAndSubmit(email: string) {
+ const user = userEvent.setup();
+ const emailInput = screen.getByLabelText('Email');
+ await user.clear(emailInput);
+ await user.type(emailInput, email);
+ const submitBtn = screen.getByRole('button', { name: /join waitlist/i });
+ await user.click(submitBtn);
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('WaitlistPage', () => {
+ describe('successful join', () => {
+ it('renders the CheckCircle confirmation view after a successful join', async () => {
+ mockJoin.mockResolvedValue({ data: {} } as ReturnType);
+
+ render();
+
+ await fillAndSubmit('user@example.com');
+
+ await waitFor(() => {
+ expect(screen.getByText("You're on the list!")).toBeInTheDocument();
+ });
+
+ // Form should no longer be visible
+ expect(screen.queryByRole('button', { name: /join waitlist/i })).not.toBeInTheDocument();
+ });
+
+ it('calls waitlistApi.join with the submitted email', async () => {
+ mockJoin.mockResolvedValue({ data: {} } as ReturnType);
+
+ render();
+
+ await fillAndSubmit('merchant@example.com');
+
+ await waitFor(() => {
+ expect(mockJoin).toHaveBeenCalledWith(
+ expect.objectContaining({ email: 'merchant@example.com' }),
+ );
+ });
+ });
+ });
+
+ describe('failed join', () => {
+ it('shows a toast error and keeps the form visible on API failure', async () => {
+ mockJoin.mockRejectedValue(new Error('Server error'));
+
+ render();
+
+ await fillAndSubmit('user@example.com');
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalled();
+ });
+
+ // Form should still be visible
+ expect(screen.getByRole('button', { name: /join waitlist/i })).toBeInTheDocument();
+ });
+
+ it('shows the API error message in the toast', async () => {
+ const { AxiosError } = await import('axios');
+ const axiosError = new AxiosError('Request failed');
+ (axiosError as unknown as { response: unknown }).response = {
+ data: { message: 'Email already registered' },
+ status: 400,
+ };
+ mockJoin.mockRejectedValue(axiosError);
+
+ render();
+
+ await fillAndSubmit('taken@example.com');
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith('Email already registered');
+ });
+ });
+
+ it('shows a fallback toast message when no specific error message is available', async () => {
+ mockJoin.mockRejectedValue(new Error(''));
+
+ render();
+
+ await fillAndSubmit('user@example.com');
+
+ await waitFor(() => {
+ expect(mockToastError).toHaveBeenCalledWith('Failed to join waitlist');
+ });
+ });
+ });
+
+ describe('required-field validation', () => {
+ it('does not submit when the email field is empty (HTML required attribute prevents submission)', async () => {
+ render();
+
+ // Verify the email input has the required attribute
+ const emailInput = screen.getByLabelText('Email');
+ expect(emailInput).toBeRequired();
+
+ // Without filling in the email, clicking submit should not invoke the API.
+ // JSDOM doesn't enforce native form validation; instead verify checkValidity()
+ expect((emailInput as HTMLInputElement).checkValidity()).toBe(false);
+ expect(mockJoin).not.toHaveBeenCalled();
+ });
+
+ it('disables inputs while submission is in-flight', async () => {
+ // Never resolves — keeps loading state
+ mockJoin.mockReturnValue(new Promise(() => {}) as ReturnType);
+
+ render();
+
+ await fillAndSubmit('user@example.com');
+
+ await waitFor(() => {
+ // The fieldset is disabled during loading
+ const fieldset = document.querySelector('fieldset');
+ expect(fieldset).toBeDisabled();
+ });
+ });
+
+ it('shows "Joining..." text on the button while submitting', async () => {
+ mockJoin.mockReturnValue(new Promise(() => {}) as ReturnType);
+
+ render();
+
+ await fillAndSubmit('user@example.com');
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /joining/i })).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx
index 419b62f0..24bbc663 100644
--- a/src/components/FormField.tsx
+++ b/src/components/FormField.tsx
@@ -14,4 +14,4 @@ export function FormField({ label, id, ...props }: FormFieldProps) {
);
-}
\ No newline at end of file
+}