feat(platform): Show "On the waitlist" status for joined users

- Add GET /api/store/waitlist/my-memberships endpoint to fetch user's joined waitlists
- Add get_user_waitlist_memberships() db function
- Update useWaitlistSection hook to fetch memberships when logged in
- Update WaitlistCard to show green "On the waitlist" button for members
- Update WaitlistDetailModal to show member status
- Add onSuccess callback to JoinWaitlistModal for optimistic UI updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-01-07 21:15:03 -07:00
parent a73fb8f114
commit d4ecdb64ed
9 changed files with 160 additions and 35 deletions

View File

@@ -2016,6 +2016,21 @@ async def get_waitlist() -> list[store_model.StoreWaitlistEntry]:
raise DatabaseError("Failed to fetch waitlists") from e
async def get_user_waitlist_memberships(user_id: str) -> list[str]:
"""Get all waitlist IDs that a user has joined."""
try:
user = await prisma.models.User.prisma().find_unique(
where={"id": user_id},
include={"joinedWaitlists": True},
)
if not user or not user.joinedWaitlists:
return []
return [w.id for w in user.joinedWaitlists]
except Exception as e:
logger.error(f"Error fetching user waitlist memberships: {e}")
raise DatabaseError("Failed to fetch waitlist memberships") from e
async def add_user_to_waitlist(
waitlist_id: str, user_id: str | None, email: str | None
) -> store_model.StoreWaitlistEntry:
@@ -2342,7 +2357,8 @@ async def link_waitlist_to_listing_admin(
waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"storeListingId": store_listing_id}, # type: ignore[arg-type]
# TODO: fix this properly in prisma by making sure this is setable. surely theres somewhere else we've done something like this
data={"storeListingId": store_listing_id},
include={"joinedUsers": True},
)

View File

