Compare commits

...

9 Commits

Author SHA1 Message Date
Nicholas Tindle
9edfe0fb97 refactor(frontend): migrate EditWaitlistDialog to design system and generated API
- Replace legacy Dialog components with molecules/Dialog
- Replace legacy Input/Label/Textarea with atoms/Input
- Replace legacy Select with atoms/Select
- Replace @/lib/autogpt-server-api/types with @/app/api/__generated__/models
- Replace updateWaitlist action with usePutV2UpdateWaitlist hook
- Remove dependency on BackendAPI in favor of generated React Query hooks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:49:35 -07:00
Nicholas Tindle
4aabe71001 fix(platform): address PR review feedback for waitlist feature
Backend fixes:
- Fix creator_username null check in store URL construction
- Add embed=True to link_waitlist_to_listing endpoint body param
- Fix race condition in email list with transaction wrapper
- Replace str(e) with generic error messages in admin ValueError handlers
- Add validation requiring user_id or email in waitlist join
- Configure WAITLIST_LAUNCH in notification system (data type, queue, template, subject)
- Change StoreListing cascade delete to SetNull to preserve waitlist data

Frontend fixes:
- Escape internal quotes in CSV export for proper RFC 4180 compliance
- Remove incorrect 'use server' directive from page.tsx
- Replace lucide-react Check icon with Phosphor Icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:40:35 -07:00
Nicholas Tindle
b3999669f2 refactor(platform): simplify waitlist code and remove type duplication
- Backend: Extract _waitlist_to_store_entry helper to reduce duplication
- Backend: Use dict comprehension in update_waitlist_admin for cleaner code
- Frontend: Import types directly from shared types file instead of re-exporting
- Frontend: Remove redundant isMember check in WaitlistCard handleJoinClick

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:25:27 -07:00
Swifty
8c45a5ee98 Merge branch 'dev' into ntindle/waitlist 2026-01-08 12:38:46 +01:00
Nicholas Tindle
4b654c7e9f fix(frontend): Fix lint and type errors in waitlist admin components
- Remove unused WaitlistSignup import
- Change button size from "sm" to "small"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:48:53 -07:00
Nicholas Tindle
8d82e3b633 fix(backend): Use Prisma connect pattern for waitlist-listing relation
Use StoreListing relation with connect pattern instead of directly
setting storeListingId, which doesn't work with Prisma's typed update.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:01:18 -07:00
Nicholas Tindle
d4ecdb64ed 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>
2026-01-07 21:15:03 -07:00
Nicholas Tindle
a73fb8f114 feat(platform): Add waitlist feature with admin management and user notifications
Backend:
- Add waitlist admin API routes for CRUD operations
- Add admin functions for waitlist management (create, update, delete, list)
- Add WaitlistLaunchData notification type for user notifications
- Integrate waitlist notifications into store submission approval flow
- Auto-notify waitlist users when linked agent is approved

Frontend:
- Add admin waitlist management page with table, create/edit dialogs
- Add WaitlistSection component to marketplace homepage
- Add WaitlistCard, WaitlistDetailModal, JoinWaitlistModal components
- Add API client methods and types for waitlist operations

Database:
- Add WAITLIST_LAUNCH notification type enum
- Add baseline migration for APScheduler tables

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:38:15 -07:00
Nicholas Tindle
2c60aa64ef wip: adding waitlist 2026-01-06 22:13:35 -07:00
25 changed files with 3497 additions and 3 deletions

View File

