diff --git a/fern/assets/styles.css b/fern/assets/styles.css index 8f1df1b39..605bd6104 100644 --- a/fern/assets/styles.css +++ b/fern/assets/styles.css @@ -282,4 +282,105 @@ html.dark button[data-highlighted] .fern-api-property-meta { /* Fix: Make subtitle white on Simulations pages in dark mode */ :is(.dark) [id*="simulations"] .prose-p\:text-\(color\:--grayscale-a11\) :where(p):not(:where([class~=not-prose],[class~=not-prose] *)) { color: var(--grayscale-12) !important; +} + +/* Subscribe form on What's New page */ +.subscribe-form-row { + display: flex; + gap: 0.5rem; +} + +.subscribe-form-input { + border: 1px solid #e2e8f0; + border-radius: 0.375rem; + padding: 0.5rem 1rem; + width: 100%; + font-size: 0.875rem; + outline: none; + transition: border-color 0.2s ease-in-out; + color: #1f2937; + background-color: #fff; +} + +.subscribe-form-input:focus { + border-color: #4f46e5; + box-shadow: 0 0 0 1px #4f46e5; +} + +.subscribe-form-button { + background-color: #37aa9d; + color: white; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + border: none; + cursor: pointer; + transition: all 0.2s ease-in-out; + white-space: nowrap; +} + +.subscribe-form-button:hover { + background-color: #2e8b7d; + transform: translateY(-1px); +} + +.subscribe-form-button:active { + transform: translateY(0); +} + +.subscribe-form-button:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +.subscribe-form-message { + margin-top: 0.5rem; + font-size: 0.875rem; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; +} + +.subscribe-form-message.success { + color: #065f46; + background-color: #d1fae5; +} + +.subscribe-form-message.error { + color: #991b1b; + background-color: #fee2e2; +} + +:is(.dark) .subscribe-form-input { + background-color: #374151; + border-color: #4b5563; + color: #f3f4f6; +} + +:is(.dark) .subscribe-form-input::placeholder { + color: #9ca3af; +} + +:is(.dark) .subscribe-form-input:focus { + border-color: #6366f1; + box-shadow: 0 0 0 1px #6366f1; +} + +:is(.dark) .subscribe-form-button { + background-color: #94ffd2; + color: #1f2937; +} + +:is(.dark) .subscribe-form-button:hover { + background-color: #7cd9b0; +} + +:is(.dark) .subscribe-form-message.success { + color: #a7f3d0; + background-color: #064e3b; +} + +:is(.dark) .subscribe-form-message.error { + color: #fca5a5; + background-color: #7f1d1d; } \ No newline at end of file diff --git a/fern/changelog/overview.mdx b/fern/changelog/overview.mdx index 9821a09b6..168d27496 100644 --- a/fern/changelog/overview.mdx +++ b/fern/changelog/overview.mdx @@ -2,7 +2,7 @@ slug: whats-new --- document.querySelector('input[type="email"]').focus()}>Subscribe to the latest product updates} + title="Subscribe to the latest product updates" icon="envelope" iconType="solid" > @@ -11,80 +11,24 @@ slug: whats-new action="https://customerioforms.com/forms/submit_action?site_id=5f95a74ff6539f0bc48f&form_id=01jk7tf2khhf5satn62531qe25&success_url=https://docs.vapi.ai/whats-new" className="subscribe-form" style={{margin: '1rem 0'}} - onSubmit={(e) => { - const emailInput = document.getElementById('email_input'); - const emailValue = emailInput.value; - const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailPattern.test(emailValue)) { - e.preventDefault(); - alert('Please enter a valid email address.'); - } - }} > -
+
+
\ No newline at end of file diff --git a/fern/custom.js b/fern/custom.js index ec8857239..6434797a4 100644 --- a/fern/custom.js +++ b/fern/custom.js @@ -109,18 +109,116 @@ function initializeHubSpot() { document.getElementsByTagName('head')[0].appendChild(hubSpotScript); } +function initializeSubscribeForm() { + // Fern's MDX renderer strips JSX event handlers (onSubmit, onClick), so the + // form's validation and submission logic must be attached from plain JS. + // Without this, the form falls through to a native HTML POST that silently + // redirects back to the same page with no user feedback. + + var form = document.querySelector('form.subscribe-form'); + if (!form) { + return; + } + + // Avoid attaching the handler twice on SPA navigations + if (form.dataset.enhanced === 'true') { + return; + } + form.dataset.enhanced = 'true'; + + form.addEventListener('submit', function (e) { + e.preventDefault(); + + var emailInput = form.querySelector('input[name="email"]'); + var submitBtn = form.querySelector('button[type="submit"]'); + var messageDiv = form.querySelector('.subscribe-form-message'); + + if (!emailInput || !submitBtn) { + return; + } + + var email = emailInput.value.trim(); + var emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!emailPattern.test(email)) { + if (messageDiv) { + messageDiv.textContent = 'Please enter a valid email address.'; + messageDiv.className = 'subscribe-form-message error'; + messageDiv.style.display = 'block'; + } + return; + } + + // Hide any previous message and disable the button while submitting + if (messageDiv) { + messageDiv.style.display = 'none'; + } + submitBtn.disabled = true; + var originalText = submitBtn.textContent; + submitBtn.textContent = 'Submitting...'; + + var formAction = form.getAttribute('action'); + var formData = new FormData(); + formData.append('email', email); + + fetch(formAction, { + method: 'POST', + body: formData, + redirect: 'manual', + }) + .then(function (response) { + // Customer.io returns 302 on success which becomes an opaque redirect + // with redirect:'manual'. Both 302 and opaque (type 0) indicate success. + if (response.ok || response.status === 302 || response.status === 0 || response.type === 'opaqueredirect') { + if (messageDiv) { + messageDiv.textContent = 'Thanks for subscribing! You will receive product updates at ' + email + '.'; + messageDiv.className = 'subscribe-form-message success'; + messageDiv.style.display = 'block'; + } + emailInput.value = ''; + } else { + throw new Error('Unexpected response: ' + response.status); + } + }) + .catch(function () { + if (messageDiv) { + messageDiv.textContent = 'Something went wrong. Please try again.'; + messageDiv.className = 'subscribe-form-message error'; + messageDiv.style.display = 'block'; + } + }) + .finally(function () { + submitBtn.disabled = false; + submitBtn.textContent = originalText; + }); + }); +} + function initializeAll() { initializeHockeyStack(); initializeReo(); initializeHubSpot(); configurePostHog(); + initializeSubscribeForm(); if (ENABLE_VOICE_WIDGET) { injectVapiWidget(); } } +// Fern uses client-side routing, so the form may appear after the initial page +// load. Re-attach the handler whenever the DOM changes on the whats-new page. +var subscribeFormObserver = new MutationObserver(function () { + if (window.location.pathname.indexOf('whats-new') !== -1) { + initializeSubscribeForm(); + } +}); + if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeAll); + document.addEventListener('DOMContentLoaded', function () { + initializeAll(); + subscribeFormObserver.observe(document.body, { childList: true, subtree: true }); + }); } else { initializeAll(); -} \ No newline at end of file + subscribeFormObserver.observe(document.body, { childList: true, subtree: true }); +} \ No newline at end of file diff --git a/fern/custom.spec.js b/fern/custom.spec.js new file mode 100644 index 000000000..8b4860837 --- /dev/null +++ b/fern/custom.spec.js @@ -0,0 +1,155 @@ +/** + * Standalone tests for the subscribe form logic in custom.js. + * + * These tests validate the initializeSubscribeForm() function by extracting + * its logic and running it against a mock DOM. No external dependencies + * required -- run with: node fern/custom.spec.js + * + * The function under test is extracted here rather than imported because + * custom.js is a browser script that reads window.location at parse time. + */ + +'use strict'; + +let passed = 0; +let failed = 0; + +function assert(condition, message) { + if (condition) { + passed++; + console.log(' PASS: ' + message); + } else { + failed++; + console.error(' FAIL: ' + message); + } +} + +function assertEqual(actual, expected, message) { + if (actual === expected) { + passed++; + console.log(' PASS: ' + message); + } else { + failed++; + console.error(' FAIL: ' + message + ' (expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual) + ')'); + } +} + +// --------------------------------------------------------------------------- +// Extracted logic from initializeSubscribeForm (the core of the fix) +// --------------------------------------------------------------------------- + +function emailValidate(email) { + var emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailPattern.test(email); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +console.log('\n--- Email validation ---'); + +assert(emailValidate('user@example.com'), 'accepts standard email'); +assert(emailValidate('user+tag@domain.co.uk'), 'accepts email with plus and subdomain'); +assert(emailValidate('a@b.c'), 'accepts minimal valid email'); +assert(!emailValidate(''), 'rejects empty string'); +assert(!emailValidate('not-an-email'), 'rejects string without @'); +assert(!emailValidate('user@'), 'rejects email missing domain'); +assert(!emailValidate('@domain.com'), 'rejects email missing local part'); +assert(!emailValidate('user @domain.com'), 'rejects email with space'); +assert(!emailValidate('user@domain'), 'rejects email without TLD dot'); + +console.log('\n--- MDX structure validation ---'); + +var fs = require('fs'); +var path = require('path'); + +var mdxPath = path.join(__dirname, 'changelog', 'overview.mdx'); +var mdxContent = fs.readFileSync(mdxPath, 'utf-8'); + +assert(mdxContent.indexOf('class="subscribe-form"') !== -1 || mdxContent.indexOf('className="subscribe-form"') !== -1, + 'MDX contains form with subscribe-form class'); +assert(mdxContent.indexOf('customerioforms.com') !== -1, + 'MDX contains Customer.io form action URL'); +assert(mdxContent.indexOf('name="email"') !== -1, + 'MDX contains email input with correct name attribute'); +assert(mdxContent.indexOf('type="submit"') !== -1, + 'MDX contains submit button'); +assert(mdxContent.indexOf('subscribe-form-message') !== -1, + 'MDX contains message div for feedback'); +assert(mdxContent.indexOf('subscribe-form-input') !== -1, + 'MDX uses CSS class for input styling'); +assert(mdxContent.indexOf('subscribe-form-button') !== -1, + 'MDX uses CSS class for button styling'); + +// Verify the broken onSubmit handler is removed +assert(mdxContent.indexOf('onSubmit') === -1, + 'MDX does not contain onSubmit handler (Fern strips JSX event handlers)'); +assert(mdxContent.indexOf('onClick') === -1, + 'MDX does not contain onClick handler (Fern strips JSX event handlers)'); + +console.log('\n--- custom.js structure validation ---'); + +var customJsPath = path.join(__dirname, 'custom.js'); +var customJsContent = fs.readFileSync(customJsPath, 'utf-8'); + +assert(customJsContent.indexOf('initializeSubscribeForm') !== -1, + 'custom.js contains initializeSubscribeForm function'); +assert(customJsContent.indexOf('addEventListener') !== -1 && customJsContent.indexOf("'submit'") !== -1, + 'custom.js attaches submit event listener'); +assert(customJsContent.indexOf('e.preventDefault()') !== -1, + 'custom.js prevents default form submission'); +assert(customJsContent.indexOf("redirect: 'manual'") !== -1, + 'custom.js uses fetch with redirect:manual to handle 302'); +assert(customJsContent.indexOf('opaqueredirect') !== -1, + 'custom.js checks for opaqueredirect response type'); +assert(customJsContent.indexOf('subscribe-form-message') !== -1, + 'custom.js updates the message div'); +assert(customJsContent.indexOf('Thanks for subscribing') !== -1, + 'custom.js shows success message'); +assert(customJsContent.indexOf('Something went wrong') !== -1, + 'custom.js shows error message on failure'); +assert(customJsContent.indexOf("dataset.enhanced === 'true'") !== -1, + 'custom.js guards against duplicate handler attachment'); +assert(customJsContent.indexOf('MutationObserver') !== -1, + 'custom.js uses MutationObserver for SPA route changes'); +assert(customJsContent.indexOf('Submitting...') !== -1, + 'custom.js shows loading state on button'); + +console.log('\n--- CSS validation ---'); + +var cssPath = path.join(__dirname, 'assets', 'styles.css'); +var cssContent = fs.readFileSync(cssPath, 'utf-8'); + +assert(cssContent.indexOf('.subscribe-form-input') !== -1, + 'CSS contains subscribe-form-input styles'); +assert(cssContent.indexOf('.subscribe-form-button') !== -1, + 'CSS contains subscribe-form-button styles'); +assert(cssContent.indexOf('.subscribe-form-message.success') !== -1, + 'CSS contains success message styles'); +assert(cssContent.indexOf('.subscribe-form-message.error') !== -1, + 'CSS contains error message styles'); +assert(cssContent.indexOf('.subscribe-form-input:focus') !== -1, + 'CSS contains focus styles for input'); +assert(cssContent.indexOf('.subscribe-form-button:hover') !== -1, + 'CSS contains hover styles for button'); +assert(cssContent.indexOf('.subscribe-form-button:disabled') !== -1, + 'CSS contains disabled styles for button'); +assert(cssContent.indexOf(':is(.dark) .subscribe-form-input') !== -1, + 'CSS contains dark mode styles for input'); +assert(cssContent.indexOf(':is(.dark) .subscribe-form-button') !== -1, + 'CSS contains dark mode styles for button'); +assert(cssContent.indexOf('.subscribe-form-row') !== -1, + 'CSS contains flex row layout for form'); + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +console.log('\n--- Results ---'); +console.log('Passed: ' + passed); +console.log('Failed: ' + failed); + +if (failed > 0) { + process.exit(1); +}