feat(platform): Onboarding flow backend (#9511)

This PR adds backend to make Onboarding UI added in
https://github.com/Significant-Gravitas/AutoGPT/pull/9485 functional and
adds missing confetti screen at the end of Onboarding.

*Make sure to have at least any 2 agents in store when testing locally.*
Otherwise the onboarding will finish without showing agents.
Visit `/onboarding/reset` to reset onboarding state, otherwise it'll
always redirect to `/library` once finished.

### Changes 🏗️

- Onboarding opens automatically on sign up and login (if unfinished)
for all users
- Update db schema to add `UserOnboarding` and add migration
- Add GET and PATCH `/onboarding` endpoints and logic to retrieve and
update data Onboarding for a user
- Update `POST /library/agents` endpoint
(`addMarketplaceAgentToLibrary`), so it includes input and output nodes;
these are needed to know input schema for the Onboarding
- Use new endpoints during onboarding to fetch and update data
- Use agents from the marketplace and their input schema for the
onboarding
- Add algorithm to choose store agents based on user answers and related
endpoint `GET /onboarding/agents`
- Redirect outside onboarding if finished and resume on proper page
- Add `canvas-confetti` and `@types/canvas-confetti` frontend packages
- Add and show congrats confetti screen when user runs and agent and
before opening library
- Minor design updates and onboarding fixes

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Redirect to `/onboarding/*` on sign up and sign in (if onboarding
unfinished)
  - [x] Onboarding works and can be finished
  - [x] Agent runs on finish
- [x] Onboarding state is saved and logging out or refreshing page
restores correct state (user choices)
- [x] When onboarding finished, trying to go into `/onboarding`
redirects to `/library`

---------

Co-authored-by: Nicholas Tindle <nicholas.tindle@agpt.co>
Co-authored-by: Reinier van der Leer <pwuts@agpt.co>
This commit is contained in:
Krzysztof Czerwinski
2025-03-06 13:19:33 +01:00
committed by GitHub
parent e5eadeace4
commit 3cf198eea1
32 changed files with 883 additions and 235 deletions

View File

@@ -0,0 +1,247 @@
import re
from typing import Any, Optional
import prisma
import pydantic
from prisma import Json
from prisma.models import (
AgentGraph,
AgentGraphExecution,
StoreListingVersion,
UserOnboarding,
)
from prisma.types import UserOnboardingUpdateInput
from backend.server.v2.library.db import set_is_deleted_for_library_agent
from backend.server.v2.store.db import get_store_agent_details
from backend.server.v2.store.model import StoreAgentDetails
# Mapping from user reason id to categories to search for when choosing agent to show
REASON_MAPPING: dict[str, list[str]] = {
"content_marketing": ["writing", "marketing", "creative"],
"business_workflow_automation": ["business", "productivity"],
"data_research": ["data", "research"],
"ai_innovation": ["development", "research"],
"personal_productivity": ["personal", "productivity"],
}
class UserOnboardingUpdate(pydantic.BaseModel):
step: int
usageReason: Optional[str] = None
integrations: list[str] = pydantic.Field(default_factory=list)
otherIntegrations: Optional[str] = None
selectedAgentCreator: Optional[str] = None
selectedAgentSlug: Optional[str] = None
agentInput: Optional[dict[str, Any]] = None
isCompleted: bool = False
async def get_user_onboarding(user_id: str):
return await UserOnboarding.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id}, # type: ignore
"update": {},
},
)
async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
# Get the user onboarding data
user_onboarding = await get_user_onboarding(user_id)
update: UserOnboardingUpdateInput = {
"step": data.step,
"isCompleted": data.isCompleted,
}
if data.usageReason:
update["usageReason"] = data.usageReason
if data.integrations:
update["integrations"] = data.integrations
if data.otherIntegrations:
update["otherIntegrations"] = data.otherIntegrations
if data.selectedAgentSlug and data.selectedAgentCreator:
update["selectedAgentSlug"] = data.selectedAgentSlug
update["selectedAgentCreator"] = data.selectedAgentCreator
# Check if slug changes
if (
user_onboarding.selectedAgentCreator
and user_onboarding.selectedAgentSlug
and user_onboarding.selectedAgentSlug != data.selectedAgentSlug
):
store_agent = await get_store_agent_details(
user_onboarding.selectedAgentCreator, user_onboarding.selectedAgentSlug
)
store_listing = await StoreListingVersion.prisma().find_unique_or_raise(
where={"id": store_agent.store_listing_version_id}
)
agent_graph = await AgentGraph.prisma().find_first(
where={"id": store_listing.agentId, "version": store_listing.version}
)
execution_count = await AgentGraphExecution.prisma().count(
where={
"userId": user_id,
"agentGraphId": store_listing.agentId,
"agentGraphVersion": store_listing.version,
}
)
# If there was no execution and graph doesn't belong to the user,
# mark the agent as deleted
if execution_count == 0 and agent_graph and agent_graph.userId != user_id:
await set_is_deleted_for_library_agent(
user_id, store_listing.agentId, store_listing.agentVersion, True
)
if data.agentInput:
update["agentInput"] = Json(data.agentInput)
return await UserOnboarding.prisma().upsert(
where={"userId": user_id},
data={
"create": {"userId": user_id, **update}, # type: ignore
"update": update,
},
)
def clean_and_split(text: str) -> list[str]:
"""
Removes all special characters from a string, truncates it to 100 characters,
and splits it by whitespace and commas.
Args:
text (str): The input string.
Returns:
list[str]: A list of cleaned words.
"""
# Remove all special characters (keep only alphanumeric and whitespace)
cleaned_text = re.sub(r"[^a-zA-Z0-9\s,]", "", text.strip()[:100])
# Split by whitespace and commas
words = re.split(r"[\s,]+", cleaned_text)
# Remove empty strings from the list
words = [word.lower() for word in words if word]
return words
def calculate_points(
agent, categories: list[str], custom: list[str], integrations: list[str]
) -> int:
"""
Calculates the total points for an agent based on the specified criteria.
Args:
agent: The agent object.
categories (list[str]): List of categories to match.
words (list[str]): List of words to match in the description.
Returns:
int: Total points for the agent.
"""
points = 0
# 1. Category Matches
matched_categories = sum(
1 for category in categories if category in agent.categories
)
points += matched_categories * 100
# 2. Description Word Matches
description_words = agent.description.split() # Split description into words
matched_words = sum(1 for word in custom if word in description_words)
points += matched_words * 100
matched_words = sum(1 for word in integrations if word in description_words)
points += matched_words * 50
# 3. Featured Bonus
if agent.featured:
points += 50
# 4. Rating Bonus
points += agent.rating * 10
# 5. Runs Bonus
runs_points = min(agent.runs / 1000 * 100, 100) # Cap at 100 points
points += runs_points
return int(points)
async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
user_onboarding = await get_user_onboarding(user_id)
categories = REASON_MAPPING.get(user_onboarding.usageReason or "", [])
where_clause: dict[str, Any] = {}
custom = clean_and_split((user_onboarding.usageReason or "").lower())
if categories:
where_clause["OR"] = [
{"categories": {"has": category}} for category in categories
]
else:
where_clause["OR"] = [
{"description": {"contains": word, "mode": "insensitive"}}
for word in custom
]
where_clause["OR"] += [
{"description": {"contains": word, "mode": "insensitive"}}
for word in user_onboarding.integrations
]
agents = await prisma.models.StoreAgent.prisma().find_many(
where=prisma.types.StoreAgentWhereInput(**where_clause),
order=[
{"featured": "desc"},
{"runs": "desc"},
{"rating": "desc"},
],
)
if len(agents) < 2:
agents += await prisma.models.StoreAgent.prisma().find_many(
where={
"listing_id": {"not_in": [agent.listing_id for agent in agents]},
},
order=[
{"featured": "desc"},
{"runs": "desc"},
{"rating": "desc"},
],
take=2 - len(agents),
)
# Calculate points for the first 30 agents and choose the top 2
agent_points = []
for agent in agents[:50]:
points = calculate_points(
agent, categories, custom, user_onboarding.integrations
)
agent_points.append((agent, points))
agent_points.sort(key=lambda x: x[1], reverse=True)
recommended_agents = [agent for agent, _ in agent_points[:2]]
return [
StoreAgentDetails(
store_listing_version_id=agent.storeListingVersionId,
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
agent_image=agent.agent_image,
creator=agent.creator_username,
creator_avatar=agent.creator_avatar,
sub_heading=agent.sub_heading,
description=agent.description,
categories=agent.categories,
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
last_updated=agent.updated_at,
)
for agent in recommended_agents
]

