mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): refine AutoPilot onboarding — branding, auto-advance, soft cap, polish (#12686)
### Why / What / How
**Why:** The onboarding flow had inconsistent branding ("Autopilot" vs
"AutoPilot"), a heavy progress bar that dominated the header, an extra
click on the role screen, and no guidance on how many pain points to
select — leading to users selecting everything or nothing useful.
**What:** Copy & brand fixes, UX improvements (auto-advance, soft cap),
and visual polish (progress bar, checkmark badges, purple focus inputs).
**How:**
- Replaced all "Autopilot" with "AutoPilot" (capital P) across screens
1-3
- Removed the `?` tooltip on screen 1 (users will learn about AutoPilot
from the access email)
- Changed name label to conversational "What should I call you?"
- Screen 2: auto-advances 350ms after role selection (except "Other"
which still shows input + button)
- Screen 3: soft cap of 3 selections with green confirmation text and
shake animation on overflow attempt
- Thinned progress bar from ~10px to 3px (Linear/Notion style)
- Added purple checkmark badges on selected cards
- Updated Input atom focus state to purple ring
### Changes 🏗️
- **WelcomeStep**: "AutoPilot" branding, removed tooltip, conversational
label
- **RoleStep**: Updated subtitle, auto-advance on non-"Other" role
select, Continue button only for "Other"
- **PainPointsStep**: Soft cap of 3 with dynamic helper text and shake
animation
- **usePainPointsStep**: Added `atLimit`/`shaking` state, wrapped
`togglePainPoint` with cap logic
- **store.ts**: `togglePainPoint` returns early when at 3 and adding
- **ProgressBar**: 3px height, removed glow shadow
- **SelectableCard**: Added purple checkmark badge on selected state
- **Input atom**: Focus ring changed from zinc to purple
- **tailwind.config.ts**: Added `shake` keyframe and `animate-shake`
utility
### Checklist 📋
#### For code changes:
- [ ] I have clearly listed my changes in the PR description
- [ ] I have made a test plan
- [ ] I have tested my changes according to the test plan:
- [ ] Navigate through full onboarding flow (screens 1→2→3→4)
- [ ] Verify "AutoPilot" branding on all screens (no "Autopilot")
- [ ] Verify screen 2 auto-advances after tapping a role (non-"Other")
- [ ] Verify "Other" role still shows text input and Continue button
- [ ] Verify Back button works correctly from screen 2 and 3
- [ ] Select 3 pain points and verify green "3 selected" text
- [ ] Attempt 4th selection and verify shake animation + swap message
- [ ] Deselect one and verify can select a different one
- [ ] Verify checkmark badges appear on selected cards
- [ ] Verify progress bar is thin (3px) and subtle
- [ ] Verify input focus state is purple across onboarding inputs
- [ ] Verify "Something else" + other text input still works on screen 3
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,29 @@ describe("useOnboardingWizardStore", () => {
|
||||
"no tests",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores new selections when at the max limit", () => {
|
||||
useOnboardingWizardStore.getState().togglePainPoint("a");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("b");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("c");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("d");
|
||||
expect(useOnboardingWizardStore.getState().painPoints).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still allows deselecting when at the max limit", () => {
|
||||
useOnboardingWizardStore.getState().togglePainPoint("a");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("b");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("c");
|
||||
useOnboardingWizardStore.getState().togglePainPoint("b");
|
||||
expect(useOnboardingWizardStore.getState().painPoints).toEqual([
|
||||
"a",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOtherPainPoint", () => {
|
||||
|
||||
@@ -7,9 +7,9 @@ export function ProgressBar({ currentStep, totalSteps }: Props) {
|
||||
const percent = (currentStep / totalSteps) * 100;
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 top-0 h-[0.625rem] w-full bg-neutral-300">
|
||||
<div className="absolute left-0 top-0 h-[3px] w-full bg-neutral-200">
|
||||
<div
|
||||
className="h-full bg-purple-400 shadow-[0_0_4px_2px_rgba(168,85,247,0.5)] transition-all duration-500 ease-out"
|
||||
className="h-full bg-purple-400 transition-all duration-500 ease-out"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "@phosphor-icons/react";
|
||||
|
||||
interface Props {
|
||||
icon: React.ReactNode;
|
||||
@@ -24,13 +25,18 @@ export function SelectableCard({
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex h-[9rem] w-[10.375rem] shrink-0 flex-col items-center justify-center gap-3 rounded-xl border-2 bg-white px-6 py-5 transition-all hover:shadow-sm md:shrink lg:gap-2 lg:px-10 lg:py-8",
|
||||
"relative flex h-[9rem] w-[10.375rem] shrink-0 flex-col items-center justify-center gap-3 rounded-xl border-2 bg-white px-6 py-5 transition-all hover:shadow-sm md:shrink lg:gap-2 lg:px-10 lg:py-8",
|
||||
className,
|
||||
selected
|
||||
? "border-purple-500 bg-purple-50 shadow-sm"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<span className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-purple-500">
|
||||
<Check size={12} weight="bold" className="text-white" />
|
||||
</span>
|
||||
)}
|
||||
<Text
|
||||
variant="lead"
|
||||
as="span"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
|
||||
@@ -73,6 +74,8 @@ export function PainPointsStep() {
|
||||
togglePainPoint,
|
||||
setOtherPainPoint,
|
||||
hasSomethingElse,
|
||||
atLimit,
|
||||
shaking,
|
||||
canContinue,
|
||||
handleLaunch,
|
||||
} = usePainPointsStep();
|
||||
@@ -90,7 +93,7 @@ export function PainPointsStep() {
|
||||
What's eating your time?
|
||||
</Text>
|
||||
<Text variant="lead" className="!text-zinc-500">
|
||||
Pick the tasks you'd love to hand off to Autopilot
|
||||
Pick the tasks you'd love to hand off to AutoPilot
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -107,11 +110,22 @@ export function PainPointsStep() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!hasSomethingElse ? (
|
||||
<Text variant="small" className="!text-zinc-500">
|
||||
Pick as many as you want — you can always change later
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
variant="small"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
atLimit && canContinue ? "!text-green-600" : "!text-zinc-500",
|
||||
shaking && "animate-shake",
|
||||
)}
|
||||
>
|
||||
{shaking
|
||||
? "You've picked 3 — tap one to swap it out"
|
||||
: atLimit && canContinue
|
||||
? "3 selected — you're all set!"
|
||||
: atLimit && hasSomethingElse
|
||||
? "Tell us what else takes up your time"
|
||||
: "Pick up to 3 to start — AutoPilot can help with anything else later"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{hasSomethingElse && (
|
||||
@@ -133,7 +147,7 @@ export function PainPointsStep() {
|
||||
disabled={!canContinue}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
Launch Autopilot
|
||||
Launch AutoPilot
|
||||
</Button>
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
|
||||
import { SelectableCard } from "../components/SelectableCard";
|
||||
import { useOnboardingWizardStore } from "../store";
|
||||
import { Emoji } from "@/components/atoms/Emoji/Emoji";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const IMG_SIZE = 42;
|
||||
|
||||
@@ -57,12 +58,26 @@ export function RoleStep() {
|
||||
const setRole = useOnboardingWizardStore((s) => s.setRole);
|
||||
const setOtherRole = useOnboardingWizardStore((s) => s.setOtherRole);
|
||||
const nextStep = useOnboardingWizardStore((s) => s.nextStep);
|
||||
const autoAdvanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isOther = role === "Other";
|
||||
const canContinue = role && (!isOther || otherRole.trim());
|
||||
|
||||
function handleContinue() {
|
||||
if (canContinue) {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleRoleSelect(id: string) {
|
||||
if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current);
|
||||
setRole(id);
|
||||
if (id !== "Other") {
|
||||
autoAdvanceTimer.current = setTimeout(nextStep, 350);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOtherContinue() {
|
||||
if (otherRole.trim()) {
|
||||
nextStep();
|
||||
}
|
||||
}
|
||||
@@ -78,7 +93,7 @@ export function RoleStep() {
|
||||
What best describes you, {name}?
|
||||
</Text>
|
||||
<Text variant="lead" className="!text-zinc-500">
|
||||
Autopilot will tailor automations to your world
|
||||
So AutoPilot knows how to help you best
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -89,33 +104,35 @@ export function RoleStep() {
|
||||
icon={r.icon}
|
||||
label={r.label}
|
||||
selected={role === r.id}
|
||||
onClick={() => setRole(r.id)}
|
||||
onClick={() => handleRoleSelect(r.id)}
|
||||
className="p-8"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isOther && (
|
||||
<div className="-mb-5 w-full px-8 md:px-0">
|
||||
<Input
|
||||
id="other-role"
|
||||
label="Other role"
|
||||
hideLabel
|
||||
placeholder="Describe your role..."
|
||||
value={otherRole}
|
||||
onChange={(e) => setOtherRole(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<div className="-mb-5 w-full px-8 md:px-0">
|
||||
<Input
|
||||
id="other-role"
|
||||
label="Other role"
|
||||
hideLabel
|
||||
placeholder="Describe your role..."
|
||||
value={otherRole}
|
||||
onChange={(e) => setOtherRole(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={!canContinue}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOtherContinue}
|
||||
disabled={!otherRole.trim()}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FadeIn>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,6 @@ import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { Question } from "@phosphor-icons/react";
|
||||
import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
|
||||
import { useOnboardingWizardStore } from "../store";
|
||||
|
||||
@@ -40,36 +33,16 @@ export function WelcomeStep() {
|
||||
<Text variant="h3">Welcome to AutoGPT</Text>
|
||||
<Text variant="lead" as="span" className="!text-zinc-500">
|
||||
Let's personalize your experience so{" "}
|
||||
<span className="relative mr-3 inline-block bg-gradient-to-r from-purple-500 to-indigo-500 bg-clip-text text-transparent">
|
||||
Autopilot
|
||||
<span className="absolute -right-4 top-0">
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="What is Autopilot?"
|
||||
className="inline-flex text-purple-500"
|
||||
>
|
||||
<Question size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Autopilot is AutoGPT's AI assistant that watches your
|
||||
connected apps, spots repetitive tasks you do every day
|
||||
and runs them for you automatically.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
<span className="bg-gradient-to-r from-purple-500 to-indigo-500 bg-clip-text text-transparent">
|
||||
AutoPilot
|
||||
</span>{" "}
|
||||
can start saving you time right away
|
||||
can start saving you time
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="first-name"
|
||||
label="Your first name"
|
||||
label="What should I call you?"
|
||||
placeholder="e.g. John"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { useOnboardingWizardStore } from "../../store";
|
||||
import { PainPointsStep } from "../PainPointsStep";
|
||||
|
||||
vi.mock("@/components/atoms/Emoji/Emoji", () => ({
|
||||
Emoji: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/atoms/FadeIn/FadeIn", () => ({
|
||||
FadeIn: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function getCard(name: RegExp) {
|
||||
return screen.getByRole("button", { name });
|
||||
}
|
||||
|
||||
function clickCard(name: RegExp) {
|
||||
fireEvent.click(getCard(name));
|
||||
}
|
||||
|
||||
function getLaunchButton() {
|
||||
return screen.getByRole("button", { name: /launch autopilot/i });
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
useOnboardingWizardStore.getState().reset();
|
||||
useOnboardingWizardStore.getState().setName("Alice");
|
||||
useOnboardingWizardStore.getState().setRole("Founder/CEO");
|
||||
useOnboardingWizardStore.getState().goToStep(3);
|
||||
});
|
||||
|
||||
describe("PainPointsStep", () => {
|
||||
test("renders all pain point cards", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
expect(getCard(/finding leads/i)).toBeDefined();
|
||||
expect(getCard(/email & outreach/i)).toBeDefined();
|
||||
expect(getCard(/reports & data/i)).toBeDefined();
|
||||
expect(getCard(/customer support/i)).toBeDefined();
|
||||
expect(getCard(/social media/i)).toBeDefined();
|
||||
expect(getCard(/something else/i)).toBeDefined();
|
||||
});
|
||||
|
||||
test("shows default helper text", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
expect(
|
||||
screen.getAllByText(/pick up to 3 to start/i).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("selecting a card marks it as pressed", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
clickCard(/finding leads/i);
|
||||
|
||||
expect(getCard(/finding leads/i).getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
test("launch button is disabled when nothing is selected", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
expect(getLaunchButton().hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
test("launch button is enabled after selecting a pain point", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
clickCard(/finding leads/i);
|
||||
|
||||
expect(getLaunchButton().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
test("shows success text when 3 items are selected", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
clickCard(/finding leads/i);
|
||||
clickCard(/email & outreach/i);
|
||||
clickCard(/reports & data/i);
|
||||
|
||||
expect(screen.getAllByText(/3 selected/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("does not select a 4th item when at the limit", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
clickCard(/finding leads/i);
|
||||
clickCard(/email & outreach/i);
|
||||
clickCard(/reports & data/i);
|
||||
clickCard(/customer support/i);
|
||||
|
||||
expect(getCard(/customer support/i).getAttribute("aria-pressed")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
test("can deselect when at the limit and select a different one", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
clickCard(/finding leads/i);
|
||||
clickCard(/email & outreach/i);
|
||||
clickCard(/reports & data/i);
|
||||
|
||||
clickCard(/finding leads/i);
|
||||
expect(getCard(/finding leads/i).getAttribute("aria-pressed")).toBe(
|
||||
"false",
|
||||
);
|
||||
|
||||
clickCard(/customer support/i);
|
||||
expect(getCard(/customer support/i).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("shows input when 'Something else' is selected", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
clickCard(/something else/i);
|
||||
|
||||
expect(
|
||||
screen.getByPlaceholderText(/what else takes up your time/i),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("launch button is disabled when 'Something else' selected but input empty", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
clickCard(/something else/i);
|
||||
|
||||
expect(getLaunchButton().hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
test("launch button is enabled when 'Something else' selected and input filled", () => {
|
||||
render(<PainPointsStep />);
|
||||
|
||||
clickCard(/something else/i);
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(/what else takes up your time/i),
|
||||
{ target: { value: "Manual invoicing" } },
|
||||
);
|
||||
|
||||
expect(getLaunchButton().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
} from "@/tests/integrations/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { useOnboardingWizardStore } from "../../store";
|
||||
import { RoleStep } from "../RoleStep";
|
||||
|
||||
vi.mock("@/components/atoms/Emoji/Emoji", () => ({
|
||||
Emoji: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/atoms/FadeIn/FadeIn", () => ({
|
||||
FadeIn: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useOnboardingWizardStore.getState().reset();
|
||||
useOnboardingWizardStore.getState().setName("Alice");
|
||||
useOnboardingWizardStore.getState().goToStep(2);
|
||||
});
|
||||
|
||||
describe("RoleStep", () => {
|
||||
test("renders all role cards", () => {
|
||||
render(<RoleStep />);
|
||||
|
||||
expect(screen.getByText("Founder / CEO")).toBeDefined();
|
||||
expect(screen.getByText("Operations")).toBeDefined();
|
||||
expect(screen.getByText("Sales / BD")).toBeDefined();
|
||||
expect(screen.getByText("Marketing")).toBeDefined();
|
||||
expect(screen.getByText("Product / PM")).toBeDefined();
|
||||
expect(screen.getByText("Engineering")).toBeDefined();
|
||||
expect(screen.getByText("HR / People")).toBeDefined();
|
||||
expect(screen.getByText("Other")).toBeDefined();
|
||||
});
|
||||
|
||||
test("displays the user name in the heading", () => {
|
||||
render(<RoleStep />);
|
||||
|
||||
expect(
|
||||
screen.getAllByText(/what best describes you, alice/i).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("selecting a non-Other role auto-advances after delay", () => {
|
||||
render(<RoleStep />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /engineering/i }));
|
||||
|
||||
expect(useOnboardingWizardStore.getState().role).toBe("Engineering");
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(2);
|
||||
|
||||
vi.advanceTimersByTime(350);
|
||||
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(3);
|
||||
});
|
||||
|
||||
test("selecting 'Other' does not auto-advance", () => {
|
||||
render(<RoleStep />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /\bother\b/i }));
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(2);
|
||||
});
|
||||
|
||||
test("selecting 'Other' shows text input and Continue button", () => {
|
||||
render(<RoleStep />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /\bother\b/i }));
|
||||
|
||||
expect(screen.getByPlaceholderText(/describe your role/i)).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: /continue/i })).toBeDefined();
|
||||
});
|
||||
|
||||
test("Continue button is disabled when Other input is empty", () => {
|
||||
render(<RoleStep />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /\bother\b/i }));
|
||||
|
||||
const continueBtn = screen.getByRole("button", { name: /continue/i });
|
||||
expect(continueBtn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
test("Continue button advances when Other role text is filled", () => {
|
||||
render(<RoleStep />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /\bother\b/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/describe your role/i), {
|
||||
target: { value: "Designer" },
|
||||
});
|
||||
|
||||
const continueBtn = screen.getByRole("button", { name: /continue/i });
|
||||
expect(continueBtn.hasAttribute("disabled")).toBe(false);
|
||||
|
||||
fireEvent.click(continueBtn);
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(3);
|
||||
});
|
||||
|
||||
test("switching from Other to a regular role cancels Other and auto-advances", () => {
|
||||
render(<RoleStep />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /\bother\b/i }));
|
||||
expect(screen.getByPlaceholderText(/describe your role/i)).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /marketing/i }));
|
||||
|
||||
expect(useOnboardingWizardStore.getState().role).toBe("Marketing");
|
||||
vi.advanceTimersByTime(350);
|
||||
expect(useOnboardingWizardStore.getState().currentStep).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useOnboardingWizardStore } from "../store";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MAX_PAIN_POINT_SELECTIONS, useOnboardingWizardStore } from "../store";
|
||||
|
||||
const ROLE_TOP_PICKS: Record<string, string[]> = {
|
||||
"Founder/CEO": [
|
||||
@@ -23,18 +24,38 @@ export function usePainPointsStep() {
|
||||
const role = useOnboardingWizardStore((s) => s.role);
|
||||
const painPoints = useOnboardingWizardStore((s) => s.painPoints);
|
||||
const otherPainPoint = useOnboardingWizardStore((s) => s.otherPainPoint);
|
||||
const togglePainPoint = useOnboardingWizardStore((s) => s.togglePainPoint);
|
||||
const storeToggle = useOnboardingWizardStore((s) => s.togglePainPoint);
|
||||
const setOtherPainPoint = useOnboardingWizardStore(
|
||||
(s) => s.setOtherPainPoint,
|
||||
);
|
||||
const nextStep = useOnboardingWizardStore((s) => s.nextStep);
|
||||
const [shaking, setShaking] = useState(false);
|
||||
const shakeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (shakeTimer.current) clearTimeout(shakeTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const topIDs = getTopPickIDs(role);
|
||||
const hasSomethingElse = painPoints.includes("Something else");
|
||||
const atLimit = painPoints.length >= MAX_PAIN_POINT_SELECTIONS;
|
||||
const canContinue =
|
||||
painPoints.length > 0 &&
|
||||
(!hasSomethingElse || Boolean(otherPainPoint.trim()));
|
||||
|
||||
function togglePainPoint(id: string) {
|
||||
const alreadySelected = painPoints.includes(id);
|
||||
if (!alreadySelected && atLimit) {
|
||||
if (shakeTimer.current) clearTimeout(shakeTimer.current);
|
||||
setShaking(true);
|
||||
shakeTimer.current = setTimeout(() => setShaking(false), 600);
|
||||
return;
|
||||
}
|
||||
storeToggle(id);
|
||||
}
|
||||
|
||||
function handleLaunch() {
|
||||
if (canContinue) {
|
||||
nextStep();
|
||||
@@ -48,6 +69,8 @@ export function usePainPointsStep() {
|
||||
togglePainPoint,
|
||||
setOtherPainPoint,
|
||||
hasSomethingElse,
|
||||
atLimit,
|
||||
shaking,
|
||||
canContinue,
|
||||
handleLaunch,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export const MAX_PAIN_POINT_SELECTIONS = 3;
|
||||
export type Step = 1 | 2 | 3 | 4;
|
||||
|
||||
interface OnboardingWizardState {
|
||||
@@ -40,6 +41,8 @@ export const useOnboardingWizardStore = create<OnboardingWizardState>(
|
||||
togglePainPoint(painPoint) {
|
||||
set((state) => {
|
||||
const exists = state.painPoints.includes(painPoint);
|
||||
if (!exists && state.painPoints.length >= MAX_PAIN_POINT_SELECTIONS)
|
||||
return state;
|
||||
return {
|
||||
painPoints: exists
|
||||
? state.painPoints.filter((p) => p !== painPoint)
|
||||
|
||||
@@ -78,7 +78,7 @@ export function Input({
|
||||
"font-normal text-black",
|
||||
"placeholder:font-normal placeholder:text-zinc-400",
|
||||
// Focus and hover states
|
||||
"focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0",
|
||||
"focus:border-purple-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-purple-400 focus:ring-offset-0",
|
||||
className,
|
||||
);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ test("onboarding wizard step navigation works", async ({ page }) => {
|
||||
|
||||
// Step 1: Welcome
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
await page.getByLabel("Your first name").fill("Bob");
|
||||
await page.getByLabel("What should I call you?").fill("Bob");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Step 2: Role — verify we're here, then go back
|
||||
@@ -41,7 +41,7 @@ test("onboarding wizard step navigation works", async ({ page }) => {
|
||||
|
||||
// Should be back on step 1 with name preserved
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
await expect(page.getByLabel("Your first name")).toHaveValue("Bob");
|
||||
await expect(page.getByLabel("What should I call you?")).toHaveValue("Bob");
|
||||
});
|
||||
|
||||
test("onboarding wizard validates required fields", async ({ page }) => {
|
||||
@@ -53,18 +53,13 @@ test("onboarding wizard validates required fields", async ({ page }) => {
|
||||
await expect(continueButton).toBeDisabled();
|
||||
|
||||
// Fill name — continue should become enabled
|
||||
await page.getByLabel("Your first name").fill("Charlie");
|
||||
await page.getByLabel("What should I call you?").fill("Charlie");
|
||||
await expect(continueButton).toBeEnabled();
|
||||
await continueButton.click();
|
||||
|
||||
// Step 2: Continue should be disabled without a role
|
||||
const step2Continue = page.getByRole("button", { name: "Continue" });
|
||||
await expect(step2Continue).toBeDisabled();
|
||||
|
||||
// Select role — continue should become enabled
|
||||
// Step 2: Role — selecting auto-advances to step 3
|
||||
await expect(page.getByText("What best describes you")).toBeVisible();
|
||||
await page.getByText("Engineering").click();
|
||||
await expect(step2Continue).toBeEnabled();
|
||||
await step2Continue.click();
|
||||
|
||||
// Step 3: Launch Autopilot should be disabled without any pain points
|
||||
const launchButton = page.getByRole("button", { name: "Launch Autopilot" });
|
||||
@@ -95,7 +90,7 @@ test("onboarding URL params sync with steps", async ({ page }) => {
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
|
||||
|
||||
// Fill name and go to step 2
|
||||
await page.getByLabel("Your first name").fill("Test");
|
||||
await page.getByLabel("What should I call you?").fill("Test");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// URL should show step=2
|
||||
@@ -106,12 +101,11 @@ test("role-based pain point ordering works", async ({ page }) => {
|
||||
await signupTestUser(page, undefined, undefined, false);
|
||||
|
||||
// Complete step 1
|
||||
await page.getByLabel("Your first name").fill("Test");
|
||||
await page.getByLabel("What should I call you?").fill("Test");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Select Sales/BD role
|
||||
// Select Sales/BD role (auto-advances to step 3)
|
||||
await page.getByText("Sales / BD").click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// On pain points step, "Finding leads" should be visible (top pick for Sales)
|
||||
await expect(page.getByText("What's eating your time?")).toBeVisible();
|
||||
|
||||
@@ -52,15 +52,14 @@ export async function completeOnboardingWizard(
|
||||
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByLabel("Your first name").fill(name);
|
||||
await page.getByLabel("What should I call you?").fill(name);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Step 2: Role — select a role
|
||||
// Step 2: Role — select a role (auto-advances after selection)
|
||||
await expect(page.getByText("What best describes you")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.getByText(role, { exact: false }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Step 3: Pain points — select tasks
|
||||
await expect(page.getByText("What's eating your time?")).toBeVisible({
|
||||
@@ -72,9 +71,6 @@ export async function completeOnboardingWizard(
|
||||
await page.getByRole("button", { name: "Launch Autopilot" }).click();
|
||||
|
||||
// Step 4: Preparing — wait for animation to complete and redirect to /copilot
|
||||
await expect(page.getByText("Preparing your workspace")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.waitForURL(/\/copilot/, { timeout: 15000 });
|
||||
|
||||
return { name, role, painPoints };
|
||||
|
||||
@@ -175,6 +175,13 @@ const config = {
|
||||
boxShadow: "0 0 0 30px rgba(0, 0, 0, 0)",
|
||||
},
|
||||
},
|
||||
shake: {
|
||||
"0%, 100%": { transform: "translateX(0)" },
|
||||
"20%": { transform: "translateX(-4px)" },
|
||||
"40%": { transform: "translateX(4px)" },
|
||||
"60%": { transform: "translateX(-3px)" },
|
||||
"80%": { transform: "translateX(3px)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
@@ -182,6 +189,7 @@ const config = {
|
||||
"fade-in": "fade-in 0.2s ease-out",
|
||||
shimmer: "shimmer 4s ease-in-out infinite",
|
||||
loader: "loader 1s infinite",
|
||||
shake: "shake 0.5s ease-in-out",
|
||||
},
|
||||
transitionDuration: {
|
||||
"2000": "2000ms",
|
||||
|
||||
Reference in New Issue
Block a user