spb/phi-conseil
Public
1import { z } from "zod";23/** Supprime les caractères de contrôle et normalise les espaces. */4export function cleanText(value: string): string {5 return value6 .normalize("NFC")7 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")8 .replace(/[ \t]+/g, " ")9 .trim();10}1112/** Une seule ligne : empêche l'injection d'en-têtes dans les courriels. */13const singleLine = (max: number) =>14 z15 .string()16 .transform(cleanText)17 .pipe(18 z19 .string()20 .min(1, "Ce champ est requis.")21 .max(max, `Maximum ${max} caractères.`)22 .refine((v) => !/[\r\n]/.test(v), "Les retours à la ligne ne sont pas permis."),23 );2425export const contactSchema = z.object({26 name: singleLine(120),27 email: z28 .string()29 .transform((v) => cleanText(v).toLowerCase())30 .pipe(z.email("Adresse courriel invalide.").max(254)),31 organization: z32 .string()33 .optional()34 .default("")35 .transform(cleanText)36 .pipe(z.string().max(160, "Maximum 160 caractères.")),37 subject: singleLine(160),38 message: z39 .string()40 .transform((v) => v.normalize("NFC").replace(/\r\n?/g, "\n").trim())41 .pipe(42 z43 .string()44 .min(20, "Décrivez votre demande en au moins 20 caractères.")45 .max(4000, "Maximum 4 000 caractères."),46 ),47 consent: z.literal(true, {48 error: "Votre consentement est requis pour traiter la demande.",49 }),50 /** Honeypot : doit rester vide (champ invisible pour les humains). */51 website: z.string().max(0).optional().default(""),52 /** Horodatage du rendu du formulaire : rejette les soumissions instantanées. */53 startedAt: z.coerce.number().int().nonnegative().optional(),54});5556export type ContactInput = z.input<typeof contactSchema>;57export type ContactData = z.output<typeof contactSchema>;5859/** Schéma côté client (sans honeypot/horodatage, consentement booléen). */60export const contactFormSchema = contactSchema.omit({61 website: true,62 startedAt: true,63});64export type ContactFormValues = z.input<typeof contactFormSchema>;65