Skip to content

Commit 1aa5de5

Browse files
committed
Tweaks to Footer, Header, and Hero components
1 parent b5bcf7d commit 1aa5de5

10 files changed

Lines changed: 511 additions & 201 deletions

File tree

api/newsletter/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Vercel API function for newsletter signup - Entry point
2+
import handler from './newsletter';
3+
4+
// Export the default handler for Vercel Functions
5+
export default handler;

api/newsletter/newsletter.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Vercel API function for ConvertKit newsletter subscription
2+
3+
// Types
4+
interface NewsletterFormData {
5+
email: string;
6+
firstName?: string;
7+
}
8+
9+
interface ConvertKitSubscriber {
10+
email_address: string;
11+
first_name?: string;
12+
state?: 'active' | 'inactive';
13+
fields?: Record<string, string>;
14+
}
15+
16+
interface ConvertKitResponse {
17+
subscriber: {
18+
id: number;
19+
first_name: string | null;
20+
email_address: string;
21+
state: string;
22+
created_at: string;
23+
fields: Record<string, string>;
24+
};
25+
}
26+
27+
interface ConvertKitErrorResponse {
28+
errors: string[];
29+
}
30+
31+
// Simple in-memory rate limiting (use Redis in production)
32+
const rateLimitStore = new Map<string, number[]>();
33+
34+
/**
35+
* Check if the IP address has exceeded the rate limit
36+
* @param ip - Client IP address
37+
* @returns true if within rate limit, false if exceeded
38+
*/
39+
function checkRateLimit(ip: string): boolean {
40+
const now = Date.now();
41+
const windowMs = 15 * 60 * 1000; // 15 minutes
42+
const maxRequests = 10; // More lenient for newsletter signups
43+
const key = `newsletter_rate_limit_${ip}`;
44+
const requests = rateLimitStore.get(key) || [];
45+
46+
// Clean old requests
47+
const validRequests = requests.filter(timestamp => now - timestamp < windowMs);
48+
49+
if (validRequests.length >= maxRequests) {
50+
return false;
51+
}
52+
53+
validRequests.push(now);
54+
rateLimitStore.set(key, validRequests);
55+
return true;
56+
}
57+
58+
/**
59+
* Validate email address format
60+
* @param email - Email address to validate
61+
* @returns Validated and normalized email address
62+
*/
63+
function validateEmail(email: string): string {
64+
if (!email) {
65+
throw new Error('Email address is required.');
66+
}
67+
68+
// Email validation - same pattern as client-side
69+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
70+
if (!emailRegex.test(email)) {
71+
throw new Error('Email address is invalid');
72+
}
73+
74+
return email.trim().toLowerCase();
75+
}
76+
77+
/**
78+
* Subscribe email to ConvertKit
79+
* @param data - Newsletter form data
80+
* @returns ConvertKit API response
81+
*/
82+
async function subscribeToConvertKit(data: NewsletterFormData): Promise<ConvertKitResponse> {
83+
const apiKey = process.env['CONVERTKIT_API_KEY'];
84+
85+
if (!apiKey) {
86+
throw new Error('ConvertKit API key is not configured.');
87+
}
88+
89+
/* eslint-disable camelcase */
90+
// ConvertKit API requires snake_case property names
91+
const subscriberData: ConvertKitSubscriber = {
92+
email_address: data.email,
93+
state: 'active',
94+
};
95+
96+
// Add first name if provided
97+
if (data.firstName) {
98+
subscriberData.first_name = data.firstName.trim();
99+
}
100+
/* eslint-enable camelcase */
101+
102+
try {
103+
const response = await fetch('https://api.kit.com/v4/subscribers', {
104+
method: 'POST',
105+
headers: {
106+
'Content-Type': 'application/json',
107+
'X-Kit-Api-Key': apiKey,
108+
},
109+
body: JSON.stringify(subscriberData),
110+
});
111+
112+
const responseData = await response.json();
113+
114+
// Handle different response codes
115+
if (response.status === 401) {
116+
const errorData = responseData as ConvertKitErrorResponse;
117+
console.error('ConvertKit API authentication failed:', errorData.errors);
118+
throw new Error('Newsletter service configuration error. Please contact support.');
119+
}
120+
121+
if (response.status === 422) {
122+
const errorData = responseData as ConvertKitErrorResponse;
123+
throw new Error(errorData.errors[0] || 'Invalid email address');
124+
}
125+
126+
// Success: 200 (updated), 201 (created), 202 (accepted)
127+
if (response.status === 200 || response.status === 201 || response.status === 202) {
128+
return responseData as ConvertKitResponse;
129+
}
130+
131+
// Unexpected response
132+
throw new Error('An unexpected error occurred. Please try again later.');
133+
} catch (error) {
134+
if (error instanceof Error) {
135+
throw error;
136+
}
137+
throw new Error('Failed to connect to newsletter service. Please try again later.');
138+
}
139+
}
140+
141+
/**
142+
* Main API handler for newsletter subscriptions
143+
* @param req - Vercel request object
144+
* @param res - Vercel response object
145+
*/
146+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
147+
export default async function handler(req: any, res: any): Promise<void> {
148+
// CORS headers
149+
res.setHeader('Access-Control-Allow-Origin', '*');
150+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
151+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
152+
153+
// Handle OPTIONS for CORS preflight
154+
if (req.method === 'OPTIONS') {
155+
return res.status(200).end();
156+
}
157+
158+
// Only allow POST
159+
if (req.method !== 'POST') {
160+
return res.status(405).json({
161+
success: false,
162+
error: 'Method not allowed',
163+
});
164+
}
165+
166+
try {
167+
// Get client IP for rate limiting
168+
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0] ||
169+
req.socket.remoteAddress ||
170+
'unknown';
171+
172+
// Check rate limit
173+
if (!checkRateLimit(ip)) {
174+
return res.status(429).json({
175+
success: false,
176+
error: 'Too many subscription requests. Please try again later.',
177+
});
178+
}
179+
180+
// Parse and validate input
181+
const { email, firstName } = req.body as NewsletterFormData;
182+
const validatedEmail = validateEmail(email);
183+
184+
// Subscribe to ConvertKit
185+
const result = await subscribeToConvertKit({
186+
email: validatedEmail,
187+
...(firstName && { firstName }),
188+
});
189+
190+
// Return success response
191+
return res.status(200).json({
192+
success: true,
193+
message: result.subscriber.id
194+
? 'Successfully subscribed to newsletter!'
195+
: 'Thank you for subscribing!',
196+
subscriber: {
197+
email: result.subscriber.email_address,
198+
firstName: result.subscriber.first_name,
199+
},
200+
});
201+
202+
} catch (error) {
203+
console.error('Newsletter subscription error:', error);
204+
205+
// Return user-friendly error
206+
const errorMessage = error instanceof Error
207+
? error.message
208+
: 'An unexpected error occurred. Please try again.';
209+
210+
return res.status(400).json({
211+
success: false,
212+
error: errorMessage,
213+
});
214+
}
215+
}

