fix(frontend/marketplace): comprehensive marketplace UI redesign (#12462)

## Summary

<img width="600" height="964" alt="Screenshot_2026-03-19_at_00 07 52"
src="https://github.com/user-attachments/assets/95c0430a-26a3-499b-8f6a-25b9715d3012"
/>
<img width="600" height="968" alt="Screenshot_2026-03-19_at_00 08 01"
src="https://github.com/user-attachments/assets/d440c3b0-c247-4f13-bf82-a51ff2e50902"
/>
<img width="600" height="939" alt="Screenshot_2026-03-19_at_00 08 14"
src="https://github.com/user-attachments/assets/f19be759-e102-4a95-9474-64f18bce60cf"
/>"
<img width="600" height="953" alt="Screenshot_2026-03-19_at_00 08 24"
src="https://github.com/user-attachments/assets/ba4fa644-3958-45e2-89e9-a6a4448c63c5"
/>



- Re-style and re-skin the Marketplace pages to look more "professional"
...
- Move the `Give feedback` button to the header

## Test plan
- [x] Verify marketplace page search bar matches Form text field styling
- [x] Verify agent cards have padding and subtle border
- [x] Verify hover/focus states work correctly
- [x] Check responsive behavior at different breakpoints

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubbe
2026-03-19 22:28:01 +08:00
committed by GitHub
parent a5f9c43a41
commit 1bb91b53b7
45 changed files with 1694 additions and 856 deletions

View File

@@ -8,33 +8,39 @@ import { Text } from "@/components/atoms/Text/Text";
import { useJumpBackIn } from "./useJumpBackIn";
export function JumpBackIn() {
const { agent, isLoading } = useJumpBackIn();
const { execution, isLoading } = useJumpBackIn();
if (isLoading || !agent) {
if (isLoading || !execution) {
return null;
}
const href = execution.libraryAgentId
? `/library/agents/${execution.libraryAgentId}?activeTab=runs&activeItem=${execution.id}`
: "#";
return (
<div className="flex items-center justify-between rounded-large border border-zinc-200 bg-gradient-to-r from-zinc-50 to-white px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-zinc-900">
<Lightning size={18} weight="fill" className="text-white" />
</div>
<div className="flex flex-col">
<Text variant="small" className="text-zinc-500">
Continue where you left off
</Text>
<Text variant="body-medium" className="text-zinc-900">
{agent.name}
</Text>
<div className="rounded-large bg-gradient-to-r from-zinc-200 via-zinc-200/60 to-indigo-200/50 p-[1px]">
<div className="flex items-center justify-between rounded-large bg-[#F6F7F8] px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-zinc-900">
<Lightning size={18} weight="fill" className="text-white" />
</div>
<div className="flex flex-col">
<Text variant="small" className="text-zinc-500">
{execution.statusLabel} · {execution.duration}
</Text>
<Text variant="body-medium" className="text-zinc-900">
{execution.agentName}
</Text>
</div>
</div>
<NextLink href={href}>
<Button variant="secondary" size="small" className="gap-1.5">
Jump Back In
<ArrowRight size={16} />
</Button>
</NextLink>
</div>
<NextLink href={`/library/agents/${agent.id}`}>
<Button variant="primary" size="small" className="gap-1.5">
Jump Back In
<ArrowRight size={16} />
</Button>
</NextLink>
</div>
);
}

View File

@@ -1,28 +1,82 @@
"use client";
import { useGetV2ListLibraryAgents } from "@/app/api/__generated__/endpoints/library/library";
import { useGetV1ListAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { okData } from "@/app/api/helpers";
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
import { useMemo } from "react";
function isActive(status: AgentExecutionStatus) {
return (
status === AgentExecutionStatus.RUNNING ||
status === AgentExecutionStatus.QUEUED ||
status === AgentExecutionStatus.REVIEW
);
}
function formatDuration(startedAt: Date | string | null | undefined): string {
if (!startedAt) return "";
const start = new Date(startedAt);
if (isNaN(start.getTime())) return "";
const ms = Date.now() - start.getTime();
if (ms < 0) return "";
const sec = Math.floor(ms / 1000);
if (sec < 5) return "a few seconds";
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ${sec % 60}s`;
const hr = Math.floor(min / 60);
return `${hr}h ${min % 60}m`;
}
function getStatusLabel(status: AgentExecutionStatus) {
if (status === AgentExecutionStatus.RUNNING) return "Running";
if (status === AgentExecutionStatus.QUEUED) return "Queued";
if (status === AgentExecutionStatus.REVIEW) return "Awaiting approval";
return "";
}
export function useJumpBackIn() {
const { data, isLoading } = useGetV2ListLibraryAgents(
{
page: 1,
page_size: 1,
sort_by: "updatedAt",
},
{
const { data: executions, isLoading: executionsLoading } =
useGetV1ListAllExecutions({
query: { select: okData },
},
);
});
// The API doesn't include execution data by default (include_executions is
// internal to the backend), so recent_executions is always empty here.
// We use the most recently updated agent as the "jump back in" candidate
// instead — updatedAt is the best available proxy for recent activity.
const agent = data?.agents[0] ?? null;
const { agentInfoMap, isRefreshing: agentsLoading } = useLibraryAgents();
const activeExecution = useMemo(() => {
if (!executions) return null;
const active = executions
.filter((e) => isActive(e.status))
.sort((a, b) => {
const aTime = a.started_at ? new Date(a.started_at).getTime() : 0;
const bTime = b.started_at ? new Date(b.started_at).getTime() : 0;
return bTime - aTime;
});
return active[0] ?? null;
}, [executions]);
const enriched = useMemo(() => {
if (!activeExecution) return null;
const info = agentInfoMap.get(activeExecution.graph_id);
return {
id: activeExecution.id,
agentName: info?.name ?? "Unknown Agent",
libraryAgentId: info?.library_agent_id,
status: activeExecution.status,
statusLabel: getStatusLabel(activeExecution.status),
duration: formatDuration(activeExecution.started_at),
};
}, [activeExecution, agentInfoMap]);
return {
agent,
isLoading,
execution: enriched,
isLoading: executionsLoading || agentsLoading,
};
}

View File

@@ -3,7 +3,6 @@
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { LibraryActionSubHeader } from "../LibraryActionSubHeader/LibraryActionSubHeader";
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
import { LibraryFolder } from "../LibraryFolder/LibraryFolder";
import { LibrarySubSection } from "../LibrarySubSection/LibrarySubSection";
@@ -96,7 +95,8 @@ export function LibraryAgentList({
const {
isFavoritesTab,
agentLoading,
agentCount,
allAgentsCount,
favoritesCount,
agents,
hasNextPage,
isFetchingNextPage,
@@ -120,19 +120,18 @@ export function LibraryAgentList({
return (
<>
<LibraryActionSubHeader
agentCount={agentCount}
setLibrarySort={setLibrarySort}
/>
{!selectedFolderId && (
<LibrarySubSection
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
allCount={allAgentsCount}
favoritesCount={favoritesCount}
setLibrarySort={setLibrarySort}
/>
)}
<div>
<div className="pt-4">
{selectedFolderId && (
<div className="mb-4 flex items-center gap-2">
<button

View File

@@ -209,6 +209,8 @@ export function useLibraryAgentList({
isFavoritesTab,
agentLoading,
agentCount,
allAgentsCount,
favoritesCount: favoriteAgentsData.agentCount,
agents,
hasNextPage: agentsHasNextPage,
isFetchingNextPage: agentsIsFetchingNextPage,

View File

@@ -19,9 +19,11 @@ export function LibrarySortMenu({ setLibrarySort }: Props) {
const { handleSortChange } = useLibrarySortMenu({ setLibrarySort });
return (
<div className="flex items-center" data-testid="sort-by-dropdown">
<span className="hidden whitespace-nowrap sm:inline">sort by</span>
<span className="hidden whitespace-nowrap text-sm sm:inline">
sort by
</span>
<Select onValueChange={handleSortChange}>
<SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-base underline underline-offset-4 shadow-none">
<SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-sm underline underline-offset-4 shadow-none">
<ArrowDownNarrowWideIcon className="h-4 w-4 sm:hidden" />
<SelectValue placeholder="Last Modified" />
</SelectTrigger>

View File

@@ -4,17 +4,29 @@ import {
TabsLineList,
TabsLineTrigger,
} from "@/components/molecules/TabsLine/TabsLine";
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
import { LibraryTab } from "../../types";
import LibraryFolderCreationDialog from "../LibraryFolderCreationDialog/LibraryFolderCreationDialog";
import { LibrarySortMenu } from "../LibrarySortMenu/LibrarySortMenu";
interface Props {
tabs: LibraryTab[];
activeTab: string;
onTabChange: (tabId: string) => void;
allCount: number;
favoritesCount: number;
setLibrarySort: (value: LibraryAgentSort) => void;
}
export function LibrarySubSection({ tabs, activeTab, onTabChange }: Props) {
export function LibrarySubSection({
tabs,
activeTab,
onTabChange,
allCount,
favoritesCount,
setLibrarySort,
}: Props) {
const { registerFavoritesTabRef } = useFavoriteAnimation();
const favoritesRef = useRef<HTMLButtonElement>(null);
@@ -25,8 +37,21 @@ export function LibrarySubSection({ tabs, activeTab, onTabChange }: Props) {
};
}, [registerFavoritesTabRef]);
function getTabLabel(tab: LibraryTab) {
if (tab.id === "all") {
return `${tab.title} ${allCount}`;
}
if (tab.id === "favorites") {
return favoritesCount > 0 ? `${tab.title} ${favoritesCount}` : tab.title;
}
return tab.title;
}
return (
<div className="flex items-center justify-between gap-4">
<span data-testid="agents-count" className="sr-only">
{allCount}
</span>
<TabsLine value={activeTab} onValueChange={onTabChange}>
<TabsLineList>
{tabs.map((tab) => (
@@ -35,14 +60,18 @@ export function LibrarySubSection({ tabs, activeTab, onTabChange }: Props) {
value={tab.id}
ref={tab.id === "favorites" ? favoritesRef : undefined}
className="inline-flex items-center gap-1.5"
disabled={tab.id === "favorites" && favoritesCount === 0}
>
<tab.icon size={16} />
{tab.title}
{getTabLabel(tab)}
</TabsLineTrigger>
))}
</TabsLineList>
</TabsLine>
<LibraryFolderCreationDialog />
<div className="hidden items-center gap-6 md:flex">
<LibraryFolderCreationDialog />
<LibrarySortMenu setLibrarySort={setLibrarySort} />
</div>
</div>
);
}

View File

@@ -39,8 +39,8 @@ export default function LibraryPage() {
onAnimationComplete={handleFavoriteAnimationComplete}
>
<main className="pt-160 container min-h-screen space-y-4 pb-20 pt-16 sm:px-8 md:px-12">
<JumpBackIn />
<LibraryActionHeader setSearchTerm={setSearchTerm} />
<JumpBackIn />
<LibraryAgentList
searchTerm={searchTerm}
librarySort={librarySort}

View File

@@ -0,0 +1,194 @@
"use client";
import {
getGetV2ListLibraryAgentsQueryKey,
useDeleteV2DeleteLibraryAgent,
useGetV2ListLibraryAgents,
usePostV2AddMarketplaceAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import { getV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentResponse } from "@/app/api/__generated__/models/libraryAgentResponse";
import { Button } from "@/components/atoms/Button/Button";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { analytics } from "@/services/analytics";
import { PlusIcon } from "@phosphor-icons/react";
import * as Sentry from "@sentry/nextjs";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
function UndoActions({
libraryAgentID,
libraryHref,
onUndo,
}: {
libraryAgentID: string;
libraryHref: string;
onUndo: (id: string) => Promise<void>;
}) {
const [isUndoing, setIsUndoing] = useState(false);
return (
<div className="mt-6 flex items-center gap-2">
<Button
variant="primary"
size="small"
as="NextLink"
className="bg-neutral-200 text-zinc-900 hover:bg-neutral-300 hover:text-zinc-800"
href={libraryHref}
>
Open agent
</Button>
<Button
variant="ghost"
size="small"
loading={isUndoing}
className="border-none text-zinc-200 hover:bg-transparent hover:text-zinc-400"
onClick={async () => {
setIsUndoing(true);
try {
await onUndo(libraryAgentID);
} finally {
setIsUndoing(false);
}
}}
>
{isUndoing ? "Undoing..." : "Undo"}
</Button>
</div>
);
}
interface Props {
creatorSlug: string;
agentSlug: string;
agentName: string;
agentGraphID: string;
className?: string;
isInLibrary?: boolean;
}
export function AddToLibraryButton({
creatorSlug,
agentSlug,
agentName,
agentGraphID,
className,
isInLibrary,
}: Props) {
const { isLoggedIn } = useSupabase();
const { toast } = useToast();
const queryClient = useQueryClient();
const [justAdded, setJustAdded] = useState(false);
// Only fetch library list if isInLibrary wasn't provided by parent
const { data: libraryAgents } = useGetV2ListLibraryAgents(undefined, {
query: {
enabled: isLoggedIn && isInLibrary === undefined,
select: (res) =>
res.status === 200 ? (res.data as LibraryAgentResponse) : undefined,
},
});
const { mutateAsync: addToLibrary, isPending } =
usePostV2AddMarketplaceAgent();
const { mutateAsync: removeFromLibrary } = useDeleteV2DeleteLibraryAgent();
if (!isLoggedIn) return null;
if (justAdded) return null;
const isAlreadyInLibrary =
isInLibrary ??
libraryAgents?.agents?.some(
(a: LibraryAgent) => a.graph_id === agentGraphID,
);
if (isAlreadyInLibrary) return null;
async function handleClick(e: React.MouseEvent) {
e.stopPropagation();
e.preventDefault();
try {
const details = await getV2GetSpecificAgent(creatorSlug, agentSlug);
if (details.status !== 200) {
throw new Error("Failed to fetch agent details");
}
const { data: response } = await addToLibrary({
data: {
store_listing_version_id: details.data.store_listing_version_id,
},
});
const data = response as LibraryAgent;
setJustAdded(true);
await queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
analytics.sendDatafastEvent("add_to_library", {
name: data.name,
id: data.id,
});
const addedToast = toast({
title: `Agent ${agentName} added to your library.`,
description: (
<UndoActions
libraryAgentID={data.id}
libraryHref={`/library/agents/${data.id}`}
onUndo={async (id) => {
try {
await removeFromLibrary({ libraryAgentId: id });
await queryClient.invalidateQueries({
queryKey: getGetV2ListLibraryAgentsQueryKey(),
});
setJustAdded(false);
addedToast.dismiss();
toast({
title: "Action undone.",
variant: "info",
duration: 3000,
});
} catch (undoError) {
Sentry.captureException(undoError);
toast({
title: "Failed to undo. Please try again.",
variant: "destructive",
});
}
}}
/>
),
dismissable: false,
duration: 10000,
});
} catch (error) {
Sentry.captureException(error);
toast({
title: "Error",
description: "Failed to add agent to library. Please try again.",
variant: "destructive",
});
}
}
return (
<Button
variant="ghost"
size="small"
loading={isPending}
leftIcon={<PlusIcon size={14} weight="bold" />}
onClick={handleClick}
className={`z-10 text-zinc-500 hover:border-transparent hover:bg-transparent hover:text-zinc-800 ${className ?? ""}`}
aria-label={`Add ${agentName} to library`}
>
{isPending ? "Adding..." : "Add"}
</Button>
);
}

View File

@@ -1,6 +1,8 @@
import { Button } from "@/components/atoms/Button/Button";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
import { Play } from "@phosphor-icons/react";
import Image from "next/image";
import { PlayIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/__legacy__/ui/button";
import { useEffect, useState } from "react";
import {
getYouTubeVideoId,
isValidVideoFile,
@@ -16,19 +18,24 @@ interface AgentImageItemProps {
handlePause: (index: number) => void;
}
export const AgentImageItem: React.FC<AgentImageItemProps> = ({
export function AgentImageItem({
image,
index,
playingVideoIndex,
handlePlay,
handlePause,
}) => {
}: AgentImageItemProps) {
const { videoRef } = useAgentImageItem({ playingVideoIndex, index });
const isVideoFile = isValidVideoFile(image);
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
setImageLoaded(false);
}, [image]);
return (
<div className="relative">
<div className="h-[15rem] overflow-hidden rounded-[26px] bg-[#a8a8a8] dark:bg-neutral-700 sm:h-[20rem] sm:w-full md:h-[25rem] lg:h-[30rem]">
<div className="h-[15rem] overflow-hidden rounded-xl border border-neutral-100 bg-[#a8a8a8] sm:h-[20rem] sm:w-full md:h-[25rem] lg:h-[30rem]">
{isValidVideoUrl(image) ? (
getYouTubeVideoId(image) ? (
<iframe
@@ -60,12 +67,17 @@ export const AgentImageItem: React.FC<AgentImageItemProps> = ({
)
) : (
<div className="relative h-full w-full">
{!imageLoaded && (
<Skeleton className="absolute inset-0 rounded-xl" />
)}
<Image
src={image}
alt="Image"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="rounded-xl object-cover"
onLoad={() => setImageLoaded(true)}
onError={() => setImageLoaded(true)}
/>
</div>
)}
@@ -73,20 +85,25 @@ export const AgentImageItem: React.FC<AgentImageItemProps> = ({
{isVideoFile && playingVideoIndex !== index && (
<div className="absolute bottom-2 left-2 sm:bottom-3 sm:left-3 md:bottom-4 md:left-4 lg:bottom-[1.25rem] lg:left-[1.25rem]">
<Button
size="default"
variant="secondary"
size="large"
onClick={() => {
if (videoRef.current) {
videoRef.current.play();
}
}}
rightIcon={
<Play
size={20}
weight="fill"
className="text-black dark:text-neutral-200 sm:h-6 sm:w-6 md:h-7 md:w-7"
/>
}
>
<span className="pr-1 text-sm font-medium leading-6 tracking-tight text-[#272727] dark:text-neutral-200 sm:pr-2 sm:text-base sm:leading-7 md:text-lg md:leading-8 lg:text-xl lg:leading-9">
Play demo
</span>
<PlayIcon className="h-5 w-5 text-black dark:text-neutral-200 sm:h-6 sm:w-6 md:h-7 md:w-7" />
Play demo
</Button>
</div>
)}
</div>
);
};
}

View File

@@ -1,27 +1,90 @@
"use client";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import { AgentImageItem } from "../AgentImageItem/AgentImageItem";
import { getYouTubeVideoId, isValidVideoUrl } from "../AgentImageItem/helpers";
import { useAgentImage } from "./useAgentImage";
interface AgentImagesProps {
images: string[];
}
export const AgentImages: React.FC<AgentImagesProps> = ({ images }) => {
export function AgentImages({ images }: AgentImagesProps) {
const { playingVideoIndex, handlePlay, handlePause } = useAgentImage();
const [selectedIndex, setSelectedIndex] = useState(0);
const [loadedThumbs, setLoadedThumbs] = useState<Set<number>>(new Set());
useEffect(() => {
setSelectedIndex((prev) => Math.max(0, Math.min(prev, images.length - 1)));
}, [images.length]);
if (images.length === 0) return null;
return (
<div className="w-full overflow-y-auto bg-white px-2 dark:bg-transparent lg:w-[56.25rem]">
<div className="space-y-4 sm:space-y-6 md:space-y-[1.875rem]">
{images.map((image, index) => (
<AgentImageItem
key={index}
image={image}
index={index}
playingVideoIndex={playingVideoIndex}
handlePlay={handlePlay}
handlePause={handlePause}
/>
))}
</div>
<div className="w-full px-2 dark:bg-transparent lg:w-3/5 lg:flex-1">
{/* Main preview */}
<AgentImageItem
image={images[selectedIndex]}
index={selectedIndex}
playingVideoIndex={playingVideoIndex}
handlePlay={handlePlay}
handlePause={handlePause}
/>
{/* Thumbnails */}
{images.length > 1 && (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1 sm:mt-4 sm:gap-3">
{images.map((image, index) => {
const isVideo = isValidVideoUrl(image);
const youtubeId = isVideo ? getYouTubeVideoId(image) : null;
return (
<button
key={index}
type="button"
onClick={() => setSelectedIndex(index)}
className={cn(
"relative h-16 w-24 shrink-0 overflow-hidden rounded-lg border transition-all sm:h-20 sm:w-32",
selectedIndex === index
? "border-violet-500"
: "border-zinc-100 opacity-70 hover:opacity-100",
)}
>
{(!isVideo || youtubeId) && !loadedThumbs.has(index) && (
<Skeleton className="absolute inset-0 rounded-lg" />
)}
{youtubeId ? (
<img
src={`https://img.youtube.com/vi/${youtubeId}/mqdefault.jpg`}
alt={`Thumbnail ${index + 1}`}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
onLoad={() =>
setLoadedThumbs((prev) => new Set(prev).add(index))
}
/>
) : isVideo ? (
<div className="flex h-full w-full items-center justify-center bg-neutral-200 text-xs text-neutral-500">
Video
</div>
) : (
<img
src={image}
alt={`Thumbnail ${index + 1}`}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
onLoad={() =>
setLoadedThumbs((prev) => new Set(prev).add(index))
}
/>
)}
</button>
);
})}
</div>
)}
</div>
);
};
}

