- configure wrangler for CF account with D1 binding - add feedback API route with rate limiting and github issue creation - add feedback widget component - add project detail page with status/schedule/info tabs - add frappe-gantt type declarations - fix type errors for production build - add migration 0004 for feedback table
183 lines
5.8 KiB
TypeScript
Executable File
183 lines
5.8 KiB
TypeScript
Executable File
"use client"
|
|
|
|
import { createContext, useContext, useState } from "react"
|
|
import { usePathname } from "next/navigation"
|
|
import { MessageCircle } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
|
|
const FeedbackContext = createContext<{ open: () => void }>({ open: () => {} })
|
|
|
|
export function useFeedback() {
|
|
return useContext(FeedbackContext)
|
|
}
|
|
|
|
export function FeedbackCallout() {
|
|
const { open } = useFeedback()
|
|
return (
|
|
<p className="text-primary font-semibold">
|
|
Have feedback?{" "}
|
|
<button onClick={open} className="underline underline-offset-2 hover:opacity-80">
|
|
Let us know what you think
|
|
</button>
|
|
{" "}— we'd love to hear from you.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
export function FeedbackWidget({ children }: { children?: React.ReactNode }) {
|
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [type, setType] = useState<string>("")
|
|
const [message, setMessage] = useState("")
|
|
const [name, setName] = useState("")
|
|
const [email, setEmail] = useState("")
|
|
const pathname = usePathname()
|
|
|
|
function resetForm() {
|
|
setType("")
|
|
setMessage("")
|
|
setName("")
|
|
setEmail("")
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!type || !message.trim()) return
|
|
|
|
setSubmitting(true)
|
|
try {
|
|
const res = await fetch("/api/feedback", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type,
|
|
message,
|
|
name: name || undefined,
|
|
email: email || undefined,
|
|
pageUrl: pathname,
|
|
userAgent: navigator.userAgent,
|
|
viewportWidth: window.innerWidth,
|
|
viewportHeight: window.innerHeight,
|
|
}),
|
|
})
|
|
|
|
if (res.ok) {
|
|
toast.success("Feedback submitted, thank you!")
|
|
resetForm()
|
|
setDialogOpen(false)
|
|
} else {
|
|
const data = await res.json() as { error?: string }
|
|
toast.error(data.error || "Something went wrong")
|
|
}
|
|
} catch {
|
|
toast.error("Failed to submit feedback")
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<FeedbackContext.Provider value={{ open: () => setDialogOpen(true) }}>
|
|
{children}
|
|
|
|
<Button
|
|
onClick={() => setDialogOpen(true)}
|
|
size="icon-lg"
|
|
className="group fixed bottom-12 right-6 z-40 gap-0 rounded-full shadow-lg transition-all duration-200 hover:w-auto hover:gap-2 hover:px-4 overflow-hidden"
|
|
>
|
|
<MessageCircle className="size-5 shrink-0" />
|
|
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-200 group-hover:max-w-40 group-hover:opacity-100">
|
|
Feedback
|
|
</span>
|
|
</Button>
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Send Feedback</DialogTitle>
|
|
<DialogDescription>
|
|
Report a bug, request a feature, or ask a question.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="feedback-type">Type</Label>
|
|
<Select value={type} onValueChange={setType}>
|
|
<SelectTrigger id="feedback-type">
|
|
<SelectValue placeholder="Select type..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="bug">Bug Report</SelectItem>
|
|
<SelectItem value="feature">Feature Request</SelectItem>
|
|
<SelectItem value="question">Question</SelectItem>
|
|
<SelectItem value="general">General Feedback</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="feedback-message">Message</Label>
|
|
<Textarea
|
|
id="feedback-message"
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
placeholder="Describe your feedback..."
|
|
maxLength={2000}
|
|
rows={4}
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground text-right">
|
|
{message.length}/2000
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="feedback-name">Name (optional)</Label>
|
|
<Input
|
|
id="feedback-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Your name"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="feedback-email">Email (optional)</Label>
|
|
<Input
|
|
id="feedback-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="you@example.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Button type="submit" disabled={submitting || !type || !message.trim()}>
|
|
{submitting ? "Submitting..." : "Submit Feedback"}
|
|
</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</FeedbackContext.Provider>
|
|
)
|
|
}
|