@@ -96,6 +96,18 @@ async def get_waitlist():
return store_model.StoreWaitlistsAllResponse(listings=waitlists)
@router.get(
"/waitlist/my-memberships",
summary="Get waitlist IDs the current user has joined",
tags=["store", "private"],
)
async def get_my_waitlist_memberships(
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
) -> list[str]:
"""Returns list of waitlist IDs the authenticated user has joined."""
return await store_db.get_user_waitlist_memberships(user_id)
@router.post(
path="/waitlist/{waitlist_id}/join",
summary="Add self to the agent waitlist",

View File

@@ -21,11 +21,13 @@ import { Check } from "lucide-react";
interface JoinWaitlistModalProps {
waitlist: StoreWaitlistEntry;
onClose: () => void;
onSuccess?: () => void;
}
export function JoinWaitlistModal({
waitlist,
onClose,
onSuccess,
}: JoinWaitlistModalProps) {
const { user } = useSupabaseStore();
const [email, setEmail] = useState("");
@@ -47,6 +49,7 @@ export function JoinWaitlistModal({
// Close after a short delay to show success state
setTimeout(() => {
onSuccess?.();
onClose();
}, 1500);
} catch (error) {

View File

@@ -2,12 +2,14 @@
import Image from "next/image";
import { Button } from "@/components/atoms/Button/Button";
import { Check } from "lucide-react";
interface WaitlistCardProps {
name: string;
subHeading: string;
description: string;
imageUrl: string | null;
isMember?: boolean;
onCardClick: () => void;
onJoinClick: (e: React.MouseEvent) => void;
}
@@ -17,12 +19,15 @@ export function WaitlistCard({
subHeading,
description,
imageUrl,
isMember = false,
onCardClick,
onJoinClick,
}: WaitlistCardProps) {
function handleJoinClick(e: React.MouseEvent) {
e.stopPropagation();
onJoinClick(e);
if (!isMember) {
onJoinClick(e);
}
}
return (
@@ -79,12 +84,22 @@ export function WaitlistCard({
{/* Join Waitlist Button */}
<div className="mt-4 w-full pb-4">
<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"
>
Join waitlist
</Button>
{isMember ? (
<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 className="mr-2 h-4 w-4" />
On the waitlist
</Button>
) : (
<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"
>
Join waitlist
</Button>
)}
</div>
</div>
</div>

View File

@@ -9,16 +9,18 @@ import {
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { StoreWaitlistEntry } from "@/lib/autogpt-server-api/types";
import { X } from "lucide-react";
import { Check } from "lucide-react";
interface WaitlistDetailModalProps {
waitlist: StoreWaitlistEntry;
isMember?: boolean;
onClose: () => void;
onJoin: () => void;
}
export function WaitlistDetailModal({
waitlist,
isMember = false,
onClose,
onJoin,
}: WaitlistDetailModalProps) {
@@ -26,17 +28,7 @@ export function WaitlistDetailModal({
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>{waitlist.name}</span>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</DialogTitle>
<DialogTitle>{waitlist.name}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
@@ -117,12 +109,22 @@ export function WaitlistDetailModal({
)}
{/* Join 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"
>
Join waitlist
</Button>
{isMember ? (
<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 className="mr-2 h-4 w-4" />
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"
>
Join waitlist
</Button>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -13,7 +13,8 @@ import { StoreWaitlistEntry } from "@/lib/autogpt-server-api/types";
import { useWaitlistSection } from "./useWaitlistSection";
export function WaitlistSection() {
const { waitlists, isLoading, hasError } = useWaitlistSection();
const { waitlists, joinedWaitlistIds, isLoading, hasError, markAsJoined } =
useWaitlistSection();
const [selectedWaitlist, setSelectedWaitlist] =
useState<StoreWaitlistEntry | null>(null);
const [joiningWaitlist, setJoiningWaitlist] =
@@ -34,6 +35,11 @@ export function WaitlistSection() {
}
}
function handleJoinSuccess(waitlistId: string) {
markAsJoined(waitlistId);
setJoiningWaitlist(null);
}
// Don't render if loading, error, or no waitlists
if (isLoading || hasError || !waitlists || waitlists.length === 0) {
return null;
@@ -71,6 +77,7 @@ export function WaitlistSection() {
subHeading={waitlist.subHeading}
description={waitlist.description}
imageUrl={waitlist.imageUrls[0] || null}
isMember={joinedWaitlistIds.has(waitlist.waitlist_id)}
onCardClick={() => handleCardClick(waitlist)}
onJoinClick={() => handleJoinClick(waitlist)}
/>
@@ -88,6 +95,7 @@ export function WaitlistSection() {
subHeading={waitlist.subHeading}
description={waitlist.description}
imageUrl={waitlist.imageUrls[0] || null}
isMember={joinedWaitlistIds.has(waitlist.waitlist_id)}
onCardClick={() => handleCardClick(waitlist)}
onJoinClick={() => handleJoinClick(waitlist)}
/>
@@ -99,6 +107,7 @@ export function WaitlistSection() {
{selectedWaitlist && (
<WaitlistDetailModal
waitlist={selectedWaitlist}
isMember={joinedWaitlistIds.has(selectedWaitlist.waitlist_id)}
onClose={() => setSelectedWaitlist(null)}
onJoin={handleJoinFromDetail}
/>
@@ -109,6 +118,7 @@ export function WaitlistSection() {
<JoinWaitlistModal
waitlist={joiningWaitlist}
onClose={() => setJoiningWaitlist(null)}
onSuccess={() => handleJoinSuccess(joiningWaitlist.waitlist_id)}
/>
)}
</div>

View File

@@ -3,18 +3,36 @@
import { useEffect, useState } from "react";
import BackendAPI from "@/lib/autogpt-server-api/client";
import { StoreWaitlistEntry } from "@/lib/autogpt-server-api/types";
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
export function useWaitlistSection() {
const { user } = useSupabaseStore();
const [waitlists, setWaitlists] = useState<StoreWaitlistEntry[]>([]);
const [joinedWaitlistIds, setJoinedWaitlistIds] = useState<Set<string>>(
new Set(),
);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
useEffect(() => {
async function fetchWaitlists() {
async function fetchData() {
try {
const api = new BackendAPI();
// Fetch waitlists
const response = await api.getWaitlists();
setWaitlists(response.listings);
// Fetch memberships if logged in
if (user) {
try {
const memberships = await api.getMyWaitlistMemberships();
setJoinedWaitlistIds(new Set(memberships));
} catch (error) {
// Don't fail the whole component if membership fetch fails
console.error("Error fetching waitlist memberships:", error);
}
}
} catch (error) {
console.error("Error fetching waitlists:", error);
setHasError(true);
@@ -23,8 +41,13 @@ export function useWaitlistSection() {
}
}
fetchWaitlists();
}, []);
fetchData();
}, [user]);
return { waitlists, isLoading, hasError };
// Function to add a waitlist ID to joined set (called after successful join)
function markAsJoined(waitlistId: string) {
setJoinedWaitlistIds((prev) => new Set([...prev, waitlistId]));
}
return { waitlists, joinedWaitlistIds, isLoading, hasError, markAsJoined };
}

View File

@@ -6064,6 +6064,32 @@
}
}
},
"/api/store/waitlist/my-memberships": {
"get": {
"tags": ["v2", "store", "private"],
"summary": "Get waitlist IDs the current user has joined",
"description": "Returns list of waitlist IDs the authenticated user has joined.",
"operationId": "getV2Get waitlist ids the current user has joined",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": { "type": "string" },
"type": "array",
"title": "Response Getv2Get Waitlist Ids The Current User Has Joined"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/store/waitlist/{waitlist_id}/join": {
"post": {
"tags": ["v2", "store", "public"],
@@ -6088,9 +6114,7 @@
"content": {
"application/json": {
"schema": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"description": "Email address for unauthenticated users",
"title": "Email"
"$ref": "#/components/schemas/Body_postV2Add_self_to_the_agent_waitlist"
}
}
}
@@ -6862,6 +6886,17 @@
"required": ["store_listing_version_id"],
"title": "Body_postV2Add marketplace agent"
},
"Body_postV2Add_self_to_the_agent_waitlist": {
"properties": {
"email": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Email",
"description": "Email address for unauthenticated users"
}
},
"type": "object",
"title": "Body_postV2Add self to the agent waitlist"
},
"Body_postV2Execute_a_preset": {
"properties": {
"inputs": {
@@ -12223,7 +12258,7 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
},
"status": { "type": "string", "title": "Status" },
"status": { "$ref": "#/components/schemas/WaitlistExternalStatus" },
"votes": { "type": "integer", "title": "Votes" },
"signupCount": { "type": "integer", "title": "Signupcount" },
"storeListingId": {
@@ -12283,6 +12318,11 @@
"title": "WaitlistCreateRequest",
"description": "Request model for creating a new waitlist."
},
"WaitlistExternalStatus": {
"type": "string",
"enum": ["DONE", "NOT_STARTED", "CANCELED", "WORK_IN_PROGRESS"],
"title": "WaitlistExternalStatus"
},
"WaitlistSignup": {
"properties": {
"type": { "type": "string", "title": "Type" },

View File

@@ -680,6 +680,10 @@ export default class BackendAPI {
});
}
getMyWaitlistMemberships(): Promise<string[]> {
return this._get("/store/waitlist/my-memberships");
}
////////////////////////////////////////
//////////// V2 LIBRARY API ////////////
////////////////////////////////////////