Skip to content

Commit 3946ed6

Browse files
feat(web): anonymous feedback panel in dashboard (#763)
Adds a "Send feedback" entry point in the sidebar and mobile-nav footers that opens a categorised dialog (UI/UX, bug, feature request, other) with an optional reply email. Works without an account. The self-hosted dashboard has no database or mail transport, so the local server relays each submission to the hosted feedback endpoint via a new /api/feedback route, tagged with source and the running server version. Relaying server-side keeps the web app on its local origin and avoids the hosted API's cross-origin restrictions.
1 parent f859c2e commit 3946ed6

8 files changed

Lines changed: 290 additions & 1 deletion

File tree

packages/api-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"./dead-code": "./src/dead-code.ts",
2222
"./decisions": "./src/decisions.ts",
2323
"./external-systems": "./src/external-systems.ts",
24+
"./feedback": "./src/feedback.ts",
2425
"./files": "./src/files.ts",
2526
"./git": "./src/git.ts",
2627
"./graph": "./src/graph.ts",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* In-app feedback. The web app POSTs to the local server's `/api/feedback`,
3+
* which forwards the message to the hosted Repowise backend (tagged as OSS).
4+
*/
5+
6+
import { apiPost } from "./client";
7+
8+
/** Feedback categories. Mirrors the server-side `_CATEGORIES` set. */
9+
export type FeedbackCategory = "ui_ux" | "bug" | "feature_request" | "other";
10+
11+
export interface FeedbackInput {
12+
category: FeedbackCategory;
13+
message: string;
14+
/** Optional reply-to address, so the maintainers can follow up. */
15+
email?: string;
16+
/** The dashboard page the feedback was sent from. */
17+
pageUrl?: string;
18+
}
19+
20+
export async function submitFeedback(input: FeedbackInput): Promise<{ ok: boolean }> {
21+
return apiPost<{ ok: boolean }>("/api/feedback", {
22+
category: input.category,
23+
message: input.message,
24+
...(input.email && { email: input.email }),
25+
...(input.pageUrl && { page_url: input.pageUrl }),
26+
});
27+
}

packages/server/src/repowise/server/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
dead_code,
4242
decisions,
4343
external_systems,
44+
feedback,
4445
files,
4546
git,
4647
graph,
@@ -447,5 +448,6 @@ async def bad_request_handler(request: Request, exc: ValueError) -> JSONResponse
447448
app.include_router(stats.router)
448449
app.include_router(files.router)
449450
app.include_router(external_systems.router)
451+
app.include_router(feedback.router)
450452

451453
return app
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""``/api/feedback``: forward in-app feedback from the OSS dashboard to the
2+
hosted Repowise backend.
3+
4+
The self-hosted dashboard has no database or mail transport of its own, so the
5+
local server acts as a thin, server-side relay: the browser POSTs here, and we
6+
forward the message to the hosted feedback endpoint (which persists it and
7+
emails the maintainers). The submission is tagged ``source="oss"`` and carries
8+
the running server version so OSS feedback is distinguishable from hosted, and
9+
triageable by version.
10+
11+
This is an explicit, user-initiated action (they typed a message and clicked
12+
send), so it is not gated on the telemetry opt-out. Forwarding server-side
13+
(rather than a direct browser call) keeps the web app talking only to its local
14+
origin and sidesteps cross-origin restrictions on the hosted API.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
21+
import httpx
22+
from fastapi import APIRouter, Depends, HTTPException
23+
from pydantic import BaseModel, Field
24+
25+
from repowise.server import __version__
26+
from repowise.server.deps import verify_api_key
27+
28+
logger = logging.getLogger(__name__)
29+
30+
router = APIRouter(prefix="/api/feedback", tags=["feedback"], dependencies=[Depends(verify_api_key)])
31+
32+
#: Hosted feedback sink. Persists the row and emails the maintainer list. No
33+
#: localhost override: feedback is only useful when it reaches the maintainers.
34+
_HOSTED_FEEDBACK_URL = "https://api.repowise.dev/feedback"
35+
36+
_CATEGORIES = frozenset({"ui_ux", "bug", "feature_request", "other"})
37+
38+
39+
class FeedbackRequest(BaseModel):
40+
category: str = Field(..., description="One of ui_ux, bug, feature_request, other")
41+
message: str = Field(..., min_length=1, max_length=4000)
42+
email: str | None = Field(default=None, max_length=320)
43+
page_url: str | None = Field(default=None, max_length=2048)
44+
45+
46+
class FeedbackResponse(BaseModel):
47+
ok: bool
48+
49+
50+
@router.post("", response_model=FeedbackResponse)
51+
async def submit_feedback(body: FeedbackRequest) -> FeedbackResponse:
52+
"""Relay one feedback message to the hosted backend, tagged as OSS."""
53+
category = body.category if body.category in _CATEGORIES else "other"
54+
payload = {
55+
"category": category,
56+
"message": body.message.strip(),
57+
"email": (body.email or "").strip() or None,
58+
"page_url": body.page_url,
59+
"source": "oss",
60+
"client_version": __version__,
61+
}
62+
63+
try:
64+
async with httpx.AsyncClient(timeout=10.0) as client:
65+
resp = await client.post(_HOSTED_FEEDBACK_URL, json=payload)
66+
resp.raise_for_status()
67+
except httpx.HTTPError as exc:
68+
logger.warning("feedback_forward_failed: %s", exc)
69+
raise HTTPException(
70+
status_code=502,
71+
detail="Couldn't reach the feedback service. Please try again.",
72+
) from exc
73+
74+
logger.info("feedback_forwarded category=%s has_email=%s", category, bool(payload["email"]))
75+
return FeedbackResponse(ok=True)
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { MessageSquarePlus } from "lucide-react";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogDescription,
9+
DialogFooter,
10+
DialogHeader,
11+
DialogTitle,
12+
} from "@repowise-dev/ui/ui/dialog";
13+
import { Button } from "@repowise-dev/ui/ui/button";
14+
import { toast } from "sonner";
15+
import { submitFeedback, type FeedbackCategory } from "@/lib/api/feedback";
16+
17+
const CATEGORIES: { value: FeedbackCategory; label: string }[] = [
18+
{ value: "ui_ux", label: "UI / UX" },
19+
{ value: "bug", label: "Bug" },
20+
{ value: "feature_request", label: "Feature request" },
21+
{ value: "other", label: "Other" },
22+
];
23+
24+
const MAX_LENGTH = 4000;
25+
26+
/**
27+
* Sidebar-footer feedback entry point for the self-hosted dashboard. Opens a
28+
* categorised dialog; submissions POST to the local server's `/api/feedback`,
29+
* which forwards them to the Repowise maintainers. Works without an account.
30+
*/
31+
export function FeedbackButton() {
32+
const [open, setOpen] = useState(false);
33+
const [category, setCategory] = useState<FeedbackCategory>("ui_ux");
34+
const [message, setMessage] = useState("");
35+
const [email, setEmail] = useState("");
36+
const [submitting, setSubmitting] = useState(false);
37+
38+
const reset = () => {
39+
setCategory("ui_ux");
40+
setMessage("");
41+
setEmail("");
42+
};
43+
44+
const handleOpenChange = (next: boolean) => {
45+
if (submitting) return;
46+
setOpen(next);
47+
if (!next) reset();
48+
};
49+
50+
const handleSubmit = async () => {
51+
const trimmed = message.trim();
52+
if (!trimmed) {
53+
toast.error("Please enter your feedback.");
54+
return;
55+
}
56+
setSubmitting(true);
57+
try {
58+
await submitFeedback({
59+
category,
60+
message: trimmed,
61+
...(email.trim() && { email: email.trim() }),
62+
...(typeof window !== "undefined" && { pageUrl: window.location.href }),
63+
});
64+
toast.success("Thanks for the feedback!");
65+
setOpen(false);
66+
reset();
67+
} catch {
68+
toast.error("Couldn't send feedback. Please try again.");
69+
} finally {
70+
setSubmitting(false);
71+
}
72+
};
73+
74+
return (
75+
<>
76+
<button
77+
type="button"
78+
onClick={() => setOpen(true)}
79+
aria-label="Send feedback"
80+
className="flex w-full items-center gap-1.5 text-xs text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-secondary)]"
81+
>
82+
<MessageSquarePlus className="h-3.5 w-3.5 shrink-0" />
83+
<span>Send feedback</span>
84+
</button>
85+
86+
<Dialog open={open} onOpenChange={handleOpenChange}>
87+
<DialogContent className="max-w-md">
88+
<DialogHeader>
89+
<DialogTitle>Send feedback</DialogTitle>
90+
<DialogDescription>
91+
Found a bug or have an idea? It goes straight to the maintainers. We read every
92+
message.
93+
</DialogDescription>
94+
</DialogHeader>
95+
96+
<div className="space-y-4">
97+
{/* Category */}
98+
<div>
99+
<label className="mb-1.5 block text-xs font-medium text-[var(--color-text-tertiary)]">
100+
Category
101+
</label>
102+
<div className="flex flex-wrap gap-2">
103+
{CATEGORIES.map((option) => (
104+
<button
105+
key={option.value}
106+
type="button"
107+
onClick={() => setCategory(option.value)}
108+
className={`cursor-pointer rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
109+
category === option.value
110+
? "border-[var(--color-accent-primary)] bg-[var(--color-accent-muted)] text-[var(--color-accent-primary)]"
111+
: "border-[var(--color-border-default)] text-[var(--color-text-secondary)] hover:border-[var(--color-text-tertiary)]"
112+
}`}
113+
>
114+
{option.label}
115+
</button>
116+
))}
117+
</div>
118+
</div>
119+
120+
{/* Message */}
121+
<div>
122+
<label
123+
htmlFor="feedback-message"
124+
className="mb-1.5 block text-xs font-medium text-[var(--color-text-tertiary)]"
125+
>
126+
Your feedback
127+
</label>
128+
<textarea
129+
id="feedback-message"
130+
rows={5}
131+
autoFocus
132+
maxLength={MAX_LENGTH}
133+
value={message}
134+
onChange={(e) => setMessage(e.target.value)}
135+
placeholder="Tell us what's working, what's broken, or what you'd love to see..."
136+
className="w-full resize-none rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-3 py-2.5 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-accent-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-primary)]"
137+
/>
138+
<p className="mt-1 text-right text-[11px] text-[var(--color-text-tertiary)]">
139+
{message.length}/{MAX_LENGTH}
140+
</p>
141+
</div>
142+
143+
{/* Optional email — so the maintainers can reply */}
144+
<div>
145+
<label
146+
htmlFor="feedback-email"
147+
className="mb-1.5 block text-xs font-medium text-[var(--color-text-tertiary)]"
148+
>
149+
Email{" "}
150+
<span className="font-normal text-[var(--color-text-tertiary)]">
151+
(optional, if you&apos;d like a reply)
152+
</span>
153+
</label>
154+
<input
155+
id="feedback-email"
156+
type="email"
157+
value={email}
158+
onChange={(e) => setEmail(e.target.value)}
159+
placeholder="you@example.com"
160+
className="w-full rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-accent-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-primary)]"
161+
/>
162+
</div>
163+
</div>
164+
165+
<DialogFooter>
166+
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
167+
Cancel
168+
</Button>
169+
<Button onClick={handleSubmit} disabled={submitting || !message.trim()}>
170+
{submitting ? "Sending…" : "Send feedback"}
171+
</Button>
172+
</DialogFooter>
173+
</DialogContent>
174+
</Dialog>
175+
</>
176+
);
177+
}

