mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-10 23:58:06 -05:00
refactor(frontend): Update data fetching strategy in marketplace main page (#10520)
With this PR, we’re changing the data fetching strategy on the
marketplace page. We’re now using autogenerated React queries.
### Changes
- Splits separate render logic and hook logic.
- Update the data fetching strategy.
- Currently, we’re seeing agents in the featured section and creators in
the featured creators section, even if they’re not set to “isFeatured”
true. I’ve fixed that also.
### Checklist 📋
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] All marketplace E2E tests are working.
- [x] I’ve tested all the links and checked if everything renders
perfectly on the marketplace page.
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import BackendAPI, {
|
||||
CreatorsResponse,
|
||||
StoreAgentsResponse,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
|
||||
const EMPTY_AGENTS_RESPONSE: StoreAgentsResponse = {
|
||||
agents: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
};
|
||||
const EMPTY_CREATORS_RESPONSE: CreatorsResponse = {
|
||||
creators: [],
|
||||
pagination: {
|
||||
total_items: 0,
|
||||
total_pages: 0,
|
||||
current_page: 0,
|
||||
page_size: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export async function getMarketplaceData(): Promise<{
|
||||
featuredAgents: StoreAgentsResponse;
|
||||
topAgents: StoreAgentsResponse;
|
||||
featuredCreators: CreatorsResponse;
|
||||
}> {
|
||||
const api = new BackendAPI();
|
||||
|
||||
const [featuredAgents, topAgents, featuredCreators] = await Promise.all([
|
||||
api.getStoreAgents({ featured: true }).catch((error) => {
|
||||
console.error("Error fetching featured marketplace agents:", error);
|
||||
return EMPTY_AGENTS_RESPONSE;
|
||||
}),
|
||||
api.getStoreAgents({ sorted_by: "runs" }).catch((error) => {
|
||||
console.error("Error fetching top marketplace agents:", error);
|
||||
return EMPTY_AGENTS_RESPONSE;
|
||||
}),
|
||||
api
|
||||
.getStoreCreators({ featured: true, sorted_by: "num_agents" })
|
||||
.catch((error) => {
|
||||
console.error("Error fetching featured marketplace creators:", error);
|
||||
return EMPTY_CREATORS_RESPONSE;
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
featuredAgents,
|
||||
topAgents,
|
||||
featuredCreators,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import { useAgentsSection } from "./useAgentsSection";
|
||||
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||
import { StoreCard } from "../StoreCard/StoreCard";
|
||||
|
||||
export interface Agent {
|
||||
slug: string;
|
||||
agent_name: string;
|
||||
agent_image: string;
|
||||
creator: string;
|
||||
creator_avatar: string;
|
||||
sub_heading: string;
|
||||
description: string;
|
||||
runs: number;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
interface AgentsSectionProps {
|
||||
sectionTitle: string;
|
||||
agents: StoreAgent[];
|
||||
hideAvatars?: boolean;
|
||||
margin?: string;
|
||||
}
|
||||
|
||||
export const AgentsSection = ({
|
||||
sectionTitle,
|
||||
agents: allAgents,
|
||||
hideAvatars = false,
|
||||
margin = "24px",
|
||||
}: AgentsSectionProps) => {
|
||||
// TODO: Update this when we have pagination and shifts to useAgentsSection
|
||||
const displayedAgents = allAgents;
|
||||
const { handleCardClick } = useAgentsSection();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-full max-w-[1360px]">
|
||||
<h2
|
||||
style={{ marginBottom: margin }}
|
||||
className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
|
||||
>
|
||||
{sectionTitle}
|
||||
</h2>
|
||||
{!displayedAgents || displayedAgents.length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
No agents found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile Carousel View */}
|
||||
<Carousel
|
||||
className="md:hidden"
|
||||
opts={{
|
||||
loop: true,
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
{displayedAgents.map((agent, index) => (
|
||||
<CarouselItem key={index} className="min-w-64 max-w-71">
|
||||
<StoreCard
|
||||
agentName={agent.agent_name}
|
||||
agentImage={agent.agent_image}
|
||||
description={agent.description}
|
||||
runs={agent.runs}
|
||||
rating={agent.rating}
|
||||
avatarSrc={agent.creator_avatar}
|
||||
creatorName={agent.creator}
|
||||
hideAvatar={hideAvatars}
|
||||
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
<div className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{displayedAgents.map((agent, index) => (
|
||||
<StoreCard
|
||||
key={index}
|
||||
agentName={agent.agent_name}
|
||||
agentImage={agent.agent_image}
|
||||
description={agent.description}
|
||||
runs={agent.runs}
|
||||
rating={agent.rating}
|
||||
avatarSrc={agent.creator_avatar}
|
||||
creatorName={agent.creator}
|
||||
hideAvatar={hideAvatars}
|
||||
onClick={() => handleCardClick(agent.creator, agent.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// FRONTEND-TODO : Need to add more logic for pagination in future
|
||||
export const useAgentsSection = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleCardClick = (creator: string, slug: string) => {
|
||||
router.push(
|
||||
`/marketplace/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
};
|
||||
|
||||
return { handleCardClick };
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
|
||||
import * as React from "react";
|
||||
|
||||
interface BecomeACreatorProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
}
|
||||
|
||||
export function BecomeACreator({
|
||||
title = "Become a creator",
|
||||
description = "Join a community where your AI creations can inspire, engage, and be downloaded by users around the world.",
|
||||
buttonText = "Upload your agent",
|
||||
}: BecomeACreatorProps) {
|
||||
return (
|
||||
<div className="relative mx-auto h-auto min-h-[300px] w-full max-w-[1360px] md:min-h-[400px] lg:h-[459px]">
|
||||
{/* Title */}
|
||||
<h2 className="mb-[77px] font-poppins text-[18px] font-semibold leading-[28px] text-neutral-800 dark:text-neutral-200">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="mx-auto w-full max-w-[900px] px-4 text-center md:px-6 lg:px-0">
|
||||
<h2 className="mb-6 text-center font-poppins text-[48px] font-semibold leading-[54px] tracking-[-0.012em] text-neutral-950 dark:text-neutral-50 md:mb-8 lg:mb-12">
|
||||
Build AI agents and share
|
||||
<br />
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
your
|
||||
</span>{" "}
|
||||
vision
|
||||
</h2>
|
||||
|
||||
<p className="mx-auto mb-8 max-w-[90%] text-lg font-normal leading-relaxed text-neutral-700 dark:text-neutral-300 md:mb-10 md:text-xl md:leading-loose lg:mb-14 lg:text-2xl">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<PublishAgentModal
|
||||
trigger={
|
||||
<button className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5">
|
||||
<span className="whitespace-nowrap font-poppins text-base font-medium leading-normal text-neutral-50 md:text-lg md:leading-relaxed lg:text-xl lg:leading-7">
|
||||
{buttonText}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Image from "next/image";
|
||||
import { backgroundColor } from "./helper";
|
||||
|
||||
interface CreatorCardProps {
|
||||
creatorName: string;
|
||||
creatorImage: string;
|
||||
bio: string;
|
||||
agentsUploaded: number;
|
||||
onClick: () => void;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const CreatorCard = ({
|
||||
creatorName,
|
||||
creatorImage,
|
||||
bio,
|
||||
agentsUploaded,
|
||||
onClick,
|
||||
index,
|
||||
}: CreatorCardProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor(index)} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-all duration-200 hover:brightness-95`}
|
||||
onClick={onClick}
|
||||
data-testid="creator-card"
|
||||
>
|
||||
<div className="relative h-[64px] w-[64px]">
|
||||
<div className="absolute inset-0 overflow-hidden rounded-full">
|
||||
{creatorImage ? (
|
||||
<Image
|
||||
src={creatorImage}
|
||||
alt={creatorName}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-full w-full object-cover"
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-neutral-300 dark:bg-neutral-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-poppins text-2xl font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
|
||||
{creatorName}
|
||||
</h3>
|
||||
<p className="text-sm font-normal leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
{bio}
|
||||
</p>
|
||||
<div className="text-lg font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
|
||||
{agentsUploaded} agents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
const BACKGROUND_COLORS = [
|
||||
"bg-amber-100 dark:bg-amber-800", // #fef3c7 / #92400e
|
||||
"bg-violet-100 dark:bg-violet-800", // #ede9fe / #5b21b6
|
||||
"bg-green-100 dark:bg-green-800", // #dcfce7 / #065f46
|
||||
"bg-blue-100 dark:bg-blue-800", // #dbeafe / #1e3a8a
|
||||
];
|
||||
export const backgroundColor = (index: number) =>
|
||||
BACKGROUND_COLORS[index % BACKGROUND_COLORS.length];
|
||||
@@ -0,0 +1,74 @@
|
||||
import Image from "next/image";
|
||||
import { StarRatingIcons } from "@/components/ui/icons";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||
|
||||
interface FeaturedStoreCardProps {
|
||||
agent: StoreAgent;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export const FeaturedAgentCard = ({
|
||||
agent,
|
||||
backgroundColor,
|
||||
}: FeaturedStoreCardProps) => {
|
||||
// TODO: Need to use group for hover
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-testid="featured-store-card"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`flex h-full flex-col ${backgroundColor} rounded-[1.5rem] border-none`}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-2 text-base sm:text-xl">
|
||||
{agent.agent_name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
By {agent.creator}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-4">
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={agent.agent_image || "/autogpt-logo-dark-bg.png"}
|
||||
alt={`${agent.agent_name} preview`}
|
||||
fill
|
||||
sizes="100%"
|
||||
className={`object-cover transition-opacity duration-200 ${
|
||||
isHovered ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-0 overflow-y-auto p-4 transition-opacity duration-200 ${
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<CardDescription className="line-clamp-[6] text-xs sm:line-clamp-[8] sm:text-sm">
|
||||
{agent.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between">
|
||||
<div className="font-semibold">
|
||||
{agent.runs?.toLocaleString() ?? "0"} runs
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p>{agent.rating.toFixed(1) ?? "0.0"}</p>
|
||||
{StarRatingIcons(agent.rating)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { CreatorCard } from "../CreatorCard/CreatorCard";
|
||||
import { useFeaturedCreators } from "./useFeaturedCreators";
|
||||
import { Creator } from "@/app/api/__generated__/models/creator";
|
||||
|
||||
interface FeaturedCreatorsProps {
|
||||
title?: string;
|
||||
featuredCreators: Creator[];
|
||||
}
|
||||
|
||||
export const FeaturedCreators = ({
|
||||
featuredCreators,
|
||||
title = "Featured Creators",
|
||||
}: FeaturedCreatorsProps) => {
|
||||
const { handleCardClick, displayedCreators } = useFeaturedCreators({
|
||||
featuredCreators,
|
||||
});
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center">
|
||||
<div className="w-full max-w-[1360px]">
|
||||
<h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{displayedCreators.map((creator, index) => (
|
||||
<CreatorCard
|
||||
key={index}
|
||||
creatorName={creator.name || creator.username}
|
||||
creatorImage={creator.avatar_url}
|
||||
bio={creator.description}
|
||||
agentsUploaded={creator.num_agents}
|
||||
onClick={() => handleCardClick(creator.username)}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Creator } from "@/app/api/__generated__/models/creator";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface useFeaturedCreatorsProps {
|
||||
featuredCreators: Creator[];
|
||||
}
|
||||
|
||||
export const useFeaturedCreators = ({
|
||||
featuredCreators,
|
||||
}: useFeaturedCreatorsProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleCardClick = (creator: string) => {
|
||||
router.push(`/marketplace/creator/${encodeURIComponent(creator)}`);
|
||||
};
|
||||
|
||||
const displayedCreators = featuredCreators.slice(0, 4);
|
||||
|
||||
return {
|
||||
handleCardClick,
|
||||
displayedCreators,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
CarouselIndicator,
|
||||
} from "@/components/ui/carousel";
|
||||
import Link from "next/link";
|
||||
import { useFeaturedSection } from "./useFeaturedSection";
|
||||
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||
import { getBackgroundColor } from "./helper";
|
||||
import { FeaturedAgentCard } from "../FeaturedAgentCard/FeaturedAgentCard";
|
||||
|
||||
interface FeaturedSectionProps {
|
||||
featuredAgents: StoreAgent[];
|
||||
}
|
||||
|
||||
export const FeaturedSection = ({ featuredAgents }: FeaturedSectionProps) => {
|
||||
const { handleNextSlide, handlePrevSlide } = useFeaturedSection({
|
||||
featuredAgents,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="w-full">
|
||||
<h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
|
||||
Featured agents
|
||||
</h2>
|
||||
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "center",
|
||||
containScroll: "trimSnaps",
|
||||
}}
|
||||
>
|
||||
<CarouselContent>
|
||||
{featuredAgents.map((agent, index) => (
|
||||
<CarouselItem
|
||||
key={index}
|
||||
className="h-[480px] md:basis-1/2 lg:basis-1/3"
|
||||
>
|
||||
<Link
|
||||
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`}
|
||||
className="block h-full"
|
||||
>
|
||||
<FeaturedAgentCard
|
||||
agent={agent}
|
||||
backgroundColor={getBackgroundColor(index)}
|
||||
/>
|
||||
</Link>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<div className="relative mt-4">
|
||||
<CarouselIndicator />
|
||||
<CarouselPrevious afterClick={handlePrevSlide} />
|
||||
<CarouselNext afterClick={handleNextSlide} />
|
||||
</div>
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
const BACKGROUND_COLORS = [
|
||||
"bg-violet-200 dark:bg-violet-800", // #ddd6fe / #5b21b6
|
||||
"bg-blue-200 dark:bg-blue-800", // #bfdbfe / #1e3a8a
|
||||
"bg-green-200 dark:bg-green-800", // #bbf7d0 / #065f46
|
||||
];
|
||||
|
||||
export const getBackgroundColor = (index: number) => {
|
||||
return BACKGROUND_COLORS[index % BACKGROUND_COLORS.length];
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
|
||||
|
||||
interface useFeaturedSectionProps {
|
||||
featuredAgents: StoreAgent[];
|
||||
}
|
||||
|
||||
export const useFeaturedSection = ({
|
||||
featuredAgents,
|
||||
}: useFeaturedSectionProps) => {
|
||||
const [_, setCurrentSlide] = useState(0);
|
||||
|
||||
const handlePrevSlide = () => {
|
||||
setCurrentSlide((prev) =>
|
||||
prev === 0 ? featuredAgents.length - 1 : prev - 1,
|
||||
);
|
||||
};
|
||||
|
||||
const handleNextSlide = () => {
|
||||
setCurrentSlide((prev) =>
|
||||
prev === featuredAgents.length - 1 ? 0 : prev + 1,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
handleNextSlide,
|
||||
handlePrevSlide,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useFilterChips } from "./useFilterChips";
|
||||
|
||||
interface FilterChipsProps {
|
||||
badges: string[];
|
||||
onFilterChange?: (selectedFilters: string[]) => void;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
// Some flaws in its logic
|
||||
// FRONTEND-TODO : This needs to be fixed
|
||||
export const FilterChips = ({
|
||||
badges,
|
||||
onFilterChange,
|
||||
multiSelect = true,
|
||||
}: FilterChipsProps) => {
|
||||
const { selectedFilters, handleBadgeClick } = useFilterChips({
|
||||
multiSelect,
|
||||
onFilterChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start lg:gap-5">
|
||||
{badges.map((badge) => (
|
||||
<Badge
|
||||
key={badge}
|
||||
variant={selectedFilters.includes(badge) ? "secondary" : "outline"}
|
||||
className="mb-2 flex cursor-pointer items-center justify-center gap-2 rounded-full border border-black/50 px-3 py-1 dark:border-white/50 lg:mb-3 lg:gap-2.5 lg:px-6 lg:py-2"
|
||||
onClick={() => handleBadgeClick(badge)}
|
||||
>
|
||||
<div className="text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
|
||||
{badge}
|
||||
</div>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface useFilterChipsProps {
|
||||
onFilterChange?: (selectedFilters: string[]) => void;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
export const useFilterChips = ({
|
||||
onFilterChange,
|
||||
multiSelect,
|
||||
}: useFilterChipsProps) => {
|
||||
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||
|
||||
const handleBadgeClick = (badge: string) => {
|
||||
setSelectedFilters((prevFilters) => {
|
||||
let newFilters;
|
||||
if (multiSelect) {
|
||||
newFilters = prevFilters.includes(badge)
|
||||
? prevFilters.filter((filter) => filter !== badge)
|
||||
: [...prevFilters, badge];
|
||||
} else {
|
||||
newFilters = prevFilters.includes(badge) ? [] : [badge];
|
||||
}
|
||||
|
||||
if (onFilterChange) {
|
||||
onFilterChange(newFilters);
|
||||
}
|
||||
|
||||
return newFilters;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
selectedFilters,
|
||||
handleBadgeClick,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { FilterChips } from "../FilterChips/FilterChips";
|
||||
import { SearchBar } from "../SearchBar/SearchBar";
|
||||
import { useHeroSection } from "./useHeroSection";
|
||||
|
||||
export const HeroSection = () => {
|
||||
const { onFilterChange } = useHeroSection();
|
||||
return (
|
||||
<div className="mb-2 mt-8 flex flex-col items-center justify-center px-4 sm:mb-4 sm:mt-12 sm:px-6 md:mb-6 md:mt-16 lg:my-24 lg:px-8 xl:my-16">
|
||||
<div className="w-full max-w-3xl lg:max-w-4xl xl:max-w-5xl">
|
||||
<div className="mb-4 text-center md:mb-8">
|
||||
<h1 className="text-center">
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
||||
Explore AI agents built for{" "}
|
||||
</span>
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
|
||||
you
|
||||
</span>
|
||||
<br />
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
|
||||
by the{" "}
|
||||
</span>
|
||||
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
|
||||
community
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<h3 className="mb:text-2xl mb-6 text-center font-sans text-xl font-normal leading-loose text-neutral-700 dark:text-neutral-300 md:mb-12">
|
||||
Bringing you AI agents designed by thinkers from around the world
|
||||
</h3>
|
||||
<div className="mb-4 flex justify-center sm:mb-5">
|
||||
<SearchBar height="h-[74px]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-center">
|
||||
<FilterChips
|
||||
badges={[
|
||||
"Marketing",
|
||||
"SEO",
|
||||
"Content Creation",
|
||||
"Automation",
|
||||
"Fun",
|
||||
]}
|
||||
onFilterChange={onFilterChange}
|
||||
multiSelect={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const useHeroSection = () => {
|
||||
const router = useRouter();
|
||||
const { completeStep } = useOnboarding();
|
||||
|
||||
// Mark marketplace visit task as completed
|
||||
useEffect(() => {
|
||||
completeStep("MARKETPLACE_VISIT");
|
||||
}, [completeStep]);
|
||||
|
||||
function onFilterChange(selectedFilters: string[]) {
|
||||
const encodedTerm = encodeURIComponent(selectedFilters.join(", "));
|
||||
router.push(`/marketplace/search?searchTerm=${encodedTerm}`);
|
||||
}
|
||||
|
||||
return {
|
||||
onFilterChange,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FeaturedSection } from "../FeaturedSection/FeaturedSection";
|
||||
import { BecomeACreator } from "../BecomeACreator/BecomeACreator";
|
||||
import { HeroSection } from "../HeroSection/HeroSection";
|
||||
import { AgentsSection } from "../AgentsSection/AgentsSection";
|
||||
import { useMainMarketplacePage } from "./useMainMarketplacePage";
|
||||
import { FeaturedCreators } from "../FeaturedCreators/FeaturedCreators";
|
||||
|
||||
export const MainMarkeplacePage = () => {
|
||||
const { featuredAgents, topAgents, featuredCreators, isLoading, hasError } =
|
||||
useMainMarketplacePage();
|
||||
|
||||
// FRONTEND-TODO : Add better Loading Skeletons
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<main className="px-4">
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FRONTEND-TODO : Add better Error UI
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<main className="px-4">
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<div className="text-lg text-red-500">
|
||||
Error loading marketplace data. Please try again later.
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// FRONTEND-TODO : Need better state location, need to fetch creators and agents in their respective file, Can't do it right now because these files are used in some other pages of marketplace, will fix it when encounter with those pages
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<main className="px-4">
|
||||
<HeroSection />
|
||||
{featuredAgents && (
|
||||
<FeaturedSection featuredAgents={featuredAgents.agents} />
|
||||
)}
|
||||
{/* 100px margin because our featured sections button are placed 40px below the container */}
|
||||
<Separator className="mb-6 mt-24" />
|
||||
|
||||
{topAgents && (
|
||||
<AgentsSection sectionTitle="Top Agents" agents={topAgents.agents} />
|
||||
)}
|
||||
<Separator className="mb-[25px] mt-[60px]" />
|
||||
{featuredCreators && (
|
||||
<FeaturedCreators featuredCreators={featuredCreators.creators} />
|
||||
)}
|
||||
<Separator className="mb-[25px] mt-[60px]" />
|
||||
<BecomeACreator
|
||||
title="Become a Creator"
|
||||
description="Join our ever-growing community of hackers and tinkerers"
|
||||
buttonText="Become a Creator"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
useGetV2ListStoreAgents,
|
||||
useGetV2ListStoreCreators,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { StoreAgentsResponse } from "@/app/api/__generated__/models/storeAgentsResponse";
|
||||
import { CreatorsResponse } from "@/app/api/__generated__/models/creatorsResponse";
|
||||
|
||||
export const useMainMarketplacePage = () => {
|
||||
// Below queries are already fetched on server and hydrated properly in cache, hence these requests are fast
|
||||
const {
|
||||
data: featuredAgents,
|
||||
isLoading: isFeaturedAgentsLoading,
|
||||
isError: isFeaturedAgentsError,
|
||||
} = useGetV2ListStoreAgents(
|
||||
{ featured: true },
|
||||
{
|
||||
query: {
|
||||
select: (x) => {
|
||||
return x.data as StoreAgentsResponse;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: topAgents,
|
||||
isLoading: isTopAgentsLoading,
|
||||
isError: isTopAgentsError,
|
||||
} = useGetV2ListStoreAgents(
|
||||
{
|
||||
sorted_by: "runs",
|
||||
},
|
||||
{
|
||||
query: {
|
||||
select: (x) => {
|
||||
return x.data as StoreAgentsResponse;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: featuredCreators,
|
||||
isLoading: isFeaturedCreatorsLoading,
|
||||
isError: isFeaturedCreatorsError,
|
||||
} = useGetV2ListStoreCreators(
|
||||
{ featured: true, sorted_by: "num_agents" },
|
||||
{
|
||||
query: {
|
||||
select: (x) => {
|
||||
return x.data as CreatorsResponse;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isFeaturedAgentsLoading || isTopAgentsLoading || isFeaturedCreatorsLoading;
|
||||
const hasError =
|
||||
isFeaturedAgentsError || isTopAgentsError || isFeaturedCreatorsError;
|
||||
|
||||
return {
|
||||
featuredAgents,
|
||||
topAgents,
|
||||
featuredCreators,
|
||||
isLoading,
|
||||
hasError,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
|
||||
import { useSearchbar } from "./useSearchBar";
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
backgroundColor?: string;
|
||||
iconColor?: string;
|
||||
textColor?: string;
|
||||
placeholderColor?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export const SearchBar = ({
|
||||
placeholder = 'Search for tasks like "optimise SEO"',
|
||||
backgroundColor = "bg-neutral-100 dark:bg-neutral-800",
|
||||
iconColor = "text-[#646464] dark:text-neutral-400",
|
||||
textColor = "text-[#707070] dark:text-neutral-200",
|
||||
placeholderColor = "text-[#707070] dark:text-neutral-400",
|
||||
width = "w-9/10 lg:w-[56.25rem]",
|
||||
height = "h-[60px]",
|
||||
}: SearchBarProps) => {
|
||||
const { handleSubmit, setSearchQuery, searchQuery } = useSearchbar();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
data-testid="store-search-bar"
|
||||
className={`${width} ${height} px-4 pt-2 md:px-6 md:pt-1 ${backgroundColor} flex items-center justify-center gap-2 rounded-full md:gap-5`}
|
||||
>
|
||||
<MagnifyingGlassIcon className={`h-5 w-5 md:h-7 md:w-7 ${iconColor}`} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`flex-grow border-none bg-transparent ${textColor} font-sans text-lg font-normal leading-[2.25rem] tracking-tight md:text-xl placeholder:${placeholderColor} focus:outline-none`}
|
||||
data-testid="store-search-input"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export const useSearchbar = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
console.log(searchQuery);
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const encodedTerm = encodeURIComponent(searchQuery);
|
||||
router.push(`/marketplace/search?searchTerm=${encodedTerm}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
setSearchQuery,
|
||||
searchQuery,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import Image from "next/image";
|
||||
import { StarRatingIcons } from "@/components/ui/icons";
|
||||
|
||||
interface StoreCardProps {
|
||||
agentName: string;
|
||||
agentImage: string;
|
||||
description: string;
|
||||
runs: number;
|
||||
rating: number;
|
||||
onClick: () => void;
|
||||
avatarSrc: string;
|
||||
hideAvatar?: boolean;
|
||||
creatorName?: string;
|
||||
}
|
||||
|
||||
export const StoreCard: React.FC<StoreCardProps> = ({
|
||||
agentName,
|
||||
agentImage,
|
||||
description,
|
||||
runs,
|
||||
rating,
|
||||
onClick,
|
||||
avatarSrc,
|
||||
hideAvatar = false,
|
||||
creatorName,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[27rem] 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={handleClick}
|
||||
data-testid="store-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${agentName} agent card`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* First Section: Image with Avatar */}
|
||||
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-3xl md:aspect-[2.17/1]">
|
||||
{agentImage && (
|
||||
<Image
|
||||
src={agentImage}
|
||||
alt={`${agentName} preview image`}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
{!hideAvatar && (
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
{avatarSrc && (
|
||||
<AvatarImage
|
||||
src={avatarSrc}
|
||||
alt={`${creatorName || agentName} creator avatar`}
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback size={64}>
|
||||
{(creatorName || agentName).charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex w-full flex-1 flex-col px-4">
|
||||
{/* Second Section: Agent Name and Creator Name */}
|
||||
<div className="flex w-full flex-col">
|
||||
<h3 className="line-clamp-2 font-poppins text-2xl font-semibold text-[#272727] dark:text-neutral-100">
|
||||
{agentName}
|
||||
</h3>
|
||||
{!hideAvatar && creatorName && (
|
||||
<p className="mt-3 truncate font-sans text-xl font-normal text-neutral-600 dark:text-neutral-400">
|
||||
by {creatorName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Third Section: Description */}
|
||||
<div className="mt-2.5 flex w-full flex-col">
|
||||
<p className="line-clamp-3 text-base font-normal leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow" />
|
||||
{/* Spacer to push stats to bottom */}
|
||||
|
||||
{/* Fourth Section: Stats Row - aligned to bottom */}
|
||||
<div className="mt-5 w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{runs.toLocaleString()} runs
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
<div
|
||||
className="inline-flex items-center"
|
||||
role="img"
|
||||
aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`}
|
||||
>
|
||||
{StarRatingIcons(rating)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,12 @@
|
||||
import React from "react";
|
||||
import { HeroSection } from "@/components/agptui/composite/HeroSection";
|
||||
import { FeaturedSection } from "@/components/agptui/composite/FeaturedSection";
|
||||
import {
|
||||
AgentsSection,
|
||||
Agent,
|
||||
} from "@/components/agptui/composite/AgentsSection";
|
||||
import { BecomeACreator } from "@/components/agptui/BecomeACreator";
|
||||
import {
|
||||
FeaturedCreators,
|
||||
FeaturedCreator,
|
||||
} from "@/components/agptui/composite/FeaturedCreators";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Metadata } from "next";
|
||||
import {
|
||||
prefetchGetV2ListStoreAgentsQuery,
|
||||
prefetchGetV2ListStoreCreatorsQuery,
|
||||
} from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
import { MainMarkeplacePage } from "./components/MainMarketplacePage/MainMarketplacePage";
|
||||
|
||||
import { getMarketplaceData } from "./actions";
|
||||
|
||||
// Force dynamic rendering to avoid static generation issues with cookies
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// FIX: Correct metadata
|
||||
@@ -63,31 +54,24 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function MarketplacePage(): Promise<React.ReactElement> {
|
||||
const { featuredAgents, topAgents, featuredCreators } =
|
||||
await getMarketplaceData();
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
await Promise.all([
|
||||
prefetchGetV2ListStoreAgentsQuery(queryClient, {
|
||||
featured: true,
|
||||
}),
|
||||
prefetchGetV2ListStoreAgentsQuery(queryClient, {
|
||||
sorted_by: "runs",
|
||||
}),
|
||||
prefetchGetV2ListStoreCreatorsQuery(queryClient, {
|
||||
featured: true,
|
||||
sorted_by: "num_agents",
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<main className="px-4">
|
||||
<HeroSection />
|
||||
<FeaturedSection featuredAgents={featuredAgents.agents} />
|
||||
{/* 100px margin because our featured sections button are placed 40px below the container */}
|
||||
<Separator className="mb-6 mt-24" />
|
||||
<AgentsSection
|
||||
sectionTitle="Top Agents"
|
||||
agents={topAgents.agents as Agent[]}
|
||||
/>
|
||||
<Separator className="mb-[25px] mt-[60px]" />
|
||||
<FeaturedCreators
|
||||
featuredCreators={featuredCreators.creators as FeaturedCreator[]}
|
||||
/>
|
||||
<Separator className="mb-[25px] mt-[60px]" />
|
||||
<BecomeACreator
|
||||
title="Become a Creator"
|
||||
description="Join our ever-growing community of hackers and tinkerers"
|
||||
buttonText="Become a Creator"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<MainMarkeplacePage />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user