Skip to content

Commit d47228f

Browse files
TokenBriceclaude
andcommitted
feat(frontend): add FeedbackModal component
Adds the FeedbackModal component with bug/data-correction/feature-request type selector, context banner, honeypot spam guard, and POST to https://api.pharos.watch/api/feedback. Also installs required shadcn primitives: dialog, textarea, label. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 278d861 commit d47228f

File tree

4 files changed

+451
-0
lines changed

4 files changed

+451
-0
lines changed

src/components/feedback-modal.tsx

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
"use client";
2+
3+
import { useState, useCallback } from "react";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogHeader,
8+
DialogTitle,
9+
} from "@/components/ui/dialog";
10+
import { Button } from "@/components/ui/button";
11+
import { Textarea } from "@/components/ui/textarea";
12+
import { Input } from "@/components/ui/input";
13+
import { Label } from "@/components/ui/label";
14+
15+
type FeedbackType = "bug" | "data-correction" | "feature-request";
16+
17+
export interface FeedbackModalProps {
18+
open: boolean;
19+
onOpenChange: (open: boolean) => void;
20+
defaultType?: FeedbackType;
21+
stablecoinId?: string;
22+
stablecoinName?: string;
23+
pegValue?: string;
24+
}
25+
26+
const TYPE_LABELS: Record<FeedbackType, string> = {
27+
bug: "Bug Report",
28+
"data-correction": "Data Correction",
29+
"feature-request": "Feature Request",
30+
};
31+
32+
const DESCRIPTION_HINTS: Record<FeedbackType, string> = {
33+
bug: "Describe what happened and what you expected instead.",
34+
"data-correction":
35+
"e.g. USDC shows $0.00 price since yesterday. CoinGecko shows $1.0001.",
36+
"feature-request": "Describe the feature and why it would be useful.",
37+
};
38+
39+
export function FeedbackModal({
40+
open,
41+
onOpenChange,
42+
defaultType = "bug",
43+
stablecoinId,
44+
stablecoinName,
45+
pegValue,
46+
}: FeedbackModalProps) {
47+
const [type, setType] = useState<FeedbackType>(defaultType);
48+
const [title, setTitle] = useState("");
49+
const [description, setDescription] = useState("");
50+
const [expectedValue, setExpectedValue] = useState("");
51+
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
52+
const [errorMsg, setErrorMsg] = useState("");
53+
54+
const reset = useCallback(() => {
55+
setType(defaultType);
56+
setTitle("");
57+
setDescription("");
58+
setExpectedValue("");
59+
setStatus("idle");
60+
setErrorMsg("");
61+
}, [defaultType]);
62+
63+
const handleOpenChange = useCallback(
64+
(next: boolean) => {
65+
if (!next) reset();
66+
onOpenChange(next);
67+
},
68+
[onOpenChange, reset]
69+
);
70+
71+
const handleSubmit = useCallback(async () => {
72+
setStatus("loading");
73+
setErrorMsg("");
74+
75+
const pageUrl = typeof window !== "undefined" ? window.location.pathname : "/";
76+
77+
const body = {
78+
type,
79+
...(title.trim() ? { title: title.trim() } : {}),
80+
description: description.trim(),
81+
...(expectedValue.trim() ? { expectedValue: expectedValue.trim() } : {}),
82+
...(stablecoinId ? { stablecoinId } : {}),
83+
...(stablecoinName ? { stablecoinName } : {}),
84+
...(pegValue ? { pegValue } : {}),
85+
pageUrl,
86+
website: "",
87+
};
88+
89+
try {
90+
const res = await fetch("https://api.pharos.watch/api/feedback", {
91+
method: "POST",
92+
headers: { "Content-Type": "application/json" },
93+
body: JSON.stringify(body),
94+
});
95+
const data = (await res.json()) as { ok?: boolean; error?: string };
96+
if (!res.ok || !data.ok) {
97+
setErrorMsg(data.error ?? "Something went wrong. Please try again.");
98+
setStatus("error");
99+
} else {
100+
setStatus("success");
101+
}
102+
} catch {
103+
setErrorMsg("Network error. Please try again.");
104+
setStatus("error");
105+
}
106+
}, [type, title, description, expectedValue, stablecoinId, stablecoinName, pegValue]);
107+
108+
const needsTitle = type === "bug" || type === "feature-request";
109+
const isValid =
110+
description.trim().length >= 10 &&
111+
description.trim().length <= 2000 &&
112+
(!needsTitle || (title.trim().length >= 3 && title.trim().length <= 100));
113+
114+
const pageUrl = typeof window !== "undefined" ? window.location.pathname : "";
115+
116+
return (
117+
<Dialog open={open} onOpenChange={handleOpenChange}>
118+
<DialogContent className="sm:max-w-lg">
119+
<DialogHeader>
120+
<DialogTitle>Send Feedback</DialogTitle>
121+
</DialogHeader>
122+
123+
{status === "success" ? (
124+
<div className="py-8 text-center space-y-2">
125+
<p className="text-lg font-medium">Thanks — submitted!</p>
126+
<p className="text-sm text-muted-foreground">
127+
We review all submissions and prioritize data corrections.
128+
</p>
129+
<Button variant="outline" className="mt-4" onClick={() => handleOpenChange(false)}>
130+
Close
131+
</Button>
132+
</div>
133+
) : (
134+
<div className="space-y-4">
135+
{/* Type selector */}
136+
<div className="flex gap-1 rounded-lg bg-muted p-1">
137+
{(["bug", "data-correction", "feature-request"] as FeedbackType[]).map((t) => (
138+
<button
139+
key={t}
140+
onClick={() => setType(t)}
141+
className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
142+
type === t
143+
? "bg-background text-foreground shadow-sm"
144+
: "text-muted-foreground hover:text-foreground"
145+
}`}
146+
>
147+
{TYPE_LABELS[t]}
148+
</button>
149+
))}
150+
</div>
151+
152+
{/* Context banner */}
153+
{(stablecoinName || pageUrl) && (
154+
<div className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground space-y-0.5">
155+
{stablecoinName && <div><span className="font-medium">Stablecoin:</span> {stablecoinName}</div>}
156+
{pegValue && <div><span className="font-medium">Current value:</span> {pegValue}</div>}
157+
<div><span className="font-medium">Page:</span> {pageUrl}</div>
158+
</div>
159+
)}
160+
161+
{/* Title field (bug + feature-request) */}
162+
{needsTitle && (
163+
<div className="space-y-1.5">
164+
<Label htmlFor="fb-title">Title</Label>
165+
<Input
166+
id="fb-title"
167+
placeholder={type === "bug" ? "e.g. Sidebar breaks on mobile" : "e.g. Add EUR peg heatmap"}
168+
value={title}
169+
onChange={(e) => setTitle(e.target.value)}
170+
maxLength={100}
171+
disabled={status === "loading"}
172+
/>
173+
</div>
174+
)}
175+
176+
{/* Description */}
177+
<div className="space-y-1.5">
178+
<Label htmlFor="fb-desc">
179+
{type === "data-correction" ? "What is wrong?" : "Description"}
180+
</Label>
181+
<Textarea
182+
id="fb-desc"
183+
placeholder={DESCRIPTION_HINTS[type]}
184+
value={description}
185+
onChange={(e) => setDescription(e.target.value)}
186+
rows={4}
187+
maxLength={2000}
188+
disabled={status === "loading"}
189+
className="resize-none"
190+
/>
191+
<p className="text-xs text-muted-foreground text-right">
192+
{description.length}/2000
193+
</p>
194+
</div>
195+
196+
{/* Expected value (data-correction only) */}
197+
{type === "data-correction" && (
198+
<div className="space-y-1.5">
199+
<Label htmlFor="fb-expected">
200+
Expected value / source{" "}
201+
<span className="text-muted-foreground">(optional)</span>
202+
</Label>
203+
<Input
204+
id="fb-expected"
205+
placeholder="e.g. CoinGecko shows $1.0001"
206+
value={expectedValue}
207+
onChange={(e) => setExpectedValue(e.target.value)}
208+
maxLength={200}
209+
disabled={status === "loading"}
210+
/>
211+
</div>
212+
)}
213+
214+
{/* Honeypot */}
215+
<input
216+
type="text"
217+
name="website"
218+
tabIndex={-1}
219+
aria-hidden="true"
220+
style={{ position: "absolute", left: "-9999px", opacity: 0, pointerEvents: "none" }}
221+
readOnly
222+
value=""
223+
/>
224+
225+
{/* Error */}
226+
{status === "error" && errorMsg && (
227+
<p className="text-sm text-destructive">{errorMsg}</p>
228+
)}
229+
230+
{/* Submit */}
231+
<div className="flex justify-end gap-2">
232+
<Button
233+
variant="outline"
234+
onClick={() => handleOpenChange(false)}
235+
disabled={status === "loading"}
236+
>
237+
Cancel
238+
</Button>
239+
<Button
240+
onClick={handleSubmit}
241+
disabled={!isValid || status === "loading"}
242+
>
243+
{status === "loading" ? "Submitting…" : "Submit"}
244+
</Button>
245+
</div>
246+
</div>
247+
)}
248+
</DialogContent>
249+
</Dialog>
250+
);
251+
}

0 commit comments

Comments
 (0)