packages/web/src/components/layout/mobile-nav.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ScrollArea } from "@repowise-dev/ui/ui/scroll-area";
1717
import { Separator } from "@repowise-dev/ui/ui/separator";
1818
import { AddRepoDialog } from "@/components/repos/add-repo-dialog";
1919
import { VersionFooter } from "./version-footer";
20+
import { FeedbackButton } from "./feedback-button";
2021
import { cn } from "@/lib/utils/cn";
2122
import {
2223
GLOBAL_NAV,
@@ -241,7 +242,8 @@ export function MobileNav({ repos = [], workspace }: MobileNavProps) {
241242
</div>
242243
</ScrollArea>
243244

244-
<div className="border-t border-[var(--color-border-default)] px-4 py-3">
245+
<div className="flex flex-col gap-3 border-t border-[var(--color-border-default)] px-4 py-3">
246+
<FeedbackButton />
245247
<VersionFooter />
246248
</div>
247249
</SheetContent>

packages/web/src/components/layout/sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repowise-dev/ui/ui/too
2525
import { ThemeToggle } from "@repowise-dev/ui/shared/theme-toggle";
2626
import { AddRepoDialog } from "@/components/repos/add-repo-dialog";
2727
import { VersionFooter } from "./version-footer";
28+
import { FeedbackButton } from "./feedback-button";
2829
import type { RepoResponse, WorkspaceResponse } from "@/lib/api/types";
2930

3031
interface SidebarProps {
@@ -390,6 +391,7 @@ export function Sidebar({ repos = [], activeRepoId, workspace }: SidebarProps) {
390391
{!isIconOnly && (
391392
<div className="flex flex-col gap-3 border-t border-[var(--color-border-default)] px-4 py-3">
392393
<ThemeToggle className="w-full justify-between" />
394+
<FeedbackButton />
393395
<VersionFooter />
394396
</div>
395397
)}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import "./client";
2+
3+
export * from "@repowise-dev/api-client/feedback";

0 commit comments

Comments
 (0)