@plany/feedback-widget
v1.4.0
Published
Embeddable feedback widget for Plany: a bottom-right bubble that files issues into your Plany project
Readme
@plany/feedback-widget
Embeddable feedback widget for Plany: a bottom-right bubble that files reports straight into your Plany project's Issues.
Server-proxied by design — the browser widget carries no credential:
browser widget ──POST──▶ your backend ──POST + server key──▶ PlanyPlany gives you ONE server key (Issues → Embed widget). It lives in your server env only — never in page HTML, never in client bundles. Because submissions pass through your backend, you can gate them to signed-in users and stamp the reporter's identity server-side, making it non-spoofable.
1 · Backend: proxy endpoint
npm i @plany/feedback-widget// Next.js app/api/feedback/route.ts — any fetch-shaped handler works
// (Hono, Bun, Deno, Node 20+, edge runtimes).
import { proxyPlanyFeedback } from "@plany/feedback-widget";
export async function POST(req: Request) {
// Optional: gate to signed-in users and attest their identity.
// const session = await auth();
// if (!session) return new Response(null, { status: 401 });
return proxyPlanyFeedback(req, {
token: process.env.PLANY_FEEDBACK_KEY!,
api: "https://<deployment>.convex.site",
// name: session.user.name, // overrides whatever the browser sent —
// contact: session.user.email, // the identity is attested by YOUR server
// reporterId: session.user.id, // binds report history to the account
});
}2 · Frontend: mount the bubble
import { initPlanyFeedback } from "@plany/feedback-widget";
const widget = initPlanyFeedback({ endpoint: "/api/feedback" });
widget.setPosition({ bottom: 24, right: 24 }); // move an existing widget
widget.refreshContext(); // call after an SPA route change
widget.destroy(); // unmount (e.g. in a React effect cleanup)Or as a script tag (equally credential-free):
<script async src="https://plany.bielcrystal.app/widget.js" data-endpoint="/api/feedback"></script>Optional attributes / config:
data-lang="zh"/lang: "zh"— UI language (default: auto-detect)data-name/name— prefill the reporter name and hide the field (for an attested identity, set it inproxyPlanyFeedbackinstead);name: false/data-name="off"hides the field and sends nothingdata-contact/contact— same semantics for the contact fieldpageUrl: false— don't attach the current page URL (attached by default; the panel shows the exact URL that will be sent)data-screenshot="auto|prompt|off"/screenshot: "auto" | "prompt" | false— screenshot mode (defaultauto); screenshots are optionalrequest— a fetch-compatible function used for all host endpoint requests, useful for adding session authorizationhistoryNamespace— isolates local report history within an endpoint, for example by signed-in user idbottom/right— initial bubble offsets in CSS pixels (default20);zIndexcontrols its stacking contextonError(err)— observe failed submits
The panel lets users attach up to 5 files of any type, ≤20MB total. PNG/JPEG/GIF/WebP attachments get thumbnail previews; every other format is stored and served as a download so browser-executable files never render inline.
Page screenshot (recommended) is optional and remains a removable,
previewed attachment until the user hits send. In auto mode (default), the
widget renders the visible viewport from the DOM when the form opens, without
a browser prompt (cross-origin images and canvas/video content may be missing).
prompt captures only after the user clicks the button and uses the browser's
tab-share dialog (pixel-perfect, asks every time, hidden where unsupported);
off removes the button. Call widget.refreshContext() after SPA route changes
so the page hint and any automatic screenshot follow the current route.
My reports: anonymous status tracking
Every accepted report returns a per-issue claim token, which the widget
keeps in localStorage. A "My reports" view in the panel lists past reports
with a live status — received / in progress (with the assignee's display
name) / fixed / closed — and a per-report timeline. A red dot on the bubble
signals unseen updates (checked at most hourly).
This is capability auth, not accounts: presenting the token proves "I filed
this report" and unlocks nothing else. Only a hash is stored server-side;
clearing browser storage forgets the history. Status queries reuse the same
proxy endpoint (a JSON POST with a claims array), so hosts already running
proxyPlanyFeedback get all of this by just updating the package.
Bind history to your users' accounts. If your site has signed-in users,
pass reporterId: session.user.id to proxyPlanyFeedback. Plany stores a
salted hash of it per report — never the id itself — and the widget seeds
"My reports" from it on any device, so a cleared cache or a new laptop no
longer loses the history and replying keeps working. Your system stores
nothing extra: the id is read from the session you already have, per
request. Without reporterId the widget stays fully anonymous and
per-device, exactly as before.
Two-way replies, live. Each report's timeline is a thread: team replies appear as messages, and the reporter writes back from the same view (2000 chars, 50 replies per report) — attachments included, both ways (≤5 files and ≤20MB total per message). While a thread is open on screen it's subscribed over Plany's WebSocket sync channel, so replies land the moment they're written — real chat pace. The subscription is authorized by a per-report thread key handed out through your proxy; no credential reaches the browser beyond that single-report, read-only capability. Internal comments never leave Plany — visibility is a per-message mode in the issue drawer (reply to reporter is the default on widget reports), mirroring the public-reply/private-note split of support tools.
Headless (CLIs, backends, scripts — Node 18+)
import { submitPlanyFeedback } from "@plany/feedback-widget";
await submitPlanyFeedback({
token: process.env.PLANY_FEEDBACK_KEY!, // server-side only
api: "https://<deployment>.convex.site",
message: "Export crashed on empty CSV",
name: "my-cli", // optional
contact: "[email protected]", // optional
files: [new File([buf], "crash.log", { type: "text/plain" })], // optional, Node 20+
});Or without the package at all:
curl -X POST https://<deployment>.convex.site/embed/issues \
-H 'Content-Type: application/json' \
-d '{"token":"pesk_...","message":"something broke"}'Notes
- The server key authorizes filing feedback into one project, plus the
reporter-scoped reads that power "My reports": status/thread lookups for
reports proven by a claim token or by the
reporterIdthe key holder attests. It can read nothing else — no titles you didn't file, no internal comments, no project data. Keep it server-side and rotate it anytime by toggling the widget off and on in Plany. - Submissions are rate-capped per project. Message ≤ 5000 chars.
- Migrating from ≤0.3.x (
token/apiin the browser,getSignature): move the credential into a backend route withproxyPlanyFeedbackand point the widget at it viaendpoint. The old public-token mode and HMAC signature flow are gone — one key, server-side, is the whole story.