View File

@@ -1,28 +1,32 @@
"use client";
import { StarRatingIcons } from "@/components/__legacy__/ui/icons";
import { Separator } from "@/components/__legacy__/ui/separator";
import Link from "next/link";
import { User } from "@supabase/supabase-js";
import { cn } from "@/lib/utils";
import { okData } from "@/app/api/helpers";
import type { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
import { useGetV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store";
import type { ChangelogEntry } from "@/app/api/__generated__/models/changelogEntry";
import type { GetV2GetSpecificAgentParams } from "@/app/api/__generated__/models/getV2GetSpecificAgentParams";
import { useAgentInfo } from "./useAgentInfo";
import { useGetV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store";
import type { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
import { okData } from "@/app/api/helpers";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { Badge } from "@/components/atoms/Badge/Badge";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { formatTimeAgo } from "@/lib/utils/time";
import * as React from "react";
import Link from "next/link";
import { FileArrowDownIcon, PlusIcon } from "@phosphor-icons/react";
import { User } from "@supabase/supabase-js";
import { useAgentInfo } from "./useAgentInfo";
interface AgentInfoProps {
user: User | null;
agentId: string;
name: string;
creator: string;
creatorAvatar?: string;
shortDescription: string;
longDescription: string;
rating: number;
runs: number;
categories: string[];
lastUpdated: string;
@@ -38,9 +42,9 @@ export const AgentInfo = ({
agentId,
name,
creator,
creatorAvatar,
shortDescription,
longDescription,
rating,
runs,
categories,
lastUpdated,
@@ -57,9 +61,6 @@ export const AgentInfo = ({
isAddingAgentToLibrary,
} = useAgentInfo({ storeListingVersionId });
// State for expanding version list - start with 3, then show 3 more each time
const [visibleVersionCount, setVisibleVersionCount] = React.useState(3);
// Get store agent data for version history
const params: GetV2GetSpecificAgentParams = { include_changelog: true };
const { data: storeAgentData } = useGetV2GetSpecificAgent(
@@ -87,9 +88,6 @@ export const AgentInfo = ({
}))
: [];
const agentVersions = allVersions.slice(0, visibleVersionCount);
const hasMoreVersions = allVersions.length > visibleVersionCount;
const renderVersionItem = (versionInfo: {
version: number;
isCurrentVersion: boolean;
@@ -147,166 +145,176 @@ export const AgentInfo = ({
};
return (
<div className="w-full max-w-[396px] px-4 sm:px-6 lg:w-[396px] lg:px-0">
{/* Title */}
<div
data-testid="agent-title"
className="mb-3 w-full font-poppins text-2xl font-medium leading-normal text-neutral-900 dark:text-neutral-100 sm:text-3xl lg:mb-4 lg:text-[35px] lg:leading-10"
>
{name}
</div>
<div className="w-full px-4 sm:px-6 lg:px-0">
<div className="mb-8 rounded-2xl bg-gradient-to-r from-blue-200 to-indigo-200 p-[1px]">
<div className="flex flex-col rounded-[calc(1rem-2px)] bg-gray-50 p-4">
{/* Title */}
<Text variant="h2" data-testid="agent-title" className="mb-3 w-full">
{name}
</Text>
{/* Creator */}
<div className="mb-3 flex w-full items-center gap-1.5 lg:mb-4">
<div className="text-base font-normal text-neutral-800 dark:text-neutral-200 sm:text-lg lg:text-xl">
by
</div>
<Link
data-testid={"agent-creator"}
href={`/marketplace/creator/${encodeURIComponent(creator)}`}
className="text-base font-medium text-neutral-800 hover:underline dark:text-neutral-200 sm:text-lg lg:text-xl"
>
{creator}
</Link>
</div>
{/* Short Description */}
<div className="mb-4 line-clamp-2 w-full text-base font-normal leading-normal text-neutral-600 dark:text-neutral-300 sm:text-lg lg:mb-5 lg:text-xl lg:leading-7">
{shortDescription}
</div>
{/* Rating and Runs */}
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-1.5 sm:gap-2">
<span className="whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{rating.toFixed(1)}
</span>
<div className="flex gap-0.5">{StarRatingIcons(rating)}</div>
</div>
<div className="whitespace-nowrap text-base font-semibold text-neutral-800 dark:text-neutral-200 sm:text-lg">
{runs.toLocaleString()} runs
</div>
</div>
{/* Buttons */}
{user && (
<div className="mt-6 flex w-full gap-3 lg:mt-8">
<button
className={cn(
"inline-flex min-w-24 items-center justify-center rounded-full bg-violet-600 px-4 py-3",
"transition-colors duration-200 hover:bg-violet-500 disabled:bg-zinc-400",
)}
data-testid={"agent-add-library-button"}
disabled={isAddingAgentToLibrary}
onClick={() =>
handleLibraryAction({
isAddingAgentFirstTime: !isAgentAddedToLibrary,
})
}
>
<span className="justify-start font-sans text-sm font-medium leading-snug text-primary-foreground">
{isAgentAddedToLibrary ? "See runs" : "Add to library"}
</span>
</button>
</div>
)}
{/* Download section */}
<p className="mt-6 text-zinc-600 dark:text-zinc-400 lg:mt-12">
Want to use this agent locally?{" "}
<button
className="underline"
onClick={() => handleDownload(agentId, name)}
disabled={isDownloadingAgent}
data-testid="agent-download-button"
>
Download here.
</button>
</p>
{/* Separator */}
<Separator className="my-7" />
{/* Agent Details Section */}
<div className="flex w-full flex-col gap-4 lg:gap-6">
{/* Description Section */}
<div className="w-full">
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Description
</div>
{/* Creator */}
<div
data-testid={"agent-description"}
className="whitespace-pre-line text-base font-normal leading-6 text-neutral-600 dark:text-neutral-400"
className="mb-3 flex w-full items-center gap-2 lg:mb-12"
data-testid="agent-creator"
>
{longDescription}
</div>
</div>
{/* Categories */}
<div className="flex w-full flex-col gap-1.5 sm:gap-2">
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200 sm:mb-2">
Categories
</div>
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{categories.map((category, index) => (
<div
key={index}
className="decoration-skip-ink-none whitespace-nowrap rounded-full border border-neutral-600 bg-white px-2 py-0.5 text-base font-normal leading-6 text-neutral-800 underline-offset-[from-font] dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 sm:px-[16px] sm:py-[10px]"
>
{category}
</div>
))}
</div>
</div>
{/* Version history */}
<div className="flex w-full flex-col gap-1.5 sm:gap-2">
<div className="decoration-skip-ink-none text-base font-medium leading-6 text-neutral-800 dark:text-neutral-200">
Version history
</div>
<div className="decoration-skip-ink-none text-sm font-normal leading-6 text-neutral-600 underline-offset-[from-font] dark:text-neutral-400">
Last updated {formatTimeAgo(lastUpdated)}
</div>
<div className="decoration-skip-ink-none text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
Version {version}.0
<Avatar className="h-7 w-7 shrink-0">
{creatorAvatar && (
<AvatarImage src={creatorAvatar} alt={`${creator} avatar`} />
)}
<AvatarFallback size={28}>{creator.charAt(0)}</AvatarFallback>
</Avatar>
<Text variant="body" className="text-md">
by
</Text>
<Link
href={`/marketplace/creator/${encodeURIComponent(creatorSlug ?? creator)}`}
className="text-md font-medium hover:underline"
>
{creator}
</Link>
</div>
{/* Version List */}
{agentVersions.length > 0 ? (
<div className="mt-3">
<div className="decoration-skip-ink-none mb-1.5 text-base font-medium leading-6 text-neutral-900 dark:text-neutral-200 sm:mb-2">
Changelog
</div>
{agentVersions.map(renderVersionItem)}
{hasMoreVersions && (
<button
onClick={() => setVisibleVersionCount((prev) => prev + 3)}
className="mt-2 flex items-center gap-1 text-sm font-medium text-neutral-700 hover:text-neutral-700 dark:text-neutral-100 dark:hover:text-neutral-300"
{/* Short Description */}
<div className="mb-4 line-clamp-2 w-full text-base font-normal leading-normal text-neutral-600 dark:text-neutral-300 sm:text-lg lg:mb-5 lg:text-xl lg:leading-7">
{shortDescription}
</div>
{/* Buttons + Runs */}
<div className="mt-6 flex w-full items-center justify-between lg:mt-8">
<div className="flex gap-3">
{user && (
<Button
variant="primary"
className="group/add min-w-36 border-violet-600 bg-violet-600 transition-shadow duration-300 hover:border-violet-500 hover:bg-violet-500 hover:shadow-[0_0_20px_rgba(139,92,246,0.4)]"
data-testid="agent-add-library-button"
disabled={isAddingAgentToLibrary}
loading={isAddingAgentToLibrary}
leftIcon={
!isAddingAgentToLibrary && !isAgentAddedToLibrary ? (
<PlusIcon
size={16}
weight="bold"
className="transition-transform duration-300 group-hover/add:rotate-90 group-hover/add:scale-125"
/>
) : undefined
}
onClick={() =>
handleLibraryAction({
isAddingAgentFirstTime: !isAgentAddedToLibrary,
})
}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Read more</span>
</button>
{isAddingAgentToLibrary
? "Adding..."
: isAgentAddedToLibrary
? "See runs"
: "Add to library"}
</Button>
)}
<Button
variant="ghost"
loading={isDownloadingAgent}
onClick={() => handleDownload(agentId, name)}
data-testid="agent-download-button"
>
{!isDownloadingAgent && <FileArrowDownIcon size={18} />}
{isDownloadingAgent ? "Downloading..." : "Download"}
</Button>
</div>
<Text
variant="small"
className="mr-4 hidden whitespace-nowrap text-zinc-500 lg:block"
>
{runs === 0
? "No runs"
: `${runs.toLocaleString()} run${runs > 1 ? "s" : ""}`}
</Text>
</div>
</div>
</div>
<div className="mb-8 flex flex-col gap-24">
{/* Agent Details Section */}
<div className="flex w-full flex-col gap-4 lg:gap-6">
{/* Description Section */}
<div className="mb-4 w-full">
<Text variant="h5" className="mb-1.5 text-[1.3rem]">
Description
</Text>
<Text
variant="body"
data-testid={"agent-description"}
className="text-md whitespace-pre-line text-neutral-600"
>
{longDescription}
</Text>
</div>
{/* Categories */}
<div className="mb-4 flex w-full flex-col gap-1.5 sm:gap-2 md:px-2">
<Text variant="h5" className="mb-1.5 text-[1.3rem]">
Categories
</Text>
{categories.filter((c) => c.trim()).length > 0 ? (
<div className="flex flex-wrap gap-1.5 sm:gap-2">
{categories
.filter((c) => c.trim())
.map((category, index) => (
<Badge
variant="info"
key={index}
className="border border-purple-100 bg-purple-50 text-purple-800"
>
{category}
</Badge>
))}
</div>
) : (
<Text variant="body" className="text-neutral-400">
None
</Text>
)}
</div>
{/* Version history */}
<div className="flex w-full flex-col gap-1.5 sm:gap-2 md:px-2">
<div className="flex items-baseline justify-start">
<Text variant="h5" className="text-[1.3rem]">
Version
</Text>
{allVersions.length > 0 && (
<Dialog
title="Changelog"
styling={{
maxWidth: "30rem",
}}
>
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="text-violet-600 hover:text-violet-500"
>
(Changelog)
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div className="max-h-[60vh] space-y-4 overflow-y-auto p-4">
{allVersions.map(renderVersionItem)}
</div>
</Dialog.Content>
</Dialog>
)}
</div>
) : (
<div className="text-xs text-neutral-600 dark:text-neutral-400 sm:text-sm">
Version {version}.0
<div className="flex w-full items-center justify-start gap-8">
<Text variant="body" className="text-neutral-600">
{version}.0
</Text>
<Text variant="body" className="text-neutral-600">
Last updated {formatTimeAgo(lastUpdated)}
</Text>
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -1,26 +1,95 @@
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
export const AgentPageLoading = () => {
export function AgentPageLoading() {
return (
<div className="mx-auto w-full max-w-[1360px]">
<main className="mt-5 px-4">
<div className="flex items-center space-x-2">
<Skeleton className="h-4 w-24" />
<span>/</span>
<Skeleton className="h-4 w-32" />
<span>/</span>
<Skeleton className="h-4 w-40" />
<main className="mt-5 px-4 pb-12">
{/* Breadcrumbs */}
<div className="mb-4 flex items-center justify-between px-4 md:!-mb-3">
<Skeleton className="h-8 w-20 rounded-lg" />
<div className="hidden items-center gap-2 md:flex">
<Skeleton className="h-4 w-24" />
<span className="text-zinc-300">/</span>
<Skeleton className="h-4 w-28" />
<span className="text-zinc-300">/</span>
<Skeleton className="h-4 w-36" />
</div>
</div>
<div className="mt-8 flex flex-col gap-8 md:flex-row">
<div className="w-full max-w-sm">
<Skeleton className="h-64 w-full rounded-lg" />
{/* Main content: Info left + Images right */}
<div className="mt-0 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 lg:mt-8 lg:flex-row lg:gap-12">
{/* Left: Agent info panel */}
<div className="w-full lg:w-2/5">
<div className="rounded-2xl bg-gradient-to-r from-blue-100/50 to-indigo-100/50 p-[1px]">
<div className="flex flex-col rounded-[calc(1rem-2px)] bg-gray-50 p-4">
{/* Title */}
<Skeleton className="mb-3 h-9 w-3/4" />
{/* Creator */}
<div className="mb-3 flex items-center gap-2 lg:mb-12">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-24" />
</div>
{/* Short description */}
<Skeleton className="mb-4 h-5 w-full lg:mb-5" />
<Skeleton className="mb-6 h-5 w-2/3" />
{/* Buttons */}
<div className="flex gap-3">
<Skeleton className="h-12 w-36 rounded-full" />
<Skeleton className="h-12 w-28 rounded-full" />
</div>
</div>
</div>
{/* Description section */}
<div className="mt-8 space-y-3">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
{/* Categories */}
<div className="mt-6 space-y-3">
<Skeleton className="h-6 w-24" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-7 w-20 rounded-full" />
<Skeleton className="h-7 w-24 rounded-full" />
<Skeleton className="h-7 w-16 rounded-full" />
</div>
</div>
</div>
<div className="flex-1">
<Skeleton className="aspect-video w-full rounded-lg" />
{/* Right: Image preview */}
<div className="w-full px-2 lg:w-3/5 lg:flex-1">
<Skeleton className="h-[15rem] w-full rounded-xl sm:h-[20rem] md:h-[25rem] lg:h-[30rem]" />
{/* Thumbnails */}
<div className="mt-3 flex gap-2 sm:mt-4 sm:gap-3">
<Skeleton className="h-16 w-24 shrink-0 rounded-lg sm:h-20 sm:w-32" />
<Skeleton className="h-16 w-24 shrink-0 rounded-lg sm:h-20 sm:w-32" />
<Skeleton className="h-16 w-24 shrink-0 rounded-lg sm:h-20 sm:w-32" />
</div>
</div>
</div>
{/* Related agents section */}
<div className="my-6" />
<div className="space-y-6">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6" />
<Skeleton className="h-6 w-48" />
</div>
<div className="hidden grid-cols-1 gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[25rem] w-full rounded-2xl" />
))}
</div>
</div>
</main>
</div>
);
};
}

View File

@@ -1,13 +1,15 @@
"use client";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/__legacy__/ui/carousel";
import { useAgentsSection } from "./useAgentsSection";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import { Text } from "@/components/atoms/Text/Text";
import { DotsNineIcon } from "@phosphor-icons/react";
import { StoreCard } from "../StoreCard/StoreCard";
import { useAgentsSection } from "./useAgentsSection";
export interface Agent {
slug: string;
@@ -21,62 +23,68 @@ export interface Agent {
rating: number;
}
interface AgentsSectionProps {
sectionTitle: string;
interface Props {
sectionTitle?: string;
agents: StoreAgent[];
hideAvatars?: boolean;
margin?: string;
}
export const AgentsSection = ({
export function AgentsSection({
sectionTitle,
agents: allAgents,
hideAvatars = false,
margin = "24px",
}: AgentsSectionProps) => {
// TODO: Update this when we have pagination and shifts to useAgentsSection
}: Props) {
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
{sectionTitle ? (
<div className="mb-8 flex flex-row items-center gap-2">
<DotsNineIcon size={24} />
<Text variant="h4">{sectionTitle}</Text>
</div>
) : null}
{!displayedAgents || displayedAgents.length === 0 ? (
<Text variant="body" className="text-center text-gray-500">
No agents found
</Text>
) : (
<>
{/* Mobile Carousel View */}
<Carousel
className="md:hidden"
className="-mx-4 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>
<div className="relative">
<CarouselContent className="px-4 pb-2">
{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}
creatorSlug={agent.creator}
agentSlug={agent.slug}
agentGraphID={agent.agent_graph_id}
onClick={() =>
handleCardClick(agent.creator, agent.slug)
}
/>
</CarouselItem>
))}
</CarouselContent>
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[rgb(246,247,248)] to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[rgb(246,247,248)] to-transparent" />
</div>
</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">
@@ -91,6 +99,9 @@ export const AgentsSection = ({
avatarSrc={agent.creator_avatar}
creatorName={agent.creator}
hideAvatar={hideAvatars}
creatorSlug={agent.creator}
agentSlug={agent.slug}
agentGraphID={agent.agent_graph_id}
onClick={() => handleCardClick(agent.creator, agent.slug)}
/>
))}
@@ -100,4 +111,4 @@ export const AgentsSection = ({
</div>
</div>
);
};
}

View File

@@ -1,7 +1,6 @@
"use client";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import * as React from "react";
interface BecomeACreatorProps {
title?: string;
@@ -10,17 +9,11 @@ interface BecomeACreatorProps {
}
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>
<div className="relative mx-auto mt-16 h-auto min-h-[300px] w-full max-w-[1360px] md:min-h-[400px] lg:h-[459px]">
{/* 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">

View File

@@ -1,7 +1,13 @@
import Image from "next/image";
"use client";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { Text } from "@/components/atoms/Text/Text";
import { backgroundColor } from "./helper";
interface CreatorCardProps {
interface Props {
creatorName: string;
creatorImage: string | null;
bio: string;
@@ -10,47 +16,44 @@ interface CreatorCardProps {
index: number;
}
export const CreatorCard = ({
export function CreatorCard({
creatorName,
creatorImage,
bio,
agentsUploaded,
onClick,
index,
}: CreatorCardProps) => {
}: Props) {
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`}
<button
type="button"
className={`relative flex h-[16rem] w-full cursor-pointer flex-col items-start rounded-2xl border p-4 text-left shadow-md transition-all duration-300 hover:shadow-lg ${backgroundColor(index)}`}
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"
/>
) : (
<div className="h-full w-full bg-neutral-300 dark:bg-neutral-600" />
)}
{/* Avatar */}
<Avatar className="h-14 w-14 shrink-0">
{creatorImage && (
<AvatarImage src={creatorImage} alt={`${creatorName} avatar`} />
)}
<AvatarFallback size={56}>{creatorName.charAt(0)}</AvatarFallback>
</Avatar>
<div className="mt-3 flex w-full flex-1 flex-col">
<Text variant="h4" className="leading-tight">
{creatorName}
</Text>
<div className="mt-2 flex w-full flex-col">
<Text variant="body" className="line-clamp-3 leading-normal">
{bio}
</Text>
</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>
{/* Stats */}
<Text variant="body" className="absolute bottom-4 left-4 text-zinc-500">
{agentsUploaded} {agentsUploaded === 1 ? "agent" : "agents"}
</Text>
</button>
);
};
}

View File

@@ -1,8 +1,9 @@
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
"bg-amber-50 border-amber-100/70",
"bg-violet-50 border-violet-100/70",
"bg-green-50 border-green-100/70",
"bg-blue-50 border-blue-100/70",
];
export const backgroundColor = (index: number) =>
BACKGROUND_COLORS[index % BACKGROUND_COLORS.length];

View File

@@ -1,5 +1,15 @@
import { getIconForSocial } from "@/components/__legacy__/ui/icons";
import { Fragment } from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import {
FacebookLogo,
GithubLogo,
Globe,
InstagramLogo,
LinkedinLogo,
TiktokLogo,
XLogo,
YoutubeLogo,
} from "@phosphor-icons/react";
interface CreatorLinksProps {
links: string[];
@@ -21,39 +31,59 @@ function getHostnameFromURL(url: string): string {
}
}
export const CreatorLinks = ({ links }: CreatorLinksProps) => {
if (!links || links.length === 0) {
return null;
function getSocialIcon(url: string) {
let host;
try {
host = new URL(normalizeURL(url)).hostname.toLowerCase();
} catch {
return <Globe className="h-4 w-4" />;
}
const renderLinkButton = (url: string) => (
<a
href={normalizeURL(url)}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-[200px] flex-1 items-center justify-between rounded-[34px] border border-neutral-600 px-5 py-3 dark:border-neutral-400"
>
<div className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
{getHostnameFromURL(url)}
</div>
<div className="relative h-6 w-6">
{getIconForSocial(url, {
className: "h-6 w-6 text-neutral-800 dark:text-neutral-200",
})}
</div>
</a>
);
if (host === "facebook.com" || host.endsWith(".facebook.com")) {
return <FacebookLogo className="h-4 w-4" />;
} else if (
host === "twitter.com" ||
host.endsWith(".twitter.com") ||
host === "x.com" ||
host.endsWith(".x.com")
) {
return <XLogo className="h-4 w-4" />;
} else if (host === "instagram.com" || host.endsWith(".instagram.com")) {
return <InstagramLogo className="h-4 w-4" />;
} else if (host === "linkedin.com" || host.endsWith(".linkedin.com")) {
return <LinkedinLogo className="h-4 w-4" />;
} else if (host === "github.com" || host.endsWith(".github.com")) {
return <GithubLogo className="h-4 w-4" />;
} else if (host === "youtube.com" || host.endsWith(".youtube.com")) {
return <YoutubeLogo className="h-4 w-4" />;
} else if (host === "tiktok.com" || host.endsWith(".tiktok.com")) {
return <TiktokLogo className="h-4 w-4" />;
}
return <Globe className="h-4 w-4" />;
}
export function CreatorLinks({ links }: CreatorLinksProps) {
if (!links || links.length === 0) return null;
return (
<div className="flex flex-col items-start justify-start gap-4">
<div className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Other links
</div>
<div className="flex w-full flex-wrap gap-3">
{links.map((link, index) => (
<Fragment key={index}>{renderLinkButton(link)}</Fragment>
<div className="flex flex-col items-start gap-3">
<Text variant="h5">Links</Text>
<div className="flex flex-wrap gap-2">
{links.map((link) => (
<Button
key={link}
variant="secondary"
size="small"
as="NextLink"
href={normalizeURL(link)}
target="_blank"
rel="noopener noreferrer"
rightIcon={getSocialIcon(link)}
>
{getHostnameFromURL(link)}
</Button>
))}
</div>
</div>
);
};
}

View File

@@ -1,40 +1,86 @@
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
export const CreatorPageLoading = () => {
export function CreatorPageLoading() {
return (
<div className="mx-auto w-full max-w-[1360px]">
<main className="mt-5 px-4">
<Skeleton className="mb-4 h-6 w-40" />
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
<div className="w-full md:w-auto md:shrink-0">
<Skeleton className="h-80 w-80 rounded-xl" />
<div className="mt-4 space-y-2">
<Skeleton className="h-6 w-80" />
<Skeleton className="h-4 w-80" />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-4">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-8 w-full max-w-xl" />
<Skeleton className="h-4 w-1/2" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
<main className="mt-5 px-4 pb-12">
{/* Breadcrumbs */}
<div className="mb-4 flex items-center justify-between px-4 md:!-mb-3">
<Skeleton className="h-8 w-20 rounded-lg" />
<div className="hidden items-center gap-2 md:flex">
<Skeleton className="h-4 w-24" />
<span className="text-zinc-300">/</span>
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="mt-8">
<Skeleton className="mb-6 h-px w-full" />
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full rounded-lg" />
{/* Main content */}
<div className="mt-0 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 lg:mt-8 lg:flex-row lg:gap-12">
{/* Left: Creator info card */}
<div className="w-full lg:w-2/5">
<div className="w-full px-4 sm:px-6 lg:px-0">
<div className="rounded-2xl bg-gradient-to-r from-blue-100/50 to-indigo-100/50 p-[1px]">
<div className="flex flex-col rounded-[calc(1rem-2px)] bg-gray-50 p-4">
{/* Avatar */}
<Skeleton className="mb-4 h-20 w-20 rounded-full sm:h-24 sm:w-24" />
{/* Name */}
<Skeleton className="mb-1 h-9 w-48" />
{/* Handle */}
<Skeleton className="mb-4 h-5 w-28" />
{/* Description */}
<div className="mb-6 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
{/* Categories */}
<div className="mb-6">
<Skeleton className="mb-2 h-5 w-28" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-7 w-20 rounded-full" />
<Skeleton className="h-7 w-24 rounded-full" />
<Skeleton className="h-7 w-16 rounded-full" />
</div>
</div>
{/* Links */}
<Skeleton className="mb-2 h-5 w-14" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-9 w-28 rounded-full" />
<Skeleton className="h-9 w-32 rounded-full" />
</div>
</div>
</div>
</div>
</div>
{/* Right side spacer */}
<div className="hidden lg:block lg:w-3/5" />
</div>
{/* Agent grid section */}
<div className="my-6" />
<div className="space-y-6">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6" />
<Skeleton className="h-6 w-40" />
</div>
<div className="hidden grid-cols-1 gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[25rem] w-full rounded-2xl" />
))}
</div>
{/* Mobile carousel placeholder */}
<div className="flex gap-4 overflow-hidden md:hidden">
<Skeleton className="h-[25rem] min-w-64 rounded-2xl" />
<Skeleton className="h-[25rem] min-w-64 rounded-2xl" />
</div>
</div>
</main>
</div>
);
};
}

View File

@@ -1,74 +1,139 @@
import Image from "next/image";
import { StarRatingIcons } from "@/components/__legacy__/ui/icons";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/__legacy__/ui/card";
import { useState } from "react";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
"use client";
interface FeaturedStoreCardProps {
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { Text } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
import Image from "next/image";
import { useRef, useState } from "react";
import { AddToLibraryButton } from "../AddToLibraryButton/AddToLibraryButton";
interface Props {
agent: StoreAgent;
backgroundColor: string;
}
export const FeaturedAgentCard = ({
agent,
backgroundColor,
}: FeaturedStoreCardProps) => {
// TODO: Need to use group for hover
const [isHovered, setIsHovered] = useState(false);
function getAccentTextClass(bg: string) {
if (bg.includes("violet")) return "text-violet-500 hover:text-violet-800";
if (bg.includes("blue")) return "text-blue-500 hover:text-blue-800";
if (bg.includes("green")) return "text-green-500 hover:text-green-800";
return "text-zinc-500 hover:text-zinc-800";
}
export function FeaturedAgentCard({ agent, backgroundColor }: Props) {
const [imageError, setImageError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const titleRef = useRef<HTMLSpanElement>(null);
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
function checkTitleOverflow() {
const el = titleRef.current;
if (el) setIsTitleTruncated(el.scrollHeight > el.clientHeight);
}
return (
<Card
<div
className={`relative flex h-[28rem] w-full max-w-md cursor-pointer flex-col items-start rounded-2xl p-4 shadow-md transition-all duration-300 hover:shadow-lg ${backgroundColor} border`}
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"
}`}
{/* Image */}
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-xl md:aspect-[2.17/1]">
{agent.agent_image && !imageError ? (
<>
{!imageLoaded && (
<Skeleton className="absolute inset-0 rounded-xl" />
)}
<Image
src={agent.agent_image}
alt={`${agent.agent_name} preview image`}
fill
className="object-cover"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="absolute inset-0 rounded-xl bg-violet-50" />
)}
</div>
<div className="mt-3 flex w-full flex-1 flex-col">
{/* Agent Name and Creator */}
<div className="flex w-full flex-col">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
ref={titleRef}
onPointerEnter={checkTitleOverflow}
className="line-clamp-2 block min-h-[2lh] min-w-0 leading-tight"
>
<Text variant="h4" as="span" className="leading-tight">
{agent.agent_name}
</Text>
</span>
</TooltipTrigger>
{isTitleTruncated && (
<TooltipContent>
<p>{agent.agent_name}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{agent.creator && (
<div className="mt-3 flex items-center gap-2">
<Avatar className="h-6 w-6 shrink-0">
{agent.creator_avatar && (
<AvatarImage
src={agent.creator_avatar}
alt={`${agent.creator} creator avatar`}
/>
)}
<AvatarFallback size={32}>
{agent.creator.charAt(0)}
</AvatarFallback>
</Avatar>
<Text variant="body-medium" className="truncate">
by {agent.creator}
</Text>
</div>
)}
</div>
{/* Description */}
<div className="mt-2.5 flex w-full flex-col">
<Text variant="body" className="line-clamp-3 leading-normal">
{agent.description}
</Text>
</div>
</div>
{/* Stats */}
<Text variant="body" className="absolute bottom-4 left-4 text-zinc-500">
{agent.runs === 0
? "No runs"
: `${(agent.runs ?? 0).toLocaleString()} runs`}
</Text>
{agent.creator && agent.slug && agent.agent_graph_id && (
<div className="absolute bottom-2 right-0">
<AddToLibraryButton
creatorSlug={agent.creator}
agentSlug={agent.slug}
agentName={agent.agent_name}
agentGraphID={agent.agent_graph_id}
className={getAccentTextClass(backgroundColor)}
/>
<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>
)}
</div>
);
};
}

View File

@@ -1,8 +1,10 @@
"use client";
import { CreatorDetails } from "@/app/api/__generated__/models/creatorDetails";
import { Text } from "@/components/atoms/Text/Text";
import { UserCircleDashedIcon } from "@phosphor-icons/react";
import { CreatorCard } from "../CreatorCard/CreatorCard";
import { useFeaturedCreators } from "./useFeaturedCreators";
import { CreatorDetails } from "@/app/api/__generated__/models/creatorDetails";
interface FeaturedCreatorsProps {
title?: string;
@@ -19,9 +21,10 @@ export const 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="mb-8 flex flex-row items-center gap-2">
<UserCircleDashedIcon size={24} />
<Text variant="h4">{title}</Text>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{displayedCreators.map((creator, index) => (

View File

@@ -1,18 +1,25 @@
"use client";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
CarouselIndicator,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/__legacy__/ui/carousel";
import { Text } from "@/components/atoms/Text/Text";
import { SparkleIcon } from "@phosphor-icons/react";
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";
import { useFeaturedSection } from "./useFeaturedSection";
const FEATURED_COLORS = [
"bg-violet-50 border-violet-100/70",
"bg-blue-50 border-blue-100/70",
"bg-green-50 border-green-100/70",
];
interface FeaturedSectionProps {
featuredAgents: StoreAgent[];
@@ -25,38 +32,52 @@ export const FeaturedSection = ({ featuredAgents }: FeaturedSectionProps) => {
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>
<div className="mb-8 flex flex-row items-center gap-2">
<SparkleIcon size={24} />
<Text variant="h4">Featured Agents</Text>
</div>
<Carousel
opts={{
align: "center",
containScroll: "trimSnaps",
}}
className="-mx-4"
>
<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"
<div className="relative">
<CarouselContent className="px-4">
{featuredAgents.map((agent, index) => (
<CarouselItem
key={index}
className="h-[480px] md:basis-1/2 lg:basis-1/3"
>
<FeaturedAgentCard
agent={agent}
backgroundColor={getBackgroundColor(index)}
/>
</Link>
</CarouselItem>
))}
</CarouselContent>
<div className="relative mt-4">
<CarouselIndicator />
<CarouselPrevious afterClick={handlePrevSlide} />
<CarouselNext afterClick={handleNextSlide} />
<Link
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`}
className="block h-full"
>
<FeaturedAgentCard
agent={agent}
backgroundColor={
FEATURED_COLORS[index % FEATURED_COLORS.length]
}
/>
</Link>
</CarouselItem>
))}
</CarouselContent>
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[rgb(246,247,248)] to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[rgb(246,247,248)] to-transparent" />
</div>
<div className="relative mt-2">
<CarouselIndicator className="-mt-6 ml-8" />
<CarouselPrevious
afterClick={handlePrevSlide}
className="right-14 h-10 w-10"
/>
<CarouselNext
afterClick={handleNextSlide}
className="right-2 h-10 w-10"
/>
</div>
</Carousel>
</section>

View File

@@ -1,9 +0,0 @@
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];
};

View File

@@ -1,6 +1,7 @@
"use client";
import { Badge } from "@/components/__legacy__/ui/badge";
import { cn } from "@/lib/utils";
import { MagnifyingGlass } from "@phosphor-icons/react";
import { useFilterChips } from "./useFilterChips";
interface FilterChipsProps {
@@ -9,32 +10,56 @@ interface FilterChipsProps {
multiSelect?: boolean;
}
// Some flaws in its logic
// FRONTEND-TODO : This needs to be fixed
export const FilterChips = ({
export function FilterChips({
badges,
onFilterChange,
multiSelect = true,
}: FilterChipsProps) => {
}: 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 className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start">
{badges.map((badge) => {
const isSelected = selectedFilters.includes(badge);
return (
<button
key={badge}
type="button"
onClick={() => handleBadgeClick(badge)}
className={cn(
"group relative inline-flex items-center gap-2 rounded-full p-[1px] text-sm font-medium transition-all hover:scale-[1.03] md:text-lg",
isSelected
? "bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400"
: "bg-gradient-to-r from-blue-200 via-purple-200 to-indigo-200",
)}
>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-transparent transition-all md:gap-2 md:px-5 md:py-2",
isSelected
? "bg-purple-50 group-hover:bg-purple-100"
: "bg-[rgb(246,247,248)] group-hover:bg-[rgb(236,237,238)]",
)}
>
<MagnifyingGlass
size={18}
weight="regular"
className={cn(
isSelected
? "text-purple-500"
: "text-purple-300 group-hover:text-purple-400",
)}
/>
<span className="bg-gradient-to-r from-blue-400 via-purple-400 to-indigo-400 bg-clip-text">
{badge}
</span>
</span>
</button>
);
})}
</div>
);
};
}

View File

@@ -1,37 +1,41 @@
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
interface useFilterChipsProps {
onFilterChange?: (selectedFilters: string[]) => void;
multiSelect?: boolean;
}
export const useFilterChips = ({
export function useFilterChips({
onFilterChange,
multiSelect,
}: useFilterChipsProps) => {
}: useFilterChipsProps) {
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const pendingFilters = useRef<string[] | null>(null);
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];
}
useEffect(() => {
if (pendingFilters.current !== null) {
onFilterChange?.(pendingFilters.current);
pendingFilters.current = null;
}
}, [selectedFilters, onFilterChange]);
if (onFilterChange) {
onFilterChange(newFilters);
}
const handleBadgeClick = useCallback(
(badge: string) => {
setSelectedFilters((prev) => {
let next;
if (multiSelect) {
next = prev.includes(badge)
? prev.filter((f) => f !== badge)
: [...prev, badge];
} else {
next = prev.includes(badge) ? [] : [badge];
}
pendingFilters.current = next;
return next;
});
},
[multiSelect],
);
return newFilters;
});
};
return {
selectedFilters,
handleBadgeClick,
};
};
return { selectedFilters, handleBadgeClick };
}

View File

@@ -1,36 +1,46 @@
"use client";
import { cn } from "@/lib/utils";
import { FilterChips } from "../FilterChips/FilterChips";
import { SearchBar } from "../SearchBar/SearchBar";
import { useHeroSection } from "./useHeroSection";
const textStyles =
"font-poppins text-[2.45rem] leading-[2.75rem] md:text-[3rem] font-semibold md:leading-[3.375rem]";
export const HeroSection = () => {
const { onFilterChange, searchTerms } = 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="mb-12 mt-8 flex flex-col items-center justify-center px-4 sm:mt-12 sm:px-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">
<span
className={cn(
textStyles,
"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>
<span className={cn(textStyles, "text-violet-600")}>you</span>
<br />
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
<span
className={cn(
textStyles,
"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>
<span className={cn(textStyles, "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">
<h3 className="mb-6 text-center font-sans text-lg font-normal leading-normal text-neutral-700 dark:text-neutral-300 md:mb-12 md:text-xl">
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 className="mb-4 flex w-full justify-center sm:mb-5">
<SearchBar />
</div>
<div>
<div className="flex justify-center">

View File

@@ -1,8 +1,10 @@
"use client";
import { okData } from "@/app/api/helpers";
import { Separator } from "@/components/__legacy__/ui/separator";
import { Button } from "@/components/atoms/Button/Button";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { okData } from "@/app/api/helpers";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { MarketplaceAgentPageParams } from "../../agent/[creator]/[slug]/page";
import { AgentImages } from "../AgentImages/AgentImage";
import { AgentInfo } from "../AgentInfo/AgentInfo";
@@ -27,15 +29,7 @@ export function MainAgentPage({ params }: Props) {
} = useMainAgentPage({ params });
if (isLoading) {
return (
<div className="mx-auto w-full max-w-[1360px]">
<main className="px-4">
<div className="flex h-[600px] items-center justify-center">
<AgentPageLoading />
</div>
</main>
</div>
);
return <AgentPageLoading />;
}
if (hasError) {
@@ -84,19 +78,33 @@ export function MainAgentPage({ params }: Props) {
return (
<div className="mx-auto w-full max-w-[1360px]">
<main className="mt-5 px-4">
<Breadcrumbs items={breadcrumbs} />
<main className="mt-5 px-4 pb-12">
<div className="mb-4 flex items-center justify-between px-4 md:!-mb-3">
<Button
variant="ghost"
size="small"
as="NextLink"
href="/marketplace"
className="relative -left-2 lg:!-left-4"
leftIcon={<ArrowLeftIcon size={16} />}
>
Go back
</Button>
<div className="hidden md:block">
<Breadcrumbs items={breadcrumbs} />
</div>
</div>
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
<div className="w-full md:w-auto md:shrink-0">
<div className="mt-0 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 lg:mt-8 lg:flex-row lg:gap-12">
<div className="w-full lg:w-2/5">
<AgentInfo
user={user}
agentId={agentData.active_version_id ?? ""}
name={agentData.agent_name ?? ""}
creator={agentData.creator ?? ""}
creatorAvatar={agentData.creator_avatar ?? ""}
shortDescription={agentData.sub_heading ?? ""}
longDescription={agentData.description ?? ""}
rating={agentData.rating ?? 0}
runs={agentData.runs ?? 0}
categories={agentData.categories ?? []}
lastUpdated={
@@ -144,22 +152,20 @@ export function MainAgentPage({ params }: Props) {
})()}
/>
</div>
<Separator className="mb-[25px] mt-[60px]" />
<Separator className="my-6 bg-transparent" />
{otherAgents && (
<AgentsSection
margin="32px"
agents={otherAgents.agents}
sectionTitle={`Other agents by ${agentData.creator ?? ""}`}
/>
)}
<Separator className="mb-[25px] mt-[60px]" />
{similarAgents && (
<Separator className="mb-[25px] mt-[60px] bg-transparent" />
{similarAgents && similarAgents.agents.length > 0 ? (
<AgentsSection
margin="32px"
agents={similarAgents.agents}
sectionTitle="Similar agents"
/>
)}
) : null}
<BecomeACreator />
</main>
</div>

View File

@@ -1,90 +0,0 @@
"use client";
import { Separator } from "@/components/__legacy__/ui/separator";
import { AgentsSection } from "../AgentsSection/AgentsSection";
import { MarketplaceCreatorPageParams } from "../../creator/[creator]/page";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { CreatorInfoCard } from "../CreatorInfoCard/CreatorInfoCard";
import { CreatorLinks } from "../CreatorLinks/CreatorLinks";
import { useMainCreatorPage } from "./useMainCreatorPage";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { CreatorPageLoading } from "../CreatorPageLoading";
interface MainCreatorPageProps {
params: MarketplaceCreatorPageParams;
}
export const MainCreatorPage = ({ params }: MainCreatorPageProps) => {
const { creatorAgents, creator, isLoading, hasError } = useMainCreatorPage({
params,
});
if (isLoading) return <CreatorPageLoading />;
if (hasError) {
return (
<div className="mx-auto w-full max-w-[1360px]">
<div className="flex min-h-[60vh] items-center justify-center">
<ErrorCard
isSuccess={false}
responseError={{ message: "Failed to load creator data" }}
context="creator page"
onRetry={() => window.location.reload()}
className="w-full max-w-md"
/>
</div>
</div>
);
}
if (creator)
return (
<div className="mx-auto w-full max-w-[1360px]">
<main className="mt-5 px-4">
<Breadcrumbs
items={[
{ name: "Marketplace", link: "/marketplace" },
{ name: creator.name, link: "#" },
]}
/>
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
<div className="w-full md:w-auto md:shrink-0">
<CreatorInfoCard
username={creator.name}
handle={creator.username}
avatarSrc={creator.avatar_url}
categories={creator.top_categories}
averageRating={creator.agent_rating}
totalRuns={creator.agent_runs}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-4 sm:gap-6 md:gap-8">
<p className="text-underline-position-from-font text-decoration-skip-none text-left font-poppins text-base font-medium leading-6">
About
</p>
<div
className="text-[48px] font-normal leading-[59px] text-neutral-900 dark:text-zinc-50"
style={{ whiteSpace: "pre-line" }}
data-testid="creator-description"
>
{creator.description}
</div>
<CreatorLinks links={creator.links} />
</div>
</div>
<div className="mt-8 sm:mt-12 md:mt-16 lg:pb-[58px]">
<Separator className="mb-6 bg-gray-200" />
{creatorAgents && (
<AgentsSection
agents={creatorAgents.agents}
hideAvatars={true}
sectionTitle={`Agents by ${creator.name}`}
/>
)}
</div>
</main>
</div>
);
};

View File

@@ -1,13 +1,13 @@
"use client";
import { Separator } from "@/components/__legacy__/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";
import { MainMarketplacePageLoading } from "../MainMarketplacePageLoading";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { AgentsSection } from "../AgentsSection/AgentsSection";
import { BecomeACreator } from "../BecomeACreator/BecomeACreator";
import { FeaturedCreators } from "../FeaturedCreators/FeaturedCreators";
import { FeaturedSection } from "../FeaturedSection/FeaturedSection";
import { HeroSection } from "../HeroSection/HeroSection";
import { MainMarketplacePageLoading } from "../MainMarketplacePageLoading";
import { useMainMarketplacePage } from "./useMainMarketplacePage";
export const MainMarkeplacePage = () => {
const { featuredAgents, topAgents, featuredCreators, isLoading, hasError } =
@@ -38,22 +38,22 @@ export const MainMarkeplacePage = () => {
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-full max-w-[1360px]">
<main className="px-4">
<main className="px-4 pb-12">
<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" />
<Separator className="mb-6 mt-24 bg-transparent" />
{topAgents && (
<AgentsSection sectionTitle="Top Agents" agents={topAgents.agents} />
<AgentsSection sectionTitle="All Agents" agents={topAgents.agents} />
)}
<Separator className="mb-[25px] mt-[60px]" />
<Separator className="mb-[25px] mt-[60px] bg-transparent" />
{featuredCreators && (
<FeaturedCreators featuredCreators={featuredCreators.creators} />
)}
<Separator className="mb-[25px] mt-[60px]" />
<Separator className="mb-[25px] mt-[60px] bg-transparent" />
<BecomeACreator
title="Become a Creator"
description="Join our ever-growing community of hackers and tinkerers"

View File

@@ -1,27 +1,62 @@
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
export const MainSearchResultPageLoading = () => {
export function MainSearchResultPageLoading() {
return (
<div className="w-full">
<div className="mx-auto min-h-screen max-w-[1440px] px-10 lg:min-w-[1440px]">
<div className="mt-8 flex items-center">
{/* Go back button */}
<div className="mb-4 mt-5">
<Skeleton className="h-9 w-24 rounded-full" />
</div>
{/* Header: search term + search bar */}
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex-1">
<Skeleton className="mb-2 h-5 w-32 bg-neutral-200 dark:bg-neutral-700" />
<Skeleton className="h-8 w-64 bg-neutral-200 dark:bg-neutral-700" />
<Skeleton className="mb-2 h-5 w-36" />
<Skeleton className="h-8 w-56" />
</div>
<div className="flex-none">
<Skeleton className="h-[60px] w-[439px] bg-neutral-200 dark:bg-neutral-700" />
<Skeleton className="h-[2.75rem] w-full rounded-full md:w-[439px]" />
</div>
</div>
<div className="mt-[36px] flex items-center justify-between">
<Skeleton className="h-8 w-48 bg-neutral-200 dark:bg-neutral-700" />
<Skeleton className="h-8 w-32 bg-neutral-200 dark:bg-neutral-700" />
{/* Filter chips + sort */}
<div className="mt-6 flex flex-col gap-3 md:mt-9 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2">
<Skeleton className="h-9 w-16 rounded-full" />
<Skeleton className="h-9 w-20 rounded-full" />
<Skeleton className="h-9 w-24 rounded-full" />
</div>
<Skeleton className="h-9 w-32 rounded-lg" />
</div>
<div className="mt-20 flex flex-col items-center justify-center">
<Skeleton className="mb-4 h-6 w-40 bg-neutral-200 dark:bg-neutral-700" />
<Skeleton className="h-6 w-80 bg-neutral-200 dark:bg-neutral-700" />
{/* Agent cards grid */}
<div className="space-y-8 py-8">
<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">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-[25rem] w-full rounded-2xl" />
))}
</div>
{/* Mobile carousel placeholder */}
<div className="flex gap-4 overflow-hidden md:hidden">
<Skeleton className="h-[25rem] min-w-64 rounded-2xl" />
<Skeleton className="h-[25rem] min-w-64 rounded-2xl" />
</div>
{/* Separator */}
<Skeleton className="h-px w-full" />
{/* Creator cards section */}
<div className="space-y-6">
<Skeleton className="h-6 w-24" />
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[16rem] w-full rounded-2xl" />
))}
</div>
</div>
</div>
</div>
</div>
);
};
}

View File

@@ -1,44 +1,40 @@
"use client";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { MagnifyingGlass } from "@phosphor-icons/react";
import { useSearchbar } from "./useSearchBar";
interface SearchBarProps {
placeholder?: string;
backgroundColor?: string;
iconColor?: string;
textColor?: string;
placeholderColor?: string;
width?: string;
height?: string;
}
export const SearchBar = ({
export function 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) => {
width = "w-full lg:w-[56.25rem]",
height = "h-[3.8rem]",
}: 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`}
className={`${width} ${height} flex items-center gap-3 rounded-full border border-zinc-200 bg-white px-4 shadow-none focus-within:border-zinc-400 focus-within:ring-1 focus-within:ring-zinc-400 focus-within:ring-offset-0`}
>
<MagnifyingGlassIcon className={`h-5 w-5 md:h-7 md:w-7 ${iconColor}`} />
<MagnifyingGlass
size={20}
className="text-zinc-400 md:h-6 md:w-6"
aria-hidden="true"
/>
<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`}
className="flex-grow border-none bg-transparent text-base font-normal text-black placeholder:text-base placeholder:font-normal placeholder:text-zinc-400 focus:outline-none md:text-lg md:placeholder:text-lg"
data-testid="store-search-input"
/>
</form>
);
};
}

View File

@@ -1,11 +1,23 @@
import Image from "next/image";
"use client";
import { StarRatingIcons } from "@/components/__legacy__/ui/icons";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { Text } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
import Image from "next/image";
import { useRef, useState } from "react";
import { AddToLibraryButton } from "../AddToLibraryButton/AddToLibraryButton";
interface StoreCardProps {
interface Props {
agentName: string;
agentImage: string;
description: string;
@@ -15,9 +27,12 @@ interface StoreCardProps {
avatarSrc: string;
hideAvatar?: boolean;
creatorName?: string;
creatorSlug?: string;
agentSlug?: string;
agentGraphID?: string;
}
export const StoreCard: React.FC<StoreCardProps> = ({
export function StoreCard({
agentName,
agentImage,
description,
@@ -27,14 +42,27 @@ export const StoreCard: React.FC<StoreCardProps> = ({
avatarSrc,
hideAvatar = false,
creatorName,
}) => {
creatorSlug,
agentSlug,
agentGraphID,
}: Props) {
const [imageError, setImageError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const titleRef = useRef<HTMLSpanElement>(null);
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
function checkTitleOverflow() {
const el = titleRef.current;
if (el) setIsTitleTruncated(el.scrollHeight > el.clientHeight);
}
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"
className="relative flex h-[25rem] w-full max-w-md cursor-pointer flex-col items-start rounded-2xl border border-border/50 bg-background p-4 shadow-md transition-all duration-300 hover:shadow-lg"
onClick={handleClick}
data-testid="store-card"
role="button"
@@ -46,77 +74,110 @@ export const StoreCard: React.FC<StoreCardProps> = ({
}
}}
>
{/* 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"
/>
)}
{!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>
{/* First Section: Image */}
<div className="relative aspect-[2/1.2] w-full overflow-hidden rounded-xl md:aspect-[2.17/1]">
{agentImage && !imageError ? (
<>
{!imageLoaded && (
<Skeleton className="absolute inset-0 rounded-xl" />
)}
<Image
src={agentImage}
alt={`${agentName} preview image`}
fill
className="object-cover"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</>
) : (
<div className="absolute inset-0 rounded-xl bg-violet-50" />
)}
</div>
<div className="mt-3 flex w-full flex-1 flex-col px-4">
<div className="mt-3 flex w-full flex-1 flex-col">
{/* 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>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
ref={titleRef}
onPointerEnter={checkTitleOverflow}
className="line-clamp-2 block min-h-[2.7lh] min-w-0 leading-tight"
>
<Text
variant="h4"
as="span"
className="text-xl leading-tight"
>
{agentName}
</Text>
</span>
</TooltipTrigger>
{isTitleTruncated && (
<TooltipContent>
<p>{agentName}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{!hideAvatar && creatorName && (
<p className="mt-3 truncate font-sans text-xl font-normal text-neutral-600 dark:text-neutral-400">
by {creatorName}
</p>
<div className="mb-4 mt-2 flex items-center gap-2">
<Avatar className="h-6 w-6 shrink-0">
{avatarSrc && (
<AvatarImage
src={avatarSrc}
alt={`${creatorName} creator avatar`}
/>
)}
<AvatarFallback size={32}>
{creatorName.charAt(0)}
</AvatarFallback>
</Avatar>
<Text variant="body-medium" className="truncate">
by {creatorName}
</Text>
</div>
)}
</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">
<Text variant="body" className="line-clamp-3 leading-normal">
{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>
</Text>
</div>
</div>
{/* Stats */}
<Text variant="body" className="absolute bottom-4 left-4 text-zinc-500">
{runs === 0 ? "No runs" : `${runs.toLocaleString()} runs`}
</Text>
{rating >= 1 && (
<div className="absolute bottom-4 right-4 flex items-center gap-2">
<span className="text-lg font-semibold text-neutral-800">
{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>
)}
{creatorSlug && agentSlug && agentGraphID && (
<div className="absolute bottom-2 right-0">
<AddToLibraryButton
creatorSlug={creatorSlug}
agentSlug={agentSlug}
agentName={agentName}
agentGraphID={agentGraphID}
/>
</div>
)}
</div>
);
};
}

View File

@@ -0,0 +1,158 @@
"use client";
import { AgentsSection } from "@/app/(platform)/marketplace/components/AgentsSection/AgentsSection";
import { CreatorLinks } from "@/app/(platform)/marketplace/components/CreatorLinks/CreatorLinks";
import { CreatorPageLoading } from "@/app/(platform)/marketplace/components/CreatorPageLoading";
import { MarketplaceCreatorPageParams } from "@/app/(platform)/marketplace/creator/[creator]/page";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
import { Badge } from "@/components/atoms/Badge/Badge";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { useMainCreatorPage } from "./useMainCreatorPage";
interface Props {
params: MarketplaceCreatorPageParams;
}
export function MainCreatorPage({ params }: Props) {
const { creatorAgents, creator, isLoading, hasError } = useMainCreatorPage({
params,
});
if (isLoading) return <CreatorPageLoading />;
if (hasError) {
return (
<div className="mx-auto w-full max-w-[1360px]">
<div className="flex min-h-[60vh] items-center justify-center">
<ErrorCard
isSuccess={false}
responseError={{ message: "Failed to load creator data" }}
context="creator page"
onRetry={() => window.location.reload()}
className="w-full max-w-md"
/>
</div>
</div>
);
}
if (!creator) return null;
const breadcrumbs = [
{ name: "Marketplace", link: "/marketplace" },
{ name: creator.name, link: "#" },
];
return (
<div className="mx-auto w-full max-w-[1360px]">
<main className="mt-5 px-4 pb-12">
<div className="mb-4 flex items-center justify-between px-4 md:!-mb-3">
<Button
variant="ghost"
size="small"
as="NextLink"
href="/marketplace"
className="relative -left-2 lg:!-left-4"
leftIcon={<ArrowLeftIcon size={16} />}
>
Go back
</Button>
<div className="hidden md:block">
<Breadcrumbs items={breadcrumbs} />
</div>
</div>
<div className="mt-0 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 lg:mt-8 lg:flex-row lg:gap-12">
{/* Creator info - left side */}
<div className="w-full lg:w-2/5">
<div className="w-full px-4 sm:px-6 lg:px-0">
<div className="max-w-[45rem] rounded-2xl bg-gradient-to-r from-blue-200 to-indigo-200 p-[1px]">
<div className="flex flex-col rounded-[calc(1rem-2px)] bg-gray-50 p-4">
{/* Avatar */}
<Avatar className="mb-4 h-20 w-20 sm:h-24 sm:w-24">
{creator.avatar_url && (
<AvatarImage
src={creator.avatar_url}
alt={`${creator.name} avatar`}
/>
)}
<AvatarFallback size={96}>
{creator.name.charAt(0)}
</AvatarFallback>
</Avatar>
{/* Name */}
<Text
variant="h2"
className="mb-1"
data-testid="creator-title"
>
{creator.name}
</Text>
{/* Handle */}
<Text variant="body" className="mb-4 text-neutral-500">
@{creator.username}
</Text>
{/* Description */}
<Text
variant="body"
className="mb-6 leading-relaxed text-neutral-600"
data-testid="creator-description"
>
{creator.description}
</Text>
{/* Categories */}
{creator.top_categories.length > 0 && (
<div className="mb-6">
<Text variant="h5" className="mb-2">
Top categories
</Text>
<div className="flex flex-wrap gap-2">
{creator.top_categories.map((category, index) => (
<Badge
variant="info"
key={index}
className="border border-purple-100 bg-purple-50 text-purple-800"
>
{category}
</Badge>
))}
</div>
</div>
)}
{/* Links */}
<CreatorLinks links={creator.links} />
</div>
</div>
</div>
</div>
{/* Right side - empty for now, keeps layout consistent */}
<div className="hidden lg:block lg:w-3/5" />
</div>
<div className="my-6" />
{creatorAgents && (
<AgentsSection
agents={creatorAgents.agents}
hideAvatars
sectionTitle={`Agents by ${creator.name}`}
/>
)}
</main>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import {
useGetV2ListStoreAgents,
} from "@/app/api/__generated__/endpoints/store/store";
import { StoreAgentsResponse } from "@/app/api/__generated__/models/storeAgentsResponse";
import { MarketplaceCreatorPageParams } from "../../creator/[creator]/page";
import { MarketplaceCreatorPageParams } from "@/app/(platform)/marketplace/creator/[creator]/page";
import { CreatorDetails } from "@/app/api/__generated__/models/creatorDetails";
interface useMainCreatorPageProps {

View File

@@ -7,7 +7,7 @@ import { CreatorDetails } from "@/app/api/__generated__/models/creatorDetails";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { Metadata } from "next";
import { MainCreatorPage } from "../../components/MainCreatorPage/MainCreatorPage";
import { MainCreatorPage } from "./components/MainCreatorPage/MainCreatorPage";
export const dynamic = "force-dynamic";

View File

@@ -1,13 +1,15 @@
import { SearchBar } from "@/components/__legacy__/SearchBar";
import { useMainSearchResultPage } from "./useMainSearchResultPage";
import { GetV2ListStoreAgentsParams } from "@/app/api/__generated__/models/getV2ListStoreAgentsParams";
import { SearchFilterChips } from "@/components/__legacy__/SearchFilterChips";
import { SortDropdown } from "@/components/__legacy__/SortDropdown";
import { AgentsSection } from "../AgentsSection/AgentsSection";
import { Separator } from "@/components/__legacy__/ui/separator";
import { FeaturedCreators } from "../FeaturedCreators/FeaturedCreators";
import { Button } from "@/components/atoms/Button/Button";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { MainMarketplacePageLoading } from "../MainMarketplacePageLoading";
import { GetV2ListStoreAgentsParams } from "@/app/api/__generated__/models/getV2ListStoreAgentsParams";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { AgentsSection } from "../../../components/AgentsSection/AgentsSection";
import { FeaturedCreators } from "../../../components/FeaturedCreators/FeaturedCreators";
import { MainSearchResultPageLoading } from "../../../components/MainSearchResultPageLoading";
import { SearchBar } from "../../../components/SearchBar/SearchBar";
import { useMainSearchResultPage } from "./useMainSearchResultPage";
type MarketplaceSearchSort = GetV2ListStoreAgentsParams["sorted_by"];
@@ -38,7 +40,7 @@ export const MainSearchResultPage = ({
const hasError = isAgentsError || isCreatorsError;
if (isLoading) {
return <MainMarketplacePageLoading />;
return <MainSearchResultPageLoading />;
}
if (hasError) {
@@ -56,39 +58,49 @@ export const MainSearchResultPage = ({
return (
<div className="w-full">
<div className="mx-auto min-h-screen max-w-[1440px] px-10 lg:min-w-[1440px]">
<div className="mt-8 flex items-center">
<div className="mb-4 mt-5">
<Button
variant="secondary"
size="small"
as="NextLink"
href="/marketplace"
leftIcon={<ArrowLeftIcon size={16} />}
>
Go back
</Button>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex-1">
<h2 className="text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
Results for:
Showing results for:
</h2>
<h1 className="font-poppins text-2xl font-semibold leading-[32px] text-neutral-800 dark:text-neutral-100">
{searchTerm}
&quot;{searchTerm}&quot;
</h1>
</div>
<div className="flex-none">
<SearchBar width="w-[439px]" height="h-[60px]" />
<SearchBar width="w-full md:w-[439px]" height="h-[2.75rem]" />
</div>
</div>
{totalCount > 0 ? (
<>
<div className="mt-[36px] flex items-center justify-between">
<div className="mt-6 flex flex-col gap-3 md:mt-[36px] md:flex-row md:items-center md:justify-between">
<SearchFilterChips
totalCount={totalCount}
agentsCount={agentsCount}
creatorsCount={creatorsCount}
onFilterChange={handleFilterChange}
/>
<SortDropdown onSort={handleSortChange} />
<div className="mt-4 md:!mt-0">
<SortDropdown onSort={handleSortChange} />
</div>
</div>
{/* Content section */}
<div className="min-h-[500px] max-w-[1440px] space-y-8 py-8">
{showAgents && agentsCount > 0 && agents && (
<div className="mt-[36px]">
<AgentsSection agents={agents} sectionTitle="Agents" />
</div>
<AgentsSection agents={agents} />
)}
{showAgents && agentsCount > 0 && creatorsCount > 0 && (
<Separator />
)}
@@ -101,7 +113,7 @@ export const MainSearchResultPage = ({
</div>
</>
) : (
<div className="mt-20 flex flex-col items-center justify-center">
<div className="flex min-h-[60vh] flex-col items-center justify-center">
<h3 className="mb-2 text-xl font-medium text-neutral-600 dark:text-neutral-300">
No results found
</h3>

View File

@@ -1,8 +1,8 @@
"use client";
import { use } from "react";
import { MainSearchResultPage } from "../components/MainSearchResultPage/MainSearchResultPage";
import { GetV2ListStoreAgentsParams } from "@/app/api/__generated__/models/getV2ListStoreAgentsParams";
import { use } from "react";
import { MainSearchResultPage } from "./components/MainSearchResultPage/MainSearchResultPage";
type MarketplaceSearchSort = GetV2ListStoreAgentsParams["sorted_by"];
type MarketplaceSearchPageSearchParams = {

View File

@@ -41,10 +41,13 @@ export const SearchFilterChips: React.FC<SearchFilterChipsProps> = ({
<button
key={filter.value}
onClick={() => handleFilterClick(filter.value)}
disabled={filter.value !== "all" && filter.count === 0}
className={`flex items-center gap-2.5 rounded-[34px] px-5 py-2 ${
selected === filter.value
? "bg-neutral-800 text-white dark:bg-neutral-100 dark:text-neutral-900"
: "border border-neutral-600 text-neutral-800 dark:border-neutral-400 dark:text-neutral-200"
filter.value !== "all" && filter.count === 0
? "cursor-not-allowed border border-neutral-200 text-neutral-300 dark:border-neutral-700 dark:text-neutral-600"
: selected === filter.value
? "bg-neutral-800 text-white dark:bg-neutral-100 dark:text-neutral-900"
: "border border-neutral-600 text-neutral-800 dark:border-neutral-400 dark:text-neutral-200"
}`}
>
<span

View File

@@ -1,14 +1,14 @@
// This file has been updated for the Store's "Featured Agent Section". If you want to add Carousel, keep these components in mind: CarouselIndicator, CarouselPrevious, and CarouselNext.
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/__legacy__/ui/button";
import { cn } from "@/lib/utils";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -311,8 +311,8 @@ const CarouselIndicator = React.forwardRef<
onClick={() => scrollTo(index)}
className={cn(
selectedIndex === index
? "h-3 w-[52px] rounded-[39px] bg-neutral-800 transition-all duration-500 dark:bg-neutral-200"
: "h-3 w-3 rounded-full bg-neutral-300 transition-all duration-500 dark:bg-neutral-600",
? "h-2 w-[1.5rem] rounded-[39px] bg-neutral-800 transition-all duration-500 dark:bg-neutral-200"
: "h-2 w-2 rounded-full bg-neutral-300 transition-all duration-500 dark:bg-neutral-600",
"cursor-pointer",
)}
/>
@@ -323,11 +323,11 @@ const CarouselIndicator = React.forwardRef<
CarouselIndicator.displayName = "CarouselIndicator";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselIndicator,
CarouselPrevious,
CarouselItem,
CarouselNext,
CarouselPrevious,
type CarouselApi,
};

View File

@@ -11,6 +11,7 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { AccountMenu } from "./components/AccountMenu/AccountMenu";
import { FeedbackButton } from "./components/FeedbackButton";
import { AgentActivityDropdown } from "./components/AgentActivityDropdown/AgentActivityDropdown";
import { LoginButton } from "./components/LoginButton";
import { MobileNavBar } from "./components/MobileNavbar/MobileNavBar";
@@ -62,7 +63,7 @@ export function Navbar() {
<PreviewBanner branchName={previewBranchName} />
) : null}
<nav
className="inline-flex w-full items-center border border-none bg-[#FAFAFA] p-3 backdrop-blur-[26px]"
className="inline-flex w-full items-center bg-[#FAFAFA]/80 p-3 backdrop-blur-xl"
style={{ height: NAVBAR_HEIGHT_PX }}
>
{/* Left section */}
@@ -95,6 +96,7 @@ export function Navbar() {
{isLoggedIn && !isSmallScreen ? (
<div className="flex flex-1 items-center justify-end gap-4">
<div className="flex items-center gap-4">
<FeedbackButton />
<AgentActivityDropdown />
{profile && <Wallet key={profile.username} />}
<AccountMenu

View File

@@ -0,0 +1,37 @@
"use client";
import { useTallyPopup } from "@/components/molecules/TallyPoup/useTallyPopup";
import { ChatCircleDotsIcon } from "@phosphor-icons/react";
export function FeedbackButton() {
const { state } = useTallyPopup();
if (state.isFormVisible) return null;
return (
<button
type="button"
className="group inline-flex overflow-hidden rounded-full active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50"
data-tally-open="3yx2L0"
data-tally-emoji-text="👋"
data-tally-emoji-animation="wave"
data-sentry-replay-id={state.sentryReplayId || "not-initialized"}
data-sentry-replay-url={state.replayUrl || "not-initialized"}
data-page-url={
state.pageUrl ? state.pageUrl.split("?")[0] : "not-initialized"
}
data-is-authenticated={
state.isAuthenticated === null
? "unknown"
: String(state.isAuthenticated)
}
>
<div className="rounded-full bg-gradient-to-r from-indigo-100 to-indigo-300 to-zinc-400 p-[1px]">
<div className="flex items-center gap-1.5 rounded-full bg-[#FAFAFA]/80 px-3 py-1.5 text-sm font-medium text-neutral-700 backdrop-blur-xl transition-colors duration-150 ease-out group-hover:bg-zinc-100/90">
Give Feedback
<ChatCircleDotsIcon size={16} />
</div>
</div>
</button>
);
}

View File

@@ -1,9 +1,8 @@
"use client";
import { IconLaptop } from "@/components/__legacy__/ui/icons";
import { cn } from "@/lib/utils";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { ListChecksIcon } from "@phosphor-icons/react/dist/ssr";
import { Laptop, ListChecksIcon } from "@phosphor-icons/react/dist/ssr";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Text } from "../../../atoms/Text/Text";
@@ -13,7 +12,8 @@ import {
MarketplaceIcon,
} from "./MenuIcon/MenuIcon";
const iconWidthClass = "h-5 w-5";
const iconBaseClass = "h-4 w-4 shrink-0";
const iconNudgedClass = "relative bottom-[2px] h-4 w-4 shrink-0";
interface Props {
name: string;
@@ -34,15 +34,15 @@ export function NavbarLink({ name, href }: Props) {
<Link href={href} data-testid={`navbar-link-${name.toLowerCase()}`}>
<div
className={cn(
"flex items-center justify-start gap-1 p-1 md:p-2",
"flex items-center justify-start gap-2.5 p-1 md:p-2",
isActive &&
"rounded-small bg-neutral-800 py-1 pl-1 pr-1.5 transition-all duration-300 dark:bg-neutral-200 md:py-2 md:pl-2 md:pr-3",
"rounded-small bg-neutral-800 py-1 pl-1 pr-1.5 transition-all duration-300 md:py-[0.7rem] md:pl-2 md:pr-3",
)}
>
{href === "/marketplace" && (
<div
className={cn(
iconWidthClass,
iconNudgedClass,
isActive && "text-white dark:text-black",
)}
>
@@ -52,7 +52,7 @@ export function NavbarLink({ name, href }: Props) {
{href === "/build" && (
<div
className={cn(
iconWidthClass,
iconNudgedClass,
isActive && "text-white dark:text-black",
)}
>
@@ -60,9 +60,9 @@ export function NavbarLink({ name, href }: Props) {
</div>
)}
{href === "/monitor" && (
<IconLaptop
<Laptop
className={cn(
iconWidthClass,
iconBaseClass,
isActive && "text-white dark:text-black",
)}
/>
@@ -70,7 +70,7 @@ export function NavbarLink({ name, href }: Props) {
{href === "/copilot" && (
<div
className={cn(
iconWidthClass,
iconNudgedClass,
isActive && "text-white dark:text-black",
)}
>
@@ -81,14 +81,14 @@ export function NavbarLink({ name, href }: Props) {
(isChatEnabled ? (
<ListChecksIcon
className={cn(
iconWidthClass,
"h-5 w-5 shrink-0",
isActive && "text-white dark:text-black",
)}
/>
) : (
<div
className={cn(
iconWidthClass,
iconNudgedClass,
isActive && "text-white dark:text-black",
)}
>
@@ -98,7 +98,7 @@ export function NavbarLink({ name, href }: Props) {
<Text
variant="h5"
className={cn(
"hidden !font-poppins lg:block",
"hidden !font-poppins leading-none lg:block",
isActive ? "!text-white" : "!text-black",
)}
>

View File

@@ -19,12 +19,12 @@ export function Breadcrumbs({ items }: Props) {
{item.link ? (
<Link
href={item.link}
className="text-[0.75rem] font-[400] text-zinc-600 transition-colors hover:text-zinc-900 hover:no-underline"
className="text-sm font-[400] text-zinc-600 transition-colors hover:text-zinc-900 hover:no-underline"
>
{item.name}
</Link>
) : (
<span className="text-[0.75rem] font-[400] text-zinc-900">
<span className="text-sm font-[400] text-zinc-900">
{item.name}
</span>
)}

View File

@@ -1,39 +1,11 @@
"use client";
import React from "react";
import { useTallyPopup } from "./useTallyPopup";
import { Button } from "@/components/atoms/Button/Button";
export function TallyPopupSimple() {
const { state } = useTallyPopup();
if (state.isFormVisible) {
return null;
}
return (
<div className="fixed bottom-1 right-0 z-20 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
<Button
variant="primary"
data-tally-open="3yx2L0"
data-tally-emoji-text="👋"
data-tally-emoji-animation="wave"
data-sentry-replay-id={state.sentryReplayId || "not-initialized"}
data-sentry-replay-url={state.replayUrl || "not-initialized"}
data-user-agent={state.userAgent}
data-page-url={state.pageUrl}
data-is-authenticated={
state.isAuthenticated === null
? "unknown"
: String(state.isAuthenticated)
}
data-email={state.userEmail || "not-authenticated"}
className="mb-0 h-14 rounded-2xl bg-[rgba(65,65,64,1)] text-center font-sans text-lg font-medium leading-6"
>
Give Feedback
</Button>
</div>
);
// Load the Tally script and set up event listeners
useTallyPopup();
return null;
}
export default TallyPopupSimple;

View File

@@ -80,7 +80,7 @@ export class MarketplacePage extends BasePage {
async getTopAgentsSection(page: Page) {
const { getText } = getSelectors(page);
return getText("Top Agents");
return getText("All Agents");
}
async getFeaturedCreatorsSection(page: Page) {