From 33a4f82dd7b9205c3ba95464edab079e28e9454c Mon Sep 17 00:00:00 2001 From: maxnorm Date: Thu, 15 Jan 2026 11:10:32 -0500 Subject: [PATCH 1/5] add newletters forms in blogs and footers --- website/docusaurus.config.js | 7 + .../netlify/functions/newsletter-subscribe.js | 199 +++++++ .../FooterNewsletterSignup/index.js | 126 +++++ .../FooterNewsletterSignup/styles.module.css | 287 ++++++++++ .../newsletter/NewsletterSignup/index.js | 263 +++++++++ .../NewsletterSignup/styles.module.css | 521 ++++++++++++++++++ website/src/hooks/useNewsletterSubscribe.js | 138 +++++ website/src/theme/BlogPostItem/index.js | 9 +- website/src/theme/Footer/index.js | 67 ++- website/src/theme/Footer/styles.module.css | 64 +++ 10 files changed, 1677 insertions(+), 4 deletions(-) create mode 100644 website/netlify/functions/newsletter-subscribe.js create mode 100644 website/src/components/newsletter/FooterNewsletterSignup/index.js create mode 100644 website/src/components/newsletter/FooterNewsletterSignup/styles.module.css create mode 100644 website/src/components/newsletter/NewsletterSignup/index.js create mode 100644 website/src/components/newsletter/NewsletterSignup/styles.module.css create mode 100644 website/src/hooks/useNewsletterSubscribe.js diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 6c08639c..3a293814 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -279,6 +279,13 @@ const config = { loading: process.env.GISCUS_LOADING || 'lazy' }, }), + // Newsletter email collection configuratio + ...(process.env.NEWSLETTER_FORM_ID && { + newsletter: { + formId: process.env.NEWSLETTER_FORM_ID, + apiUrl: process.env.NEWSLETTER_API_URL || 'https://api.convertkit.com/v3', + }, + }), }), 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..8ade60ad --- /dev/null +++ b/website/netlify/functions/newsletter-subscribe.js @@ -0,0 +1,199 @@ +/** + * Netlify Serverless Function to handle newsletter form submissions + * + * This function securely handles ConvertKit 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.convertkit.com/#subscribe-to-a-form + */ + +exports.handler = async (event, context) => { + // Only allow POST requests + if (event.httpMethod !== 'POST') { + return { + statusCode: 405, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + body: JSON.stringify({ error: 'Method not allowed' }), + }; + } + + // Handle CORS preflight + if (event.httpMethod === 'OPTIONS') { + return { + statusCode: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + body: '', + }; + } + + try { + // Parse request body + let body; + try { + body = JSON.parse(event.body); + } catch (parseError) { + return { + statusCode: 400, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + 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: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ error: 'Valid email is required' }), + }; + } + + // Get configuration from environment variables + const newsletterApiKey = process.env.NEWSLETTER_API_KEY; + const newsletterFormId = process.env.NEWSLETTER_FORM_ID; + const apiUrl = process.env.NEWSLETTER_API_URL || 'https://api.convertkit.com/v3'; + + if (!newsletterApiKey || !newsletterFormId) { + console.error('Newsletter API configuration missing:', { + hasApiKey: !!newsletterApiKey, + hasFormId: !!newsletterFormId, + }); + return { + statusCode: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ error: 'Server configuration error' }), + }; + } + + // Prepare subscriber data for ConvertKit + const subscriberData = { + email: email.trim().toLowerCase(), + ...(firstName && { first_name: firstName.trim() }), + ...(lastName && { last_name: lastName.trim() }), + ...customFields, + }; + + // Call ConvertKit API with timeout + // ConvertKit requires api_key as a query parameter + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + try { + const convertKitUrl = `${apiUrl}/forms/${newsletterFormId}/subscribe?api_key=${encodeURIComponent(newsletterApiKey)}`; + const convertKitResponse = await fetch(convertKitUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(subscriberData), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const convertKitData = await convertKitResponse.json(); + + if (!convertKitResponse.ok) { + console.error('ConvertKit API error:', { + status: convertKitResponse.status, + statusText: convertKitResponse.statusText, + data: convertKitData, + }); + + // Don't expose internal API errors to client + const errorMessage = convertKitData.message || 'Failed to subscribe. Please try again.'; + + return { + statusCode: convertKitResponse.status >= 400 && convertKitResponse.status < 500 + ? convertKitResponse.status + : 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + error: errorMessage, + }), + }; + } + + // Success response + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': process.env.NODE_ENV === 'production' + ? process.env.URL || '*' + : '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + body: JSON.stringify({ + success: true, + message: 'Successfully subscribed!', + data: convertKitData, + }), + }; + + } catch (fetchError) { + clearTimeout(timeoutId); + + if (fetchError.name === 'AbortError') { + console.error('Request timeout to ConvertKit API'); + return { + statusCode: 504, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + 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: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + error: 'Internal server error', + message: process.env.NODE_ENV === 'development' ? error.message : undefined, + }), + }; + } +}; diff --git a/website/src/components/newsletter/FooterNewsletterSignup/index.js b/website/src/components/newsletter/FooterNewsletterSignup/index.js new file mode 100644 index 00000000..48157dae --- /dev/null +++ b/website/src/components/newsletter/FooterNewsletterSignup/index.js @@ -0,0 +1,126 @@ +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. + * Uses the useNewsletterSubscribe hook and integrates seamlessly with the footer design. + * + * @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, message, isConfigured } = useNewsletterSubscribe(); + + const [email, setEmail] = useState(''); + + // Don't render if not configured + if (!isConfigured) { + return null; + } + + const handleSubmit = async (e) => { + e.preventDefault(); + + try { + await subscribe({ email }); + + // Reset form on success + setEmail(''); + } catch (error) { + // Error is already handled by the hook + } + }; + + // Shield/Trust icon SVG + const ShieldIcon = () => ( + + ); + + 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. +
+ + {message.text && ( +
+ {message.text} +
+ )} +
+
+ ); +} 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..d4e0346d --- /dev/null +++ b/website/src/components/newsletter/NewsletterSignup/index.js @@ -0,0 +1,263 @@ +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'; + +/** + * Premium Newsletter Signup Component + * + * A stunning newsletter subscription form with glass morphism, gradients, + * and smooth animations. Uses the useNewsletterSubscribe hook directly. + * + * Configuration is read from themeConfig.newsletter in docusaurus.config.js. + * The component automatically adapts to the current Docusaurus theme (light/dark mode). + * + * @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 { siteConfig } = useDocusaurusContext(); + const newsletterConfig = siteConfig.themeConfig?.newsletter; + + const { subscribe, isSubmitting, message, isConfigured, clearMessage } = useNewsletterSubscribe(); + + const [email, setEmail] = useState(''); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + + // Don't render if not configured + if (!isConfigured) { + return null; + } + + const handleSubmit = async (e) => { + e.preventDefault(); + + try { + await subscribe({ + email, + ...(showNameFields && firstName && { firstName }), + ...(showNameFields && lastName && { lastName }), + }); + + // Reset form on success + setEmail(''); + setFirstName(''); + setLastName(''); + + // Call success callback if provided + if (onSuccess) { + onSuccess({ email, firstName, lastName }); + } + } catch (error) { + // Error is already handled by the hook, but call error callback if provided + if (onError) { + onError(error); + } + } + }; + + // Envelope icon SVG + const EnvelopeIcon = () => ( + + ); + + // Checkmark icon SVG + const CheckmarkIcon = () => ( + + ); + + // Alert/Error icon SVG + const AlertIcon = () => ( + + ); + + // Shield/Trust icon SVG + const ShieldIcon = () => ( + + ); + + 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. +
+
+ + {/* Message States with Icons */} + {message.text && ( +
+ {message.type === 'success' ? : } + {message.text} +
+ )} +
+
+ ); +} 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..c88bd0d9 --- /dev/null +++ b/website/src/components/newsletter/NewsletterSignup/styles.module.css @@ -0,0 +1,521 @@ +/** + * 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; + color: var(--ifm-color-primary); + stroke-width: 2; +} + +.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/hooks/useNewsletterSubscribe.js b/website/src/hooks/useNewsletterSubscribe.js new file mode 100644 index 00000000..a5295387 --- /dev/null +++ b/website/src/hooks/useNewsletterSubscribe.js @@ -0,0 +1,138 @@ +import { useState, useCallback } from 'react'; +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 {Object} returns.message - Message object { type: 'success'|'error'|null, text: string } + * @returns {boolean} returns.isConfigured - Whether newsletter is configured + * @returns {Function} returns.clearMessage - Function to clear current message + */ +export function useNewsletterSubscribe({ + formId = null, + endpoint = '/.netlify/functions/newsletter-subscribe' +} = {}) { + const { siteConfig } = useDocusaurusContext(); + const newsletterConfig = siteConfig.themeConfig?.newsletter; + + const [isSubmitting, setIsSubmitting] = useState(false); + const [message, setMessage] = useState({ type: null, text: '' }); + + // Check if newsletter is configured + const finalFormId = formId || newsletterConfig?.formId; + const isConfigured = !!finalFormId; + + /** + * 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'); + setMessage({ + type: 'error', + text: 'Newsletter subscription is not available.' + }); + throw error; + } + + if (!subscriberData.email) { + const error = new Error('Email is required'); + setMessage({ + type: 'error', + text: 'Email address is required.' + }); + throw error; + } + + setIsSubmitting(true); + setMessage({ type: null, text: '' }); + + 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 + setMessage({ + type: 'success', + text: 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.'; + setMessage({ + type: 'error', + text: errorMessage + }); + throw new Error(errorMessage); + } + + // Handle other errors + const errorMessage = error.message || 'Something went wrong. Please try again.'; + setMessage({ + type: 'error', + text: errorMessage + }); + throw error; + } finally { + setIsSubmitting(false); + } + }, [isConfigured, endpoint]); + + /** + * Clear the current message + */ + const clearMessage = useCallback(() => { + setMessage({ type: null, text: '' }); + }, []); + + // 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, + message, + isConfigured, + clearMessage, + }; +} 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..3657ec4c 100644 --- a/website/src/theme/Footer/index.js +++ b/website/src/theme/Footer/index.js @@ -1,16 +1,77 @@ /** * Footer Component - * Custom footer with Netlify badge + * Custom footer with Netlify badge and newsletter signup */ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import Footer from '@theme-original/Footer'; +import FooterNewsletterSignup from '@site/src/components/newsletter/FooterNewsletterSignup'; import styles from './styles.module.css'; export default function FooterWrapper(props) { + const footerRef = useRef(null); + const newsletterRef = useRef(null); + + useEffect(() => { + // Function to position newsletter based on viewport size + const positionNewsletter = () => { + if (!footerRef.current || !newsletterRef.current) return; + + const footerLinks = footerRef.current.querySelector('.footer__links'); + if (!footerLinks) return; + + // Check if newsletter is already in the container + const isInContainer = footerLinks.contains(newsletterRef.current); + + // Determine if mobile or desktop (breakpoint: 996px) + const isMobile = window.innerWidth <= 996; + + if (!isInContainer) { + // Newsletter not yet in container, add it + if (isMobile) { + // Prepend on mobile (appears first) + footerLinks.insertBefore(newsletterRef.current, footerLinks.firstChild); + } else { + // Append on desktop (appears on right side) + footerLinks.appendChild(newsletterRef.current); + } + } else { + // Newsletter already in container, reposition if needed + const isFirst = footerLinks.firstChild === newsletterRef.current; + const isLast = footerLinks.lastChild === newsletterRef.current; + + if (isMobile && !isFirst) { + // Should be first on mobile + footerLinks.insertBefore(newsletterRef.current, footerLinks.firstChild); + } else if (!isMobile && !isLast) { + // Should be last on desktop + footerLinks.appendChild(newsletterRef.current); + } + } + }; + + // Position on mount + positionNewsletter(); + + // Handle window resize + const handleResize = () => { + positionNewsletter(); + }; + + window.addEventListener('resize', handleResize); + + // Cleanup + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + return ( -
+
); diff --git a/website/src/components/newsletter/NewsletterSignup/styles.module.css b/website/src/components/newsletter/NewsletterSignup/styles.module.css index c88bd0d9..9914f653 100644 --- a/website/src/components/newsletter/NewsletterSignup/styles.module.css +++ b/website/src/components/newsletter/NewsletterSignup/styles.module.css @@ -118,8 +118,7 @@ .newsletterIcon { width: 24px; height: 24px; - color: var(--ifm-color-primary); - stroke-width: 2; + display: block; } .newsletterTitle { 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 index e83dad32..ced20a7a 100644 --- a/website/src/hooks/useNewsletterSubscribe.js +++ b/website/src/hooks/useNewsletterSubscribe.js @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import toast from 'react-hot-toast'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; /** @@ -13,9 +14,7 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; * @returns {Object} Subscribe function and state * @returns {Function} returns.subscribe - Subscribe function * @returns {boolean} returns.isSubmitting - Loading state - * @returns {Object} returns.message - Message object { type: 'success'|'error'|null, text: string } * @returns {boolean} returns.isConfigured - Whether newsletter is configured - * @returns {Function} returns.clearMessage - Function to clear current message */ export function useNewsletterSubscribe({ formId = null, @@ -25,7 +24,6 @@ export function useNewsletterSubscribe({ const newsletterConfig = siteConfig.themeConfig?.newsletter; const [isSubmitting, setIsSubmitting] = useState(false); - const [message, setMessage] = useState({ type: null, text: '' }); const isConfigured = newsletterConfig?.isEnabled; @@ -42,24 +40,17 @@ export function useNewsletterSubscribe({ const subscribe = useCallback(async (subscriberData) => { if (!isConfigured) { const error = new Error('Newsletter is not configured'); - setMessage({ - type: 'error', - text: 'Newsletter subscription is not available.' - }); + toast.error('Newsletter subscription is not available.'); throw error; } if (!subscriberData.email) { const error = new Error('Email is required'); - setMessage({ - type: 'error', - text: 'Email address is required.' - }); + toast.error('Email address is required.'); throw error; } setIsSubmitting(true); - setMessage({ type: null, text: '' }); try { const response = await fetch(endpoint, { @@ -82,10 +73,7 @@ export function useNewsletterSubscribe({ } // Success - setMessage({ - type: 'success', - text: data.message || 'Thank you for subscribing!' - }); + toast.success(data.message || 'Thank you for subscribing!'); return data; @@ -93,32 +81,19 @@ export function useNewsletterSubscribe({ // Handle network errors if (error.name === 'TypeError' && error.message.includes('fetch')) { const errorMessage = 'Network error. Please check your connection and try again.'; - setMessage({ - type: 'error', - text: errorMessage - }); + toast.error(errorMessage); throw new Error(errorMessage); } // Handle other errors const errorMessage = error.message || 'Something went wrong. Please try again.'; - setMessage({ - type: 'error', - text: errorMessage - }); + toast.error(errorMessage); throw error; } finally { setIsSubmitting(false); } }, [isConfigured, endpoint]); - /** - * Clear the current message - */ - const clearMessage = useCallback(() => { - setMessage({ type: null, text: '' }); - }, []); - // Warn in development if not configured if (!isConfigured && process.env.NODE_ENV === 'development') { console.warn( @@ -129,8 +104,6 @@ export function useNewsletterSubscribe({ return { subscribe, isSubmitting, - message, isConfigured, - clearMessage, }; } diff --git a/website/src/theme/Footer/index.js b/website/src/theme/Footer/index.js index 3657ec4c..74201420 100644 --- a/website/src/theme/Footer/index.js +++ b/website/src/theme/Footer/index.js @@ -3,68 +3,16 @@ * Custom footer with Netlify badge and newsletter signup */ -import React, { useEffect, useRef } 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); - - useEffect(() => { - // Function to position newsletter based on viewport size - const positionNewsletter = () => { - if (!footerRef.current || !newsletterRef.current) return; - - const footerLinks = footerRef.current.querySelector('.footer__links'); - if (!footerLinks) return; - - // Check if newsletter is already in the container - const isInContainer = footerLinks.contains(newsletterRef.current); - - // Determine if mobile or desktop (breakpoint: 996px) - const isMobile = window.innerWidth <= 996; - - if (!isInContainer) { - // Newsletter not yet in container, add it - if (isMobile) { - // Prepend on mobile (appears first) - footerLinks.insertBefore(newsletterRef.current, footerLinks.firstChild); - } else { - // Append on desktop (appears on right side) - footerLinks.appendChild(newsletterRef.current); - } - } else { - // Newsletter already in container, reposition if needed - const isFirst = footerLinks.firstChild === newsletterRef.current; - const isLast = footerLinks.lastChild === newsletterRef.current; - - if (isMobile && !isFirst) { - // Should be first on mobile - footerLinks.insertBefore(newsletterRef.current, footerLinks.firstChild); - } else if (!isMobile && !isLast) { - // Should be last on desktop - footerLinks.appendChild(newsletterRef.current); - } - } - }; - - // Position on mount - positionNewsletter(); - - // Handle window resize - const handleResize = () => { - positionNewsletter(); - }; - - window.addEventListener('resize', handleResize); - - // Cleanup - return () => { - window.removeEventListener('resize', handleResize); - }; - }, []); + useFooterNewsletterPosition({ footerRef, newsletterRef }); return (
diff --git a/website/src/theme/Root.js b/website/src/theme/Root.js index 12798ef8..15af11bf 100644 --- a/website/src/theme/Root.js +++ b/website/src/theme/Root.js @@ -4,12 +4,81 @@ */ import React from 'react'; +import { Toaster } from 'react-hot-toast'; import NavbarEnhancements from '@site/src/components/navigation/NavbarEnhancements'; export default function Root({children}) { return ( <> + {children} ); diff --git a/website/static/icons/envelope.svg b/website/static/icons/envelope.svg new file mode 100644 index 00000000..aea38676 --- /dev/null +++ b/website/static/icons/envelope.svg @@ -0,0 +1,9 @@ + + + diff --git a/website/static/icons/shield-check.svg b/website/static/icons/shield-check.svg new file mode 100644 index 00000000..d652a22a --- /dev/null +++ b/website/static/icons/shield-check.svg @@ -0,0 +1,9 @@ + + +