refactor(frontend): migrate waitlist admin components to generated API hooks

- Convert WaitlistTable to use generated React Query hooks directly
- Convert CreateWaitlistButton to use generated hooks
- Update WaitlistDetailModal to use generated types and design system Dialog
- Remove deprecated waitlist types from types.ts
- Remove deprecated waitlist methods from BackendAPI client
- Delete actions.ts server actions (no longer needed)
- Replace lucide-react icons with Phosphor icons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-01-12 15:26:34 -06:00
parent 1dd83b4cf8
commit 764cdf17fe
6 changed files with 242 additions and 462 deletions

View File

@@ -1,68 +0,0 @@
"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

@@ -1,29 +1,62 @@
"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
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";
usePostV2CreateWaitlist,
getGetV2ListAllWaitlistsQueryKey,
} from "@/app/api/__generated__/endpoints/admin/admin";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { Plus } from "@phosphor-icons/react";
export function CreateWaitlistButton() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const queryClient = useQueryClient();
const createWaitlistMutation = usePostV2CreateWaitlist({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
toast({
title: "Success",
description: "Waitlist created successfully",
});
setOpen(false);
setFormData({
name: "",
slug: "",
subHeading: "",
description: "",
categories: "",
imageUrls: "",
videoUrl: "",
agentOutputDemoUrl: "",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
} else {
toast({
variant: "destructive",
title: "Error",
description: "Failed to create waitlist",
});
}
},
onError: (error) => {
console.error("Error creating waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to create waitlist",
});
},
},
});
const [formData, setFormData] = useState({
name: "",
@@ -36,12 +69,10 @@ export function CreateWaitlistButton() {
agentOutputDemoUrl: "",
});
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
function handleInputChange(id: string, value: string) {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
[id]: value,
}));
}
@@ -52,12 +83,11 @@ export function CreateWaitlistButton() {
.replace(/^-|-$/g, "");
}
async function handleSubmit(e: React.FormEvent) {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await createWaitlist({
createWaitlistMutation.mutate({
data: {
name: formData.name,
slug: formData.slug || generateSlug(formData.name),
subHeading: formData.subHeading,
@@ -70,164 +100,118 @@ export function CreateWaitlistButton() {
: [],
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>
<>
<Button onClick={() => setOpen(true)}>
<Plus size={16} className="mr-2" />
Create Waitlist
</Button>
<Dialog
title="Create New Waitlist"
controlled={{
isOpen: open,
set: async (isOpen) => setOpen(isOpen),
}}
onClose={() => setOpen(false)}
styling={{ maxWidth: "600px" }}
>
<Dialog.Content>
<p className="mb-4 text-sm text-zinc-500">
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>
</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)}
placeholder="SEO Analysis Agent"
required
/>
<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>
<Input
id="slug"
label="Slug"
value={formData.slug}
onChange={(e) => handleInputChange("slug", e.target.value)}
placeholder="seo-analysis-agent (auto-generated if empty)"
/>
<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>
<Input
id="subHeading"
label="Subheading"
value={formData.subHeading}
onChange={(e) => handleInputChange("subHeading", e.target.value)}
placeholder="Analyze your website's SEO in minutes"
required
/>
<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>
<Input
id="description"
label="Description"
type="textarea"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="Detailed description of what this agent does..."
rows={4}
required
/>
<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>
<Input
id="categories"
label="Categories (comma-separated)"
value={formData.categories}
onChange={(e) => handleInputChange("categories", e.target.value)}
placeholder="SEO, Marketing, Analysis"
/>
<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>
<Input
id="imageUrls"
label="Image URLs (comma-separated)"
value={formData.imageUrls}
onChange={(e) => handleInputChange("imageUrls", e.target.value)}
placeholder="https://example.com/image1.jpg, https://example.com/image2.jpg"
/>
<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>
<Input
id="videoUrl"
label="Video URL (optional)"
value={formData.videoUrl}
onChange={(e) => handleInputChange("videoUrl", e.target.value)}
placeholder="https://youtube.com/watch?v=..."
/>
<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>
<Input
id="agentOutputDemoUrl"
label="Output Demo URL (optional)"
value={formData.agentOutputDemoUrl}
onChange={(e) =>
handleInputChange("agentOutputDemoUrl", e.target.value)
}
placeholder="https://example.com/demo-output.mp4"
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={loading}>
Create Waitlist
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog.Footer>
<Button
type="button"
variant="secondary"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={createWaitlistMutation.isPending}>
Create Waitlist
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog>
</>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Table,
TableBody,
@@ -10,59 +11,58 @@ import {
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 {
useGetV2ListAllWaitlists,
useDeleteV2DeleteWaitlist,
getGetV2ListAllWaitlistsQueryKey,
} from "@/app/api/__generated__/endpoints/admin/admin";
import type { WaitlistAdminResponse } from "@/app/api/__generated__/models/waitlistAdminResponse";
import { EditWaitlistDialog } from "./EditWaitlistDialog";
import { WaitlistSignupsDialog } from "./WaitlistSignupsDialog";
import { Trash, PencilSimple, Users, Link } from "@phosphor-icons/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();
const queryClient = useQueryClient();
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);
}
const { data: response, isLoading, error } = useGetV2ListAllWaitlists();
const deleteWaitlistMutation = useDeleteV2DeleteWaitlist({
mutation: {
onSuccess: () => {
toast({
title: "Success",
description: "Waitlist deleted successfully",
});
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
},
onError: (error) => {
console.error("Error deleting waitlist:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete waitlist",
});
},
},
});
function handleDelete(waitlistId: string) {
if (!confirm("Are you sure you want to delete this waitlist?")) return;
deleteWaitlistMutation.mutate({ waitlistId });
}
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 handleWaitlistSaved() {
setEditingWaitlist(null);
queryClient.invalidateQueries({
queryKey: getGetV2ListAllWaitlistsQueryKey(),
});
}
function formatStatus(status: string) {
@@ -91,10 +91,20 @@ export function WaitlistTable() {
}).format(new Date(dateStr));
}
if (loading) {
if (isLoading) {
return <div className="py-10 text-center">Loading waitlists...</div>;
}
if (error) {
return (
<div className="py-10 text-center text-red-500">
Error loading waitlists. Please try again.
</div>
);
}
const waitlists = response?.status === 200 ? response.data.waitlists : [];
if (waitlists.length === 0) {
return (
<div className="py-10 text-center text-gray-500">
@@ -165,6 +175,7 @@ export function WaitlistTable() {
size="small"
onClick={() => handleDelete(waitlist.id)}
title="Delete"
disabled={deleteWaitlistMutation.isPending}
>
<Trash size={16} className="text-red-500" />
</Button>
@@ -180,10 +191,7 @@ export function WaitlistTable() {
<EditWaitlistDialog
waitlist={editingWaitlist}
onClose={() => setEditingWaitlist(null)}
onSave={() => {
setEditingWaitlist(null);
loadWaitlists();
}}
onSave={handleWaitlistSaved}
/>
)}

View File

@@ -2,14 +2,9 @@
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";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import type { StoreWaitlistEntry } from "@/app/api/__generated__/models/storeWaitlistEntry";
import { Check } from "@phosphor-icons/react";
interface WaitlistDetailModalProps {
waitlist: StoreWaitlistEntry;
@@ -25,12 +20,18 @@ export function WaitlistDetailModal({
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>
<Dialog
title={waitlist.name}
controlled={{
isOpen: true,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "700px" }}
>
<Dialog.Content>
<div className="space-y-6">
{/* Main Image */}
{waitlist.imageUrls.length > 0 && (
@@ -109,24 +110,26 @@ export function WaitlistDetailModal({
)}
{/* 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>
)}
<Dialog.Footer>
{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 size={16} className="mr-2" />
You&apos;re on the waitlist
</Button>
) : (
<Button
onClick={onJoin}
className="w-full rounded-full bg-neutral-800 text-white hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600"
>
Join waitlist
</Button>
)}
</Dialog.Footer>
</div>
</DialogContent>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -67,13 +67,6 @@ import type {
User,
UserPasswordCredentials,
UsersBalanceHistoryResponse,
StoreWaitlistEntry,
StoreWaitlistsAllResponse,
WaitlistAdminListResponse,
WaitlistAdminResponse,
WaitlistCreateRequest,
WaitlistSignupListResponse,
WaitlistUpdateRequest,
WebSocketNotification,
} from "./types";
@@ -623,67 +616,6 @@ 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

@@ -1103,85 +1103,6 @@ export type AddUserCreditsResponse = {
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 = {
waitlistId: 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,