src/components/CallToActions/Newsletter/index.astro

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22
/**
33
* Newsletter Call-to-Action Component
44
*
5-
* An email subscription form with attractive styling and theme-aware colors.
6-
* Encourages visitors to subscribe to updates and newsletters.
5+
* A ConvertKit newsletter subscription form with client-side validation
6+
* and attractive theme-aware styling.
77
*
88
* @example
99
* ```astro
1010
* <Newsletter />
1111
* <Newsletter
1212
* title="Join Our Community"
1313
* description="Get weekly insights on web development, design, and digital strategy."
14-
* formAction="/api/newsletter"
1514
* />
1615
* ```
1716
*/
@@ -25,16 +24,13 @@ export interface Props {
2524
placeholder?: string
2625
/** Submit button text */
2726
buttonText?: string
28-
/** Form submission endpoint */
29-
formAction?: string
3027
}
3128
3229
const {
3330
title = "Stay Updated",
3431
description = "Subscribe to our newsletter for the latest insights on web development and digital solutions.",
3532
placeholder = "Enter your email address",
36-
buttonText = "Subscribe",
37-
formAction = "/api/newsletter"
33+
buttonText = "Subscribe"
3834
} = Astro.props
3935
---
4036

@@ -56,31 +52,51 @@ const {
5652
</p>
5753
</div>
5854

59-
<form class="max-w-xl mx-auto" method="POST" action={formAction}>
55+
<form id="newsletter-form" class="max-w-xl mx-auto">
6056
<div class="flex flex-col sm:flex-row gap-3">
6157
<input
6258
type="email"
59+
id="newsletter-email"
6360
name="email"
6461
placeholder={placeholder}
6562
class="flex-1 px-6 py-4 border-2 border-[var(--color-border)] rounded-xl bg-[var(--color-bg)] text-[var(--color-text)] placeholder:text-[var(--color-text-offset)] focus:border-[var(--color-primary)] focus:ring-0 focus:outline-none transition-colors duration-200"
6663
required
6764
aria-label="Email address for newsletter subscription"
65+
aria-describedby="newsletter-message"
6866
/>
6967
<button
7068
type="submit"
71-
class="px-8 py-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-semibold rounded-xl transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 whitespace-nowrap"
69+
id="newsletter-submit"
70+
class="px-8 py-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-semibold rounded-xl transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
7271
>
73-
{buttonText}
74-
<svg class="inline-block w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
72+
<span id="button-text">{buttonText}</span>
73+
<svg id="button-arrow" class="inline-block w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
7574
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
7675
</svg>
76+
<svg id="button-spinner" class="hidden w-5 h-5 ml-2 animate-spin" fill="none" viewBox="0 0 24 24">
77+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
78+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
79+
</svg>
7780
</button>
7881
</div>
79-
<p class="mt-4 text-sm text-[var(--color-text-offset)] text-center">
82+
<p
83+
id="newsletter-message"
84+
class="mt-4 text-sm text-[var(--color-text-offset)] text-center"
85+
role="status"
86+
aria-live="polite"
87+
>
8088
We respect your privacy. Unsubscribe at any time.
8189
</p>
8290
</form>
8391
</div>
8492
</div>
8593
</div>
8694
</section>
95+
96+
<script>
97+
import { initNewsletterForm } from './newsletterForm';
98+
99+
document.addEventListener('DOMContentLoaded', function() {
100+
initNewsletterForm();
101+
});
102+
</script>

0 commit comments

Comments
 (0)