SPB Git forge

spb/hilmacorp

Public
1commits 1branches 0releases
29.2 MBsize
maindefault branch
19 days agolast push
TypeScript 93.3% JavaScript 4.4% CSS 2.3%
5.1 KB · 155 lines tsx
Raw Blame History
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