返回 presentation-ai
PresentationCompletionFeedback.tsx
根目录 / src / components / presentation / core / PresentationCompletionFeedback.tsx
1 "use client";
2
3 import { Frown, Meh, Plus, RotateCcw, Smile } from "lucide-react";
4 import { useSession } from "next-auth/react";
5 import { useRouter } from "next/navigation";
6 import { useEffect, useRef, useState } from "react";
7
8 import { Button } from "@/components/ui/button";
9 import {
10 Dialog,
11 DialogContent,
12 DialogDescription,
13 DialogHeader,
14 DialogTitle,
15 } from "@/components/ui/dialog";
16 import { Textarea } from "@/components/ui/textarea";
17 import { PRESENTATION_GENERATION_FEEDBACK_SOURCE } from "@/lib/feedback/metadata";
18 import { cn } from "@/lib/utils";
19
20 interface PresentationCompletionFeedbackProps {
21 presentationId: string;
22 presentationTitle: string | null;
23 onHide: () => void;
24 }
25
26 type Reaction = "like" | "neutral" | "dislike" | null;
27
28 const UNTITLED_PRESENTATION_LABEL = "Untitled Presentation";
29
30 function buildPresentationFeedbackMessage({
31 reaction,
32 presentationId,
33 presentationTitle,
34 details,
35 }: {
36 reaction: Exclude<Reaction, null>;
37 presentationId: string;
38 presentationTitle: string | null;
39 details?: string;
40 }) {
41 const trimmedDetails = details?.trim();
42
43 return [
44 `Presentation generation satisfaction: ${reaction}`,
45 `Presentation ID: ${presentationId}`,
46 `Presentation title: ${presentationTitle ?? UNTITLED_PRESENTATION_LABEL}`,
47 trimmedDetails ? `What they did not like: ${trimmedDetails}` : null,
48 ]
49 .filter((value): value is string => value !== null)
50 .join("\n");
51 }
52
53 export function PresentationCompletionFeedback({
54 presentationId,
55 presentationTitle,
56 onHide,
57 }: PresentationCompletionFeedbackProps) {
58 const router = useRouter();
59 const { push } = router;
60 const { data: session } = useSession();
61 const [isPending, setIsPending] = useState(false);
62 const [reaction, setReaction] = useState<Reaction>(null);
63 const [dislikeDialogOpen, setDislikeDialogOpen] = useState(false);
64 const [dislikeDetails, setDislikeDetails] = useState("");
65
66 const containerRef = useRef<HTMLDivElement>(null);
67 const [bgStyle, setBgStyle] = useState<{ top: number; right: number }>({
68 top: 9999,
69 right: 0,
70 });
71
72 useEffect(() => {
73 const scrollEl = containerRef.current?.closest<HTMLElement>(
74 ".presentation-slides",
75 );
76
77 const update = () => {
78 if (!containerRef.current) return;
79 const top = containerRef.current.getBoundingClientRect().top;
80 // Stop background at the scrollbar's left edge, not the viewport's right edge
81 const scrollbarWidth = scrollEl
82 ? scrollEl.offsetWidth - scrollEl.clientWidth
83 : 0;
84 const distanceToViewportRight = scrollEl
85 ? window.innerWidth - scrollEl.getBoundingClientRect().right
86 : 0;
87 const right = distanceToViewportRight + scrollbarWidth;
88 setBgStyle({ top, right });
89 };
90
91 update();
92
93 scrollEl?.addEventListener("scroll", update, { passive: true });
94 window.addEventListener("resize", update);
95
96 return () => {
97 scrollEl?.removeEventListener("scroll", update);
98 window.removeEventListener("resize", update);
99 };
100 }, []);
101
102 const submitPresentationFeedback = async (
103 nextReaction: Exclude<Reaction, null>,
104 details?: string,
105 ) => {
106 const trimmedDetails = details?.trim();
107
108 setIsPending(true);
109 try {
110 console.info("Presentation feedback", {
111 type: nextReaction === "like" ? "feedback" : "suggestion",
112 message: buildPresentationFeedbackMessage({
113 reaction: nextReaction,
114 presentationId,
115 presentationTitle,
116 details: trimmedDetails,
117 }),
118 email: session?.user?.email ?? null,
119 metadata: {
120 source: PRESENTATION_GENERATION_FEEDBACK_SOURCE,
121 reaction: nextReaction,
122 presentationId,
123 presentationTitle: presentationTitle ?? UNTITLED_PRESENTATION_LABEL,
124 hasDetailedFeedback: Boolean(trimmedDetails),
125 },
126 });
127 } finally {
128 setIsPending(false);
129 }
130
131 setReaction(nextReaction);
132 };
133
134 const submitDislikeFeedback = async (details?: string) => {
135 await submitPresentationFeedback("dislike", details);
136 setDislikeDialogOpen(false);
137 };
138
139 const handleReaction = async (nextReaction: Exclude<Reaction, null>) => {
140 if (nextReaction === "dislike") {
141 setDislikeDetails("");
142 setDislikeDialogOpen(true);
143 return;
144 }
145
146 await submitPresentationFeedback(nextReaction);
147 };
148
149 return (
150 <>
151 <div
152 ref={containerRef}
153 className="relative flex min-h-full w-full flex-col items-center justify-center py-12"
154 >
155 {/* Fixed background: inset-x-0 bottom-0, top tracks component's viewport position */}
156 <div
157 className="fixed right-0 bottom-0 left-0 z-1 bg-muted/40 dark:bg-card/60"
158 style={{ top: bgStyle.top, right: bgStyle.right }}
159 />
160
161 {/* Content sits above the background */}
162 <div className="relative z-2 flex w-full flex-col items-center">
163 <div className="w-full max-w-xs rounded-xl border border-border/40 bg-muted/20 p-4 text-center">
164 <p className="mb-3 text-xs text-muted-foreground/50">
165 Help us to improve
166 </p>
167 <p className="mb-4 text-sm font-medium text-foreground">
168 What is your satisfaction level with this presentation ?
169 </p>
170 <div className="flex items-center justify-center gap-3">
171 <button
172 type="button"
173 disabled={isPending}
174 onClick={() => handleReaction("dislike")}
175 className={cn(
176 "flex size-11 items-center justify-center rounded-full border border-border/40 bg-transparent transition-colors hover:border-red-400/50 hover:bg-red-400/10",
177 reaction === "dislike" &&
178 "border-red-400 bg-red-400/10 text-red-500",
179 )}
180 >
181 <Frown className="size-5" />
182 </button>
183 <button
184 type="button"
185 disabled={isPending}
186 onClick={() => handleReaction("neutral")}
187 className={cn(
188 "flex size-11 items-center justify-center rounded-full border border-border/40 bg-transparent transition-colors hover:border-amber-400/50 hover:bg-amber-400/10",
189 reaction === "neutral" &&
190 "border-amber-400 bg-amber-400/10 text-amber-500",
191 )}
192 >
193 <Meh className="size-5" />
194 </button>
195 <button
196 type="button"
197 disabled={isPending}
198 onClick={() => handleReaction("like")}
199 className={cn(
200 "flex size-11 items-center justify-center rounded-full border border-border/40 bg-transparent transition-colors hover:border-green-400/50 hover:bg-green-400/10",
201 reaction === "like" &&
202 "border-green-400 bg-green-400/10 text-green-500",
203 )}
204 >
205 <Smile className="size-5" />
206 </button>
207 </div>
208 </div>
209
210 <div className="mt-5 flex w-full max-w-xs flex-col gap-2.5">
211 <Button
212 size="lg"
213 className="w-full gap-2 rounded-full"
214 onClick={() => push("/presentation/create")}
215 >
216 <Plus className="size-4" />
217 Create a new doc
218 </Button>
219 <Button
220 size="lg"
221 variant="outline"
222 className="w-full gap-2 rounded-full"
223 onClick={() => push(`/presentation/generate/${presentationId}`)}
224 >
225 <RotateCcw className="size-4" />
226 Back to prompt
227 </Button>
228 </div>
229
230 <Button
231 size="lg"
232 variant="outline"
233 className="mt-3 w-full max-w-xs rounded-full"
234 onClick={onHide}
235 >
236 Hide
237 </Button>
238 </div>
239 </div>
240
241 <Dialog open={dislikeDialogOpen} onOpenChange={setDislikeDialogOpen}>
242 <DialogContent className="sm:max-w-md">
243 <form
244 className="space-y-4"
245 action={() => {
246 void submitDislikeFeedback(dislikeDetails);
247 }}
248 >
249 <DialogHeader>
250 <DialogTitle>Tell us what you did not like</DialogTitle>
251 <DialogDescription>
252 This is optional. You can skip it and we will still record your
253 dislike.
254 </DialogDescription>
255 </DialogHeader>
256
257 <Textarea
258 value={dislikeDetails}
259 onChange={(event) => setDislikeDetails(event.target.value)}
260 placeholder="Share what felt wrong, missing, or low quality about this presentation generation."
261 className="min-h-32 resize-none"
262 />
263
264 <Button type="submit" disabled={isPending} className="w-full">
265 Send feedback
266 </Button>
267 </form>
268 </DialogContent>
269 </Dialog>
270 </>
271 );
272 }
273
273 lines Plain Text