Skip to content

Commit b653ef4

Browse files
thedaviddiasclaude
andauthored
feat: add newsletter component and GitHub stars to UP Kit (#225)
Co-authored-by: Claude <[email protected]>
1 parent 072a8be commit b653ef4

File tree

17 files changed

+773
-54
lines changed

17 files changed

+773
-54
lines changed

apps/kit/app/(home)/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
Zap,
1818
} from "lucide-react";
1919
import Link from "next/link";
20+
import { NewsletterForm } from "@/components/newsletter";
2021

2122
export default function HomePage() {
2223
return (
@@ -143,6 +144,11 @@ export default function HomePage() {
143144
</div>
144145
</section>
145146

147+
{/* Newsletter Section */}
148+
<section className="container mx-auto px-4 py-16">
149+
<NewsletterForm />
150+
</section>
151+
146152
{/* Getting Started */}
147153
<section className="bg-muted/50 py-16">
148154
<div className="container mx-auto px-4">
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import { NextResponse } from "next/server";
2+
import { z } from "zod";
3+
4+
const subscribeSchema = z.object({
5+
email: z
6+
.string()
7+
.trim()
8+
.min(1, "Email is required")
9+
.email("Invalid email address"),
10+
groups: z.array(z.string().trim()).optional(),
11+
honeypot: z.string().optional(), // Hidden field for bot detection
12+
timestamp: z.number().optional(), // Deprecated - kept for backwards compatibility
13+
});
14+
15+
// Simple in-memory rate limiter (consider using Redis/Upstash in production)
16+
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
17+
18+
// Clean up expired entries every 5 minutes
19+
setInterval(
20+
() => {
21+
const now = Date.now();
22+
const keysToDelete: string[] = [];
23+
rateLimitMap.forEach((value, key) => {
24+
if (value.resetTime < now) {
25+
keysToDelete.push(key);
26+
}
27+
});
28+
keysToDelete.forEach((key) => {
29+
rateLimitMap.delete(key);
30+
});
31+
},
32+
5 * 60 * 1000,
33+
);
34+
35+
function getRateLimitKey(request: Request): string {
36+
// Get client IP from headers (works with Vercel/Cloudflare)
37+
const forwardedFor = request.headers.get("x-forwarded-for");
38+
const realIp = request.headers.get("x-real-ip");
39+
const cfConnectingIp = request.headers.get("cf-connecting-ip");
40+
41+
const clientIp =
42+
forwardedFor?.split(",")[0] || realIp || cfConnectingIp || "unknown";
43+
return `newsletter:${clientIp}`;
44+
}
45+
46+
function checkRateLimit(
47+
key: string,
48+
maxRequests: number = 3,
49+
windowMs: number = 60000,
50+
): boolean {
51+
const now = Date.now();
52+
const record = rateLimitMap.get(key);
53+
54+
if (!record || record.resetTime < now) {
55+
// Create new record or reset expired one
56+
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs });
57+
return true;
58+
}
59+
60+
if (record.count >= maxRequests) {
61+
return false; // Rate limit exceeded
62+
}
63+
64+
record.count++;
65+
return true;
66+
}
67+
68+
const API_BASE = "https://connect.mailerlite.com/api";
69+
70+
interface MailerliteSubscriber {
71+
email: string;
72+
groups?: string[];
73+
}
74+
75+
interface MailerliteErrorResponse {
76+
errors?: {
77+
email?: string[];
78+
[key: string]: string[] | undefined;
79+
};
80+
message?: string;
81+
}
82+
83+
interface MailerliteSuccessResponse {
84+
data?: {
85+
id: string;
86+
email: string;
87+
status: string;
88+
created_at: string;
89+
updated_at: string;
90+
};
91+
}
92+
93+
export async function POST(request: Request) {
94+
try {
95+
const body = await request.json();
96+
const validationResult = subscribeSchema.safeParse(body);
97+
98+
if (!validationResult.success) {
99+
return NextResponse.json(
100+
{ success: false, message: "Invalid email address" },
101+
{ status: 400 },
102+
);
103+
}
104+
105+
const { email, groups, honeypot } = validationResult.data;
106+
107+
// Bot detection: if honeypot field is filled, reject the request
108+
if (honeypot && honeypot.trim() !== "") {
109+
return NextResponse.json(
110+
{ success: false, message: "Invalid submission detected." },
111+
{ status: 400 },
112+
);
113+
}
114+
115+
// Server-side rate limiting based on IP address
116+
const rateLimitKey = getRateLimitKey(request);
117+
if (!checkRateLimit(rateLimitKey)) {
118+
return NextResponse.json(
119+
{
120+
success: false,
121+
message: "Too many requests. Please try again in a minute.",
122+
},
123+
{ status: 429 },
124+
);
125+
}
126+
127+
if (!process.env.MAILERLITE_API_KEY) {
128+
console.error("MAILERLITE_API_KEY is not configured");
129+
return NextResponse.json(
130+
{
131+
success: false,
132+
message: "Newsletter service not configured. Please try again later.",
133+
},
134+
{ status: 503 },
135+
);
136+
}
137+
138+
const requestBody: MailerliteSubscriber = { email };
139+
140+
// Use default group IDs from environment or provided groups
141+
const envGroupIds = process.env.MAILERLITE_GROUP_IDS?.split(",")
142+
.map((id) => id.trim())
143+
.filter((v, i, a) => a.indexOf(v) === i)
144+
.filter(Boolean);
145+
const groupIds =
146+
Array.isArray(groups) && groups.length > 0 ? groups : envGroupIds;
147+
if (groupIds && groupIds.length > 0) {
148+
requestBody.groups = groupIds;
149+
}
150+
151+
const controller = new AbortController();
152+
const timeout = setTimeout(() => controller.abort(), 10_000);
153+
154+
let response: Response;
155+
try {
156+
response = await fetch(`${API_BASE}/subscribers`, {
157+
method: "POST",
158+
headers: {
159+
"Content-Type": "application/json",
160+
Accept: "application/json",
161+
Authorization: `Bearer ${process.env.MAILERLITE_API_KEY}`,
162+
},
163+
body: JSON.stringify(requestBody),
164+
signal: controller.signal,
165+
});
166+
} catch (error) {
167+
clearTimeout(timeout);
168+
169+
// Handle abort/timeout specifically
170+
if (error instanceof Error && error.name === "AbortError") {
171+
return NextResponse.json(
172+
{ success: false, message: "Request timeout. Please try again." },
173+
{ status: 504 },
174+
);
175+
}
176+
177+
// Re-throw other errors to be handled by outer catch
178+
throw error;
179+
}
180+
181+
clearTimeout(timeout);
182+
183+
// Check if it's a successful response (200 or 201)
184+
if (response.ok) {
185+
const result = (await response.json()) as MailerliteSuccessResponse;
186+
187+
return NextResponse.json(
188+
{
189+
success: true,
190+
message:
191+
"Successfully subscribed to the newsletter! Check your email for confirmation.",
192+
subscriber: {
193+
id: result.data?.id || "",
194+
email: result.data?.email || email,
195+
status: result.data?.status || "active",
196+
createdAt: result.data?.created_at || new Date().toISOString(),
197+
updatedAt: result.data?.updated_at || new Date().toISOString(),
198+
},
199+
},
200+
{ status: response.status },
201+
);
202+
}
203+
204+
// Handle error responses
205+
const errorText = await response.text();
206+
let errorMessage = "Failed to subscribe to newsletter. Please try again.";
207+
208+
if (response.status === 422) {
209+
try {
210+
const errorData = JSON.parse(errorText) as MailerliteErrorResponse;
211+
212+
// Check for specific error messages
213+
const emailErrors = errorData.errors?.email;
214+
if (emailErrors && emailErrors.length > 0 && emailErrors[0]) {
215+
const firstError = emailErrors[0];
216+
const errorString = firstError.toLowerCase();
217+
218+
if (errorString.includes("unsubscribed")) {
219+
errorMessage =
220+
"This email was previously unsubscribed. Please contact support to reactivate.";
221+
} else if (
222+
errorString.includes("already exists") ||
223+
errorString.includes("already subscribed")
224+
) {
225+
errorMessage =
226+
"You're already subscribed! Check your inbox for our newsletters.";
227+
} else if (errorString.includes("invalid")) {
228+
errorMessage = "Please enter a valid email address.";
229+
} else {
230+
errorMessage = firstError; // Use the actual error message from API
231+
}
232+
}
233+
} catch (parseError) {
234+
console.error("Failed to parse Mailerlite error:", parseError);
235+
}
236+
} else if (response.status === 409) {
237+
errorMessage =
238+
"You're already subscribed! Check your inbox for our newsletters.";
239+
} else if (response.status === 401) {
240+
console.error("Invalid Mailerlite API key");
241+
errorMessage =
242+
"Newsletter service configuration error. Please contact support.";
243+
} else if (response.status === 429) {
244+
errorMessage = "Too many requests. Please try again in a few minutes.";
245+
}
246+
247+
try {
248+
const parsed = JSON.parse(errorText);
249+
console.error("Mailerlite API error:", {
250+
status: response.status,
251+
message: parsed?.message,
252+
errors: parsed?.errors ? Object.keys(parsed.errors) : undefined,
253+
});
254+
} catch {
255+
console.error("Mailerlite API error (unparsed):", {
256+
status: response.status,
257+
});
258+
}
259+
260+
return NextResponse.json(
261+
{ success: false, message: errorMessage },
262+
{ status: response.status === 422 ? 400 : response.status },
263+
);
264+
} catch (error) {
265+
console.error("Newsletter subscription error:", error);
266+
267+
// Network/abort errors
268+
if ((error as Error).name === "AbortError") {
269+
return NextResponse.json(
270+
{
271+
success: false,
272+
message: "Newsletter service timed out. Please try again.",
273+
},
274+
{ status: 504 },
275+
);
276+
}
277+
if (error instanceof TypeError) {
278+
return NextResponse.json(
279+
{
280+
success: false,
281+
message: "Network error. Please check your connection and try again.",
282+
},
283+
{ status: 503 },
284+
);
285+
}
286+
287+
return NextResponse.json(
288+
{
289+
success: false,
290+
message: "An unexpected error occurred. Please try again later.",
291+
},
292+
{ status: 500 },
293+
);
294+
}
295+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { unstable_cache } from "next/cache";
2+
3+
interface GitHubRepoResponse {
4+
stargazers_count: number;
5+
full_name: string;
6+
}
7+
8+
const fetchGitHubStars = async (): Promise<number> => {
9+
try {
10+
const response = await fetch(
11+
"https://api.github.com/repos/thedaviddias/ux-patterns-for-developers",
12+
{
13+
headers: {
14+
Accept: "application/vnd.github.v3+json",
15+
"X-GitHub-Api-Version": "2022-11-28",
16+
"User-Agent": "UX-Patterns-for-Developers",
17+
...(process.env.GITHUB_TOKEN
18+
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
19+
: {}),
20+
},
21+
signal: AbortSignal.timeout(5000),
22+
},
23+
);
24+
25+
if (!response.ok) {
26+
throw new Error(`GitHub API error: ${response.status}`);
27+
}
28+
29+
const data: GitHubRepoResponse = await response.json();
30+
return data.stargazers_count;
31+
} catch (error) {
32+
console.error("Failed to fetch GitHub stars:", error);
33+
throw error;
34+
}
35+
};
36+
37+
// Cache the GitHub stars fetch for 1 hour to avoid rate limits
38+
export const getGitHubStars = unstable_cache(
39+
fetchGitHubStars,
40+
["github-stars"],
41+
{
42+
revalidate: 3600, // 1 hour
43+
tags: ["github-stars"],
44+
},
45+
);

0 commit comments

Comments
 (0)