UNPKG

payload-plugin-newsletter

Version:

Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration

860 lines (855 loc) 27.6 kB
"use client"; "use client"; // src/components/NewsletterForm.tsx import { useState } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; var defaultStyles = { form: { display: "flex", flexDirection: "column", gap: "1rem", maxWidth: "400px", margin: "0 auto" }, inputGroup: { display: "flex", flexDirection: "column", gap: "0.5rem" }, label: { fontSize: "0.875rem", fontWeight: "500", color: "#374151" }, input: { padding: "0.5rem 0.75rem", fontSize: "1rem", border: "1px solid #e5e7eb", borderRadius: "0.375rem", outline: "none", transition: "border-color 0.2s" }, button: { padding: "0.75rem 1.5rem", fontSize: "1rem", fontWeight: "500", color: "#ffffff", backgroundColor: "#3b82f6", border: "none", borderRadius: "0.375rem", cursor: "pointer", transition: "background-color 0.2s" }, buttonDisabled: { opacity: 0.5, cursor: "not-allowed" }, error: { fontSize: "0.875rem", color: "#ef4444", marginTop: "0.25rem" }, success: { fontSize: "0.875rem", color: "#10b981", marginTop: "0.25rem" }, checkbox: { display: "flex", alignItems: "center", gap: "0.5rem" }, checkboxInput: { width: "1rem", height: "1rem" }, checkboxLabel: { fontSize: "0.875rem", color: "#374151" } }; var NewsletterForm = ({ onSuccess, onError, showName = false, showPreferences = false, leadMagnet, className, styles: customStyles = {}, apiEndpoint = "/api/newsletter/subscribe", buttonText = "Subscribe", loadingText = "Subscribing...", successMessage = "Successfully subscribed!", placeholders = { email: "Enter your email", name: "Enter your name" }, labels = { email: "Email", name: "Name", newsletter: "Newsletter updates", announcements: "Product announcements" } }) => { const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [preferences, setPreferences] = useState({ newsletter: true, announcements: true }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const styles = { form: { ...defaultStyles.form, ...customStyles.form }, inputGroup: { ...defaultStyles.inputGroup, ...customStyles.inputGroup }, label: { ...defaultStyles.label, ...customStyles.label }, input: { ...defaultStyles.input, ...customStyles.input }, button: { ...defaultStyles.button, ...customStyles.button }, buttonDisabled: { ...defaultStyles.buttonDisabled, ...customStyles.buttonDisabled }, error: { ...defaultStyles.error, ...customStyles.error }, success: { ...defaultStyles.success, ...customStyles.success }, checkbox: { ...defaultStyles.checkbox, ...customStyles.checkbox }, checkboxInput: { ...defaultStyles.checkboxInput, ...customStyles.checkboxInput }, checkboxLabel: { ...defaultStyles.checkboxLabel, ...customStyles.checkboxLabel } }; const handleSubmit = async (e) => { e.preventDefault(); setError(null); setLoading(true); try { const payload = { email, ...showName && name && { name }, ...showPreferences && { preferences }, ...leadMagnet && { leadMagnet: leadMagnet.id }, metadata: { signupPage: window.location.href, ...typeof window !== "undefined" && window.location.search && { utmParams: Object.fromEntries(new URLSearchParams(window.location.search)) } } }; const response = await fetch(apiEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || data.errors?.join(", ") || "Subscription failed"); } setSuccess(true); setEmail(""); setName(""); if (onSuccess) { onSuccess(data.subscriber); } } catch (err) { const errorMessage = err instanceof Error ? err.message : "An error occurred"; setError(errorMessage); if (onError) { onError(new Error(errorMessage)); } } finally { setLoading(false); } }; if (success && !showPreferences) { return /* @__PURE__ */ jsx("div", { className, style: styles.form, children: /* @__PURE__ */ jsx("p", { style: styles.success, children: successMessage }) }); } return /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className, style: styles.form, children: [ /* @__PURE__ */ jsxs("div", { style: styles.inputGroup, children: [ /* @__PURE__ */ jsx("label", { htmlFor: "email", style: styles.label, children: labels.email }), /* @__PURE__ */ jsx( "input", { id: "email", type: "email", value: email, onChange: (e) => setEmail(e.target.value), placeholder: placeholders.email, required: true, disabled: loading, style: { ...styles.input, ...loading && { opacity: 0.5 } } } ) ] }), showName && /* @__PURE__ */ jsxs("div", { style: styles.inputGroup, children: [ /* @__PURE__ */ jsx("label", { htmlFor: "name", style: styles.label, children: labels.name }), /* @__PURE__ */ jsx( "input", { id: "name", type: "text", value: name, onChange: (e) => setName(e.target.value), placeholder: placeholders.name, disabled: loading, style: { ...styles.input, ...loading && { opacity: 0.5 } } } ) ] }), showPreferences && /* @__PURE__ */ jsxs("div", { style: styles.inputGroup, children: [ /* @__PURE__ */ jsx("label", { style: styles.label, children: "Email Preferences" }), /* @__PURE__ */ jsxs("div", { style: styles.checkbox, children: [ /* @__PURE__ */ jsx( "input", { id: "newsletter", type: "checkbox", checked: preferences.newsletter, onChange: (e) => setPreferences({ ...preferences, newsletter: e.target.checked }), disabled: loading, style: styles.checkboxInput } ), /* @__PURE__ */ jsx("label", { htmlFor: "newsletter", style: styles.checkboxLabel, children: labels.newsletter }) ] }), /* @__PURE__ */ jsxs("div", { style: styles.checkbox, children: [ /* @__PURE__ */ jsx( "input", { id: "announcements", type: "checkbox", checked: preferences.announcements, onChange: (e) => setPreferences({ ...preferences, announcements: e.target.checked }), disabled: loading, style: styles.checkboxInput } ), /* @__PURE__ */ jsx("label", { htmlFor: "announcements", style: styles.checkboxLabel, children: labels.announcements }) ] }) ] }), /* @__PURE__ */ jsx( "button", { type: "submit", disabled: loading, style: { ...styles.button, ...loading && styles.buttonDisabled }, children: loading ? loadingText : buttonText } ), error && /* @__PURE__ */ jsx("p", { style: styles.error, children: error }), success && /* @__PURE__ */ jsx("p", { style: styles.success, children: successMessage }) ] }); }; function createNewsletterForm(defaultProps) { return (props) => /* @__PURE__ */ jsx(NewsletterForm, { ...defaultProps, ...props }); } // src/components/PreferencesForm.tsx import { useState as useState2, useEffect } from "react"; import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime"; var defaultStyles2 = { container: { maxWidth: "600px", margin: "0 auto", padding: "2rem" }, heading: { fontSize: "1.5rem", fontWeight: "600", marginBottom: "1.5rem", color: "#111827" }, form: { display: "flex", flexDirection: "column", gap: "1.5rem" }, section: { padding: "1.5rem", backgroundColor: "#f9fafb", borderRadius: "0.5rem", border: "1px solid #e5e7eb" }, sectionTitle: { fontSize: "1.125rem", fontWeight: "500", marginBottom: "1rem", color: "#111827" }, inputGroup: { display: "flex", flexDirection: "column", gap: "0.5rem" }, label: { fontSize: "0.875rem", fontWeight: "500", color: "#374151" }, input: { padding: "0.5rem 0.75rem", fontSize: "1rem", border: "1px solid #e5e7eb", borderRadius: "0.375rem", outline: "none", transition: "border-color 0.2s" }, select: { padding: "0.5rem 0.75rem", fontSize: "1rem", border: "1px solid #e5e7eb", borderRadius: "0.375rem", outline: "none", backgroundColor: "#ffffff" }, checkbox: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" }, checkboxInput: { width: "1rem", height: "1rem" }, checkboxLabel: { fontSize: "0.875rem", color: "#374151" }, buttonGroup: { display: "flex", gap: "1rem", marginTop: "1rem" }, button: { padding: "0.75rem 1.5rem", fontSize: "1rem", fontWeight: "500", borderRadius: "0.375rem", cursor: "pointer", transition: "all 0.2s", border: "none" }, primaryButton: { color: "#ffffff", backgroundColor: "#3b82f6" }, secondaryButton: { color: "#374151", backgroundColor: "#ffffff", border: "1px solid #e5e7eb" }, dangerButton: { color: "#ffffff", backgroundColor: "#ef4444" }, error: { fontSize: "0.875rem", color: "#ef4444", marginTop: "0.5rem" }, success: { fontSize: "0.875rem", color: "#10b981", marginTop: "0.5rem" }, info: { fontSize: "0.875rem", color: "#6b7280", marginTop: "0.5rem" } }; var PreferencesForm = ({ subscriber: initialSubscriber, onSuccess, onError, className, styles: customStyles = {}, sessionToken, apiEndpoint = "/api/newsletter/preferences", showUnsubscribe = true, locales = ["en"], labels = { title: "Newsletter Preferences", personalInfo: "Personal Information", emailPreferences: "Email Preferences", name: "Name", language: "Preferred Language", newsletter: "Newsletter updates", announcements: "Product announcements", saveButton: "Save Preferences", unsubscribeButton: "Unsubscribe", saving: "Saving...", saved: "Preferences saved successfully!", unsubscribeConfirm: "Are you sure you want to unsubscribe? This cannot be undone." } }) => { const [subscriber, setSubscriber] = useState2(initialSubscriber || {}); const [loading, setLoading] = useState2(false); const [loadingData, setLoadingData] = useState2(!initialSubscriber); const [error, setError] = useState2(null); const [success, setSuccess] = useState2(false); const styles = { container: { ...defaultStyles2.container, ...customStyles.container }, heading: { ...defaultStyles2.heading, ...customStyles.heading }, form: { ...defaultStyles2.form, ...customStyles.form }, section: { ...defaultStyles2.section, ...customStyles.section }, sectionTitle: { ...defaultStyles2.sectionTitle, ...customStyles.sectionTitle }, inputGroup: { ...defaultStyles2.inputGroup, ...customStyles.inputGroup }, label: { ...defaultStyles2.label, ...customStyles.label }, input: { ...defaultStyles2.input, ...customStyles.input }, select: { ...defaultStyles2.select, ...customStyles.select }, checkbox: { ...defaultStyles2.checkbox, ...customStyles.checkbox }, checkboxInput: { ...defaultStyles2.checkboxInput, ...customStyles.checkboxInput }, checkboxLabel: { ...defaultStyles2.checkboxLabel, ...customStyles.checkboxLabel }, buttonGroup: { ...defaultStyles2.buttonGroup, ...customStyles.buttonGroup }, button: { ...defaultStyles2.button, ...customStyles.button }, primaryButton: { ...defaultStyles2.primaryButton, ...customStyles.primaryButton }, secondaryButton: { ...defaultStyles2.secondaryButton, ...customStyles.secondaryButton }, dangerButton: { ...defaultStyles2.dangerButton, ...customStyles.dangerButton }, error: { ...defaultStyles2.error, ...customStyles.error }, success: { ...defaultStyles2.success, ...customStyles.success }, info: { ...defaultStyles2.info, ...customStyles.info } }; useEffect(() => { if (!initialSubscriber && sessionToken) { fetchPreferences(); } }, []); const fetchPreferences = async () => { try { const response = await fetch(apiEndpoint, { headers: { "Authorization": `Bearer ${sessionToken}` } }); if (!response.ok) { throw new Error("Failed to load preferences"); } const data = await response.json(); setSubscriber(data.subscriber); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load preferences"); if (onError) { onError(err instanceof Error ? err : new Error("Failed to load preferences")); } } finally { setLoadingData(false); } }; const handleSave = async (e) => { e.preventDefault(); setError(null); setSuccess(false); setLoading(true); try { const response = await fetch(apiEndpoint, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${sessionToken}` }, body: JSON.stringify({ name: subscriber.name, locale: subscriber.locale, emailPreferences: subscriber.emailPreferences }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Failed to save preferences"); } setSubscriber(data.subscriber); setSuccess(true); if (onSuccess) { onSuccess(data.subscriber); } } catch (err) { const errorMessage = err instanceof Error ? err.message : "An error occurred"; setError(errorMessage); if (onError) { onError(new Error(errorMessage)); } } finally { setLoading(false); } }; const handleUnsubscribe = async () => { if (!window.confirm(labels.unsubscribeConfirm)) { return; } setLoading(true); setError(null); try { const response = await fetch("/api/newsletter/unsubscribe", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${sessionToken}` }, body: JSON.stringify({ email: subscriber.email }) }); if (!response.ok) { throw new Error("Failed to unsubscribe"); } setSubscriber({ ...subscriber, subscriptionStatus: "unsubscribed" }); if (onSuccess) { onSuccess({ ...subscriber, subscriptionStatus: "unsubscribed" }); } } catch (err) { setError("Failed to unsubscribe. Please try again."); if (onError) { onError(err instanceof Error ? err : new Error("Failed to unsubscribe")); } } finally { setLoading(false); } }; if (loadingData) { return /* @__PURE__ */ jsx2("div", { className, style: styles.container, children: /* @__PURE__ */ jsx2("p", { style: styles.info, children: "Loading preferences..." }) }); } if (subscriber.subscriptionStatus === "unsubscribed") { return /* @__PURE__ */ jsxs2("div", { className, style: styles.container, children: [ /* @__PURE__ */ jsx2("h2", { style: styles.heading, children: "Unsubscribed" }), /* @__PURE__ */ jsx2("p", { style: styles.info, children: "You have been unsubscribed from all emails. To resubscribe, please sign up again." }) ] }); } return /* @__PURE__ */ jsxs2("div", { className, style: styles.container, children: [ /* @__PURE__ */ jsx2("h2", { style: styles.heading, children: labels.title }), /* @__PURE__ */ jsxs2("form", { onSubmit: handleSave, style: styles.form, children: [ /* @__PURE__ */ jsxs2("div", { style: styles.section, children: [ /* @__PURE__ */ jsx2("h3", { style: styles.sectionTitle, children: labels.personalInfo }), /* @__PURE__ */ jsxs2("div", { style: styles.inputGroup, children: [ /* @__PURE__ */ jsx2("label", { htmlFor: "name", style: styles.label, children: labels.name }), /* @__PURE__ */ jsx2( "input", { id: "name", type: "text", value: subscriber.name || "", onChange: (e) => setSubscriber({ ...subscriber, name: e.target.value }), disabled: loading, style: styles.input } ) ] }), locales.length > 1 && /* @__PURE__ */ jsxs2("div", { style: styles.inputGroup, children: [ /* @__PURE__ */ jsx2("label", { htmlFor: "locale", style: styles.label, children: labels.language }), /* @__PURE__ */ jsx2( "select", { id: "locale", value: subscriber.locale || locales[0], onChange: (e) => setSubscriber({ ...subscriber, locale: e.target.value }), disabled: loading, style: styles.select, children: locales.map((locale) => /* @__PURE__ */ jsx2("option", { value: locale, children: locale.toUpperCase() }, locale)) } ) ] }) ] }), /* @__PURE__ */ jsxs2("div", { style: styles.section, children: [ /* @__PURE__ */ jsx2("h3", { style: styles.sectionTitle, children: labels.emailPreferences }), /* @__PURE__ */ jsxs2("div", { style: styles.checkbox, children: [ /* @__PURE__ */ jsx2( "input", { id: "pref-newsletter", type: "checkbox", checked: subscriber.emailPreferences?.newsletter ?? true, onChange: (e) => setSubscriber({ ...subscriber, emailPreferences: { ...subscriber.emailPreferences, newsletter: e.target.checked } }), disabled: loading, style: styles.checkboxInput } ), /* @__PURE__ */ jsx2("label", { htmlFor: "pref-newsletter", style: styles.checkboxLabel, children: labels.newsletter }) ] }), /* @__PURE__ */ jsxs2("div", { style: styles.checkbox, children: [ /* @__PURE__ */ jsx2( "input", { id: "pref-announcements", type: "checkbox", checked: subscriber.emailPreferences?.announcements ?? true, onChange: (e) => setSubscriber({ ...subscriber, emailPreferences: { ...subscriber.emailPreferences, announcements: e.target.checked } }), disabled: loading, style: styles.checkboxInput } ), /* @__PURE__ */ jsx2("label", { htmlFor: "pref-announcements", style: styles.checkboxLabel, children: labels.announcements }) ] }) ] }), /* @__PURE__ */ jsxs2("div", { style: styles.buttonGroup, children: [ /* @__PURE__ */ jsx2( "button", { type: "submit", disabled: loading, style: { ...styles.button, ...styles.primaryButton, ...loading && { opacity: 0.5, cursor: "not-allowed" } }, children: loading ? labels.saving : labels.saveButton } ), showUnsubscribe && /* @__PURE__ */ jsx2( "button", { type: "button", onClick: handleUnsubscribe, disabled: loading, style: { ...styles.button, ...styles.dangerButton, ...loading && { opacity: 0.5, cursor: "not-allowed" } }, children: labels.unsubscribeButton } ) ] }), error && /* @__PURE__ */ jsx2("p", { style: styles.error, children: error }), success && /* @__PURE__ */ jsx2("p", { style: styles.success, children: labels.saved }) ] }) ] }); }; function createPreferencesForm(defaultProps) { return (props) => /* @__PURE__ */ jsx2(PreferencesForm, { ...defaultProps, ...props }); } // src/components/MagicLinkVerify.tsx import { useState as useState3, useEffect as useEffect2 } from "react"; import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime"; var defaultStyles3 = { container: { maxWidth: "400px", margin: "4rem auto", padding: "2rem", textAlign: "center" }, heading: { fontSize: "1.5rem", fontWeight: "600", marginBottom: "1rem", color: "#111827" }, message: { fontSize: "1rem", color: "#6b7280", marginBottom: "1.5rem" }, error: { fontSize: "1rem", color: "#ef4444", marginBottom: "1.5rem" }, button: { padding: "0.75rem 1.5rem", fontSize: "1rem", fontWeight: "500", color: "#ffffff", backgroundColor: "#3b82f6", border: "none", borderRadius: "0.375rem", cursor: "pointer", transition: "background-color 0.2s" } }; var MagicLinkVerify = ({ token: propToken, onSuccess, onError, apiEndpoint = "/api/newsletter/verify-magic-link", className, styles: customStyles = {}, labels = { verifying: "Verifying your magic link...", success: "Successfully verified! Redirecting...", error: "Failed to verify magic link", expired: "This magic link has expired. Please request a new one.", invalid: "This magic link is invalid. Please request a new one.", redirecting: "Redirecting to your preferences...", tryAgain: "Try Again" } }) => { const [status, setStatus] = useState3("verifying"); const [error, setError] = useState3(null); const [_sessionToken, setSessionToken] = useState3(null); const styles = { container: { ...defaultStyles3.container, ...customStyles.container }, heading: { ...defaultStyles3.heading, ...customStyles.heading }, message: { ...defaultStyles3.message, ...customStyles.message }, error: { ...defaultStyles3.error, ...customStyles.error }, button: { ...defaultStyles3.button, ...customStyles.button } }; useEffect2(() => { const token = propToken || new URLSearchParams(window.location.search).get("token"); if (token) { verifyToken(token); } else { setStatus("error"); setError(labels.invalid || "Invalid magic link"); } }, [propToken]); const verifyToken = async (token) => { try { const response = await fetch(apiEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); const data = await response.json(); if (!response.ok) { if (data.error?.includes("expired")) { throw new Error(labels.expired); } throw new Error(data.error || labels.error); } setStatus("success"); setSessionToken(data.sessionToken); if (typeof window !== "undefined" && data.sessionToken) { localStorage.setItem("newsletter_session", data.sessionToken); } if (onSuccess) { onSuccess(data.sessionToken, data.subscriber); } } catch (err) { setStatus("error"); const errorMessage = err instanceof Error ? err.message : labels.error || "Verification failed"; setError(errorMessage); if (onError) { onError(err instanceof Error ? err : new Error(errorMessage)); } } }; const handleTryAgain = () => { window.location.href = "/"; }; return /* @__PURE__ */ jsxs3("div", { className, style: styles.container, children: [ status === "verifying" && /* @__PURE__ */ jsxs3(Fragment, { children: [ /* @__PURE__ */ jsx3("h2", { style: styles.heading, children: "Verifying" }), /* @__PURE__ */ jsx3("p", { style: styles.message, children: labels.verifying }) ] }), status === "success" && /* @__PURE__ */ jsxs3(Fragment, { children: [ /* @__PURE__ */ jsx3("h2", { style: styles.heading, children: "Success!" }), /* @__PURE__ */ jsx3("p", { style: styles.message, children: labels.success }) ] }), status === "error" && /* @__PURE__ */ jsxs3(Fragment, { children: [ /* @__PURE__ */ jsx3("h2", { style: styles.heading, children: "Verification Failed" }), /* @__PURE__ */ jsx3("p", { style: styles.error, children: error }), /* @__PURE__ */ jsx3("button", { onClick: handleTryAgain, style: styles.button, children: labels.tryAgain }) ] }) ] }); }; function createMagicLinkVerify(defaultProps) { return (props) => /* @__PURE__ */ jsx3(MagicLinkVerify, { ...defaultProps, ...props }); } // src/hooks/useNewsletterAuth.ts import { useState as useState4, useEffect as useEffect3, useCallback } from "react"; function useNewsletterAuth(_options = {}) { const [subscriber, setSubscriber] = useState4(null); const [isLoading, setIsLoading] = useState4(true); const [error, setError] = useState4(null); const checkAuth = useCallback(async () => { try { const response = await fetch("/api/newsletter/me", { method: "GET", credentials: "include", headers: { "Content-Type": "application/json" } }); if (response.ok) { const data = await response.json(); setSubscriber(data.subscriber); setError(null); } else { setSubscriber(null); if (response.status !== 401) { setError(new Error("Failed to check authentication")); } } } catch (err) { console.error("Auth check failed:", err); setError(err instanceof Error ? err : new Error("An error occurred")); setSubscriber(null); } finally { setIsLoading(false); } }, []); useEffect3(() => { checkAuth(); }, [checkAuth]); const signOut = useCallback(async () => { try { const response = await fetch("/api/newsletter/signout", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" } }); if (response.ok) { setSubscriber(null); setError(null); } else { throw new Error("Failed to sign out"); } } catch (err) { console.error("Sign out error:", err); setError(err instanceof Error ? err : new Error("Sign out failed")); throw err; } }, []); const refreshAuth = useCallback(async () => { setIsLoading(true); await checkAuth(); }, [checkAuth]); const login = useCallback(async (_token) => { await refreshAuth(); }, [refreshAuth]); return { subscriber, isAuthenticated: !!subscriber, isLoading, loading: isLoading, // Alias for backward compatibility error, signOut, logout: signOut, // Alias for backward compatibility refreshAuth, refreshSubscriber: refreshAuth, // Alias for backward compatibility login // For backward compatibility }; } export { MagicLinkVerify, NewsletterForm, PreferencesForm, createMagicLinkVerify, createNewsletterForm, createPreferencesForm, useNewsletterAuth };