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:
Ubbe
2025-10-02 19:21:31 +09:00
committed by GitHub
parent 7c9db7419a
commit 4a1cb6d64b
24 changed files with 515 additions and 202 deletions

View File

@@ -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>

View File

@@ -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

View 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>
);
}

View File

@@ -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",
};
}
}

View 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>
);
}

View File

@@ -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>

View File

@@ -85,7 +85,7 @@ export default function LoginPage() {
/>
{/* Turnstile CAPTCHA Component */}
{isCloudEnv && !turnstile.verified ? (
{turnstile.shouldRender ? (
<Turnstile
key={captchaKey}
siteKey={turnstile.siteKey}

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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} />

View File

@@ -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={[

View File

@@ -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 && (

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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

View File

@@ -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"}

View File

@@ -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"
>

View File

@@ -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}

View 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,
};
}

View File

@@ -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]);

View File

@@ -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) {

View File

@@ -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");
}
}
}