mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(platform): address remaining PR review feedback for waitlist
Backend fixes: - Fix optional field clearing by using model_fields_set - Re-fetch waitlist data after join operation - Only mark waitlist as DONE if all notifications succeed - Fix race condition in email removal with transaction - Rename waitlist_id to waitlistId for naming consistency Frontend fixes: - Migrate useWaitlistSection to generated API hooks - Migrate JoinWaitlistModal to design system + generated hooks - Migrate WaitlistSignupsDialog to design system + generated hooks - Replace lucide-react icons with Phosphor in WaitlistTable - Add proper error state in WaitlistSignupsDialog - Update waitlistId naming across components Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1988,7 +1988,7 @@ def _waitlist_to_store_entry(
|
||||
) -> store_model.StoreWaitlistEntry:
|
||||
"""Convert a WaitlistEntry to StoreWaitlistEntry for public display."""
|
||||
return store_model.StoreWaitlistEntry(
|
||||
waitlist_id=waitlist.id,
|
||||
waitlistId=waitlist.id,
|
||||
slug=waitlist.slug,
|
||||
name=waitlist.name,
|
||||
subHeading=waitlist.subHeading,
|
||||
@@ -2084,14 +2084,24 @@ async def add_user_to_waitlist(
|
||||
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},
|
||||
)
|
||||
# Use transaction to prevent race conditions
|
||||
if email:
|
||||
async with transaction() as tx:
|
||||
current_waitlist = await tx.waitlistentry.find_unique(
|
||||
where={"id": waitlist_id}
|
||||
)
|
||||
if current_waitlist and email in (
|
||||
current_waitlist.unafilliatedEmailUsers or []
|
||||
):
|
||||
updated_emails: list[str] = [
|
||||
e
|
||||
for e in (current_waitlist.unafilliatedEmailUsers or [])
|
||||
if e != email
|
||||
]
|
||||
await tx.waitlistentry.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
|
||||
@@ -2114,7 +2124,11 @@ async def add_user_to_waitlist(
|
||||
else:
|
||||
logger.debug(f"Email {email} already on waitlist {waitlist_id}")
|
||||
|
||||
return _waitlist_to_store_entry(waitlist)
|
||||
# Re-fetch to return updated data
|
||||
updated_waitlist = await prisma.models.WaitlistEntry.prisma().find_unique(
|
||||
where={"id": waitlist_id}
|
||||
)
|
||||
return _waitlist_to_store_entry(updated_waitlist or waitlist)
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
@@ -2235,7 +2249,8 @@ async def update_waitlist_admin(
|
||||
logger.info(f"Updating waitlist {waitlist_id}")
|
||||
|
||||
try:
|
||||
# Build update data from non-None fields
|
||||
# Build update data from explicitly provided fields
|
||||
# Use model_fields_set to allow clearing fields by setting them to None
|
||||
field_mappings = {
|
||||
"name": data.name,
|
||||
"slug": data.slug,
|
||||
@@ -2248,11 +2263,11 @@ async def update_waitlist_admin(
|
||||
"storeListingId": data.storeListingId,
|
||||
}
|
||||
update_data: dict[str, typing.Any] = {
|
||||
k: v for k, v in field_mappings.items() if v is not None
|
||||
k: v for k, v in field_mappings.items() if k in data.model_fields_set
|
||||
}
|
||||
|
||||
# Handle status separately due to enum conversion
|
||||
if data.status is not None:
|
||||
if "status" in data.model_fields_set and data.status is not None:
|
||||
update_data["status"] = prisma.enums.WaitlistExternalStatus(data.status)
|
||||
|
||||
if not update_data:
|
||||
@@ -2408,8 +2423,12 @@ async def notify_waitlist_users_on_launch(
|
||||
launched_at = datetime.now(tz=timezone.utc)
|
||||
|
||||
for waitlist in waitlists:
|
||||
# Track notification results for this waitlist
|
||||
users_to_notify = waitlist.joinedUsers or []
|
||||
failed_user_ids: list[str] = []
|
||||
|
||||
# Notify registered users
|
||||
for user in waitlist.joinedUsers or []:
|
||||
for user in users_to_notify:
|
||||
try:
|
||||
notification_data = WaitlistLaunchData(
|
||||
agent_name=agent_name,
|
||||
@@ -2430,6 +2449,7 @@ async def notify_waitlist_users_on_launch(
|
||||
logger.error(
|
||||
f"Failed to send waitlist launch notification to user {user.id}: {e}"
|
||||
)
|
||||
failed_user_ids.append(user.id)
|
||||
|
||||
# Note: For unaffiliated email users, you would need to send emails directly
|
||||
# since they don't have user IDs for the notification system.
|
||||
@@ -2441,12 +2461,18 @@ async def notify_waitlist_users_on_launch(
|
||||
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")
|
||||
# Only mark waitlist as DONE if all registered user notifications succeeded
|
||||
if not failed_user_ids:
|
||||
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")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Waitlist {waitlist.id} not marked as DONE due to "
|
||||
f"{len(failed_user_ids)} failed notifications"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sent {notification_count} waitlist launch notifications for store listing {store_listing_id}"
|
||||
|
||||
@@ -220,7 +220,7 @@ class ReviewSubmissionRequest(pydantic.BaseModel):
|
||||
|
||||
|
||||
class StoreWaitlistEntry(pydantic.BaseModel):
|
||||
waitlist_id: str
|
||||
waitlistId: str
|
||||
storeListing: StoreListingWithVersions | None = None
|
||||
owner: User | None = None
|
||||
slug: str
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
"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";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { User, Envelope, DownloadSimple } from "@phosphor-icons/react";
|
||||
import { useGetV2GetWaitlistSignups } from "@/app/api/__generated__/endpoints/admin/admin";
|
||||
|
||||
type WaitlistSignupsDialogProps = {
|
||||
waitlistId: string;
|
||||
@@ -31,31 +14,13 @@ export function WaitlistSignupsDialog({
|
||||
waitlistId,
|
||||
onClose,
|
||||
}: WaitlistSignupsDialogProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [signups, setSignups] = useState<WaitlistSignupListResponse | null>(
|
||||
null,
|
||||
);
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
data: signupsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useGetV2GetWaitlistSignups(waitlistId);
|
||||
|
||||
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]);
|
||||
const signups = signupsResponse?.status === 200 ? signupsResponse.data : null;
|
||||
|
||||
function exportToCSV() {
|
||||
if (!signups) return;
|
||||
@@ -84,79 +49,108 @@ export function WaitlistSignupsDialog({
|
||||
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>
|
||||
function renderContent() {
|
||||
if (isLoading) {
|
||||
return <div className="py-10 text-center">Loading signups...</div>;
|
||||
}
|
||||
|
||||
{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>
|
||||
)}
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="py-10 text-center text-red-500">
|
||||
Failed to load signups. Please try again.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!signups || signups.signups.length === 0) {
|
||||
return (
|
||||
<div className="py-10 text-center text-gray-500">
|
||||
No signups yet for this waitlist.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="small" onClick={exportToCSV}>
|
||||
<DownloadSimple className="mr-2 h-4 w-4" size={16} />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded-md border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
||||
Email / Username
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
||||
User ID
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{signups.signups.map((signup, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-4 py-3">
|
||||
{signup.type === "user" ? (
|
||||
<span className="flex items-center gap-1 text-blue-600">
|
||||
<User className="h-4 w-4" size={16} /> User
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-gray-600">
|
||||
<Envelope className="h-4 w-4" size={16} /> Email
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{signup.type === "user"
|
||||
? signup.username || signup.email
|
||||
: signup.email}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-sm">
|
||||
{signup.userId || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Waitlist Signups"
|
||||
controlled={{
|
||||
isOpen: true,
|
||||
set: async (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{ maxWidth: "700px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="mb-4 text-sm text-zinc-500">
|
||||
{signups
|
||||
? `${signups.totalCount} total signups`
|
||||
: "Loading signups..."}
|
||||
</p>
|
||||
|
||||
{renderContent()}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 { Trash, PencilSimple, Users, Link } from "@phosphor-icons/react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
export function WaitlistTable() {
|
||||
@@ -136,7 +136,7 @@ export function WaitlistTable() {
|
||||
<TableCell>
|
||||
{waitlist.storeListingId ? (
|
||||
<span className="text-green-600">
|
||||
<Link className="inline h-4 w-4" /> Linked
|
||||
<Link size={16} className="inline" /> Linked
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">Not linked</span>
|
||||
@@ -150,7 +150,7 @@ export function WaitlistTable() {
|
||||
onClick={() => setViewingSignups(waitlist.id)}
|
||||
title="View signups"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
<Users size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -158,7 +158,7 @@ export function WaitlistTable() {
|
||||
onClick={() => setEditingWaitlist(waitlist)}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<PencilSimple size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -166,7 +166,7 @@ export function WaitlistTable() {
|
||||
onClick={() => handleDelete(waitlist.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
<Trash size={16} className="text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -2,21 +2,13 @@
|
||||
|
||||
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 { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
|
||||
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import BackendAPI from "@/lib/autogpt-server-api/client";
|
||||
import { Check } from "lucide-react";
|
||||
import { usePostV2AddSelfToTheAgentWaitlist } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { Check } from "@phosphor-icons/react";
|
||||
|
||||
interface JoinWaitlistModalProps {
|
||||
waitlist: StoreWaitlistEntry;
|
||||
@@ -31,70 +23,101 @@ export function JoinWaitlistModal({
|
||||
}: JoinWaitlistModalProps) {
|
||||
const { user } = useSupabaseStore();
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const joinWaitlistMutation = usePostV2AddSelfToTheAgentWaitlist();
|
||||
|
||||
async function handleJoin() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const api = new BackendAPI();
|
||||
await api.joinWaitlist(waitlist.waitlist_id, user ? undefined : email);
|
||||
function handleJoin() {
|
||||
joinWaitlistMutation.mutate(
|
||||
{
|
||||
waitlistId: waitlist.waitlistId,
|
||||
data: { email: user ? undefined : email },
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.status === 200) {
|
||||
setSuccess(true);
|
||||
toast({
|
||||
title: "You're on the list!",
|
||||
description: `We'll notify you when ${waitlist.name} is ready.`,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
// Close after a short delay to show success state
|
||||
setTimeout(() => {
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to join waitlist. Please try again.",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to join waitlist. Please try again.",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<Dialog
|
||||
title=""
|
||||
controlled={{
|
||||
isOpen: true,
|
||||
set: async (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{ maxWidth: "400px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
|
||||
<Check className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
<Check
|
||||
className="h-8 w-8 text-green-600 dark:text-green-400"
|
||||
size={32}
|
||||
weight="bold"
|
||||
/>
|
||||
</div>
|
||||
<DialogTitle className="mb-2 text-center text-xl">
|
||||
<h2 className="mb-2 text-center text-xl font-semibold">
|
||||
You're on the list!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
</h2>
|
||||
<p className="text-center text-zinc-500">
|
||||
We'll notify you when {waitlist.name} is ready.
|
||||
</DialogDescription>
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog.Content>
|
||||
</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>
|
||||
<Dialog
|
||||
title="Join waitlist"
|
||||
controlled={{
|
||||
isOpen: true,
|
||||
set: async (open) => {
|
||||
if (!open) onClose();
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
styling={{ maxWidth: "400px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<p className="mb-4 text-sm text-zinc-500">
|
||||
{user
|
||||
? `Get notified when ${waitlist.name} is ready to use.`
|
||||
: `Enter your email to get notified when ${waitlist.name} is ready.`}
|
||||
</p>
|
||||
|
||||
<div className="py-4">
|
||||
{user ? (
|
||||
@@ -107,34 +130,32 @@ export function JoinWaitlistModal({
|
||||
</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>
|
||||
<Input
|
||||
id="email"
|
||||
label="Email address"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleJoin}
|
||||
loading={loading}
|
||||
loading={joinWaitlistMutation.isPending}
|
||||
disabled={!user && !email}
|
||||
className="bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600"
|
||||
>
|
||||
{user ? "Join waitlist" : "Join with email"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { WaitlistCard } from "../WaitlistCard/WaitlistCard";
|
||||
import { WaitlistDetailModal } from "../WaitlistDetailModal/WaitlistDetailModal";
|
||||
import { JoinWaitlistModal } from "../JoinWaitlistModal/JoinWaitlistModal";
|
||||
import { StoreWaitlistEntry } from "@/lib/autogpt-server-api/types";
|
||||
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
|
||||
import { useWaitlistSection } from "./useWaitlistSection";
|
||||
|
||||
export function WaitlistSection() {
|
||||
@@ -69,7 +69,7 @@ export function WaitlistSection() {
|
||||
<CarouselContent>
|
||||
{waitlists.map((waitlist) => (
|
||||
<CarouselItem
|
||||
key={waitlist.waitlist_id}
|
||||
key={waitlist.waitlistId}
|
||||
className="min-w-64 max-w-71"
|
||||
>
|
||||
<WaitlistCard
|
||||
@@ -77,7 +77,7 @@ export function WaitlistSection() {
|
||||
subHeading={waitlist.subHeading}
|
||||
description={waitlist.description}
|
||||
imageUrl={waitlist.imageUrls[0] || null}
|
||||
isMember={joinedWaitlistIds.has(waitlist.waitlist_id)}
|
||||
isMember={joinedWaitlistIds.has(waitlist.waitlistId)}
|
||||
onCardClick={() => handleCardClick(waitlist)}
|
||||
onJoinClick={() => handleJoinClick(waitlist)}
|
||||
/>
|
||||
@@ -90,12 +90,12 @@ export function WaitlistSection() {
|
||||
<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}
|
||||
key={waitlist.waitlistId}
|
||||
name={waitlist.name}
|
||||
subHeading={waitlist.subHeading}
|
||||
description={waitlist.description}
|
||||
imageUrl={waitlist.imageUrls[0] || null}
|
||||
isMember={joinedWaitlistIds.has(waitlist.waitlist_id)}
|
||||
isMember={joinedWaitlistIds.has(waitlist.waitlistId)}
|
||||
onCardClick={() => handleCardClick(waitlist)}
|
||||
onJoinClick={() => handleJoinClick(waitlist)}
|
||||
/>
|
||||
@@ -107,7 +107,7 @@ export function WaitlistSection() {
|
||||
{selectedWaitlist && (
|
||||
<WaitlistDetailModal
|
||||
waitlist={selectedWaitlist}
|
||||
isMember={joinedWaitlistIds.has(selectedWaitlist.waitlist_id)}
|
||||
isMember={joinedWaitlistIds.has(selectedWaitlist.waitlistId)}
|
||||
onClose={() => setSelectedWaitlist(null)}
|
||||
onJoin={handleJoinFromDetail}
|
||||
/>
|
||||
@@ -118,7 +118,7 @@ export function WaitlistSection() {
|
||||
<JoinWaitlistModal
|
||||
waitlist={joiningWaitlist}
|
||||
onClose={() => setJoiningWaitlist(null)}
|
||||
onSuccess={() => handleJoinSuccess(joiningWaitlist.waitlist_id)}
|
||||
onSuccess={() => handleJoinSuccess(joiningWaitlist.waitlistId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import BackendAPI from "@/lib/autogpt-server-api/client";
|
||||
import { StoreWaitlistEntry } from "@/lib/autogpt-server-api/types";
|
||||
import { useMemo } from "react";
|
||||
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
|
||||
import {
|
||||
useGetV2GetTheAgentWaitlist,
|
||||
useGetV2GetWaitlistIdsTheCurrentUserHasJoined,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
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);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const api = new BackendAPI();
|
||||
// Fetch waitlists
|
||||
const {
|
||||
data: waitlistsResponse,
|
||||
isLoading: waitlistsLoading,
|
||||
isError: waitlistsError,
|
||||
} = useGetV2GetTheAgentWaitlist();
|
||||
|
||||
// Fetch waitlists
|
||||
const response = await api.getWaitlists();
|
||||
setWaitlists(response.listings);
|
||||
// Fetch memberships if logged in
|
||||
const { data: membershipsResponse, isLoading: membershipsLoading } =
|
||||
useGetV2GetWaitlistIdsTheCurrentUserHasJoined({
|
||||
query: {
|
||||
enabled: !!user,
|
||||
},
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
const waitlists: StoreWaitlistEntry[] = useMemo(() => {
|
||||
if (waitlistsResponse?.status === 200) {
|
||||
return waitlistsResponse.data.listings;
|
||||
}
|
||||
return [];
|
||||
}, [waitlistsResponse]);
|
||||
|
||||
fetchData();
|
||||
}, [user]);
|
||||
const joinedWaitlistIds: Set<string> = useMemo(() => {
|
||||
if (membershipsResponse?.status === 200) {
|
||||
return new Set(membershipsResponse.data);
|
||||
}
|
||||
return new Set();
|
||||
}, [membershipsResponse]);
|
||||
|
||||
const isLoading = waitlistsLoading || (!!user && membershipsLoading);
|
||||
const hasError = waitlistsError;
|
||||
|
||||
// Function to add a waitlist ID to joined set (called after successful join)
|
||||
function markAsJoined(waitlistId: string) {
|
||||
setJoinedWaitlistIds((prev) => new Set([...prev, waitlistId]));
|
||||
function markAsJoined(_waitlistId: string) {
|
||||
// Invalidate the memberships query to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["getV2GetWaitlistIdsTheCurrentUserHasJoined"],
|
||||
});
|
||||
}
|
||||
|
||||
return { waitlists, joinedWaitlistIds, isLoading, hasError, markAsJoined };
|
||||
|
||||
@@ -5185,9 +5185,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "The ID of the store listing",
|
||||
"title": "Store Listing Id"
|
||||
"$ref": "#/components/schemas/Body_postV2Link_waitlist_to_store_listing"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6915,6 +6913,18 @@
|
||||
"type": "object",
|
||||
"title": "Body_postV2Execute a preset"
|
||||
},
|
||||
"Body_postV2Link_waitlist_to_store_listing": {
|
||||
"properties": {
|
||||
"store_listing_id": {
|
||||
"type": "string",
|
||||
"title": "Store Listing Id",
|
||||
"description": "The ID of the store listing"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["store_listing_id"],
|
||||
"title": "Body_postV2Link waitlist to store listing"
|
||||
},
|
||||
"Body_postV2Upload_submission_media": {
|
||||
"properties": {
|
||||
"file": { "type": "string", "format": "binary", "title": "File" }
|
||||
@@ -10291,7 +10301,7 @@
|
||||
},
|
||||
"StoreWaitlistEntry": {
|
||||
"properties": {
|
||||
"waitlist_id": { "type": "string", "title": "Waitlist Id" },
|
||||
"waitlistId": { "type": "string", "title": "Waitlistid" },
|
||||
"storeListing": {
|
||||
"anyOf": [
|
||||
{ "$ref": "#/components/schemas/StoreListingWithVersions" },
|
||||
@@ -10329,7 +10339,7 @@
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"waitlist_id",
|
||||
"waitlistId",
|
||||
"slug",
|
||||
"name",
|
||||
"subHeading",
|
||||
|
||||
@@ -1167,7 +1167,7 @@ export type WaitlistUpdateRequest = {
|
||||
|
||||
// Public Waitlist Types
|
||||
export type StoreWaitlistEntry = {
|
||||
waitlist_id: string;
|
||||
waitlistId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
subHeading: string;
|
||||
|
||||
Reference in New Issue
Block a user