Skip to content

Commit 488a251

Browse files
committed
feat(landing):add sdk waitlist
1 parent 1195fd0 commit 488a251

File tree

7 files changed

+1081
-1
lines changed

7 files changed

+1081
-1
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { z } from 'zod';
3+
import { addToWaitlist, getWaitlistCount, checkEmailInWaitlist } from '@/lib/db/queries';
4+
5+
const joinWaitlistSchema = z.object({
6+
email: z.string().email(),
7+
});
8+
9+
export async function POST(request: NextRequest) {
10+
try {
11+
const body = await request.json();
12+
const { email } = joinWaitlistSchema.parse(body);
13+
14+
// Check if email already exists
15+
const alreadyExists = await checkEmailInWaitlist({ email });
16+
if (alreadyExists) {
17+
return NextResponse.json(
18+
{ error: 'Email already on waitlist' },
19+
{ status: 400 }
20+
);
21+
}
22+
23+
await addToWaitlist({ email });
24+
return NextResponse.json({ success: true });
25+
} catch (error) {
26+
if (error instanceof z.ZodError) {
27+
return NextResponse.json(
28+
{ error: 'Invalid email address' },
29+
{ status: 400 }
30+
);
31+
}
32+
console.error('[Waitlist API] Error:', error);
33+
return NextResponse.json(
34+
{ error: 'Failed to join waitlist' },
35+
{ status: 500 }
36+
);
37+
}
38+
}
39+
40+
export async function GET() {
41+
try {
42+
const count = await getWaitlistCount();
43+
return NextResponse.json({ count });
44+
} catch (error) {
45+
console.error('[Waitlist API] Error getting count:', error);
46+
return NextResponse.json(
47+
{ error: 'Failed to get waitlist count', count: 0 },
48+
{ status: 500 }
49+
);
50+
}
51+
}
52+

