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}

} + +
+
+ setEmail(e.target.value)} + className={styles.footerNewsletterInput} + required + disabled={isSubmitting} + aria-label="Email address" + aria-required="true" + /> + +
+ + {/* Trust Signal */} +
+ + No spam. Unsubscribe anytime. +
+
+
+ ); +} 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 */} +
+ {showNameFields && ( +
+
+ setFirstName(e.target.value)} + className={styles.newsletterInput} + disabled={isSubmitting} + aria-label="First name" + /> +
+
+ setLastName(e.target.value)} + className={styles.newsletterInput} + disabled={isSubmitting} + aria-label="Last name" + /> +
+
+ )} + + {/* Email Input and Button Row - Side by side on desktop */} +
+
+ setEmail(e.target.value)} + className={styles.newsletterInput} + required + disabled={isSubmitting} + aria-label="Email address" + aria-required="true" + /> +
+ +
+ + {/* Trust Signal */} +
+ + No spam. Unsubscribe anytime. +
+
+
+
+ ); +} 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} Response data or throws error + */ + const subscribe = useCallback(async (subscriberData) => { + if (!isConfigured) { + const error = new Error('Newsletter is not configured'); + toast.error('Newsletter subscription is not available.'); + throw error; + } + + if (!subscriberData.email) { + const error = new Error('Email is required'); + toast.error('Email address is required.'); + throw error; + } + + setIsSubmitting(true); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: subscriberData.email.trim().toLowerCase(), + ...(subscriberData.firstName && { firstName: subscriberData.firstName.trim() }), + ...(subscriberData.lastName && { lastName: subscriberData.lastName.trim() }), + ...(subscriberData.customFields || {}), + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Subscription failed'); + } + + // Success + toast.success(data.message || 'Thank you for subscribing!'); + + return data; + + } catch (error) { + // Handle network errors + if (error.name === 'TypeError' && error.message.includes('fetch')) { + const errorMessage = 'Network error. Please check your connection and try again.'; + toast.error(errorMessage); + throw new Error(errorMessage); + } + + // Handle other errors + const errorMessage = error.message || 'Something went wrong. Please try again.'; + toast.error(errorMessage); + throw error; + } finally { + setIsSubmitting(false); + } + }, [isConfigured, endpoint]); + + // Warn in development if not configured + if (!isConfigured && process.env.NODE_ENV === 'development') { + console.warn( + 'Newsletter is not configured. Please add newsletter configuration to themeConfig in docusaurus.config.js' + ); + } + + return { + subscribe, + isSubmitting, + isConfigured, + }; +} diff --git a/website/src/theme/BlogPostItem/index.js b/website/src/theme/BlogPostItem/index.js index 50c3753a..6b018ae9 100644 --- a/website/src/theme/BlogPostItem/index.js +++ b/website/src/theme/BlogPostItem/index.js @@ -2,12 +2,13 @@ import React from 'react'; import { useBlogPost } from '@docusaurus/plugin-content-blog/client'; import BlogPostItem from '@theme-original/BlogPostItem'; import GiscusComponent from '@site/src/components/Giscus'; +import NewsletterSignup from '@site/src/components/newsletter/NewsletterSignup'; export default function BlogPostItemWrapper(props) { const { metadata, isBlogPostPage } = useBlogPost(); const { frontMatter } = metadata; - const { enableComments } = frontMatter; + const { enableComments, enableNewsletter } = frontMatter; return ( <> @@ -15,6 +16,12 @@ export default function BlogPostItemWrapper(props) { {(enableComments !== false && isBlogPostPage) && ( )} + {(enableNewsletter !== false && isBlogPostPage) && ( + + )} ); } diff --git a/website/src/theme/Footer/index.js b/website/src/theme/Footer/index.js index be9f96ea..74201420 100644 --- a/website/src/theme/Footer/index.js +++ b/website/src/theme/Footer/index.js @@ -1,16 +1,25 @@ /** * Footer Component - * Custom footer with Netlify badge + * Custom footer with Netlify badge and newsletter signup */ -import React from 'react'; +import React, { useRef } from 'react'; import Footer from '@theme-original/Footer'; +import FooterNewsletterSignup from '@site/src/components/newsletter/FooterNewsletterSignup'; +import { useFooterNewsletterPosition } from '@site/src/hooks/useFooterNewsletterPosition'; import styles from './styles.module.css'; export default function FooterWrapper(props) { + const footerRef = useRef(null); + const newsletterRef = useRef(null); + useFooterNewsletterPosition({ footerRef, newsletterRef }); + return ( -
+