View File

@@ -43,6 +43,12 @@ from backend.data.credit import (
set_auto_top_up,
)
from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO
from backend.data.onboarding import (
UserOnboardingUpdate,
get_recommended_agents,
get_user_onboarding,
update_user_onboarding,
)
from backend.data.user import (
get_or_create_user,
get_user_notification_preference,
@@ -152,6 +158,38 @@ async def update_preferences(
return output
########################################################
##################### Onboarding #######################
########################################################
@v1_router.get(
"/onboarding", tags=["onboarding"], dependencies=[Depends(auth_middleware)]
)
async def get_onboarding(user_id: Annotated[str, Depends(get_user_id)]):
return await get_user_onboarding(user_id)
@v1_router.patch(
"/onboarding", tags=["onboarding"], dependencies=[Depends(auth_middleware)]
)
async def update_onboarding(
user_id: Annotated[str, Depends(get_user_id)], data: UserOnboardingUpdate
):
return await update_user_onboarding(user_id, data)
@v1_router.get(
"/onboarding/agents",
tags=["onboarding"],
dependencies=[Depends(auth_middleware)],
)
async def get_onboarding_agents(
user_id: Annotated[str, Depends(get_user_id)],
):
return await get_recommended_agents(user_id)
########################################################
##################### Blocks ###########################
########################################################

View File

@@ -14,6 +14,7 @@ import backend.server.v2.library.model as library_model
import backend.server.v2.store.exceptions as store_exceptions
import backend.server.v2.store.image_gen as store_image_gen
import backend.server.v2.store.media as store_media
from backend.data.db import locked_transaction
from backend.data.includes import library_agent_include
from backend.util.settings import Config
@@ -362,56 +363,70 @@ async def add_store_agent_to_library(
)
try:
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"Agent": True}
)
)
if not store_listing_version or not store_listing_version.Agent:
logger.warning(
f"Store listing version not found: {store_listing_version_id}"
)
raise store_exceptions.AgentNotFoundError(
f"Store listing version {store_listing_version_id} not found or invalid"
async with locked_transaction(f"user_trx_{user_id}"):
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"Agent": True}
)
)
if not store_listing_version or not store_listing_version.Agent:
logger.warning(
f"Store listing version not found: {store_listing_version_id}"
)
raise store_exceptions.AgentNotFoundError(
f"Store listing version {store_listing_version_id} not found or invalid"
)
graph = store_listing_version.Agent
if graph.userId == user_id:
logger.warning(
f"User #{user_id} attempted to add their own agent to their library"
)
raise store_exceptions.DatabaseError("Cannot add own agent to library")
graph = store_listing_version.Agent
if graph.userId == user_id:
logger.warning(
f"User #{user_id} attempted to add their own agent to their library"
)
raise store_exceptions.DatabaseError("Cannot add own agent to library")
# Check if user already has this agent
existing_library_agent = await prisma.models.LibraryAgent.prisma().find_first(
where={
"userId": user_id,
"agentId": graph.id,
"agentVersion": graph.version,
},
include=library_agent_include(user_id),
)
if existing_library_agent:
logger.debug(
f"User #{user_id} already has graph #{graph.id} in their library"
)
return library_model.LibraryAgent.from_db(existing_library_agent)
# Create LibraryAgent entry
added_agent = await prisma.models.LibraryAgent.prisma().create(
data={
"userId": user_id,
"Agent": {
"connect": {
"graphVersionId": {"id": graph.id, "version": graph.version}
# Check if user already has this agent
existing_library_agent = (
await prisma.models.LibraryAgent.prisma().find_first(
where={
"userId": user_id,
"agentId": graph.id,
"agentVersion": graph.version,
},
include=library_agent_include(user_id),
)
)
if existing_library_agent:
if existing_library_agent.isDeleted:
# Even if agent exists it needs to be marked as not deleted
await set_is_deleted_for_library_agent(
user_id, graph.id, graph.version, False
)
else:
logger.debug(
f"User #{user_id} already has graph #{graph.id} "
"in their library"
)
return library_model.LibraryAgent.from_db(existing_library_agent)
# Create LibraryAgent entry
added_agent = await prisma.models.LibraryAgent.prisma().create(
data={
"userId": user_id,
"Agent": {
"connect": {
"graphVersionId": {"id": graph.id, "version": graph.version}
},
},
"isCreatedByUser": False,
},
"isCreatedByUser": False,
},
include=library_agent_include(user_id),
)
logger.debug(f"Added agent #{graph.id} to library for user #{user_id}")
return library_model.LibraryAgent.from_db(added_agent)
include=library_agent_include(user_id),
)
logger.debug(
f"Added graph #{graph.id} "
f"for store listing #{store_listing_version.id} "
f"to library for user #{user_id}"
)
return library_model.LibraryAgent.from_db(added_agent)
except store_exceptions.AgentNotFoundError:
# Reraise for external handling.
@@ -421,6 +436,45 @@ async def add_store_agent_to_library(
raise store_exceptions.DatabaseError("Failed to add agent to library") from e
async def set_is_deleted_for_library_agent(
user_id: str, agent_id: str, agent_version: int, is_deleted: bool
) -> None:
"""
Changes the isDeleted flag for a library agent.
Args:
user_id: The user's library from which the agent is being removed.
agent_id: The ID of the agent to remove.
agent_version: The version of the agent to remove.
is_deleted: Whether the agent is being marked as deleted.
Raises:
DatabaseError: If there's an issue updating the Library
"""
logger.debug(
f"Setting isDeleted={is_deleted} for agent {agent_id} v{agent_version} "
f"in library for user {user_id}"
)
try:
logger.warning(
f"Setting isDeleted={is_deleted} for agent {agent_id} v{agent_version} in library for user {user_id}"
)
count = await prisma.models.LibraryAgent.prisma().update_many(
where={
"userId": user_id,
"agentId": agent_id,
"agentVersion": agent_version,
},
data={"isDeleted": is_deleted},
)
logger.warning(f"Updated {count} isDeleted library agents")
except prisma.errors.PrismaError as e:
logger.error(f"Database error setting agent isDeleted: {e}")
raise store_exceptions.DatabaseError(
"Failed to set agent isDeleted in library"
) from e
##############################################
########### Presets DB Functions #############
##############################################

View File

@@ -106,7 +106,7 @@ async def add_marketplace_agent_to_library(
user_id: ID of the authenticated user.
Returns:
201 (Created) on success.
library_model.LibraryAgent: Agent added to the library
Raises:
HTTPException(404): If the listing version is not found.

View File

@@ -0,0 +1,26 @@
-- Create UserOnboarding table
CREATE TABLE "UserOnboarding" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"step" INTEGER NOT NULL DEFAULT 0,
"usageReason" TEXT,
"integrations" TEXT[] DEFAULT ARRAY[]::TEXT[],
"otherIntegrations" TEXT,
"selectedAgentCreator" TEXT,
"selectedAgentSlug" TEXT,
"agentInput" JSONB,
"isCompleted" BOOLEAN NOT NULL DEFAULT false,
"userId" TEXT NOT NULL,
CONSTRAINT "UserOnboarding_pkey" PRIMARY KEY ("id")
);
-- Create unique constraint on userId
ALTER TABLE "UserOnboarding" ADD CONSTRAINT "UserOnboarding_userId_key" UNIQUE ("userId");
-- Create index on userId
CREATE INDEX "UserOnboarding_userId_idx" ON "UserOnboarding"("userId");
-- Add foreign key constraint
ALTER TABLE "UserOnboarding" ADD CONSTRAINT "UserOnboarding_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -46,6 +46,7 @@ model User {
LibraryAgent LibraryAgent[]
Profile Profile[]
UserOnboarding UserOnboarding?
StoreListing StoreListing[]
StoreListingReview StoreListingReview[]
StoreListingSubmission StoreListingSubmission[]
@@ -57,6 +58,25 @@ model User {
@@index([email])
}
model UserOnboarding {
id String @id @default(uuid())
createdAt DateTime @default(now())
step Int @default(0)
usageReason String?
integrations String[] @default([])
otherIntegrations String?
selectedAgentCreator String?
selectedAgentSlug String?
agentInput Json?
isCompleted Boolean @default(false)
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// This model describes the Agent Graph/Flow (Multi Agent System).
model AgentGraph {
id String @default(uuid())

View File

@@ -51,6 +51,7 @@
"@xyflow/react": "^12.4.2",
"ajv": "^8.17.1",
"boring-avatars": "^1.11.2",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
@@ -94,7 +95,8 @@
"@storybook/nextjs": "^8.5.3",
"@storybook/react": "^8.3.5",
"@storybook/test": "^8.3.5",
"@storybook/test-runner": "^0.20.1",
"@storybook/test-runner": "^0.21.0",
"@types/canvas-confetti": "^1.9.0",
"@types/lodash": "^4.17.13",
"@types/negotiator": "^0.6.3",
"@types/node": "^22.13.0",

View File

@@ -49,11 +49,14 @@ export async function login(values: z.infer<typeof loginFormSchema>) {
}
await api.createUser();
if (!(await api.getUserOnboarding()).isCompleted) {
revalidatePath("/onboarding", "layout");
redirect("/onboarding");
}
if (data.session) {
await supabase.auth.setSession(data.session);
}
console.log("Logged in");
revalidatePath("/", "layout");
redirect("/");
});
@@ -86,7 +89,10 @@ export async function providerLogin(provider: LoginProvider) {
}
await api.createUser();
console.log("Logged in");
if (!(await api.getUserOnboarding()).isCompleted) {
revalidatePath("/onboarding", "layout");
redirect("/onboarding");
}
},
);
}

