mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(onboarding): collect install method and friction area in the usage survey (#499)
Adds install method and hardest-setup-area as two optional questions to the first-login usage survey, so these fields are collected at onboarding scale instead of only via the low-traffic admin installer card. Both stay null until chosen and are omitted from the payload when unanswered. No API change and no new i18n; reuses the shared feedback schema and existing locale strings.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { FEEDBACK_FRICTION_AREA_VALUES, FEEDBACK_INSTALL_METHOD_VALUES } from "@snapotter/shared";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -18,7 +19,9 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
|||||||
import { apiGet, apiPut } from "@/lib/api";
|
import { apiGet, apiPut } from "@/lib/api";
|
||||||
import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes";
|
import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes";
|
||||||
import {
|
import {
|
||||||
|
type FeedbackFrictionArea,
|
||||||
type FeedbackImportantArea,
|
type FeedbackImportantArea,
|
||||||
|
type FeedbackInstallMethod,
|
||||||
type FeedbackUsageType,
|
type FeedbackUsageType,
|
||||||
promptVariantForSource,
|
promptVariantForSource,
|
||||||
shouldShowUsageSurvey,
|
shouldShowUsageSurvey,
|
||||||
@@ -61,6 +64,10 @@ export function UsageSurveyOverlay() {
|
|||||||
const [settings, setSettings] = useState<Record<string, string> | null>(null);
|
const [settings, setSettings] = useState<Record<string, string> | null>(null);
|
||||||
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
|
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
|
||||||
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
|
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
|
||||||
|
// Install method and friction area are optional: null until the admin picks one,
|
||||||
|
// so we never record an unanswered dropdown as a real value.
|
||||||
|
const [installMethod, setInstallMethod] = useState<FeedbackInstallMethod | null>(null);
|
||||||
|
const [frictionArea, setFrictionArea] = useState<FeedbackFrictionArea | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [dismissing, setDismissing] = useState(false);
|
const [dismissing, setDismissing] = useState(false);
|
||||||
const busy = submitting || dismissing;
|
const busy = submitting || dismissing;
|
||||||
@@ -110,7 +117,12 @@ export function UsageSurveyOverlay() {
|
|||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
if (!usageType || busy) return;
|
if (!usageType || busy) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const answerKey = JSON.stringify({ usageType, importantAreas: [...importantAreas].sort() });
|
const answerKey = JSON.stringify({
|
||||||
|
usageType,
|
||||||
|
importantAreas: [...importantAreas].sort(),
|
||||||
|
installMethod,
|
||||||
|
frictionArea,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
if (submittedAnswerKeyRef.current !== answerKey) {
|
if (submittedAnswerKeyRef.current !== answerKey) {
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
@@ -120,6 +132,8 @@ export function UsageSurveyOverlay() {
|
|||||||
promptVariant: promptVariantForSource("onboarding"),
|
promptVariant: promptVariantForSource("onboarding"),
|
||||||
usageType,
|
usageType,
|
||||||
importantAreas,
|
importantAreas,
|
||||||
|
...(installMethod ? { installMethod } : {}),
|
||||||
|
...(frictionArea ? { frictionArea } : {}),
|
||||||
}),
|
}),
|
||||||
WRITE_TIMEOUT_MS,
|
WRITE_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
@@ -162,7 +176,7 @@ export function UsageSurveyOverlay() {
|
|||||||
aria-labelledby="usage-survey-title"
|
aria-labelledby="usage-survey-title"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background p-4"
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6 max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||||
<div className="flex flex-col items-center text-center gap-3">
|
<div className="flex flex-col items-center text-center gap-3">
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -236,6 +250,60 @@ export function UsageSurveyOverlay() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p id="usage-survey-install-label" className="text-sm font-medium text-foreground">
|
||||||
|
{t.feedback.installMethodLabel}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-labelledby="usage-survey-install-label"
|
||||||
|
className="grid grid-cols-2 gap-2"
|
||||||
|
>
|
||||||
|
{FEEDBACK_INSTALL_METHOD_VALUES.map((value) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: styled button acting as an ARIA radio, not a native input
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={installMethod === value}
|
||||||
|
onClick={() => setInstallMethod((current) => (current === value ? null : value))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
|
||||||
|
installMethod === value
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-border text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.feedback.installMethods[value]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="usage-survey-friction-area"
|
||||||
|
className="block text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{t.feedback.frictionAreaLabel}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="usage-survey-friction-area"
|
||||||
|
value={frictionArea ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFrictionArea((event.target.value || null) as FeedbackFrictionArea | null)
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="" />
|
||||||
|
{FEEDBACK_FRICTION_AREA_VALUES.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{t.feedback.frictionAreas[value]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -106,6 +106,33 @@ describe("UsageSurveyOverlay", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes install method and friction area when the admin selects them", async () => {
|
||||||
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
|
||||||
|
renderOverlay();
|
||||||
|
await screen.findByText("How are you using SnapOtter?");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Built from source" }));
|
||||||
|
fireEvent.change(screen.getByLabelText("Hardest setup area"), {
|
||||||
|
target: { value: "docker" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitFeedback).toHaveBeenCalledWith({
|
||||||
|
source: "onboarding",
|
||||||
|
surveyId: "onboarding-usage-v1",
|
||||||
|
promptVariant: "onboarding-overlay-v1",
|
||||||
|
usageType: "personal",
|
||||||
|
importantAreas: [],
|
||||||
|
installMethod: "source",
|
||||||
|
frictionArea: "docker",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not resubmit feedback if only the settings write failed on the first attempt", async () => {
|
it("does not resubmit feedback if only the settings write failed on the first attempt", async () => {
|
||||||
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
apiGet.mockResolvedValue({ settings: {} });
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
|||||||
Reference in New Issue
Block a user