mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: request a tool when home search finds nothing (#385)
Adds a prefilled 'Request a tool' affordance to the home search empty state and beneath weak results. Opens the in-app feedback dialog with a new search_miss source and a structured search_query when analytics is on; links to a prefilled GitHub Discussions (Ideas) post when off, so a request is never silently dropped. Reuses the existing feedback pipe, dialog, and analytics gate; no new storage. i18n across all 21 locales.
This commit is contained in:
@@ -8,8 +8,13 @@ import { analyticsEnabled, bakedEnabled } from "./analytics-gate.js";
|
|||||||
let posthogClient: PostHog | null = null;
|
let posthogClient: PostHog | null = null;
|
||||||
|
|
||||||
export interface FeedbackEventProperties {
|
export interface FeedbackEventProperties {
|
||||||
source: "global" | "tool_result" | "failed_job" | "admin_installer";
|
source: "global" | "tool_result" | "failed_job" | "admin_installer" | "search_miss";
|
||||||
survey_id?: "global-feedback-v1" | "tool-result-v1" | "failed-job-v1" | "admin-install-v1";
|
survey_id?:
|
||||||
|
| "global-feedback-v1"
|
||||||
|
| "tool-result-v1"
|
||||||
|
| "failed-job-v1"
|
||||||
|
| "admin-install-v1"
|
||||||
|
| "search-miss-v1";
|
||||||
prompt_variant?: string;
|
prompt_variant?: string;
|
||||||
sentiment?: "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
|
sentiment?: "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
|
||||||
feedback_type?: "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
|
feedback_type?: "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
|
||||||
@@ -19,6 +24,7 @@ export interface FeedbackEventProperties {
|
|||||||
contact_name?: string;
|
contact_name?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
tool_id?: string;
|
tool_id?: string;
|
||||||
|
search_query?: string;
|
||||||
job_status?: "completed" | "failed";
|
job_status?: "completed" | "failed";
|
||||||
install_method?: "docker" | "docker_compose" | "source" | "cloud" | "other";
|
install_method?: "docker" | "docker_compose" | "source" | "cloud" | "other";
|
||||||
usage_type?: "personal" | "team_internal" | "business_workflow" | "education" | "evaluating";
|
usage_type?: "personal" | "team_internal" | "business_workflow" | "education" | "evaluating";
|
||||||
@@ -128,6 +134,9 @@ function cleanFeedbackProperties(properties: FeedbackEventProperties): Record<st
|
|||||||
copyString("contact_name");
|
copyString("contact_name");
|
||||||
copyString("company");
|
copyString("company");
|
||||||
copyString("tool_id");
|
copyString("tool_id");
|
||||||
|
// Intentional: this is the user-typed query from a missing-tool feature request,
|
||||||
|
// not the tool-telemetry "search query" that analytics-allowlist.ts never forwards.
|
||||||
|
copyString("search_query");
|
||||||
copyString("job_status");
|
copyString("job_status");
|
||||||
copyString("install_method");
|
copyString("install_method");
|
||||||
copyString("usage_type");
|
copyString("usage_type");
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import { captureFeedback, type FeedbackEventProperties } from "../lib/analytics.
|
|||||||
import { analyticsEnabled } from "../lib/analytics-gate.js";
|
import { analyticsEnabled } from "../lib/analytics-gate.js";
|
||||||
import { requireAuth } from "../plugins/auth.js";
|
import { requireAuth } from "../plugins/auth.js";
|
||||||
|
|
||||||
const SOURCE_VALUES = ["global", "tool_result", "failed_job", "admin_installer"] as const;
|
const SOURCE_VALUES = [
|
||||||
|
"global",
|
||||||
|
"tool_result",
|
||||||
|
"failed_job",
|
||||||
|
"admin_installer",
|
||||||
|
"search_miss",
|
||||||
|
] as const;
|
||||||
const SURVEY_ID_VALUES = [
|
const SURVEY_ID_VALUES = [
|
||||||
"global-feedback-v1",
|
"global-feedback-v1",
|
||||||
"tool-result-v1",
|
"tool-result-v1",
|
||||||
"failed-job-v1",
|
"failed-job-v1",
|
||||||
"admin-install-v1",
|
"admin-install-v1",
|
||||||
|
"search-miss-v1",
|
||||||
] as const;
|
] as const;
|
||||||
const SENTIMENT_VALUES = ["great", "okay", "issue", "missing", "bug", "idea", "other"] as const;
|
const SENTIMENT_VALUES = ["great", "okay", "issue", "missing", "bug", "idea", "other"] as const;
|
||||||
const FEEDBACK_TYPE_VALUES = [
|
const FEEDBACK_TYPE_VALUES = [
|
||||||
@@ -93,6 +100,7 @@ const feedbackBodySchema = z
|
|||||||
contactName: optionalText(120),
|
contactName: optionalText(120),
|
||||||
company: optionalText(160),
|
company: optionalText(160),
|
||||||
toolId: toolIdSchema.optional(),
|
toolId: toolIdSchema.optional(),
|
||||||
|
searchQuery: optionalText(200),
|
||||||
jobStatus: z.enum(["completed", "failed"]).optional(),
|
jobStatus: z.enum(["completed", "failed"]).optional(),
|
||||||
installMethod: z.enum(INSTALL_METHOD_VALUES).optional(),
|
installMethod: z.enum(INSTALL_METHOD_VALUES).optional(),
|
||||||
usageType: z.enum(USAGE_TYPE_VALUES).optional(),
|
usageType: z.enum(USAGE_TYPE_VALUES).optional(),
|
||||||
@@ -103,7 +111,11 @@ const feedbackBodySchema = z
|
|||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
const hasText = Boolean(value.message?.trim());
|
const hasText = Boolean(value.message?.trim());
|
||||||
const hasChoice = Boolean(
|
const hasChoice = Boolean(
|
||||||
value.sentiment || value.feedbackType || value.installMethod || value.usageType,
|
value.sentiment ||
|
||||||
|
value.feedbackType ||
|
||||||
|
value.installMethod ||
|
||||||
|
value.usageType ||
|
||||||
|
value.searchQuery,
|
||||||
);
|
);
|
||||||
if (!hasText && !hasChoice) {
|
if (!hasText && !hasChoice) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -127,6 +139,7 @@ function toPostHogProperties(body: z.infer<typeof feedbackBodySchema>): Feedback
|
|||||||
contact_name: body.contactOk ? body.contactName || undefined : undefined,
|
contact_name: body.contactOk ? body.contactName || undefined : undefined,
|
||||||
company: body.contactOk ? body.company || undefined : undefined,
|
company: body.contactOk ? body.company || undefined : undefined,
|
||||||
tool_id: body.toolId,
|
tool_id: body.toolId,
|
||||||
|
search_query: body.searchQuery,
|
||||||
job_status: body.jobStatus,
|
job_status: body.jobStatus,
|
||||||
install_method: body.installMethod,
|
install_method: body.installMethod,
|
||||||
usage_type: body.usageType,
|
usage_type: body.usageType,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type FeedbackImportantArea,
|
type FeedbackImportantArea,
|
||||||
type FeedbackInstallMethod,
|
type FeedbackInstallMethod,
|
||||||
type FeedbackPayload,
|
type FeedbackPayload,
|
||||||
|
type FeedbackPromptVariant,
|
||||||
type FeedbackSentiment,
|
type FeedbackSentiment,
|
||||||
type FeedbackSource,
|
type FeedbackSource,
|
||||||
type FeedbackType,
|
type FeedbackType,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
submitFeedback,
|
submitFeedback,
|
||||||
surveyIdForSource,
|
surveyIdForSource,
|
||||||
} from "@/lib/feedback";
|
} from "@/lib/feedback";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
|
import { buildToolRequestDiscussionUrl } from "@/lib/tool-request";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface FeedbackDialogProps {
|
interface FeedbackDialogProps {
|
||||||
@@ -25,6 +28,8 @@ interface FeedbackDialogProps {
|
|||||||
jobStatus?: "completed" | "failed";
|
jobStatus?: "completed" | "failed";
|
||||||
errorCategory?: FeedbackErrorCategory;
|
errorCategory?: FeedbackErrorCategory;
|
||||||
initialSentiment?: FeedbackSentiment;
|
initialSentiment?: FeedbackSentiment;
|
||||||
|
searchQuery?: string;
|
||||||
|
promptVariant?: FeedbackPromptVariant;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmitted?: () => void;
|
onSubmitted?: () => void;
|
||||||
}
|
}
|
||||||
@@ -79,6 +84,8 @@ export function FeedbackDialog({
|
|||||||
jobStatus,
|
jobStatus,
|
||||||
errorCategory,
|
errorCategory,
|
||||||
initialSentiment,
|
initialSentiment,
|
||||||
|
searchQuery,
|
||||||
|
promptVariant: promptVariantProp,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmitted,
|
onSubmitted,
|
||||||
}: FeedbackDialogProps) {
|
}: FeedbackDialogProps) {
|
||||||
@@ -97,6 +104,7 @@ export function FeedbackDialog({
|
|||||||
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
|
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [accepted, setAccepted] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useFocusTrap(dialogRef, open);
|
useFocusTrap(dialogRef, open);
|
||||||
@@ -104,7 +112,9 @@ export function FeedbackDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setSentiment(initialSentiment ?? "");
|
setSentiment(initialSentiment ?? "");
|
||||||
setFeedbackType(source === "failed_job" ? "bug" : "other");
|
setFeedbackType(
|
||||||
|
source === "failed_job" ? "bug" : source === "search_miss" ? "feature_request" : "other",
|
||||||
|
);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setContactOk(false);
|
setContactOk(false);
|
||||||
setContactEmail("");
|
setContactEmail("");
|
||||||
@@ -116,6 +126,7 @@ export function FeedbackDialog({
|
|||||||
setImportantAreas([]);
|
setImportantAreas([]);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setSubmitted(false);
|
setSubmitted(false);
|
||||||
|
setAccepted(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [open, initialSentiment, source]);
|
}, [open, initialSentiment, source]);
|
||||||
|
|
||||||
@@ -132,10 +143,12 @@ export function FeedbackDialog({
|
|||||||
if (source === "tool_result") return t.feedback.toolDialogTitle;
|
if (source === "tool_result") return t.feedback.toolDialogTitle;
|
||||||
if (source === "failed_job") return t.feedback.failedDialogTitle;
|
if (source === "failed_job") return t.feedback.failedDialogTitle;
|
||||||
if (source === "admin_installer") return t.feedback.adminDialogTitle;
|
if (source === "admin_installer") return t.feedback.adminDialogTitle;
|
||||||
|
if (source === "search_miss") return t.feedback.searchMissTitle;
|
||||||
return t.feedback.dialogTitle;
|
return t.feedback.dialogTitle;
|
||||||
}, [source, t.feedback]);
|
}, [source, t.feedback]);
|
||||||
|
|
||||||
const isAdminInstall = source === "admin_installer";
|
const isAdminInstall = source === "admin_installer";
|
||||||
|
const isSearchMiss = source === "search_miss";
|
||||||
const canSubmit = Boolean(
|
const canSubmit = Boolean(
|
||||||
message.trim() || sentiment || feedbackType !== "other" || isAdminInstall,
|
message.trim() || sentiment || feedbackType !== "other" || isAdminInstall,
|
||||||
);
|
);
|
||||||
@@ -155,7 +168,8 @@ export function FeedbackDialog({
|
|||||||
const payload: FeedbackPayload = {
|
const payload: FeedbackPayload = {
|
||||||
source,
|
source,
|
||||||
surveyId: surveyIdForSource(source),
|
surveyId: surveyIdForSource(source),
|
||||||
promptVariant: promptVariantForSource(source),
|
promptVariant: promptVariantProp ?? promptVariantForSource(source),
|
||||||
|
searchQuery: isSearchMiss ? searchQuery?.slice(0, 200) : undefined,
|
||||||
...(sentiment ? { sentiment } : {}),
|
...(sentiment ? { sentiment } : {}),
|
||||||
feedbackType,
|
feedbackType,
|
||||||
message: message.trim() || undefined,
|
message: message.trim() || undefined,
|
||||||
@@ -177,7 +191,8 @@ export function FeedbackDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitFeedback(payload);
|
const response = await submitFeedback(payload);
|
||||||
|
setAccepted(response.accepted);
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
onSubmitted?.();
|
onSubmitted?.();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -223,13 +238,26 @@ export function FeedbackDialog({
|
|||||||
|
|
||||||
{submitted ? (
|
{submitted ? (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
{isSearchMiss && !accepted ? (
|
||||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0 mt-0.5" />
|
<p className="text-sm text-foreground">
|
||||||
<div className="space-y-1">
|
<a
|
||||||
<p className="text-sm font-medium text-foreground">{t.feedback.thanksTitle}</p>
|
href={buildToolRequestDiscussionUrl(searchQuery ?? "")}
|
||||||
<p className="text-sm text-muted-foreground">{t.feedback.thanksDescription}</p>
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t.feedback.searchMissDiscussionsFallback}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{t.feedback.thanksTitle}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.feedback.thanksDescription}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -242,7 +270,11 @@ export function FeedbackDialog({
|
|||||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 space-y-5">
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 space-y-5">
|
||||||
<p className="text-sm text-muted-foreground">{t.feedback.privacyNote}</p>
|
<p className="text-sm text-muted-foreground">{t.feedback.privacyNote}</p>
|
||||||
|
|
||||||
{isAdminInstall ? (
|
{isSearchMiss ? (
|
||||||
|
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{format(t.feedback.searchMissContext, { query: searchQuery ?? "" })}
|
||||||
|
</div>
|
||||||
|
) : isAdminInstall ? (
|
||||||
<AdminInstallFields
|
<AdminInstallFields
|
||||||
installMethod={installMethod}
|
installMethod={installMethod}
|
||||||
setInstallMethod={setInstallMethod}
|
setInstallMethod={setInstallMethod}
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { apiPost } from "@/lib/api";
|
import { apiPost } from "@/lib/api";
|
||||||
|
|
||||||
export type FeedbackSource = "global" | "tool_result" | "failed_job" | "admin_installer";
|
export type FeedbackSource =
|
||||||
|
| "global"
|
||||||
|
| "tool_result"
|
||||||
|
| "failed_job"
|
||||||
|
| "admin_installer"
|
||||||
|
| "search_miss";
|
||||||
export type FeedbackSurveyId =
|
export type FeedbackSurveyId =
|
||||||
| "global-feedback-v1"
|
| "global-feedback-v1"
|
||||||
| "tool-result-v1"
|
| "tool-result-v1"
|
||||||
| "failed-job-v1"
|
| "failed-job-v1"
|
||||||
| "admin-install-v1";
|
| "admin-install-v1"
|
||||||
|
| "search-miss-v1";
|
||||||
export type FeedbackPromptVariant =
|
export type FeedbackPromptVariant =
|
||||||
| "nav-v1"
|
| "nav-v1"
|
||||||
| "inline-v1"
|
| "inline-v1"
|
||||||
| "failed-button-v1"
|
| "failed-button-v1"
|
||||||
| "settings-card-v1";
|
| "settings-card-v1"
|
||||||
|
| "search-empty-v1"
|
||||||
|
| "search-results-v1";
|
||||||
export type FeedbackSentiment = "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
|
export type FeedbackSentiment = "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
|
||||||
export type FeedbackType = "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
|
export type FeedbackType = "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
|
||||||
export type FeedbackInstallMethod = "docker" | "docker_compose" | "source" | "cloud" | "other";
|
export type FeedbackInstallMethod = "docker" | "docker_compose" | "source" | "cloud" | "other";
|
||||||
@@ -58,6 +66,7 @@ export interface FeedbackPayload {
|
|||||||
contactName?: string;
|
contactName?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
toolId?: string;
|
toolId?: string;
|
||||||
|
searchQuery?: string;
|
||||||
jobStatus?: "completed" | "failed";
|
jobStatus?: "completed" | "failed";
|
||||||
installMethod?: FeedbackInstallMethod;
|
installMethod?: FeedbackInstallMethod;
|
||||||
usageType?: FeedbackUsageType;
|
usageType?: FeedbackUsageType;
|
||||||
@@ -87,6 +96,8 @@ export function surveyIdForSource(source: FeedbackSource): FeedbackSurveyId {
|
|||||||
return "failed-job-v1";
|
return "failed-job-v1";
|
||||||
case "admin_installer":
|
case "admin_installer":
|
||||||
return "admin-install-v1";
|
return "admin-install-v1";
|
||||||
|
case "search_miss":
|
||||||
|
return "search-miss-v1";
|
||||||
case "global":
|
case "global":
|
||||||
return "global-feedback-v1";
|
return "global-feedback-v1";
|
||||||
}
|
}
|
||||||
@@ -100,6 +111,8 @@ export function promptVariantForSource(source: FeedbackSource): FeedbackPromptVa
|
|||||||
return "failed-button-v1";
|
return "failed-button-v1";
|
||||||
case "admin_installer":
|
case "admin_installer":
|
||||||
return "settings-card-v1";
|
return "settings-card-v1";
|
||||||
|
case "search_miss":
|
||||||
|
return "search-empty-v1";
|
||||||
case "global":
|
case "global":
|
||||||
return "nav-v1";
|
return "nav-v1";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const DISCUSSIONS_BASE = "https://github.com/snapotter-hq/snapotter/discussions/new";
|
||||||
|
const MAX_QUERY_LEN = 200;
|
||||||
|
|
||||||
|
/** Collapse whitespace/newlines and clamp length so the query is URL-safe. */
|
||||||
|
function sanitizeQuery(query: string): string {
|
||||||
|
return query.replace(/\s+/g, " ").trim().slice(0, MAX_QUERY_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a prefilled GitHub Discussions (Ideas) URL for a missing-tool request.
|
||||||
|
* The repo routes feature requests to Discussions/Ideas (blank issues are off),
|
||||||
|
* so this is the canonical target. `category=ideas` is the guaranteed floor;
|
||||||
|
* `title`/`body` are best-effort prefill.
|
||||||
|
*/
|
||||||
|
export function buildToolRequestDiscussionUrl(query: string): string {
|
||||||
|
const q = sanitizeQuery(query);
|
||||||
|
const title = `Tool request: ${q}`;
|
||||||
|
const body = [
|
||||||
|
`I searched SnapOtter for "${q}" and could not find a tool for it.`,
|
||||||
|
"",
|
||||||
|
"What I'm trying to do:",
|
||||||
|
"",
|
||||||
|
"(describe your use case)",
|
||||||
|
"",
|
||||||
|
"_Submitted from in-app search._",
|
||||||
|
].join("\n");
|
||||||
|
const params = new URLSearchParams({ category: "ideas", title, body });
|
||||||
|
return `${DISCUSSIONS_BASE}?${params.toString()}`;
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
import type { Tool } from "@snapotter/shared";
|
import type { Tool } from "@snapotter/shared";
|
||||||
import { ANALYTICS_EVENTS, CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
import { ANALYTICS_EVENTS, CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import { ChevronDown, Search, X } from "lucide-react";
|
import { ChevronDown, Plus, Search, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ToolCard } from "@/components/common/tool-card.js";
|
import { ToolCard } from "@/components/common/tool-card.js";
|
||||||
|
import { FeedbackDialog } from "@/components/feedback/feedback-dialog.js";
|
||||||
import { AppLayout } from "@/components/layout/app-layout.js";
|
import { AppLayout } from "@/components/layout/app-layout.js";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useFuseSearch } from "@/hooks/use-fuse-search.js";
|
import { useFuseSearch } from "@/hooks/use-fuse-search.js";
|
||||||
import { usePageTitle } from "@/hooks/use-page-title.js";
|
import { usePageTitle } from "@/hooks/use-page-title.js";
|
||||||
import { useRecentTools } from "@/hooks/use-recent-tools.js";
|
import { useRecentTools } from "@/hooks/use-recent-tools.js";
|
||||||
|
import type { FeedbackPromptVariant } from "@/lib/feedback.js";
|
||||||
import { format } from "@/lib/format.js";
|
import { format } from "@/lib/format.js";
|
||||||
import { ICON_MAP } from "@/lib/icon-map.js";
|
import { ICON_MAP } from "@/lib/icon-map.js";
|
||||||
import { getCategoryName, getToolName } from "@/lib/tool-i18n.js";
|
import { getCategoryName, getToolName } from "@/lib/tool-i18n.js";
|
||||||
|
import { buildToolRequestDiscussionUrl } from "@/lib/tool-request.js";
|
||||||
import { cn } from "@/lib/utils.js";
|
import { cn } from "@/lib/utils.js";
|
||||||
|
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
@@ -43,6 +47,17 @@ export function HomePage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { fetch: fetchSettings, disabledTools, experimentalEnabled, loaded } = useSettingsStore();
|
const { fetch: fetchSettings, disabledTools, experimentalEnabled, loaded } = useSettingsStore();
|
||||||
const recentToolIds = useRecentTools();
|
const recentToolIds = useRecentTools();
|
||||||
|
const analyticsConfig = useAnalyticsStore((s) => s.config);
|
||||||
|
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
|
||||||
|
const analyticsOn = analyticsConfigLoaded && analyticsConfig?.enabled === true;
|
||||||
|
const [requestOpen, setRequestOpen] = useState(false);
|
||||||
|
const [requestVariant, setRequestVariant] = useState<FeedbackPromptVariant>("search-empty-v1");
|
||||||
|
|
||||||
|
const openRequest = useCallback((variant: FeedbackPromptVariant) => {
|
||||||
|
setRequestVariant(variant);
|
||||||
|
setRequestOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -151,12 +166,26 @@ export function HomePage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{search ? (
|
{search ? (
|
||||||
<SearchResults results={searchResults} query={search} onClear={() => setSearch("")} />
|
<SearchResults
|
||||||
|
results={searchResults}
|
||||||
|
query={search}
|
||||||
|
onClear={() => setSearch("")}
|
||||||
|
analyticsOn={analyticsOn}
|
||||||
|
onRequest={openRequest}
|
||||||
|
/>
|
||||||
) : activeTab === "all" ? (
|
) : activeTab === "all" ? (
|
||||||
<AllTabContent recentTools={recentTools} visibleTools={visibleTools} />
|
<AllTabContent recentTools={recentTools} visibleTools={visibleTools} />
|
||||||
) : (
|
) : (
|
||||||
<CategoryGrid groupedTools={groupedTools} />
|
<CategoryGrid groupedTools={groupedTools} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FeedbackDialog
|
||||||
|
open={requestOpen}
|
||||||
|
source="search_miss"
|
||||||
|
searchQuery={search}
|
||||||
|
promptVariant={requestVariant}
|
||||||
|
onClose={() => setRequestOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
@@ -272,14 +301,66 @@ function ModalityTabs({
|
|||||||
|
|
||||||
// ── Search Results ───────────────────────────────────────────────
|
// ── Search Results ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RequestToolAffordance({
|
||||||
|
query,
|
||||||
|
variant,
|
||||||
|
analyticsOn,
|
||||||
|
onRequest,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
variant: "empty" | "below";
|
||||||
|
analyticsOn: boolean;
|
||||||
|
onRequest: (promptVariant: FeedbackPromptVariant) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label =
|
||||||
|
variant === "empty"
|
||||||
|
? format(t.homePage.requestToolCta, { query })
|
||||||
|
: t.homePage.requestToolBelowResults;
|
||||||
|
const promptVariant: FeedbackPromptVariant =
|
||||||
|
variant === "empty" ? "search-empty-v1" : "search-results-v1";
|
||||||
|
const className = "inline-flex items-center gap-1.5 text-sm text-primary hover:underline";
|
||||||
|
|
||||||
|
if (analyticsOn) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="request-tool"
|
||||||
|
onClick={() => onRequest(promptVariant)}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={buildToolRequestDiscussionUrl(query)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
data-testid="request-tool"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SearchResults({
|
function SearchResults({
|
||||||
results,
|
results,
|
||||||
query,
|
query,
|
||||||
onClear,
|
onClear,
|
||||||
|
analyticsOn,
|
||||||
|
onRequest,
|
||||||
}: {
|
}: {
|
||||||
results: Tool[];
|
results: Tool[];
|
||||||
query: string;
|
query: string;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
|
analyticsOn: boolean;
|
||||||
|
onRequest: (promptVariant: FeedbackPromptVariant) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -287,22 +368,36 @@ function SearchResults({
|
|||||||
return (
|
return (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<p className="text-muted-foreground">{format(t.homePage.noToolsMatch, { query })}</p>
|
<p className="text-muted-foreground">{format(t.homePage.noToolsMatch, { query })}</p>
|
||||||
<button
|
<div className="mt-4 flex flex-col items-center gap-3">
|
||||||
type="button"
|
<RequestToolAffordance
|
||||||
onClick={onClear}
|
query={query}
|
||||||
className="mt-3 text-sm text-primary hover:underline"
|
variant="empty"
|
||||||
>
|
analyticsOn={analyticsOn}
|
||||||
{t.homePage.clearSearch}
|
onRequest={onRequest}
|
||||||
</button>
|
/>
|
||||||
|
<button type="button" onClick={onClear} className="text-sm text-primary hover:underline">
|
||||||
|
{t.homePage.clearSearch}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
<div className="space-y-4">
|
||||||
{results.map((tool) => (
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
<ToolCard key={tool.id} tool={tool} variant="descriptive" showModalityBadge />
|
{results.map((tool) => (
|
||||||
))}
|
<ToolCard key={tool.id} tool={tool} variant="descriptive" showModalityBadge />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="pt-1 text-center">
|
||||||
|
<RequestToolAffordance
|
||||||
|
query={query}
|
||||||
|
variant="below"
|
||||||
|
analyticsOn={analyticsOn}
|
||||||
|
onRequest={onRequest}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const ar: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "طلب أداة",
|
||||||
|
searchMissContext: "لقد بحثت عن: {query}",
|
||||||
|
searchMissDiscussionsFallback: "تعذّر تسجيل ذلك. افتح طلبًا في Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2934,6 +2937,8 @@ export const ar: TranslationKeys = {
|
|||||||
gettingStarted: "البدء",
|
gettingStarted: "البدء",
|
||||||
noToolsMatch: "لا توجد أدوات تطابق '{query}'",
|
noToolsMatch: "لا توجد أدوات تطابق '{query}'",
|
||||||
clearSearch: "مسح البحث",
|
clearSearch: "مسح البحث",
|
||||||
|
requestToolCta: "اطلب '{query}'",
|
||||||
|
requestToolBelowResults: "لم تجده؟ اطلب أداة",
|
||||||
all: "الكل",
|
all: "الكل",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "الملفات",
|
data: "الملفات",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const de: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Tool anfragen",
|
||||||
|
searchMissContext: "Du hast gesucht nach: {query}",
|
||||||
|
searchMissDiscussionsFallback:
|
||||||
|
"Das konnten wir nicht speichern. Stelle eine Anfrage in Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2956,6 +2960,8 @@ export const de: TranslationKeys = {
|
|||||||
gettingStarted: "Erste Schritte",
|
gettingStarted: "Erste Schritte",
|
||||||
noToolsMatch: "Keine Werkzeuge für '{query}' gefunden",
|
noToolsMatch: "Keine Werkzeuge für '{query}' gefunden",
|
||||||
clearSearch: "Suche zurücksetzen",
|
clearSearch: "Suche zurücksetzen",
|
||||||
|
requestToolCta: "'{query}' anfragen",
|
||||||
|
requestToolBelowResults: "Nicht gefunden? Tool anfragen",
|
||||||
all: "Alle",
|
all: "Alle",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Dateien",
|
data: "Dateien",
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export const en = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Request a tool",
|
||||||
|
searchMissContext: "You searched for: {query}",
|
||||||
|
searchMissDiscussionsFallback: "We couldn't record that. Open a request in Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2900,6 +2903,8 @@ export const en = {
|
|||||||
gettingStarted: "Getting Started",
|
gettingStarted: "Getting Started",
|
||||||
noToolsMatch: "No tools match '{query}'",
|
noToolsMatch: "No tools match '{query}'",
|
||||||
clearSearch: "Clear search",
|
clearSearch: "Clear search",
|
||||||
|
requestToolCta: "Request '{query}'",
|
||||||
|
requestToolBelowResults: "Can't find it? Request a tool",
|
||||||
all: "All",
|
all: "All",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Files",
|
data: "Files",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const es: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Solicitar una herramienta",
|
||||||
|
searchMissContext: "Buscaste: {query}",
|
||||||
|
searchMissDiscussionsFallback: "No pudimos registrarlo. Abre una solicitud en Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2938,6 +2941,8 @@ export const es: TranslationKeys = {
|
|||||||
gettingStarted: "Primeros pasos",
|
gettingStarted: "Primeros pasos",
|
||||||
noToolsMatch: "Ninguna herramienta coincide con '{query}'",
|
noToolsMatch: "Ninguna herramienta coincide con '{query}'",
|
||||||
clearSearch: "Borrar búsqueda",
|
clearSearch: "Borrar búsqueda",
|
||||||
|
requestToolCta: "Solicitar '{query}'",
|
||||||
|
requestToolBelowResults: "¿No lo encuentras? Solicita una herramienta",
|
||||||
all: "Todas",
|
all: "Todas",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Archivos",
|
data: "Archivos",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const fr: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Demander un outil",
|
||||||
|
searchMissContext: "Vous avez recherché : {query}",
|
||||||
|
searchMissDiscussionsFallback:
|
||||||
|
"Nous n'avons pas pu l'enregistrer. Ouvrez une demande dans Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2961,6 +2965,8 @@ export const fr: TranslationKeys = {
|
|||||||
gettingStarted: "Pour commencer",
|
gettingStarted: "Pour commencer",
|
||||||
noToolsMatch: "Aucun outil ne correspond à « {query} »",
|
noToolsMatch: "Aucun outil ne correspond à « {query} »",
|
||||||
clearSearch: "Effacer la recherche",
|
clearSearch: "Effacer la recherche",
|
||||||
|
requestToolCta: "Demander '{query}'",
|
||||||
|
requestToolBelowResults: "Introuvable ? Demandez un outil",
|
||||||
all: "Tous",
|
all: "Tous",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Fichiers",
|
data: "Fichiers",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const hi: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "टूल का अनुरोध करें",
|
||||||
|
searchMissContext: "आपने खोजा: {query}",
|
||||||
|
searchMissDiscussionsFallback: "हम इसे रिकॉर्ड नहीं कर सके. Discussions में अनुरोध खोलें.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2766,6 +2769,8 @@ export const hi: TranslationKeys = {
|
|||||||
gettingStarted: "शुरू करें",
|
gettingStarted: "शुरू करें",
|
||||||
noToolsMatch: "'{query}' से कोई टूल मेल नहीं खाता",
|
noToolsMatch: "'{query}' से कोई टूल मेल नहीं खाता",
|
||||||
clearSearch: "खोज साफ़ करें",
|
clearSearch: "खोज साफ़ करें",
|
||||||
|
requestToolCta: "'{query}' का अनुरोध करें",
|
||||||
|
requestToolBelowResults: "नहीं मिला? कोई टूल अनुरोध करें",
|
||||||
all: "सभी",
|
all: "सभी",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "फ़ाइल्स",
|
data: "फ़ाइल्स",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const id: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Minta alat",
|
||||||
|
searchMissContext: "Anda mencari: {query}",
|
||||||
|
searchMissDiscussionsFallback: "Kami tidak dapat mencatatnya. Buka permintaan di Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2947,6 +2950,8 @@ export const id: TranslationKeys = {
|
|||||||
gettingStarted: "Memulai",
|
gettingStarted: "Memulai",
|
||||||
noToolsMatch: "Tidak ada alat yang cocok dengan '{query}'",
|
noToolsMatch: "Tidak ada alat yang cocok dengan '{query}'",
|
||||||
clearSearch: "Hapus pencarian",
|
clearSearch: "Hapus pencarian",
|
||||||
|
requestToolCta: "Minta '{query}'",
|
||||||
|
requestToolBelowResults: "Tidak menemukannya? Minta alat",
|
||||||
all: "Semua",
|
all: "Semua",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "File",
|
data: "File",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const it: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Richiedi uno strumento",
|
||||||
|
searchMissContext: "Hai cercato: {query}",
|
||||||
|
searchMissDiscussionsFallback:
|
||||||
|
"Non siamo riusciti a registrarlo. Apri una richiesta in Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2952,6 +2956,8 @@ export const it: TranslationKeys = {
|
|||||||
gettingStarted: "Per iniziare",
|
gettingStarted: "Per iniziare",
|
||||||
noToolsMatch: "Nessuno strumento corrisponde a '{query}'",
|
noToolsMatch: "Nessuno strumento corrisponde a '{query}'",
|
||||||
clearSearch: "Cancella ricerca",
|
clearSearch: "Cancella ricerca",
|
||||||
|
requestToolCta: "Richiedi '{query}'",
|
||||||
|
requestToolBelowResults: "Non lo trovi? Richiedi uno strumento",
|
||||||
all: "Tutti",
|
all: "Tutti",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "File",
|
data: "File",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const ja: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "ツールをリクエスト",
|
||||||
|
searchMissContext: "検索した内容: {query}",
|
||||||
|
searchMissDiscussionsFallback:
|
||||||
|
"記録できませんでした。Discussions でリクエストを開いてください。",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2903,6 +2907,8 @@ export const ja: TranslationKeys = {
|
|||||||
gettingStarted: "はじめに",
|
gettingStarted: "はじめに",
|
||||||
noToolsMatch: "'{query}' に一致するツールはありません",
|
noToolsMatch: "'{query}' に一致するツールはありません",
|
||||||
clearSearch: "検索をクリア",
|
clearSearch: "検索をクリア",
|
||||||
|
requestToolCta: "「{query}」をリクエスト",
|
||||||
|
requestToolBelowResults: "見つかりませんか?ツールをリクエスト",
|
||||||
all: "すべて",
|
all: "すべて",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "ファイル",
|
data: "ファイル",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const ko: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "도구 요청",
|
||||||
|
searchMissContext: "검색한 내용: {query}",
|
||||||
|
searchMissDiscussionsFallback: "기록하지 못했습니다. Discussions에서 요청을 열어 주세요.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2887,6 +2890,8 @@ export const ko: TranslationKeys = {
|
|||||||
gettingStarted: "시작하기",
|
gettingStarted: "시작하기",
|
||||||
noToolsMatch: "'{query}'에 일치하는 도구가 없습니다",
|
noToolsMatch: "'{query}'에 일치하는 도구가 없습니다",
|
||||||
clearSearch: "검색 지우기",
|
clearSearch: "검색 지우기",
|
||||||
|
requestToolCta: "'{query}' 요청",
|
||||||
|
requestToolBelowResults: "찾을 수 없나요? 도구 요청",
|
||||||
all: "전체",
|
all: "전체",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "파일",
|
data: "파일",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const nl: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Tool aanvragen",
|
||||||
|
searchMissContext: "Je zocht op: {query}",
|
||||||
|
searchMissDiscussionsFallback:
|
||||||
|
"We konden dit niet vastleggen. Open een verzoek in Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2953,6 +2957,8 @@ export const nl: TranslationKeys = {
|
|||||||
gettingStarted: "Aan de slag",
|
gettingStarted: "Aan de slag",
|
||||||
noToolsMatch: "Geen tools gevonden voor '{query}'",
|
noToolsMatch: "Geen tools gevonden voor '{query}'",
|
||||||
clearSearch: "Zoekopdracht wissen",
|
clearSearch: "Zoekopdracht wissen",
|
||||||
|
requestToolCta: "'{query}' aanvragen",
|
||||||
|
requestToolBelowResults: "Niet gevonden? Vraag een tool aan",
|
||||||
all: "Alles",
|
all: "Alles",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Bestanden",
|
data: "Bestanden",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const pl: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Poproś o narzędzie",
|
||||||
|
searchMissContext: "Szukano: {query}",
|
||||||
|
searchMissDiscussionsFallback: "Nie udało się tego zapisać. Otwórz prośbę w Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2952,6 +2955,8 @@ export const pl: TranslationKeys = {
|
|||||||
gettingStarted: "Pierwsze kroki",
|
gettingStarted: "Pierwsze kroki",
|
||||||
noToolsMatch: "Brak narzędzi pasujących do '{query}'",
|
noToolsMatch: "Brak narzędzi pasujących do '{query}'",
|
||||||
clearSearch: "Wyczyść wyszukiwanie",
|
clearSearch: "Wyczyść wyszukiwanie",
|
||||||
|
requestToolCta: "Poproś o '{query}'",
|
||||||
|
requestToolBelowResults: "Nie znajdujesz? Poproś o narzędzie",
|
||||||
all: "Wszystkie",
|
all: "Wszystkie",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Pliki",
|
data: "Pliki",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const ptBR: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Solicitar uma ferramenta",
|
||||||
|
searchMissContext: "Você pesquisou: {query}",
|
||||||
|
searchMissDiscussionsFallback:
|
||||||
|
"Não conseguimos registrar. Abra uma solicitação no Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2948,6 +2952,8 @@ export const ptBR: TranslationKeys = {
|
|||||||
gettingStarted: "Primeiros passos",
|
gettingStarted: "Primeiros passos",
|
||||||
noToolsMatch: "Nenhuma ferramenta corresponde a '{query}'",
|
noToolsMatch: "Nenhuma ferramenta corresponde a '{query}'",
|
||||||
clearSearch: "Limpar busca",
|
clearSearch: "Limpar busca",
|
||||||
|
requestToolCta: "Solicitar '{query}'",
|
||||||
|
requestToolBelowResults: "Não encontrou? Solicite uma ferramenta",
|
||||||
all: "Todas",
|
all: "Todas",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Arquivos",
|
data: "Arquivos",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const ru: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Запросить инструмент",
|
||||||
|
searchMissContext: "Вы искали: {query}",
|
||||||
|
searchMissDiscussionsFallback: "Не удалось записать. Откройте запрос в Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2948,6 +2951,8 @@ export const ru: TranslationKeys = {
|
|||||||
gettingStarted: "Начало работы",
|
gettingStarted: "Начало работы",
|
||||||
noToolsMatch: "Инструменты по запросу '{query}' не найдены",
|
noToolsMatch: "Инструменты по запросу '{query}' не найдены",
|
||||||
clearSearch: "Очистить поиск",
|
clearSearch: "Очистить поиск",
|
||||||
|
requestToolCta: "Запросить '{query}'",
|
||||||
|
requestToolBelowResults: "Не нашли? Запросите инструмент",
|
||||||
all: "Все",
|
all: "Все",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Файлы",
|
data: "Файлы",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const sv: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Begär ett verktyg",
|
||||||
|
searchMissContext: "Du sökte efter: {query}",
|
||||||
|
searchMissDiscussionsFallback:
|
||||||
|
"Vi kunde inte registrera det. Öppna en förfrågan i Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2945,6 +2949,8 @@ export const sv: TranslationKeys = {
|
|||||||
gettingStarted: "Kom igång",
|
gettingStarted: "Kom igång",
|
||||||
noToolsMatch: "Inga verktyg matchar '{query}'",
|
noToolsMatch: "Inga verktyg matchar '{query}'",
|
||||||
clearSearch: "Rensa sökning",
|
clearSearch: "Rensa sökning",
|
||||||
|
requestToolCta: "Begär '{query}'",
|
||||||
|
requestToolBelowResults: "Hittar du inte? Begär ett verktyg",
|
||||||
all: "Alla",
|
all: "Alla",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Filer",
|
data: "Filer",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const th: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "ขอเครื่องมือ",
|
||||||
|
searchMissContext: "คุณค้นหา: {query}",
|
||||||
|
searchMissDiscussionsFallback: "เราบันทึกไม่ได้ เปิดคำขอใน Discussions",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2919,6 +2922,8 @@ export const th: TranslationKeys = {
|
|||||||
gettingStarted: "เริ่มต้นใช้งาน",
|
gettingStarted: "เริ่มต้นใช้งาน",
|
||||||
noToolsMatch: "ไม่พบเครื่องมือที่ตรงกับ '{query}'",
|
noToolsMatch: "ไม่พบเครื่องมือที่ตรงกับ '{query}'",
|
||||||
clearSearch: "ล้างการค้นหา",
|
clearSearch: "ล้างการค้นหา",
|
||||||
|
requestToolCta: "ขอ '{query}'",
|
||||||
|
requestToolBelowResults: "ไม่พบใช่ไหม ขอเครื่องมือ",
|
||||||
all: "ทั้งหมด",
|
all: "ทั้งหมด",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "ไฟล์",
|
data: "ไฟล์",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const tr: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Araç iste",
|
||||||
|
searchMissContext: "Şunu aradın: {query}",
|
||||||
|
searchMissDiscussionsFallback: "Bunu kaydedemedik. Discussions'ta bir istek aç.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2950,6 +2953,8 @@ export const tr: TranslationKeys = {
|
|||||||
gettingStarted: "Başlarken",
|
gettingStarted: "Başlarken",
|
||||||
noToolsMatch: "'{query}' ile eşleşen araç yok",
|
noToolsMatch: "'{query}' ile eşleşen araç yok",
|
||||||
clearSearch: "Aramayı temizle",
|
clearSearch: "Aramayı temizle",
|
||||||
|
requestToolCta: "'{query}' iste",
|
||||||
|
requestToolBelowResults: "Bulamadın mı? Bir araç iste",
|
||||||
all: "Tümü",
|
all: "Tümü",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Dosyalar",
|
data: "Dosyalar",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const uk: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Запитати інструмент",
|
||||||
|
searchMissContext: "Ви шукали: {query}",
|
||||||
|
searchMissDiscussionsFallback: "Не вдалося записати. Відкрийте запит у Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2950,6 +2953,8 @@ export const uk: TranslationKeys = {
|
|||||||
gettingStarted: "Початок роботи",
|
gettingStarted: "Початок роботи",
|
||||||
noToolsMatch: 'Інструментів за запитом "{query}" не знайдено',
|
noToolsMatch: 'Інструментів за запитом "{query}" не знайдено',
|
||||||
clearSearch: "Очистити пошук",
|
clearSearch: "Очистити пошук",
|
||||||
|
requestToolCta: "Запитати '{query}'",
|
||||||
|
requestToolBelowResults: "Не знайшли? Запитайте інструмент",
|
||||||
all: "Усі",
|
all: "Усі",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Файли",
|
data: "Файли",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export const vi: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "Yêu cầu một công cụ",
|
||||||
|
searchMissContext: "Bạn đã tìm: {query}",
|
||||||
|
searchMissDiscussionsFallback:
|
||||||
|
"Chúng tôi không ghi lại được. Mở một yêu cầu trong Discussions.",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2946,6 +2950,8 @@ export const vi: TranslationKeys = {
|
|||||||
gettingStarted: "Bắt đầu",
|
gettingStarted: "Bắt đầu",
|
||||||
noToolsMatch: "Không tìm thấy công cụ '{query}'",
|
noToolsMatch: "Không tìm thấy công cụ '{query}'",
|
||||||
clearSearch: "Xóa tìm kiếm",
|
clearSearch: "Xóa tìm kiếm",
|
||||||
|
requestToolCta: "Yêu cầu '{query}'",
|
||||||
|
requestToolBelowResults: "Không tìm thấy? Yêu cầu một công cụ",
|
||||||
all: "Tất cả",
|
all: "Tất cả",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "Tệp",
|
data: "Tệp",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const zhCN: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "请求工具",
|
||||||
|
searchMissContext: "你搜索了:{query}",
|
||||||
|
searchMissDiscussionsFallback: "我们无法记录。请在 Discussions 中发起请求。",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2708,6 +2711,8 @@ export const zhCN: TranslationKeys = {
|
|||||||
gettingStarted: "开始使用",
|
gettingStarted: "开始使用",
|
||||||
noToolsMatch: "没有匹配 '{query}' 的工具",
|
noToolsMatch: "没有匹配 '{query}' 的工具",
|
||||||
clearSearch: "清除搜索",
|
clearSearch: "清除搜索",
|
||||||
|
requestToolCta: "请求 '{query}'",
|
||||||
|
requestToolBelowResults: "找不到?请求一个工具",
|
||||||
all: "全部",
|
all: "全部",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "文件",
|
data: "文件",
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const zhTW: TranslationKeys = {
|
|||||||
submit: "Send feedback",
|
submit: "Send feedback",
|
||||||
submitting: "Sending...",
|
submitting: "Sending...",
|
||||||
submitFailed: "Feedback could not be sent. Please try again.",
|
submitFailed: "Feedback could not be sent. Please try again.",
|
||||||
|
searchMissTitle: "請求工具",
|
||||||
|
searchMissContext: "你搜尋了:{query}",
|
||||||
|
searchMissDiscussionsFallback: "我們無法記錄。請在 Discussions 中發起請求。",
|
||||||
thanksTitle: "Thanks for the feedback.",
|
thanksTitle: "Thanks for the feedback.",
|
||||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||||
quickThanks: "Thanks for the signal.",
|
quickThanks: "Thanks for the signal.",
|
||||||
@@ -2706,6 +2709,8 @@ export const zhTW: TranslationKeys = {
|
|||||||
gettingStarted: "快速入門",
|
gettingStarted: "快速入門",
|
||||||
noToolsMatch: "找不到符合「{query}」的工具",
|
noToolsMatch: "找不到符合「{query}」的工具",
|
||||||
clearSearch: "清除搜尋",
|
clearSearch: "清除搜尋",
|
||||||
|
requestToolCta: "請求 '{query}'",
|
||||||
|
requestToolBelowResults: "找不到?請求一個工具",
|
||||||
all: "全部",
|
all: "全部",
|
||||||
documents: "PDF",
|
documents: "PDF",
|
||||||
data: "檔案",
|
data: "檔案",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("search-miss tool request", () => {
|
||||||
|
test("offers a request when a search finds nothing", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const searchInput = page.locator("[data-search-input]");
|
||||||
|
await expect(searchInput).toBeVisible();
|
||||||
|
await searchInput.fill("zzxqwv nonexistent capability");
|
||||||
|
|
||||||
|
const request = page.getByTestId("request-tool").first();
|
||||||
|
await expect(request).toBeVisible();
|
||||||
|
|
||||||
|
const href = await request.getAttribute("href");
|
||||||
|
if (href) {
|
||||||
|
expect(href).toContain("/discussions/new");
|
||||||
|
expect(href).toContain("category=ideas");
|
||||||
|
} else {
|
||||||
|
await request.click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText("Request a tool");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens the in-app request dialog when analytics is enabled", async ({ page }) => {
|
||||||
|
// Force the client to see analytics as enabled so the affordance renders as an
|
||||||
|
// in-app dialog trigger instead of a Discussions link. Merge with the real
|
||||||
|
// config response so unrelated fields stay intact.
|
||||||
|
await page.route("**/api/v1/config/analytics", async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const body = await response.json();
|
||||||
|
await route.fulfill({ json: { ...body, enabled: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const searchInput = page.locator("[data-search-input]");
|
||||||
|
await expect(searchInput).toBeVisible();
|
||||||
|
await searchInput.fill("zzxqwv nonexistent capability");
|
||||||
|
|
||||||
|
const request = page.getByTestId("request-tool").first();
|
||||||
|
await expect(request).toBeVisible();
|
||||||
|
// With analytics "on" the affordance is a button (no href) that opens the dialog.
|
||||||
|
await expect(request).not.toHaveAttribute("href", /.+/);
|
||||||
|
await request.click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText("Request a tool");
|
||||||
|
await expect(dialog).toContainText("zzxqwv nonexistent capability");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -226,4 +226,78 @@ describe("POST /api/v1/feedback", () => {
|
|||||||
expect.objectContaining({ path: "frictionArea" }),
|
expect.objectContaining({ path: "frictionArea" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a search_miss tool request and forwards the search query", async () => {
|
||||||
|
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||||
|
await refreshAnalyticsGate();
|
||||||
|
const token = await loginAsAdmin(testApp.app);
|
||||||
|
|
||||||
|
const res = await testApp.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/feedback",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {
|
||||||
|
source: "search_miss",
|
||||||
|
surveyId: "search-miss-v1",
|
||||||
|
promptVariant: "search-empty-v1",
|
||||||
|
feedbackType: "feature_request",
|
||||||
|
searchQuery: "convert to dicom",
|
||||||
|
message: "Radiology workflow.",
|
||||||
|
contactOk: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: true });
|
||||||
|
expect(captureFeedback).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
source: "search_miss",
|
||||||
|
survey_id: "search-miss-v1",
|
||||||
|
prompt_variant: "search-empty-v1",
|
||||||
|
feedback_type: "feature_request",
|
||||||
|
search_query: "convert to dicom",
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bare search_miss query with no message or rating", async () => {
|
||||||
|
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||||
|
await refreshAnalyticsGate();
|
||||||
|
const token = await loginAsAdmin(testApp.app);
|
||||||
|
|
||||||
|
const res = await testApp.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/feedback",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {
|
||||||
|
source: "search_miss",
|
||||||
|
surveyId: "search-miss-v1",
|
||||||
|
searchQuery: "make animated gif",
|
||||||
|
contactOk: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declines search_miss capture when analytics is disabled", async () => {
|
||||||
|
const token = await loginAsAdmin(testApp.app);
|
||||||
|
const res = await testApp.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/feedback",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {
|
||||||
|
source: "search_miss",
|
||||||
|
surveyId: "search-miss-v1",
|
||||||
|
searchQuery: "convert to dicom",
|
||||||
|
contactOk: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: false });
|
||||||
|
expect(captureFeedback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -268,6 +268,36 @@ describe("captureFeedback", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards search_query for a search_miss request", async () => {
|
||||||
|
bakedConfig.enabled = true;
|
||||||
|
bakedConfig.posthogApiKey = "phc_test_key";
|
||||||
|
await mod.initAnalytics();
|
||||||
|
|
||||||
|
await mod.captureFeedback(
|
||||||
|
{
|
||||||
|
source: "search_miss",
|
||||||
|
survey_id: "search-miss-v1",
|
||||||
|
prompt_variant: "search-empty-v1",
|
||||||
|
feedback_type: "feature_request",
|
||||||
|
search_query: "convert to dicom",
|
||||||
|
contact_ok: false,
|
||||||
|
},
|
||||||
|
"distinct-search-miss",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCapture).toHaveBeenCalledWith({
|
||||||
|
distinctId: "distinct-search-miss",
|
||||||
|
event: "feedback_submitted",
|
||||||
|
properties: expect.objectContaining({
|
||||||
|
source: "search_miss",
|
||||||
|
survey_id: "search-miss-v1",
|
||||||
|
prompt_variant: "search-empty-v1",
|
||||||
|
feedback_type: "feature_request",
|
||||||
|
search_query: "convert to dicom",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does nothing when analytics is disabled", async () => {
|
it("does nothing when analytics is disabled", async () => {
|
||||||
bakedConfig.enabled = false;
|
bakedConfig.enabled = false;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { promptVariantForSource, surveyIdForSource } from "@/lib/feedback";
|
||||||
|
|
||||||
|
describe("feedback search_miss mappings", () => {
|
||||||
|
it("maps the search_miss source to its survey id", () => {
|
||||||
|
expect(surveyIdForSource("search_miss")).toBe("search-miss-v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the search_miss prompt variant to the empty-results entry point", () => {
|
||||||
|
expect(promptVariantForSource("search_miss")).toBe("search-empty-v1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildToolRequestDiscussionUrl } from "@/lib/tool-request";
|
||||||
|
|
||||||
|
describe("buildToolRequestDiscussionUrl", () => {
|
||||||
|
it("targets the Ideas discussions category on the SnapOtter repo", () => {
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl("convert to dicom"));
|
||||||
|
expect(`${url.origin}${url.pathname}`).toBe(
|
||||||
|
"https://github.com/snapotter-hq/snapotter/discussions/new",
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get("category")).toBe("ideas");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embeds the query in the title and body", () => {
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl("convert to dicom"));
|
||||||
|
expect(url.searchParams.get("title")).toContain("convert to dicom");
|
||||||
|
expect(url.searchParams.get("body")).toContain("convert to dicom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps an over-long query to 200 characters", () => {
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl("a".repeat(500)));
|
||||||
|
expect(url.searchParams.get("title")).toBe(`Tool request: ${"a".repeat(200)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses newlines in the query", () => {
|
||||||
|
const url = new URL(buildToolRequestDiscussionUrl("line one\nline two"));
|
||||||
|
expect(url.searchParams.get("title")).toBe("Tool request: line one line two");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user