From e0d50801ad98899304962723fda88ad1c1599b25 Mon Sep 17 00:00:00 2001 From: Gillian Scott Date: Sat, 18 Oct 2025 01:21:10 -0400 Subject: [PATCH 1/2] frontend: barebones donation form component --- .gitignore | 3 +- apps/frontend/src/api/apiClient.ts | 41 +- apps/frontend/src/app.tsx | 10 + .../donations/DonationForm.spec.tsx | 137 +++++++ .../src/containers/donations/DonationForm.tsx | 386 ++++++++++++++++++ .../src/containers/donations/donations.css | 91 +++++ 6 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/containers/donations/DonationForm.spec.tsx create mode 100644 apps/frontend/src/containers/donations/DonationForm.tsx create mode 100644 apps/frontend/src/containers/donations/donations.css diff --git a/.gitignore b/.gitignore index ac8cb93..f2d7726 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ Thumbs.db # Environment file *.env *.env.* -!example.env \ No newline at end of file +!example.env.NM_TRASH_PENDING_DELETE/ +.NM_TRASH_PENDING_DELETE/ diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index cfaf52d..ad621f0 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -3,6 +3,21 @@ import axios, { type AxiosInstance } from 'axios'; const defaultBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'; +export type DonationCreateRequest = { + firstName: string; + lastName: string; + email: string; + amount: number; // parsed to number in the form + isAnonymous: boolean; + donationType: 'one_time' | 'recurring'; + dedicationMessage: string; // allow '' from ui + showDedicationPublicly: boolean; + recurringInterval?: 'weekly' | 'bimonthly' | 'monthly' | 'quarterly'; +}; + +export type CreateDonationResponse = { id: string }; +type ApiError = { error?: string; message?: string }; + export class ApiClient { private axiosInstance: AxiosInstance; @@ -11,7 +26,29 @@ export class ApiClient { } public async getHello(): Promise { - return this.get('/api') as Promise; + //return this.get('/api') as Promise; + const res = await this.axiosInstance.get('/api'); + return res.data; + } + + public async createDonation( + body: DonationCreateRequest, + ): Promise { + try { + const res = await this.axiosInstance.post('/api/donations', body); + return res.data as CreateDonationResponse; + } catch (err: unknown) { + if (axios.isAxiosError(err)) { + const data = err.response?.data; + const msg = + data?.error ?? + data?.message ?? + err.message ?? + 'Failed to create donation'; + throw new Error(msg); + } + throw new Error('Failed to create donation'); + } } private async get(path: string): Promise { @@ -36,3 +73,5 @@ export class ApiClient { } export default new ApiClient(); + +export type { DonationCreateRequest as CreateDonationRequest }; diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index a51df65..253c1f6 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -5,6 +5,7 @@ import apiClient from '@api/apiClient'; import Root from '@containers/root'; import NotFound from '@containers/404'; import Test from '@containers/test'; +import { DonationForm } from '@containers/donations/DonationForm'; const router = createBrowserRouter([ { @@ -16,6 +17,15 @@ const router = createBrowserRouter([ path: '/test', element: , }, + { + path: '/donate', + element: ( + console.log('Donation successful:', id)} + onError={(err) => console.error('Donation failed:', err)} + /> + ), + }, ]); export const App: React.FC = () => { diff --git a/apps/frontend/src/containers/donations/DonationForm.spec.tsx b/apps/frontend/src/containers/donations/DonationForm.spec.tsx new file mode 100644 index 0000000..7064c04 --- /dev/null +++ b/apps/frontend/src/containers/donations/DonationForm.spec.tsx @@ -0,0 +1,137 @@ +/** @vitest-environment jsdom */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import apiClient from '../../api/apiClient'; +import { DonationForm } from './DonationForm'; +import type { CreateDonationResponse } from '../../api/apiClient'; + +describe('DonationForm Component', () => { + const onSuccess = vi.fn(); + const onError = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows validation errors for empty fields', async () => { + const spy = vi.spyOn(apiClient, 'createDonation'); + render(); + + fireEvent.click(screen.getByRole('button', { name: /submit donation/i })); + + await waitFor(() => { + expect(screen.queryByText(/first name is required/i)).not.toBeNull(); + expect(screen.queryByText(/last name is required/i)).not.toBeNull(); + expect(screen.queryByText(/email is required/i)).not.toBeNull(); + expect(screen.queryByText(/positive amount/i)).not.toBeNull(); + }); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('submits valid donation and calls onSuccess', async () => { + const spy = vi + .spyOn(apiClient, 'createDonation') + .mockResolvedValueOnce({ id: '123' }); + + render(); + + fireEvent.change(screen.getByLabelText(/first name/i), { + target: { value: 'Hello' }, + }); + fireEvent.change(screen.getByLabelText(/last name/i), { + target: { value: 'Kitty' }, + }); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'hello@kitty.com' }, + }); + fireEvent.change(screen.getByLabelText(/donation amount/i), { + target: { value: '25.50' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /submit donation/i })); + + await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); + + const payload = spy.mock.calls[0][0]; + expect(payload).toMatchObject({ + firstName: 'Hello', + lastName: 'Kitty', + email: 'hello@kitty.com', + amount: 25.5, + }); + expect(onSuccess).toHaveBeenCalledWith('123'); + }); + + it('shows error banner and calls onError when API fails', async () => { + const spy = vi + .spyOn(apiClient, 'createDonation') + .mockRejectedValueOnce(new Error('Network error')); + + render(); + + fireEvent.change(screen.getByLabelText(/first name/i), { + target: { value: 'John' }, + }); + fireEvent.change(screen.getByLabelText(/last name/i), { + target: { value: 'Doe' }, + }); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'john@northeastern.edu' }, + }); + fireEvent.change(screen.getByLabelText(/donation amount/i), { + target: { value: '50' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /submit donation/i })); + + await waitFor(() => { + expect(screen.queryByText(/network error/i)).not.toBeNull(); + }); + expect(onError).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('disables submit button while submitting', async () => { + let resolvePending!: (value: CreateDonationResponse) => void; + + const pending = new Promise((resolve) => { + resolvePending = resolve; + }); + const spy = vi + .spyOn(apiClient, 'createDonation') + .mockReturnValueOnce(pending); + + render(); + + fireEvent.change(screen.getByLabelText(/first name/i), { + target: { value: 'Scooby' }, + }); + fireEvent.change(screen.getByLabelText(/last name/i), { + target: { value: 'Doo' }, + }); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'scooby@doobydoo.com' }, + }); + fireEvent.change(screen.getByLabelText(/donation amount/i), { + target: { value: '100' }, + }); + + const button = screen.getByRole('button', { name: /submit donation/i }); + + fireEvent.click(button); + + await waitFor(() => { + expect((button as HTMLButtonElement).disabled).toBe(true); + }); + + resolvePending({ id: 'ok' }); + + await waitFor(() => { + expect((button as HTMLButtonElement).disabled).toBe(false); + }); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/frontend/src/containers/donations/DonationForm.tsx b/apps/frontend/src/containers/donations/DonationForm.tsx new file mode 100644 index 0000000..3bf6427 --- /dev/null +++ b/apps/frontend/src/containers/donations/DonationForm.tsx @@ -0,0 +1,386 @@ +import apiClient, { + type CreateDonationResponse, + type CreateDonationRequest, +} from '../../api/apiClient'; +import React, { useState, FormEvent } from 'react'; +import './donations.css'; + +type RecurringInterval = 'weekly' | 'bimonthly' | 'monthly' | 'quarterly'; + +interface DonationFormData { + firstName: string; + lastName: string; + email: string; // validated + amount: string; // decimal, validated positive + isAnonymous: boolean; + donationType: 'one_time' | 'recurring'; + dedicationMessage: string; // optional + showDedicationPublicly: boolean; + recurringInterval: RecurringInterval; +} + +interface DonationFormProps { + onSuccess: (donationId: string) => void; + onError: (error: Error) => void; +} + +interface FormErrors { + firstName: string; + lastName: string; + email: string; + amount: string; + recurringInterval: string; +} + +export const DonationForm: React.FC = ({ + onSuccess, + onError, +}) => { + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + email: '', + amount: '', + isAnonymous: false, + donationType: 'one_time', + dedicationMessage: '', + showDedicationPublicly: false, + recurringInterval: 'monthly', + }); + + const [errors, setErrors] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const validateForm = (): boolean => { + const newErrors: Partial = {}; + + if (!formData.firstName.trim()) { + newErrors.firstName = 'First name is required'; + } + + if (!formData.lastName.trim()) { + newErrors.lastName = 'Last name is required'; + } + + if (!formData.email.trim()) { + newErrors.email = 'Email is required'; + } else if (!validateEmail(formData.email)) { + newErrors.email = 'Please enter a valid email'; + } + + const amountNum = parseFloat(formData.amount); + const amountOK = + /^\d+(\.\d{1,2})?$/.test(formData.amount) && + !isNaN(amountNum) && + amountNum > 0; + if (!amountOK) { + newErrors.amount = 'Enter a positive amount (max 2 decimals)'; + } + + if (formData.donationType === 'recurring' && !formData.recurringInterval) { + newErrors.recurringInterval = 'Please select recurring interval'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleInputChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >, + ) => { + const { name, type, value } = e.target; + const next = + type === 'checkbox' ? (e.target as HTMLInputElement).checked : value; + + // update form based on input type + setFormData((prev) => ({ + ...prev, + [name]: next, + })); + + // clear field error + if (errors[name as keyof FormErrors]) { + setErrors((prev) => ({ + ...prev, + [name]: undefined, + })); + } + + // if donationType becomes one_time, clear error + if ( + name === 'donationType' && + value === 'one_time' && + errors.recurringInterval + ) { + setErrors((prev) => ({ ...prev, recurringInterval: undefined })); + } + + // clear any prev submission errors + setSubmitError(null); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (isSubmitting) { + return; // prevent over clicking + } + + // validate form before submission + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + setSubmitError(null); + + try { + // prep payload for API + const payload: CreateDonationRequest = { + firstName: formData.firstName.trim(), + lastName: formData.lastName.trim(), + email: formData.email.trim(), + amount: parseFloat(formData.amount), + isAnonymous: formData.isAnonymous, + donationType: formData.donationType, + dedicationMessage: formData.dedicationMessage, + showDedicationPublicly: formData.showDedicationPublicly, + // if donationType = recurring + ...(formData.donationType === 'recurring' && { + recurringInterval: formData.recurringInterval, + }), + }; + + // submit donation to API + const response: CreateDonationResponse = + await apiClient.createDonation(payload); + + onSuccess(response.id); + setErrors({}); + + // reset form to initial state after successful submit + setFormData({ + firstName: '', + lastName: '', + email: '', + amount: '', + isAnonymous: false, + donationType: 'one_time', + dedicationMessage: '', + showDedicationPublicly: false, + recurringInterval: 'monthly', + }); + } catch (error) { + const err = error as Error; + setSubmitError(err.message || 'Failed to submit donation'); + + onError(err); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+

Make a Donation

+ + {submitError && ( +
+ {submitError} +
+ )} + +
+ + + {errors.firstName && ( + + {errors.firstName} + + )} +
+ +
+ + + {errors.lastName && ( + + {errors.lastName} + + )} +
+ +
+ + + {errors.email && ( + + {errors.email} + + )} +
+ +
+ + + {errors.amount && ( + + {errors.amount} + + )} +
+ +
+ + +
+ + {formData.donationType === 'recurring' && ( +
+ + + {errors.recurringInterval && ( + {errors.recurringInterval} + )} +
+ )} + +
+ +
+ +
+ +