apps/saru/app/sdk/page.tsx

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { ChevronRight } from "lucide-react";
5+
import { toast } from "sonner";
6+
import useSWR from "swr";
7+
import { z } from "zod";
8+
9+
import { Button } from "@/components/ui/button";
10+
import { Input } from "@/components/ui/input";
11+
import { AnimatePresence, motion } from "framer-motion";
12+
13+
const formSchema = z.object({
14+
email: z.string().email(),
15+
});
16+
17+
const fetcher = (url: string) => fetch(url).then((res) => res.json());
18+
19+
type FormSchema = z.infer<typeof formSchema>;
20+
21+
function useWaitlistCount() {
22+
const { data, mutate } = useSWR<{ count: number }>(
23+
"/api/waitlist",
24+
fetcher,
25+
{
26+
revalidateOnFocus: false,
27+
},
28+
);
29+
30+
return { count: data?.count ?? 0, mutate } as const;
31+
}
32+
33+
export default function SDKPage() {
34+
const [email, setEmail] = useState("");
35+
const [isSubmitting, setIsSubmitting] = useState(false);
36+
const [success, setSuccess] = useState(false);
37+
const waitlist = useWaitlistCount();
38+
39+
async function joinWaitlist(e: React.FormEvent<HTMLFormElement>) {
40+
e.preventDefault();
41+
42+
const validation = formSchema.safeParse({ email });
43+
if (!validation.success) {
44+
toast.error("Please enter a valid email address");
45+
return;
46+
}
47+
48+
setIsSubmitting(true);
49+
50+
try {
51+
const response = await fetch("/api/waitlist", {
52+
method: "POST",
53+
headers: {
54+
"Content-Type": "application/json",
55+
},
56+
body: JSON.stringify({ email }),
57+
});
58+
59+
const data = await response.json();
60+
if (!response.ok) throw new Error(data.error || "Something went wrong");
61+
62+
setSuccess(true);
63+
setEmail("");
64+
waitlist.mutate({ count: waitlist.count + 1 }, false);
65+
} catch (error) {
66+
const msg =
67+
error instanceof Error ? error.message : "Something went wrong. Please try again.";
68+
toast.error(msg);
69+
} finally {
70+
setIsSubmitting(false);
71+
}
72+
}
73+
74+
return (
75+
<main className="flex min-h-screen flex-col items-center justify-center px-6 py-16 md:px-8 lg:px-12">
76+
<div className="flex w-full max-w-3xl flex-col items-center gap-6 text-center">
77+
<header className="space-y-2">
78+
<p className="text-lg text-muted-foreground">
79+
Join the waitlist to get early access to the SDK for <i>AI-Native writing interfaces</i>.
80+
</p>
81+
</header>
82+
83+
<AnimatePresence mode="wait" initial={false}>
84+
{success ? (
85+
<motion.div
86+
key="success"
87+
animate={{ opacity: 1, y: 0 }}
88+
exit={{ opacity: 0, y: -12 }}
89+
transition={{ duration: 0.25, ease: "easeOut" }}
90+
className="flex h-11 w-full items-center justify-center"
91+
>
92+
<p className="text-sm font-medium text-muted-foreground">
93+
You're in! We'll keep you posted.
94+
</p>
95+
</motion.div>
96+
) : (
97+
<motion.form
98+
key="form"
99+
onSubmit={joinWaitlist}
100+
animate={{ opacity: 1, y: 0 }}
101+
exit={{ opacity: 0, y: -12 }}
102+
transition={{ duration: 0.25, ease: "easeOut" }}
103+
className="flex w-full max-w-lg flex-col gap-3 sm:flex-row"
104+
>
105+
<Input
106+
type="email"
107+
placeholder="example@email.com"
108+
value={email}
109+
onChange={(e) => setEmail(e.target.value)}
110+
className="h-11 w-full rounded-md px-4 text-base font-medium placeholder:font-medium placeholder:text-muted-foreground md:text-base"
111+
disabled={isSubmitting}
112+
required
113+
/>
114+
<Button
115+
variant="outline"
116+
type="submit"
117+
className="h-11 w-full pl-4 pr-3 text-base sm:w-fit"
118+
disabled={isSubmitting}
119+
>
120+
Join Waitlist <ChevronRight className="ml-1 h-5 w-5" />
121+
</Button>
122+
</motion.form>
123+
)}
124+
</AnimatePresence>
125+
126+
<div className="relative flex flex-row items-center justify-center gap-2">
127+
<span className="size-2 rounded-full bg-green-600 dark:bg-green-400" />
128+
<span className="absolute left-0 size-2 rounded-full bg-green-600 blur-xs dark:bg-green-400" />
129+
<span className="text-sm text-green-600 dark:text-green-400 sm:text-base">
130+
{waitlist.count.toLocaleString()} people already joined
131+
</span>
132+
</div>
133+
</div>
134+
</main>
135+
);
136+
}

apps/saru/lib/db/queries.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,3 +1224,41 @@ export async function deleteMessageById({
12241224
throw error;
12251225
}
12261226
}
1227+
1228+
export async function addToWaitlist({ email }: { email: string }): Promise<void> {
1229+
try {
1230+
await db.insert(schema.waitlist).values({
1231+
email,
1232+
createdAt: new Date(),
1233+
});
1234+
} catch (error) {
1235+
console.error('Error adding to waitlist:', error);
1236+
throw error;
1237+
}
1238+
}
1239+
1240+
export async function getWaitlistCount(): Promise<number> {
1241+
try {
1242+
const [{ count }] = await db
1243+
.select({ count: sql<number>`count(*)` })
1244+
.from(schema.waitlist);
1245+
return Number(count);
1246+
} catch (error) {
1247+
console.error('Error getting waitlist count:', error);
1248+
return 0;
1249+
}
1250+
}
1251+
1252+
export async function checkEmailInWaitlist({ email }: { email: string }): Promise<boolean> {
1253+
try {
1254+
const result = await db
1255+
.select({ id: schema.waitlist.id })
1256+
.from(schema.waitlist)
1257+
.where(eq(schema.waitlist.email, email))
1258+
.limit(1);
1259+
return result.length > 0;
1260+
} catch (error) {
1261+
console.error('Error checking email in waitlist:', error);
1262+
return false;
1263+
}
1264+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CREATE TABLE IF NOT EXISTS "waitlist" (
2+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3+
"email" text NOT NULL,
4+
"created_at" timestamp DEFAULT now() NOT NULL,
5+
CONSTRAINT "waitlist_email_unique" UNIQUE("email")
6+
);

0 commit comments

Comments
 (0)