Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, test, vi } from "vitest";

const mockGetSpecificAgent = vi.hoisted(() => vi.fn());

vi.mock("@/app/api/__generated__/endpoints/store/store", () => ({
getV2GetSpecificAgent: mockGetSpecificAgent,
prefetchGetV2GetSpecificAgentQuery: vi.fn(),
prefetchGetV2ListStoreAgentsQuery: vi.fn(),
}));

vi.mock("@/app/api/__generated__/endpoints/library/library", () => ({
prefetchGetV2GetAgentByStoreIdQuery: vi.fn(),
}));

vi.mock("@/lib/auth/server/getServerUser", () => ({
getServerUser: vi.fn(),
}));

vi.mock("../../../../components/MainAgentPage/MainAgentPage", () => ({
MainAgentPage: () => null,
}));

import { generateMetadata } from "../page";

const params = { creator: "pwuts", slug: "an-agent" };

describe("generateMetadata", () => {
beforeEach(() => {
mockGetSpecificAgent.mockReset();
});

test("previews the agent's own name, description and image", async () => {
mockGetSpecificAgent.mockResolvedValue({
data: {
agent_name: "An Agent",
description: "What the agent does",
agent_image: ["https://cdn.example.com/agent.png"],
},
});

const metadata = await generateMetadata({
params: Promise.resolve(params),
});

expect(metadata.title).toBe("An Agent - AutoGPT Marketplace");
expect(metadata.openGraph?.title).toBe("An Agent - AutoGPT Marketplace");
expect(metadata.openGraph?.description).toBe("What the agent does");
expect(metadata.openGraph?.images).toEqual([
"https://cdn.example.com/agent.png",
]);
expect(metadata.twitter).toMatchObject({ card: "summary_large_image" });
});

test("uses only the first image when the listing carries several", async () => {
mockGetSpecificAgent.mockResolvedValue({
data: {
agent_name: "An Agent",
description: "What the agent does",
agent_image: ["https://cdn.example.com/1.png", "https://x/2.png"],
},
});

const metadata = await generateMetadata({
params: Promise.resolve(params),
});

expect(metadata.openGraph?.images).toEqual([
"https://cdn.example.com/1.png",
]);
});

test("falls back to a text card when the listing has no image", async () => {
mockGetSpecificAgent.mockResolvedValue({
data: {
agent_name: "An Agent",
description: "What the agent does",
agent_image: [],
},
});

const metadata = await generateMetadata({
params: Promise.resolve(params),
});

expect(metadata.openGraph).not.toHaveProperty("images");
expect(metadata.twitter).toMatchObject({ card: "summary" });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { getServerUser } from "@/lib/auth/server/getServerUser";
import { buildPageMetadata } from "@/lib/metadata";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { Metadata } from "next";
import { MainAgentPage } from "../../../components/MainAgentPage/MainAgentPage";
Expand All @@ -21,14 +22,16 @@ export async function generateMetadata({
params: Promise<MarketplaceAgentPageParams>;
}): Promise<Metadata> {
const params = await _params;
const { data: creator_agent } = await getV2GetSpecificAgent(
params.creator,
params.slug,
);
return {
title: `${(creator_agent as StoreAgentDetails).agent_name} - AutoGPT Marketplace`,
description: (creator_agent as StoreAgentDetails).description,
};
const { data } = await getV2GetSpecificAgent(params.creator, params.slug);
const agent = data as StoreAgentDetails;

return buildPageMetadata({
title: `${agent.agent_name} - AutoGPT Marketplace`,
description: agent.description,
path: `/marketplace/agent/${params.creator}/${params.slug}`,
images: agent.agent_image?.slice(0, 1),
type: "article",
});
}

export default async function MarketplaceAgentPage({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ describe("generateMetadata", () => {

test("returns creator metadata on success", async () => {
mockGetCreatorDetails.mockResolvedValue({
data: { name: "Creator One", description: "Creator profile" },
data: {
name: "Creator One",
description: "Creator profile",
avatar_url: "https://cdn.example.com/avatar.png",
},
});

const metadata = await generateMetadata({
Expand All @@ -42,6 +46,32 @@ describe("generateMetadata", () => {
expect(mockGetCreatorDetails).toHaveBeenCalledWith("creator-one");
expect(metadata.title).toBe("Creator One - AutoGPT Store");
expect(metadata.description).toBe("Creator profile");
expect(metadata.openGraph).toMatchObject({
title: "Creator One - AutoGPT Store",
description: "Creator profile",
type: "profile",
});
expect(metadata.openGraph?.images).toEqual([
"https://cdn.example.com/avatar.png",
]);
expect(metadata.twitter).toMatchObject({ card: "summary_large_image" });
});

test("falls back to a text card when the creator has no avatar", async () => {
mockGetCreatorDetails.mockResolvedValue({
data: {
name: "Creator One",
description: "Creator profile",
avatar_url: null,
},
});

const metadata = await generateMetadata({
params: Promise.resolve({ creator: "creator-one" }),
});

expect(metadata.openGraph).not.toHaveProperty("images");
expect(metadata.twitter).toMatchObject({ card: "summary" });
});

test("renders the 404 page when the creator does not exist", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { CreatorDetails } from "@/app/api/__generated__/models/creatorDetails";
import { ApiError } from "@/lib/autogpt-server-api/helpers";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { buildPageMetadata } from "@/lib/metadata";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { Metadata } from "next";
import { notFound } from "next/navigation";
Expand Down Expand Up @@ -35,10 +36,13 @@ export async function generateMetadata({
throw error;
}

return {
return buildPageMetadata({
title: `${creator.name} - AutoGPT Store`,
description: creator.description,
};
path: `/marketplace/creator/${params.creator}`,
images: [creator.avatar_url],
type: "profile",
});
}

export default async function Page({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { getExpertAccent } from "@/app/(platform)/marketplace/components/ExpertsSection/helpers";
import { Icon } from "@/components/atoms/Icon/Icon";
import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { VoicePicker } from "@/components/organisms/VoicePicker/VoicePicker";
import { ArrowLeft02Icon } from "@hugeicons/core-free-icons";
import Link from "next/link";
import { notFound, useParams } from "next/navigation";
import { ReactNode } from "react";
import { ExpertAbout } from "./ExpertAbout";
import { ExpertComingSoonLabel } from "./ExpertComingSoonLabel";
import { ExpertHireActions } from "./ExpertHireActions";
import { ExpertPageHeader } from "./ExpertPageHeader";
import { ExpertSkills } from "./ExpertSkills";
import { ExpertWorkflowList } from "./ExpertWorkflowList";
import { useExpertPage } from "../useExpertPage";
import { useHireFlow } from "../useHireFlow";

const MAIN_CLASS =
"mx-auto flex w-full max-w-[760px] flex-col px-6 pb-24 pt-8 md:px-8";

function BackToMarketplaceLink() {
return (
<Link
href="/marketplace#experts"
className="mb-6 inline-flex w-fit items-center gap-1.5 text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
>
<Icon icon={ArrowLeft02Icon} size={14} />
Back to marketplace
</Link>
);
}

export function ExpertPage() {
const { expertId } = useParams<{ expertId: string }>();
const {
expert,
hiredExpert,
isLoggedIn,
isHiringOpen,
isActionReady,
isLoading,
isError,
refetch,
} = useExpertPage({ expertId });
const {
hire,
isHiring,
hireResult,
pickVoice,
skipVoice,
dismissVoicePick,
isSavingVoice,
} = useHireFlow(expert);

if (isLoading) {
return (
<main className={MAIN_CLASS}>
<Skeleton className="mb-6 h-4 w-32" />
<div className="flex items-center gap-5">
<Skeleton className="h-18 w-18 rounded-full" />
<div className="flex flex-1 flex-col gap-2.5">
<Skeleton className="h-7 w-36" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
<Skeleton className="h-9 w-28 rounded-full" />
</div>
<Skeleton className="mt-5 h-5 w-3/4" />
<div className="mt-8 flex flex-col gap-3 border-t border-zinc-200 pt-8">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-3/4" />
</div>
</main>
);
}

if (isError) {
return (
<main className={MAIN_CLASS}>
<BackToMarketplaceLink />
<ErrorCard
context="this expert"
hint="We could not load this expert."
onRetry={() => refetch()}
/>
</main>
);
}

if (!expert) {
notFound();
}

const accent = getExpertAccent(expert.role);

let actions: ReactNode = <Skeleton className="h-9 w-28 rounded-full" />;
if (isActionReady) {
actions = isHiringOpen ? (
<ExpertHireActions
expert={expert}
hiredExpert={hiredExpert}
isLoggedIn={isLoggedIn}
isHiring={isHiring}
onHire={hire}
/>
) : (
<ExpertComingSoonLabel />
);
}

return (
<main className={MAIN_CLASS}>
<BackToMarketplaceLink />
<ExpertPageHeader expert={expert} accent={accent} actions={actions} />
<div className="mt-8 flex flex-col gap-10 border-t border-zinc-200 pt-8">
<ExpertAbout key={expert.id} text={expert.bio || expert.identity} />
<ExpertSkills skills={expert.skills ?? []} accent={accent} />
<ExpertWorkflowList
name={expert.name}
workflows={expert.workflows}
accent={accent}
/>
</div>

{/* The voice pick follows a successful hire when the persona ships
writing samples; dismissing it still celebrates the hire. */}
<Dialog
styling={{ width: "640px" }}
controlled={{
isOpen: hireResult !== null,
set: (open) => {
if (!open) dismissVoicePick();
},
}}
>
<Dialog.Content>
{hireResult ? (
<VoicePicker
name={hireResult.expert.name}
samples={expert.voice_samples ?? []}
onPick={pickVoice}
onSkip={skipVoice}
isSubmitting={isSavingVoice}
/>
) : null}
</Dialog.Content>
</Dialog>
</main>
);
}
Loading
Loading