View File

@@ -5,7 +5,8 @@ import OnboardingButton from "@/components/onboarding/OnboardingButton";
import Image from "next/image";
export default function Page() {
const {} = useOnboarding(1);
// Just set step to 1
useOnboarding(1);
return (
<>

View File

@@ -56,13 +56,13 @@ export default function Page() {
</OnboardingHeader>
<OnboardingList
elements={reasons}
selectedId={state.usageReason}
selectedId={state?.usageReason}
onSelect={(usageReason) => setState({ usageReason })}
/>
<OnboardingFooter>
<OnboardingButton
href="/onboarding/3-services"
disabled={isEmptyOrWhitespace(state.usageReason)}
disabled={isEmptyOrWhitespace(state?.usageReason)}
>
Next
</OnboardingButton>

View File

@@ -118,13 +118,17 @@ export default function Page() {
const switchIntegration = useCallback(
(name: string) => {
if (!state) {
return;
}
const integrations = state.integrations.includes(name)
? state.integrations.filter((i) => i !== name)
: [...state.integrations, name];
setState({ integrations });
},
[state.integrations, setState],
[state, setState],
);
return (
@@ -144,7 +148,7 @@ export default function Page() {
</OnboardingText>
<OnboardingGrid
elements={services}
selected={state.integrations}
selected={state?.integrations}
onSelect={switchIntegration}
/>
<OnboardingText className="mt-12" variant="subheader">
@@ -156,7 +160,7 @@ export default function Page() {
<OnboardingInput
className="mb-4"
placeholder="Others (please specify)"
value={state.otherIntegrations || ""}
value={state?.otherIntegrations || ""}
onChange={(otherIntegrations) => setState({ otherIntegrations })}
/>
</div>
@@ -166,7 +170,7 @@ export default function Page() {
className="mb-2"
href="/onboarding/4-agent"
disabled={
state.integrations.length === 0 &&
state?.integrations.length === 0 &&
isEmptyOrWhitespace(state.otherIntegrations)
}
>

View File

@@ -8,29 +8,10 @@ import {
} from "@/components/onboarding/OnboardingStep";
import { OnboardingText } from "@/components/onboarding/OnboardingText";
import OnboardingAgentCard from "@/components/onboarding/OnboardingAgentCard";
const agents = [
{
id: "0",
image: "/placeholder.png",
name: "Viral News Video Creator: AI TikTok Shorts",
description:
"Description of what the agent does. Written by the creator. Example of text that's longer than two lines. Lorem ipsum set dolor amet bacon ipsum dolor amet kielbasa chicken ullamco frankfurter cupim nisi. Esse jerky turkey pancetta lorem officia ad qui ut ham hock venison ut pig mollit ball tip. Tempor chicken eiusmod tongue tail pork belly labore kielbasa consequat culpa cow aliqua. Ea tail dolore sausage flank.",
author: "Pwuts",
runs: 1539,
rating: 4.1,
},
{
id: "1",
image: "/placeholder.png",
name: "Financial Analysis Agent: Your Personalized Financial Insights Tool",
description:
"Description of what the agent does. Written by the creator. Example of text that's longer than two lines. Lorem ipsum set dolor amet bacon ipsum dolor amet kielbasa chicken ullamco frankfurter cupim nisi. Esse jerky turkey pancetta lorem officia ad qui ut ham hock venison ut pig mollit ball tip. Tempor chicken eiusmod tongue tail pork belly labore kielbasa consequat culpa cow aliqua. Ea tail dolore sausage flank.",
author: "John Ababseh",
runs: 824,
rating: 4.5,
},
];
import { useEffect, useState } from "react";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { StoreAgentDetails } from "@/lib/autogpt-server-api";
import { finishOnboarding } from "../6-congrats/actions";
function isEmptyOrWhitespace(str: string | undefined | null): boolean {
return !str || str.trim().length === 0;
@@ -38,6 +19,31 @@ function isEmptyOrWhitespace(str: string | undefined | null): boolean {
export default function Page() {
const { state, setState } = useOnboarding(4);
const [agents, setAgents] = useState<StoreAgentDetails[]>([]);
const api = useBackendAPI();
useEffect(() => {
api.getOnboardingAgents().then((agents) => {
if (agents.length < 2) {
finishOnboarding();
}
setAgents(agents);
});
}, [api, setAgents]);
useEffect(() => {
// Deselect agent if it's not in the list of agents
if (
state?.selectedAgentSlug &&
!agents.some((agent) => agent.slug === state.selectedAgentSlug)
) {
setState({
selectedAgentSlug: undefined,
selectedAgentCreator: undefined,
agentInput: undefined,
});
}
}, [state?.selectedAgentSlug, setState, agents]);
return (
<OnboardingStep>
@@ -52,21 +58,39 @@ export default function Page() {
<div className="my-12 flex items-center justify-between gap-5">
<OnboardingAgentCard
{...agents[0]}
selected={state.chosenAgentId == "0"}
onClick={() => setState({ chosenAgentId: "0" })}
{...(agents[0] || {})}
selected={
agents[0] !== undefined
? state?.selectedAgentSlug == agents[0]?.slug
: false
}
onClick={() =>
setState({
selectedAgentSlug: agents[0].slug,
selectedAgentCreator: agents[0].creator,
})
}
/>
<OnboardingAgentCard
{...agents[1]}
selected={state.chosenAgentId == "1"}
onClick={() => setState({ chosenAgentId: "1" })}
{...(agents[1] || {})}
selected={
agents[1] !== undefined
? state?.selectedAgentSlug == agents[1]?.slug
: false
}
onClick={() =>
setState({
selectedAgentSlug: agents[1].slug,
selectedAgentCreator: agents[1].creator,
})
}
/>
</div>
<OnboardingFooter>
<OnboardingButton
href="/onboarding/5-run"
disabled={isEmptyOrWhitespace(state.chosenAgentId)}
disabled={isEmptyOrWhitespace(state?.selectedAgentSlug)}
>
Next
</OnboardingButton>

View File

@@ -9,57 +9,80 @@ import { useOnboarding } from "../layout";
import StarRating from "@/components/onboarding/StarRating";
import { Play } from "lucide-react";
import { cn } from "@/lib/utils";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import OnboardingAgentInput from "@/components/onboarding/OnboardingAgentInput";
import Image from "next/image";
const agents = [
{
id: "0",
image: "/placeholder.png",
name: "Viral News Video Creator: AI TikTok Shorts",
description:
"Description of what the agent does. Written by the creator. Example of text that's longer than two lines. Lorem ipsum set dolor amet bacon ipsum dolor amet kielbasa chicken ullamco frankfurter cupim nisi. Esse jerky turkey pancetta lorem officia ad qui ut ham hock venison ut pig mollit ball tip. Tempor chicken eiusmod tongue tail pork belly labore kielbasa consequat culpa cow aliqua. Ea tail dolore sausage flank.",
author: "Pwuts",
runs: 1539,
rating: 4.1,
},
{
id: "1",
image: "/placeholder.png",
name: "Financial Analysis Agent: Your Personalized Financial Insights Tool",
description:
"Description of what the agent does. Written by the creator. Example of text that's longer than two lines. Lorem ipsum set dolor amet bacon ipsum dolor amet kielbasa chicken ullamco frankfurter cupim nisi. Esse jerky turkey pancetta lorem officia ad qui ut ham hock venison ut pig mollit ball tip. Tempor chicken eiusmod tongue tail pork belly labore kielbasa consequat culpa cow aliqua. Ea tail dolore sausage flank.",
author: "John Ababseh",
runs: 824,
rating: 4.5,
},
];
function isEmptyOrWhitespace(str: string | undefined | null): boolean {
return !str || str.trim().length === 0;
}
import { LibraryAgent, StoreAgentDetails } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useRouter } from "next/navigation";
export default function Page() {
const { state, setState } = useOnboarding(5);
const [showInput, setShowInput] = useState(false);
const selectedAgent = agents.find(
(agent) => agent.id === state.chosenAgentId,
);
const [storeAgent, setStoreAgent] = useState<StoreAgentDetails | null>(null);
const [agent, setAgent] = useState<LibraryAgent | null>(null);
const router = useRouter();
const api = useBackendAPI();
useEffect(() => {
if (!state?.selectedAgentCreator || !state?.selectedAgentSlug) {
return;
}
api
.getStoreAgent(state?.selectedAgentCreator!, state?.selectedAgentSlug!)
.then((agent) => {
setStoreAgent(agent);
api
.addMarketplaceAgentToLibrary(agent?.store_listing_version_id!)
.then((agent) => {
setAgent(agent);
const update: { [key: string]: any } = {};
// Set default values from schema
Object.entries(agent?.input_schema?.properties || {}).forEach(
([key, value]) => {
// Skip if already set
if (state?.agentInput && state?.agentInput[key]) {
update[key] = state?.agentInput[key];
return;
}
update[key] = value.type !== "null" ? value.default || "" : "";
},
);
setState({
agentInput: update,
});
});
});
}, [
api,
setAgent,
setStoreAgent,
setState,
state?.selectedAgentCreator,
state?.selectedAgentSlug,
]);
const setAgentInput = useCallback(
(key: string, value: string) => {
setState({
...state,
agentInput: {
...state.agentInput,
...state?.agentInput,
[key]: value,
},
});
},
[state, setState],
[state, state?.agentInput, setState],
);
const runAgent = useCallback(() => {
if (!agent) {
return;
}
api.executeGraph(agent.agent_id, agent.agent_version, state?.agentInput);
router.push("/onboarding/6-congrats");
}, [api, agent, router]);
const runYourAgent = (
<div className="ml-[54px] w-[481px] pl-5">
<div className="flex flex-col">
@@ -115,11 +138,10 @@ export default function Page() {
<span className="font-sans text-xs font-medium tracking-wide text-zinc-500">
SELECTED AGENT
</span>
<div className="mt-4 flex h-20 rounded-lg bg-violet-50 p-2">
{/* Left image */}
<Image
src="/placeholder.png"
src={storeAgent?.agent_image[0] || ""}
alt="Description"
width={350}
height={196}
@@ -129,26 +151,25 @@ export default function Page() {
{/* Right content */}
<div className="ml-2 flex flex-1 flex-col">
<span className="w-[292px] truncate font-sans text-[14px] font-medium leading-normal text-zinc-800">
{selectedAgent?.name}
{agent?.name}
</span>
<span className="mt-[5px] w-[292px] truncate font-sans text-xs font-normal leading-tight text-zinc-600">
by {selectedAgent?.author}
by {storeAgent?.creator}
</span>
<div className="mt-auto flex w-[292px] justify-between">
<span className="mt-1 truncate font-sans text-xs font-normal leading-tight text-zinc-600">
{selectedAgent?.runs.toLocaleString("en-US")} runs
{storeAgent?.runs.toLocaleString("en-US")} runs
</span>
<StarRating
className="font-sans text-xs font-normal leading-tight text-zinc-600"
starSize={12}
rating={selectedAgent?.rating || 0}
rating={storeAgent?.rating || 0}
/>
</div>
</div>
</div>
</div>
</div>
{/* Right side */}
{!showInput ? (
runYourAgent
@@ -169,28 +190,28 @@ export default function Page() {
<OnboardingText className="mb-3 font-semibold" variant="header">
Input
</OnboardingText>
<OnboardingAgentInput
name={"Video Count"}
description={"The number of videos you'd like to generate"}
placeholder={"eg. 1"}
value={state.agentInput?.videoCount || ""}
onChange={(v) => setAgentInput("videoCount", v)}
/>
<OnboardingAgentInput
name={"Source Website"}
description={"The website to source the stories from"}
placeholder={"eg. youtube URL"}
value={state.agentInput?.sourceWebsite || ""}
onChange={(v) => setAgentInput("sourceWebsite", v)}
/>
{Object.entries(agent?.input_schema?.properties || {}).map(
([key, value]) => (
<OnboardingAgentInput
key={key}
name={value.title!}
description={value.description || ""}
placeholder={value.placeholder || ""}
value={state?.agentInput?.[key] || ""}
onChange={(v) => setAgentInput(key, v)}
/>
),
)}
</div>
<OnboardingButton
variant="violet"
className="mt-8 w-[136px]"
disabled={
isEmptyOrWhitespace(state.agentInput?.videoCount) ||
isEmptyOrWhitespace(state.agentInput?.sourceWebsite)
Object.values(state?.agentInput || {}).some(
(value) => value.trim() === "",
) || !agent
}
onClick={runAgent}
>
<Play className="" size={18} />
Run agent

View File

@@ -0,0 +1,11 @@
"use server";
import BackendAPI from "@/lib/autogpt-server-api";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function finishOnboarding() {
const api = new BackendAPI();
await api.updateUserOnboarding({ step: 5, isCompleted: true });
revalidatePath("/library", "layout");
redirect("/library");
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { finishOnboarding } from "./actions";
import confetti from "canvas-confetti";
export default function Page() {
const [showText, setShowText] = useState(false);
const [showSubtext, setShowSubtext] = useState(false);
useEffect(() => {
confetti({
particleCount: 100,
spread: 360,
shapes: ["square", "circle"],
scalar: 2,
decay: 0.92,
origin: { y: 0.4, x: 0.5 },
});
const timer0 = setTimeout(() => {
setShowText(true);
}, 100);
const timer1 = setTimeout(() => {
setShowSubtext(true);
}, 500);
const timer2 = setTimeout(() => {
finishOnboarding();
}, 3000);
return () => {
clearTimeout(timer0);
clearTimeout(timer1);
clearTimeout(timer2);
};
}, []);
return (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-violet-100">
<div
className={cn(
"z-10 -mb-16 text-9xl duration-500",
showText ? "opacity-100" : "opacity-0",
)}
>
🎉
</div>
<h1
className={cn(
"font-poppins text-9xl font-medium tracking-tighter text-violet-700 duration-500",
showText ? "opacity-100" : "opacity-0",
)}
>
Congrats!
</h1>
<p
className={cn(
"mb-16 mt-4 font-poppins text-2xl font-medium text-violet-800 transition-opacity duration-500",
showSubtext ? "opacity-100" : "opacity-0",
)}
>
You earned 10$ for running your first agent
</p>
</div>
);
}

View File

@@ -1,25 +1,20 @@
"use client";
import { UserOnboarding } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { usePathname, useRouter } from "next/navigation";
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
type OnboardingState = {
step: number;
usageReason?: string;
integrations: string[];
otherIntegrations?: string;
chosenAgentId?: string;
agentInput?: { [key: string]: string };
};
const OnboardingContext = createContext<
| {
state: OnboardingState;
setState: (state: Partial<OnboardingState>) => void;
state: UserOnboarding | null;
setState: (state: Partial<UserOnboarding>) => void;
}
| undefined
>(undefined);
@@ -27,7 +22,7 @@ const OnboardingContext = createContext<
export function useOnboarding(step?: number) {
const context = useContext(OnboardingContext);
if (!context)
throw new Error("useOnboarding must be used within OnboardingLayout");
throw new Error("useOnboarding must be used within /onboarding pages");
useEffect(() => {
if (!step) return;
@@ -43,14 +38,57 @@ export default function OnboardingLayout({
}: {
children: ReactNode;
}) {
const [state, setStateRaw] = useState<OnboardingState>({
step: 0,
integrations: [],
});
const [state, setStateRaw] = useState<UserOnboarding | null>(null);
const api = useBackendAPI();
const pathname = usePathname();
const router = useRouter();
const setState = (newState: Partial<OnboardingState>) => {
setStateRaw((prev) => ({ ...prev, ...newState }));
};
useEffect(() => {
const fetchOnboarding = async () => {
const onboarding = await api.getUserOnboarding();
setStateRaw(onboarding);
// Redirect outside onboarding if completed
if (onboarding.isCompleted && !pathname.startsWith("/onboarding/reset")) {
router.push("/library");
}
};
fetchOnboarding();
}, [api, pathname, router]);
const setState = useCallback(
(newState: Partial<UserOnboarding>) => {
function removeNullFields<T extends object>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_, value]) => value != null),
) as Partial<T>;
}
const updateState = (state: Partial<UserOnboarding>) => {
if (!state) return;
api.updateUserOnboarding(state);
};
setStateRaw((prev) => {
if (newState.step && prev && prev?.step !== newState.step) {
updateState(removeNullFields({ ...prev, ...newState }));
}
if (!prev) {
// Handle initial state
return {
step: 1,
integrations: [],
isCompleted: false,
...newState,
};
}
return { ...prev, ...newState };
});
},
[api, setStateRaw],
);
return (
<OnboardingContext.Provider value={{ state, setState }}>

View File

@@ -1,5 +1,22 @@
import BackendAPI from "@/lib/autogpt-server-api";
import { redirect } from "next/navigation";
export default function OnboardingPage() {
redirect("/onboarding/1-welcome");
export default async function OnboardingPage() {
const api = new BackendAPI();
const onboarding = await api.getUserOnboarding();
switch (onboarding.step) {
case 1:
redirect("/onboarding/1-welcome");
case 2:
redirect("/onboarding/2-reason");
case 3:
redirect("/onboarding/3-services");
case 4:
redirect("/onboarding/4-agent");
case 5:
redirect("/onboarding/5-run");
default:
redirect("/onboarding/1-welcome");
}
}

View File

@@ -0,0 +1,17 @@
import BackendAPI from "@/lib/autogpt-server-api";
import { redirect } from "next/navigation";
export default async function OnboardingResetPage() {
const api = new BackendAPI();
await api.updateUserOnboarding({
step: 1,
usageReason: undefined,
integrations: [],
otherIntegrations: undefined,
selectedAgentCreator: undefined,
selectedAgentSlug: undefined,
agentInput: undefined,
isCompleted: false,
});
redirect("/onboarding/1-welcome");
}

View File

@@ -36,8 +36,8 @@ export async function signup(values: z.infer<typeof signupFormSchema>) {
if (data.session) {
await supabase.auth.setSession(data.session);
}
revalidatePath("/", "layout");
redirect("/");
revalidatePath("/onboarding", "layout");
redirect("/onboarding");
},
);
}

View File

@@ -1,25 +1,19 @@
import { cn } from "@/lib/utils";
import Image from "next/image";
import StarRating from "./StarRating";
import { StoreAgentDetails } from "@/lib/autogpt-server-api";
interface OnboardingAgentCardProps {
id: string;
image: string;
name: string;
description: string;
author: string;
runs: number;
rating: number;
type OnboardingAgentCardProps = StoreAgentDetails & {
selected?: boolean;
onClick: () => void;
}
};
export default function OnboardingAgentCard({
id,
image,
name,
agent_image,
creator_avatar,
agent_name,
description,
author,
creator,
runs,
rating,
selected,
@@ -29,7 +23,7 @@ export default function OnboardingAgentCard({
<div
className={cn(
"relative cursor-pointer transition-all duration-200 ease-in-out",
"h-[394px] w-[368px] rounded-xl border border-transparent bg-white",
"h-[394px] w-[368px] rounded-[20px] border border-transparent bg-white",
selected ? "bg-[#F5F3FF80]" : "hover:border-zinc-400",
)}
onClick={onClick}
@@ -37,16 +31,16 @@ export default function OnboardingAgentCard({
{/* Image container */}
<div className="relative">
<Image
src={image}
src={agent_image?.[0] || ""}
alt="Agent cover"
className="m-2 h-[196px] w-[350px] rounded-xl object-cover"
className="m-2 h-[196px] w-[350px] rounded-[16px] object-cover"
width={350}
height={196}
/>
{/* Profile picture overlay */}
<div className="absolute bottom-2 left-4">
<Image
src={image}
src={creator_avatar}
alt="Profile picture"
className="h-[50px] w-[50px] rounded-full border border-white object-cover object-center"
width={50}
@@ -61,12 +55,12 @@ export default function OnboardingAgentCard({
<div>
{/* Title - 2 lines max */}
<p className="text-md line-clamp-2 max-h-[50px] font-sans text-base font-medium leading-normal text-zinc-800">
{name}
{agent_name}
</p>
{/* Author - single line with truncate */}
<p className="truncate text-sm font-normal leading-normal text-zinc-600">
by {author}
by {creator}
</p>
{/* Description - 3 lines max */}
@@ -83,14 +77,14 @@ export default function OnboardingAgentCard({
{/* Bottom stats */}
<div className="flex w-full items-center justify-between">
<span className="mt-1 font-sans text-sm font-medium text-zinc-800">
{runs.toLocaleString("en-US")} runs
{runs?.toLocaleString("en-US")} runs
</span>
<StarRating rating={rating} />
</div>
</div>
<div
className={cn(
"pointer-events-none absolute inset-0 rounded-xl border-2 transition-all duration-200 ease-in-out",
"pointer-events-none absolute inset-0 rounded-[20px] border-2 transition-all duration-200 ease-in-out",
selected ? "border-violet-700" : "border-transparent",
)}
/>

View File

@@ -17,9 +17,8 @@ export default function OnboardingInput({
<input
className={cn(
className,
"relative h-[50px] w-[512px] rounded-[25px] border border-transparent bg-white px-4",
"font-poppin text-sm font-normal leading-normal text-zinc-900 placeholder:text-zinc-400",
"transition-all duration-200 ease-in-out",
"font-poppin relative h-[50px] w-[512px] rounded-[25px] border border-transparent bg-white px-4 text-sm font-normal leading-normal text-zinc-900",
"transition-all duration-200 ease-in-out placeholder:text-zinc-400",
"focus:border-transparent focus:bg-[#F5F3FF80] focus:outline-none focus:ring-2 focus:ring-violet-700",
)}
placeholder={placeholder}

View File

@@ -10,18 +10,7 @@ type OnboardingListElementProps = {
onClick: (content: string) => void;
};
type OnboardingListProps = {
className?: string;
elements: Array<{
label: string;
text: string;
id: string;
}>;
selectedId?: string;
onSelect: (id: string) => void;
};
function OnboardingListElement({
export function OnboardingListElement({
label,
text,
selected,
@@ -46,20 +35,18 @@ function OnboardingListElement({
<button
onClick={() => onClick(content)}
className={cn(
"relative flex h-[78px] w-[530px] items-center rounded-xl px-5 py-4",
"border border-transparent",
"transition-all duration-200 ease-in-out",
"relative flex h-[78px] w-[530px] items-center rounded-xl border border-transparent px-5 py-4 transition-all duration-200 ease-in-out",
selected ? "bg-[#F5F3FF80]" : "bg-white hover:border-zinc-400",
)}
>
<div className="flex flex-col items-start gap-1">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-sm font-medium text-zinc-700">{label}</span>
{custom && selected ? (
<input
ref={inputRef}
className={cn(
selected ? "text-zinc-600" : "text-zinc-400",
"font-poppin border-0 bg-[#F5F3FF80] text-sm focus:outline-none",
"font-poppin w-full border-0 bg-[#F5F3FF80] text-sm focus:outline-none",
)}
placeholder="Please specify"
value={content}
@@ -89,8 +76,7 @@ function OnboardingListElement({
)}
<div
className={cn(
"pointer-events-none absolute inset-0 rounded-xl border-2",
"transition-all duration-200 ease-in-out",
"pointer-events-none absolute inset-0 rounded-xl border-2 transition-all duration-200 ease-in-out",
selected ? "border-violet-700" : "border-transparent",
)}
/>
@@ -98,7 +84,18 @@ function OnboardingListElement({
);
}
export default function OnboardingList({
type OnboardingListProps = {
className?: string;
elements: Array<{
label: string;
text: string;
id: string;
}>;
selectedId?: string | null;
onSelect: (id: string) => void;
};
function OnboardingList({
className,
elements,
selectedId,
@@ -106,7 +103,7 @@ export default function OnboardingList({
}: OnboardingListProps) {
const isCustom = useCallback(() => {
return (
selectedId !== undefined &&
selectedId !== null &&
!elements.some((element) => element.id === selectedId)
);
}, [selectedId, elements]);
@@ -134,3 +131,5 @@ export default function OnboardingList({
</div>
);
}
export default OnboardingList;

View File

@@ -42,7 +42,7 @@ export function OnboardingHeader({
>
<div className="flex w-full items-center justify-between px-5 py-4">
<OnboardingBackButton href={backHref} />
<OnboardingProgress totalSteps={5} toStep={state.step - 1} />
<OnboardingProgress totalSteps={5} toStep={(state?.step || 1) - 1} />
</div>
{children}
</div>

View File

@@ -1,4 +1,5 @@
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { FaRegStar, FaStar, FaStarHalfAlt } from "react-icons/fa";
export default function StarRating({
@@ -15,23 +16,27 @@ export default function StarRating({
starSize ??= 15;
// Generate array of 5 star values
const stars = Array(5)
.fill(0)
.map((_, index) => {
const difference = roundedRating - index;
const stars = useMemo(
() =>
Array(5)
.fill(0)
.map((_, index) => {
const difference = roundedRating - index;
if (difference >= 1) {
return "full";
} else if (difference > 0) {
// Half star for values between 0.2 and 0.8
return difference >= 0.8
? "full"
: difference >= 0.2
? "half"
: "empty";
}
return "empty";
});
if (difference >= 1) {
return "full";
} else if (difference > 0) {
// Half star for values between 0.2 and 0.8
return difference >= 0.8
? "full"
: difference >= 0.2
? "half"
: "empty";
}
return "empty";
}),
[roundedRating],
);
return (
<div

View File

@@ -47,6 +47,7 @@ import {
TransactionHistory,
User,
UserPasswordCredentials,
UserOnboarding,
} from "./types";
import { createBrowserClient } from "@supabase/ssr";
import getServerSupabase from "../supabase/getServerSupabase";
@@ -99,6 +100,9 @@ export default class BackendAPI {
return this._request("POST", "/auth/user/email", { email });
}
////////////////////////////////////////
///////////// CREDITS //////////////////
////////////////////////////////////////
getUserCredit(): Promise<{ credits: number }> {
try {
return this._get("/credits");
@@ -162,6 +166,24 @@ export default class BackendAPI {
return this._request("PATCH", "/credits");
}
////////////////////////////////////////
/////////// ONBOARDING /////////////////
////////////////////////////////////////
getUserOnboarding(): Promise<UserOnboarding> {
return this._get("/onboarding");
}
updateUserOnboarding(onboarding: Partial<UserOnboarding>): Promise<void> {
return this._request("PATCH", "/onboarding", onboarding);
}
getOnboardingAgents(): Promise<StoreAgentDetails[]> {
return this._get("/onboarding/agents");
}
////////////////////////////////////////
/////////// GRAPHS /////////////////////
////////////////////////////////////////
getBlocks(): Promise<Block[]> {
return this._get("/blocks");
}

View File

@@ -732,6 +732,17 @@ export interface RefundRequest {
updated_at: Date;
}
export interface UserOnboarding {
step: number;
usageReason?: string;
integrations: string[];
otherIntegrations?: string;
selectedAgentCreator?: string;
selectedAgentSlug?: string;
agentInput?: { [key: string]: string };
isCompleted: boolean;
}
/* *** UTILITIES *** */
/** Use branded types for IDs -> deny mixing IDs between different object classes */

View File

@@ -18,7 +18,6 @@ test.describe("Authentication", () => {
}) => {
await page.goto("/login");
await loginPage.login(testUser.email, testUser.password);
await test.expect(page).toHaveURL("/marketplace");
// Click on the profile menu trigger to open popout

View File

@@ -19,7 +19,7 @@ test.describe("Build", () => { //(1)!
// Start each test with login using worker auth
await page.goto("/login"); //(4)!
await loginPage.login(testUser.email, testUser.password);
await test.expect(page).toHaveURL("/"); //(5)!
await test.expect(page).toHaveURL("/marketplace"); //(5)!
await buildPage.navbar.clickBuildLink();
});

View File

@@ -17,7 +17,7 @@ test.describe("Monitor", () => {
// Start each test with login using worker auth
await page.goto("/login");
await loginPage.login(testUser.email, testUser.password);
await test.expect(page).toHaveURL("/");
await test.expect(page).toHaveURL("/marketplace");
// add a test agent
const basicBlock = await buildPage.getDictionaryBlockDetails();

View File

@@ -34,7 +34,11 @@ export class LoginPage {
await loginButton.waitFor({ state: "visible" });
// Start waiting for navigation before clicking
const navigationPromise = this.page.waitForURL("/", { timeout: 10_000 });
const navigationPromise = Promise.race([
this.page.waitForURL("/", { timeout: 10_000 }), // Wait for home page
this.page.waitForURL("/marketplace", { timeout: 10_000 }), // Wait for home page
this.page.waitForURL("/onboarding/**", { timeout: 10_000 }), // Wait for onboarding page
]);
console.log("About to click login button"); // Debug log
await loginButton.click();
@@ -42,6 +46,12 @@ export class LoginPage {
console.log("Waiting for navigation"); // Debug log
await navigationPromise;
// If the user is redirected to onboarding, manually redirect to /marketplace
if (this.page.url().includes("/onboarding")) {
console.log("Redirecting to /marketplace"); // Debug log
await this.page.goto("/marketplace");
}
console.log("Navigation complete, waiting for network idle"); // Debug log
await this.page.waitForLoadState("load", { timeout: 10_000 });
console.log("Login process complete"); // Debug log

View File

@@ -126,10 +126,15 @@ const config = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"fade-in": {
"0%": { opacity: "0" }, // Start with opacity 0
"100%": { opacity: "1" }, // End with opacity 1
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"fade-in": "fade-in 0.2s ease-out",
},
},
},

View File

@@ -3237,10 +3237,10 @@
"@storybook/react-dom-shim" "8.5.3"
"@storybook/theming" "8.5.3"
"@storybook/test-runner@^0.20.1":
version "0.20.1"
resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.20.1.tgz#e2efa6266d512312a6b810db376da2919008cccd"
integrity sha512-3WU/th/uncIR6vpQDK9hKjiZjmczsluoLbgkRV7ufxY9IgHCGcbIjvT5EPS+XZIaOrNGjaPsyB5cE1okKn9iSA==
"@storybook/test-runner@^0.21.0":
version "0.21.3"
resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.21.3.tgz#20ad7a9ee20811c51f332557e362733df2c31c4b"
integrity sha512-tsorR0IVYElcaTZrT/ZrH/8YDw7c+wrLDN0e3qj1WdbqU8l2IU4+a32HLXaakzdDpuTn+d8xCk4VZMlzMrmToA==
dependencies:
"@babel/core" "^7.22.5"
"@babel/generator" "^7.22.5"
@@ -3526,6 +3526,11 @@
dependencies:
"@babel/types" "^7.20.7"
"@types/canvas-confetti@^1.9.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz#d1077752e046413c9881fbb2ba34a70ebe3c1773"
integrity sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==
"@types/connect@3.4.36":
version "3.4.36"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab"
@@ -4932,6 +4937,11 @@ caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001688:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz#040bbbb54463c4b4b3377c716b34a322d16e6fc7"
integrity sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==
canvas-confetti@^1.9.3:
version "1.9.3"
resolved "https://registry.yarnpkg.com/canvas-confetti/-/canvas-confetti-1.9.3.tgz#ef4c857420ad8045ab4abe8547261c8cdf229845"
integrity sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==
case-sensitive-paths-webpack-plugin@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"