@@ -11,7 +11,8 @@ const formEndpoint = site.formEndpoint as string;
1111const recaptchaApiUrl = site .recaptchaApiUrl as string ;
1212const recaptchaSiteKey = site .recaptchaSiteKey as string ;
1313const recaptchaAction = site .recaptchaAction as string ;
14- const enabled = formEndpoint .length > 0 && recaptchaSiteKey .length > 0 && recaptchaAction .length > 0 ;
14+ const enabled =
15+ formEndpoint .length > 0 && recaptchaSiteKey .length > 0 && recaptchaAction .length > 0 ;
1516const f = apply .fields ;
1617const recaptchaNotice = apply .recaptchaNotice ;
1718---
@@ -26,7 +27,8 @@ const recaptchaNotice = apply.recaptchaNotice;
2627 {
2728 apply .reassurance .map ((item ) => (
2829 <li >
29- <span class = " apply__tick" aria-hidden = " true" ></span >{ item }
30+ <span class = " apply__tick" aria-hidden = " true" />
31+ { item }
3032 </li >
3133 ))
3234 }
@@ -40,6 +42,16 @@ const recaptchaNotice = apply.recaptchaNotice;
4042 data-endpoint ={ formEndpoint }
4143 data-error ={ apply .errorBody }
4244 data-sending ={ apply .sendingLabel }
45+ data-recaptcha-api-url ={ recaptchaApiUrl }
46+ data-recaptcha-site-key ={ recaptchaSiteKey }
47+ data-recaptcha-action ={ recaptchaAction }
48+ data-form-type ={ apply .crm .formType }
49+ data-service-interest ={ apply .crm .serviceInterest }
50+ data-contact-reason ={ apply .crm .contactReason }
51+ data-project-type ={ apply .crm .projectType }
52+ data-initiative ={ apply .crm .initiative }
53+ data-source-note ={ apply .crm .sourceNote }
54+ data-form-visible-started-at =" 0"
4355 method =" post"
4456 action ={ enabled ? formEndpoint : undefined }
4557 >
@@ -150,7 +162,26 @@ const recaptchaNotice = apply.recaptchaNotice;
150162 <p class =" apply__status font-mono" data-apply-status role =" status" aria-live =" polite" ></p >
151163 </div >
152164
165+ {
166+ enabled && (
167+ <p class = " apply__recaptcha font-mono" >
168+ { recaptchaNotice .before }
169+ <a href = { recaptchaNotice .privacyUrl } rel = " noreferrer" >
170+ { recaptchaNotice .privacyLabel }
171+ </a >
172+ { recaptchaNotice .middle }
173+ <a href = { recaptchaNotice .termsUrl } rel = " noreferrer" >
174+ { recaptchaNotice .termsLabel }
175+ </a >
176+ { recaptchaNotice .after }
177+ </p >
178+ )
179+ }
180+
153181 { ! enabled && <p class = " apply__disabled font-mono" >{ apply .disabledNote } </p >}
182+ <noscript >
183+ <p class =" apply__disabled font-mono" >{ apply .noscriptNote } </p >
184+ </noscript >
154185 </form >
155186
156187 <div class =" apply__success card" data-apply-success hidden tabindex =" -1" >
@@ -327,6 +358,21 @@ const recaptchaNotice = apply.recaptchaNotice;
327358 padding-top: var(--space-2xs);
328359 border-top: 1.5px dotted var(--hairline-strong);
329360 }
361+ .apply__recaptcha {
362+ font-size: var(--step--2);
363+ line-height: 1.5;
364+ color: var(--fg-faint);
365+ margin: calc(var(--space-2xs) * -1) 0 0;
366+ }
367+ .apply__recaptcha a {
368+ color: currentColor;
369+ text-decoration: underline;
370+ text-decoration-thickness: var(--border-px);
371+ text-underline-offset: 0.18em;
372+ }
373+ .apply__recaptcha a:hover {
374+ color: var(--fg);
375+ }
330376
331377 /* ---------- Success panel ---------- */
332378 .apply__success {
@@ -377,11 +423,24 @@ const recaptchaNotice = apply.recaptchaNotice;
377423</style >
378424
379425<script >
380- /* Progressive enhancement: POST the form as JSON and swap in an inline
381- success panel. Without an endpoint the form is inert (the server already
382- rendered the disabled state) and this handler never wires up. Without JS,
383- the native <form method=post action=endpoint> still submits. */
426+ /* Progressive enhancement: POST the form as JSON, fetch a reCAPTCHA token
427+ lazily, and swap in an inline success panel. */
384428 (() => {
429+ interface Grecaptcha {
430+ ready(cb: () => void): void;
431+ execute(siteKey: string, options: { action: string }): Promise<string>;
432+ }
433+
434+ interface GrecaptchaWindow extends Window {
435+ grecaptcha?: Grecaptcha;
436+ }
437+
438+ type Payload = Record<string, unknown>;
439+
440+ const grecaptchaReadyTimeoutMs = 8000;
441+ const grecaptchaPollMs = 100;
442+ let recaptchaScriptPromise: Promise<void> | null = null;
443+
385444 const form = document.querySelector<HTMLFormElement>('[data-apply-form]');
386445 if (!form) return;
387446 const endpoint = form.dataset.endpoint || '';
@@ -392,12 +451,202 @@ const recaptchaNotice = apply.recaptchaNotice;
392451 const success = document.querySelector<HTMLElement>('[data-apply-success]');
393452 const sendingLabel = form.dataset.sending || 'Sending…';
394453 const errorMsg = form.dataset.error || 'Something went wrong. Try again.';
454+ form.dataset.formVisibleStartedAt = String(Date.now());
455+
456+ const currentGrecaptcha = () => (window as GrecaptchaWindow).grecaptcha;
457+
458+ const waitForGrecaptcha = async (): Promise<Grecaptcha | null> => {
459+ const current = currentGrecaptcha();
460+ if (current?.execute) return current;
461+
462+ const deadline = Date.now() + grecaptchaReadyTimeoutMs;
463+ return await new Promise((resolve) => {
464+ const timer = window.setInterval(() => {
465+ const candidate = currentGrecaptcha();
466+ if (candidate?.execute) {
467+ window.clearInterval(timer);
468+ resolve(candidate);
469+ return;
470+ }
471+ if (Date.now() > deadline) {
472+ window.clearInterval(timer);
473+ resolve(null);
474+ }
475+ }, grecaptchaPollMs);
476+ });
477+ };
478+
479+ const loadRecaptchaScript = async (apiUrl: string, siteKey: string): Promise<void> => {
480+ if (currentGrecaptcha()?.execute) return;
481+ if (recaptchaScriptPromise) return recaptchaScriptPromise;
482+
483+ recaptchaScriptPromise = new Promise((resolve, reject) => {
484+ const existing = document.querySelector<HTMLScriptElement>('script[data-apply-recaptcha]');
485+ if (existing) {
486+ existing.addEventListener('load', () => resolve(), { once: true });
487+ existing.addEventListener('error', () => reject(new Error('reCAPTCHA failed to load')), {
488+ once: true,
489+ });
490+ return;
491+ }
492+
493+ const script = document.createElement('script');
494+ script.async = true;
495+ script.defer = true;
496+ script.dataset.applyRecaptcha = 'true';
497+ script.src = `${apiUrl}?render=${encodeURIComponent(siteKey)}`;
498+ script.addEventListener('load', () => resolve(), { once: true });
499+ script.addEventListener('error', () => reject(new Error('reCAPTCHA failed to load')), {
500+ once: true,
501+ });
502+ document.head.appendChild(script);
503+ });
504+
505+ return recaptchaScriptPromise;
506+ };
507+
508+ const getRecaptchaToken = async (siteKey: string, action: string): Promise<string | null> => {
509+ const apiUrl = form.dataset.recaptchaApiUrl || '';
510+ if (!siteKey || !action || !apiUrl) return null;
511+
512+ if (!currentGrecaptcha()?.execute) {
513+ await loadRecaptchaScript(apiUrl, siteKey);
514+ }
515+
516+ const grecaptcha = await waitForGrecaptcha();
517+ if (!grecaptcha) return null;
518+
519+ return await new Promise((resolve) => {
520+ grecaptcha.ready(async () => {
521+ try {
522+ resolve(await grecaptcha.execute(siteKey, { action }));
523+ } catch {
524+ resolve(null);
525+ }
526+ });
527+ });
528+ };
529+
530+ const trimmed = (value: unknown): string | undefined => {
531+ if (typeof value !== 'string') return undefined;
532+ const text = value.trim();
533+ return text.length > 0 ? text : undefined;
534+ };
535+
536+ const setIfEmpty = (payload: Payload, key: string, value: unknown) => {
537+ const text = trimmed(value);
538+ if (text && payload[key] === undefined) {
539+ payload[key] = text;
540+ }
541+ };
542+
543+ const readLandingPageUrl = () => {
544+ const key = 'mission-landing-page-url';
545+ try {
546+ const existing = sessionStorage.getItem(key);
547+ if (existing) return existing;
548+ sessionStorage.setItem(key, window.location.href);
549+ } catch {
550+ /* ignore */
551+ }
552+ return window.location.href;
553+ };
554+
555+ const browserLocaleCountry = () => {
556+ const locale = navigator.language || '';
557+ const parts = locale.split('-');
558+ return parts.length > 1 ? parts[parts.length - 1] : undefined;
559+ };
560+
561+ const buildPayload = (): Payload => {
562+ const payload: Payload = {};
563+ for (const [key, value] of new FormData(form).entries()) {
564+ if (typeof value !== 'string') continue;
565+ const text = value.trim();
566+ if (text.length > 0) payload[key] = text;
567+ }
568+
569+ const stack = trimmed(payload.stack);
570+ const notes = trimmed(payload.notes);
571+ const grade = trimmed(payload.grade);
572+ const fundingShape = trimmed(payload.budget);
573+ const timeline = trimmed(payload.timeline);
574+
575+ setIfEmpty(payload, 'companyName', payload.company);
576+ setIfEmpty(payload, 'currentStack', stack);
577+ setIfEmpty(payload, 'projectDescription', stack);
578+ setIfEmpty(payload, 'serviceInterest', form.dataset.serviceInterest);
579+ setIfEmpty(payload, 'managedCodeFormType', form.dataset.formType);
580+ setIfEmpty(payload, 'contactReason', form.dataset.contactReason);
581+ setIfEmpty(payload, 'projectType', form.dataset.projectType);
582+
583+ const message = [
584+ stack ? `Open source stack:\n${stack}` : undefined,
585+ notes ? `Additional context:\n${notes}` : undefined,
586+ ]
587+ .filter(Boolean)
588+ .join('\n\n');
589+ setIfEmpty(payload, 'message', message);
590+
591+ payload.metadata = {
592+ initiative: form.dataset.initiative,
593+ openSource: 'true',
594+ sourceNote: form.dataset.sourceNote,
595+ formType: form.dataset.formType,
596+ stack,
597+ patronageGrade: grade,
598+ fundingShape,
599+ timeline,
600+ notes,
601+ };
602+
603+ const startedAt = Number(form.dataset.formVisibleStartedAt || '0');
604+ if (startedAt > 0) {
605+ payload.formVisibleSeconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
606+ }
607+
608+ const params = new URLSearchParams(window.location.search);
609+ setIfEmpty(payload, 'pageUrl', window.location.href);
610+ setIfEmpty(payload, 'currentPageUrl', window.location.href);
611+ setIfEmpty(payload, 'landingPageUrl', readLandingPageUrl());
612+ setIfEmpty(payload, 'referrer', document.referrer);
613+ setIfEmpty(payload, 'clientSubmittedAt', new Date().toISOString());
614+ setIfEmpty(payload, 'browserLanguage', navigator.language);
615+ setIfEmpty(payload, 'browserLocaleCountry', browserLocaleCountry());
616+ setIfEmpty(payload, 'browserLanguages', navigator.languages?.join(','));
617+ setIfEmpty(payload, 'browserPlatform', navigator.platform);
618+ setIfEmpty(payload, 'timeZone', Intl.DateTimeFormat().resolvedOptions().timeZone);
619+ payload.timeZoneOffsetMinutes = new Date().getTimezoneOffset();
620+ payload.screenWidth = window.screen.width;
621+ payload.screenHeight = window.screen.height;
622+ payload.viewportWidth = window.innerWidth;
623+ payload.viewportHeight = window.innerHeight;
624+ payload.devicePixelRatio = String(window.devicePixelRatio);
625+ payload.colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches
626+ ? 'dark'
627+ : 'light';
628+ payload.cookiesEnabled = navigator.cookieEnabled;
629+ payload.browserOnline = navigator.onLine;
630+ setIfEmpty(
631+ payload,
632+ 'connectionEffectiveType',
633+ (navigator as Navigator & { connection?: { effectiveType?: string } }).connection
634+ ?.effectiveType
635+ );
636+ setIfEmpty(payload, 'utmSource', params.get('utm_source'));
637+ setIfEmpty(payload, 'utmMedium', params.get('utm_medium'));
638+ setIfEmpty(payload, 'utmCampaign', params.get('utm_campaign'));
639+ setIfEmpty(payload, 'utmTerm', params.get('utm_term'));
640+ setIfEmpty(payload, 'utmContent', params.get('utm_content'));
641+
642+ return payload;
643+ };
395644
396645 form.addEventListener('submit', async (event) => {
397646 event.preventDefault();
398647 if (!form.reportValidity()) return;
399648
400- const payload = Object.fromEntries(new FormData(form).entries() );
649+ const payload = buildPayload( );
401650 const restore = submit ? submit.innerHTML : '';
402651 if (submit) {
403652 submit.disabled = true;
@@ -409,6 +658,13 @@ const recaptchaNotice = apply.recaptchaNotice;
409658 }
410659
411660 try {
661+ const recaptchaSiteKey = form.dataset.recaptchaSiteKey || '';
662+ const recaptchaAction = form.dataset.recaptchaAction || '';
663+ const recaptchaToken = await getRecaptchaToken(recaptchaSiteKey, recaptchaAction);
664+ if (!recaptchaToken) throw new Error('reCAPTCHA token not available');
665+ payload.recaptchaToken = recaptchaToken;
666+ payload.recaptchaAction = recaptchaAction;
667+
412668 const res = await fetch(endpoint, {
413669 method: 'POST',
414670 headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
0 commit comments