feat: add PostHog customer feedback

This commit is contained in:
SnapOtter
2026-06-29 18:16:33 +08:00
parent 6f85b3d12a
commit 649e65b035
47 changed files with 3937 additions and 4 deletions
+59
View File
@@ -221,3 +221,62 @@ describe("trackEvent", () => {
await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined();
});
});
describe("captureFeedback", () => {
it("captures feedback_submitted with explicit feedback properties", async () => {
bakedConfig.enabled = true;
bakedConfig.posthogApiKey = "phc_test_key";
await mod.initAnalytics();
await mod.captureFeedback(
{
source: "admin_installer",
survey_id: "admin-install-v1",
prompt_variant: "settings-card-v1",
sentiment: "issue",
feedback_type: "bug",
message: "Docs need a complete S3 example.",
contact_ok: true,
contact_email: "admin@example.com",
contact_name: "Pat",
company: "Example Co",
install_method: "docker_compose",
usage_type: "team_internal",
friction_area: "environment_variables",
important_areas: ["pdf_docs", "batch_workflows"],
error_category: "processing_error",
},
"distinct-feedback",
);
expect(mockCapture).toHaveBeenCalledWith({
distinctId: "distinct-feedback",
event: "feedback_submitted",
properties: expect.objectContaining({
feedback_version: 1,
source: "admin_installer",
survey_id: "admin-install-v1",
prompt_variant: "settings-card-v1",
contact_ok: true,
contact_email: "admin@example.com",
install_method: "docker_compose",
usage_type: "team_internal",
friction_area: "environment_variables",
important_areas: ["pdf_docs", "batch_workflows"],
error_category: "processing_error",
}),
});
});
it("does nothing when analytics is disabled", async () => {
bakedConfig.enabled = false;
await mod.captureFeedback({
source: "global",
contact_ok: false,
message: "A message",
});
expect(mockCapture).not.toHaveBeenCalled();
});
});
+7 -2
View File
@@ -2,8 +2,8 @@ import { ANALYTICS_EVENTS } from "@snapotter/shared";
import { describe, expect, it } from "vitest";
describe("ANALYTICS_EVENTS", () => {
it("has exactly 12 event keys", () => {
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(12);
it("has exactly 13 event keys", () => {
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(13);
});
it("contains the expected keys", () => {
@@ -19,6 +19,7 @@ describe("ANALYTICS_EVENTS", () => {
expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_ACTION");
expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_PROMPTED");
expect(ANALYTICS_EVENTS).toHaveProperty("BATCH_PROCESSED");
expect(ANALYTICS_EVENTS).toHaveProperty("FEEDBACK_SUBMITTED");
});
it("all event values are strings", () => {
@@ -43,6 +44,10 @@ describe("ANALYTICS_EVENTS", () => {
expect(ANALYTICS_EVENTS.AI_BUNDLE_ACTION).toBe("ai_bundle_action");
});
it("FEEDBACK_SUBMITTED has the correct snake_case value", () => {
expect(ANALYTICS_EVENTS.FEEDBACK_SUBMITTED).toBe("feedback_submitted");
});
it("all values follow snake_case convention", () => {
for (const value of Object.values(ANALYTICS_EVENTS)) {
expect(value).toMatch(/^[a-z][a-z0-9_]*$/);
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AdminInstallFeedbackCard } from "@/components/feedback/admin-install-feedback-card";
afterEach(() => {
cleanup();
});
describe("AdminInstallFeedbackCard", () => {
it("does not render when hidden", () => {
render(
<AdminInstallFeedbackCard
visible={false}
onShare={vi.fn()}
onRemindLater={vi.fn()}
onDismissForever={vi.fn()}
/>,
);
expect(screen.queryByText("How was setup?")).toBeNull();
});
it("renders quiet admin install feedback actions", () => {
render(
<AdminInstallFeedbackCard
visible={true}
onShare={vi.fn()}
onRemindLater={vi.fn()}
onDismissForever={vi.fn()}
/>,
);
expect(screen.getByText("How was setup?")).toBeDefined();
expect(screen.getByRole("button", { name: "Share feedback" })).toBeDefined();
expect(screen.getByRole("button", { name: "Remind me later" })).toBeDefined();
expect(screen.getByRole("button", { name: "Don't ask again" })).toBeDefined();
});
it("calls the supplied action callbacks", () => {
const onShare = vi.fn();
const onRemindLater = vi.fn();
const onDismissForever = vi.fn();
render(
<AdminInstallFeedbackCard
visible={true}
onShare={onShare}
onRemindLater={onRemindLater}
onDismissForever={onDismissForever}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Share feedback" }));
fireEvent.click(screen.getByRole("button", { name: "Remind me later" }));
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
expect(onShare).toHaveBeenCalledTimes(1);
expect(onRemindLater).toHaveBeenCalledTimes(1);
expect(onDismissForever).toHaveBeenCalledTimes(1);
});
});
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
vi.mock("@/lib/feedback", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return {
...actual,
submitFeedback,
};
});
afterEach(() => {
cleanup();
submitFeedback.mockClear();
});
describe("FeedbackDialog", () => {
it("submits admin install feedback with install-specific fields", async () => {
render(<FeedbackDialog open={true} source="admin_installer" onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Install method"), {
target: { value: "docker_compose" },
});
fireEvent.change(screen.getByLabelText("Use case"), {
target: { value: "team_internal" },
});
fireEvent.change(screen.getByLabelText("Hardest setup area"), {
target: { value: "environment_variables" },
});
fireEvent.click(screen.getByLabelText("PDF/docs"));
fireEvent.click(screen.getByLabelText("Batch workflows"));
fireEvent.change(screen.getByLabelText("What should we improve first?"), {
target: { value: "The S3 example needs one complete compose snippet." },
});
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
await waitFor(() => {
expect(submitFeedback).toHaveBeenCalledWith({
source: "admin_installer",
surveyId: "admin-install-v1",
promptVariant: "settings-card-v1",
feedbackType: "other",
message: "The S3 example needs one complete compose snippet.",
contactOk: false,
contactEmail: undefined,
contactName: undefined,
company: undefined,
installMethod: "docker_compose",
usageType: "team_internal",
frictionArea: "environment_variables",
importantAreas: ["pdf_docs", "batch_workflows"],
});
});
});
it("does not submit contact fields without contact consent", async () => {
render(<FeedbackDialog open={true} source="global" onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Feedback"), {
target: { value: "I want a keyboard shortcut for download." },
});
expect(screen.queryByPlaceholderText("Email (optional)")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
await waitFor(() => {
expect(submitFeedback).toHaveBeenCalledWith(
expect.objectContaining({
source: "global",
surveyId: "global-feedback-v1",
contactOk: false,
contactEmail: undefined,
contactName: undefined,
company: undefined,
}),
);
});
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { shouldShowInstallFeedbackCard } from "@/lib/feedback";
const NOW = new Date("2026-01-15T00:00:00Z").getTime();
describe("shouldShowInstallFeedbackCard", () => {
it("shows only for admins after analytics config is loaded and enabled", () => {
expect(
shouldShowInstallFeedbackCard({
settings: {},
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
now: NOW,
}),
).toBe(true);
expect(
shouldShowInstallFeedbackCard({
settings: {},
role: "user",
analyticsConfigLoaded: true,
analyticsEnabled: true,
now: NOW,
}),
).toBe(false);
expect(
shouldShowInstallFeedbackCard({
settings: {},
role: "admin",
analyticsConfigLoaded: false,
analyticsEnabled: true,
now: NOW,
}),
).toBe(false);
expect(
shouldShowInstallFeedbackCard({
settings: {},
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: false,
now: NOW,
}),
).toBe(false);
});
it("stays hidden after submit or permanent dismiss", () => {
expect(
shouldShowInstallFeedbackCard({
settings: { "feedback.install.submittedAt": "2026-01-14T00:00:00Z" },
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
now: NOW,
}),
).toBe(false);
expect(
shouldShowInstallFeedbackCard({
settings: { "feedback.install.dismissedAt": "2026-01-14T00:00:00Z" },
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
now: NOW,
}),
).toBe(false);
});
it("honors snooze until the stored timestamp expires", () => {
expect(
shouldShowInstallFeedbackCard({
settings: { "feedback.install.snoozedUntil": "2026-01-16T00:00:00Z" },
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
now: NOW,
}),
).toBe(false);
expect(
shouldShowInstallFeedbackCard({
settings: { "feedback.install.snoozedUntil": "2026-01-14T00:00:00Z" },
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
now: NOW,
}),
).toBe(true);
});
});
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToolFeedbackPrompt } from "@/components/feedback/tool-feedback-prompt";
import { useAnalyticsStore } from "@/stores/analytics-store";
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
const storageMap = vi.hoisted(() => new Map<string, string>());
const localStorageMock = vi.hoisted(() => ({
getItem: vi.fn((key: string) => storageMap.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => storageMap.set(key, value)),
removeItem: vi.fn((key: string) => storageMap.delete(key)),
clear: vi.fn(() => storageMap.clear()),
key: vi.fn((_index: number) => null),
get length() {
return storageMap.size;
},
}));
vi.mock("@/lib/feedback", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return {
...actual,
submitFeedback,
};
});
beforeEach(() => {
vi.stubGlobal("localStorage", localStorageMock);
localStorage.clear();
submitFeedback.mockClear();
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
localStorageMock.removeItem.mockClear();
localStorageMock.clear.mockClear();
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-01-15T00:00:00Z").getTime());
useAnalyticsStore.setState({
configLoaded: true,
config: {
enabled: true,
posthogApiKey: "phc_test",
posthogHost: "https://us.i.posthog.com",
sentryDsn: "",
sampleRate: 1,
instanceId: "instance-1",
},
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("ToolFeedbackPrompt", () => {
it("renders when analytics feedback capture is enabled", () => {
render(<ToolFeedbackPrompt toolId="resize" />);
expect(screen.getByText("How did this tool work?")).toBeDefined();
expect(screen.getByRole("button", { name: "Worked well" })).toBeDefined();
});
it("does not render when analytics is disabled", () => {
useAnalyticsStore.setState({
configLoaded: true,
config: {
enabled: false,
posthogApiKey: "",
posthogHost: "",
sentryDsn: "",
sampleRate: 0,
instanceId: "",
},
});
render(<ToolFeedbackPrompt toolId="resize" />);
expect(screen.queryByText("How did this tool work?")).toBeNull();
});
it("submits quick positive feedback with survey and prompt metadata", async () => {
render(<ToolFeedbackPrompt toolId="resize" />);
fireEvent.click(screen.getByRole("button", { name: "Worked well" }));
await waitFor(() => {
expect(submitFeedback).toHaveBeenCalledWith({
source: "tool_result",
surveyId: "tool-result-v1",
promptVariant: "inline-v1",
sentiment: "great",
feedbackType: "other",
toolId: "resize",
jobStatus: "completed",
});
});
expect(screen.getByText("Thanks for the signal.")).toBeDefined();
expect(localStorage.getItem("snapotter-feedback-last-prompt-at")).toBeTruthy();
expect(localStorage.getItem("snapotter-feedback-tool-prompt:resize")).toBeTruthy();
});
it("is suppressed during the 30-day global cooldown", () => {
localStorage.setItem(
"snapotter-feedback-last-prompt-at",
String(Date.now() - 29 * 24 * 60 * 60 * 1000),
);
render(<ToolFeedbackPrompt toolId="resize" />);
expect(screen.queryByText("How did this tool work?")).toBeNull();
});
it("is suppressed during the 90-day per-tool cooldown", () => {
localStorage.setItem(
"snapotter-feedback-tool-prompt:resize",
String(Date.now() - 89 * 24 * 60 * 60 * 1000),
);
render(<ToolFeedbackPrompt toolId="resize" />);
expect(screen.queryByText("How did this tool work?")).toBeNull();
});
it("shows after the global and per-tool cooldowns have both elapsed", () => {
localStorage.setItem(
"snapotter-feedback-last-prompt-at",
String(Date.now() - 31 * 24 * 60 * 60 * 1000),
);
localStorage.setItem(
"snapotter-feedback-tool-prompt:resize",
String(Date.now() - 91 * 24 * 60 * 60 * 1000),
);
render(<ToolFeedbackPrompt toolId="resize" />);
expect(screen.getByText("How did this tool work?")).toBeDefined();
});
it("supports Don't ask again suppression", () => {
const { unmount } = render(<ToolFeedbackPrompt toolId="resize" />);
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
expect(localStorage.getItem("snapotter-feedback-prompts-disabled")).toBe("true");
expect(screen.queryByText("How did this tool work?")).toBeNull();
unmount();
render(<ToolFeedbackPrompt toolId="convert" />);
expect(screen.queryByText("How did this tool work?")).toBeNull();
});
});