👋 Recap from Previous Post
In the last part, we enhanced our chatbot gallery by adding a new feature to add new chatbot to the gallery:
✅ Searchbar: Lets users dynamically search chatbots by name.
✅ Filter by Interests & Personality: Added interactive filter buttons (desktop view) and dropdowns (mobile view) to narrow down chatbot results.
✅ State Management: We used React useState to manage selected filters and updated the filtered list of chatbots accordingly.
✅ Dynamic Filtering: The filteredChatbots array now updates based on search input and selected tags in real-time.By the end of that post, your Gallery Page allowed users to:
- Search by chatbot name
- Filter by selected Interests
- Filter by selected Personality traits
Adding Create New Chatbot Feature
🎯 Goals for This Post
In this tutorial, we’ll add a new feature that allows users to add a new chatbot to the gallery. We’ll implement a new “Add Chatbot” button placed between the Searchbar
and Filter
. When clicked, it will open a modal (popup) containing a form where users can input chatbot details.
By the end of this section, you’ll have:
- A fully functional “Add Chatbot” button
- A responsive modal form to input chatbot details
📱 Step by Step Guide
1. Creating the Button
The first thing we’ll create is the “Add Chatbot” button
. This will be a simple button, and we’ll re-use the existing Button
component we’ve previously built. To keep things organized, we’ll place it in a new component called AddChatbotButton
. Since this button is specific to the gallery page, the file will be located at: src/modules/gallery/components/AddChatbotButton.tsx
We will be needing an icon, so don’t forget to install
pnpm add @phospor-icons/react
Here’s the code for AddChatbotButton
:
import { Button } from '@/components/ui/button';
import React from 'react';
import { cn } from '@/lib/utils';
import { PlusIcon } from '@phosphor-icons/react';
interface AddChatbotButtonProps {
onClick: () => void;
}
export const AddChatbotButton: React.FC<AddChatbotButtonProps> = ({
onClick,
}) => {
return (
<Button
className={cn(
'ml-2 px-6 py-3 flex',
'bg-gradient-to-r from-purple-600 via-pink-500 to-orange-500',
'text-white font-semibold text-sm',
'border-0 rounded-xl',
'shadow-lg shadow-purple-500/25',
'hover:shadow-xl hover:shadow-purple-500/40',
'hover:scale-105 hover:-translate-y-0.5',
'transition-all duration-300 ease-out',
'focus:outline-none focus:ring-4 focus:ring-purple-500/50',
'active:scale-95 active:translate-y-0'
)}
onClick={onClick}
aria-label="Add new chatbot"
>
<span className="flex items-center gap-2">
<PlusIcon size={20} />
Add Chatbot
</span>
</Button>
);
};
As shown above, we’re importing the Button
component from our shared components and applying some custom styles to it. We’ve also added an onClick
handler for the button’s click event, along with an icon and label text. Simple and straightforward.
Now you can add it to the GalleryPage
and place it in between the Searchbar
and FilterControl
. Then you can add a state to manage what happens when it is clicked:
Here’s what the code looks like:
// .... imports etc
const GalleryPage = () => {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [selectedInterest, setSelectedInterest] = useState<string[]>([]);
const [selectedPersonality, setSelectedPersonality] = useState<string[]>([]);
// Add state here
const [isModalOpen, setModalOpen] = useState(false);
// ....
return (
// ........ other html before
<div className="flex gap-x-2">
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search chatbots by name or description..."
/>
</div>
{/* Add the Chatbot Button here */}
<AddChatbotButton onClick={() => setModalOpen(true)} />
{/* Filter for Interests */}
<div className="bg-white p-4 rounded-lg shadow">
<h2 className="text-lg font-medium mb-3">Filter by Interests</h2>
<FilterControls
values={allInterests}
selectedValues={selectedInterest}
onToggle={handleInterestsToggle}
/>
</div>
// ........ other html after
)
2. Creating the Modal
Now let’s build the form for creating a new chatbot. We’ll structure it from top to bottom starting with the header, followed by the form fields, and then the action buttons.
Creating Dialog (Modal) reusable components
To begin, we’ll install the @radix-ui/react-dialog
package. This will serve as the foundation for our custom dialog component. It provides accessible, unstyled primitives, making it a great choice for building consistent and flexible modal components.
Run the following command to install it:
pnpm add @radix-ui/react-dialog
We’ll place our custom dialog component inside src/components/ui/dialog.tsx
. This file will contain all custom dialog components based on @radix-ui/react-dialog
. We’ll also apply our own styles to these components to match the overall design of the app.
Here’s the full code for the file:
import React, { ComponentPropsWithoutRef, HTMLAttributes } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '@/lib/utils';
import { XIcon } from '@phosphor-icons/react';
const Dialog = DialogPrimitive.Root;
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = ({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) => (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
);
const DialogContent = ({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
const DialogHeader = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
const DialogFooter = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
const DialogTitle = ({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) => (
<DialogPrimitive.Title
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
);
const DialogDescription = ({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) => (
<DialogPrimitive.Description
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
export {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
We’ve created several components to build a custom, reusable dialog system. Here’s a brief explanation of each one:
- Dialog: The root component that manages the open/close state of the dialog.
- DialogPortal: Renders the overlay and content in a React portal (typically into the ), allowing the dialog to appear above other content regardless of DOM hierarchy.
- DialogOverlay: A full-screen background layer that dims the rest of the UI and prevents interaction with it while the dialog is open.
- DialogContent: The main dialog window. It includes the portal and overlay, and renders any custom content passed via children. It also includes a close button in the top-right corner.
- DialogHeader: A layout wrapper for the top section of the dialog, typically used to contain the title and optional description.
- DialogFooter: A layout wrapper for the bottom section of the dialog, usually used for action buttons. It stacks vertically on mobile and horizontally on desktop.
- DialogTitle: An accessible title element that is announced by screen readers when the dialog opens.
- DialogDescription: An optional accessible description for additional context, also announced by screen readers when the dialog opens.
Creating AddChatbotModal
component
Now that we’ve built our reusable dialog components, it’s time to create the AddChatbotModal
component. This modal will be located at: src/modules/gallery/components/AddChatbotModal.tsx
. We’ll begin by building the header section of the modal.
Here’s the code for the header:
import React from "react";
import { Button } from "@/components/ui/button";
import Input from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
interface AddChatbotModalProps {
isOpen: boolean;
onClose: () => void;
onSubmitSuccess?: () => void;
}
export const AddChatbotModal: React.FC<AddChatbotModalProps> = ({
isOpen,
onClose,
onSubmitSuccess,
}) => {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
<div className="bg-gradient-to-br from-purple-600 via-pink-500 to-orange-500 p-6 rounded-t-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-white">
Create New Chatbot
</DialogTitle>
<DialogDescription className="text-purple-100 mt-2">
Design a unique AI personality for the marketplace
</DialogDescription>
</DialogHeader>
</div>
</DialogContent>
</Dialog>
);
};
In this section, we’re using the Dialog
and DialogContent
components we created earlier to structure the modal. Inside the DialogContent
, we build the header portion of the modal. If you render this component now, you should be able to clearly see the header appear.
Next, we’ll add the main content of the modal, the form. If you refer to the screenshot I shared earlier, most of the form fields follow a consistent layout: a label followed by an input element. The only exception is the “Personality” field, which will use a <textarea>
instead of a standard input.
Here’s an example of the markup for one field. You can duplicate this structure to create the other inputs:
// ... rest of the code
export const AddChatbotModal: React.FC<AddChatbotModalProps> = ({
isOpen,
onClose,
onSubmitSuccess,
}) => {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
<div className="bg-gradient-to-br from-purple-600 via-pink-500 to-orange-500 p-6 rounded-t-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-white">
Create New Chatbot
</DialogTitle>
<DialogDescription className="text-purple-100 mt-2">
Design a unique AI personality for the marketplace
</DialogDescription>
</DialogHeader>
</div>
{/* The Form Part */}
<div className="p-6 space-y-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
placeholder="e.g., Tech Assistant Pro"
className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Personality
</label>
<textarea
placeholder="Describe the chatbot's core personality traits..."
className={cn(
"w-full px-4 py-3 border-2 border-purple-200 rounded-lg bg-white",
"focus:border-purple-500 focus:ring-purple-500/20",
"text-sm leading-relaxed resize-none",
"transition-all duration-200"
)}
rows={2}
/>
</div>
{/* Put the rest of the input here */}
</div>
</div>
</DialogContent>
</Dialog>
)
Finally, let’s add the footer section with the action buttons. This includes two buttons:
- Cancel – Closes the modal
- Create Chatbot – Submits the form and triggers the API call to create a new chatbot
Here’s the code for the button group:
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
{/** Form */}
<div className="p-6 space-y-6">
{/** Form here */}
</div>
{/** Footer here */}
<DialogFooter className="px-6 py-4 bg-gradient-to-r from-purple-50 to-pink-50 border-t border-purple-200 rounded-b-2xl">
<Button
variant="outline"
onClick={onClose}
className="border-purple-300 text-purple-700 hover:bg-purple-100 hover:border-purple-400"
>
Cancel
</Button>
<Button
onClick={() => {}}
disabled={isPending}
className={cn(
"bg-gradient-to-r from-purple-600 to-pink-600",
"hover:from-purple-700 hover:to-pink-700",
"text-white font-semibold",
"border-0 shadow-lg shadow-purple-500/25",
"hover:shadow-xl hover:shadow-purple-500/40",
"hover:scale-105 transition-all duration-200",
isPending && "opacity-70 cursor-not-allowed"
)}
>
{isPending ? "Creating..." : "Create Chatbot"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
You might notice that isPending
is used but not defined yet. For now, you can set it to false
as a placeholder we’ll implement its functionality later.
At this point, your modal should visually match the screenshot I showed earlier. However, it’s still non-functional. In the next step, we’ll integrate react-hook-form
to handle form state, validation, and submission.
Before we continue, make sure to install the necessary dependencies:
pnpm install react-hook-form @hookform/resolvers
These are required for integrating react-hook-form
along with the zodResolver
, which we’ll use for schema validation.
I’ll provide the personaSchema
we’re using shortly.
Here’s the updated version of the AddChatbotModal
component:
// ... rest of code
export const AddChatbotModal: React.FC<AddChatbotModalProps> = ({
isOpen,
onClose,
onSubmitSuccess,
}) => {
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm({
resolver: zodResolver(personaSchema),
defaultValues: {
name: "",
personality: "",
tone_style: "",
interests: "",
behaviour: "",
age: 0,
gender: "",
},
});
const onFormSubmit = (data: {
name: string;
personality?: string;
tone_style?: string;
interests?: string;
behaviour?: string;
age: number;
gender: string;
}) => {
// Handle Form Here
};
const isPending = false;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
{/** ... other HTML tags above */}
<DialogFooter className="px-6 py-4 bg-gradient-to-r from-purple-50 to-pink-50 border-t border-purple-200 rounded-b-2xl">
<Button
variant="outline"
onClick={onClose}
className="border-purple-300 text-purple-700 hover:bg-purple-100 hover:border-purple-400"
>
Cancel
</Button>
<Button
onClick={handleSubmit(onFormSubmit)}
disabled={isPending}
className={cn(
"bg-gradient-to-r from-purple-600 to-pink-600",
"hover:from-purple-700 hover:to-pink-700",
"text-white font-semibold",
"border-0 shadow-lg shadow-purple-500/25",
"hover:shadow-xl hover:shadow-purple-500/40",
"hover:scale-105 transition-all duration-200",
isPending && "opacity-70 cursor-not-allowed"
)}
>
{isPending ? "Creating..." : "Create Chatbot"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
In the example above, we’ve initialized useForm
from react-hook-form
and used zodResolver
from @hookform/resolvers
to connect our schema validation.
Next, I’ll show you how to register each input field using register, and how to display validation error messages beneath them.
Here’s a sample input field with validation applied:
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
<div className="p-6 space-y-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
{...register('name')}
placeholder="e.g., Tech Assistant Pro"
className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
/>
{errors.name && (
<p className="text-red-500 text-xs mt-1">
{errors.name.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Personality
</label>
<textarea
{...register('personality')}
placeholder="Describe the chatbot's core personality traits..."
className={cn(
'w-full px-4 py-3 border-2 border-purple-200 rounded-lg bg-white',
'focus:border-purple-500 focus:ring-purple-500/20',
'text-sm leading-relaxed resize-none',
'transition-all duration-200'
)}
rows={2}
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Tone Style
</label>
<Input
{...register('tone_style')}
placeholder="e.g., professional, casual, humorous"
className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Interests
</label>
<Input
{...register('interests')}
placeholder="e.g., technology, sports, music"
className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Behavior
</label>
<Input
{...register('behaviour')}
placeholder="e.g., helpful, inquisitive, reserved"
className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Age
</label>
<Input
type="number"
{...register('age', { valueAsNumber: true })}
placeholder="e.g., 25"
className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Gender
</label>
<Input
{...register('gender')}
placeholder="e.g., male, female, non-binary"
className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
/>
</div>
</div>
</div>
</div>
{/** ... footer HTML tags */}
</DialogContent>
</Dialog>
)
We’ll place the new personaSchema.ts
file under a new directory: src/schema/personaSchema.ts
. This folder will be used to store all schema definitions, whether for form validation or API response validation.
Here’s the code for personaSchema.ts
:
import z from "zod/v4";
export const personaSchema = z.object({
name: z.string(),
personality: z.string().optional(),
tone_style: z.string().optional(),
interests: z.string().optional(),
behaviour: z.string().optional(),
age: z.number(),
gender: z.string(),
});
🚀 What’s Next?
By now, you should have a working “Add Chatbot” button that opens a modal when clicked. In the next part, we’ll integrate the API so you can create new chatbots and fetch all the chatbots you’ve created to display them in your gallery.
New posts will be released every 2–3 days, so make sure to subscribe or bookmark this page to stay updated!
Please check Part 6 here:
👉 (Part 6) Build a Simple Chat Character Gallery: Integrating the API
Top comments (0)