mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 15:17:59 -05:00
fix(frontend): performance and layout issues (#11036)
## Changes 🏗️ ### Performance (Onboarding) 🐎 - Moved non-UI logic into `providers/onboarding/helpers.ts` to reduce provider complexity. - Memoized provider value and narrowed state updates to cut unnecessary re-renders. - Deferred non-critical effects until after mount to lower initial JS work. **Result:** faster initial render and smoother onboarding flows under load. ### Layout and overflow fixes 📐 - Replaced `w-screen` with `w-full` in platform/admin/profile layouts and marketplace wrappers to avoid 100vw scrollbar overflow. - Adjusted mobile navbar position (`right-0` instead of `-right-4`) to prevent off-viewport elements. **Result:** removed horizontal scrolling on Marketplace, Library, and Settings pages; Build remains unaffected. ### New Generic Error pages - Standardized global error handling in `app/global-error.tsx` for consistent display and user feedback. - Added platform-scoped error page(s) under `app/(platform)/error` for route-level failures with a consistent layout. - Improved retry affordances using existing `ErrorCard`. ## 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] Verify onboarding flows render faster and re-render less (DevTools flamegraph) - [x] Confirm no horizontal scrolling on Marketplace, Library, Settings at common widths - [x] Validate mobile navbar stays within viewport - [x] Trigger errors to confirm global and platform error pages render consistently ### For configuration changes: None
This commit is contained in:
@@ -31,7 +31,7 @@ export default function AdminLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-screen flex-col lg:flex-row">
|
||||
<div className="flex min-h-screen w-full flex-col lg:flex-row">
|
||||
<Sidebar linkGroups={sidebarLinkGroups} />
|
||||
<div className="flex-1 pl-4">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -11,56 +11,12 @@ async function shouldShowOnboarding() {
|
||||
);
|
||||
}
|
||||
|
||||
// Default redirect path - matches the home page redirect destination
|
||||
const DEFAULT_REDIRECT_PATH = "/marketplace";
|
||||
|
||||
// Validate redirect URL to prevent open redirect attacks and malformed URLs
|
||||
function validateRedirectUrl(url: string): string {
|
||||
try {
|
||||
const cleanUrl = url.trim();
|
||||
|
||||
// Check for completely invalid patterns that suggest URL corruption
|
||||
if (
|
||||
cleanUrl.includes(",") || // Any comma suggests concatenated URLs
|
||||
cleanUrl.includes(" ") // Spaces in URLs are problematic
|
||||
) {
|
||||
console.warn(
|
||||
"Detected corrupted redirect URL (likely race condition):",
|
||||
cleanUrl,
|
||||
);
|
||||
return DEFAULT_REDIRECT_PATH;
|
||||
}
|
||||
|
||||
// Only allow relative URLs that start with /
|
||||
if (!cleanUrl.startsWith("/") || cleanUrl.startsWith("//")) {
|
||||
console.warn("Invalid redirect URL format:", cleanUrl);
|
||||
return DEFAULT_REDIRECT_PATH;
|
||||
}
|
||||
|
||||
// Additional safety checks
|
||||
if (cleanUrl.split("/").length > 5) {
|
||||
// Reasonable path depth limit
|
||||
console.warn("Suspiciously deep redirect URL:", cleanUrl);
|
||||
return DEFAULT_REDIRECT_PATH;
|
||||
}
|
||||
|
||||
// For now, allow any valid relative path (can be restricted later if needed)
|
||||
return cleanUrl;
|
||||
} catch (error) {
|
||||
console.error("Error validating redirect URL:", error);
|
||||
return DEFAULT_REDIRECT_PATH;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the callback to complete the user session login
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
|
||||
// if "next" is in param, use it as the redirect URL
|
||||
const nextParam = searchParams.get("next") ?? "/";
|
||||
// Validate redirect URL to prevent open redirect attacks
|
||||
let next = validateRedirectUrl(nextParam);
|
||||
let next = "/marketplace";
|
||||
|
||||
if (code) {
|
||||
const supabase = await getServerSupabase();
|
||||
@@ -70,7 +26,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||
// data.session?.refresh_token is available if you need to store it for later use
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const api = new BackendAPI();
|
||||
@@ -84,7 +40,45 @@ export async function GET(request: Request) {
|
||||
}
|
||||
} catch (createUserError) {
|
||||
console.error("Error creating user:", createUserError);
|
||||
// Continue with redirect even if createUser fails
|
||||
|
||||
// Handle ApiError from the backend API client
|
||||
if (
|
||||
createUserError &&
|
||||
typeof createUserError === "object" &&
|
||||
"status" in createUserError
|
||||
) {
|
||||
const apiError = createUserError as any;
|
||||
|
||||
if (apiError.status === 401) {
|
||||
// Authentication issues - token missing/invalid
|
||||
return NextResponse.redirect(
|
||||
`${origin}/error?message=auth-token-invalid`,
|
||||
);
|
||||
} else if (apiError.status >= 500) {
|
||||
// Server/database errors
|
||||
return NextResponse.redirect(
|
||||
`${origin}/error?message=server-error`,
|
||||
);
|
||||
} else if (apiError.status === 429) {
|
||||
// Rate limiting
|
||||
return NextResponse.redirect(
|
||||
`${origin}/error?message=rate-limited`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle network/fetch errors
|
||||
if (
|
||||
createUserError instanceof TypeError &&
|
||||
createUserError.message.includes("fetch")
|
||||
) {
|
||||
return NextResponse.redirect(`${origin}/error?message=network-error`);
|
||||
}
|
||||
|
||||
// Generic user creation failure
|
||||
return NextResponse.redirect(
|
||||
`${origin}/error?message=user-creation-failed`,
|
||||
);
|
||||
}
|
||||
|
||||
const forwardedHost = request.headers.get("x-forwarded-host"); // original origin before load balancer
|
||||
|
||||
55
autogpt_platform/frontend/src/app/(platform)/error.tsx
Normal file
55
autogpt_platform/frontend/src/app/(platform)/error.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { getErrorDetails } from "./error/helpers";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
function ErrorPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const errorMessage = searchParams.get("message");
|
||||
|
||||
const errorDetails = getErrorDetails(errorMessage);
|
||||
|
||||
function handleRetry() {
|
||||
if (
|
||||
errorMessage === "user-creation-failed" ||
|
||||
errorMessage === "auth-failed"
|
||||
) {
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
window.location.href = "/marketplace";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md">
|
||||
<ErrorCard
|
||||
responseError={errorDetails.responseError}
|
||||
context={errorDetails.context}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ErrorPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md">
|
||||
<ErrorCard
|
||||
responseError={{ message: "Loading..." }}
|
||||
context="application"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ErrorPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export function getErrorDetails(errorType: string | null) {
|
||||
switch (errorType) {
|
||||
case "user-creation-failed":
|
||||
return {
|
||||
responseError: {
|
||||
message:
|
||||
"Failed to create your user account in our system. This could be due to a temporary server issue or a problem with your account setup.",
|
||||
},
|
||||
context: "user account creation",
|
||||
};
|
||||
case "auth-token-invalid":
|
||||
return {
|
||||
responseError: {
|
||||
message:
|
||||
"Your authentication token is missing or invalid. Please try signing in again.",
|
||||
},
|
||||
context: "authentication token",
|
||||
};
|
||||
case "server-error":
|
||||
return {
|
||||
responseError: {
|
||||
message:
|
||||
"Our servers are experiencing issues. Please try again in a few minutes, or contact support if the problem persists.",
|
||||
},
|
||||
context: "server error",
|
||||
};
|
||||
case "rate-limited":
|
||||
return {
|
||||
responseError: {
|
||||
message:
|
||||
"Too many requests have been made. Please wait a moment before trying again.",
|
||||
},
|
||||
context: "rate limiting",
|
||||
};
|
||||
case "network-error":
|
||||
return {
|
||||
responseError: {
|
||||
message:
|
||||
"Unable to connect to our servers. Please check your internet connection and try again.",
|
||||
},
|
||||
context: "network connectivity",
|
||||
};
|
||||
case "auth-failed":
|
||||
return {
|
||||
responseError: {
|
||||
message: "Authentication failed. Please try signing in again.",
|
||||
},
|
||||
context: "authentication",
|
||||
};
|
||||
case "session-expired":
|
||||
return {
|
||||
responseError: {
|
||||
message:
|
||||
"Your session has expired. Please sign in again to continue.",
|
||||
},
|
||||
context: "session",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
responseError: {
|
||||
message:
|
||||
"An unexpected error occurred. Please try again or contact support if the problem persists.",
|
||||
},
|
||||
context: "application",
|
||||
};
|
||||
}
|
||||
}
|
||||
63
autogpt_platform/frontend/src/app/(platform)/error/page.tsx
Normal file
63
autogpt_platform/frontend/src/app/(platform)/error/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { getErrorDetails } from "./helpers";
|
||||
|
||||
function ErrorPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const errorMessage = searchParams.get("message");
|
||||
const errorDetails = getErrorDetails(errorMessage);
|
||||
|
||||
function handleRetry() {
|
||||
// Auth-related errors should redirect to login
|
||||
if (
|
||||
errorMessage === "user-creation-failed" ||
|
||||
errorMessage === "auth-failed" ||
|
||||
errorMessage === "auth-token-invalid" ||
|
||||
errorMessage === "session-expired"
|
||||
) {
|
||||
window.location.href = "/login";
|
||||
} else if (errorMessage === "rate-limited") {
|
||||
// For rate limiting, wait a moment then try again
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
// For server/network errors, go to marketplace
|
||||
window.location.href = "/marketplace";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="relative w-full max-w-xl lg:bottom-[4rem]">
|
||||
<ErrorCard
|
||||
responseError={errorDetails.responseError}
|
||||
context={errorDetails.context}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ErrorPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="relative w-full max-w-xl lg:-top-[4rem]">
|
||||
<ErrorCard
|
||||
responseError={{ message: "Loading..." }}
|
||||
context="application"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ErrorPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ReactNode } from "react";
|
||||
|
||||
export default function PlatformLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="flex h-screen w-screen flex-col">
|
||||
<main className="flex h-screen w-full flex-col">
|
||||
<Navbar />
|
||||
<section className="flex-1">{children}</section>
|
||||
</main>
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function LoginPage() {
|
||||
/>
|
||||
|
||||
{/* Turnstile CAPTCHA Component */}
|
||||
{isCloudEnv && !turnstile.verified ? (
|
||||
{turnstile.shouldRender ? (
|
||||
<Turnstile
|
||||
key={captchaKey}
|
||||
siteKey={turnstile.siteKey}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
|
||||
export const AgentPageLoading = () => {
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<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" />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
|
||||
export const CreatorPageLoading = () => {
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<div className="mx-auto w-full max-w-[1360px]">
|
||||
<main className="mt-5 px-4">
|
||||
<Skeleton className="mb-4 h-6 w-40" />
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
|
||||
}
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<div className="mx-auto w-full max-w-[1360px]">
|
||||
<main className="px-4">
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<ErrorCard
|
||||
@@ -48,7 +48,7 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<div className="mx-auto w-full max-w-[1360px]">
|
||||
<main className="px-4">
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<ErrorCard
|
||||
@@ -74,7 +74,7 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<div className="mx-auto w-full max-w-[1360px]">
|
||||
<main className="mt-5 px-4">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const MainCreatorPage = ({ params }: MainCreatorPageProps) => {
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<div className="mx-auto w-full max-w-[1360px]">
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<ErrorCard
|
||||
isSuccess={false}
|
||||
@@ -39,7 +39,7 @@ export const MainCreatorPage = ({ params }: MainCreatorPageProps) => {
|
||||
|
||||
if (creator)
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<div className="mx-auto w-full max-w-[1360px]">
|
||||
<main className="mt-5 px-4">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
|
||||
@@ -37,7 +37,7 @@ 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-screen max-w-[1360px]">
|
||||
<div className="mx-auto w-full max-w-[1360px]">
|
||||
<main className="px-4">
|
||||
<HeroSection />
|
||||
{featuredAgents && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
|
||||
export const MainMarketplacePageLoading = () => {
|
||||
return (
|
||||
<div className="mx-auto w-screen max-w-[1360px]">
|
||||
<div className="mx-auto w-full max-w-[1360px]">
|
||||
<main className="px-4">
|
||||
<div className="flex flex-col gap-2 pt-16">
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-screen max-w-[1360px] flex-col lg:flex-row">
|
||||
<div className="flex min-h-screen w-full max-w-[1360px] flex-col lg:flex-row">
|
||||
<Sidebar linkGroups={sidebarLinkGroups} />
|
||||
<div className="flex-1 pl-4">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextError from "next/error";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
@@ -16,11 +18,19 @@ export default function GlobalError({
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="relative w-full max-w-xl lg:bottom-[4rem]">
|
||||
<ErrorCard
|
||||
responseError={{
|
||||
message:
|
||||
error.message ||
|
||||
"An unexpected error occurred. Our team has been notified and is working to resolve the issue.",
|
||||
}}
|
||||
context="application"
|
||||
onRetry={reset}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
|
||||
import * as party from "party-js";
|
||||
import WalletRefill from "./WalletRefill";
|
||||
import { OnboardingStep } from "@/lib/autogpt-server-api";
|
||||
import { storage, Key as StorageKey } from "@/services/storage/local-storage";
|
||||
|
||||
export interface Task {
|
||||
id: OnboardingStep;
|
||||
@@ -164,7 +165,8 @@ export default function Wallet() {
|
||||
|
||||
const [prevCredits, setPrevCredits] = useState<number | null>(credits);
|
||||
const [flash, setFlash] = useState(false);
|
||||
const [walletOpen, setWalletOpen] = useState(state?.walletShown || false);
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
const [lastSeenCredits, setLastSeenCredits] = useState<number | null>(null);
|
||||
|
||||
const totalCount = useMemo(() => {
|
||||
return groups.reduce((acc, group) => acc + group.tasks.length, 0);
|
||||
@@ -193,6 +195,38 @@ export default function Wallet() {
|
||||
setCompletedCount(completed);
|
||||
}, [groups, state?.completedSteps]);
|
||||
|
||||
// Load last seen credits from localStorage once on mount
|
||||
useEffect(() => {
|
||||
const stored = storage.get(StorageKey.WALLET_LAST_SEEN_CREDITS);
|
||||
if (stored !== undefined && stored !== null) {
|
||||
const parsed = parseFloat(stored);
|
||||
if (!Number.isNaN(parsed)) setLastSeenCredits(parsed);
|
||||
else setLastSeenCredits(0);
|
||||
} else {
|
||||
setLastSeenCredits(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-open once if never shown, otherwise open only when credits increase beyond last seen
|
||||
useEffect(() => {
|
||||
if (typeof credits !== "number") return;
|
||||
// Open once for first-time users
|
||||
if (state && state.walletShown === false) {
|
||||
setWalletOpen(true);
|
||||
// Mark as shown so it won't reopen on every reload
|
||||
updateState({ walletShown: true });
|
||||
return;
|
||||
}
|
||||
// Open if user gained more credits than last acknowledged
|
||||
if (
|
||||
lastSeenCredits !== null &&
|
||||
credits > lastSeenCredits &&
|
||||
walletOpen === false
|
||||
) {
|
||||
setWalletOpen(true);
|
||||
}
|
||||
}, [credits, lastSeenCredits, state?.walletShown, updateState, walletOpen]);
|
||||
|
||||
const onWalletOpen = useCallback(async () => {
|
||||
if (!state?.walletShown) {
|
||||
updateState({ walletShown: true });
|
||||
@@ -270,7 +304,19 @@ export default function Wallet() {
|
||||
}, [credits, prevCredits]);
|
||||
|
||||
return (
|
||||
<Popover open={walletOpen} onOpenChange={setWalletOpen}>
|
||||
<Popover
|
||||
open={walletOpen}
|
||||
onOpenChange={(open) => {
|
||||
setWalletOpen(open);
|
||||
if (!open) {
|
||||
// Persist the latest acknowledged credits so we only auto-open on future gains
|
||||
if (typeof credits === "number") {
|
||||
storage.set(StorageKey.WALLET_LAST_SEEN_CREDITS, String(credits));
|
||||
setLastSeenCredits(credits);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
@@ -317,7 +363,7 @@ export default function Wallet() {
|
||||
Earn credits{" "}
|
||||
<span className="font-semibold">{formatCredits(credits)}</span>
|
||||
</div>
|
||||
<PopoverClose>
|
||||
<PopoverClose aria-label="Close wallet">
|
||||
<X className="ml-2 h-5 w-5 text-zinc-800 hover:text-foreground" />
|
||||
</PopoverClose>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
import BoringAvatar from "boring-avatars";
|
||||
|
||||
import Image, { ImageProps } from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AvatarContextValue = {
|
||||
isLoaded: boolean;
|
||||
@@ -44,10 +45,10 @@ export function Avatar({
|
||||
return (
|
||||
<AvatarContext.Provider value={value}>
|
||||
<div
|
||||
className={[
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className || "",
|
||||
].join(" ")}
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -126,7 +127,7 @@ export function AvatarImage({
|
||||
<img
|
||||
src={normalizedSrc}
|
||||
alt={alt || "Avatar image"}
|
||||
className={["h-full w-full object-cover", className || ""].join(" ")}
|
||||
className={cn("h-full w-full object-cover", className)}
|
||||
width={computedWidth}
|
||||
height={computedHeight}
|
||||
onLoad={handleLoad}
|
||||
@@ -148,7 +149,7 @@ export function AvatarImage({
|
||||
<Image
|
||||
src={normalizedSrc}
|
||||
alt={alt || "Avatar image"}
|
||||
className={["h-full w-full object-cover", className || ""].join(" ")}
|
||||
className={cn("h-full w-full object-cover", className)}
|
||||
width={fill ? undefined : computedWidth}
|
||||
height={fill ? undefined : computedHeight}
|
||||
fill={Boolean(fill)}
|
||||
@@ -179,10 +180,10 @@ export function AvatarFallback({
|
||||
typeof children === "string" && children.trim() ? children : "User";
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-neutral-200 text-lg text-neutral-600",
|
||||
className || "",
|
||||
].join(" ")}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-transparent text-lg text-neutral-600",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BoringAvatar
|
||||
|
||||
@@ -40,7 +40,7 @@ export function AccountMenu({
|
||||
aria-haspopup="true"
|
||||
data-testid="profile-popout-menu-trigger"
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<Avatar>
|
||||
<AvatarImage src={avatarSrc} alt="" aria-hidden="true" />
|
||||
<AvatarFallback aria-hidden="true">
|
||||
{userName?.charAt(0) || "U"}
|
||||
|
||||
@@ -19,7 +19,6 @@ export function LoginButton() {
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
size="small"
|
||||
className="flex items-center justify-end space-x-2"
|
||||
leftIcon={<SignInIcon className="h-5 w-5" />}
|
||||
variant="secondary"
|
||||
>
|
||||
|
||||
@@ -66,7 +66,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
|
||||
{/* Mobile Navbar - Adjust positioning */}
|
||||
<>
|
||||
{isLoggedIn ? (
|
||||
<div className="fixed -right-4 top-2 z-50 flex items-center gap-0 md:hidden">
|
||||
<div className="fixed right-0 top-2 z-50 flex items-center gap-0 md:hidden">
|
||||
<Wallet />
|
||||
<MobileNavBar
|
||||
userName={profile?.username}
|
||||
|
||||
109
autogpt_platform/frontend/src/providers/onboarding/helpers.ts
Normal file
109
autogpt_platform/frontend/src/providers/onboarding/helpers.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api";
|
||||
|
||||
export function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
export function isYesterday(date: Date): boolean {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
return (
|
||||
date.getDate() === yesterday.getDate() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getFullYear() === yesterday.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateConsecutiveDays(
|
||||
lastRunAt: Date | null,
|
||||
currentConsecutiveDays: number,
|
||||
): { lastRunAt: Date; consecutiveRunDays: number } {
|
||||
const now = new Date();
|
||||
|
||||
if (lastRunAt === null || isYesterday(lastRunAt)) {
|
||||
return {
|
||||
lastRunAt: now,
|
||||
consecutiveRunDays: currentConsecutiveDays + 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isToday(lastRunAt)) {
|
||||
return {
|
||||
lastRunAt: now,
|
||||
consecutiveRunDays: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lastRunAt: now,
|
||||
consecutiveRunDays: currentConsecutiveDays,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRunMilestoneSteps(
|
||||
newRunCount: number,
|
||||
consecutiveDays: number,
|
||||
): OnboardingStep[] {
|
||||
const steps: OnboardingStep[] = [];
|
||||
|
||||
if (newRunCount === 10) steps.push("RUN_AGENTS");
|
||||
if (newRunCount === 100) steps.push("RUN_AGENTS_100");
|
||||
if (consecutiveDays === 3) steps.push("RUN_3_DAYS");
|
||||
if (consecutiveDays === 14) steps.push("RUN_14_DAYS");
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function processOnboardingData(
|
||||
onboarding: UserOnboarding,
|
||||
): UserOnboarding {
|
||||
// Patch for TRIGGER_WEBHOOK - only set on backend then overwritten by frontend
|
||||
const completeWebhook =
|
||||
onboarding.rewardedFor.includes("TRIGGER_WEBHOOK") &&
|
||||
!onboarding.completedSteps.includes("TRIGGER_WEBHOOK")
|
||||
? (["TRIGGER_WEBHOOK"] as OnboardingStep[])
|
||||
: [];
|
||||
|
||||
return {
|
||||
...onboarding,
|
||||
completedSteps: [...completeWebhook, ...onboarding.completedSteps],
|
||||
lastRunAt: onboarding.lastRunAt ? new Date(onboarding.lastRunAt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRedirectFromOnboarding(
|
||||
completedSteps: OnboardingStep[],
|
||||
pathname: string,
|
||||
): boolean {
|
||||
return (
|
||||
completedSteps.includes("CONGRATS") &&
|
||||
!pathname.startsWith("/onboarding/reset")
|
||||
);
|
||||
}
|
||||
|
||||
export function createInitialOnboardingState(
|
||||
newState: Omit<Partial<UserOnboarding>, "rewardedFor">,
|
||||
): UserOnboarding {
|
||||
return {
|
||||
completedSteps: [],
|
||||
walletShown: true,
|
||||
notified: [],
|
||||
rewardedFor: [],
|
||||
usageReason: null,
|
||||
integrations: [],
|
||||
otherIntegrations: null,
|
||||
selectedStoreListingVersionId: null,
|
||||
agentInput: null,
|
||||
onboardingAgentExecutionId: null,
|
||||
agentRuns: 0,
|
||||
lastRunAt: null,
|
||||
consecutiveRunDays: 0,
|
||||
...newState,
|
||||
};
|
||||
}
|
||||
@@ -20,8 +20,16 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
calculateConsecutiveDays,
|
||||
createInitialOnboardingState,
|
||||
getRunMilestoneSteps,
|
||||
processOnboardingData,
|
||||
shouldRedirectFromOnboarding,
|
||||
} from "./helpers";
|
||||
|
||||
const OnboardingContext = createContext<
|
||||
| {
|
||||
@@ -39,6 +47,7 @@ const OnboardingContext = createContext<
|
||||
|
||||
export function useOnboarding(step?: number, completeStep?: OnboardingStep) {
|
||||
const context = useContext(OnboardingContext);
|
||||
|
||||
if (!context)
|
||||
throw new Error("useOnboarding must be used within an OnboardingProvider");
|
||||
|
||||
@@ -71,31 +80,31 @@ export default function OnboardingProvider({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [state, setState] = useState<UserOnboarding | null>(null);
|
||||
// Step is used to control the progress bar, it's frontend only
|
||||
const [step, setStep] = useState(1);
|
||||
const [npsDialogOpen, setNpsDialogOpen] = useState(false);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
const api = useBackendAPI();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, isUserLoading } = useSupabase();
|
||||
|
||||
// Automatically detect and set timezone for new users during onboarding
|
||||
useOnboardingTimezoneDetection();
|
||||
|
||||
const isOnOnboardingRoute = pathname.startsWith("/onboarding");
|
||||
|
||||
useEffect(() => {
|
||||
// Only run heavy onboarding API calls if user is logged in and not loading
|
||||
if (isUserLoading || !user) {
|
||||
// Prevent multiple initializations
|
||||
if (hasInitialized.current || isUserLoading || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchOnboarding = async () => {
|
||||
hasInitialized.current = true;
|
||||
|
||||
async function initializeOnboarding() {
|
||||
try {
|
||||
// For non-onboarding routes, we still need basic onboarding state for step completion
|
||||
// but we can skip the expensive isOnboardingEnabled() check
|
||||
// Check onboarding enabled only for onboarding routes
|
||||
if (isOnOnboardingRoute) {
|
||||
// Only check if onboarding is enabled when user is actually on onboarding routes
|
||||
const enabled = await api.isOnboardingEnabled();
|
||||
if (!enabled) {
|
||||
router.push("/marketplace");
|
||||
@@ -103,72 +112,41 @@ export default function OnboardingProvider({
|
||||
}
|
||||
}
|
||||
|
||||
// Always fetch user onboarding state for step completion functionality
|
||||
const onboarding = await api.getUserOnboarding();
|
||||
if (!onboarding) return;
|
||||
|
||||
// Only update state if onboarding data is valid
|
||||
if (onboarding) {
|
||||
//todo kcze this is a patch because only TRIGGER_WEBHOOK is set on the backend and then overwritten by the frontend
|
||||
const completeWebhook =
|
||||
onboarding.rewardedFor.includes("TRIGGER_WEBHOOK") &&
|
||||
!onboarding.completedSteps.includes("TRIGGER_WEBHOOK")
|
||||
? (["TRIGGER_WEBHOOK"] as OnboardingStep[])
|
||||
: [];
|
||||
const processedOnboarding = processOnboardingData(onboarding);
|
||||
setState(processedOnboarding);
|
||||
|
||||
setState((prev) => ({
|
||||
...onboarding,
|
||||
completedSteps: [...completeWebhook, ...onboarding.completedSteps],
|
||||
lastRunAt: new Date(onboarding.lastRunAt || ""),
|
||||
...prev,
|
||||
}));
|
||||
|
||||
// Only handle onboarding redirects when user is on onboarding routes
|
||||
if (isOnOnboardingRoute) {
|
||||
// Redirect outside onboarding if completed
|
||||
// If user did CONGRATS step, that means they completed introductory onboarding
|
||||
if (
|
||||
onboarding.completedSteps &&
|
||||
onboarding.completedSteps.includes("CONGRATS") &&
|
||||
!pathname.startsWith("/onboarding/reset")
|
||||
) {
|
||||
router.push("/marketplace");
|
||||
}
|
||||
}
|
||||
// Handle redirects for completed onboarding
|
||||
if (
|
||||
isOnOnboardingRoute &&
|
||||
shouldRedirectFromOnboarding(
|
||||
processedOnboarding.completedSteps,
|
||||
pathname,
|
||||
)
|
||||
) {
|
||||
router.push("/marketplace");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch onboarding data:", error);
|
||||
// Don't update state on error to prevent null access issues
|
||||
console.error("Failed to initialize onboarding:", error);
|
||||
hasInitialized.current = false; // Allow retry on next render
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fetchOnboarding();
|
||||
}, [api, isOnOnboardingRoute, router, user, isUserLoading]);
|
||||
initializeOnboarding();
|
||||
}, [api, isOnOnboardingRoute, router, user, isUserLoading, pathname]);
|
||||
|
||||
const updateState = useCallback(
|
||||
(newState: Omit<Partial<UserOnboarding>, "rewardedFor">) => {
|
||||
setState((prev) => {
|
||||
if (!prev) {
|
||||
// Handle initial state
|
||||
return {
|
||||
completedSteps: [],
|
||||
walletShown: true,
|
||||
notified: [],
|
||||
rewardedFor: [],
|
||||
usageReason: null,
|
||||
integrations: [],
|
||||
otherIntegrations: null,
|
||||
selectedStoreListingVersionId: null,
|
||||
agentInput: null,
|
||||
onboardingAgentExecutionId: null,
|
||||
agentRuns: 0,
|
||||
lastRunAt: null,
|
||||
consecutiveRunDays: 0,
|
||||
...newState,
|
||||
};
|
||||
return createInitialOnboardingState(newState);
|
||||
}
|
||||
return { ...prev, ...newState };
|
||||
});
|
||||
// Make the API call asynchronously to not block render
|
||||
|
||||
// Async API update without blocking render
|
||||
setTimeout(() => {
|
||||
api.updateUserOnboarding(newState).catch((error) => {
|
||||
console.error("Failed to update user onboarding:", error);
|
||||
@@ -180,75 +158,38 @@ export default function OnboardingProvider({
|
||||
|
||||
const completeStep = useCallback(
|
||||
(step: OnboardingStep) => {
|
||||
if (
|
||||
!state ||
|
||||
!state.completedSteps ||
|
||||
state.completedSteps.includes(step)
|
||||
)
|
||||
return;
|
||||
|
||||
updateState({
|
||||
completedSteps: [...state.completedSteps, step],
|
||||
});
|
||||
if (!state?.completedSteps?.includes(step)) {
|
||||
updateState({
|
||||
completedSteps: [...(state?.completedSteps || []), step],
|
||||
});
|
||||
}
|
||||
},
|
||||
[state, updateState],
|
||||
[state?.completedSteps, updateState],
|
||||
);
|
||||
|
||||
const isToday = useCallback((date: Date) => {
|
||||
const today = new Date();
|
||||
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}, []);
|
||||
|
||||
const isYesterday = useCallback((date: Date): boolean => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
return (
|
||||
date.getDate() === yesterday.getDate() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getFullYear() === yesterday.getFullYear()
|
||||
);
|
||||
}, []);
|
||||
|
||||
const incrementRuns = useCallback(() => {
|
||||
if (!state || !state.completedSteps) return;
|
||||
if (!state?.completedSteps) return;
|
||||
|
||||
const tenRuns = state.agentRuns + 1 === 10;
|
||||
const hundredRuns = state.agentRuns + 1 === 100;
|
||||
// Calculate if it's a run on a consecutive day
|
||||
// If the last run was yesterday, increment days
|
||||
// Otherwise, if the last run was *not* today reset it (already checked that it wasn't yesterday at this point)
|
||||
// Otherwise, don't do anything (the last run was today)
|
||||
const consecutive =
|
||||
state.lastRunAt === null || isYesterday(state.lastRunAt)
|
||||
? {
|
||||
lastRunAt: new Date(),
|
||||
consecutiveRunDays: state.consecutiveRunDays + 1,
|
||||
}
|
||||
: !isToday(state.lastRunAt)
|
||||
? { lastRunAt: new Date(), consecutiveRunDays: 1 }
|
||||
: {};
|
||||
const newRunCount = state.agentRuns + 1;
|
||||
const consecutiveData = calculateConsecutiveDays(
|
||||
state.lastRunAt,
|
||||
state.consecutiveRunDays,
|
||||
);
|
||||
|
||||
const milestoneSteps = getRunMilestoneSteps(
|
||||
newRunCount,
|
||||
consecutiveData.consecutiveRunDays,
|
||||
);
|
||||
|
||||
// Show NPS dialog at 10 runs
|
||||
if (newRunCount === 10) {
|
||||
setNpsDialogOpen(true);
|
||||
}
|
||||
|
||||
setNpsDialogOpen(tenRuns);
|
||||
updateState({
|
||||
agentRuns: state.agentRuns + 1,
|
||||
completedSteps: [
|
||||
...state.completedSteps,
|
||||
...(tenRuns ? (["RUN_AGENTS"] as OnboardingStep[]) : []),
|
||||
...(hundredRuns ? (["RUN_AGENTS_100"] as OnboardingStep[]) : []),
|
||||
...(consecutive.consecutiveRunDays === 3
|
||||
? (["RUN_3_DAYS"] as OnboardingStep[])
|
||||
: []),
|
||||
...(consecutive.consecutiveRunDays === 14
|
||||
? (["RUN_14_DAYS"] as OnboardingStep[])
|
||||
: []),
|
||||
],
|
||||
...consecutive,
|
||||
agentRuns: newRunCount,
|
||||
completedSteps: [...state.completedSteps, ...milestoneSteps],
|
||||
...consecutiveData,
|
||||
});
|
||||
}, [state, updateState]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum Key {
|
||||
WEBSOCKET_DISCONNECT_INTENT = "websocket-disconnect-intent",
|
||||
COPIED_FLOW_DATA = "copied-flow-data",
|
||||
SHEPHERD_TOUR = "shepherd-tour",
|
||||
WALLET_LAST_SEEN_CREDITS = "wallet-last-seen-credits",
|
||||
}
|
||||
|
||||
function get(key: Key) {
|
||||
|
||||
@@ -63,5 +63,32 @@ export class LoginPage {
|
||||
console.log("➡️ Navigating to /marketplace ...");
|
||||
await this.page.goto("/marketplace", { timeout: 10_000 });
|
||||
console.log("✅ Login process complete");
|
||||
|
||||
// If Wallet popover auto-opens, close it to avoid blocking account menu interactions
|
||||
try {
|
||||
const walletPanel = this.page.getByText("Your credits").first();
|
||||
// Wait briefly for wallet to appear after navigation (it may open asynchronously)
|
||||
const appeared = await walletPanel
|
||||
.waitFor({ state: "visible", timeout: 2500 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (appeared) {
|
||||
const closeWalletButton = this.page.getByRole("button", {
|
||||
name: /Close wallet/i,
|
||||
});
|
||||
await closeWalletButton.click({ timeout: 3000 }).catch(async () => {
|
||||
// Fallbacks: try Escape, then click outside
|
||||
await this.page.keyboard.press("Escape").catch(() => {});
|
||||
});
|
||||
await walletPanel
|
||||
.waitFor({ state: "hidden", timeout: 3000 })
|
||||
.catch(async () => {
|
||||
await this.page.mouse.click(5, 5).catch(() => {});
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
// Non-fatal in tests; continue
|
||||
console.log("(info) Wallet popover not present or already closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user