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 {
|
||||
Building2,
|
||||
FileText,
|
||||
@@ -18,7 +19,9 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { apiGet, apiPut } from "@/lib/api";
|
||||
import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes";
|
||||
import {
|
||||
type FeedbackFrictionArea,
|
||||
type FeedbackImportantArea,
|
||||
type FeedbackInstallMethod,
|
||||
type FeedbackUsageType,
|
||||
promptVariantForSource,
|
||||
shouldShowUsageSurvey,
|
||||
@@ -61,6 +64,10 @@ export function UsageSurveyOverlay() {
|
||||
const [settings, setSettings] = useState<Record<string, string> | null>(null);
|
||||
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
|
||||
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 [dismissing, setDismissing] = useState(false);
|
||||
const busy = submitting || dismissing;
|
||||
@@ -110,7 +117,12 @@ export function UsageSurveyOverlay() {
|
||||
async function handleContinue() {
|
||||
if (!usageType || busy) return;
|
||||
setSubmitting(true);
|
||||
const answerKey = JSON.stringify({ usageType, importantAreas: [...importantAreas].sort() });
|
||||
const answerKey = JSON.stringify({
|
||||
usageType,
|
||||
importantAreas: [...importantAreas].sort(),
|
||||
installMethod,
|
||||
frictionArea,
|
||||
});
|
||||
try {
|
||||
if (submittedAnswerKeyRef.current !== answerKey) {
|
||||
await withTimeout(
|
||||
@@ -120,6 +132,8 @@ export function UsageSurveyOverlay() {
|
||||
promptVariant: promptVariantForSource("onboarding"),
|
||||
usageType,
|
||||
importantAreas,
|
||||
...(installMethod ? { installMethod } : {}),
|
||||
...(frictionArea ? { frictionArea } : {}),
|
||||
}),
|
||||
WRITE_TIMEOUT_MS,
|
||||
);
|
||||
@@ -162,7 +176,7 @@ export function UsageSurveyOverlay() {
|
||||
aria-labelledby="usage-survey-title"
|
||||
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
|
||||
aria-hidden="true"
|
||||
@@ -236,6 +250,60 @@ export function UsageSurveyOverlay() {
|
||||
</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">
|
||||
<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 () => {
|
||||
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||
apiGet.mockResolvedValue({ settings: {} });
|
||||
|
||||
Reference in New Issue
Block a user