| 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 |