Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions apps/site/src/components/forms/FreshdeskContactForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { useRef, useState } from 'react';
import type { FieldError } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { buildContactPayload } from '../../util/freshdesk/buildContactPayload';
import { getRecaptchaToken } from '../../util/recaptcha';
import HoneypotField from './HoneypotField';
import { formErrors, formStatus } from './util/errorMessages';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type FormStatus =
| 'idle'
| 'submitting'
| 'success'
| 'already_exists'
| 'error';

interface FreshdeskContactFormProps {
// The Lambda proxy endpoint URL.
// Set via FRESHDESK_PROXY_URL in apps/site/.env.
submitUrl: string;
// The reCAPTCHA v3 site key for the current environment.
// Passed from the Astro page via import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY.
recaptchaSiteKey: string;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export default function FreshdeskContactForm({
submitUrl,
recaptchaSiteKey,
}: FreshdeskContactFormProps) {
const [status, setStatus] = useState<FormStatus>('idle');
const [submitError, setSubmitError] = useState<string | null>(null);
const confirmationRef = useRef<HTMLDivElement>(null);

const methods = useForm<Record<string, unknown>>({
mode: 'onSubmit',
reValidateMode: 'onChange',
});

const {
register,
handleSubmit,
formState: { errors },
} = methods;

// ---------------------------------------------------------------------------
// Submit handler
// ---------------------------------------------------------------------------

const onSubmit = async (values: Record<string, unknown>) => {
setStatus('submitting');
setSubmitError(null);

try {
const recaptchaToken = await getRecaptchaToken(recaptchaSiteKey);

const payload = {
...buildContactPayload(values),
recaptcha_token: recaptchaToken,
};

const response = await fetch(`${submitUrl}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

// 409 means the email already exists as a Freshdesk contact.
// Show a friendly message rather than treating it as an error.
if (response.status === 409) {
setStatus('already_exists');
setTimeout(() => confirmationRef.current?.focus(), 0);
return;
}

if (!response.ok) {
throw new Error(`Submit failed: ${response.status}`);
}

setStatus('success');
setTimeout(() => confirmationRef.current?.focus(), 0);
} catch {
setStatus('error');
setSubmitError(formErrors.submission.general);
}
};

// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------

const isComplete = status === 'success' || status === 'already_exists';

return (
<FormProvider {...methods}>
<div>
{/* Success / already exists message — shown in the same location
as the form so the page layout doesn't shift on submission. */}
{isComplete && (
<div ref={confirmationRef} tabIndex={-1}>
<div className="usa-alert usa-alert--success">
<div className="usa-alert__body">
<p className="usa-alert__text">
{status === 'already_exists'
? formStatus.alreadySubscribed
: "You're subscribed! Check your inbox for a confirmation."}
</p>
</div>
</div>
</div>
)}

{/* Form — hidden after successful submission */}
{!isComplete && (
<form
className="usa-form maxw-full"
onSubmit={handleSubmit(onSubmit)}
noValidate
>
{/* Submission error banner */}
{status === 'error' && submitError && (
<div
className="usa-alert usa-alert--error margin-bottom-2"
role="alert"
>
<div className="usa-alert__body">
<p className="usa-alert__text">{submitError}</p>
</div>
</div>
)}

<div className="display-flex grid-row">
{/* Name field — optional */}
<div className="usa-form-group margin-0 grid-col">
<label className="usa-label" htmlFor="signup-name">
Name
</label>
<input
id="signup-name"
className="usa-input"
type="text"
autoComplete="name"
{...register('name')}
/>
</div>

{/* Email field — required */}
<div className="usa-form-group margin-0 grid-col margin-left-5">
<label className="usa-label" htmlFor="signup-email">
Email
<abbr
title="required"
className="usa-hint usa-hint--required"
>
{' '}
*
</abbr>
</label>
{errors.email && (
<span className="usa-error-message" role="alert">
{(errors.email as FieldError).message}
</span>
)}
<input
id="signup-email"
className={`usa-input${errors.email ? ' usa-input--error' : ''}`}
type="email"
autoComplete="email"
aria-required
{...register('email', {
required:
'Please enter a valid email address (e.g. name@example.com).',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message:
'Please enter a valid email address (e.g. name@example.com).',
},
})}
/>
</div>
</div>

<HoneypotField register={register('website')} />

<button
type="submit"
className="usa-button margin-top-2"
disabled={status === 'submitting'}
aria-disabled={status === 'submitting'}
>
{status === 'submitting'
? 'Subscribing…'
: 'Subscribe to Updates'}
</button>
</form>
)}
</div>
</FormProvider>
);
}
5 changes: 5 additions & 0 deletions apps/site/src/components/forms/util/errorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ export const formStatus = {
'Try again later, or contact us by email at biodatacatalyst@nhlbi.nih.gov if you need help right away.',
unavailableHeading: "This form isn't available right now.",

// Shown when a contact with this email already exists in Freshdesk.
// Used by FreshdeskContactForm to acknowledge returning users.
alreadySubscribed:
'Your previous join request is still in process; email biodatacatalyst@nhlbi.nih.gov to have an activation email resent.',

// Default success heading — per-form follow-up copy is defined per form
successHeading: 'Submission Received',
successText:
Expand Down
28 changes: 28 additions & 0 deletions apps/site/src/pages/join.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
import FreshdeskContactForm from '@components/forms/FreshdeskContactForm.tsx';
import Base from '@layouts/Base.astro';
---

<Base title="Join" enableRecaptcha>
<section class="usa-section">
<div class="grid-container">
<div class="grid-row">
<div class="grid-col-12">

<h1>Join the Community</h1>

<p class="usa-intro">
Sign up to receive updates and stay connected with the community.
</p>

<FreshdeskContactForm
submitUrl={import.meta.env.FRESHDESK_PROXY_URL}
recaptchaSiteKey={import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY}
client:idle
/>

</div>
</div>
</div>
</section>
</Base>
45 changes: 45 additions & 0 deletions apps/site/src/util/freshdesk/buildContactPayload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { buildContactPayload } from './buildContactPayload';

describe('buildContactPayload', () => {
it('returns email only when name is not provided', () => {
const payload = buildContactPayload({ email: 'jane@university.edu' });
expect(payload).toEqual({ email: 'jane@university.edu' });
expect(payload.name).toBeUndefined();
});

it('includes name when provided', () => {
const payload = buildContactPayload({
email: 'jane@university.edu',
name: 'Jane Researcher',
});
expect(payload).toEqual({
email: 'jane@university.edu',
name: 'Jane Researcher',
});
});

it('trims whitespace from name', () => {
const payload = buildContactPayload({
email: 'jane@university.edu',
name: ' Jane Researcher ',
});
expect(payload.name).toBe('Jane Researcher');
});

it('omits name when it is an empty string', () => {
const payload = buildContactPayload({
email: 'jane@university.edu',
name: '',
});
expect(payload.name).toBeUndefined();
});

it('omits name when it is only whitespace', () => {
const payload = buildContactPayload({
email: 'jane@university.edu',
name: ' ',
});
expect(payload.name).toBeUndefined();
});
});
18 changes: 18 additions & 0 deletions apps/site/src/util/freshdesk/buildContactPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export interface ContactPayload {
name?: string;
email: string;
}

export function buildContactPayload(
values: Record<string, unknown>,
): ContactPayload {
const payload: ContactPayload = {
email: values.email as string,
};

if (values.name && typeof values.name === 'string' && values.name.trim()) {
payload.name = values.name.trim();
}

return payload;
}
1 change: 1 addition & 0 deletions apps/site/src/util/freshdesk/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './buildContactPayload.ts';
export * from './buildCustomObjectPayload.ts';
export * from './buildPayload';
export * from './getCustomObjectRecords';
Expand Down
Loading
Loading