DocDoeAI / src /components /support /SupportForm.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
8.63 kB
"use client";
import { useState } from "react";
import Link from "next/link";
import { ArrowLeft, Check, LoaderCircle, RotateCcw } from "lucide-react";
import { submitSupport, type SupportKind } from "@/lib/api/support";
import { isApiError } from "@/lib/api/client";
import { cn } from "@/lib/utils";
type SupportFormProps = {
kind: SupportKind;
eyebrow: string;
title: string;
intro: string;
categories?: string[];
showRating?: boolean;
showTitleField?: boolean;
messageLabel: string;
messagePlaceholder: string;
};
type Phase = "idle" | "submitting" | "done" | "error";
const inputClass =
"w-full rounded-xl border border-white/12 bg-white/[0.03] px-3.5 py-3 text-[15px] text-white placeholder:text-white/30 outline-none transition focus:border-white/40";
const labelClass = "block text-xs font-bold uppercase tracking-[0.14em] text-white/45";
export function SupportForm({
kind,
eyebrow,
title,
intro,
categories,
showRating = false,
showTitleField = false,
messageLabel,
messagePlaceholder,
}: SupportFormProps) {
const [category, setCategory] = useState<string>(categories?.[0] ?? "");
const [titleValue, setTitleValue] = useState("");
const [message, setMessage] = useState("");
const [rating, setRating] = useState<number | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
const [error, setError] = useState<string | null>(null);
const canSubmit = message.trim().length > 0 && phase !== "submitting";
async function handleSubmit() {
if (!canSubmit) return;
setPhase("submitting");
setError(null);
try {
await submitSupport({
kind,
message: message.trim(),
category: category || null,
title: showTitleField ? titleValue.trim() || null : null,
rating: showRating ? rating : null,
page_url: typeof window !== "undefined" ? window.location.href : null,
});
setPhase("done");
} catch (err) {
// Never expose an internal payload — a plain, retryable message only.
const status = isApiError(err) ? err.status : null;
if (status === 429) {
setError("You've sent a few messages in a short time. Please wait a minute and try again — your text is still here.");
} else if (status === 422) {
setError("Please add a bit more detail to your message before sending.");
} else {
setError("We could not send that just now. Check your connection and try again — your text is still here.");
}
setPhase("error");
}
}
if (phase === "done") {
return (
<div className="mx-auto w-full max-w-xl rounded-2xl border border-white/12 bg-white/[0.03] p-8 text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-300">
<Check className="size-6" />
</span>
<h2 className="mt-5 text-xl font-bold text-white">Thank you — we received it</h2>
<p className="mt-2 text-sm leading-6 text-white/55">
{kind === "bug"
? "Your report is saved. The team reviews bug reports during the beta and will act on the clear ones first."
: "Your feedback is saved. Real student feedback is what shapes what DocDoe builds next."}
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<button
type="button"
onClick={() => {
setMessage("");
setTitleValue("");
setRating(null);
setPhase("idle");
}}
className="inline-flex h-11 items-center gap-2 rounded-xl border border-white/15 px-4 text-sm font-semibold text-white/75 transition hover:text-white"
>
Send another
</button>
<Link
href="/home"
className="inline-flex h-11 items-center gap-2 rounded-xl bg-white px-5 text-sm font-bold text-black transition hover:bg-white/90"
>
Back to DocDoe
</Link>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-xl">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm font-medium text-white/45 transition hover:text-white"
>
<ArrowLeft className="size-4" /> DocDoe
</Link>
<p className="mt-6 text-[11px] font-bold uppercase tracking-[0.2em] text-white/40">{eyebrow}</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">{title}</h1>
<p className="mt-4 text-[15px] leading-7 text-white/60">{intro}</p>
<form
className="mt-8 space-y-5"
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
{categories && categories.length > 0 ? (
<div>
<span className={labelClass}>Category</span>
<div className="mt-2 flex flex-wrap gap-2">
{categories.map((item) => (
<button
key={item}
type="button"
onClick={() => setCategory(item)}
aria-pressed={category === item}
className={cn(
"rounded-lg border px-3 py-2 text-sm font-semibold transition",
category === item
? "border-white bg-white text-black"
: "border-white/12 text-white/60 hover:border-white/30 hover:text-white",
)}
>
{item}
</button>
))}
</div>
</div>
) : null}
{showTitleField ? (
<label htmlFor="support-title" className="block">
<span className={labelClass}>Title (optional)</span>
<input
id="support-title"
value={titleValue}
onChange={(event) => setTitleValue(event.target.value)}
placeholder="Short summary"
maxLength={200}
className={cn(inputClass, "mt-2")}
/>
</label>
) : null}
{showRating ? (
<div>
<span className={labelClass}>Rating (optional)</span>
<div className="mt-2 flex gap-2">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
type="button"
onClick={() => setRating(rating === value ? null : value)}
aria-label={`${value} out of 5`}
aria-pressed={rating === value}
className={cn(
"size-10 rounded-lg border text-sm font-bold transition",
rating !== null && value <= rating
? "border-white bg-white text-black"
: "border-white/12 text-white/50 hover:border-white/30 hover:text-white",
)}
>
{value}
</button>
))}
</div>
</div>
) : null}
<label htmlFor="support-message" className="block">
<span className={labelClass}>{messageLabel}</span>
<textarea
id="support-message"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder={messagePlaceholder}
rows={6}
maxLength={4000}
className={cn(inputClass, "mt-2 resize-y")}
/>
</label>
{error ? (
<p className="rounded-lg border border-rose-400/25 bg-rose-400/[0.06] px-3 py-2 text-sm text-rose-200/90" role="alert">
{error}
</p>
) : null}
<button
type="submit"
disabled={!canSubmit}
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-white px-6 text-[15px] font-bold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40 sm:w-auto"
>
{phase === "submitting" ? (
<>
<LoaderCircle className="size-4 animate-spin" /> Sending…
</>
) : phase === "error" ? (
<>
<RotateCcw className="size-4" /> Try again
</>
) : (
"Submit"
)}
</button>
<p className="text-xs leading-5 text-white/35">
We store only what you type here plus your browser type, so we can reproduce the issue.
Nothing is shared publicly.
</p>
</form>
</div>
);
}