@@ -0,0 +1,250 @@
import logging
import autogpt_libs.auth
import fastapi
import fastapi.responses
import backend.api.features.store.db as store_db
import backend.api.features.store.model as store_model
logger = logging.getLogger(__name__)
router = fastapi.APIRouter(
prefix="/admin/waitlist",
tags=["store", "admin", "waitlist"],
dependencies=[fastapi.Security(autogpt_libs.auth.requires_admin_user)],
)
@router.post(
"",
summary="Create Waitlist",
response_model=store_model.WaitlistAdminResponse,
)
async def create_waitlist(
request: store_model.WaitlistCreateRequest,
user_id: str = fastapi.Security(autogpt_libs.auth.get_user_id),
):
"""
Create a new waitlist (admin only).
Args:
request: Waitlist creation details
user_id: Authenticated admin user creating the waitlist
Returns:
WaitlistAdminResponse with the created waitlist details
"""
try:
waitlist = await store_db.create_waitlist_admin(
admin_user_id=user_id,
data=request,
)
return waitlist
except Exception as e:
logger.exception("Error creating waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while creating the waitlist"},
)
@router.get(
"",
summary="List All Waitlists",
response_model=store_model.WaitlistAdminListResponse,
)
async def list_waitlists():
"""
Get all waitlists with admin details (admin only).
Returns:
WaitlistAdminListResponse with all waitlists
"""
try:
return await store_db.get_waitlists_admin()
except Exception as e:
logger.exception("Error listing waitlists: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching waitlists"},
)
@router.get(
"/{waitlist_id}",
summary="Get Waitlist Details",
response_model=store_model.WaitlistAdminResponse,
)
async def get_waitlist(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Get a single waitlist with admin details (admin only).
Args:
waitlist_id: ID of the waitlist to retrieve
Returns:
WaitlistAdminResponse with waitlist details
"""
try:
return await store_db.get_waitlist_admin(waitlist_id)
except ValueError:
logger.warning("Waitlist not found: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error fetching waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching the waitlist"},
)
@router.put(
"/{waitlist_id}",
summary="Update Waitlist",
response_model=store_model.WaitlistAdminResponse,
)
async def update_waitlist(
request: store_model.WaitlistUpdateRequest,
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Update a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist to update
request: Fields to update
Returns:
WaitlistAdminResponse with updated waitlist details
"""
try:
return await store_db.update_waitlist_admin(waitlist_id, request)
except ValueError:
logger.warning("Waitlist not found for update: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error updating waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while updating the waitlist"},
)
@router.delete(
"/{waitlist_id}",
summary="Delete Waitlist",
)
async def delete_waitlist(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Soft delete a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist to delete
Returns:
Success message
"""
try:
deleted = await store_db.delete_waitlist_admin(waitlist_id)
if deleted:
return {"message": "Waitlist deleted successfully"}
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error deleting waitlist: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while deleting the waitlist"},
)
@router.get(
"/{waitlist_id}/signups",
summary="Get Waitlist Signups",
response_model=store_model.WaitlistSignupListResponse,
)
async def get_waitlist_signups(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
):
"""
Get all signups for a waitlist (admin only).
Args:
waitlist_id: ID of the waitlist
Returns:
WaitlistSignupListResponse with all signups
"""
try:
return await store_db.get_waitlist_signups_admin(waitlist_id)
except ValueError:
logger.warning("Waitlist not found for signups: %s", waitlist_id)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist not found"},
)
except Exception as e:
logger.exception("Error fetching waitlist signups: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while fetching waitlist signups"},
)
@router.post(
"/{waitlist_id}/link",
summary="Link Waitlist to Store Listing",
response_model=store_model.WaitlistAdminResponse,
)
async def link_waitlist_to_listing(
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist"),
store_listing_id: str = fastapi.Body(
..., embed=True, description="The ID of the store listing"
),
):
"""
Link a waitlist to a store listing (admin only).
When the linked store listing is approved/published, waitlist users
will be automatically notified.
Args:
waitlist_id: ID of the waitlist
store_listing_id: ID of the store listing to link
Returns:
WaitlistAdminResponse with updated waitlist details
"""
try:
return await store_db.link_waitlist_to_listing_admin(
waitlist_id, store_listing_id
)
except ValueError:
logger.warning(
"Link failed - waitlist or listing not found: %s, %s",
waitlist_id,
store_listing_id,
)
return fastapi.responses.JSONResponse(
status_code=404,
content={"detail": "Waitlist or store listing not found"},
)
except Exception as e:
logger.exception("Error linking waitlist to listing: %s", e)
return fastapi.responses.JSONResponse(
status_code=500,
content={"detail": "An error occurred while linking the waitlist"},
)

View File

@@ -23,6 +23,7 @@ from backend.data.notifications import (
AgentApprovalData,
AgentRejectionData,
NotificationEventModel,
WaitlistLaunchData,
)
from backend.notifications.notifications import queue_notification_async
from backend.util.exceptions import DatabaseError
@@ -1706,6 +1707,29 @@ async def review_store_submission(
# Don't fail the review process if email sending fails
pass
# Notify waitlist users if this is an approval and has a linked waitlist
if is_approved and submission.StoreListing:
try:
frontend_base_url = (
settings.config.frontend_base_url
or settings.config.platform_base_url
)
store_agent = (
await prisma.models.StoreAgent.prisma().find_first_or_raise(
where={"storeListingVersionId": submission.id}
)
)
creator_username = store_agent.creator_username or "unknown"
store_url = f"{frontend_base_url}/marketplace/agent/{creator_username}/{store_agent.slug}"
await notify_waitlist_users_on_launch(
store_listing_id=submission.StoreListing.id,
agent_name=submission.name,
store_url=store_url,
)
except Exception as e:
logger.error(f"Failed to notify waitlist users on agent approval: {e}")
# Don't fail the approval process
# Convert to Pydantic model for consistency
return store_model.StoreSubmission(
agent_id=submission.agentGraphId,
@@ -1957,3 +1981,481 @@ async def get_agent_as_admin(
)
return graph
def _waitlist_to_store_entry(
waitlist: prisma.models.WaitlistEntry,
) -> store_model.StoreWaitlistEntry:
"""Convert a WaitlistEntry to StoreWaitlistEntry for public display."""
return store_model.StoreWaitlistEntry(
waitlist_id=waitlist.id,
slug=waitlist.slug,
name=waitlist.name,
subHeading=waitlist.subHeading,
videoUrl=waitlist.videoUrl,
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
imageUrls=waitlist.imageUrls or [],
description=waitlist.description,
categories=waitlist.categories,
)
async def get_waitlist() -> list[store_model.StoreWaitlistEntry]:
"""Get all active waitlists for public display."""
try:
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
)
# Filter out closed/done waitlists and sort by votes (descending)
excluded_statuses = {
prisma.enums.WaitlistExternalStatus.CANCELED,
prisma.enums.WaitlistExternalStatus.DONE,
}
active_waitlists = [w for w in waitlists if w.status not in excluded_statuses]
sorted_list = sorted(active_waitlists, key=lambda x: x.votes, reverse=True)
return [_waitlist_to_store_entry(w) for w in sorted_list]
except Exception as e:
logger.error(f"Error fetching waitlists: {e}")
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:
"""
Add a user to a waitlist.
For logged-in users: connects via joinedUsers relation
For anonymous users: adds email to unafilliatedEmailUsers array
"""
logger.debug(f"Adding user {user_id or email} to waitlist {waitlist_id}")
if not user_id and not email:
raise ValueError("Either user_id or email must be provided")
try:
# Find the waitlist
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
if waitlist.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} is no longer available")
if waitlist.status in [
prisma.enums.WaitlistExternalStatus.CANCELED,
prisma.enums.WaitlistExternalStatus.DONE,
]:
raise ValueError(f"Waitlist {waitlist_id} is closed")
if user_id:
# Check if user already joined
joined_user_ids = [u.id for u in (waitlist.joinedUsers or [])]
if user_id in joined_user_ids:
# Already joined - return waitlist info
logger.debug(f"User {user_id} already joined waitlist {waitlist_id}")
else:
# Connect user to waitlist
await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"joinedUsers": {"connect": [{"id": user_id}]}},
)
logger.info(f"User {user_id} joined waitlist {waitlist_id}")
# If user was previously in email list, remove them
if email and email in (waitlist.unafilliatedEmailUsers or []):
updated_emails: list[str] = [
e for e in (waitlist.unafilliatedEmailUsers or []) if e != email
]
await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"unafilliatedEmailUsers": updated_emails},
)
elif email:
# Add email to unaffiliated list if not already present
# Use transaction to prevent race conditions with concurrent signups
async with transaction() as tx:
# Re-fetch within transaction to get latest state
current_waitlist = await tx.waitlistentry.find_unique(
where={"id": waitlist_id}
)
if current_waitlist:
current_emails: list[str] = list(
current_waitlist.unafilliatedEmailUsers or []
)
if email not in current_emails:
current_emails.append(email)
await tx.waitlistentry.update(
where={"id": waitlist_id},
data={"unafilliatedEmailUsers": current_emails},
)
logger.info(f"Email {email} added to waitlist {waitlist_id}")
else:
logger.debug(f"Email {email} already on waitlist {waitlist_id}")
return _waitlist_to_store_entry(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error adding user to waitlist: {e}")
raise DatabaseError("Failed to add user to waitlist") from e
# ============== Admin Waitlist Functions ==============
def _waitlist_to_admin_response(
waitlist: prisma.models.WaitlistEntry,
) -> store_model.WaitlistAdminResponse:
"""Convert a WaitlistEntry to WaitlistAdminResponse."""
joined_count = len(waitlist.joinedUsers) if waitlist.joinedUsers else 0
email_count = (
len(waitlist.unafilliatedEmailUsers) if waitlist.unafilliatedEmailUsers else 0
)
return store_model.WaitlistAdminResponse(
id=waitlist.id,
createdAt=waitlist.createdAt.isoformat() if waitlist.createdAt else "",
updatedAt=waitlist.updatedAt.isoformat() if waitlist.updatedAt else "",
slug=waitlist.slug,
name=waitlist.name,
subHeading=waitlist.subHeading,
description=waitlist.description,
categories=waitlist.categories,
imageUrls=waitlist.imageUrls or [],
videoUrl=waitlist.videoUrl,
agentOutputDemoUrl=waitlist.agentOutputDemoUrl,
status=waitlist.status or prisma.enums.WaitlistExternalStatus.NOT_STARTED,
votes=waitlist.votes,
signupCount=joined_count + email_count,
storeListingId=waitlist.storeListingId,
owningUserId=waitlist.owningUserId,
)
async def create_waitlist_admin(
admin_user_id: str,
data: store_model.WaitlistCreateRequest,
) -> store_model.WaitlistAdminResponse:
"""Create a new waitlist (admin only)."""
logger.info(f"Admin {admin_user_id} creating waitlist: {data.name}")
try:
waitlist = await prisma.models.WaitlistEntry.prisma().create(
data=prisma.types.WaitlistEntryCreateInput(
name=data.name,
slug=data.slug,
subHeading=data.subHeading,
description=data.description,
categories=data.categories,
imageUrls=data.imageUrls,
videoUrl=data.videoUrl,
agentOutputDemoUrl=data.agentOutputDemoUrl,
owningUserId=admin_user_id,
status=prisma.enums.WaitlistExternalStatus.NOT_STARTED,
),
include={"joinedUsers": True},
)
return _waitlist_to_admin_response(waitlist)
except Exception as e:
logger.error(f"Error creating waitlist: {e}")
raise DatabaseError("Failed to create waitlist") from e
async def get_waitlists_admin() -> store_model.WaitlistAdminListResponse:
"""Get all waitlists with admin details."""
try:
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where=prisma.types.WaitlistEntryWhereInput(isDeleted=False),
include={"joinedUsers": True},
order={"createdAt": "desc"},
)
return store_model.WaitlistAdminListResponse(
waitlists=[_waitlist_to_admin_response(w) for w in waitlists],
totalCount=len(waitlists),
)
except Exception as e:
logger.error(f"Error fetching waitlists for admin: {e}")
raise DatabaseError("Failed to fetch waitlists") from e
async def get_waitlist_admin(
waitlist_id: str,
) -> store_model.WaitlistAdminResponse:
"""Get a single waitlist with admin details."""
try:
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
if waitlist.isDeleted:
raise ValueError(f"Waitlist {waitlist_id} has been deleted")
return _waitlist_to_admin_response(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error fetching waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to fetch waitlist") from e
async def update_waitlist_admin(
waitlist_id: str,
data: store_model.WaitlistUpdateRequest,
) -> store_model.WaitlistAdminResponse:
"""Update a waitlist (admin only)."""
logger.info(f"Updating waitlist {waitlist_id}")
try:
# Build update data from non-None fields
field_mappings = {
"name": data.name,
"slug": data.slug,
"subHeading": data.subHeading,
"description": data.description,
"categories": data.categories,
"imageUrls": data.imageUrls,
"videoUrl": data.videoUrl,
"agentOutputDemoUrl": data.agentOutputDemoUrl,
"storeListingId": data.storeListingId,
}
update_data: dict[str, typing.Any] = {
k: v for k, v in field_mappings.items() if v is not None
}
# Handle status separately due to enum conversion
if data.status is not None:
update_data["status"] = prisma.enums.WaitlistExternalStatus(data.status)
if not update_data:
# No updates, just return current data
return await get_waitlist_admin(waitlist_id)
waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data=prisma.types.WaitlistEntryUpdateInput(**update_data),
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
return _waitlist_to_admin_response(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error updating waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to update waitlist") from e
async def delete_waitlist_admin(waitlist_id: str) -> bool:
"""Soft delete a waitlist (admin only)."""
logger.info(f"Soft deleting waitlist {waitlist_id}")
try:
waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"isDeleted": True},
)
return waitlist is not None
except Exception as e:
logger.error(f"Error deleting waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to delete waitlist") from e
async def get_waitlist_signups_admin(
waitlist_id: str,
) -> store_model.WaitlistSignupListResponse:
"""Get all signups for a waitlist (admin only)."""
try:
waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
where={"id": waitlist_id},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
signups: list[store_model.WaitlistSignup] = []
# Add user signups
for user in waitlist.joinedUsers or []:
signups.append(
store_model.WaitlistSignup(
type="user",
userId=user.id,
email=user.email,
username=user.name,
)
)
# Add email signups
for email in waitlist.unafilliatedEmailUsers or []:
signups.append(
store_model.WaitlistSignup(
type="email",
email=email,
)
)
return store_model.WaitlistSignupListResponse(
waitlistId=waitlist_id,
signups=signups,
totalCount=len(signups),
)
except ValueError:
raise
except Exception as e:
logger.error(f"Error fetching signups for waitlist {waitlist_id}: {e}")
raise DatabaseError("Failed to fetch waitlist signups") from e
async def link_waitlist_to_listing_admin(
waitlist_id: str,
store_listing_id: str,
) -> store_model.WaitlistAdminResponse:
"""Link a waitlist to a store listing (admin only)."""
logger.info(f"Linking waitlist {waitlist_id} to listing {store_listing_id}")
try:
# Verify the store listing exists
listing = await prisma.models.StoreListing.prisma().find_unique(
where={"id": store_listing_id}
)
if not listing:
raise ValueError(f"Store listing {store_listing_id} not found")
waitlist = await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist_id},
data={"StoreListing": {"connect": {"id": store_listing_id}}},
include={"joinedUsers": True},
)
if not waitlist:
raise ValueError(f"Waitlist {waitlist_id} not found")
return _waitlist_to_admin_response(waitlist)
except ValueError:
raise
except Exception as e:
logger.error(f"Error linking waitlist to listing: {e}")
raise DatabaseError("Failed to link waitlist to listing") from e
async def notify_waitlist_users_on_launch(
store_listing_id: str,
agent_name: str,
store_url: str,
) -> int:
"""
Notify all users on waitlists linked to a store listing when the agent is launched.
Args:
store_listing_id: The ID of the store listing that was approved
agent_name: The name of the approved agent
store_url: The URL to the agent's store page
Returns:
The number of notifications sent
"""
logger.info(f"Notifying waitlist users for store listing {store_listing_id}")
try:
# Find all waitlists linked to this store listing
waitlists = await prisma.models.WaitlistEntry.prisma().find_many(
where={
"storeListingId": store_listing_id,
"isDeleted": False,
},
include={"joinedUsers": True},
)
if not waitlists:
logger.info(f"No waitlists found for store listing {store_listing_id}")
return 0
notification_count = 0
launched_at = datetime.now(tz=timezone.utc)
for waitlist in waitlists:
# Notify registered users
for user in waitlist.joinedUsers or []:
try:
notification_data = WaitlistLaunchData(
agent_name=agent_name,
waitlist_name=waitlist.name,
store_url=store_url,
launched_at=launched_at,
)
notification_event = NotificationEventModel[WaitlistLaunchData](
user_id=user.id,
type=prisma.enums.NotificationType.WAITLIST_LAUNCH,
data=notification_data,
)
await queue_notification_async(notification_event)
notification_count += 1
except Exception as e:
logger.error(
f"Failed to send waitlist launch notification to user {user.id}: {e}"
)
# Note: For unaffiliated email users, you would need to send emails directly
# since they don't have user IDs for the notification system.
# This could be done via a separate email service.
# For now, we log these for potential manual follow-up or future implementation.
if waitlist.unafilliatedEmailUsers:
logger.info(
f"Waitlist {waitlist.id} has {len(waitlist.unafilliatedEmailUsers)} "
f"unaffiliated email users that need email notifications"
)
# Update waitlist status to DONE
await prisma.models.WaitlistEntry.prisma().update(
where={"id": waitlist.id},
data={"status": prisma.enums.WaitlistExternalStatus.DONE},
)
logger.info(f"Updated waitlist {waitlist.id} status to DONE")
logger.info(
f"Sent {notification_count} waitlist launch notifications for store listing {store_listing_id}"
)
return notification_count
except Exception as e:
logger.error(
f"Error notifying waitlist users for store listing {store_listing_id}: {e}"
)
# Don't raise - we don't want to fail the approval process
return 0

View File

@@ -4,6 +4,7 @@ from typing import List
import prisma.enums
import pydantic
from backend.data.model import User
from backend.util.models import Pagination
@@ -216,3 +217,99 @@ class ReviewSubmissionRequest(pydantic.BaseModel):
is_approved: bool
comments: str # External comments visible to creator
internal_comments: str | None = None # Private admin notes
class StoreWaitlistEntry(pydantic.BaseModel):
waitlist_id: str
storeListing: StoreListingWithVersions | None = None
owner: User | None = None
slug: str
# Content fields
name: str
subHeading: str
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
imageUrls: list[str]
description: str
categories: list[str]
class StoreWaitlistsAllResponse(pydantic.BaseModel):
listings: list[StoreWaitlistEntry]
# Admin Waitlist Models
class WaitlistCreateRequest(pydantic.BaseModel):
"""Request model for creating a new waitlist."""
name: str
slug: str
subHeading: str
description: str
categories: list[str] = []
imageUrls: list[str] = []
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
class WaitlistUpdateRequest(pydantic.BaseModel):
"""Request model for updating a waitlist."""
name: str | None = None
slug: str | None = None
subHeading: str | None = None
description: str | None = None
categories: list[str] | None = None
imageUrls: list[str] | None = None
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
status: str | None = None # WaitlistExternalStatus enum value
storeListingId: str | None = None # Link to a store listing
class WaitlistAdminResponse(pydantic.BaseModel):
"""Admin response model with full waitlist details including internal data."""
id: str
createdAt: str
updatedAt: str
slug: str
name: str
subHeading: str
description: str
categories: list[str]
imageUrls: list[str]
videoUrl: str | None = None
agentOutputDemoUrl: str | None = None
status: prisma.enums.WaitlistExternalStatus
votes: int
signupCount: int # Total count of joinedUsers + unafilliatedEmailUsers
storeListingId: str | None = None
owningUserId: str
class WaitlistSignup(pydantic.BaseModel):
"""Individual signup entry for a waitlist."""
type: str # "user" or "email"
userId: str | None = None
email: str | None = None
username: str | None = None # For user signups
class WaitlistSignupListResponse(pydantic.BaseModel):
"""Response model for listing waitlist signups."""
waitlistId: str
signups: list[WaitlistSignup]
totalCount: int
class WaitlistAdminListResponse(pydantic.BaseModel):
"""Response model for listing all waitlists (admin view)."""
waitlists: list[WaitlistAdminResponse]
totalCount: int

View File

@@ -7,6 +7,7 @@ from typing import Literal
import autogpt_libs.auth
import fastapi
import fastapi.responses
from autogpt_libs.auth.dependencies import get_optional_user_id
import backend.data.graph
import backend.util.json
@@ -78,6 +79,63 @@ async def update_or_create_profile(
return updated_profile
##############################################
############## Waitlist Endpoints ############
##############################################
@router.get(
"/waitlist",
summary="Get the agent waitlist",
tags=["store", "public"],
response_model=store_model.StoreWaitlistsAllResponse,
)
async def get_waitlist():
"""
Get all active waitlists for public display.
"""
waitlists = await store_db.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",
tags=["store", "public"],
response_model=store_model.StoreWaitlistEntry,
)
async def add_self_to_waitlist(
user_id: str | None = fastapi.Security(get_optional_user_id),
waitlist_id: str = fastapi.Path(..., description="The ID of the waitlist to join"),
email: str | None = fastapi.Body(
default=None, embed=True, description="Email address for unauthenticated users"
),
):
"""
Add the current user to the agent waitlist.
"""
if not user_id and not email:
raise fastapi.HTTPException(
status_code=400,
detail="Either user authentication or email address is required",
)
waitlist_entry = await store_db.add_user_to_waitlist(
waitlist_id=waitlist_id, user_id=user_id, email=email
)
return waitlist_entry
##############################################
############### Agent Endpoints ##############
##############################################

View File

@@ -19,6 +19,7 @@ from prisma.errors import PrismaError
import backend.api.features.admin.credit_admin_routes
import backend.api.features.admin.execution_analytics_routes
import backend.api.features.admin.store_admin_routes
import backend.api.features.admin.waitlist_admin_routes
import backend.api.features.builder
import backend.api.features.builder.routes
import backend.api.features.chat.routes as chat_routes
@@ -283,6 +284,11 @@ app.include_router(
tags=["v2", "admin"],
prefix="/api/store",
)
app.include_router(
backend.api.features.admin.waitlist_admin_routes.router,
tags=["v2", "admin"],
prefix="/api/store",
)
app.include_router(
backend.api.features.admin.credit_admin_routes.router,
tags=["v2", "admin"],

View File

@@ -211,6 +211,22 @@ class AgentRejectionData(BaseNotificationData):
return value
class WaitlistLaunchData(BaseNotificationData):
"""Notification data for when an agent from a waitlist is launched."""
agent_name: str
waitlist_name: str
store_url: str
launched_at: datetime
@field_validator("launched_at")
@classmethod
def validate_timezone(cls, value: datetime):
if value.tzinfo is None:
raise ValueError("datetime must have timezone information")
return value
NotificationData = Annotated[
Union[
AgentRunData,
@@ -223,6 +239,7 @@ NotificationData = Annotated[
DailySummaryData,
RefundRequestData,
BaseSummaryData,
WaitlistLaunchData,
],
Field(discriminator="type"),
]
@@ -273,6 +290,7 @@ def get_notif_data_type(
NotificationType.REFUND_PROCESSED: RefundRequestData,
NotificationType.AGENT_APPROVED: AgentApprovalData,
NotificationType.AGENT_REJECTED: AgentRejectionData,
NotificationType.WAITLIST_LAUNCH: WaitlistLaunchData,
}[notification_type]
@@ -318,6 +336,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: QueueType.ADMIN,
NotificationType.AGENT_APPROVED: QueueType.IMMEDIATE,
NotificationType.AGENT_REJECTED: QueueType.IMMEDIATE,
NotificationType.WAITLIST_LAUNCH: QueueType.IMMEDIATE,
}
return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE)
@@ -337,6 +356,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: "refund_processed.html",
NotificationType.AGENT_APPROVED: "agent_approved.html",
NotificationType.AGENT_REJECTED: "agent_rejected.html",
NotificationType.WAITLIST_LAUNCH: "waitlist_launch.html",
}[self.notification_type]
@property
@@ -354,6 +374,7 @@ class NotificationTypeOverride:
NotificationType.REFUND_PROCESSED: "Refund for ${{data.amount / 100}} to {{data.user_name}} has been processed",
NotificationType.AGENT_APPROVED: "🎉 Your agent '{{data.agent_name}}' has been approved!",
NotificationType.AGENT_REJECTED: "Your agent '{{data.agent_name}}' needs some updates",
NotificationType.WAITLIST_LAUNCH: "🚀 {{data.agent_name}} is now available!",
}[self.notification_type]

View File

@@ -0,0 +1,19 @@
-- APScheduler tables (managed by APScheduler at runtime, baseline for Prisma)
CREATE TABLE IF NOT EXISTS "apscheduler_jobs" (
"id" VARCHAR(191) NOT NULL,
"next_run_time" DOUBLE PRECISION,
"job_state" BYTEA NOT NULL,
CONSTRAINT "apscheduler_jobs_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "apscheduler_jobs_batched_notifications" (
"id" VARCHAR(191) NOT NULL,
"next_run_time" DOUBLE PRECISION,
"job_state" BYTEA NOT NULL,
CONSTRAINT "apscheduler_jobs_batched_notifications_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "ix_platform_apscheduler_jobs_next_run_time" ON "apscheduler_jobs"("next_run_time");
CREATE INDEX IF NOT EXISTS "ix_platform_apscheduler_jobs_batched_notifications_next_0b54" ON "apscheduler_jobs_batched_notifications"("next_run_time");

View File

@@ -0,0 +1,59 @@
-- CreateEnum
CREATE TYPE "WaitlistExternalStatus" AS ENUM ('DONE', 'NOT_STARTED', 'CANCELED', 'WORK_IN_PROGRESS');
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'WAITLIST_LAUNCH';
-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"storeListingId" TEXT,
"owningUserId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"search" tsvector DEFAULT ''::tsvector,
"name" TEXT NOT NULL,
"subHeading" TEXT NOT NULL,
"videoUrl" TEXT,
"agentOutputDemoUrl" TEXT,
"imageUrls" TEXT[],
"description" TEXT NOT NULL,
"categories" TEXT[],
"status" "WaitlistExternalStatus" NOT NULL DEFAULT 'NOT_STARTED',
"votes" INTEGER NOT NULL DEFAULT 0,
"unafilliatedEmailUsers" TEXT[] DEFAULT ARRAY[]::TEXT[],
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_joinedWaitlists" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_joinedWaitlists_AB_unique" ON "_joinedWaitlists"("A", "B");
-- CreateIndex
CREATE INDEX "_joinedWaitlists_B_index" ON "_joinedWaitlists"("B");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_storeListingId_fkey" FOREIGN KEY ("storeListingId") REFERENCES "StoreListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_owningUserId_fkey" FOREIGN KEY ("owningUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_joinedWaitlists" ADD CONSTRAINT "_joinedWaitlists_B_fkey" FOREIGN KEY ("B") REFERENCES "WaitlistEntry"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "ix_platform_apscheduler_jobs_next_run_time" RENAME TO "apscheduler_jobs_next_run_time_idx";
-- RenameIndex
ALTER INDEX "ix_platform_apscheduler_jobs_batched_notifications_next_0b54" RENAME TO "apscheduler_jobs_batched_notifications_next_run_time_idx";

View File

@@ -67,6 +67,10 @@ model User {
OAuthAuthorizationCodes OAuthAuthorizationCode[]
OAuthAccessTokens OAuthAccessToken[]
OAuthRefreshTokens OAuthRefreshToken[]
// Waitlist relations
waitlistEntries WaitlistEntry[]
joinedWaitlists WaitlistEntry[] @relation("joinedWaitlists")
}
enum OnboardingStep {
@@ -228,6 +232,7 @@ enum NotificationType {
REFUND_PROCESSED
AGENT_APPROVED
AGENT_REJECTED
WAITLIST_LAUNCH
}
model NotificationEvent {
@@ -834,7 +839,8 @@ model StoreListing {
OwningUser User @relation(fields: [owningUserId], references: [id])
// Relations
Versions StoreListingVersion[] @relation("ListingVersions")
Versions StoreListingVersion[] @relation("ListingVersions")
waitlistEntries WaitlistEntry[]
// Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has.
@@unique([agentGraphId])
@@ -924,6 +930,47 @@ model StoreListingReview {
@@index([reviewByUserId])
}
enum WaitlistExternalStatus {
DONE
NOT_STARTED
CANCELED
WORK_IN_PROGRESS
}
model WaitlistEntry {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
storeListingId String?
StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: SetNull)
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
slug String
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
// Content fields
name String
subHeading String
videoUrl String?
agentOutputDemoUrl String?
imageUrls String[]
description String
categories String[]
//Waitlist specific fields
status WaitlistExternalStatus @default(NOT_STARTED)
votes Int @default(0) // Hide from frontend api
joinedUsers User[] @relation("joinedWaitlists")
// NOTE: DO NOT DOUBLE SEND TO THESE USERS, IF THEY HAVE SIGNED UP SINCE THEY MAY HAVE ALREADY RECEIVED AN EMAIL
// DOUBLE CHECK WHEN SENDING THAT THEY ARE NOT IN THE JOINED USERS LIST ALSO
unafilliatedEmailUsers String[] @default([])
isDeleted Boolean @default(false)
}
enum SubmissionStatus {
DRAFT // Being prepared, not yet submitted
PENDING // Submitted, awaiting review
@@ -1094,3 +1141,22 @@ model OAuthRefreshToken {
@@index([userId, applicationId])
@@index([expiresAt]) // For cleanup
}
// APScheduler tables - managed by APScheduler, not Prisma
model apscheduler_jobs {
id String @id @db.VarChar(191)
next_run_time Float?
job_state Bytes
@@index([next_run_time])
@@ignore
}
model apscheduler_jobs_batched_notifications {
id String @id @db.VarChar(191)
next_run_time Float?
job_state Bytes
@@index([next_run_time])
@@ignore
}

View File

@@ -1,5 +1,5 @@
import { Sidebar } from "@/components/__legacy__/Sidebar";
import { Users, DollarSign, UserSearch, FileText } from "lucide-react";
import { Users, DollarSign, UserSearch, FileText, Clock } from "lucide-react";
import { IconSliders } from "@/components/__legacy__/ui/icons";
@@ -11,6 +11,11 @@ const sidebarLinkGroups = [
href: "/admin/marketplace",
icon: <Users className="h-6 w-6" />,
},
{
text: "Waitlist Management",
href: "/admin/waitlist",
icon: <Clock className="h-6 w-6" />,
},
{
text: "User Spending",
href: "/admin/spending",

View File

@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import BackendAPI from "@/lib/autogpt-server-api";
import type {
WaitlistAdminResponse,
WaitlistAdminListResponse,
WaitlistSignupListResponse,
WaitlistCreateRequest,
WaitlistUpdateRequest,
} from "@/lib/autogpt-server-api/types";
export async function getWaitlistsAdmin(): Promise<WaitlistAdminListResponse> {
const api = new BackendAPI();
const response = await api.getWaitlistsAdmin();
return response;
}
export async function getWaitlistAdmin(
waitlistId: string,
): Promise<WaitlistAdminResponse> {
const api = new BackendAPI();
const response = await api.getWaitlistAdmin(waitlistId);
return response;
}
export async function createWaitlist(
data: WaitlistCreateRequest,
): Promise<WaitlistAdminResponse> {
const api = new BackendAPI();
const response = await api.createWaitlist(data);
revalidatePath("/admin/waitlist");
return response;
}
export async function updateWaitlist(
waitlistId: string,
data: WaitlistUpdateRequest,
): Promise<WaitlistAdminResponse> {
const api = new BackendAPI();
const response = await api.updateWaitlist(waitlistId, data);
revalidatePath("/admin/waitlist");
return response;
}
export async function deleteWaitlist(waitlistId: string): Promise<void> {
const api = new BackendAPI();
await api.deleteWaitlist(waitlistId);
revalidatePath("/admin/waitlist");
}
export async function getWaitlistSignups(
waitlistId: string,
): Promise<WaitlistSignupListResponse> {
const api = new BackendAPI();
const response = await api.getWaitlistSignups(waitlistId);
return response;
}
export async function linkWaitlistToListing(
waitlistId: string,
storeListingId: string,
): Promise<WaitlistAdminResponse> {
const api = new BackendAPI();
const response = await api.linkWaitlistToListing(waitlistId, storeListingId);
revalidatePath("/admin/waitlist");
return response;
}

View File

@@ -0,0 +1,233 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/__legacy__/ui/dialog";
import { Input } from "@/components/__legacy__/ui/input";
import { Label } from "@/components/__legacy__/ui/label";
import { Textarea } from "@/components/__legacy__/ui/textarea";
import { createWaitlist } from "../actions";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
export function CreateWaitlistButton() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const [formData, setFormData] = useState({
name: "",
slug: "",
subHeading: "",
description: "",
categories: "",
imageUrls: "",
videoUrl: "",
agentOutputDemoUrl: "",
});
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
}
function generateSlug(name: string) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await createWaitlist({
name: formData.name,
slug: formData.slug || generateSlug(formData.name),
subHeading: formData.subHeading,
description: formData.description,
categories: formData.categories
? formData.categories.split(",").map((c) => c.trim())
: [],
imageUrls: formData.imageUrls
? formData.imageUrls.split(",").map((u) => u.trim())
: [],
videoUrl: formData.videoUrl || null,
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
});
toast({
title: "Success",
description: "Waitlist created successfully",
});
setOpen(false);
setFormData({
name: "",
slug: "",
subHeading: "",
description: "",
categories: "",
imageUrls: "",
videoUrl: "",
agentOutputDemoUrl: "",
});
router.refresh();
} catch (error) {
console.error("Error creating waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to create waitlist",
});
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Waitlist
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Create New Waitlist</DialogTitle>
<DialogDescription>
Create a new waitlist for an upcoming agent. Users can sign up to be
notified when it launches.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="SEO Analysis Agent"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
name="slug"
value={formData.slug}
onChange={handleChange}
placeholder="seo-analysis-agent (auto-generated if empty)"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="subHeading">Subheading *</Label>
<Input
id="subHeading"
name="subHeading"
value={formData.subHeading}
onChange={handleChange}
placeholder="Analyze your website's SEO in minutes"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Detailed description of what this agent does..."
rows={4}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="categories">Categories (comma-separated)</Label>
<Input
id="categories"
name="categories"
value={formData.categories}
onChange={handleChange}
placeholder="SEO, Marketing, Analysis"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="imageUrls">Image URLs (comma-separated)</Label>
<Input
id="imageUrls"
name="imageUrls"
value={formData.imageUrls}
onChange={handleChange}
placeholder="https://example.com/image1.jpg, https://example.com/image2.jpg"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="videoUrl">Video URL (optional)</Label>
<Input
id="videoUrl"
name="videoUrl"
value={formData.videoUrl}
onChange={handleChange}
placeholder="https://youtube.com/watch?v=..."
/>
</div>
<div className="grid gap-2">
<Label htmlFor="agentOutputDemoUrl">
Output Demo URL (optional)
</Label>
<Input
id="agentOutputDemoUrl"
name="agentOutputDemoUrl"
value={formData.agentOutputDemoUrl}
onChange={handleChange}
placeholder="https://example.com/demo-output.mp4"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={loading}>
Create Waitlist
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { usePutV2UpdateWaitlist } from "@/app/api/__generated__/endpoints/admin/admin";
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
import type { WaitlistUpdateRequest } from "@/app/api/__generated__/models/waitlistUpdateRequest";
import { WaitlistExternalStatus } from "@/app/api/__generated__/models/waitlistExternalStatus";
type EditWaitlistDialogProps = {
waitlist: WaitlistAdminResponse;
onClose: () => void;
onSave: () => void;
};
const STATUS_OPTIONS = [
{ value: WaitlistExternalStatus.NOT_STARTED, label: "Not Started" },
{ value: WaitlistExternalStatus.WORK_IN_PROGRESS, label: "Work In Progress" },
{ value: WaitlistExternalStatus.DONE, label: "Done" },
{ value: WaitlistExternalStatus.CANCELED, label: "Canceled" },
];
export function EditWaitlistDialog({
waitlist,
onClose,
onSave,
}: EditWaitlistDialogProps) {
const { toast } = useToast();
const updateWaitlistMutation = usePutV2UpdateWaitlist();
const [formData, setFormData] = useState({
name: waitlist.name,
slug: waitlist.slug,
subHeading: waitlist.subHeading,
description: waitlist.description,
categories: waitlist.categories.join(", "),
imageUrls: waitlist.imageUrls.join(", "),
videoUrl: waitlist.videoUrl || "",
agentOutputDemoUrl: waitlist.agentOutputDemoUrl || "",
status: waitlist.status,
storeListingId: waitlist.storeListingId || "",
});
function handleInputChange(id: string, value: string) {
setFormData((prev) => ({
...prev,
[id]: value,
}));
}
function handleStatusChange(value: string) {
setFormData((prev) => ({
...prev,
status: value as WaitlistExternalStatus,
}));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const updateData: WaitlistUpdateRequest = {
name: formData.name,
slug: formData.slug,
subHeading: formData.subHeading,
description: formData.description,
categories: formData.categories
? formData.categories.split(",").map((c) => c.trim())
: [],
imageUrls: formData.imageUrls
? formData.imageUrls.split(",").map((u) => u.trim())
: [],
videoUrl: formData.videoUrl || null,
agentOutputDemoUrl: formData.agentOutputDemoUrl || null,
status: formData.status,
storeListingId: formData.storeListingId || null,
};
updateWaitlistMutation.mutate(
{ waitlistId: waitlist.id, data: updateData },
{
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Success",
description: "Waitlist updated successfully",
});
onSave();
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to update waitlist",
});
}
},
onError: () => {
toast({
variant: "destructive",
title: "Error",
description: "Failed to update waitlist",
});
},
},
);
}
return (
<Dialog
title="Edit Waitlist"
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "600px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
Update the waitlist details. Changes will be reflected immediately.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Input
id="name"
label="Name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
/>
<Input
id="slug"
label="Slug"
value={formData.slug}
onChange={(e) => handleInputChange("slug", e.target.value)}
/>
<Input
id="subHeading"
label="Subheading"
value={formData.subHeading}
onChange={(e) => handleInputChange("subHeading", e.target.value)}
required
/>
<Input
id="description"
label="Description"
type="textarea"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
rows={4}
required
/>
<Select
id="status"
label="Status"
value={formData.status}
onValueChange={handleStatusChange}
options={STATUS_OPTIONS}
/>
<Input
id="categories"
label="Categories (comma-separated)"
value={formData.categories}
onChange={(e) => handleInputChange("categories", e.target.value)}
/>
<Input
id="imageUrls"
label="Image URLs (comma-separated)"
value={formData.imageUrls}
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
/>
<Input
id="videoUrl"
label="Video URL"
value={formData.videoUrl}
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
/>
<Input
id="agentOutputDemoUrl"
label="Output Demo URL"
value={formData.agentOutputDemoUrl}
onChange={(e) =>
handleInputChange("agentOutputDemoUrl", e.target.value)
}
/>
<Input
id="storeListingId"
label="Store Listing ID (for linking)"
value={formData.storeListingId}
onChange={(e) =>
handleInputChange("storeListingId", e.target.value)
}
placeholder="Leave empty if not linked"
/>
<Dialog.Footer>
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" loading={updateWaitlistMutation.isPending}>
Save Changes
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
import { getWaitlistSignups } from "../actions";
import type { WaitlistSignupListResponse } from "@/lib/autogpt-server-api/types";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { User, Mail, Download } from "lucide-react";
type WaitlistSignupsDialogProps = {
waitlistId: string;
onClose: () => void;
};
export function WaitlistSignupsDialog({
waitlistId,
onClose,
}: WaitlistSignupsDialogProps) {
const [loading, setLoading] = useState(true);
const [signups, setSignups] = useState<WaitlistSignupListResponse | null>(
null,
);
const { toast } = useToast();
useEffect(() => {
async function loadSignups() {
try {
const response = await getWaitlistSignups(waitlistId);
setSignups(response);
} catch (error) {
console.error("Error loading signups:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load signups",
});
} finally {
setLoading(false);
}
}
loadSignups();
}, [waitlistId, toast]);
function exportToCSV() {
if (!signups) return;
const headers = ["Type", "Email", "User ID", "Username"];
const rows = signups.signups.map((signup) => [
signup.type,
signup.email || "",
signup.userId || "",
signup.username || "",
]);
const escapeCell = (cell: string) => `"${cell.replace(/"/g, '""')}"`;
const csvContent = [
headers.join(","),
...rows.map((row) => row.map(escapeCell).join(",")),
].join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `waitlist-${waitlistId}-signups.csv`;
a.click();
window.URL.revokeObjectURL(url);
}
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Waitlist Signups</DialogTitle>
<DialogDescription>
{signups
? `${signups.totalCount} total signups`
: "Loading signups..."}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-10 text-center">Loading signups...</div>
) : signups && signups.signups.length > 0 ? (
<>
<div className="flex justify-end">
<Button variant="secondary" size="small" onClick={exportToCSV}>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<div className="max-h-[400px] overflow-y-auto rounded-md border">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium">Type</TableHead>
<TableHead className="font-medium">
Email / Username
</TableHead>
<TableHead className="font-medium">User ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{signups.signups.map((signup, index) => (
<TableRow key={index}>
<TableCell>
{signup.type === "user" ? (
<span className="flex items-center gap-1 text-blue-600">
<User className="h-4 w-4" /> User
</span>
) : (
<span className="flex items-center gap-1 text-gray-600">
<Mail className="h-4 w-4" /> Email
</span>
)}
</TableCell>
<TableCell>
{signup.type === "user"
? signup.username || signup.email
: signup.email}
</TableCell>
<TableCell className="font-mono text-sm">
{signup.userId || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
) : (
<div className="py-10 text-center text-gray-500">
No signups yet for this waitlist.
</div>
)}
<div className="flex justify-end">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/__legacy__/ui/table";
import { Button } from "@/components/atoms/Button/Button";
import { getWaitlistsAdmin, deleteWaitlist } from "../actions";
import type { WaitlistAdminResponse } from "@/lib/autogpt-server-api/types";
import { EditWaitlistDialog } from "./EditWaitlistDialog";
import { WaitlistSignupsDialog } from "./WaitlistSignupsDialog";
import { Trash2, Edit, Users, Link } from "lucide-react";
import { useToast } from "@/components/molecules/Toast/use-toast";
export function WaitlistTable() {
const [waitlists, setWaitlists] = useState<WaitlistAdminResponse[]>([]);
const [loading, setLoading] = useState(true);
const [editingWaitlist, setEditingWaitlist] =
useState<WaitlistAdminResponse | null>(null);
const [viewingSignups, setViewingSignups] = useState<string | null>(null);
const { toast } = useToast();
async function loadWaitlists() {
try {
const response = await getWaitlistsAdmin();
setWaitlists(response.waitlists);
} catch (error) {
console.error("Error loading waitlists:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load waitlists",
});
} finally {
setLoading(false);
}
}
useEffect(() => {
loadWaitlists();
}, []);
async function handleDelete(waitlistId: string) {
if (!confirm("Are you sure you want to delete this waitlist?")) return;
try {
await deleteWaitlist(waitlistId);
toast({
title: "Success",
description: "Waitlist deleted successfully",
});
loadWaitlists();
} catch (error) {
console.error("Error deleting waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete waitlist",
});
}
}
function formatStatus(status: string) {
const statusColors: Record<string, string> = {
NOT_STARTED: "bg-gray-100 text-gray-800",
WORK_IN_PROGRESS: "bg-blue-100 text-blue-800",
DONE: "bg-green-100 text-green-800",
CANCELED: "bg-red-100 text-red-800",
};
return (
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${statusColors[status] || "bg-gray-100"}`}
>
{status.replace(/_/g, " ")}
</span>
);
}
function formatDate(dateStr: string) {
if (!dateStr) return "-";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(dateStr));
}
if (loading) {
return <div className="py-10 text-center">Loading waitlists...</div>;
}
if (waitlists.length === 0) {
return (
<div className="py-10 text-center text-gray-500">
No waitlists found. Create one to get started!
</div>
);
}
return (
<>
<div className="rounded-md border bg-white">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium">Name</TableHead>
<TableHead className="font-medium">Status</TableHead>
<TableHead className="font-medium">Signups</TableHead>
<TableHead className="font-medium">Votes</TableHead>
<TableHead className="font-medium">Created</TableHead>
<TableHead className="font-medium">Linked Agent</TableHead>
<TableHead className="font-medium">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{waitlists.map((waitlist) => (
<TableRow key={waitlist.id}>
<TableCell>
<div>
<div className="font-medium">{waitlist.name}</div>
<div className="text-sm text-gray-500">
{waitlist.subHeading}
</div>
</div>
</TableCell>
<TableCell>{formatStatus(waitlist.status)}</TableCell>
<TableCell>{waitlist.signupCount}</TableCell>
<TableCell>{waitlist.votes}</TableCell>
<TableCell>{formatDate(waitlist.createdAt)}</TableCell>
<TableCell>
{waitlist.storeListingId ? (
<span className="text-green-600">
<Link className="inline h-4 w-4" /> Linked
</span>
) : (
<span className="text-gray-400">Not linked</span>
)}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="small"
onClick={() => setViewingSignups(waitlist.id)}
title="View signups"
>
<Users className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="small"
onClick={() => setEditingWaitlist(waitlist)}
title="Edit"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="small"
onClick={() => handleDelete(waitlist.id)}
title="Delete"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{editingWaitlist && (
<EditWaitlistDialog
waitlist={editingWaitlist}
onClose={() => setEditingWaitlist(null)}
onSave={() => {
setEditingWaitlist(null);
loadWaitlists();
}}
/>
)}
{viewingSignups && (
<WaitlistSignupsDialog
waitlistId={viewingSignups}
onClose={() => setViewingSignups(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,36 @@
import { withRoleAccess } from "@/lib/withRoleAccess";
import { Suspense } from "react";
import { WaitlistTable } from "./components/WaitlistTable";
import { CreateWaitlistButton } from "./components/CreateWaitlistButton";
function WaitlistDashboard() {
return (
<div className="mx-auto p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Waitlist Management</h1>
<p className="text-gray-500">
Manage upcoming agent waitlists and track signups
</p>
</div>
<CreateWaitlistButton />
</div>
<Suspense
fallback={
<div className="py-10 text-center">Loading waitlists...</div>
}
>
<WaitlistTable />
</Suspense>
</div>
</div>
);
}
export default async function WaitlistDashboardPage() {
const withAdminAccess = await withRoleAccess(["admin"]);
const ProtectedWaitlistDashboard = await withAdminAccess(WaitlistDashboard);
return <ProtectedWaitlistDashboard />;
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { Input } from "@/components/__legacy__/ui/input";
import { Label } from "@/components/__legacy__/ui/label";
import { StoreWaitlistEntry } from "@/lib/autogpt-server-api/types";
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
import { useToast } from "@/components/molecules/Toast/use-toast";
import BackendAPI from "@/lib/autogpt-server-api/client";
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("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const { toast } = useToast();
async function handleJoin() {
setLoading(true);
try {
const api = new BackendAPI();
await api.joinWaitlist(waitlist.waitlist_id, user ? undefined : email);
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);
} catch (error) {
console.error("Error joining waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to join waitlist. Please try again.",
});
} finally {
setLoading(false);
}
}
if (success) {
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]">
<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" />
</div>
<DialogTitle className="mb-2 text-center text-xl">
You&apos;re on the list!
</DialogTitle>
<DialogDescription className="text-center">
We&apos;ll notify you when {waitlist.name} is ready.
</DialogDescription>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Join waitlist</DialogTitle>
<DialogDescription>
{user
? `Get notified when ${waitlist.name} is ready to use.`
: `Enter your email to get notified when ${waitlist.name} is ready.`}
</DialogDescription>
</DialogHeader>
<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>
) : (
<div className="space-y-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleJoin}
loading={loading}
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>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -8,6 +8,7 @@ import { useMainMarketplacePage } from "./useMainMarketplacePage";
import { FeaturedCreators } from "../FeaturedCreators/FeaturedCreators";
import { MainMarketplacePageLoading } from "../MainMarketplacePageLoading";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { WaitlistSection } from "../WaitlistSection/WaitlistSection";
export const MainMarkeplacePage = () => {
const { featuredAgents, topAgents, featuredCreators, isLoading, hasError } =
@@ -46,6 +47,10 @@ export const MainMarkeplacePage = () => {
{/* 100px margin because our featured sections button are placed 40px below the container */}
<Separator className="mb-6 mt-24" />
{/* Waitlist Section - "Help Shape What's Next" */}
<WaitlistSection />
<Separator className="mb-6 mt-12" />
{topAgents && (
<AgentsSection sectionTitle="Top Agents" agents={topAgents.agents} />
)}

View File

@@ -0,0 +1,105 @@
"use client";
import Image from "next/image";
import { Button } from "@/components/atoms/Button/Button";
import { Check } from "@phosphor-icons/react";
interface WaitlistCardProps {
name: string;
subHeading: string;
description: string;
imageUrl: string | null;
isMember?: boolean;
onCardClick: () => void;
onJoinClick: (e: React.MouseEvent) => void;
}
export function WaitlistCard({
name,
subHeading,
description,
imageUrl,
isMember = false,
onCardClick,
onJoinClick,
}: WaitlistCardProps) {
function handleJoinClick(e: React.MouseEvent) {
e.stopPropagation();
onJoinClick(e);
}
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"
onClick={onCardClick}
data-testid="waitlist-card"
role="button"
tabIndex={0}
aria-label={`${name} waitlist card`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onCardClick();
}
}}
>
{/* Image Section */}
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-3xl md:aspect-[2.17/1]">
{imageUrl ? (
<Image
src={imageUrl}
alt={`${name} preview image`}
fill
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800">
<span className="text-4xl font-bold text-neutral-400 dark:text-neutral-500">
{name.charAt(0)}
</span>
</div>
)}
</div>
<div className="mt-3 flex w-full flex-1 flex-col px-4">
{/* Name and Subheading */}
<div className="flex w-full flex-col">
<h3 className="line-clamp-1 font-poppins text-xl font-semibold text-[#272727] dark:text-neutral-100">
{name}
</h3>
<p className="mt-1 line-clamp-1 text-sm text-neutral-500 dark:text-neutral-400">
{subHeading}
</p>
</div>
{/* 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">
{description}
</p>
</div>
<div className="flex-grow" />
{/* Join Waitlist Button */}
<div className="mt-4 w-full pb-4">
{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" size={16} weight="bold" />
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

@@ -0,0 +1,132 @@
"use client";
import Image from "next/image";
import { Button } from "@/components/atoms/Button/Button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { StoreWaitlistEntry } from "@/lib/autogpt-server-api/types";
import { Check } from "lucide-react";
interface WaitlistDetailModalProps {
waitlist: StoreWaitlistEntry;
isMember?: boolean;
onClose: () => void;
onJoin: () => void;
}
export function WaitlistDetailModal({
waitlist,
isMember = false,
onClose,
onJoin,
}: WaitlistDetailModalProps) {
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>{waitlist.name}</DialogTitle>
</DialogHeader>
<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>
)}
{/* Subheading */}
<p className="text-lg font-medium text-neutral-700 dark:text-neutral-300">
{waitlist.subHeading}
</p>
{/* 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>
</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 */}
{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

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/__legacy__/ui/carousel";
import { WaitlistCard } from "../WaitlistCard/WaitlistCard";
import { WaitlistDetailModal } from "../WaitlistDetailModal/WaitlistDetailModal";
import { JoinWaitlistModal } from "../JoinWaitlistModal/JoinWaitlistModal";
import { StoreWaitlistEntry } from "@/lib/autogpt-server-api/types";
import { useWaitlistSection } from "./useWaitlistSection";
export function WaitlistSection() {
const { waitlists, joinedWaitlistIds, isLoading, hasError, markAsJoined } =
useWaitlistSection();
const [selectedWaitlist, setSelectedWaitlist] =
useState<StoreWaitlistEntry | null>(null);
const [joiningWaitlist, setJoiningWaitlist] =
useState<StoreWaitlistEntry | null>(null);
function handleCardClick(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
if (isLoading || hasError || !waitlists || waitlists.length === 0) {
return null;
}
return (
<div className="flex flex-col items-center justify-center">
<div className="w-full max-w-[1360px]">
{/* Section Header */}
<div className="mb-6">
<h2 className="font-poppins text-2xl font-semibold text-[#282828] dark:text-neutral-200">
Help Shape What&apos;s Next
</h2>
<p className="mt-2 text-base text-neutral-600 dark:text-neutral-400">
These agents are in development. Your interest helps us prioritize
what gets built and we&apos;ll notify you when they&apos;re ready.
</p>
</div>
{/* Mobile Carousel View */}
<Carousel
className="md:hidden"
opts={{
loop: true,
}}
>
<CarouselContent>
{waitlists.map((waitlist) => (
<CarouselItem
key={waitlist.waitlist_id}
className="min-w-64 max-w-71"
>
<WaitlistCard
name={waitlist.name}
subHeading={waitlist.subHeading}
description={waitlist.description}
imageUrl={waitlist.imageUrls[0] || null}
isMember={joinedWaitlistIds.has(waitlist.waitlist_id)}
onCardClick={() => handleCardClick(waitlist)}
onJoinClick={() => handleJoinClick(waitlist)}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
{/* Desktop Grid View */}
<div className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3">
{waitlists.map((waitlist) => (
<WaitlistCard
key={waitlist.waitlist_id}
name={waitlist.name}
subHeading={waitlist.subHeading}
description={waitlist.description}
imageUrl={waitlist.imageUrls[0] || null}
isMember={joinedWaitlistIds.has(waitlist.waitlist_id)}
onCardClick={() => handleCardClick(waitlist)}
onJoinClick={() => handleJoinClick(waitlist)}
/>
))}
</div>
</div>
{/* Detail Modal */}
{selectedWaitlist && (
<WaitlistDetailModal
waitlist={selectedWaitlist}
isMember={joinedWaitlistIds.has(selectedWaitlist.waitlist_id)}
onClose={() => setSelectedWaitlist(null)}
onJoin={handleJoinFromDetail}
/>
)}
{/* Join Modal */}
{joiningWaitlist && (
<JoinWaitlistModal
waitlist={joiningWaitlist}
onClose={() => setJoiningWaitlist(null)}
onSuccess={() => handleJoinSuccess(joiningWaitlist.waitlist_id)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
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 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);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [user]);
// 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

@@ -4965,6 +4965,303 @@
}
}
},
"/api/store/admin/waitlist": {
"get": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "List All Waitlists",
"description": "Get all waitlists with admin details (admin only).\n\nReturns:\n WaitlistAdminListResponse with all waitlists",
"operationId": "getV2List all waitlists",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminListResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
},
"post": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Create Waitlist",
"description": "Create a new waitlist (admin only).\n\nArgs:\n request: Waitlist creation details\n user_id: Authenticated admin user creating the waitlist\n\nReturns:\n WaitlistAdminResponse with the created waitlist details",
"operationId": "postV2Create waitlist",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/WaitlistCreateRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/store/admin/waitlist/{waitlist_id}": {
"delete": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Delete Waitlist",
"description": "Soft delete a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to delete\n\nReturns:\n Success message",
"operationId": "deleteV2Delete waitlist",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
},
"get": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Get Waitlist Details",
"description": "Get a single waitlist with admin details (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to retrieve\n\nReturns:\n WaitlistAdminResponse with waitlist details",
"operationId": "getV2Get waitlist details",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
},
"put": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Update Waitlist",
"description": "Update a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist to update\n request: Fields to update\n\nReturns:\n WaitlistAdminResponse with updated waitlist details",
"operationId": "putV2Update waitlist",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/WaitlistUpdateRequest" }
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/admin/waitlist/{waitlist_id}/link": {
"post": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Link Waitlist to Store Listing",
"description": "Link a waitlist to a store listing (admin only).\n\nWhen the linked store listing is approved/published, waitlist users\nwill be automatically notified.\n\nArgs:\n waitlist_id: ID of the waitlist\n store_listing_id: ID of the store listing to link\n\nReturns:\n WaitlistAdminResponse with updated waitlist details",
"operationId": "postV2Link waitlist to store listing",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "string",
"description": "The ID of the store listing",
"title": "Store Listing Id"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistAdminResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/admin/waitlist/{waitlist_id}/signups": {
"get": {
"tags": ["v2", "admin", "store", "admin", "waitlist"],
"summary": "Get Waitlist Signups",
"description": "Get all signups for a waitlist (admin only).\n\nArgs:\n waitlist_id: ID of the waitlist\n\nReturns:\n WaitlistSignupListResponse with all signups",
"operationId": "getV2Get waitlist signups",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WaitlistSignupListResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/store/agents": {
"get": {
"tags": ["v2", "store", "public"],
@@ -5747,6 +6044,101 @@
}
}
},
"/api/store/waitlist": {
"get": {
"tags": ["v2", "store", "public"],
"summary": "Get the agent waitlist",
"description": "Get all active waitlists for public display.",
"operationId": "getV2Get the agent waitlist",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StoreWaitlistsAllResponse"
}
}
}
}
}
}
},
"/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"],
"summary": "Add self to the agent waitlist",
"description": "Add the current user to the agent waitlist.",
"operationId": "postV2Add self to the agent waitlist",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "waitlist_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"description": "The ID of the waitlist to join",
"title": "Waitlist Id"
},
"description": "The ID of the waitlist to join"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_postV2Add_self_to_the_agent_waitlist"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/StoreWaitlistEntry" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/health": {
"get": {
"tags": ["health"],
@@ -6494,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": {
@@ -8349,7 +8752,8 @@
"REFUND_REQUEST",
"REFUND_PROCESSED",
"AGENT_APPROVED",
"AGENT_REJECTED"
"AGENT_REJECTED",
"WAITLIST_LAUNCH"
],
"title": "NotificationType"
},
@@ -9885,6 +10289,68 @@
"required": ["submissions", "pagination"],
"title": "StoreSubmissionsResponse"
},
"StoreWaitlistEntry": {
"properties": {
"waitlist_id": { "type": "string", "title": "Waitlist Id" },
"storeListing": {
"anyOf": [
{ "$ref": "#/components/schemas/StoreListingWithVersions" },
{ "type": "null" }
]
},
"owner": {
"anyOf": [
{ "$ref": "#/components/schemas/User" },
{ "type": "null" }
]
},
"slug": { "type": "string", "title": "Slug" },
"name": { "type": "string", "title": "Name" },
"subHeading": { "type": "string", "title": "Subheading" },
"videoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Videourl"
},
"agentOutputDemoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
},
"imageUrls": {
"items": { "type": "string" },
"type": "array",
"title": "Imageurls"
},
"description": { "type": "string", "title": "Description" },
"categories": {
"items": { "type": "string" },
"type": "array",
"title": "Categories"
}
},
"type": "object",
"required": [
"waitlist_id",
"slug",
"name",
"subHeading",
"imageUrls",
"description",
"categories"
],
"title": "StoreWaitlistEntry"
},
"StoreWaitlistsAllResponse": {
"properties": {
"listings": {
"items": { "$ref": "#/components/schemas/StoreWaitlistEntry" },
"type": "array",
"title": "Listings"
}
},
"type": "object",
"required": ["listings"],
"title": "StoreWaitlistsAllResponse"
},
"SubmissionStatus": {
"type": "string",
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],
@@ -11368,6 +11834,128 @@
],
"title": "UploadFileResponse"
},
"User": {
"properties": {
"id": { "type": "string", "title": "Id", "description": "User ID" },
"email": {
"type": "string",
"title": "Email",
"description": "User email address"
},
"email_verified": {
"type": "boolean",
"title": "Email Verified",
"description": "Whether email is verified",
"default": true
},
"name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Name",
"description": "User display name"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At",
"description": "When user was created"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At",
"description": "When user was last updated"
},
"metadata": {
"additionalProperties": true,
"type": "object",
"title": "Metadata",
"description": "User metadata as dict"
},
"integrations": {
"type": "string",
"title": "Integrations",
"description": "Encrypted integrations data",
"default": ""
},
"stripe_customer_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Stripe Customer Id",
"description": "Stripe customer ID"
},
"top_up_config": {
"anyOf": [
{ "$ref": "#/components/schemas/AutoTopUpConfig" },
{ "type": "null" }
],
"description": "Top up configuration"
},
"max_emails_per_day": {
"type": "integer",
"title": "Max Emails Per Day",
"description": "Maximum emails per day",
"default": 3
},
"notify_on_agent_run": {
"type": "boolean",
"title": "Notify On Agent Run",
"description": "Notify on agent run",
"default": true
},
"notify_on_zero_balance": {
"type": "boolean",
"title": "Notify On Zero Balance",
"description": "Notify on zero balance",
"default": true
},
"notify_on_low_balance": {
"type": "boolean",
"title": "Notify On Low Balance",
"description": "Notify on low balance",
"default": true
},
"notify_on_block_execution_failed": {
"type": "boolean",
"title": "Notify On Block Execution Failed",
"description": "Notify on block execution failure",
"default": true
},
"notify_on_continuous_agent_error": {
"type": "boolean",
"title": "Notify On Continuous Agent Error",
"description": "Notify on continuous agent error",
"default": true
},
"notify_on_daily_summary": {
"type": "boolean",
"title": "Notify On Daily Summary",
"description": "Notify on daily summary",
"default": true
},
"notify_on_weekly_summary": {
"type": "boolean",
"title": "Notify On Weekly Summary",
"description": "Notify on weekly summary",
"default": true
},
"notify_on_monthly_summary": {
"type": "boolean",
"title": "Notify On Monthly Summary",
"description": "Notify on monthly summary",
"default": true
},
"timezone": {
"type": "string",
"title": "Timezone",
"description": "User timezone (IANA timezone identifier or 'not-set')",
"default": "not-set"
}
},
"additionalProperties": false,
"type": "object",
"required": ["id", "email", "created_at", "updated_at"],
"title": "User",
"description": "Application-layer User model with snake_case convention."
},
"UserHistoryResponse": {
"properties": {
"history": {
@@ -11629,6 +12217,201 @@
"required": ["loc", "msg", "type"],
"title": "ValidationError"
},
"WaitlistAdminListResponse": {
"properties": {
"waitlists": {
"items": { "$ref": "#/components/schemas/WaitlistAdminResponse" },
"type": "array",
"title": "Waitlists"
},
"totalCount": { "type": "integer", "title": "Totalcount" }
},
"type": "object",
"required": ["waitlists", "totalCount"],
"title": "WaitlistAdminListResponse",
"description": "Response model for listing all waitlists (admin view)."
},
"WaitlistAdminResponse": {
"properties": {
"id": { "type": "string", "title": "Id" },
"createdAt": { "type": "string", "title": "Createdat" },
"updatedAt": { "type": "string", "title": "Updatedat" },
"slug": { "type": "string", "title": "Slug" },
"name": { "type": "string", "title": "Name" },
"subHeading": { "type": "string", "title": "Subheading" },
"description": { "type": "string", "title": "Description" },
"categories": {
"items": { "type": "string" },
"type": "array",
"title": "Categories"
},
"imageUrls": {
"items": { "type": "string" },
"type": "array",
"title": "Imageurls"
},
"videoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Videourl"
},
"agentOutputDemoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
},
"status": { "$ref": "#/components/schemas/WaitlistExternalStatus" },
"votes": { "type": "integer", "title": "Votes" },
"signupCount": { "type": "integer", "title": "Signupcount" },
"storeListingId": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Storelistingid"
},
"owningUserId": { "type": "string", "title": "Owninguserid" }
},
"type": "object",
"required": [
"id",
"createdAt",
"updatedAt",
"slug",
"name",
"subHeading",
"description",
"categories",
"imageUrls",
"status",
"votes",
"signupCount",
"owningUserId"
],
"title": "WaitlistAdminResponse",
"description": "Admin response model with full waitlist details including internal data."
},
"WaitlistCreateRequest": {
"properties": {
"name": { "type": "string", "title": "Name" },
"slug": { "type": "string", "title": "Slug" },
"subHeading": { "type": "string", "title": "Subheading" },
"description": { "type": "string", "title": "Description" },
"categories": {
"items": { "type": "string" },
"type": "array",
"title": "Categories",
"default": []
},
"imageUrls": {
"items": { "type": "string" },
"type": "array",
"title": "Imageurls",
"default": []
},
"videoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Videourl"
},
"agentOutputDemoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
}
},
"type": "object",
"required": ["name", "slug", "subHeading", "description"],
"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" },
"userId": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Userid"
},
"email": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Email"
},
"username": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Username"
}
},
"type": "object",
"required": ["type"],
"title": "WaitlistSignup",
"description": "Individual signup entry for a waitlist."
},
"WaitlistSignupListResponse": {
"properties": {
"waitlistId": { "type": "string", "title": "Waitlistid" },
"signups": {
"items": { "$ref": "#/components/schemas/WaitlistSignup" },
"type": "array",
"title": "Signups"
},
"totalCount": { "type": "integer", "title": "Totalcount" }
},
"type": "object",
"required": ["waitlistId", "signups", "totalCount"],
"title": "WaitlistSignupListResponse",
"description": "Response model for listing waitlist signups."
},
"WaitlistUpdateRequest": {
"properties": {
"name": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Name"
},
"slug": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Slug"
},
"subHeading": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Subheading"
},
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"categories": {
"anyOf": [
{ "items": { "type": "string" }, "type": "array" },
{ "type": "null" }
],
"title": "Categories"
},
"imageUrls": {
"anyOf": [
{ "items": { "type": "string" }, "type": "array" },
{ "type": "null" }
],
"title": "Imageurls"
},
"videoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Videourl"
},
"agentOutputDemoUrl": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Agentoutputdemourl"
},
"status": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Status"
},
"storeListingId": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Storelistingid"
}
},
"type": "object",
"title": "WaitlistUpdateRequest",
"description": "Request model for updating a waitlist."
},
"Webhook": {
"properties": {
"id": { "type": "string", "title": "Id" },
@@ -11679,6 +12462,7 @@
"in": "header",
"name": "X-Postmark-Webhook-Token"
},
"HTTPBearer": { "type": "http", "scheme": "bearer" },
"HTTPBearerJWT": {
"type": "http",
"scheme": "bearer",

View File

@@ -67,6 +67,13 @@ import type {
User,
UserPasswordCredentials,
UsersBalanceHistoryResponse,
StoreWaitlistEntry,
StoreWaitlistsAllResponse,
WaitlistAdminListResponse,
WaitlistAdminResponse,
WaitlistCreateRequest,
WaitlistSignupListResponse,
WaitlistUpdateRequest,
WebSocketNotification,
} from "./types";
@@ -616,6 +623,67 @@ export default class BackendAPI {
return this._get(url);
}
/////////////////////////////////////////
///////// Waitlist Admin API ////////////
/////////////////////////////////////////
getWaitlistsAdmin(): Promise<WaitlistAdminListResponse> {
return this._get("/store/admin/waitlist");
}
getWaitlistAdmin(waitlistId: string): Promise<WaitlistAdminResponse> {
return this._get(`/store/admin/waitlist/${waitlistId}`);
}
createWaitlist(data: WaitlistCreateRequest): Promise<WaitlistAdminResponse> {
return this._request("POST", "/store/admin/waitlist", data);
}
updateWaitlist(
waitlistId: string,
data: WaitlistUpdateRequest,
): Promise<WaitlistAdminResponse> {
return this._request("PUT", `/store/admin/waitlist/${waitlistId}`, data);
}
deleteWaitlist(waitlistId: string): Promise<void> {
return this._request("DELETE", `/store/admin/waitlist/${waitlistId}`);
}
getWaitlistSignups(waitlistId: string): Promise<WaitlistSignupListResponse> {
return this._get(`/store/admin/waitlist/${waitlistId}/signups`);
}
linkWaitlistToListing(
waitlistId: string,
storeListingId: string,
): Promise<WaitlistAdminResponse> {
return this._request("POST", `/store/admin/waitlist/${waitlistId}/link`, {
store_listing_id: storeListingId,
});
}
/////////////////////////////////////////
///////// Public Waitlist API ///////////
/////////////////////////////////////////
getWaitlists(): Promise<StoreWaitlistsAllResponse> {
return this._get("/store/waitlist");
}
joinWaitlist(
waitlistId: string,
email?: string,
): Promise<StoreWaitlistEntry> {
return this._request("POST", `/store/waitlist/${waitlistId}/join`, {
email: email || null,
});
}
getMyWaitlistMemberships(): Promise<string[]> {
return this._get("/store/waitlist/my-memberships");
}
////////////////////////////////////////
//////////// V2 LIBRARY API ////////////
////////////////////////////////////////

View File

@@ -1102,6 +1102,86 @@ export type AddUserCreditsResponse = {
new_balance: number;
transaction_key: string;
};
// Waitlist Admin Types
export type WaitlistAdminResponse = {
id: string;
createdAt: string;
updatedAt: string;
slug: string;
name: string;
subHeading: string;
description: string;
categories: string[];
imageUrls: string[];
videoUrl: string | null;
agentOutputDemoUrl: string | null;
status: string;
votes: number;
signupCount: number;
storeListingId: string | null;
owningUserId: string;
};
export type WaitlistAdminListResponse = {
waitlists: WaitlistAdminResponse[];
totalCount: number;
};
export type WaitlistSignup = {
type: "user" | "email";
userId: string | null;
email: string | null;
username: string | null;
};
export type WaitlistSignupListResponse = {
waitlistId: string;
signups: WaitlistSignup[];
totalCount: number;
};
export type WaitlistCreateRequest = {
name: string;
slug: string;
subHeading: string;
description: string;
categories?: string[];
imageUrls?: string[];
videoUrl?: string | null;
agentOutputDemoUrl?: string | null;
};
export type WaitlistUpdateRequest = {
name?: string;
slug?: string;
subHeading?: string;
description?: string;
categories?: string[];
imageUrls?: string[];
videoUrl?: string | null;
agentOutputDemoUrl?: string | null;
status?: string;
storeListingId?: string | null;
};
// Public Waitlist Types
export type StoreWaitlistEntry = {
waitlist_id: string;
slug: string;
name: string;
subHeading: string;
description: string;
categories: string[];
imageUrls: string[];
videoUrl: string | null;
agentOutputDemoUrl: string | null;
};
export type StoreWaitlistsAllResponse = {
listings: StoreWaitlistEntry[];
};
const _stringFormatToDataTypeMap: Partial<Record<string, DataType>> = {
date: DataType.DATE,
time: DataType.TIME,