From 4014ec802a564250ce282ed5859a597b8a01a19b Mon Sep 17 00:00:00 2001 From: bagas Date: Sun, 22 Feb 2026 14:41:52 +0700 Subject: [PATCH] feat: integrate submit form answer with backend --- app/routes.ts | 1 + app/routes/form.tsx | 10 +- app/routes/submit-form.tsx | 440 +++++++++++++++++++++++++++++++++++++ lib/api.ts | 15 +- lib/types.ts | 9 + 5 files changed, 470 insertions(+), 5 deletions(-) create mode 100644 app/routes/submit-form.tsx diff --git a/app/routes.ts b/app/routes.ts index 59661c4..e6e8c85 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -7,6 +7,7 @@ export default [ route("forms", "routes/forms.tsx"), route("forms/new", "routes/create-form.tsx"), route("form/:id", "routes/form.tsx"), + route("form/:id/submit", "routes/submit-form.tsx"), route("form/:id/edit", "routes/edit-form.tsx"), route("*", "routes/not-found.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/form.tsx b/app/routes/form.tsx index 2cecbb0..48b8058 100644 --- a/app/routes/form.tsx +++ b/app/routes/form.tsx @@ -175,11 +175,13 @@ export default function FormPreviewPage() {

- This is a read-only preview. Form submission is disabled. + This is a read-only preview. Click below to fill out this form.

- - Submit (Preview Mode) - + + + Fill Out Form + +
diff --git a/app/routes/submit-form.tsx b/app/routes/submit-form.tsx new file mode 100644 index 0000000..6d8f026 --- /dev/null +++ b/app/routes/submit-form.tsx @@ -0,0 +1,440 @@ +import { useState, useEffect } from "react" +import { useParams, Link } from "react-router" +import { + ArrowLeft, + CheckCircle2, + FileText, + Star, + ChevronDown, + Calendar, + Send, +} from "lucide-react" +import { Navbar } from "@/components/shared/navbar" +import { Footer } from "@/components/shared/footer" +import { FormButton } from "@/components/shared/form-button" +import { getFormById, submitFormResponse } from "@/lib/api" +import type { FormDetail, Question } from "@/lib/types" + +export default function SubmitFormPage() { + const { id } = useParams() + const [form, setForm] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [answers, setAnswers] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [validationErrors, setValidationErrors] = useState>({}) + + useEffect(() => { + if (!id) return + + async function fetchForm() { + try { + const data = await getFormById(id!) + setForm(data) + // Initialize empty answers + const initial: Record = {} + data.questions.forEach((q) => { + initial[q.id] = "" + }) + setAnswers(initial) + } catch (err) { + if (err instanceof Response && err.status === 404) { + setError("Form not found") + } else { + setError("Failed to load form. Please try again.") + } + } finally { + setLoading(false) + } + } + fetchForm() + }, [id]) + + function updateAnswer(questionId: string, value: string) { + setAnswers((prev) => ({ ...prev, [questionId]: value })) + // Clear validation error on change + if (validationErrors[questionId]) { + setValidationErrors((prev) => { + const next = { ...prev } + delete next[questionId] + return next + }) + } + } + + function validate(): boolean { + if (!form) return false + const errors: Record = {} + + form.questions.forEach((q) => { + if (q.required && !answers[q.id]?.trim()) { + errors[q.id] = "This question is required" + } + }) + + setValidationErrors(errors) + return Object.keys(errors).length === 0 + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!id || !form) return + + if (!validate()) { + // Scroll to first error + const firstErrorId = form.questions.find((q) => validationErrors[q.id] || (q.required && !answers[q.id]?.trim()))?.id + if (firstErrorId) { + document.getElementById(`question-${firstErrorId}`)?.scrollIntoView({ behavior: "smooth", block: "center" }) + } + return + } + + setSubmitting(true) + setError(null) + try { + await submitFormResponse(id, { + answers: form.questions + .filter((q) => answers[q.id]?.trim()) + .map((q) => ({ + question_id: q.id, + answer: answers[q.id].trim(), + })), + }) + setSubmitted(true) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit response") + } finally { + setSubmitting(false) + } + } + + if (loading) { + return ( +
+

Loading form...

+
+ ) + } + + if (error && !form) { + return ( +
+ +
+
+

{error}

+ + + Back to forms + +
+
+
+
+ ) + } + + if (submitted) { + return ( +
+ +
+
+
+ +
+

+ Response Submitted! +

+

+ Thank you for filling out {form?.title}. + Your response has been recorded. +

+
+ + { + setSubmitted(false) + // Reset answers + if (form) { + const initial: Record = {} + form.questions.forEach((q) => { initial[q.id] = "" }) + setAnswers(initial) + } + }} + > + Submit another response + + + + + + Back to forms + + +
+
+
+
+
+ ) + } + + if (!form) return null + + return ( +
+ + +
+
+ + + Back to form preview + + +
+
+
+

+ {form.title} +

+

+ {form.description} +

+
+ + {form.questions.length} questions + * Required +
+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {form.questions.map((question, index) => ( + updateAnswer(question.id, val)} + error={validationErrors[question.id]} + /> + ))} + +
+ + {submitting ? ( + "Submitting..." + ) : ( + <> + + Submit + + )} + + +
+ +
+
+ +
+
+ ) +} + +// ─── Question field component ──────────────────────────────────────────────── + +interface QuestionFieldProps { + question: Question + index: number + value: string + onChange: (value: string) => void + error?: string +} + +function QuestionField({ question, index, value, onChange, error }: QuestionFieldProps) { + return ( +
+
+ + {index + 1} + +
+

+ {question.title} + {question.required && ( + * + )} +

+

+ {question.type.replace("_", " ")} +

+
+
+ +
+ {question.type === "short_text" && ( + onChange(e.target.value)} + placeholder="Your answer" + className="w-full rounded-lg border border-input bg-background px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + )} + + {question.type === "long_text" && ( +