1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 20:15:31 -04:00
Files
jarv.is/components/contact-form.tsx
T
jake bcc595e141 refactor: migrate contact form to TanStack Form
- Replace manual state management with @tanstack/react-form
- Add proper Field/FieldLabel components for accessibility
- Simplify server action (remove useActionState signature)
- Remove use-debounce dependency
- Update PGP key links and minor styling tweaks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:08:54 -05:00

202 lines
7.3 KiB
TypeScript

"use client";
import { useState } from "react";
import { useForm } from "@tanstack/react-form";
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
import { MarkdownIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import { sendContactForm, type ContactResult } from "@/lib/server/contact";
import { ContactSchema } from "@/lib/schemas/contact";
const ContactForm = () => {
const [result, setResult] = useState<ContactResult | null>(null);
const form = useForm({
defaultValues: {
name: "",
email: "",
message: "",
},
validators: {
onBlur: ContactSchema,
},
onSubmit: async ({ value }) => {
try {
const formData = new FormData();
formData.append("name", value.name);
formData.append("email", value.email);
formData.append("message", value.message);
const response = await sendContactForm(formData);
setResult(response);
if (response.success) {
form.reset();
}
} catch (error) {
console.error("[contact-form] error:", error);
setResult({
success: false,
message: "Something went wrong. Please try again.",
});
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="my-5 space-y-4"
>
<form.Subscribe selector={(state) => state.isSubmitting || result?.success}>
{(isDisabled) => (
<>
<form.Field name="name">
{(field) => {
const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<Field data-invalid={isInvalid || undefined} className="gap-1.5">
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input
id="name"
name="name"
type="text"
placeholder="Microsoft Bob"
autoComplete="name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
disabled={!!isDisabled}
aria-invalid={isInvalid || undefined}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
<form.Field name="email">
{(field) => {
const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<Field data-invalid={isInvalid || undefined} className="gap-1.5">
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
inputMode="email"
placeholder="robert@hotmail.com"
autoComplete="email"
spellCheck={false}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
disabled={!!isDisabled}
aria-invalid={isInvalid || undefined}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
<form.Field name="message">
{(field) => {
const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<Field data-invalid={isInvalid || undefined} className="gap-1.5">
<FieldLabel htmlFor="message">Message</FieldLabel>
<Textarea
id="message"
name="message"
placeholder="Write something…"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
disabled={!!isDisabled}
aria-invalid={isInvalid || undefined}
className="min-h-[6lh] resize-y"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
<p className="text-foreground/85 mt-1.5 text-[0.8rem] leading-relaxed">
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" />
Basic{" "}
<a
href="https://commonmark.org/help/"
target="_blank"
rel="noopener noreferrer"
title="Markdown reference sheet"
className="font-semibold"
>
Markdown syntax
</a>{" "}
is allowed, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
<a href="https://jarv.is" target="_blank" rel="noopener" className="hover:no-underline">
links
</a>
](https://jarv.is), and <code>`code`</code>.
</p>
</Field>
);
}}
</form.Field>
</>
)}
</form.Subscribe>
<div className="flex min-h-16 items-center space-x-4">
<form.Subscribe selector={(state) => [, state.isSubmitting]}>
{([isSubmitting]) => (
<>
{!result?.success && (
<Button type="submit" size="lg" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2Icon className="animate-spin" aria-hidden="true" />
Sending
</>
) : (
<>
<SendIcon aria-hidden="true" />
Send
</>
)}
</Button>
)}
{!isSubmitting && result?.message && (
<div
role="status"
aria-live="polite"
className={cn(
"space-x-0.5 text-[0.9rem] font-semibold",
result.success ? "text-green-600 dark:text-green-400" : "text-destructive"
)}
>
{result.success ? (
<CheckIcon className="inline size-4" aria-hidden="true" />
) : (
<XIcon className="inline size-4" aria-hidden="true" />
)}{" "}
<span>{result.message}</span>
</div>
)}
</>
)}
</form.Subscribe>
</div>
</form>
);
};
export { ContactForm };