Skip to content

Commit 541034e

Browse files
authored
Merge pull request #269 from maxnorm/add-newletter-sub
Add newletter sub
2 parents a040713 + 2aa586a commit 541034e

18 files changed

Lines changed: 1963 additions & 5 deletions

File tree

website/docusaurus.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,14 @@ const config = {
279279
loading: process.env.GISCUS_LOADING || 'lazy'
280280
},
281281
}),
282+
// Newsletter email collection configuration (Kit API v4)
283+
// See here for more information: https://developers.kit.com/api-reference/overview
284+
...(process.env.NEWSLETTER_API_KEY && {
285+
newsletter: {
286+
isEnabled: true,
287+
apiUrl: process.env.NEWSLETTER_API_URL || 'https://api.kit.com/v4',
288+
},
289+
}),
282290
}),
283291
plugins: [
284292
process.env.POSTHOG_API_KEY && [
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/**
2+
* Netlify Serverless Function to handle newsletter form submissions
3+
*
4+
* This function securely handles Kit API integration by keeping
5+
* the API key on the server side. It validates input, sanitizes data,
6+
* and handles errors gracefully following Netlify best practices.
7+
*
8+
* @see https://docs.netlify.com/functions/overview/
9+
* @see https://developers.kit.com/api-reference/subscribers/create-a-subscriber
10+
*/
11+
12+
/**
13+
* Get the allowed CORS origin based on environment
14+
* @returns {string} The allowed origin (site URL in production, '*' in development)
15+
*/
16+
function getCorsOrigin() {
17+
return process.env.NODE_ENV === 'production'
18+
? process.env.URL || '*'
19+
: '*';
20+
}
21+
22+
/**
23+
* Build headers with CORS for JSON responses
24+
* @param {Object} additionalHeaders - Additional headers to include
25+
* @returns {Object} Headers object with CORS and content type
26+
*/
27+
function buildCorsHeaders(additionalHeaders = {}) {
28+
return {
29+
'Content-Type': 'application/json',
30+
'Access-Control-Allow-Origin': getCorsOrigin(),
31+
...additionalHeaders,
32+
};
33+
}
34+
35+
/**
36+
* Build full CORS headers for preflight responses (no Content-Type)
37+
* @returns {Object} Complete CORS headers object
38+
*/
39+
function buildFullCorsHeaders() {
40+
return {
41+
'Access-Control-Allow-Origin': getCorsOrigin(),
42+
'Access-Control-Allow-Methods': 'POST',
43+
'Access-Control-Allow-Headers': 'Content-Type',
44+
};
45+
}
46+
47+
/**
48+
* Build headers for JSON responses with full CORS support
49+
* @returns {Object} Headers object with Content-Type and full CORS headers
50+
*/
51+
function buildJsonCorsHeaders() {
52+
return {
53+
'Content-Type': 'application/json',
54+
...buildFullCorsHeaders(),
55+
};
56+
}
57+
58+
exports.handler = async (event, context) => {
59+
// Only allow POST requests
60+
if (event.httpMethod !== 'POST') {
61+
return {
62+
statusCode: 405,
63+
headers: buildJsonCorsHeaders(),
64+
body: JSON.stringify({ error: 'Method not allowed' }),
65+
};
66+
}
67+
68+
// Handle CORS preflight
69+
if (event.httpMethod === 'OPTIONS') {
70+
return {
71+
statusCode: 200,
72+
headers: buildFullCorsHeaders(),
73+
body: '',
74+
};
75+
}
76+
77+
try {
78+
// Parse request body
79+
let body;
80+
try {
81+
body = JSON.parse(event.body);
82+
} catch (parseError) {
83+
return {
84+
statusCode: 400,
85+
headers: buildCorsHeaders(),
86+
body: JSON.stringify({ error: 'Invalid JSON in request body' }),
87+
};
88+
}
89+
90+
const { email, firstName, lastName, ...customFields } = body;
91+
92+
// Validate email (RFC 5322 compliant regex)
93+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
94+
if (!email || !emailRegex.test(email)) {
95+
return {
96+
statusCode: 400,
97+
headers: buildCorsHeaders(),
98+
body: JSON.stringify({ error: 'Valid email is required' }),
99+
};
100+
}
101+
102+
// Get configuration from environment variables
103+
const newsletterApiKey = process.env.NEWSLETTER_API_KEY;
104+
const apiUrl = process.env.NEWSLETTER_API_URL || 'https://api.kit.com/v4';
105+
106+
if (!newsletterApiKey) {
107+
console.error('Newsletter API configuration missing:', {
108+
hasApiKey: !!newsletterApiKey,
109+
});
110+
return {
111+
statusCode: 500,
112+
headers: buildCorsHeaders(),
113+
body: JSON.stringify({ error: 'Server configuration error' }),
114+
};
115+
}
116+
117+
// Prepare subscriber data for Kit API v4
118+
// Kit API expects: email_address, first_name, state, fields
119+
const subscriberData = {
120+
email_address: email.trim().toLowerCase(),
121+
...(firstName && { first_name: firstName.trim() }),
122+
state: 'active', // Default to active state
123+
...(Object.keys(customFields).length > 0 && {
124+
fields: Object.entries(customFields).reduce((acc, [key, value]) => {
125+
// Only include non-empty custom fields
126+
if (value !== null && value !== undefined && value !== '') {
127+
acc[key] = String(value);
128+
}
129+
return acc;
130+
}, {}),
131+
}),
132+
};
133+
134+
// Call Kit API with timeout
135+
// Kit API requires X-Kit-Api-Key header
136+
const controller = new AbortController();
137+
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
138+
139+
try {
140+
const kitUrl = `${apiUrl}/subscribers`;
141+
const kitResponse = await fetch(kitUrl, {
142+
method: 'POST',
143+
headers: {
144+
'Content-Type': 'application/json',
145+
'X-Kit-Api-Key': newsletterApiKey,
146+
},
147+
body: JSON.stringify(subscriberData),
148+
signal: controller.signal,
149+
});
150+
151+
clearTimeout(timeoutId);
152+
153+
const kitData = await kitResponse.json();
154+
155+
if (!kitResponse.ok) {
156+
console.error('Kit API error:', {
157+
status: kitResponse.status,
158+
statusText: kitResponse.statusText,
159+
data: kitData,
160+
});
161+
162+
// Don't expose internal API errors to client
163+
const errorMessage = kitData.message || 'Failed to subscribe. Please try again.';
164+
165+
return {
166+
statusCode: kitResponse.status >= 400 && kitResponse.status < 500
167+
? kitResponse.status
168+
: 500,
169+
headers: buildCorsHeaders(),
170+
body: JSON.stringify({
171+
error: errorMessage,
172+
}),
173+
};
174+
}
175+
176+
// Success response
177+
return {
178+
statusCode: 200,
179+
headers: buildJsonCorsHeaders(),
180+
body: JSON.stringify({
181+
success: true,
182+
message: 'Successfully subscribed!',
183+
data: kitData,
184+
}),
185+
};
186+
187+
} catch (fetchError) {
188+
clearTimeout(timeoutId);
189+
190+
if (fetchError.name === 'AbortError') {
191+
console.error('Request timeout to Kit API');
192+
return {
193+
statusCode: 504,
194+
headers: buildCorsHeaders(),
195+
body: JSON.stringify({
196+
error: 'Request timeout. Please try again.',
197+
}),
198+
};
199+
}
200+
201+
throw fetchError; // Re-throw to be caught by outer catch
202+
}
203+
204+
} catch (error) {
205+
console.error('Function error:', {
206+
message: error.message,
207+
stack: error.stack,
208+
});
209+
210+
return {
211+
statusCode: 500,
212+
headers: buildCorsHeaders(),
213+
body: JSON.stringify({
214+
error: 'Internal server error',
215+
message: process.env.NODE_ENV === 'development' ? error.message : undefined,
216+
}),
217+
};
218+
}
219+
};

website/package-lock.json

Lines changed: 29 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

website/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"prism-react-renderer": "^2.3.0",
2727
"react": "^19.0.0",
2828
"react-dom": "^19.0.0",
29+
"react-hot-toast": "^2.4.1",
2930
"three": "^0.181.2"
3031
},
3132
"devDependencies": {

0 commit comments

Comments
 (0)