diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js
index 6c08639c..42e1f4eb 100644
--- a/website/docusaurus.config.js
+++ b/website/docusaurus.config.js
@@ -279,6 +279,14 @@ const config = {
loading: process.env.GISCUS_LOADING || 'lazy'
},
}),
+ // Newsletter email collection configuration (Kit API v4)
+ // See here for more information: https://developers.kit.com/api-reference/overview
+ ...(process.env.NEWSLETTER_API_KEY && {
+ newsletter: {
+ isEnabled: true,
+ apiUrl: process.env.NEWSLETTER_API_URL || 'https://api.kit.com/v4',
+ },
+ }),
}),
plugins: [
process.env.POSTHOG_API_KEY && [
diff --git a/website/netlify/functions/newsletter-subscribe.js b/website/netlify/functions/newsletter-subscribe.js
new file mode 100644
index 00000000..2ed6094c
--- /dev/null
+++ b/website/netlify/functions/newsletter-subscribe.js
@@ -0,0 +1,219 @@
+/**
+ * Netlify Serverless Function to handle newsletter form submissions
+ *
+ * This function securely handles Kit API integration by keeping
+ * the API key on the server side. It validates input, sanitizes data,
+ * and handles errors gracefully following Netlify best practices.
+ *
+ * @see https://docs.netlify.com/functions/overview/
+ * @see https://developers.kit.com/api-reference/subscribers/create-a-subscriber
+ */
+
+/**
+ * Get the allowed CORS origin based on environment
+ * @returns {string} The allowed origin (site URL in production, '*' in development)
+ */
+function getCorsOrigin() {
+ return process.env.NODE_ENV === 'production'
+ ? process.env.URL || '*'
+ : '*';
+}
+
+/**
+ * Build headers with CORS for JSON responses
+ * @param {Object} additionalHeaders - Additional headers to include
+ * @returns {Object} Headers object with CORS and content type
+ */
+function buildCorsHeaders(additionalHeaders = {}) {
+ return {
+ 'Content-Type': 'application/json',
+ 'Access-Control-Allow-Origin': getCorsOrigin(),
+ ...additionalHeaders,
+ };
+}
+
+/**
+ * Build full CORS headers for preflight responses (no Content-Type)
+ * @returns {Object} Complete CORS headers object
+ */
+function buildFullCorsHeaders() {
+ return {
+ 'Access-Control-Allow-Origin': getCorsOrigin(),
+ 'Access-Control-Allow-Methods': 'POST',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ };
+}
+
+/**
+ * Build headers for JSON responses with full CORS support
+ * @returns {Object} Headers object with Content-Type and full CORS headers
+ */
+function buildJsonCorsHeaders() {
+ return {
+ 'Content-Type': 'application/json',
+ ...buildFullCorsHeaders(),
+ };
+}
+
+exports.handler = async (event, context) => {
+ // Only allow POST requests
+ if (event.httpMethod !== 'POST') {
+ return {
+ statusCode: 405,
+ headers: buildJsonCorsHeaders(),
+ body: JSON.stringify({ error: 'Method not allowed' }),
+ };
+ }
+
+ // Handle CORS preflight
+ if (event.httpMethod === 'OPTIONS') {
+ return {
+ statusCode: 200,
+ headers: buildFullCorsHeaders(),
+ body: '',
+ };
+ }
+
+ try {
+ // Parse request body
+ let body;
+ try {
+ body = JSON.parse(event.body);
+ } catch (parseError) {
+ return {
+ statusCode: 400,
+ headers: buildCorsHeaders(),
+ body: JSON.stringify({ error: 'Invalid JSON in request body' }),
+ };
+ }
+
+ const { email, firstName, lastName, ...customFields } = body;
+
+ // Validate email (RFC 5322 compliant regex)
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!email || !emailRegex.test(email)) {
+ return {
+ statusCode: 400,
+ headers: buildCorsHeaders(),
+ body: JSON.stringify({ error: 'Valid email is required' }),
+ };
+ }
+
+ // Get configuration from environment variables
+ const newsletterApiKey = process.env.NEWSLETTER_API_KEY;
+ const apiUrl = process.env.NEWSLETTER_API_URL || 'https://api.kit.com/v4';
+
+ if (!newsletterApiKey) {
+ console.error('Newsletter API configuration missing:', {
+ hasApiKey: !!newsletterApiKey,
+ });
+ return {
+ statusCode: 500,
+ headers: buildCorsHeaders(),
+ body: JSON.stringify({ error: 'Server configuration error' }),
+ };
+ }
+
+ // Prepare subscriber data for Kit API v4
+ // Kit API expects: email_address, first_name, state, fields
+ const subscriberData = {
+ email_address: email.trim().toLowerCase(),
+ ...(firstName && { first_name: firstName.trim() }),
+ state: 'active', // Default to active state
+ ...(Object.keys(customFields).length > 0 && {
+ fields: Object.entries(customFields).reduce((acc, [key, value]) => {
+ // Only include non-empty custom fields
+ if (value !== null && value !== undefined && value !== '') {
+ acc[key] = String(value);
+ }
+ return acc;
+ }, {}),
+ }),
+ };
+
+ // Call Kit API with timeout
+ // Kit API requires X-Kit-Api-Key header
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
+
+ try {
+ const kitUrl = `${apiUrl}/subscribers`;
+ const kitResponse = await fetch(kitUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Kit-Api-Key': newsletterApiKey,
+ },
+ body: JSON.stringify(subscriberData),
+ signal: controller.signal,
+ });
+
+ clearTimeout(timeoutId);
+
+ const kitData = await kitResponse.json();
+
+ if (!kitResponse.ok) {
+ console.error('Kit API error:', {
+ status: kitResponse.status,
+ statusText: kitResponse.statusText,
+ data: kitData,
+ });
+
+ // Don't expose internal API errors to client
+ const errorMessage = kitData.message || 'Failed to subscribe. Please try again.';
+
+ return {
+ statusCode: kitResponse.status >= 400 && kitResponse.status < 500
+ ? kitResponse.status
+ : 500,
+ headers: buildCorsHeaders(),
+ body: JSON.stringify({
+ error: errorMessage,
+ }),
+ };
+ }
+
+ // Success response
+ return {
+ statusCode: 200,
+ headers: buildJsonCorsHeaders(),
+ body: JSON.stringify({
+ success: true,
+ message: 'Successfully subscribed!',
+ data: kitData,
+ }),
+ };
+
+ } catch (fetchError) {
+ clearTimeout(timeoutId);
+
+ if (fetchError.name === 'AbortError') {
+ console.error('Request timeout to Kit API');
+ return {
+ statusCode: 504,
+ headers: buildCorsHeaders(),
+ body: JSON.stringify({
+ error: 'Request timeout. Please try again.',
+ }),
+ };
+ }
+
+ throw fetchError; // Re-throw to be caught by outer catch
+ }
+
+ } catch (error) {
+ console.error('Function error:', {
+ message: error.message,
+ stack: error.stack,
+ });
+
+ return {
+ statusCode: 500,
+ headers: buildCorsHeaders(),
+ body: JSON.stringify({
+ error: 'Internal server error',
+ message: process.env.NODE_ENV === 'development' ? error.message : undefined,
+ }),
+ };
+ }
+};
diff --git a/website/package-lock.json b/website/package-lock.json
index b9486d8c..f96dc3b1 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -20,6 +20,7 @@
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-hot-toast": "^2.4.1",
"three": "^0.181.2"
},
"devDependencies": {
@@ -7659,7 +7660,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/cytoscape": {
"version": "3.33.1",
@@ -9680,6 +9682,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/goober": {
+ "version": "2.1.18",
+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
+ "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "csstype": "^3.0.10"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -16297,6 +16308,23 @@
"react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/react-hot-toast": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
+ "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.1.3",
+ "goober": "^2.1.16"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/website/package.json b/website/package.json
index 44cd03df..3a38812b 100644
--- a/website/package.json
+++ b/website/package.json
@@ -26,6 +26,7 @@
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-hot-toast": "^2.4.1",
"three": "^0.181.2"
},
"devDependencies": {
diff --git a/website/src/components/newsletter/FooterNewsletterSignup/index.js b/website/src/components/newsletter/FooterNewsletterSignup/index.js
new file mode 100644
index 00000000..41d95ce2
--- /dev/null
+++ b/website/src/components/newsletter/FooterNewsletterSignup/index.js
@@ -0,0 +1,99 @@
+import React, { useState } from 'react';
+import { useColorMode } from '@docusaurus/theme-common';
+import { useNewsletterSubscribe } from '@site/src/hooks/useNewsletterSubscribe';
+import clsx from 'clsx';
+import styles from './styles.module.css';
+
+/**
+ * Footer Newsletter Signup Component
+ *
+ * A compact newsletter signup form designed specifically for footer placement.
+ *
+ * @param {Object} props - Component props
+ * @param {string} props.title - Optional title/label for the newsletter section
+ * @param {string} props.emailPlaceholder - Email input placeholder text
+ * @param {string} props.buttonText - Submit button text
+ * @param {string} props.className - Additional CSS classes
+ */
+export default function FooterNewsletterSignup({
+ title = 'Newsletter',
+ description = 'Get notified about releases, feature announcements, and technical deep-dives on building smart contracts with Compose.',
+ emailPlaceholder = 'Enter your email',
+ buttonText = 'Subscribe',
+ className = '',
+}) {
+ const { colorMode } = useColorMode();
+ const { subscribe, isSubmitting, isConfigured } = useNewsletterSubscribe();
+
+ const [email, setEmail] = useState('');
+
+ if (!isConfigured) {
+ return null;
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ try {
+ await subscribe({ email });
+ setEmail('');
+ } catch (error) {
+ // Error is already handled by the hook
+ }
+ };
+
+
+ return (
+
+ {title &&
{title}
}
+ {description &&
{description}
}
+
+
+
+ );
+}
diff --git a/website/src/components/newsletter/FooterNewsletterSignup/styles.module.css b/website/src/components/newsletter/FooterNewsletterSignup/styles.module.css
new file mode 100644
index 00000000..87445760
--- /dev/null
+++ b/website/src/components/newsletter/FooterNewsletterSignup/styles.module.css
@@ -0,0 +1,287 @@
+/**
+ * Footer Newsletter Signup Component Styles
+ * Compact, inline newsletter form designed for footer placement
+ */
+
+.footerNewsletter {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0;
+ margin: 0;
+}
+
+.footerNewsletterTitle {
+ margin: 0 0 0.25rem 0;
+ padding: 0;
+ color: var(--ifm-color-primary-lighter);
+ font-weight: 700;
+}
+
+[data-theme='dark'] .footerNewsletterTitle {
+ color: #ffffff;
+}
+
+.footerNewsletterDescription {
+ margin: 0 0 1rem 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.8);
+ padding: 0;
+}
+
+[data-theme='dark'] .footerNewsletterDescription {
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.footerNewsletterForm {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.footerNewsletterInputGroup {
+ display: flex;
+ gap: 0.5rem;
+ align-items: stretch;
+ flex-wrap: wrap;
+ padding: 0;
+ margin: 0;
+}
+
+/* Desktop: side-by-side layout */
+@media (min-width: 577px) {
+ .footerNewsletterInputGroup {
+ flex-wrap: nowrap;
+ }
+
+ .footerNewsletterInput {
+ min-width: 200px;
+ flex: 1 1 auto;
+ }
+
+ .footerNewsletterButton {
+ flex: 0 0 auto;
+ }
+}
+
+/* Larger desktop: maximize input field width */
+@media (min-width: 997px) {
+ .footerNewsletterInput {
+ min-width: 320px;
+ font-size: 1rem;
+ padding: 0.625rem 1rem;
+ }
+
+ .footerNewsletterButton {
+ min-width: 120px;
+ padding: 0.625rem 1.5rem;
+ font-size: 0.9375rem;
+ }
+}
+
+/* Mobile: stacked layout */
+@media (max-width: 576px) {
+ .footerNewsletterInputGroup {
+ flex-direction: column;
+ }
+
+ .footerNewsletterInput,
+ .footerNewsletterButton {
+ width: 100%;
+ min-width: 0;
+ }
+}
+
+.footerNewsletterInput {
+ flex: 1;
+ min-width: 0;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ font-size: 0.875rem;
+ font-family: var(--ifm-font-family-base);
+ background: rgba(255, 255, 255, 0.1);
+ color: #ffffff;
+ transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
+ box-sizing: border-box;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+/* Ensure input is visible on footer background */
+.footerNewsletterInput::placeholder {
+ color: rgba(255, 255, 255, 0.6);
+ opacity: 1;
+}
+
+.footerNewsletterInput::placeholder {
+ color: rgba(255, 255, 255, 0.6);
+ opacity: 1;
+}
+
+.footerNewsletterInput:focus {
+ outline: none;
+ border-color: var(--ifm-color-primary-lighter);
+ background: rgba(255, 255, 255, 0.15);
+ box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
+}
+
+[data-theme='dark'] .footerNewsletterInput {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: #ffffff;
+}
+
+[data-theme='dark'] .footerNewsletterInput::placeholder {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+[data-theme='dark'] .footerNewsletterInput:focus {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: var(--ifm-color-primary-lighter);
+}
+
+.footerNewsletterInput:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.footerNewsletterButton {
+ padding: 0.5rem 1rem;
+ white-space: nowrap;
+ min-width: 100px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ cursor: pointer;
+ transition: opacity 0.2s, transform 0.2s, background 0.2s, border-color 0.2s;
+ background: rgba(96, 165, 250, 0.2);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.footerNewsletterButton:hover:not(:disabled) {
+ background: rgba(96, 165, 250, 0.3);
+ border-color: rgba(96, 165, 250, 0.5);
+}
+
+.footerNewsletterButton:hover:not(:disabled) {
+ opacity: 0.9;
+ transform: translateY(-1px);
+}
+
+.footerNewsletterButton:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+.footerNewsletterButton:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.footerNewsletterButtonSpinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ============================================
+ TRUST SIGNAL
+ ============================================ */
+
+.footerNewsletterTrustSignal {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: rgba(255, 255, 255, 0.7);
+ margin: 0.25rem 0 0 0;
+ padding: 0;
+ opacity: 0.9;
+}
+
+[data-theme='dark'] .footerNewsletterTrustSignal {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.footerNewsletterTrustIcon {
+ width: 14px;
+ height: 14px;
+ opacity: 0.8;
+ flex-shrink: 0;
+}
+
+.footerNewsletterMessage {
+ font-size: 0.8125rem;
+ line-height: 1.4;
+ padding: 0.5rem 0.75rem;
+ border-radius: 4px;
+ margin-top: 0.25rem;
+ animation: slideIn 0.2s ease-out;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.footerNewsletterMessage--success {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+ border: 1px solid rgba(16, 185, 129, 0.2);
+}
+
+[data-theme='dark'] .footerNewsletterMessage--success {
+ background: rgba(16, 185, 129, 0.15);
+ border-color: rgba(52, 211, 153, 0.3);
+ color: #34d399;
+}
+
+.footerNewsletterMessage--error {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+ border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+[data-theme='dark'] .footerNewsletterMessage--error {
+ background: rgba(239, 68, 68, 0.15);
+ border-color: rgba(248, 113, 113, 0.3);
+ color: #f87171;
+}
+
+/* Reduced motion support */
+@media (prefers-reduced-motion: reduce) {
+ .footerNewsletterInput,
+ .footerNewsletterButton,
+ .footerNewsletterMessage,
+ .footerNewsletterButtonSpinner {
+ transition: none;
+ animation: none;
+ }
+
+ .footerNewsletterButton:hover:not(:disabled) {
+ transform: none;
+ }
+}
diff --git a/website/src/components/newsletter/NewsletterSignup/index.js b/website/src/components/newsletter/NewsletterSignup/index.js
new file mode 100644
index 00000000..3fb7291f
--- /dev/null
+++ b/website/src/components/newsletter/NewsletterSignup/index.js
@@ -0,0 +1,180 @@
+import React, { useState } from 'react';
+import { useColorMode } from '@docusaurus/theme-common';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+import { useNewsletterSubscribe } from '@site/src/hooks/useNewsletterSubscribe';
+import clsx from 'clsx';
+import styles from './styles.module.css';
+
+/**
+ * Newsletter Signup Component
+ *
+ * @param {Object} props - Component props
+ * @param {boolean} props.showNameFields - Whether to show first/last name fields
+ * @param {string} props.title - Form title
+ * @param {string} props.description - Form description
+ * @param {string} props.buttonText - Submit button text
+ * @param {string} props.emailPlaceholder - Email input placeholder
+ * @param {string} props.firstNamePlaceholder - First name input placeholder
+ * @param {string} props.lastNamePlaceholder - Last name input placeholder
+ * @param {string} props.className - Additional CSS classes
+ * @param {Function} props.onSuccess - Callback fired on successful subscription
+ * @param {Function} props.onError - Callback fired on subscription error
+ */
+export default function NewsletterSignup({
+ showNameFields = false,
+ title = 'Stay Updated',
+ description = 'Get notified about new features and updates.',
+ buttonText = 'Subscribe',
+ emailPlaceholder = 'Enter your email',
+ firstNamePlaceholder = 'First Name',
+ lastNamePlaceholder = 'Last Name',
+ className = '',
+ onSuccess,
+ onError,
+}) {
+ const { colorMode } = useColorMode();
+
+ const { subscribe, isSubmitting, isConfigured } = useNewsletterSubscribe();
+
+ const [email, setEmail] = useState('');
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+
+ if (!isConfigured) {
+ return null;
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ try {
+ await subscribe({
+ email,
+ ...(showNameFields && firstName && { firstName }),
+ ...(showNameFields && lastName && { lastName }),
+ });
+
+ setEmail('');
+ setFirstName('');
+ setLastName('');
+
+ if (onSuccess) {
+ onSuccess({ email, firstName, lastName });
+ }
+ } catch (error) {
+ if (onError) {
+ onError(error);
+ }
+ }
+ };
+
+
+ return (
+
+
+ {/* Header Section with Icon */}
+
+
+
+

+
+ {title &&
{title}
}
+
+ {description && (
+
{description}
+ )}
+
+
+ {/* Form Section */}
+
+
+
+ );
+}
diff --git a/website/src/components/newsletter/NewsletterSignup/styles.module.css b/website/src/components/newsletter/NewsletterSignup/styles.module.css
new file mode 100644
index 00000000..9914f653
--- /dev/null
+++ b/website/src/components/newsletter/NewsletterSignup/styles.module.css
@@ -0,0 +1,520 @@
+/**
+ * Premium Newsletter Signup Component Styling
+ * World-class design with glass morphism, gradients, and smooth animations
+ * Matches Compose's blue-focused theme in both light and dark modes
+ */
+
+/* ============================================
+ CONTAINER - Glass Morphism Effect
+ ============================================ */
+
+.newsletterContainer {
+ margin: 4rem auto;
+ padding: 3rem;
+ border-radius: 1.25rem;
+ position: relative;
+ overflow: hidden;
+
+ /* Glass morphism effect */
+ background: rgba(255, 255, 255, 0.7);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid rgba(59, 130, 246, 0.2);
+
+ /* Premium shadow */
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.08),
+ 0 2px 8px rgba(59, 130, 246, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.9);
+
+ /* Subtle gradient overlay */
+ background-image:
+ linear-gradient(135deg, rgba(59, 130, 246, 0.03) 0%, rgba(37, 99, 235, 0.05) 100%),
+ linear-gradient(to bottom, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.9));
+
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.newsletterContainer:hover {
+ transform: translateY(-2px);
+ box-shadow:
+ 0 12px 48px rgba(0, 0, 0, 0.12),
+ 0 4px 16px rgba(59, 130, 246, 0.15),
+ inset 0 1px 0 rgba(255, 255, 255, 0.95);
+ border-color: rgba(59, 130, 246, 0.3);
+}
+
+/* Dark mode glass morphism */
+[data-theme='dark'] .newsletterContainer {
+ background: rgba(30, 41, 59, 0.6);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid rgba(96, 165, 250, 0.2);
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.3),
+ 0 2px 8px rgba(96, 165, 250, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
+ background-image:
+ linear-gradient(135deg, rgba(96, 165, 250, 0.05) 0%, rgba(59, 130, 246, 0.08) 100%),
+ linear-gradient(to bottom, rgba(30, 41, 59, 0.6), rgba(15, 23, 42, 0.8));
+}
+
+[data-theme='dark'] .newsletterContainer:hover {
+ box-shadow:
+ 0 12px 48px rgba(0, 0, 0, 0.4),
+ 0 4px 16px rgba(96, 165, 250, 0.2),
+ inset 0 1px 0 rgba(255, 255, 255, 0.08);
+ border-color: rgba(96, 165, 250, 0.3);
+}
+
+/* ============================================
+ CONTENT LAYOUT
+ ============================================ */
+
+.newsletterContent {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ position: relative;
+ z-index: 1;
+}
+
+/* ============================================
+ HEADER SECTION
+ ============================================ */
+
+.newsletterHeader {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.newsletterTitleRow {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.newsletterIconWrapper {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border-radius: 0.75rem;
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.15) 100%);
+ flex-shrink: 0;
+ transition: transform 0.3s ease;
+}
+
+.newsletterContainer:hover .newsletterIconWrapper {
+ transform: scale(1.05) rotate(5deg);
+}
+
+[data-theme='dark'] .newsletterIconWrapper {
+ background: linear-gradient(135deg, rgba(96, 165, 250, 0.15) 0%, rgba(59, 130, 246, 0.2) 100%);
+}
+
+.newsletterIcon {
+ width: 24px;
+ height: 24px;
+ display: block;
+}
+
+.newsletterTitle {
+ margin: 0;
+ font-size: 2rem;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.2;
+
+ /* Gradient text effect */
+ background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+[data-theme='dark'] .newsletterTitle {
+ background: linear-gradient(135deg, #93c5fd 0%, #60a5fa 50%, #3b82f6 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.newsletterDescription {
+ margin: 0;
+ color: var(--ifm-color-content-secondary);
+ font-size: 1.0625rem;
+ line-height: 1.7;
+ font-weight: 400;
+}
+
+[data-theme='dark'] .newsletterDescription {
+ color: rgba(255, 255, 255, 0.75);
+}
+
+/* ============================================
+ FORM SECTION
+ ============================================ */
+
+.newsletterForm {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.newsletterRow {
+ display: flex;
+ gap: 0.875rem;
+ align-items: stretch;
+}
+
+/* Desktop: side-by-side layout */
+@media (min-width: 769px) {
+ .newsletterRow {
+ flex-direction: row;
+ }
+}
+
+/* Mobile: stacked layout */
+@media (max-width: 768px) {
+ .newsletterRow {
+ flex-direction: column;
+ }
+}
+
+/* ============================================
+ INPUT FIELD - Premium Styling
+ ============================================ */
+
+.newsletterInputWrapper {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+}
+
+.newsletterInput {
+ width: 100%;
+ padding: 1rem 1.25rem;
+ border: 2px solid var(--ifm-color-emphasis-300);
+ border-radius: 0.75rem;
+ font-size: 1rem;
+ font-family: var(--ifm-font-family-base);
+ font-weight: 500;
+ background: rgba(255, 255, 255, 0.9);
+ color: var(--ifm-font-color-base);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
+}
+
+[data-theme='dark'] .newsletterInput {
+ background: rgba(15, 23, 42, 0.6);
+ border-color: rgba(96, 165, 250, 0.2);
+ color: #ffffff;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.newsletterInput:focus {
+ outline: none;
+ border-color: var(--ifm-color-primary);
+ background: rgba(255, 255, 255, 1);
+ box-shadow:
+ 0 0 0 4px rgba(59, 130, 246, 0.15),
+ 0 4px 12px rgba(59, 130, 246, 0.2);
+ transform: translateY(-1px);
+}
+
+[data-theme='dark'] .newsletterInput:focus {
+ background: rgba(15, 23, 42, 0.8);
+ border-color: #60a5fa;
+ box-shadow:
+ 0 0 0 4px rgba(96, 165, 250, 0.2),
+ 0 4px 12px rgba(96, 165, 250, 0.15);
+}
+
+.newsletterInput:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.newsletterInput::placeholder {
+ color: var(--ifm-color-content-secondary);
+ opacity: 0.6;
+ font-weight: 400;
+}
+
+[data-theme='dark'] .newsletterInput::placeholder {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+/* ============================================
+ BUTTON - Gradient with Premium Effects
+ ============================================ */
+
+.newsletterButton {
+ position: relative;
+ padding: 1rem 2rem;
+ white-space: nowrap;
+ min-width: 140px;
+ font-weight: 700;
+ font-size: 1rem;
+ border-radius: 0.75rem;
+ border: none;
+ cursor: pointer;
+ overflow: hidden;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Gradient background */
+ background: linear-gradient(135deg, var(--ifm-color-primary) 0%, var(--ifm-color-primary-darker) 100%);
+ color: white;
+
+ /* Premium shadow */
+ box-shadow:
+ 0 4px 14px rgba(59, 130, 246, 0.4),
+ 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.newsletterButton::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
+ transition: left 0.5s ease;
+}
+
+.newsletterButton:hover::before {
+ left: 100%;
+}
+
+.newsletterButton:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow:
+ 0 8px 24px rgba(59, 130, 246, 0.5),
+ 0 4px 8px rgba(0, 0, 0, 0.15);
+}
+
+.newsletterButton:active:not(:disabled) {
+ transform: translateY(0);
+ box-shadow:
+ 0 4px 14px rgba(59, 130, 246, 0.4),
+ 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.newsletterButton:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+[data-theme='dark'] .newsletterButton {
+ background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
+ box-shadow:
+ 0 4px 14px rgba(96, 165, 250, 0.4),
+ 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+[data-theme='dark'] .newsletterButton:hover:not(:disabled) {
+ box-shadow:
+ 0 8px 24px rgba(96, 165, 250, 0.5),
+ 0 4px 8px rgba(0, 0, 0, 0.25);
+}
+
+.newsletterButtonContent {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.newsletterButtonSpinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ============================================
+ TRUST SIGNAL
+ ============================================ */
+
+.newsletterTrustSignal {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8125rem;
+ color: var(--ifm-color-content-secondary);
+ margin-top: -0.5rem;
+ opacity: 0.8;
+}
+
+[data-theme='dark'] .newsletterTrustSignal {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.newsletterTrustIcon {
+ width: 14px;
+ height: 14px;
+ opacity: 0.7;
+}
+
+/* ============================================
+ MESSAGE STATES - Enhanced with Icons
+ ============================================ */
+
+.newsletterMessage {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ border-radius: 0.75rem;
+ font-size: 0.9375rem;
+ margin-top: 0.5rem;
+ line-height: 1.5;
+ font-weight: 500;
+ animation: slideIn 0.3s ease-out;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.newsletterMessageIcon {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+}
+
+.newsletterMessage--success {
+ background: rgba(16, 185, 129, 0.1);
+ color: #10b981;
+ border: 1px solid rgba(16, 185, 129, 0.3);
+}
+
+[data-theme='dark'] .newsletterMessage--success {
+ background: rgba(16, 185, 129, 0.15);
+ border-color: rgba(52, 211, 153, 0.3);
+ color: #34d399;
+}
+
+.newsletterMessage--error {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ animation: shake 0.5s ease-out;
+}
+
+@keyframes shake {
+ 0%, 100% { transform: translateX(0); }
+ 10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
+ 20%, 40%, 60%, 80% { transform: translateX(4px); }
+}
+
+[data-theme='dark'] .newsletterMessage--error {
+ background: rgba(239, 68, 68, 0.15);
+ border-color: rgba(248, 113, 113, 0.3);
+ color: #f87171;
+}
+
+/* ============================================
+ RESPONSIVE DESIGN
+ ============================================ */
+
+@media (max-width: 768px) {
+ .newsletterContainer {
+ margin: 2.5rem 1rem;
+ padding: 2rem 1.5rem;
+ border-radius: 1rem;
+ }
+
+ .newsletterTitleRow {
+ gap: 0.75rem;
+ }
+
+ .newsletterIconWrapper {
+ width: 40px;
+ height: 40px;
+ }
+
+ .newsletterIcon {
+ width: 20px;
+ height: 20px;
+ }
+
+ .newsletterTitle {
+ font-size: 1.75rem;
+ }
+
+ .newsletterDescription {
+ font-size: 1rem;
+ }
+
+ .newsletterInput,
+ .newsletterButton {
+ width: 100%;
+ min-width: unset;
+ }
+
+ .newsletterButton {
+ padding: 1rem 1.5rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .newsletterContainer {
+ padding: 1.75rem 1.25rem;
+ }
+
+ .newsletterTitle {
+ font-size: 1.5rem;
+ }
+
+ .newsletterContent {
+ gap: 1.5rem;
+ }
+}
+
+/* ============================================
+ REDUCED MOTION SUPPORT
+ ============================================ */
+
+@media (prefers-reduced-motion: reduce) {
+ .newsletterContainer,
+ .newsletterInput,
+ .newsletterButton,
+ .newsletterMessage {
+ transition: none;
+ animation: none;
+ }
+
+ .newsletterContainer:hover {
+ transform: none;
+ }
+
+ .newsletterButton:hover:not(:disabled) {
+ transform: none;
+ }
+
+ .newsletterInput:focus {
+ transform: none;
+ }
+}
diff --git a/website/src/css/custom.css b/website/src/css/custom.css
index dc36746b..b2a417e4 100644
--- a/website/src/css/custom.css
+++ b/website/src/css/custom.css
@@ -77,6 +77,9 @@
/* Import custom scrollbar styles */
@import url('./scrollbar.css');
+/* Import toast notification styles */
+@import url('./toast.css');
+
/* ============================================
RESPONSIVE STYLES
============================================ */
diff --git a/website/src/css/toast.css b/website/src/css/toast.css
new file mode 100644
index 00000000..5aebd306
--- /dev/null
+++ b/website/src/css/toast.css
@@ -0,0 +1,246 @@
+/**
+ * Toast Notification Styles
+ * Styled to match Compose Documentation theme with light/dark mode support
+ */
+
+/* ============================================
+ TOAST CONTAINER
+ ============================================ */
+
+/* Main toast container */
+div[class*="react-hot-toast"] {
+ font-family: var(--ifm-font-family-base) !important;
+}
+
+/* Force dark mode background - highest specificity */
+[data-theme='dark'] div[class*="react-hot-toast"] > div[style] {
+ background: var(--ifm-background-surface-color) !important;
+ background-color: var(--ifm-background-surface-color) !important;
+ color: var(--ifm-font-color-base) !important;
+}
+
+/* Force light mode background */
+:root:not([data-theme='dark']) div[class*="react-hot-toast"] > div[style] {
+ background: #ffffff !important;
+ background-color: #ffffff !important;
+ color: var(--ifm-font-color-base) !important;
+}
+
+/* Individual toast notification - target all possible react-hot-toast classes */
+/* Using more specific selectors to override inline styles */
+div[class*="react-hot-toast"] > div,
+div[class*="react-hot-toast"] > div[class*="toast"],
+div[class*="react-hot-toast"] > div[class*="notification"],
+div[class*="react-hot-toast"] > div[style] {
+ /* Base styles using theme variables - these will override inline styles */
+ background: var(--ifm-background-surface-color) !important;
+ color: var(--ifm-font-color-base) !important;
+ border: 1px solid var(--ifm-color-emphasis-200) !important;
+ /* border-left will be set via inline styles from Root.js for type-specific colors */
+ border-left: 3px solid var(--ifm-color-emphasis-300) !important;
+ border-radius: var(--ifm-global-radius) !important;
+ box-shadow: var(--shadow-lg) !important;
+ padding: 0.875rem 1rem !important;
+ font-size: 0.875rem !important;
+ line-height: 1.5 !important;
+ max-width: 420px !important;
+ min-width: 300px !important;
+ backdrop-filter: blur(8px) saturate(180%) !important;
+ -webkit-backdrop-filter: blur(8px) saturate(180%) !important;
+ transition: all var(--motion-duration-normal) var(--motion-ease-standard) !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 0.75rem !important;
+}
+
+/* Toast hover state */
+div[class*="react-hot-toast"] > div:hover,
+div[class*="react-hot-toast"] > div[class*="toast"]:hover,
+div[class*="react-hot-toast"] > div[style]:hover {
+ box-shadow: var(--shadow-xl) !important;
+ transform: translateY(-1px) !important;
+}
+
+/* ============================================
+ LIGHT MODE SPECIFIC STYLES
+ ============================================ */
+
+:root div[class*="react-hot-toast"] > div,
+:root div[class*="react-hot-toast"] > div[class*="toast"],
+:root div[class*="react-hot-toast"] > div[style] {
+ background: #ffffff !important;
+ border-color: var(--ifm-color-emphasis-200) !important;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04) !important;
+}
+
+:root div[class*="react-hot-toast"] > div:hover,
+:root div[class*="react-hot-toast"] > div[class*="toast"]:hover,
+:root div[class*="react-hot-toast"] > div[style]:hover {
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.06) !important;
+}
+
+/* ============================================
+ DARK MODE SPECIFIC STYLES
+ ============================================ */
+
+[data-theme='dark'] div[class*="react-hot-toast"] > div,
+[data-theme='dark'] div[class*="react-hot-toast"] > div[class*="toast"],
+[data-theme='dark'] div[class*="react-hot-toast"] > div[style] {
+ background: var(--ifm-background-surface-color) !important;
+ border-color: var(--ifm-color-emphasis-200) !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3) !important;
+ color: var(--ifm-font-color-base) !important;
+}
+
+[data-theme='dark'] div[class*="react-hot-toast"] > div:hover,
+[data-theme='dark'] div[class*="react-hot-toast"] > div[class*="toast"]:hover,
+[data-theme='dark'] div[class*="react-hot-toast"] > div[style]:hover {
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5), 0 6px 16px rgba(0, 0, 0, 0.4) !important;
+ border-color: var(--ifm-color-emphasis-300) !important;
+}
+
+/* ============================================
+ TOAST ICONS
+ ============================================ */
+
+/* Icon container */
+div[class*="react-hot-toast"] svg {
+ flex-shrink: 0;
+ width: 20px !important;
+ height: 20px !important;
+}
+
+/* Icon colors are handled by iconTheme in Root.js */
+/* Additional icon styling if needed */
+div[class*="react-hot-toast"] svg path {
+ fill: currentColor;
+}
+
+/* Custom icon styling for react-hot-toast */
+div[class*="react-hot-toast"] > div > div:first-child,
+div[class*="react-hot-toast"] > div > span:first-child {
+ margin-right: 0.75rem !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ flex-shrink: 0 !important;
+}
+
+/* ============================================
+ TOAST CONTENT
+ ============================================ */
+
+/* Toast message text container */
+div[class*="react-hot-toast"] > div > div:last-child,
+div[class*="react-hot-toast"] > div > span:last-child {
+ flex: 1;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ min-width: 0;
+}
+
+/* Toast message text styling */
+div[class*="react-hot-toast"] > div > div:last-child > div,
+div[class*="react-hot-toast"] > div > span:last-child > div,
+div[class*="react-hot-toast"] > div > div:last-child,
+div[class*="react-hot-toast"] > div > span:last-child {
+ color: var(--ifm-font-color-base) !important;
+ font-weight: 400;
+}
+
+/* ============================================
+ TOAST ANIMATIONS
+ ============================================ */
+
+/* Entry animation */
+@keyframes toast-enter {
+ from {
+ opacity: 0;
+ transform: translateX(100%) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0) scale(1);
+ }
+}
+
+/* Exit animation */
+@keyframes toast-exit {
+ from {
+ opacity: 1;
+ transform: translateX(0) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translateX(100%) scale(0.95);
+ }
+}
+
+/* Apply animations - react-hot-toast handles these internally */
+/* We just ensure smooth transitions */
+div[class*="react-hot-toast"] > div {
+ will-change: transform, opacity;
+}
+
+/* ============================================
+ TOAST CLOSE BUTTON
+ ============================================ */
+
+/* Close button styling */
+div[class*="react-hot-toast"] button {
+ color: var(--ifm-color-emphasis-600) !important;
+ background: transparent !important;
+ border: none !important;
+ padding: 0.25rem !important;
+ border-radius: 0.25rem !important;
+ cursor: pointer !important;
+ transition: all var(--motion-duration-fast) var(--motion-ease-standard) !important;
+ opacity: 0.7 !important;
+ margin-left: 0.5rem !important;
+}
+
+div[class*="react-hot-toast"] button:hover {
+ opacity: 1 !important;
+ background: var(--ifm-color-emphasis-100) !important;
+ color: var(--ifm-font-color-base) !important;
+}
+
+[data-theme='dark'] div[class*="react-hot-toast"] button:hover {
+ background: var(--ifm-color-emphasis-200) !important;
+}
+
+/* ============================================
+ RESPONSIVE DESIGN
+ ============================================ */
+
+@media (max-width: 768px) {
+ div[class*="react-hot-toast"] > div,
+ div[class*="react-hot-toast"] > div[class*="toast"] {
+ min-width: 280px !important;
+ max-width: calc(100vw - 2rem) !important;
+ padding: 0.75rem 0.875rem !important;
+ font-size: 0.8125rem !important;
+ }
+}
+
+/* ============================================
+ ACCESSIBILITY
+ ============================================ */
+
+/* Focus styles for keyboard navigation */
+div[class*="react-hot-toast"] button:focus-visible {
+ outline: 2px solid var(--focus-ring-color) !important;
+ outline-offset: 2px !important;
+}
+
+/* Reduced motion support */
+@media (prefers-reduced-motion: reduce) {
+ div[class*="react-hot-toast"] > div {
+ transition: none !important;
+ animation: none !important;
+ }
+
+ div[class*="react-hot-toast"] > div:hover {
+ transform: none !important;
+ }
+}
diff --git a/website/src/hooks/useFooterNewsletterPosition.js b/website/src/hooks/useFooterNewsletterPosition.js
new file mode 100644
index 00000000..f107e158
--- /dev/null
+++ b/website/src/hooks/useFooterNewsletterPosition.js
@@ -0,0 +1,91 @@
+import { useEffect } from 'react';
+
+/**
+ * Hook for positioning newsletter in footer based on viewport size
+ *
+ * Handles dynamic positioning of newsletter component in footer:
+ * - First position on mobile (≤996px)
+ * - Last position on desktop (>996px)
+ *
+ * Uses MutationObserver to handle async footer rendering and debounced
+ * resize handler for performance.
+ *
+ * @param {Object} refs - React refs for footer and newsletter elements
+ * @param {React.RefObject} refs.footerRef - Ref to footer wrapper element
+ * @param {React.RefObject} refs.newsletterRef - Ref to newsletter section element
+ * @param {Object} options - Configuration options
+ * @param {number} options.mobileBreakpoint - Breakpoint for mobile/desktop (default: 996)
+ * @param {number} options.debounceMs - Debounce delay for resize handler (default: 150)
+ */
+export function useFooterNewsletterPosition(
+ { footerRef, newsletterRef },
+ { mobileBreakpoint = 996, debounceMs = 150 } = {}
+) {
+ useEffect(() => {
+ if (!footerRef?.current || !newsletterRef?.current) return;
+
+ // Debounce utility function
+ const debounce = (func, wait) => {
+ let timeout;
+ return (...args) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func(...args), wait);
+ };
+ };
+
+ // Ensure newsletter is in footer__links container
+ // Position based on viewport: first on mobile, last on desktop
+ const insertNewsletter = () => {
+ if (!footerRef.current || !newsletterRef.current) return;
+
+ const footerLinks = footerRef.current.querySelector('.footer__links');
+ if (!footerLinks) return;
+
+ const isMobile = window.innerWidth <= mobileBreakpoint;
+ const isInContainer = footerLinks.contains(newsletterRef.current);
+ const isFirst = footerLinks.firstChild === newsletterRef.current;
+ const isLast = footerLinks.lastChild === newsletterRef.current;
+
+ if (!isInContainer) {
+ // Not in container yet, add it
+ if (isMobile) {
+ footerLinks.insertBefore(newsletterRef.current, footerLinks.firstChild);
+ } else {
+ footerLinks.appendChild(newsletterRef.current);
+ }
+ } else if (isMobile && !isFirst) {
+ // In container but should be first on mobile
+ footerLinks.insertBefore(newsletterRef.current, footerLinks.firstChild);
+ } else if (!isMobile && !isLast) {
+ // In container but should be last on desktop
+ footerLinks.appendChild(newsletterRef.current);
+ }
+ };
+
+ // Initial insertion
+ insertNewsletter();
+
+ // Use MutationObserver to handle cases where footer renders asynchronously
+ const observer = new MutationObserver(() => {
+ insertNewsletter();
+ });
+
+ if (footerRef.current) {
+ observer.observe(footerRef.current, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ // Debounced resize handler
+ const handleResize = debounce(insertNewsletter, debounceMs);
+
+ window.addEventListener('resize', handleResize);
+
+ // Cleanup
+ return () => {
+ observer.disconnect();
+ window.removeEventListener('resize', handleResize);
+ };
+ }, [footerRef, newsletterRef, mobileBreakpoint, debounceMs]);
+}
diff --git a/website/src/hooks/useNewsletterSubscribe.js b/website/src/hooks/useNewsletterSubscribe.js
new file mode 100644
index 00000000..ced20a7a
--- /dev/null
+++ b/website/src/hooks/useNewsletterSubscribe.js
@@ -0,0 +1,109 @@
+import { useState, useCallback } from 'react';
+import toast from 'react-hot-toast';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+
+/**
+ * Hook for subscribing to newsletter via Netlify serverless function
+ *
+ * Handles form submission, state management, and error handling
+ * for newsletter email subscriptions through Netlify serverless function.
+ *
+ * @param {Object} options - Configuration options
+ * @param {string} options.formId - Newsletter form ID (optional, falls back to config)
+ * @param {string} options.endpoint - Custom endpoint URL (defaults to Netlify function)
+ * @returns {Object} Subscribe function and state
+ * @returns {Function} returns.subscribe - Subscribe function
+ * @returns {boolean} returns.isSubmitting - Loading state
+ * @returns {boolean} returns.isConfigured - Whether newsletter is configured
+ */
+export function useNewsletterSubscribe({
+ formId = null,
+ endpoint = '/.netlify/functions/newsletter-subscribe'
+} = {}) {
+ const { siteConfig } = useDocusaurusContext();
+ const newsletterConfig = siteConfig.themeConfig?.newsletter;
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const isConfigured = newsletterConfig?.isEnabled;
+
+ /**
+ * Subscribe function - handles the API call to newsletter service
+ *
+ * @param {Object} subscriberData - Subscriber information
+ * @param {string} subscriberData.email - Email address (required)
+ * @param {string} [subscriberData.firstName] - First name (optional)
+ * @param {string} [subscriberData.lastName] - Last name (optional)
+ * @param {Object} [subscriberData.customFields] - Additional custom fields
+ * @returns {Promise