feat: add FeedbackForm, FeedbackModal, and Give Feedback tab
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
|
||||
import { RoadmapItem } from '@/components/RoadmapItem'
|
||||
import { FeedbackForm } from '@/components/FeedbackForm'
|
||||
import type { ChangelogEntry, RoadmapItemData, Tab, VoteState } from '@/types/changelog'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import Link from 'next/link'
|
||||
@@ -297,9 +298,17 @@ export function ChangelogTabs({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Give Feedback — stub, filled in Task 6 ── */}
|
||||
{/* ── Give Feedback ── */}
|
||||
{activeTab === 'give-feedback' && (
|
||||
<p className="text-sm text-zinc-500">Feedback form coming soon.</p>
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-bold text-white">Tell us what you think.</h2>
|
||||
<p className="mt-1.5 text-sm text-zinc-500">
|
||||
Bug, feature idea, or general thought — we read everything.
|
||||
</p>
|
||||
</div>
|
||||
<FeedbackForm />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
type Status = 'idle' | 'loading' | 'done' | 'error'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'BUG', label: 'Bug report' },
|
||||
{ value: 'FEATURE_REQUEST', label: 'Feature request' },
|
||||
{ value: 'GENERAL', label: 'General feedback' },
|
||||
]
|
||||
|
||||
export function FeedbackForm({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const [category, setCategory] = useState('BUG')
|
||||
const [message, setMessage] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!message.trim()) return
|
||||
setStatus('loading')
|
||||
try {
|
||||
const res = await fetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
message: message.trim(),
|
||||
email: email.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
setStatus('done')
|
||||
onSuccess?.()
|
||||
} else {
|
||||
setStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'done') {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm font-semibold text-amber-400">Thanks — we got it.</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
We read everything. If you left an email we'll follow up.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
disabled={status === 'loading'}
|
||||
className="w-full appearance-none rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-200 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Email{' '}
|
||||
<span className="font-normal normal-case tracking-normal text-zinc-600">
|
||||
(optional — if you want a reply)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@email.com"
|
||||
disabled={status === 'loading'}
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-200 placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value.slice(0, 1000))}
|
||||
placeholder="What's on your mind?"
|
||||
rows={5}
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
className="w-full resize-y rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm leading-relaxed text-zinc-200 placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<p className="mt-1.5 text-right text-[11px] text-zinc-600">
|
||||
{message.length} / 1000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-[11px] text-zinc-600">
|
||||
We'll only use your email to follow up on your feedback.
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading' || !message.trim()}
|
||||
className="shrink-0 rounded-lg bg-amber-500/90 px-6 py-2.5 text-sm font-bold text-black hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
{status === 'loading' ? 'Sending…' : 'Send Feedback →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="text-xs text-red-400">Something went wrong. Try again.</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { XMarkIcon } from '@heroicons/react/20/solid'
|
||||
import { FeedbackForm } from '@/components/FeedbackForm'
|
||||
|
||||
export function FeedbackModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-lg rounded-2xl border border-zinc-800 bg-zinc-950 p-7 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-base font-bold text-white">Send Feedback</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-zinc-500 hover:text-zinc-300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<FeedbackForm onSuccess={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user