diff --git a/src/components/LandingPages/WorkerLanding.tsx b/src/components/LandingPages/WorkerLanding.tsx index 75941b0d..64791d62 100644 --- a/src/components/LandingPages/WorkerLanding.tsx +++ b/src/components/LandingPages/WorkerLanding.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { withAlert } from 'react-alert'; import { Helmet } from 'react-helmet'; -import { Link, Redirect } from 'react-router-dom'; +import { Link, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { PulseLoader } from 'react-spinners'; import getServerURL from '../../serverOverride'; @@ -12,6 +12,7 @@ import UploadIconBlue from '../../static/images/upload-blue.png'; import UploadIcon from '../../static/images/upload-icon.png'; import VisualizationSVG from '../../static/images/visualization.svg'; import Role from '../../static/Role'; +import IdPickupNotificationForm from '../Notifications/IdPickupNotificationForm'; interface Props { username: string; @@ -46,6 +47,7 @@ const WorkerLanding: React.FC = ({ username, name, organization, role, al const [currentPage, setCurrentPage] = useState(1); const [shouldFilterByAllClients, setShouldFilterByAllClients] = useState(true); const [isLoading, setIsLoading] = useState(true); + const { path, url } = useRouteMatch(); const loadProfilePhoto = useCallback(async (clientsArray: TargetClient[], signal: AbortSignal) => { let photos: (string | null)[]; @@ -282,210 +284,233 @@ const WorkerLanding: React.FC = ({ username, name, organization, role, al Home -
-
-
-

- {shouldFilterByAllClients ? 'All Clients' : 'My Clients'} -

-
- {role === Role.Director || role === Role.Admin ? ( - - - - ) : null} - - - -
-
- -
-
- setSearchName(e.target.value)} - value={searchName} - placeholder="Search by name, phone, email..." - /> - -
-
-
Filters
-
-
- - + + +
+
+
+

+ {shouldFilterByAllClients ? 'All Clients' : 'My Clients'} +

+
+ {role === Role.Director || role === Role.Admin ? ( + + + + ) : null} + + +
-
+
+ +
+
setSearchName(e.target.value)} + value={searchName} + placeholder="Search by name, phone, email..." /> - + +
+
+
Filters
+
+
+ + +
+
+ + +
+
-
-
-
- {isLoading ? ( -
-

Loading Clients

- -
- ) : ( -
- {clients.length > 0 ? ( -
- {currentPosts.map((client) => ( -
-
-
- - menu - -
- -
- upload icon - Upload -
- - -
- doc icon - View Documents -
- - -
- Fill Out Application +
+ {isLoading ? ( +
+

Loading Clients

+ +
+ ) : ( +
+ {clients.length > 0 ? ( +
+ {currentPosts.map((client) => ( +
+
+
+ + menu + +
+ +
+ upload icon + Upload +
+ + +
+ doc icon + View Documents +
+ + +
+ Fill Out Application +
+
- +
-
-
- - -
- {client.photo ? ( - client profile - ) : ( - a blank profile - )} -
-
-
- {client.firstName} {client.lastName} -
-
-
-
{client.phone}
-
-
-
- Birth Date: {client.birthDate} -
-
- -
- - - - - - -
+ + + Notify Client + +
- {showClientAuthModal && modalRender()} + {showClientAuthModal && modalRender()} +
+ ))}
- ))} -
- ) : ( -
-

- No Clients! Click 'Sign up Client' to get started! -

- Search for a client + ) : ( +
+

+ No Clients! Click 'Sign up Client' to get started! +

+ Search for a client +
+ )}
)}
- )} -
-
-
- {!isLoading && clients.length > 0 && ( - <> -
- {clients.length} Results -
-
- {pageNumbers.map((pageNum) => ( - setCurrentPage(pageNum)} - role="button" - tabIndex={0} - onKeyPress={(e) => e.key === 'Enter' && setCurrentPage(pageNum)} - > - {pageNum} - - ))} -
- - )} +
+
+ {!isLoading && clients.length > 0 && ( + <> +
+ {clients.length} Results +
+
+ {pageNumbers.map((pageNum) => ( + setCurrentPage(pageNum)} + role="button" + tabIndex={0} + onKeyPress={(e) => e.key === 'Enter' && setCurrentPage(pageNum)} + > + {pageNum} + + ))} +
+ + )} +
+
-
-
- + + { + const { clientUsername } = props.match.params; + const client = clients.find((client) => client.username === clientUsername); + + if (client === undefined) return ; + + const { firstName: clientFirstName, lastName: clientLastName, phone: clientPhone } = client; + + return ( + + ); + }} + /> +
); }; diff --git a/src/components/Notifications/Hooks/useIdPickupNotificationForm.tsx b/src/components/Notifications/Hooks/useIdPickupNotificationForm.tsx new file mode 100644 index 00000000..2e57a311 --- /dev/null +++ b/src/components/Notifications/Hooks/useIdPickupNotificationForm.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import { useAlert } from 'react-alert'; + +import getServerURL from '../../../serverOverride'; + +const PHONE_REGEX = /^\(?\d{3}\)?[- ]?\d{3}[- ]?\d{4}$/; +const STATE_REGEX = /^[A-Z]{2}$/; +const ZIP_REGEX = /^\d{5}(-\d{4})?$/; +const MIN_LOADING_DURATION = 500; + +const toE164US = (phone: string): string => + `+1${phone.replace(/\D/g, '')}`; + +export const ID_PICKUP_OPTIONS = [ + "Driver's License", + 'Photo ID', + 'Birth Certificate', + 'Social Security Card', + 'Other', +] as const; + +export interface IdPickupFormValues { + workerName: string; + clientName: string; + clientPhone: string; + idToPickup: string; + customIdToPickup: string; + pickupStreetAddress: string; + pickupCity: string; + pickupState: string; + pickupZipCode: string; + pickupHours: string; + additionalComments: string; +} + +export interface IdPickupFormErrors { + workerName?: string; + clientName?: string; + clientPhone?: string; + idToPickup?: string; + customIdToPickup?: string; + pickupStreetAddress?: string; + pickupCity?: string; + pickupState?: string; + pickupZipCode?: string; + pickupHours?: string; +} + +const validate = (values: IdPickupFormValues): IdPickupFormErrors => { + const errors: IdPickupFormErrors = {}; + + if (!values.workerName.trim()) errors.workerName = 'Worker name is required'; + if (!values.clientName.trim()) errors.clientName = 'Client name is required'; + + if (!values.clientPhone.trim()) { + errors.clientPhone = 'Phone number is required'; + } else if (!PHONE_REGEX.test(values.clientPhone.trim())) { + errors.clientPhone = 'Enter a valid phone number (e.g. 215-555-1234)'; + } + + if (!values.idToPickup) { + errors.idToPickup = 'Please select an ID type'; + } else if (values.idToPickup === 'Other' && !values.customIdToPickup.trim()) { + errors.customIdToPickup = 'Please specify the ID type'; + } + + if (!values.pickupStreetAddress.trim()) errors.pickupStreetAddress = 'Street address is required'; + if (!values.pickupCity.trim()) errors.pickupCity = 'City is required'; + + if (!values.pickupState.trim()) { + errors.pickupState = 'State is required'; + } else if (!STATE_REGEX.test(values.pickupState.trim().toUpperCase())) { + errors.pickupState = 'Enter a valid 2-letter state code (e.g. PA)'; + } + + if (!values.pickupZipCode.trim()) { + errors.pickupZipCode = 'ZIP code is required'; + } else if (!ZIP_REGEX.test(values.pickupZipCode.trim())) { + errors.pickupZipCode = 'Enter a valid ZIP code (e.g. 19104)'; + } + + if (!values.pickupHours.trim()) errors.pickupHours = 'Pickup hours are required'; + + return errors; +}; + +export default function useIdPickupNotificationForm( + clientUsername: string, + workerUsername: string, + initialWorkerName: string, + initialClientName: string, + initialClientPhone: string, +) { + const alert = useAlert(); + + const [formValues, setFormValues] = useState({ + workerName: initialWorkerName, + clientName: initialClientName, + clientPhone: initialClientPhone, + idToPickup: '', + customIdToPickup: '', + pickupStreetAddress: '', + pickupCity: '', + pickupState: '', + pickupZipCode: '', + pickupHours: '', + additionalComments: '', + }); + + const [errors, setErrors] = useState({}); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [serverError, setServerError] = useState(''); + + const onChange = (field: keyof IdPickupFormValues, value: string) => { + const newValues = { ...formValues, [field]: value }; + if (field === 'idToPickup' && value !== 'Other') { + newValues.customIdToPickup = ''; + } + setFormValues(newValues); + + if (hasAttemptedSubmit) { + setErrors(validate(newValues)); + } + }; + + const getIdLabel = (): string => { + const idLabel = formValues.idToPickup === 'Other' + ? formValues.customIdToPickup + : formValues.idToPickup; + + return idLabel; + }; + + const getMessagePreview = (): string => { + const location = `${formValues.pickupStreetAddress}, ${formValues.pickupCity}, ${formValues.pickupState} ${formValues.pickupZipCode}`; + + let message = `Hi ${formValues.clientName || '___'}, your ${getIdLabel() || '___'} is ready for pickup at ${location || '___'}. Pickup hours: ${formValues.pickupHours || '___'}. Your case worker ${formValues.workerName || '___'} is ready to help with further ID needs.`; + + if (formValues.additionalComments.trim()) { + message += ` ${formValues.additionalComments}`; + } + + return message; + }; + + const onSubmit = async (): Promise => { + setHasAttemptedSubmit(true); + setServerError(''); + const validationErrors = validate(formValues); + setErrors(validationErrors); + + if (Object.keys(validationErrors).length > 0) { + return validationErrors; + } + + const message = getMessagePreview(); + + const startTime = Date.now(); + + try { + setIsLoading(true); + const res = await fetch(`${getServerURL()}/notify-id-pickup`, { + method: 'POST', + credentials: 'include', + body: JSON.stringify({ + workerUsername, + clientUsername, + idToPickup: getIdLabel(), + clientPhoneNumber: toE164US(formValues.clientPhone), + message, + }), + }); + + const data = await res.json(); + + const elapsedTime = Date.now() - startTime; + const remainingTime = Math.max(0, MIN_LOADING_DURATION - elapsedTime); + + await new Promise((resolve) => setTimeout(resolve, remainingTime)); + + setIsLoading(false); + if (data.status === 'SUCCESS') { + alert.show('Notification sent successfully'); + setRefreshTrigger((prev) => prev + 1); + } else { + setServerError(data.message || 'Failed to send notification. Please try again.'); + } + } catch { + const elapsedTime = Date.now() - startTime; + const remainingTime = Math.max(0, MIN_LOADING_DURATION - elapsedTime); + + await new Promise((resolve) => setTimeout(resolve, remainingTime)); + + setIsLoading(false); + setServerError('Network error. Please try again.'); + } + + return {}; + }; + + return { + formValues, + errors, + hasAttemptedSubmit, + serverError, + refreshTrigger, + isLoading, + onChange, + onSubmit, + getMessagePreview, + }; +} diff --git a/src/components/Notifications/IdPickupNotificationForm.tsx b/src/components/Notifications/IdPickupNotificationForm.tsx new file mode 100644 index 00000000..b9171868 --- /dev/null +++ b/src/components/Notifications/IdPickupNotificationForm.tsx @@ -0,0 +1,421 @@ +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +import useIdPickupNotificationForm, { + ID_PICKUP_OPTIONS, + IdPickupFormErrors, +} from './Hooks/useIdPickupNotificationForm'; +import NotificationActivity from './NotificationActivity'; + +const BASE_INPUT_CLASS = + 'tw-col-span-2 tw-block tw-w-full tw-rounded-md tw-border-0 tw-py-1.5 tw-px-3 tw-shadow-sm tw-ring-1 tw-ring-inset focus:tw-ring-2 focus:tw-ring-inset focus:tw-ring-indigo-600'; + +const FIELD_ID_MAP: Record = { + workerName: 'worker-name', + clientName: 'client-name', + clientPhone: 'client-phone', + idToPickup: 'id-to-pickup', + customIdToPickup: 'custom-id', + pickupStreetAddress: 'street-address', + pickupCity: 'city', + pickupState: 'state', + pickupZipCode: 'zip-code', + pickupHours: 'pickup-hours', +}; + +export default function IdPickupNotificationForm({ + clientUsername, + workerUsername, + initialWorkerName, + initialClientName, + initialClientPhone, +}: { + clientUsername: string; + workerUsername: string; + initialWorkerName: string; + initialClientName: string; + initialClientPhone: string; +}) { + const history = useHistory(); + const { + formValues, + errors, + hasAttemptedSubmit, + serverError, + refreshTrigger, + isLoading, + onChange, + onSubmit, + getMessagePreview, + } = useIdPickupNotificationForm(clientUsername, workerUsername, initialWorkerName, initialClientName, initialClientPhone); + + const ringClass = (fieldName: keyof IdPickupFormErrors) => + hasAttemptedSubmit && errors[fieldName] + ? 'tw-ring-red-500' + : 'tw-ring-gray-300'; + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const validationErrors = await onSubmit(); + const firstErrorKey = Object.keys(validationErrors)[0]; + if (firstErrorKey) { + const elementId = FIELD_ID_MAP[firstErrorKey]; + if (elementId) { + document.getElementById(elementId)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + }; + + const handleGoToDashboard = () => { + history.push('/home'); + }; + + const renderError = (fieldName: keyof IdPickupFormErrors) => + hasAttemptedSubmit && errors[fieldName] ? ( +

{errors[fieldName]}

+ ) : null; + + return ( +
+

+ ID Pickup Notifier +

+ +
+
+
+
+
+

+ Message Preview +

+
+ {getMessagePreview()} +
+
+ {(hasAttemptedSubmit && Object.keys(errors).length > 0) || serverError ? ( +
+

+ {serverError || `Please fix ${Object.keys(errors).length} ${Object.keys(errors).length === 1 ? 'error' : 'errors'} below before sending.`} +

+
+ ) : null} + +
    +
  • +
    + +
    + onChange('workerName', e.target.value)} + className={`${BASE_INPUT_CLASS} ${ringClass('workerName')}`} + /> + {renderError('workerName')} +
    +
    +
  • + +
  • +
    + +
    + onChange('clientName', e.target.value)} + className={`${BASE_INPUT_CLASS} ${ringClass('clientName')}`} + /> + {renderError('clientName')} +
    +
    +
  • + +
  • +
    + +
    + onChange('clientPhone', e.target.value)} + className={`${BASE_INPUT_CLASS} ${ringClass('clientPhone')}`} + /> + {renderError('clientPhone')} +
    +
    +
  • + +
  • +
    + +
    + + {renderError('idToPickup')} +
    +
    +
  • + + {formValues.idToPickup === 'Other' && ( +
  • +
    + +
    + onChange('customIdToPickup', e.target.value)} + className={`${BASE_INPUT_CLASS} ${ringClass('customIdToPickup')}`} + /> + {renderError('customIdToPickup')} +
    +
    +
  • + )} + +
  • +
    +

    Pickup Address

    +
    +
  • + +
  • +
    + +
    + onChange('pickupStreetAddress', e.target.value)} + className={`${BASE_INPUT_CLASS} ${ringClass('pickupStreetAddress')}`} + /> + {renderError('pickupStreetAddress')} +
    +
    +
  • + +
  • +
    + +
    + onChange('pickupCity', e.target.value)} + className={`${BASE_INPUT_CLASS} ${ringClass('pickupCity')}`} + /> + {renderError('pickupCity')} +
    +
    +
  • + +
  • +
    + +
    + onChange('pickupState', e.target.value.toUpperCase())} + className={`${BASE_INPUT_CLASS} ${ringClass('pickupState')}`} + /> + {renderError('pickupState')} +
    +
    +
  • + +
  • +
    + +
    + onChange('pickupZipCode', e.target.value)} + className={`${BASE_INPUT_CLASS} ${ringClass('pickupZipCode')}`} + /> + {renderError('pickupZipCode')} +
    +
    +
  • + +
  • +
    + +
    + onChange('pickupHours', e.target.value)} + className={`${BASE_INPUT_CLASS} ${ringClass('pickupHours')}`} + /> + {renderError('pickupHours')} +
    +
    +
  • + +
  • +
    + +
    +