feat(FN-1943): add post-onboarding setup recommendations panel
- Add a PostOnboardingRecommendations component that detects incomplete AI provider, default model, and GitHub setup and offers direct actions - Persist post-onboarding dismissal state in model-onboarding-state with new helpers for dismiss, check, and clear flows - Integrate recommendations into App rendering so they appear after completed onboarding when resume onboarding is not active - Add dashboard styles and focused tests for recommendation rendering/actions plus onboarding dismissal state coverage - Add a changeset for @gsxdsm/fusion patch release describing the post-onboarding recommendations
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
|
||||
import { AlertCircle, GitPullRequest, Key, Lightbulb, X, Zap } from "lucide-react";
|
||||
import { fetchAuthStatus, fetchGlobalSettings } from "../api";
|
||||
import {
|
||||
dismissPostOnboardingRecommendations,
|
||||
isOnboardingCompleted,
|
||||
isPostOnboardingDismissed,
|
||||
} from "./model-onboarding-state";
|
||||
|
||||
interface PostOnboardingRecommendationsProps {
|
||||
onOpenSettings: (section: string) => void;
|
||||
onOpenModelOnboarding: () => void;
|
||||
}
|
||||
|
||||
interface RecommendationItem {
|
||||
id: "ai-provider" | "default-model" | "github";
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onAction: () => void;
|
||||
icon: ComponentType<{ size?: number; className?: string; "aria-hidden"?: boolean }>;
|
||||
}
|
||||
|
||||
export function PostOnboardingRecommendations({
|
||||
onOpenSettings,
|
||||
onOpenModelOnboarding,
|
||||
}: PostOnboardingRecommendationsProps) {
|
||||
const onboardingCompleted = isOnboardingCompleted();
|
||||
const postOnboardingDismissed = isPostOnboardingDismissed();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [dismissedLocally, setDismissedLocally] = useState(false);
|
||||
const [incompleteState, setIncompleteState] = useState<{
|
||||
needsAiProvider: boolean;
|
||||
needsDefaultModel: boolean;
|
||||
needsGitHub: boolean;
|
||||
}>({
|
||||
needsAiProvider: false,
|
||||
needsDefaultModel: false,
|
||||
needsGitHub: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!onboardingCompleted || postOnboardingDismissed) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setHasError(false);
|
||||
|
||||
const [authStatus, globalSettings] = await Promise.all([
|
||||
fetchAuthStatus(),
|
||||
fetchGlobalSettings(),
|
||||
]);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const providers = authStatus.providers ?? [];
|
||||
const githubProvider = providers.find((provider) => provider.id === "github");
|
||||
const hasAuthenticatedAiProvider = providers.some(
|
||||
(provider) => provider.id !== "github" && provider.authenticated,
|
||||
);
|
||||
|
||||
const needsAiProvider = !hasAuthenticatedAiProvider;
|
||||
const needsDefaultModel = !globalSettings.defaultProvider && !globalSettings.defaultModelId;
|
||||
const needsGitHub = githubProvider ? !githubProvider.authenticated : false;
|
||||
|
||||
setIncompleteState({ needsAiProvider, needsDefaultModel, needsGitHub });
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setHasError(true);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onboardingCompleted, postOnboardingDismissed]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
dismissPostOnboardingRecommendations();
|
||||
setDismissedLocally(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenModelOnboarding = useCallback(() => {
|
||||
onOpenModelOnboarding();
|
||||
}, [onOpenModelOnboarding]);
|
||||
|
||||
const handleOpenGlobalModels = useCallback(() => {
|
||||
onOpenSettings("global-models");
|
||||
}, [onOpenSettings]);
|
||||
|
||||
const handleOpenAuthentication = useCallback(() => {
|
||||
onOpenSettings("authentication");
|
||||
}, [onOpenSettings]);
|
||||
|
||||
const recommendations = useMemo<RecommendationItem[]>(() => {
|
||||
const items: RecommendationItem[] = [];
|
||||
|
||||
if (incompleteState.needsAiProvider) {
|
||||
items.push({
|
||||
id: "ai-provider",
|
||||
title: "Connect AI Provider",
|
||||
description: "Connect an AI provider to enable AI agents for task planning and code generation",
|
||||
actionLabel: "Set Up AI",
|
||||
onAction: handleOpenModelOnboarding,
|
||||
icon: Zap,
|
||||
});
|
||||
}
|
||||
|
||||
if (incompleteState.needsDefaultModel) {
|
||||
items.push({
|
||||
id: "default-model",
|
||||
title: "Select Default Model",
|
||||
description: "Choose a default AI model for task execution",
|
||||
actionLabel: "Choose Model",
|
||||
onAction: handleOpenGlobalModels,
|
||||
icon: Key,
|
||||
});
|
||||
}
|
||||
|
||||
if (incompleteState.needsGitHub) {
|
||||
items.push({
|
||||
id: "github",
|
||||
title: "Connect GitHub",
|
||||
description: "Connect GitHub to import issues and track pull requests",
|
||||
actionLabel: "Connect GitHub",
|
||||
onAction: handleOpenAuthentication,
|
||||
icon: GitPullRequest,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
incompleteState.needsAiProvider,
|
||||
incompleteState.needsDefaultModel,
|
||||
incompleteState.needsGitHub,
|
||||
handleOpenAuthentication,
|
||||
handleOpenGlobalModels,
|
||||
handleOpenModelOnboarding,
|
||||
]);
|
||||
|
||||
if (!onboardingCompleted || postOnboardingDismissed || dismissedLocally || loading || hasError || recommendations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="post-onboarding-recommendations"
|
||||
role="region"
|
||||
aria-label="Setup recommendations"
|
||||
>
|
||||
<div className="post-onboarding-recommendations__main">
|
||||
<div className="post-onboarding-recommendations__icon" aria-hidden="true">
|
||||
<Lightbulb size={18} aria-hidden={true} />
|
||||
</div>
|
||||
<div className="post-onboarding-recommendations__content">
|
||||
<h2 className="post-onboarding-recommendations__title">Recommended Next Steps</h2>
|
||||
<p className="post-onboarding-recommendations__description">
|
||||
Complete these setup items to get the most out of Fusion.
|
||||
</p>
|
||||
<ul className="post-onboarding-recommendations__list">
|
||||
{recommendations.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
|
||||
return (
|
||||
<li key={item.id} className="post-onboarding-recommendations__item">
|
||||
<span className="post-onboarding-recommendations__item-icon" aria-hidden="true">
|
||||
<AlertCircle size={14} aria-hidden={true} />
|
||||
<ItemIcon size={14} aria-hidden={true} />
|
||||
</span>
|
||||
<span className="post-onboarding-recommendations__item-text">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.description}</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-sm btn-primary" onClick={item.onAction}>
|
||||
{item.actionLabel}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="post-onboarding-recommendations__dismiss"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss recommendations"
|
||||
>
|
||||
<X size={16} aria-hidden={true} />
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { PostOnboardingRecommendations } from "../PostOnboardingRecommendations";
|
||||
|
||||
const mockFetchAuthStatus = vi.fn();
|
||||
const mockFetchGlobalSettings = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||
fetchGlobalSettings: (...args: unknown[]) => mockFetchGlobalSettings(...args),
|
||||
}));
|
||||
|
||||
const mockIsOnboardingCompleted = vi.fn();
|
||||
const mockIsPostOnboardingDismissed = vi.fn();
|
||||
const mockDismissPostOnboardingRecommendations = vi.fn();
|
||||
|
||||
vi.mock("../model-onboarding-state", () => ({
|
||||
isOnboardingCompleted: (...args: unknown[]) => mockIsOnboardingCompleted(...args),
|
||||
isPostOnboardingDismissed: (...args: unknown[]) => mockIsPostOnboardingDismissed(...args),
|
||||
dismissPostOnboardingRecommendations: (...args: unknown[]) => mockDismissPostOnboardingRecommendations(...args),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
X: () => <span data-testid="icon-x">X</span>,
|
||||
Lightbulb: () => <span data-testid="icon-lightbulb">Lightbulb</span>,
|
||||
CheckCircle: () => <span data-testid="icon-check-circle">CheckCircle</span>,
|
||||
AlertCircle: () => <span data-testid="icon-alert-circle">AlertCircle</span>,
|
||||
Key: () => <span data-testid="icon-key">Key</span>,
|
||||
GitPullRequest: () => <span data-testid="icon-git-pull-request">GitPullRequest</span>,
|
||||
Zap: () => <span data-testid="icon-zap">Zap</span>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("PostOnboardingRecommendations", () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
const onOpenModelOnboarding = vi.fn();
|
||||
|
||||
const renderComponent = () => render(
|
||||
<PostOnboardingRecommendations
|
||||
onOpenSettings={onOpenSettings}
|
||||
onOpenModelOnboarding={onOpenModelOnboarding}
|
||||
/>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsOnboardingCompleted.mockReturnValue(true);
|
||||
mockIsPostOnboardingDismissed.mockReturnValue(false);
|
||||
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
||||
{ id: "github", name: "GitHub", authenticated: true },
|
||||
],
|
||||
});
|
||||
|
||||
mockFetchGlobalSettings.mockResolvedValue({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders nothing when onboarding is not completed", () => {
|
||||
mockIsOnboardingCompleted.mockReturnValue(false);
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(mockFetchAuthStatus).not.toHaveBeenCalled();
|
||||
expect(mockFetchGlobalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing when post-onboarding is dismissed", () => {
|
||||
mockIsPostOnboardingDismissed.mockReturnValue(true);
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(mockFetchAuthStatus).not.toHaveBeenCalled();
|
||||
expect(mockFetchGlobalSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing when all setup items are complete", async () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders Connect AI Provider recommendation when no AI provider is authenticated", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||
{ id: "github", name: "GitHub", authenticated: true },
|
||||
],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText("Connect AI Provider")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Set Up AI" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Select Default Model recommendation when no default model is set", async () => {
|
||||
mockFetchGlobalSettings.mockResolvedValue({});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText("Select Default Model")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Choose Model" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Connect GitHub recommendation when GitHub exists but is not authenticated", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
||||
{ id: "github", name: "GitHub", authenticated: false },
|
||||
],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText("Connect GitHub to import issues and track pull requests")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect GitHub" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple recommendations when multiple setup items are incomplete", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||
{ id: "github", name: "GitHub", authenticated: false },
|
||||
],
|
||||
});
|
||||
mockFetchGlobalSettings.mockResolvedValue({});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText("Connect AI Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select Default Model")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Connect GitHub").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("clicking Set Up AI calls onOpenModelOnboarding", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||
{ id: "github", name: "GitHub", authenticated: true },
|
||||
],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const button = await screen.findByRole("button", { name: "Set Up AI" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onOpenModelOnboarding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clicking Choose Model calls onOpenSettings with global-models", async () => {
|
||||
mockFetchGlobalSettings.mockResolvedValue({});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const button = await screen.findByRole("button", { name: "Choose Model" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onOpenSettings).toHaveBeenCalledWith("global-models");
|
||||
});
|
||||
|
||||
it("clicking Connect GitHub calls onOpenSettings with authentication", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
||||
{ id: "github", name: "GitHub", authenticated: false },
|
||||
],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const button = await screen.findByRole("button", { name: "Connect GitHub" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onOpenSettings).toHaveBeenCalledWith("authentication");
|
||||
});
|
||||
|
||||
it("clicking dismiss calls dismissPostOnboardingRecommendations", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||
{ id: "github", name: "GitHub", authenticated: true },
|
||||
],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const dismissButton = await screen.findByRole("button", { name: "Dismiss recommendations" });
|
||||
fireEvent.click(dismissButton);
|
||||
|
||||
expect(mockDismissPostOnboardingRecommendations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null on API error", async () => {
|
||||
mockFetchAuthStatus.mockRejectedValue(new Error("network failure"));
|
||||
|
||||
const { container } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
getOnboardingState,
|
||||
saveOnboardingState,
|
||||
clearOnboardingState,
|
||||
dismissPostOnboardingRecommendations,
|
||||
isPostOnboardingDismissed,
|
||||
clearPostOnboardingDismissal,
|
||||
isOnboardingResumable,
|
||||
getOnboardingResumeStep,
|
||||
markOnboardingCompleted,
|
||||
@@ -357,6 +360,37 @@ describe("model-onboarding-state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-onboarding dismissal", () => {
|
||||
it("dismissPostOnboardingRecommendations sets postOnboardingDismissedAt", () => {
|
||||
saveOnboardingState("complete", { completed: true });
|
||||
|
||||
dismissPostOnboardingRecommendations();
|
||||
|
||||
const stored = mockStore[STORAGE_KEY];
|
||||
const parsed = JSON.parse(stored);
|
||||
expect(parsed.postOnboardingDismissedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("isPostOnboardingDismissed returns true after dismissal", () => {
|
||||
dismissPostOnboardingRecommendations();
|
||||
expect(isPostOnboardingDismissed()).toBe(true);
|
||||
});
|
||||
|
||||
it("isPostOnboardingDismissed returns false when not dismissed", () => {
|
||||
saveOnboardingState("complete", { completed: true });
|
||||
expect(isPostOnboardingDismissed()).toBe(false);
|
||||
});
|
||||
|
||||
it("clearPostOnboardingDismissal removes the dismissal field", () => {
|
||||
dismissPostOnboardingRecommendations();
|
||||
clearPostOnboardingDismissal();
|
||||
|
||||
const state = getOnboardingState();
|
||||
expect(state?.postOnboardingDismissedAt).toBeUndefined();
|
||||
expect(isPostOnboardingDismissed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOnboardingCompleted", () => {
|
||||
it("returns false when no state exists", () => {
|
||||
expect(isOnboardingCompleted()).toBe(false);
|
||||
|
||||
@@ -22,6 +22,8 @@ interface OnboardingState {
|
||||
stepData: Partial<Record<OnboardingStep, Record<string, unknown>>>;
|
||||
/** Legacy field: ISO-8601 timestamp when onboarding was marked complete */
|
||||
completedAt?: string;
|
||||
/** ISO-8601 timestamp when post-onboarding recommendations were dismissed */
|
||||
postOnboardingDismissedAt?: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "fusion_model_onboarding_state";
|
||||
@@ -34,6 +36,7 @@ const DEFAULT_SKIPPED_STEPS: OnboardingStep[] = [];
|
||||
const DEFAULT_DISMISSED = false;
|
||||
const DEFAULT_COMPLETED = false;
|
||||
const DEFAULT_STEP_DATA: Partial<Record<OnboardingStep, Record<string, unknown>>> = {};
|
||||
const DEFAULT_POST_ONBOARDING_DISMISSED_AT: string | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Step labels for display in the resume card.
|
||||
@@ -79,6 +82,8 @@ export function getOnboardingState(): OnboardingState | null {
|
||||
* Apply default values for backward compatibility with partial/legacy state objects.
|
||||
*/
|
||||
function applyStateDefaults(state: OnboardingState): OnboardingState {
|
||||
const postOnboardingDismissedAt = state.postOnboardingDismissedAt ?? DEFAULT_POST_ONBOARDING_DISMISSED_AT;
|
||||
|
||||
return {
|
||||
...state,
|
||||
completedSteps: state.completedSteps ?? DEFAULT_COMPLETED_STEPS,
|
||||
@@ -86,6 +91,7 @@ function applyStateDefaults(state: OnboardingState): OnboardingState {
|
||||
dismissed: state.dismissed ?? DEFAULT_DISMISSED,
|
||||
completed: state.completed ?? DEFAULT_COMPLETED,
|
||||
stepData: state.stepData ?? DEFAULT_STEP_DATA,
|
||||
...(postOnboardingDismissedAt ? { postOnboardingDismissedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -371,6 +377,71 @@ export function isOnboardingCompleted(): boolean {
|
||||
return typeof state.completedAt === "string" && state.completedAt.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the post-onboarding recommendations as dismissed.
|
||||
*/
|
||||
export function dismissPostOnboardingRecommendations(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const existing = getOnboardingState();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const state: OnboardingState = existing
|
||||
? {
|
||||
...existing,
|
||||
updatedAt: now,
|
||||
postOnboardingDismissedAt: now,
|
||||
}
|
||||
: {
|
||||
currentStep: "complete",
|
||||
updatedAt: now,
|
||||
completedSteps: DEFAULT_COMPLETED_STEPS,
|
||||
skippedSteps: DEFAULT_SKIPPED_STEPS,
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: DEFAULT_STEP_DATA,
|
||||
postOnboardingDismissedAt: now,
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Storage quota exceeded or private browsing - fail silently
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether post-onboarding recommendations were dismissed.
|
||||
*/
|
||||
export function isPostOnboardingDismissed(): boolean {
|
||||
const state = getOnboardingState();
|
||||
if (!state) return false;
|
||||
|
||||
return typeof state.postOnboardingDismissedAt === "string" && state.postOnboardingDismissedAt.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove persisted post-onboarding dismissal state.
|
||||
*/
|
||||
export function clearPostOnboardingDismissal(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const existing = getOnboardingState();
|
||||
if (!existing) return;
|
||||
|
||||
const { postOnboardingDismissedAt: _postOnboardingDismissedAt, ...stateWithoutDismissal } = existing;
|
||||
const state: OnboardingState = {
|
||||
...stateWithoutDismissal,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// Storage quota exceeded or private browsing - fail silently
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ISO-8601 timestamp when onboarding was marked complete.
|
||||
* Returns null if onboarding has not been completed or no state exists.
|
||||
|
||||
Reference in New Issue
Block a user