Skip to content

Commit 521ede2

Browse files
authored
PTB Waitlist (#4714)
1 parent e45eede commit 521ede2

File tree

20 files changed

+2653
-152
lines changed

20 files changed

+2653
-152
lines changed
Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
"use client";
2+
3+
import { useState, useEffect, useRef } from "react";
4+
import { CheckIcon } from "@heroicons/react/24/outline";
5+
import { Button } from "@/components/ui/button";
6+
7+
interface CreditsWaitlistFormProps {
8+
variant?: "inline" | "card";
9+
className?: string;
10+
initialCount?: number | null;
11+
}
12+
13+
export function CreditsWaitlistForm({
14+
variant = "card",
15+
className = "",
16+
initialCount,
17+
}: CreditsWaitlistFormProps) {
18+
const [email, setEmail] = useState("");
19+
const [isLoading, setIsLoading] = useState(false);
20+
const [isSuccess, setIsSuccess] = useState(false);
21+
const [error, setError] = useState<string | null>(null);
22+
const [position, setPosition] = useState<number | null>(null);
23+
const [hasSharedTwitter, setHasSharedTwitter] = useState(false);
24+
const [hasSharedLinkedIn, setHasSharedLinkedIn] = useState(false);
25+
const [pendingShareTwitter, setPendingShareTwitter] = useState(false);
26+
const [pendingShareLinkedIn, setPendingShareLinkedIn] = useState(false);
27+
const [isReturningUser, setIsReturningUser] = useState(false);
28+
29+
// Ref to store interval ID for cleanup
30+
const windowCheckInterval = useRef<NodeJS.Timeout>();
31+
32+
// Load email from localStorage on mount
33+
useEffect(() => {
34+
const savedEmail = localStorage.getItem('waitlist_email');
35+
if (savedEmail) {
36+
setEmail(savedEmail);
37+
}
38+
}, []);
39+
40+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.helicone.ai";
41+
42+
// Cleanup interval on unmount
43+
useEffect(() => {
44+
return () => {
45+
if (windowCheckInterval.current) {
46+
clearInterval(windowCheckInterval.current);
47+
}
48+
};
49+
}, []);
50+
51+
const handleSubmit = async (e: React.FormEvent) => {
52+
e.preventDefault();
53+
setError(null);
54+
55+
if (!email) {
56+
setError("Please enter your email address");
57+
return;
58+
}
59+
60+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
61+
if (!emailRegex.test(email)) {
62+
setError("Please enter a valid email address");
63+
return;
64+
}
65+
66+
setIsLoading(true);
67+
68+
try {
69+
const response = await fetch(
70+
`${apiUrl}/v1/public/waitlist/feature`,
71+
{
72+
method: "POST",
73+
headers: {
74+
"Content-Type": "application/json",
75+
Authorization: "Bearer undefined",
76+
},
77+
body: JSON.stringify({
78+
email,
79+
feature: "credits",
80+
}),
81+
}
82+
);
83+
84+
const result = await response.json();
85+
86+
if (!response.ok) {
87+
if (result.error === "already_on_waitlist") {
88+
setError("You're already on the waitlist!");
89+
} else {
90+
setError("Failed to join waitlist. Please try again.");
91+
}
92+
} else {
93+
// Save email to localStorage on successful submission
94+
localStorage.setItem('waitlist_email', email);
95+
96+
// Check if user was already on the list
97+
if (result.data?.alreadyOnList) {
98+
setIsReturningUser(true);
99+
setPosition(result.data.position);
100+
// Set which platforms they've already shared
101+
if (result.data.sharedPlatforms?.includes("twitter")) {
102+
setHasSharedTwitter(true);
103+
}
104+
if (result.data.sharedPlatforms?.includes("linkedin")) {
105+
setHasSharedLinkedIn(true);
106+
}
107+
setIsSuccess(true);
108+
} else {
109+
// New user added to waitlist
110+
if (result.data?.position) {
111+
setPosition(result.data.position);
112+
}
113+
setIsSuccess(true);
114+
}
115+
}
116+
} catch (err) {
117+
console.error("Error joining waitlist:", err);
118+
setError("Something went wrong. Please try again.");
119+
} finally {
120+
setIsLoading(false);
121+
}
122+
};
123+
124+
const openShareWindow = (platform: "twitter" | "linkedin") => {
125+
// Don't allow multiple shares on same platform
126+
if ((platform === "twitter" && (hasSharedTwitter || pendingShareTwitter)) ||
127+
(platform === "linkedin" && (hasSharedLinkedIn || pendingShareLinkedIn))) {
128+
return;
129+
}
130+
131+
// Clear any existing interval
132+
if (windowCheckInterval.current) {
133+
clearInterval(windowCheckInterval.current);
134+
windowCheckInterval.current = undefined;
135+
}
136+
137+
// Open the share link in a popup
138+
let shareWindow;
139+
if (platform === "twitter") {
140+
const tweetUrl = `https://x.com/coleywoleyyy/status/1965525511071039632`;
141+
shareWindow = window.open(tweetUrl, "twitter-share", "width=600,height=700,left=200,top=100");
142+
setPendingShareTwitter(true);
143+
} else {
144+
// For LinkedIn, open in a popup
145+
const linkedinUrl = `https://www.linkedin.com/posts/colegottdank_the-helicone-yc-w23-team-goes-to-topgolf-activity-7365872991773069312-7koL`;
146+
shareWindow = window.open(linkedinUrl, "linkedin-share", "width=700,height=700,left=200,top=100");
147+
setPendingShareLinkedIn(true);
148+
}
149+
150+
// Check when window closes
151+
if (shareWindow) {
152+
const checkClosed = setInterval(() => {
153+
if (shareWindow.closed) {
154+
clearInterval(checkClosed);
155+
windowCheckInterval.current = undefined;
156+
// Window closed, keep pending state to show confirmation
157+
}
158+
}, 500);
159+
160+
// Store the interval ID for cleanup
161+
windowCheckInterval.current = checkClosed;
162+
}
163+
};
164+
165+
const confirmShare = async (platform: "twitter" | "linkedin") => {
166+
// Mark as shared
167+
if (platform === "twitter") {
168+
setHasSharedTwitter(true);
169+
setPendingShareTwitter(false);
170+
} else {
171+
setHasSharedLinkedIn(true);
172+
setPendingShareLinkedIn(false);
173+
}
174+
175+
// Track the share (give them 10 points)
176+
try {
177+
const response = await fetch(
178+
`${apiUrl}/v1/public/waitlist/feature/share`,
179+
{
180+
method: "POST",
181+
headers: {
182+
"Content-Type": "application/json",
183+
Authorization: "Bearer undefined",
184+
},
185+
body: JSON.stringify({
186+
email,
187+
feature: "credits",
188+
platform,
189+
}),
190+
}
191+
);
192+
193+
if (response.ok) {
194+
const result = await response.json();
195+
if (result.data?.newPosition) {
196+
setPosition(result.data.newPosition);
197+
}
198+
}
199+
} catch (err) {
200+
console.error("Error tracking share:", err);
201+
}
202+
};
203+
204+
const cancelShare = (platform: "twitter" | "linkedin") => {
205+
if (platform === "twitter") {
206+
setPendingShareTwitter(false);
207+
} else {
208+
setPendingShareLinkedIn(false);
209+
}
210+
};
211+
212+
if (isSuccess) {
213+
return (
214+
<div className="flex flex-col items-center gap-2">
215+
{/* Success message */}
216+
<div className="flex items-center justify-center gap-2">
217+
<CheckIcon className="h-5 w-5 text-brand flex-shrink-0" />
218+
<p className="text-sm text-slate-700">
219+
<span className="font-semibold">
220+
{isReturningUser
221+
? `You're already on the waitlist! You're #${position?.toLocaleString()} in line`
222+
: `Success! You're #${position?.toLocaleString()} in line`}
223+
</span>
224+
</p>
225+
</div>
226+
{/* Share section */}
227+
{(!hasSharedTwitter || !hasSharedLinkedIn) ? (
228+
<div className="flex flex-col items-center gap-2">
229+
<p className="text-sm text-slate-600">
230+
{isReturningUser && (hasSharedTwitter || hasSharedLinkedIn)
231+
? "Share on more platforms to move up faster"
232+
: "Share on social to move up the waitlist faster"}
233+
</p>
234+
<div className="flex gap-2">
235+
<button
236+
onClick={() => openShareWindow("twitter")}
237+
disabled={hasSharedTwitter || pendingShareTwitter}
238+
className={`px-4 h-[42px] rounded-lg text-sm font-medium transition-colors ${
239+
hasSharedTwitter
240+
? "bg-gray-200 text-gray-500 cursor-not-allowed"
241+
: pendingShareTwitter
242+
? "bg-gray-100 text-gray-600"
243+
: "bg-black text-white hover:bg-gray-800"
244+
}`}
245+
>
246+
{hasSharedTwitter ? "✓ X" :
247+
pendingShareTwitter ? "..." :
248+
"Share X"}
249+
</button>
250+
<button
251+
onClick={() => openShareWindow("linkedin")}
252+
disabled={hasSharedLinkedIn || pendingShareLinkedIn}
253+
className={`px-4 h-[42px] rounded-lg text-sm font-medium transition-colors ${
254+
hasSharedLinkedIn
255+
? "bg-gray-200 text-gray-500 cursor-not-allowed"
256+
: pendingShareLinkedIn
257+
? "bg-blue-100 text-blue-600"
258+
: "bg-blue-600 text-white hover:bg-blue-700"
259+
}`}
260+
>
261+
{hasSharedLinkedIn ? "✓ LinkedIn" :
262+
pendingShareLinkedIn ? "..." :
263+
"Share LinkedIn"}
264+
</button>
265+
</div>
266+
</div>
267+
) : (
268+
// Both platforms shared
269+
hasSharedTwitter && hasSharedLinkedIn && (
270+
<p className="text-sm text-slate-600">
271+
Thanks for sharing! You&apos;ve maximized your position boost.
272+
</p>
273+
)
274+
)}
275+
276+
{/* Compact confirmation UI */}
277+
{(pendingShareTwitter || pendingShareLinkedIn) && (
278+
<div className="flex items-center gap-3 p-2 bg-amber-50 border border-amber-200 rounded-lg">
279+
<p className="text-sm text-amber-900">
280+
Did you share?
281+
</p>
282+
<button
283+
onClick={() => confirmShare(pendingShareTwitter ? "twitter" : "linkedin")}
284+
className="px-3 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700"
285+
>
286+
Yes ✓
287+
</button>
288+
<button
289+
onClick={() => cancelShare(pendingShareTwitter ? "twitter" : "linkedin")}
290+
className="px-3 py-1 bg-white border border-slate-300 text-slate-600 rounded text-xs hover:bg-slate-50"
291+
>
292+
Not yet
293+
</button>
294+
</div>
295+
)}
296+
</div>
297+
);
298+
}
299+
300+
if (variant === "card") {
301+
return (
302+
<div className={`rounded-xl border border-slate-200 bg-white p-6 shadow-sm text-center ${className}`}>
303+
<h3 className="mb-2 text-lg font-semibold text-slate-900">
304+
Join the Waitlist
305+
</h3>
306+
<p className="mb-4 text-sm text-slate-600">
307+
{initialCount && initialCount > 0 ? (
308+
<>
309+
<span className="font-semibold text-brand">{initialCount.toLocaleString()}+ people</span> are already waiting.
310+
<br />
311+
Be next in line for beta access.
312+
</>
313+
) : (
314+
"Be the first to know when Credits launches."
315+
)}
316+
</p>
317+
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
318+
<input
319+
type="email"
320+
placeholder="Enter your email"
321+
value={email}
322+
onChange={(e) => setEmail(e.target.value)}
323+
className="rounded-lg border border-slate-300 px-4 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
324+
disabled={isLoading}
325+
/>
326+
{error && <p className="text-sm text-red-600">{error}</p>}
327+
<Button
328+
type="submit"
329+
disabled={isLoading}
330+
className="w-full"
331+
variant="default"
332+
>
333+
{isLoading ? "Joining..." : "Join Waitlist"}
334+
</Button>
335+
</form>
336+
</div>
337+
);
338+
}
339+
340+
// Inline variant - Compact horizontal layout
341+
return (
342+
<div className={`${className}`}>
343+
<div className="flex flex-col items-center gap-2">
344+
<p className="text-sm text-slate-600 h-5">
345+
{initialCount && initialCount > 0 ? (
346+
<>
347+
<span className="font-semibold text-brand">{initialCount.toLocaleString()}+ people</span> already waiting
348+
</>
349+
) : (
350+
<span>&nbsp;</span>
351+
)}
352+
</p>
353+
<form
354+
onSubmit={handleSubmit}
355+
className="flex flex-col sm:flex-row gap-2 w-full max-w-md mx-auto"
356+
>
357+
<input
358+
type="email"
359+
placeholder="Enter your email"
360+
value={email}
361+
onChange={(e) => setEmail(e.target.value)}
362+
className="flex-1 rounded-lg border border-slate-300 px-4 py-2.5 text-sm focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand"
363+
disabled={isLoading}
364+
/>
365+
<Button
366+
type="submit"
367+
disabled={isLoading}
368+
variant="default"
369+
className="px-6 py-2.5 h-auto"
370+
>
371+
{isLoading ? "Joining..." : "Join Waitlist"}
372+
</Button>
373+
</form>
374+
{error && (
375+
<p className="text-sm text-red-600">
376+
{error}
377+
</p>
378+
)}
379+
</div>
380+
</div>
381+
);
382+
}

bifrost/app/credits/layout.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Layout } from "@/app/components/Layout";
2+
3+
export default function RootLayout({
4+
children,
5+
}: {
6+
children: React.ReactNode;
7+
}) {
8+
return <Layout>{children}</Layout>;
9+
}

0 commit comments

Comments
 (0)