Skip to content

Commit 1721701

Browse files
authored
chore: docs theme-store with feedback (#2282)
* chore: docs theme-store with feedback * fix: review * refactor: env name * fix: review * fix: pii
1 parent 2c1a7da commit 1721701

9 files changed

Lines changed: 541 additions & 5 deletions

File tree

apps/web/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ AUTH_GOOGLE_SECRET=
6969

7070
PAGERDUTY_APP_ID=
7171

72-
SLACK_SUPPORT_WEBHOOK_URL=
72+
SLACK_FEEDBACK_WEBHOOK_URL=
7373

7474
WORKSPACES_LOOKBACK_30=
7575
WORKSPACES_HIDE_URL=

apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
gitLastModified,
1515
validateDocsNav,
1616
} from "@/content/docs";
17+
import { DocsFeedback } from "@/content/docs-feedback";
1718
import { DocsSubNav } from "@/content/docs-sub-nav";
1819
import { TableOfContents } from "@/content/docs-toc";
1920
import {
@@ -241,8 +242,9 @@ export default async function DocsPage({
241242
</div>
242243

243244
<aside className="hidden w-56 shrink-0 xl:block">
244-
<div className="sticky top-4 max-h-[calc(100vh-2rem)] overflow-y-auto">
245+
<div className="sticky top-4 max-h-[calc(100vh-2rem)] space-y-2 overflow-y-auto">
245246
<TableOfContents items={toc} />
247+
<DocsFeedback />
246248
</div>
247249
</aside>
248250
</div>
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { redis } from "@openstatus/upstash";
2+
import { z } from "zod";
3+
4+
import { getClientIP, ratelimit } from "@/lib/ratelimit";
5+
import { hashIP } from "@/lib/utils";
6+
7+
export const runtime = "edge";
8+
9+
const RATE_LIMIT_WINDOW = 60; // seconds
10+
const MAX_REQUESTS_PER_WINDOW = 5;
11+
12+
// a docs pathname (from usePathname); constrained so it can't craft arbitrary Redis keys
13+
const path = z
14+
.string()
15+
.min(1)
16+
.max(512)
17+
.regex(/^\/[a-zA-Z0-9/_-]*$/);
18+
19+
// rating-only (thumbs) and message-only (feedback) are separate actions
20+
const schema = z.discriminatedUnion("kind", [
21+
z.object({
22+
kind: z.literal("rating"),
23+
path,
24+
rating: z.enum(["up", "down"]),
25+
}),
26+
z.object({
27+
kind: z.literal("message"),
28+
path,
29+
message: z.string().trim().min(1).max(2000),
30+
}),
31+
]);
32+
33+
type Rating = "up" | "down";
34+
35+
const countsKey = (path: string) => `docs-feedback:counts:${path}`;
36+
const voterKey = (path: string, id: string) =>
37+
`docs-feedback:voter:${path}:${id}`;
38+
// bound voter-key storage; after expiry a re-vote may double count (acceptable)
39+
const VOTER_TTL = 60 * 60 * 24 * 180; // 180 days, seconds
40+
41+
// Slack mrkdwn treats & < > as control chars; escape user text before interpolation
42+
const escapeSlack = (text: string) =>
43+
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
44+
45+
export async function POST(request: Request) {
46+
let data: z.infer<typeof schema>;
47+
try {
48+
data = schema.parse(await request.json());
49+
} catch {
50+
return Response.json({ error: "Invalid request" }, { status: 400 });
51+
}
52+
53+
const clientIP = getClientIP(request.headers);
54+
if (!clientIP) {
55+
return Response.json(
56+
{ error: "Unable to determine client IP" },
57+
{ status: 400 },
58+
);
59+
}
60+
const voterId = await hashIP(clientIP);
61+
62+
const limit = await ratelimit(`docs-feedback:${voterId}`, {
63+
window: RATE_LIMIT_WINDOW,
64+
limit: MAX_REQUESTS_PER_WINDOW,
65+
});
66+
if (!limit.success) {
67+
return Response.json(
68+
{ error: "Too many requests" },
69+
{
70+
status: 429,
71+
headers: {
72+
"Retry-After": Math.ceil(
73+
(limit.reset - Date.now()) / 1000,
74+
).toString(),
75+
},
76+
},
77+
);
78+
}
79+
80+
// dev: skip external writes (Redis tally + Slack) so local runs don't pollute prod
81+
if (process.env.NODE_ENV === "development") {
82+
console.log("docs feedback", data);
83+
return Response.json({ success: true });
84+
}
85+
86+
// historical tally of up/down votes per path; best-effort, never blocks Slack
87+
if (data.kind === "rating") {
88+
try {
89+
// one vote per (path, voter); SET ... GET atomically claims the new vote and
90+
// returns the prior one in a single round-trip, so concurrent requests can't
91+
// both read a stale value and double-count. The delta derives from that prior
92+
// value server-side, so a tampered count needs both a real prior vote and the IP.
93+
const previous = (await redis.set(
94+
voterKey(data.path, voterId),
95+
data.rating,
96+
{ ex: VOTER_TTL, get: true },
97+
)) as Rating | null;
98+
if (previous !== data.rating) {
99+
await redis.hincrby(countsKey(data.path), data.rating, 1);
100+
if (previous) {
101+
await redis.hincrby(countsKey(data.path), previous, -1);
102+
}
103+
}
104+
} catch (err) {
105+
console.error("Docs feedback: failed to record vote", err);
106+
}
107+
}
108+
109+
const webhook = process.env.SLACK_FEEDBACK_WEBHOOK_URL;
110+
if (!webhook) {
111+
console.error("Docs feedback: SLACK_FEEDBACK_WEBHOOK_URL not configured.");
112+
return Response.json({ success: true });
113+
}
114+
115+
const lines = [
116+
data.kind === "rating"
117+
? `*Docs feedback:* ${data.rating === "up" ? "👍 helpful" : "👎 not helpful"}`
118+
: "*Docs feedback:* 💬 comment",
119+
`*Path:* ${escapeSlack(data.path)}`,
120+
];
121+
if (data.kind === "message") {
122+
lines.push(
123+
"--------------------------------",
124+
`*Message:* ${escapeSlack(data.message)}`,
125+
);
126+
}
127+
128+
try {
129+
const response = await fetch(webhook, {
130+
method: "POST",
131+
headers: { "Content-Type": "application/json" },
132+
body: JSON.stringify({ text: lines.join("\n") }),
133+
});
134+
if (!response.ok) {
135+
console.error(
136+
`Docs feedback: Slack webhook responded ${response.status}`,
137+
);
138+
}
139+
} catch (err) {
140+
console.error("Docs feedback: failed to post to Slack", err);
141+
}
142+
143+
return Response.json({ success: true });
144+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"use client";
2+
3+
import { zodResolver } from "@hookform/resolvers/zod";
4+
import {
5+
Form,
6+
FormControl,
7+
FormField,
8+
FormItem,
9+
FormLabel,
10+
} from "@openstatus/ui/components/ui/form";
11+
import { Kbd } from "@openstatus/ui/components/ui/kbd";
12+
import {
13+
Popover,
14+
PopoverContent,
15+
PopoverTrigger,
16+
} from "@openstatus/ui/components/ui/popover";
17+
import { Textarea } from "@openstatus/ui/components/ui/textarea";
18+
import { Inbox, LoaderCircle } from "lucide-react";
19+
import { usePathname } from "next/navigation";
20+
import { useEffect, useState } from "react";
21+
import { useForm } from "react-hook-form";
22+
import { z } from "zod";
23+
24+
import { toastAction } from "@/lib/toast";
25+
26+
type Rating = "up" | "down";
27+
28+
const ratingKey = (path: string) => `docs-rating:${path}`;
29+
30+
const schema = z.object({
31+
message: z.string().trim().min(1).max(2000),
32+
});
33+
34+
type FeedbackBody =
35+
| { kind: "rating"; path: string; rating: Rating; previous?: Rating }
36+
| { kind: "message"; path: string; message: string };
37+
38+
// never throws; returns whether the server accepted the submission
39+
async function postFeedback(body: FeedbackBody): Promise<boolean> {
40+
try {
41+
const res = await fetch("/api/feedback/docs", {
42+
method: "POST",
43+
headers: { "Content-Type": "application/json" },
44+
body: JSON.stringify(body),
45+
});
46+
return res.ok;
47+
} catch {
48+
return false;
49+
}
50+
}
51+
52+
function DocsFeedbackBar({ path }: { path: string }) {
53+
const [rating, setRating] = useState<Rating>();
54+
const [open, setOpen] = useState(false);
55+
const [sent, setSent] = useState(false);
56+
const form = useForm<z.infer<typeof schema>>({
57+
resolver: zodResolver(schema),
58+
defaultValues: { message: "" },
59+
});
60+
61+
// rehydrate a prior vote so the chosen arrow stays highlighted across loads
62+
useEffect(() => {
63+
const stored = window.localStorage.getItem(ratingKey(path));
64+
if (stored === "up" || stored === "down") setRating(stored);
65+
}, [path]);
66+
67+
// reset the popover contents shortly after it closes (300ms = close anim)
68+
useEffect(() => {
69+
if (!open && sent) {
70+
const t = setTimeout(() => {
71+
setSent(false);
72+
form.reset();
73+
}, 300);
74+
return () => clearTimeout(t);
75+
}
76+
}, [open, sent, form]);
77+
78+
function rate(value: Rating) {
79+
// read the prior vote from localStorage (not state) so a click before the
80+
// hydration effect still reports the correct `previous` to the server
81+
const stored = window.localStorage.getItem(ratingKey(path));
82+
const previous = stored === "up" || stored === "down" ? stored : undefined;
83+
if (previous === value) return;
84+
setRating(value);
85+
window.localStorage.setItem(ratingKey(path), value);
86+
void postFeedback({ kind: "rating", path, rating: value, previous });
87+
}
88+
89+
async function onSubmit(values: z.infer<typeof schema>) {
90+
const ok = await postFeedback({
91+
kind: "message",
92+
path,
93+
message: values.message,
94+
});
95+
if (ok) {
96+
setSent(true);
97+
} else {
98+
toastAction("error");
99+
}
100+
}
101+
102+
return (
103+
<div className="text-sm">
104+
<p className="text-foreground py-2 font-mono font-medium">
105+
Was this helpful?
106+
</p>
107+
<Popover open={open} onOpenChange={setOpen}>
108+
<div className="bg-border text-muted-foreground [&>*]:bg-background [&>*]:hover:bg-muted flex items-stretch gap-px border font-mono [&>*]:flex [&>*]:items-center [&>*]:justify-center [&>*]:py-2 [&>*]:transition-colors [&>*]:disabled:pointer-events-none">
109+
<button
110+
type="button"
111+
aria-label="Yes, this page was helpful"
112+
data-active={rating === "up"}
113+
onClick={() => rate("up")}
114+
className="hover:text-success data-[active=true]:bg-muted data-[active=true]:text-success w-10"
115+
>
116+
<span className="leading-none" aria-hidden="true">
117+
118+
</span>
119+
</button>
120+
<button
121+
type="button"
122+
aria-label="No, this page was not helpful"
123+
data-active={rating === "down"}
124+
onClick={() => rate("down")}
125+
className="hover:text-destructive data-[active=true]:bg-muted data-[active=true]:text-destructive w-10"
126+
>
127+
<span className="leading-none" aria-hidden="true">
128+
129+
</span>
130+
</button>
131+
<PopoverTrigger asChild>
132+
<button
133+
type="button"
134+
data-active={open}
135+
className="hover:text-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground flex-1"
136+
>
137+
Send feedback
138+
</button>
139+
</PopoverTrigger>
140+
</div>
141+
<PopoverContent
142+
align="end"
143+
side="bottom"
144+
className="w-64 rounded-none p-0"
145+
>
146+
{sent ? (
147+
<div className="flex flex-col items-center justify-center gap-1 p-3">
148+
<Inbox className="size-4 shrink-0" />
149+
<p className="text-center text-sm font-medium">
150+
Thanks for sharing!
151+
</p>
152+
<p className="text-muted-foreground text-center text-xs">
153+
We read every note.
154+
</p>
155+
</div>
156+
) : (
157+
<Form {...form}>
158+
<form className="relative" onSubmit={form.handleSubmit(onSubmit)}>
159+
<FormField
160+
control={form.control}
161+
name="message"
162+
render={({ field }) => (
163+
<FormItem>
164+
<FormLabel className="sr-only">Feedback</FormLabel>
165+
<FormControl>
166+
<Textarea
167+
placeholder="Ideas, corrections, or anything missing..."
168+
className="field-sizing-fixed h-[110px] resize-none rounded-none p-3"
169+
rows={4}
170+
onKeyDown={(e) => {
171+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
172+
e.preventDefault();
173+
void form.handleSubmit(onSubmit)();
174+
}
175+
}}
176+
{...field}
177+
/>
178+
</FormControl>
179+
</FormItem>
180+
)}
181+
/>
182+
<button
183+
type="submit"
184+
className="text-muted-foreground hover:text-foreground absolute right-2 bottom-2 flex items-center font-mono text-sm disabled:opacity-50"
185+
disabled={form.formState.isSubmitting}
186+
>
187+
{form.formState.isSubmitting ? (
188+
<LoaderCircle className="size-4 animate-spin" />
189+
) : (
190+
<>
191+
Send
192+
<Kbd className="ml-1 font-mono"></Kbd>
193+
<Kbd className="ml-1 font-mono"></Kbd>
194+
</>
195+
)}
196+
</button>
197+
</form>
198+
</Form>
199+
)}
200+
</PopoverContent>
201+
</Popover>
202+
</div>
203+
);
204+
}
205+
206+
export function DocsFeedback() {
207+
const pathname = usePathname();
208+
// key by path so rating + popover state reset when navigating between docs
209+
return <DocsFeedbackBar key={pathname} path={pathname} />;
210+
}

apps/web/src/content/docs.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ export const docsNav: DocsNavSection[] = [
108108
slug: "guides/how-to-configure-status-page",
109109
label: "How to Configure Your Status Page",
110110
},
111+
{
112+
slug: "guides/how-to-create-status-page-theme",
113+
label: "How to Create Your Own Status Page Theme",
114+
},
111115
{
112116
slug: "guides/how-to-import-status-page",
113117
label: "How to Import a Status Page from Another Provider",

0 commit comments

Comments
 (0)