feat: add usage onboarding survey overlay (#388)

* feat: add usage-survey feedback types and gating function

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* feat: add onboarding usage-survey i18n strings to all locales

Relabels three ambiguous feedback.usageTypes values (personal/team_internal/
business_workflow) and adds a new onboarding namespace (4 keys) across the
reference locale and all 20 translations, so the tree compiles at every
commit instead of only after both locale groups land.

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* feat: add UsageSurveyOverlay component

* feat: mount UsageSurveyOverlay inside AuthGuard

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* fix: use text-start instead of text-left for RTL support in UsageSurveyOverlay

* refactor: drop redundant usage-type field from the admin feedback dialog

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* feat: accept onboarding source and survey id in the feedback route

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* test: cover the onboarding source in the feedback route integration test

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* chore: remove orphaned usageTypeLabel i18n key

* refactor: derive feedback source/survey_id enums from a single source of truth

* perf: skip the settings fetch in UsageSurveyOverlay for non-admin users
This commit is contained in:
SnapOtter
2026-07-02 00:02:24 +08:00
committed by GitHub
parent 9052da27f3
commit a0d1c70172
31 changed files with 674 additions and 168 deletions
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
const apiGet = vi.hoisted(() => vi.fn());
const apiPut = vi.hoisted(() => vi.fn().mockResolvedValue({}));
const useAuth = vi.hoisted(() => vi.fn());
vi.mock("@/lib/feedback", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, submitFeedback };
});
vi.mock("@/lib/api", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, apiGet, apiPut };
});
vi.mock("@/hooks/use-auth", () => ({ useAuth }));
vi.mock("@/stores/analytics-store", () => ({
useAnalyticsStore: (
selector: (state: { config: { enabled: boolean }; configLoaded: boolean }) => unknown,
) => selector({ config: { enabled: true }, configLoaded: true }),
}));
import { UsageSurveyOverlay } from "@/components/onboarding/usage-survey-overlay";
afterEach(() => {
cleanup();
submitFeedback.mockClear();
apiGet.mockClear();
apiPut.mockClear();
useAuth.mockReset();
});
describe("UsageSurveyOverlay", () => {
it("renders nothing for a non-admin", () => {
useAuth.mockReturnValue({ role: "user" });
render(<UsageSurveyOverlay />);
expect(apiGet).not.toHaveBeenCalled();
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("renders nothing once already answered or dismissed", async () => {
useAuth.mockReturnValue({ role: "admin" });
apiGet.mockResolvedValue({
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" },
});
render(<UsageSurveyOverlay />);
await waitFor(() => expect(apiGet).toHaveBeenCalled());
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("shows both questions for an admin instance that hasn't answered", async () => {
useAuth.mockReturnValue({ role: "admin" });
apiGet.mockResolvedValue({ settings: {} });
render(<UsageSurveyOverlay />);
expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined();
expect(screen.getByText("What matters most to you?")).toBeDefined();
expect(screen.getByRole("button", { name: /Just me/ })).toBeDefined();
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
});
it("submits the selected answers and records the settings key", async () => {
useAuth.mockReturnValue({ role: "admin" });
apiGet.mockResolvedValue({ settings: {} });
render(<UsageSurveyOverlay />);
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("button", { name: /Small team/ }));
fireEvent.click(screen.getByRole("button", { name: /Images/ }));
fireEvent.click(screen.getByRole("button", { name: /PDF\/docs/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(submitFeedback).toHaveBeenCalledWith({
source: "onboarding",
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1",
usageType: "team_internal",
importantAreas: ["images", "pdf_docs"],
});
});
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.answeredAt": expect.any(String),
});
});
it("dismissing writes the dismiss key without submitting feedback", async () => {
useAuth.mockReturnValue({ role: "admin" });
apiGet.mockResolvedValue({ settings: {} });
render(<UsageSurveyOverlay />);
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
await waitFor(() => {
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.dismissedAt": expect.any(String),
});
});
expect(submitFeedback).not.toHaveBeenCalled();
});
});