TypeScript 93.3%
JavaScript 4.4%
CSS 2.3%
1/**2 * Author: Simon-Pierre Boucher3 * Contact: contact@spboucher.ai4 * Project: Hilmacorp.ai — Web Platform5 * File: components/ContactForm.tsx6 * Description: Client-side contact form with inline validation, posting to /api/contact (light theme)7 */89"use client";1011import { useState, type FormEvent } from "react";1213interface FormStrings {14 name: string;15 namePlaceholder: string;16 organization: string;17 organizationPlaceholder: string;18 email: string;19 emailPlaceholder: string;20 message: string;21 messagePlaceholder: string;22 submit: string;23 sending: string;24 success: string;25 error: string;26 errName: string;27 errEmail: string;28 errMessage: string;29}3031type Status = "idle" | "sending" | "success" | "error";3233const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;3435export default function ContactForm({ strings }: { strings: FormStrings }) {36 const [status, setStatus] = useState<Status>("idle");37 const [errors, setErrors] = useState<Record<string, string>>({});3839 async function handleSubmit(event: FormEvent<HTMLFormElement>) {40 event.preventDefault();41 const form = event.currentTarget;42 const payload = {43 name: (form.elements.namedItem("name") as HTMLInputElement).value.trim(),44 organization: (form.elements.namedItem("organization") as HTMLInputElement).value.trim(),45 email: (form.elements.namedItem("email") as HTMLInputElement).value.trim(),46 message: (form.elements.namedItem("message") as HTMLTextAreaElement).value.trim(),47 };4849 const nextErrors: Record<string, string> = {};50 if (payload.name.length < 2) nextErrors.name = strings.errName;51 if (!EMAIL_RE.test(payload.email)) nextErrors.email = strings.errEmail;52 if (payload.message.length < 10) nextErrors.message = strings.errMessage;53 setErrors(nextErrors);54 if (Object.keys(nextErrors).length > 0) return;5556 setStatus("sending");57 try {58 const response = await fetch("/api/contact", {59 method: "POST",60 headers: { "Content-Type": "application/json" },61 body: JSON.stringify(payload),62 });63 if (!response.ok) throw new Error(`HTTP ${response.status}`);64 setStatus("success");65 form.reset();66 } catch {67 setStatus("error");68 }69 }7071 const fieldClass =72 "w-full rounded-xl border border-line-strong bg-card px-4 py-3 text-sm text-ink placeholder:text-ink-faint focus:border-accent focus:outline-none";7374 return (75 <form onSubmit={handleSubmit} noValidate className="flex flex-col gap-5">76 <div className="grid gap-5 sm:grid-cols-2">77 <div>78 <label htmlFor="name" className="mb-1.5 block text-sm font-medium text-ink">79 {strings.name}80 </label>81 <input82 id="name"83 name="name"84 type="text"85 autoComplete="name"86 required87 placeholder={strings.namePlaceholder}88 className={fieldClass}89 aria-invalid={Boolean(errors.name)}90 />91 {errors.name ? <p className="mt-1.5 text-xs text-red-700">{errors.name}</p> : null}92 </div>93 <div>94 <label htmlFor="organization" className="mb-1.5 block text-sm font-medium text-ink">95 {strings.organization}96 </label>97 <input98 id="organization"99 name="organization"100 type="text"101 autoComplete="organization"102 placeholder={strings.organizationPlaceholder}103 className={fieldClass}104 />105 </div>106 </div>107 <div>108 <label htmlFor="email" className="mb-1.5 block text-sm font-medium text-ink">109 {strings.email}110 </label>111 <input112 id="email"113 name="email"114 type="email"115 autoComplete="email"116 required117 placeholder={strings.emailPlaceholder}118 className={fieldClass}119 aria-invalid={Boolean(errors.email)}120 />121 {errors.email ? <p className="mt-1.5 text-xs text-red-700">{errors.email}</p> : null}122 </div>123 <div>124 <label htmlFor="message" className="mb-1.5 block text-sm font-medium text-ink">125 {strings.message}126 </label>127 <textarea128 id="message"129 name="message"130 rows={6}131 required132 placeholder={strings.messagePlaceholder}133 className={fieldClass}134 aria-invalid={Boolean(errors.message)}135 />136 {errors.message ? <p className="mt-1.5 text-xs text-red-700">{errors.message}</p> : null}137 </div>138139 <div className="flex flex-col gap-4 sm:flex-row sm:items-center">140 <button141 type="submit"142 disabled={status === "sending"}143 className="rounded-full bg-ink px-7 py-3 text-sm font-medium text-paper transition-colors hover:bg-accent-strong disabled:opacity-60"144 >145 {status === "sending" ? strings.sending : strings.submit}146 </button>147 <p aria-live="polite" className="text-sm">148 {status === "success" ? <span className="text-accent-strong">{strings.success}</span> : null}149 {status === "error" ? <span className="text-red-700">{strings.error}</span> : null}150 </p>151 </div>152 </form>153 );154}155