diff options
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/package-lock.json | 35 | ||||
| -rw-r--r-- | apps/web/package.json | 2 | ||||
| -rw-r--r-- | apps/web/src/app/api/waitlist/route.ts | 67 | ||||
| -rw-r--r-- | apps/web/src/components/landing/hero.tsx | 61 | ||||
| -rw-r--r-- | apps/web/src/lib/db/schema.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/rate-limit.ts | 14 |
6 files changed, 184 insertions, 3 deletions
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 5fd9e57d..b36bb337 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 e68c84fd..deac31e0 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 00000000..e949ea65 --- /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 e5f0b867..e5eca8b6 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 (
<div className="relative min-h-screen flex flex-col items-center justify-center text-center px-4">
<motion.div
@@ -45,15 +90,25 @@ export function Hero() { animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }}
>
- <form className="flex gap-3 w-full max-w-lg">
+ <form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
<Input
type="email"
placeholder="Enter your email"
className="h-11 text-base flex-1"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ disabled={isSubmitting}
required
/>
- <Button type="submit" size="lg" className="px-6 h-11 text-base">
- <span className="relative z-10">Join waitlist</span>
+ <Button
+ type="submit"
+ size="lg"
+ className="px-6 h-11 text-base"
+ disabled={isSubmitting}
+ >
+ <span className="relative z-10">
+ {isSubmitting ? "Joining..." : "Join waitlist"}
+ </span>
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
</Button>
</form>
diff --git a/apps/web/src/lib/db/schema.ts b/apps/web/src/lib/db/schema.ts index 02df5d4a..7c38724a 100644 --- a/apps/web/src/lib/db/schema.ts +++ b/apps/web/src/lib/db/schema.ts @@ -59,3 +59,11 @@ export const verifications = pgTable("verifications", { () => /* @__PURE__ */ new Date()
),
}).enableRLS();
+
+export const waitlist = pgTable("waitlist", {
+ id: text("id").primaryKey(),
+ email: text("email").notNull().unique(),
+ createdAt: timestamp("created_at")
+ .$defaultFn(() => /* @__PURE__ */ new Date())
+ .notNull(),
+}).enableRLS();
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts new file mode 100644 index 00000000..bf1cce02 --- /dev/null +++ b/apps/web/src/lib/rate-limit.ts @@ -0,0 +1,14 @@ +// lib/rate-limit.ts
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+
+const redis = new Redis({
+ url: process.env.UPSTASH_REDIS_REST_URL!,
+ token: process.env.UPSTASH_REDIS_REST_TOKEN!,
+});
+
+export const waitlistRateLimit = new Ratelimit({
+ redis,
+ limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute
+ analytics: true,
+});
|
