refactor(frontend): consolidate waitlist modals and align with Figma design

- Merge JoinWaitlistModal into WaitlistDetailModal for unified experience
- Add MediaCarousel component supporting videos and images with play overlay
- Update WaitlistCard styling to match Figma (rounded-large, line-clamp-5, zinc-800 button)
- Update success state with party emoji and Close button per Figma design
- Add sticky footer for buttons during modal scroll
- Support email input for non-logged-in users

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-01-12 16:27:09 -06:00
parent b31cd05675
commit 6028a2528c
4 changed files with 270 additions and 290 deletions

View File

@@ -1,161 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Input } from "@/components/atoms/Input/Input";
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { usePostV2AddSelfToTheAgentWaitlist } from "@/app/api/__generated__/endpoints/store/store";
import { Check } from "@phosphor-icons/react";
interface JoinWaitlistModalProps {
waitlist: StoreWaitlistEntry;
onClose: () => void;
onSuccess?: () => void;
}
export function JoinWaitlistModal({
waitlist,
onClose,
onSuccess,
}: JoinWaitlistModalProps) {
const { user } = useSupabaseStore();
const [email, setEmail] = useState("");
const [success, setSuccess] = useState(false);
const { toast } = useToast();
const joinWaitlistMutation = usePostV2AddSelfToTheAgentWaitlist();
function handleJoin() {
joinWaitlistMutation.mutate(
{
waitlistId: waitlist.waitlistId,
data: { email: user ? undefined : email },
},
{
onSuccess: (response) => {
if (response.status === 200) {
setSuccess(true);
toast({
title: "You're on the list!",
description: `We'll notify you when ${waitlist.name} is ready.`,
});
// Close after a short delay to show success state
setTimeout(() => {
onSuccess?.();
onClose();
}, 1500);
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to join waitlist. Please try again.",
});
}
},
onError: () => {
toast({
variant: "destructive",
title: "Error",
description: "Failed to join waitlist. Please try again.",
});
},
},
);
}
if (success) {
return (
<Dialog
title=""
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "400px" }}
>
<Dialog.Content>
<div className="flex flex-col items-center justify-center py-8">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<Check
className="h-8 w-8 text-green-600 dark:text-green-400"
size={32}
weight="bold"
/>
</div>
<h2 className="mb-2 text-center text-xl font-semibold">
You&apos;re on the list!
</h2>
<p className="text-center text-zinc-500">
We&apos;ll notify you when {waitlist.name} is ready.
</p>
</div>
</Dialog.Content>
</Dialog>
);
}
return (
<Dialog
title="Join waitlist"
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "400px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
{user
? `Get notified when ${waitlist.name} is ready to use.`
: `Enter your email to get notified when ${waitlist.name} is ready.`}
</p>
<div className="py-4">
{user ? (
<div className="rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
You&apos;ll be notified at:
</p>
<p className="mt-1 font-medium text-neutral-900 dark:text-neutral-100">
{user.email}
</p>
</div>
) : (
<Input
id="email"
label="Email address"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
)}
</div>
<Dialog.Footer>
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleJoin}
loading={joinWaitlistMutation.isPending}
disabled={!user && !email}
className="bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600"
>
{user ? "Join waitlist" : "Join with email"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -30,7 +30,7 @@ export function WaitlistCard({
return (
<div
className="flex h-[24rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-background transition-all duration-300 hover:shadow-lg dark:hover:shadow-gray-700"
className="flex h-[24rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-white transition-all duration-300 hover:shadow-lg dark:bg-zinc-900 dark:hover:shadow-gray-700"
onClick={onCardClick}
data-testid="waitlist-card"
role="button"
@@ -43,7 +43,7 @@ export function WaitlistCard({
}}
>
{/* Image Section */}
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-3xl md:aspect-[2.17/1]">
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-large md:aspect-[2.17/1]">
{imageUrl ? (
<Image
src={imageUrl}
@@ -73,7 +73,7 @@ export function WaitlistCard({
{/* Description */}
<div className="mt-2 flex w-full flex-col">
<p className="line-clamp-3 text-sm font-normal leading-relaxed text-neutral-600 dark:text-neutral-400">
<p className="line-clamp-5 text-sm font-normal leading-relaxed text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
@@ -93,7 +93,7 @@ export function WaitlistCard({
) : (
<Button
onClick={handleJoinClick}
className="w-full rounded-full bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600"
className="w-full rounded-full bg-zinc-800 text-white hover:bg-zinc-700 dark:bg-zinc-700 dark:hover:bg-zinc-600"
>
Join waitlist
</Button>

View File

@@ -1,27 +1,224 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Input } from "@/components/atoms/Input/Input";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/__legacy__/ui/carousel";
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
import { Check } from "@phosphor-icons/react";
import { Check, Play } from "@phosphor-icons/react";
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { usePostV2AddSelfToTheAgentWaitlist } from "@/app/api/__generated__/endpoints/store/store";
interface MediaItem {
type: "image" | "video";
url: string;
label?: string;
}
function MediaCarousel({ waitlist }: { waitlist: StoreWaitlistEntry }) {
const [activeVideo, setActiveVideo] = useState<string | null>(null);
// Build media items array: videos first, then images
const mediaItems: MediaItem[] = [
...(waitlist.videoUrl
? [{ type: "video" as const, url: waitlist.videoUrl, label: "Video" }]
: []),
...(waitlist.agentOutputDemoUrl
? [
{
type: "video" as const,
url: waitlist.agentOutputDemoUrl,
label: "Demo",
},
]
: []),
...waitlist.imageUrls.map((url) => ({ type: "image" as const, url })),
];
if (mediaItems.length === 0) return null;
// Single item - no carousel needed
if (mediaItems.length === 1) {
const item = mediaItems[0];
return (
<div className="relative aspect-[350/196] w-full overflow-hidden rounded-large">
{item.type === "image" ? (
<Image
src={item.url}
alt={`${waitlist.name} preview`}
fill
className="object-cover"
/>
) : (
<video
src={item.url}
controls
className="h-full w-full object-cover"
/>
)}
</div>
);
}
// Multiple items - use carousel
return (
<Carousel className="w-full">
<CarouselContent>
{mediaItems.map((item, index) => (
<CarouselItem key={index}>
<div className="relative aspect-[350/196] w-full overflow-hidden rounded-large">
{item.type === "image" ? (
<Image
src={item.url}
alt={`${waitlist.name} preview ${index + 1}`}
fill
className="object-cover"
/>
) : activeVideo === item.url ? (
<video
src={item.url}
controls
autoPlay
className="h-full w-full object-cover"
/>
) : (
<button
onClick={() => setActiveVideo(item.url)}
className="group relative h-full w-full bg-zinc-900"
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 transition-transform group-hover:scale-110">
<Play size={32} weight="fill" className="text-zinc-800" />
</div>
</div>
<span className="absolute bottom-3 left-3 text-sm text-white">
{item.label}
</span>
</button>
)}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-2 top-1/2 -translate-y-1/2" />
<CarouselNext className="right-2 top-1/2 -translate-y-1/2" />
</Carousel>
);
}
interface WaitlistDetailModalProps {
waitlist: StoreWaitlistEntry;
isMember?: boolean;
onClose: () => void;
onJoin: () => void;
onJoinSuccess?: (waitlistId: string) => void;
}
export function WaitlistDetailModal({
waitlist,
isMember = false,
onClose,
onJoin,
onJoinSuccess,
}: WaitlistDetailModalProps) {
const { user } = useSupabaseStore();
const [email, setEmail] = useState("");
const [success, setSuccess] = useState(false);
const { toast } = useToast();
const joinWaitlistMutation = usePostV2AddSelfToTheAgentWaitlist();
function handleJoin() {
joinWaitlistMutation.mutate(
{
waitlistId: waitlist.waitlistId,
data: { email: user ? undefined : email },
},
{
onSuccess: (response) => {
if (response.status === 200) {
setSuccess(true);
toast({
title: "You're on the waitlist!",
description: `We'll notify you when ${waitlist.name} goes live.`,
});
onJoinSuccess?.(waitlist.waitlistId);
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to join waitlist. Please try again.",
});
}
},
onError: () => {
toast({
variant: "destructive",
title: "Error",
description: "Failed to join waitlist. Please try again.",
});
},
},
);
}
// Success state
if (success) {
return (
<Dialog
title=""
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "500px" }}
>
<Dialog.Content>
<div className="flex flex-col items-center justify-center py-4 text-center">
{/* Party emoji */}
<span className="mb-2 text-5xl">🎉</span>
{/* Title */}
<h2 className="mb-2 font-poppins text-[22px] font-medium leading-7 text-zinc-900 dark:text-zinc-100">
You&apos;re on the waitlist
</h2>
{/* Subtitle */}
<p className="text-base leading-[26px] text-zinc-600 dark:text-zinc-400">
Thanks for helping us prioritize which agents to build next.
We&apos;ll notify you when this agent goes live in the
marketplace.
</p>
</div>
{/* Close button */}
<Dialog.Footer className="flex justify-center pb-2 pt-4">
<Button
variant="secondary"
onClick={onClose}
className="rounded-full border border-zinc-700 bg-white px-4 py-3 text-zinc-900 hover:bg-zinc-100 dark:border-zinc-500 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
Close
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}
// Main modal - handles both member and non-member states
return (
<Dialog
title={waitlist.name}
title="Join the waitlist"
controlled={{
isOpen: true,
set: async (open) => {
@@ -29,106 +226,74 @@ export function WaitlistDetailModal({
},
}}
onClose={onClose}
styling={{ maxWidth: "700px" }}
styling={{ maxWidth: "500px" }}
>
<Dialog.Content>
<div className="space-y-6">
{/* Main Image */}
{waitlist.imageUrls.length > 0 && (
<div className="relative aspect-video w-full overflow-hidden rounded-xl">
<Image
src={waitlist.imageUrls[0]}
alt={`${waitlist.name} preview`}
fill
className="object-cover"
/>
</div>
)}
{/* Subtitle */}
<p className="mb-6 text-center text-base text-zinc-600 dark:text-zinc-400">
Help us decide what to build next and get notified when this agent
is ready
</p>
{/* Subheading */}
<p className="text-lg font-medium text-neutral-700 dark:text-neutral-300">
{waitlist.subHeading}
</p>
{/* Media Carousel */}
<MediaCarousel waitlist={waitlist} />
{/* Description */}
<div className="prose prose-neutral dark:prose-invert max-w-none">
<p className="whitespace-pre-wrap text-neutral-600 dark:text-neutral-400">
{waitlist.description}
</p>
{/* Agent Name */}
<h3 className="mt-4 font-poppins text-[22px] font-medium leading-7 text-zinc-800 dark:text-zinc-100">
{waitlist.name}
</h3>
{/* Agent Description */}
<p className="mt-2 line-clamp-5 text-sm leading-[22px] text-zinc-500 dark:text-zinc-400">
{waitlist.description}
</p>
{/* Email input for non-logged-in users who haven't joined */}
{!isMember && !user && (
<div className="mt-4 pr-1">
<Input
id="email"
label="Email address"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
)}
{/* Video */}
{waitlist.videoUrl && (
<div className="space-y-2">
<h4 className="font-medium text-neutral-800 dark:text-neutral-200">
Video
</h4>
<div className="relative aspect-video w-full overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800">
<iframe
src={waitlist.videoUrl}
title={`${waitlist.name} video`}
className="h-full w-full"
allowFullScreen
/>
</div>
</div>
)}
{/* Output Demo */}
{waitlist.agentOutputDemoUrl && (
<div className="space-y-2">
<h4 className="font-medium text-neutral-800 dark:text-neutral-200">
Output Demo
</h4>
<div className="relative aspect-video w-full overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800">
<video
src={waitlist.agentOutputDemoUrl}
controls
className="h-full w-full"
/>
</div>
</div>
)}
{/* Categories */}
{waitlist.categories.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-neutral-800 dark:text-neutral-200">
Categories
</h4>
<div className="flex flex-wrap gap-2">
{waitlist.categories.map((category, index) => (
<span
key={index}
className="rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
{category}
</span>
))}
</div>
</div>
)}
{/* Join Button */}
<Dialog.Footer>
{isMember ? (
{/* Footer buttons */}
<Dialog.Footer className="sticky bottom-0 mt-6 flex justify-center gap-3 bg-white pb-2 pt-4 dark:bg-zinc-900">
{isMember ? (
<Button
disabled
className="rounded-full bg-green-600 px-4 py-3 text-white hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-700"
>
<Check size={16} className="mr-2" />
You&apos;re on the waitlist
</Button>
) : (
<>
<Button
disabled
className="w-full rounded-full bg-green-600 text-white hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-700"
>
<Check size={16} className="mr-2" />
You&apos;re on the waitlist
</Button>
) : (
<Button
onClick={onJoin}
className="w-full rounded-full bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600"
onClick={handleJoin}
loading={joinWaitlistMutation.isPending}
disabled={!user && !email}
className="rounded-full bg-zinc-800 px-4 py-3 text-white hover:bg-zinc-700 dark:bg-zinc-700 dark:hover:bg-zinc-600"
>
Join waitlist
</Button>
)}
</Dialog.Footer>
</div>
<Button
type="button"
variant="secondary"
onClick={onClose}
className="rounded-full bg-zinc-200 px-4 py-3 text-zinc-900 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-100 dark:hover:bg-zinc-600"
>
Not now
</Button>
</>
)}
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);

View File

@@ -8,7 +8,6 @@ import {
} from "@/components/__legacy__/ui/carousel";
import { WaitlistCard } from "../WaitlistCard/WaitlistCard";
import { WaitlistDetailModal } from "../WaitlistDetailModal/WaitlistDetailModal";
import { JoinWaitlistModal } from "../JoinWaitlistModal/JoinWaitlistModal";
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
import { useWaitlistSection } from "./useWaitlistSection";
@@ -17,27 +16,13 @@ export function WaitlistSection() {
useWaitlistSection();
const [selectedWaitlist, setSelectedWaitlist] =
useState<StoreWaitlistEntry | null>(null);
const [joiningWaitlist, setJoiningWaitlist] =
useState<StoreWaitlistEntry | null>(null);
function handleCardClick(waitlist: StoreWaitlistEntry) {
function handleOpenModal(waitlist: StoreWaitlistEntry) {
setSelectedWaitlist(waitlist);
}
function handleJoinClick(waitlist: StoreWaitlistEntry) {
setJoiningWaitlist(waitlist);
}
function handleJoinFromDetail() {
if (selectedWaitlist) {
setJoiningWaitlist(selectedWaitlist);
setSelectedWaitlist(null);
}
}
function handleJoinSuccess(waitlistId: string) {
markAsJoined(waitlistId);
setJoiningWaitlist(null);
}
// Don't render if loading, error, or no waitlists
@@ -78,8 +63,8 @@ export function WaitlistSection() {
description={waitlist.description}
imageUrl={waitlist.imageUrls[0] || null}
isMember={joinedWaitlistIds.has(waitlist.waitlistId)}
onCardClick={() => handleCardClick(waitlist)}
onJoinClick={() => handleJoinClick(waitlist)}
onCardClick={() => handleOpenModal(waitlist)}
onJoinClick={() => handleOpenModal(waitlist)}
/>
</CarouselItem>
))}
@@ -96,29 +81,20 @@ export function WaitlistSection() {
description={waitlist.description}
imageUrl={waitlist.imageUrls[0] || null}
isMember={joinedWaitlistIds.has(waitlist.waitlistId)}
onCardClick={() => handleCardClick(waitlist)}
onJoinClick={() => handleJoinClick(waitlist)}
onCardClick={() => handleOpenModal(waitlist)}
onJoinClick={() => handleOpenModal(waitlist)}
/>
))}
</div>
</div>
{/* Detail Modal */}
{/* Single Modal for both viewing and joining */}
{selectedWaitlist && (
<WaitlistDetailModal
waitlist={selectedWaitlist}
isMember={joinedWaitlistIds.has(selectedWaitlist.waitlistId)}
onClose={() => setSelectedWaitlist(null)}
onJoin={handleJoinFromDetail}
/>
)}
{/* Join Modal */}
{joiningWaitlist && (
<JoinWaitlistModal
waitlist={joiningWaitlist}
onClose={() => setJoiningWaitlist(null)}
onSuccess={() => handleJoinSuccess(joiningWaitlist.waitlistId)}
onJoinSuccess={handleJoinSuccess}
/>
)}
</div>