Skip to content

Commit 0b9405d

Browse files
authored
Merge branch 'main' into dependabot/npm_and_yarn/astro-6.4.6
2 parents 5ed3445 + 6cea45d commit 0b9405d

88 files changed

Lines changed: 375 additions & 12 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/playwright.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,14 @@ jobs:
6060
- name: Commit updated baselines
6161
if: github.event_name != 'pull_request'
6262
run: |
63+
# Mark the checkout as a safe dir before any git command; the
64+
# Playwright container runs as root while the checkout is runner-owned.
65+
git config --global --add safe.directory "$GITHUB_WORKSPACE"
66+
git config user.name "github-actions[bot]"
67+
git config user.email "github-actions[bot]@users.noreply.github.com"
68+
6369
if [ -n "$(git status --porcelain tests)" ]; then
64-
git config user.name "github-actions[bot]"
65-
git config user.email "github-actions[bot]@users.noreply.github.com"
66-
# Mark the checkout as a safe dir (container runs as root).
67-
git config --global --add safe.directory "$GITHUB_WORKSPACE"
68-
git add tests/**/__screenshots__ || true
70+
git add tests/__screenshots__
6971
if ! git diff --cached --quiet; then
7072
git commit -m "chore: update visual baselines [skip ci]"
7173
git push "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" HEAD:${{ github.ref_name }}

src/components/Apply.astro

Lines changed: 263 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const formEndpoint = site.formEndpoint as string;
1111
const recaptchaApiUrl = site.recaptchaApiUrl as string;
1212
const recaptchaSiteKey = site.recaptchaSiteKey as string;
1313
const 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;
1516
const f = apply.fields;
1617
const 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' },

src/data/site.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ export const apply = {
541541
'We’ll read your stack and come back within two business days with a scoped proposal — a capacity band, an SLA, and a named maintainer.',
542542
errorBody:
543543
'That didn’t send. Try again in a moment, or email us directly at opensource@managed-code.com and we’ll pick it up.',
544+
noscriptNote: 'Enable JavaScript to pass reCAPTCHA and send this form.',
544545
recaptchaNotice: {
545546
before: 'This site is protected by reCAPTCHA and the Google ',
546547
privacyLabel: 'Privacy Policy',
159 KB
167 KB
118 KB
112 KB
83.6 KB
171 KB
159 KB

0 commit comments

Comments
 (0)