diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 5fd9e57..b36bb33 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -37,6 +37,8 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.5", "@types/pg": "^8.15.4", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.4.1", "better-auth": "^1.2.7", "class-variance-authority": "^0.7.1", @@ -3230,6 +3232,39 @@ "@types/react": "^18.0.0" } }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.5.tgz", + "integrity": "sha512-1FRv0cs3ZlBjCNOCpCmKYmt9BYGIJf0J0R3pucOPE88R21rL7jNjXG+I+rN/BVOvYJhI9niRAS/JaSNjiSICxA==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.0.tgz", + "integrity": "sha512-WUm0Jz1xN4DBDGeJIi2Y0kVsolWRB2tsVds4SExaiLg4wBdHFMB+8IfZtBWr+BP0FvhuBr5G1/VLrJ9xzIWHsg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vercel/analytics": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index e68c84f..deac31e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,6 +41,8 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.5", "@types/pg": "^8.15.4", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.4.1", "better-auth": "^1.2.7", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/app/api/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts new file mode 100644 index 0000000..e949ea6 --- /dev/null +++ b/apps/web/src/app/api/waitlist/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { waitlist } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { waitlistRateLimit } from "@/lib/rate-limit"; + +export async function POST(request: NextRequest) { + // Rate limit check + const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1"; + const { success } = await waitlistRateLimit.limit(identifier); + + if (!success) { + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { status: 429 } + ); + } + + try { + const { email } = await request.json(); + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Invalid email format" }, + { status: 400 } + ); + } + + // Check if email already exists + const existingEmail = await db + .select() + .from(waitlist) + .where(eq(waitlist.email, email.toLowerCase())) + .limit(1); + + if (existingEmail.length > 0) { + return NextResponse.json( + { error: "Email already registered" }, + { status: 409 } + ); + } + + // Add to waitlist + await db.insert(waitlist).values({ + id: nanoid(), + email: email.toLowerCase(), + }); + + return NextResponse.json( + { message: "Successfully joined waitlist!" }, + { status: 201 } + ); + } catch (error) { + console.error("Waitlist signup error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index e5f0b86..e5eca8b 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -5,8 +5,53 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { ArrowRight } from "lucide-react"; import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; export function Hero() { + const [email, setEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!email.trim()) { + toast.error("Email required", { + description: "Please enter your email address.", + }); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch("/api/waitlist", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: email.trim() }), + }); + + const data = await response.json(); + + if (response.ok) { + toast.success("Welcome to the waitlist! 🎉"); + setEmail(""); + } else { + toast.error("Oops!", { + description: data.error || "Something went wrong. Please try again.", + }); + } + } catch (error) { + toast.error("Network error", { + description: "Please check your connection and try again.", + }); + } finally { + setIsSubmitting(false); + } + }; + return (