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,6 +8,7 @@ import {
|
||||
type FeedbackImportantArea,
|
||||
type FeedbackInstallMethod,
|
||||
type FeedbackPayload,
|
||||
type FeedbackPromptVariant,
|
||||
type FeedbackSentiment,
|
||||
type FeedbackSource,
|
||||
type FeedbackType,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
submitFeedback,
|
||||
surveyIdForSource,
|
||||
} from "@/lib/feedback";
|
||||
import { format } from "@/lib/format";
|
||||
import { buildToolRequestDiscussionUrl } from "@/lib/tool-request";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
@@ -25,6 +28,8 @@ interface FeedbackDialogProps {
|
||||
jobStatus?: "completed" | "failed";
|
||||
errorCategory?: FeedbackErrorCategory;
|
||||
initialSentiment?: FeedbackSentiment;
|
||||
searchQuery?: string;
|
||||
promptVariant?: FeedbackPromptVariant;
|
||||
onClose: () => void;
|
||||
onSubmitted?: () => void;
|
||||
}
|
||||
@@ -79,6 +84,8 @@ export function FeedbackDialog({
|
||||
jobStatus,
|
||||
errorCategory,
|
||||
initialSentiment,
|
||||
searchQuery,
|
||||
promptVariant: promptVariantProp,
|
||||
onClose,
|
||||
onSubmitted,
|
||||
}: FeedbackDialogProps) {
|
||||
@@ -97,6 +104,7 @@ export function FeedbackDialog({
|
||||
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [accepted, setAccepted] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useFocusTrap(dialogRef, open);
|
||||
@@ -104,7 +112,9 @@ export function FeedbackDialog({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSentiment(initialSentiment ?? "");
|
||||
setFeedbackType(source === "failed_job" ? "bug" : "other");
|
||||
setFeedbackType(
|
||||
source === "failed_job" ? "bug" : source === "search_miss" ? "feature_request" : "other",
|
||||
);
|
||||
setMessage("");
|
||||
setContactOk(false);
|
||||
setContactEmail("");
|
||||
@@ -116,6 +126,7 @@ export function FeedbackDialog({
|
||||
setImportantAreas([]);
|
||||
setSubmitting(false);
|
||||
setSubmitted(false);
|
||||
setAccepted(true);
|
||||
setError(null);
|
||||
}, [open, initialSentiment, source]);
|
||||
|
||||
@@ -132,10 +143,12 @@ export function FeedbackDialog({
|
||||
if (source === "tool_result") return t.feedback.toolDialogTitle;
|
||||
if (source === "failed_job") return t.feedback.failedDialogTitle;
|
||||
if (source === "admin_installer") return t.feedback.adminDialogTitle;
|
||||
if (source === "search_miss") return t.feedback.searchMissTitle;
|
||||
return t.feedback.dialogTitle;
|
||||
}, [source, t.feedback]);
|
||||
|
||||
const isAdminInstall = source === "admin_installer";
|
||||
const isSearchMiss = source === "search_miss";
|
||||
const canSubmit = Boolean(
|
||||
message.trim() || sentiment || feedbackType !== "other" || isAdminInstall,
|
||||
);
|
||||
@@ -155,7 +168,8 @@ export function FeedbackDialog({
|
||||
const payload: FeedbackPayload = {
|
||||
source,
|
||||
surveyId: surveyIdForSource(source),
|
||||
promptVariant: promptVariantForSource(source),
|
||||
promptVariant: promptVariantProp ?? promptVariantForSource(source),
|
||||
searchQuery: isSearchMiss ? searchQuery?.slice(0, 200) : undefined,
|
||||
...(sentiment ? { sentiment } : {}),
|
||||
feedbackType,
|
||||
message: message.trim() || undefined,
|
||||
@@ -177,7 +191,8 @@ export function FeedbackDialog({
|
||||
};
|
||||
|
||||
try {
|
||||
await submitFeedback(payload);
|
||||
const response = await submitFeedback(payload);
|
||||
setAccepted(response.accepted);
|
||||
setSubmitted(true);
|
||||
onSubmitted?.();
|
||||
} catch {
|
||||
@@ -223,13 +238,26 @@ export function FeedbackDialog({
|
||||
|
||||
{submitted ? (
|
||||
<div className="p-6 space-y-4">
|
||||
<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>
|
||||
{isSearchMiss && !accepted ? (
|
||||
<p className="text-sm text-foreground">
|
||||
<a
|
||||
href={buildToolRequestDiscussionUrl(searchQuery ?? "")}
|
||||
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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -242,7 +270,11 @@ export function FeedbackDialog({
|
||||
<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>
|
||||
|
||||
{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
|
||||
installMethod={installMethod}
|
||||
setInstallMethod={setInstallMethod}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
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 =
|
||||
| "global-feedback-v1"
|
||||
| "tool-result-v1"
|
||||
| "failed-job-v1"
|
||||
| "admin-install-v1";
|
||||
| "admin-install-v1"
|
||||
| "search-miss-v1";
|
||||
export type FeedbackPromptVariant =
|
||||
| "nav-v1"
|
||||
| "inline-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 FeedbackType = "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
|
||||
export type FeedbackInstallMethod = "docker" | "docker_compose" | "source" | "cloud" | "other";
|
||||
@@ -58,6 +66,7 @@ export interface FeedbackPayload {
|
||||
contactName?: string;
|
||||
company?: string;
|
||||
toolId?: string;
|
||||
searchQuery?: string;
|
||||
jobStatus?: "completed" | "failed";
|
||||
installMethod?: FeedbackInstallMethod;
|
||||
usageType?: FeedbackUsageType;
|
||||
@@ -87,6 +96,8 @@ export function surveyIdForSource(source: FeedbackSource): FeedbackSurveyId {
|
||||
return "failed-job-v1";
|
||||
case "admin_installer":
|
||||
return "admin-install-v1";
|
||||
case "search_miss":
|
||||
return "search-miss-v1";
|
||||
case "global":
|
||||
return "global-feedback-v1";
|
||||
}
|
||||
@@ -100,6 +111,8 @@ export function promptVariantForSource(source: FeedbackSource): FeedbackPromptVa
|
||||
return "failed-button-v1";
|
||||
case "admin_installer":
|
||||
return "settings-card-v1";
|
||||
case "search_miss":
|
||||
return "search-empty-v1";
|
||||
case "global":
|
||||
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 { 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 { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
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 { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFuseSearch } from "@/hooks/use-fuse-search.js";
|
||||
import { usePageTitle } from "@/hooks/use-page-title.js";
|
||||
import { useRecentTools } from "@/hooks/use-recent-tools.js";
|
||||
import type { FeedbackPromptVariant } from "@/lib/feedback.js";
|
||||
import { format } from "@/lib/format.js";
|
||||
import { ICON_MAP } from "@/lib/icon-map.js";
|
||||
import { getCategoryName, getToolName } from "@/lib/tool-i18n.js";
|
||||
import { buildToolRequestDiscussionUrl } from "@/lib/tool-request.js";
|
||||
import { cn } from "@/lib/utils.js";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface TabDef {
|
||||
@@ -43,6 +47,17 @@ export function HomePage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { fetch: fetchSettings, disabledTools, experimentalEnabled, loaded } = useSettingsStore();
|
||||
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 navigate = useNavigate();
|
||||
|
||||
@@ -151,12 +166,26 @@ export function HomePage() {
|
||||
/>
|
||||
|
||||
{search ? (
|
||||
<SearchResults results={searchResults} query={search} onClear={() => setSearch("")} />
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
query={search}
|
||||
onClear={() => setSearch("")}
|
||||
analyticsOn={analyticsOn}
|
||||
onRequest={openRequest}
|
||||
/>
|
||||
) : activeTab === "all" ? (
|
||||
<AllTabContent recentTools={recentTools} visibleTools={visibleTools} />
|
||||
) : (
|
||||
<CategoryGrid groupedTools={groupedTools} />
|
||||
)}
|
||||
|
||||
<FeedbackDialog
|
||||
open={requestOpen}
|
||||
source="search_miss"
|
||||
searchQuery={search}
|
||||
promptVariant={requestVariant}
|
||||
onClose={() => setRequestOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -272,14 +301,66 @@ function ModalityTabs({
|
||||
|
||||
// ── 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({
|
||||
results,
|
||||
query,
|
||||
onClear,
|
||||
analyticsOn,
|
||||
onRequest,
|
||||
}: {
|
||||
results: Tool[];
|
||||
query: string;
|
||||
onClear: () => void;
|
||||
analyticsOn: boolean;
|
||||
onRequest: (promptVariant: FeedbackPromptVariant) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -287,22 +368,36 @@ function SearchResults({
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground">{format(t.homePage.noToolsMatch, { query })}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="mt-3 text-sm text-primary hover:underline"
|
||||
>
|
||||
{t.homePage.clearSearch}
|
||||
</button>
|
||||
<div className="mt-4 flex flex-col items-center gap-3">
|
||||
<RequestToolAffordance
|
||||
query={query}
|
||||
variant="empty"
|
||||
analyticsOn={analyticsOn}
|
||||
onRequest={onRequest}
|
||||
/>
|
||||
<button type="button" onClick={onClear} className="text-sm text-primary hover:underline">
|
||||
{t.homePage.clearSearch}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{results.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} variant="descriptive" showModalityBadge />
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user