Primer vistaso
This commit is contained in:
402
src/components/ContactForm/ContactForm.tsx
Normal file
402
src/components/ContactForm/ContactForm.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, FormEvent } from 'react';
|
||||
import styles from './ContactForm.module.css';
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
phone: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type FormErrors = Partial<Record<keyof FormData, string>>;
|
||||
type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
const initialData: FormData = {
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
phone: '',
|
||||
message: '',
|
||||
};
|
||||
|
||||
function validateForm(data: FormData): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
if (!data.name.trim()) errors.name = 'El nombre es requerido';
|
||||
if (!data.email.trim()) {
|
||||
errors.email = 'El email es requerido';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.email = 'Ingresa un email válido';
|
||||
}
|
||||
if (!data.company.trim()) errors.company = 'La empresa es requerida';
|
||||
if (data.phone && !/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
|
||||
errors.phone = 'Ingresa un teléfono válido';
|
||||
}
|
||||
if (!data.message.trim()) {
|
||||
errors.message = 'El mensaje es requerido';
|
||||
} else if (data.message.trim().length < 10) {
|
||||
errors.message = 'El mensaje debe tener al menos 10 caracteres';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export default function ContactForm() {
|
||||
const [formData, setFormData] = useState<FormData>(initialData);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
|
||||
const [status, setStatus] = useState<SubmitStatus>('idle');
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) entry.target.classList.add(styles.visible);
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
const elements = sectionRef.current?.querySelectorAll(`.${styles.reveal}`);
|
||||
elements?.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
if (touched[name as keyof FormData]) {
|
||||
const newErrors = validateForm({ ...formData, [name]: value });
|
||||
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (
|
||||
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name } = e.target;
|
||||
setTouched((prev) => ({ ...prev, [name]: true }));
|
||||
const newErrors = validateForm(formData);
|
||||
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const allTouched = Object.keys(formData).reduce(
|
||||
(acc, k) => ({ ...acc, [k]: true }),
|
||||
{} as Record<keyof FormData, boolean>
|
||||
);
|
||||
setTouched(allTouched);
|
||||
|
||||
const validationErrors = validateForm(formData);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('loading');
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
setStatus('success');
|
||||
setFormData(initialData);
|
||||
setTouched({});
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('idle');
|
||||
setFormData(initialData);
|
||||
setErrors({});
|
||||
setTouched({});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`section ${styles.contactSection}`}
|
||||
id="contact"
|
||||
ref={sectionRef}
|
||||
aria-labelledby="contact-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<div className={styles.layout}>
|
||||
{/* Left info panel */}
|
||||
<div className={`${styles.reveal} ${styles.infoPanel}`}>
|
||||
<span className="badge badge-dark">Contacto</span>
|
||||
<h2 id="contact-heading" className={styles.title}>
|
||||
Hablemos de
|
||||
<br />
|
||||
tu negocio
|
||||
</h2>
|
||||
<p className={styles.subtitle}>
|
||||
Cuéntanos qué necesitas. Nuestro equipo responderá en menos de 24 horas
|
||||
con una propuesta personalizada.
|
||||
</p>
|
||||
|
||||
{/* Contact details */}
|
||||
<div className={styles.contactDetails}>
|
||||
<div className={styles.contactItem}>
|
||||
<div className={styles.contactIcon}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" stroke="currentColor" strokeWidth="2" />
|
||||
<polyline points="22,6 12,13 2,6" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.contactLabel}>Email</div>
|
||||
<div className={styles.contactValue}>hola@flowsync.io</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.contactItem}>
|
||||
<div className={styles.contactIcon}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.62a19.79 19.79 0 01-3.07-8.63A2 2 0 012.18 0h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L6.91 7.91a16 16 0 006.18 6.18l1.28-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.contactLabel}>Teléfono</div>
|
||||
<div className={styles.contactValue}>+1 (800) 123-4567</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.contactItem}>
|
||||
<div className={styles.contactIcon}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.contactLabel}>Respuesta</div>
|
||||
<div className={styles.contactValue}>Menos de 24 horas</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial */}
|
||||
<blockquote className={styles.testimonial}>
|
||||
<p className={styles.testimonialText}>
|
||||
“FlowSync transformó la forma en que colaboramos. Lo que antes
|
||||
tomaba días, ahora lo hacemos en horas.”
|
||||
</p>
|
||||
<footer className={styles.testimonialAuthor}>
|
||||
<div className={styles.authorAvatar}>MR</div>
|
||||
<div>
|
||||
<div className={styles.authorName}>María Rodríguez</div>
|
||||
<div className={styles.authorRole}>CTO en TechLatam</div>
|
||||
</div>
|
||||
</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
{/* Form panel */}
|
||||
<div className={`${styles.reveal} ${styles.formPanel}`} style={{ transitionDelay: '0.15s' }}>
|
||||
{status === 'success' ? (
|
||||
<div className={styles.successState} role="alert" aria-live="polite">
|
||||
<div className={styles.successIcon}>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||
<path d="M6 16l7 7L26 9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={styles.successTitle}>¡Mensaje enviado!</h3>
|
||||
<p className={styles.successDesc}>
|
||||
Gracias por contactarnos. Nuestro equipo te responderá en menos de 24 horas.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleReset}
|
||||
id="contact-send-another-btn"
|
||||
>
|
||||
Enviar otro mensaje
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
aria-label="Formulario de contacto"
|
||||
id="contact-form"
|
||||
>
|
||||
<div className={styles.formHeader}>
|
||||
<h3 className={styles.formTitle}>Envíanos un mensaje</h3>
|
||||
<p className={styles.formSubtitle}>Todos los campos marcados con * son requeridos</p>
|
||||
</div>
|
||||
|
||||
{/* Row: Name + Email */}
|
||||
<div className={styles.row}>
|
||||
<div className={styles.fieldGroup}>
|
||||
<label htmlFor="contact-name" className={styles.label}>
|
||||
Nombre completo <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
type="text"
|
||||
className={`${styles.input} ${errors.name && touched.name ? styles.inputError : ''}`}
|
||||
placeholder="Juan García"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
autoComplete="name"
|
||||
aria-required="true"
|
||||
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
|
||||
aria-invalid={!!(errors.name && touched.name)}
|
||||
/>
|
||||
{errors.name && touched.name && (
|
||||
<span id="name-error" className={styles.errorMsg} role="alert">
|
||||
{errors.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGroup}>
|
||||
<label htmlFor="contact-email" className={styles.label}>
|
||||
Email <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
type="email"
|
||||
className={`${styles.input} ${errors.email && touched.email ? styles.inputError : ''}`}
|
||||
placeholder="juan@empresa.com"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
autoComplete="email"
|
||||
aria-required="true"
|
||||
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
|
||||
aria-invalid={!!(errors.email && touched.email)}
|
||||
/>
|
||||
{errors.email && touched.email && (
|
||||
<span id="email-error" className={styles.errorMsg} role="alert">
|
||||
{errors.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row: Company + Phone */}
|
||||
<div className={styles.row}>
|
||||
<div className={styles.fieldGroup}>
|
||||
<label htmlFor="contact-company" className={styles.label}>
|
||||
Empresa <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-company"
|
||||
name="company"
|
||||
type="text"
|
||||
className={`${styles.input} ${errors.company && touched.company ? styles.inputError : ''}`}
|
||||
placeholder="Mi Empresa S.A."
|
||||
value={formData.company}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
autoComplete="organization"
|
||||
aria-required="true"
|
||||
aria-describedby={errors.company && touched.company ? 'company-error' : undefined}
|
||||
aria-invalid={!!(errors.company && touched.company)}
|
||||
/>
|
||||
{errors.company && touched.company && (
|
||||
<span id="company-error" className={styles.errorMsg} role="alert">
|
||||
{errors.company}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGroup}>
|
||||
<label htmlFor="contact-phone" className={styles.label}>
|
||||
Teléfono
|
||||
<span className={styles.optional}> (opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
className={`${styles.input} ${errors.phone && touched.phone ? styles.inputError : ''}`}
|
||||
placeholder="+52 55 1234 5678"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
autoComplete="tel"
|
||||
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
|
||||
aria-invalid={!!(errors.phone && touched.phone)}
|
||||
/>
|
||||
{errors.phone && touched.phone && (
|
||||
<span id="phone-error" className={styles.errorMsg} role="alert">
|
||||
{errors.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className={styles.fieldGroup}>
|
||||
<label htmlFor="contact-message" className={styles.label}>
|
||||
Mensaje <span className={styles.required}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
className={`${styles.textarea} ${errors.message && touched.message ? styles.inputError : ''}`}
|
||||
placeholder="Cuéntanos sobre tu proyecto, equipo y qué quieres lograr con FlowSync..."
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
aria-required="true"
|
||||
aria-describedby={errors.message && touched.message ? 'message-error' : undefined}
|
||||
aria-invalid={!!(errors.message && touched.message)}
|
||||
/>
|
||||
<div className={styles.textareaFooter}>
|
||||
{errors.message && touched.message ? (
|
||||
<span id="message-error" className={styles.errorMsg} role="alert">
|
||||
{errors.message}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className={styles.charCount}>
|
||||
{formData.message.length} caracteres
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary btn-lg ${styles.submitBtn}`}
|
||||
disabled={status === 'loading'}
|
||||
id="contact-submit-btn"
|
||||
aria-busy={status === 'loading'}
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<>
|
||||
<span className={styles.spinner} aria-hidden="true" />
|
||||
Enviando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Enviar mensaje
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M2 8h12M10 4l4 4-4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className={styles.privacyNote}>
|
||||
Al enviar, aceptas nuestra{' '}
|
||||
<a href="#" className={styles.privacyLink}>política de privacidad</a>.
|
||||
Nunca compartiremos tu información.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user