first commit
This commit is contained in:
86
components/forms/ContactoForm.tsx
Normal file
86
components/forms/ContactoForm.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ContactoSchema, ContactoFormData } from "@/utils/validations";
|
||||
import { useFormSubmit } from "@/hooks/useForm";
|
||||
import { Input } from "../ui/Input";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { Textarea } from "../ui/Textarea";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useLanguage } from "@/context/LanguageContext";
|
||||
|
||||
export const ContactoForm = () => {
|
||||
const { t, locale } = useLanguage();
|
||||
const { submit, isSubmitting, success, error } = useFormSubmit("contactos");
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, reset } = useForm<ContactoFormData>({
|
||||
resolver: zodResolver(ContactoSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ContactoFormData) => {
|
||||
await submit(data);
|
||||
if (!error) reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AnimatePresence mode="wait">
|
||||
{success ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-10 border border-ocean rounded-[24px] text-center bg-ice/10"
|
||||
>
|
||||
<h3 className="text-xl font-bold text-navy mb-2">
|
||||
{t.contacto.success.title}
|
||||
</h3>
|
||||
<p className="text-navy/60">
|
||||
{t.contacto.success.desc}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<Input
|
||||
label={t.contacto.form.name}
|
||||
placeholder={locale === 'en' ? "Your name" : locale === 'ca' ? "El teu nom" : "Tu nombre"}
|
||||
{...register("nombre")}
|
||||
error={errors.nombre?.message}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t.contacto.form.email}
|
||||
placeholder="tu@email.com"
|
||||
{...register("email")}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={t.contacto.form.message}
|
||||
placeholder={locale === 'en' ? "Write your message here..." : locale === 'ca' ? "Escriu aquí el teu missatge..." : "Escribe aquí tu mensaje..."}
|
||||
rows={5}
|
||||
{...register("mensaje")}
|
||||
error={errors.mensaje?.message}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={t.contacto.form.privacy}
|
||||
{...register("privacidad")}
|
||||
error={errors.privacidad?.message}
|
||||
/>
|
||||
|
||||
{error && <p className="text-red-600 font-bold text-sm">{error}</p>}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting
|
||||
? (locale === 'en' ? "Sending..." : locale === 'ca' ? "Enviant..." : "Enviando...")
|
||||
: t.contacto.form.submit}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
305
components/forms/ParticipaForm.tsx
Normal file
305
components/forms/ParticipaForm.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ParticipaSchema, ParticipaFormData } from "@/utils/validations";
|
||||
import { Input } from "../ui/Input";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { Textarea } from "../ui/Textarea";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useLanguage } from "@/context/LanguageContext";
|
||||
import { supabase } from "@/lib/supabase/client";
|
||||
|
||||
export const ParticipaForm = () => {
|
||||
const { t, locale } = useLanguage();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const [campanas, setCampanas] = useState<any[]>([]);
|
||||
|
||||
// Estados para manejar las preguntas dinámicas (El Screener)
|
||||
const [preguntasDinamicas, setPreguntasDinamicas] = useState<any[]>([]);
|
||||
const [respuestasDinamicas, setRespuestasDinamicas] = useState<Record<string, any>>({});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
reset
|
||||
} = useForm<ParticipaFormData>({
|
||||
resolver: zodResolver(ParticipaSchema),
|
||||
defaultValues: {
|
||||
interes: "" as any
|
||||
}
|
||||
});
|
||||
|
||||
// Vigila en tiempo real qué campaña ha seleccionado el usuario
|
||||
const campanaSeleccionadaId = watch("interes");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCampanas = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("campanas")
|
||||
.select(`
|
||||
id,
|
||||
nombre,
|
||||
encuestas ( configuracion )
|
||||
`)
|
||||
.eq('estado', 'activa');
|
||||
|
||||
if (data && !error) {
|
||||
setCampanas(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error al cargar las campañas:", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCampanas();
|
||||
}, []);
|
||||
|
||||
// Efecto que se dispara cuando el usuario cambia de campaña en el select
|
||||
useEffect(() => {
|
||||
if (campanaSeleccionadaId) {
|
||||
const campana = campanas.find(c => c.id === campanaSeleccionadaId);
|
||||
|
||||
if (campana && campana.encuestas && campana.encuestas.length > 0) {
|
||||
const config = campana.encuestas[0].configuracion;
|
||||
if (config && config.preguntas_filtro) {
|
||||
setPreguntasDinamicas(config.preguntas_filtro);
|
||||
setRespuestasDinamicas({});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setPreguntasDinamicas([]);
|
||||
setRespuestasDinamicas({});
|
||||
}, [campanaSeleccionadaId, campanas]);
|
||||
|
||||
// Manejador para los cambios en los inputs dinámicos
|
||||
const handleDynamicChange = (preguntaId: string, valor: any) => {
|
||||
setRespuestasDinamicas(prev => ({
|
||||
...prev,
|
||||
[preguntaId]: valor
|
||||
}));
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ParticipaFormData) => {
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg(null);
|
||||
|
||||
const payload: any = {
|
||||
datos_participante: {
|
||||
nombre: data.nombre,
|
||||
apellidos: data.apellidos,
|
||||
email: data.email,
|
||||
telefono: data.telefono,
|
||||
perfil_demografico: {
|
||||
provincia: data.provincia,
|
||||
edad: Number(data.edad),
|
||||
mensaje: data.mensaje || ""
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (data.interes && data.interes !== "") {
|
||||
payload.campana_id = data.interes;
|
||||
payload.datos_especificos = respuestasDinamicas;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: responseData, error } = await supabase.functions.invoke('validate-participant-data', {
|
||||
body: payload
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Error al procesar la solicitud en el servidor.");
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
reset();
|
||||
} catch (err: any) {
|
||||
console.error("Error en Screener:", err);
|
||||
setErrorMsg(err.message || "Hubo un problema de conexión al enviar tus datos.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AnimatePresence mode="wait">
|
||||
{success ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="p-12 border border-ocean rounded-[32px] text-center bg-ice/10"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-navy mb-4">
|
||||
{t.participa.success.title}
|
||||
</h3>
|
||||
<p className="text-navy/70 mb-8">
|
||||
{t.participa.success.desc}
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
{t.participa.success.btn}
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Input label={t.participa.fields.nombre} {...register("nombre")} error={errors.nombre?.message} />
|
||||
<Input label={t.participa.fields.apellidos} {...register("apellidos")} error={errors.apellidos?.message} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Input label={t.participa.fields.email} type="email" {...register("email")} error={errors.email?.message} />
|
||||
<Input label={t.participa.fields.telefono} type="tel" {...register("telefono")} error={errors.telefono?.message} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Input label={t.participa.fields.provincia} {...register("provincia")} error={errors.provincia?.message} />
|
||||
<Input label={t.participa.fields.edad} type="number" {...register("edad")} error={errors.edad?.message} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-extrabold text-navy uppercase tracking-widest">
|
||||
{locale === 'en' ? "Select a Campaign (Optional)" : locale === 'ca' ? "Selecciona una Campanya (Opcional)" : "Selecciona una Campaña (Opcional)"}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
{...register("interes")}
|
||||
className={`
|
||||
w-full bg-ice/10 border rounded-[12px] px-4 py-3.5 text-[15px]
|
||||
font-medium text-navy outline-none focus:border-ocean focus:bg-white
|
||||
transition-colors appearance-none cursor-pointer
|
||||
${errors.interes ? "border-red-500" : "border-crisp"}
|
||||
`}
|
||||
>
|
||||
<option value="">
|
||||
{locale === 'en' ? "None / General Registration" : locale === 'ca' ? "Cap / Registre General" : "Ninguna / Registro General"}
|
||||
</option>
|
||||
|
||||
{campanas.map((campana) => (
|
||||
<option key={campana.id} value={campana.id}>
|
||||
{campana.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none opacity-40 text-navy">
|
||||
<svg width="12" height="8" viewBox="0 0 12 8" fill="none" stroke="currentColor">
|
||||
<path d="M1 1L6 6L11 1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{errors.interes && <span className="text-red-500 text-xs font-bold">{errors.interes.message}</span>}
|
||||
</div>
|
||||
|
||||
{/* --- BLOQUE DINÁMICO MULTIDIOMA: EL SCREENER --- */}
|
||||
{preguntasDinamicas.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
className="p-6 bg-sky/5 border border-sky/20 rounded-[20px] space-y-6 mt-4"
|
||||
>
|
||||
<h4 className="text-[15px] font-extrabold text-ocean uppercase tracking-widest border-b border-sky/20 pb-2">
|
||||
{locale === 'en' ? "Specific Campaign Questions" : locale === 'ca' ? "Preguntes de la Campanya" : "Preguntas de la Campaña"}
|
||||
</h4>
|
||||
|
||||
{preguntasDinamicas.map((pregunta) => {
|
||||
|
||||
// HELPER MULTIDIOMA: Lee el JSON para el idioma actual o usa el string antiguo
|
||||
const obtenerTexto = (campo: any) => {
|
||||
if (!campo) return "";
|
||||
if (typeof campo === 'string') return campo;
|
||||
return campo[locale] || campo['es'] || "";
|
||||
};
|
||||
|
||||
const obtenerOpciones = (campo: any): string[] => {
|
||||
if (!campo) return [];
|
||||
if (Array.isArray(campo)) return campo;
|
||||
return campo[locale] || campo['es'] || [];
|
||||
};
|
||||
|
||||
const etiquetaTraducida = obtenerTexto(pregunta.etiqueta);
|
||||
const opcionesTraducidas = obtenerOpciones(pregunta.opciones);
|
||||
|
||||
return (
|
||||
<div key={pregunta.id_pregunta} className="flex flex-col gap-2">
|
||||
<label className="text-[14px] font-bold text-navy">
|
||||
{etiquetaTraducida} {pregunta.requerido && <span className="text-ocean">*</span>}
|
||||
</label>
|
||||
|
||||
{pregunta.tipo === "select" && (
|
||||
<select
|
||||
required={pregunta.requerido}
|
||||
onChange={(e) => handleDynamicChange(pregunta.id_pregunta, e.target.value)}
|
||||
className="w-full bg-white border border-crisp rounded-[12px] px-4 py-3 text-[15px] font-medium text-navy focus:border-ocean outline-none"
|
||||
>
|
||||
<option value="">{locale === 'en' ? "Select..." : locale === 'ca' ? "Selecciona..." : "Selecciona..."}</option>
|
||||
{opcionesTraducidas.map((opt: string) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{pregunta.tipo === "checkbox" && (
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{opcionesTraducidas.map((opt: string) => (
|
||||
<label key={opt} className="flex items-center gap-2 text-[14px] text-navy/80 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={opt}
|
||||
onChange={(e) => {
|
||||
const currentAnswers = respuestasDinamicas[pregunta.id_pregunta] || [];
|
||||
let newAnswers;
|
||||
if (e.target.checked) {
|
||||
newAnswers = [...currentAnswers, opt];
|
||||
} else {
|
||||
newAnswers = currentAnswers.filter((a: string) => a !== opt);
|
||||
}
|
||||
handleDynamicChange(pregunta.id_pregunta, newAnswers);
|
||||
}}
|
||||
className="w-4 h-4 rounded border-crisp text-ocean focus:ring-ocean"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
{/* --- FIN BLOQUE DINÁMICO --- */}
|
||||
|
||||
<Textarea label={t.participa.fields.mensaje} rows={3} {...register("mensaje")} error={errors.mensaje?.message} />
|
||||
|
||||
<Checkbox label={t.contacto.form.privacy} {...register("privacidad")} error={errors.privacidad?.message} />
|
||||
|
||||
{errorMsg && (
|
||||
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-red-600 bg-red-50 p-4 rounded-lg text-sm font-bold">
|
||||
{errorMsg}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full md:w-auto" variant="primary">
|
||||
{isSubmitting
|
||||
? (locale === 'en' ? "Sending application..." : locale === 'ca' ? "Enviant sol·licitud..." : "Enviando solicitud...")
|
||||
: (locale === 'en' ? "Send application" : locale === 'ca' ? "Enviar sol·licitud" : "Enviar solicitud")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
components/layout/Footer.tsx
Normal file
46
components/layout/Footer.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useLanguage } from "@/context/LanguageContext";
|
||||
|
||||
export const Footer = () => {
|
||||
const { t } = useLanguage();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="w-full bg-white pt-20 pb-10 border-t border-crisp mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-12">
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center pb-12 mb-8">
|
||||
{/* Logo / Brand */}
|
||||
<div className="text-[32px] font-extrabold text-navy tracking-tight mb-8 md:mb-0">
|
||||
ITEM OPINA.
|
||||
</div>
|
||||
|
||||
{/* Nav Links Traducidos */}
|
||||
<div className="flex flex-col sm:flex-row gap-6 sm:gap-12 text-[14px] font-bold text-navy/60">
|
||||
<Link href="/legal" className="hover:text-ocean transition-colors">
|
||||
{t.footer.legal}
|
||||
</Link>
|
||||
<Link href="/privacidad" className="hover:text-ocean transition-colors">
|
||||
{t.footer.privacy}
|
||||
</Link>
|
||||
<Link href="/cookies" className="hover:text-ocean transition-colors">
|
||||
{t.footer.cookies}
|
||||
</Link>
|
||||
<Link href="/contacto" className="hover:text-ocean transition-colors">
|
||||
{t.footer.contact}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Credits Traducidos */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center text-[13px] text-navy/40 font-semibold uppercase tracking-wider">
|
||||
<p>
|
||||
{t.footer.rights.replace("2026", currentYear.toString())}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
145
components/layout/Navbar.tsx
Normal file
145
components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button } from "../ui/Button";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { FiMenu, FiX, FiGlobe } from "react-icons/fi";
|
||||
import { useLanguage } from "@/context/LanguageContext";
|
||||
|
||||
export const Navbar = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { locale, setLocale, t } = useLanguage();
|
||||
|
||||
const isActive = (path: string) => pathname === path;
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const navLinks = [
|
||||
{ name: t.navbar.consultora, href: "/nosotros" },
|
||||
{ name: t.navbar.servicios, href: "/servicios" },
|
||||
{ name: t.navbar.participa, href: "/participa" },
|
||||
{ name: t.navbar.insights, href: "/insights" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="w-full h-[80px] bg-white/80 backdrop-blur-md border-b border-crisp sticky top-0 z-[100]">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-12 h-full flex justify-between items-center">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="text-[20px] md:text-[22px] font-extrabold tracking-tight text-navy uppercase">
|
||||
Item Opina.
|
||||
</Link>
|
||||
|
||||
{/* Menú Central (Escritorio) */}
|
||||
<div className="hidden md:flex space-x-8 lg:space-x-10 text-[13px] lg:text-[14px] font-bold uppercase tracking-wider">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`${isActive(link.href) ? "text-ocean" : "text-navy/60"} hover:text-ocean transition-colors`}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Idiomas y Acción (Escritorio) */}
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
{/* Selector de Idiomas */}
|
||||
<div className="flex items-center gap-3 pr-6">
|
||||
{(["es", "ca", "en"] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => setLocale(lang)}
|
||||
className={`text-[11px] font-black uppercase transition-all ${
|
||||
locale === lang ? "text-ocean scale-110" : "text-navy/30 hover:text-navy"
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link href="/contacto">
|
||||
<Button
|
||||
variant={isActive("/contacto") ? "primary" : "outline"}
|
||||
className="!py-2.5 !px-6 text-[12px]"
|
||||
>
|
||||
{t.navbar.contacto}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Botón Hamburguesa (Móvil) */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden text-navy p-2 outline-none focus:outline-none ring-0"
|
||||
aria-label="Toggle Menu"
|
||||
>
|
||||
{isOpen ? <FiX size={28} /> : <FiMenu size={28} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Menú Móvil (Overlay) */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed inset-0 top-[80px] w-full h-[calc(100vh-80px)] bg-white z-[90] flex flex-col p-8 md:hidden"
|
||||
>
|
||||
<div className="flex flex-col space-y-6 mt-6">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`text-[32px] font-extrabold uppercase tracking-tighter ${
|
||||
isActive(link.href) ? "text-ocean" : "text-navy"
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="pt-8 border-t border-crisp flex flex-col gap-8">
|
||||
{/* Selector de Idiomas Móvil */}
|
||||
<div className="flex gap-6">
|
||||
{(["es", "ca", "en"] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => setLocale(lang)}
|
||||
className={`text-[16px] font-bold uppercase ${
|
||||
locale === lang ? "text-ocean border-b-2 border-ocean" : "text-navy/40"
|
||||
}`}
|
||||
>
|
||||
{lang === "es" ? "Español" : lang === "ca" ? "Català" : "English"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link href="/contacto">
|
||||
<Button variant="primary" className="w-full !py-5 text-[16px] uppercase tracking-widest font-bold">
|
||||
{t.navbar.contacto}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
26
components/ui/Badge.tsx
Normal file
26
components/ui/Badge.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente Badge para etiquetas de sección con estilo Flat Premium.
|
||||
*
|
||||
*/
|
||||
export const Badge = ({ children }: BadgeProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-block bg-ice/30 px-4 py-1.5 rounded-full mb-8 border border-ice"
|
||||
>
|
||||
<span className="text-[12px] font-bold tracking-[0.1em] uppercase text-ocean">
|
||||
{children}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
9
components/ui/BentoGrid.tsx
Normal file
9
components/ui/BentoGrid.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const BentoGrid = ({ children, className = "" }: { children: ReactNode; className?: string }) => {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-3 gap-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
components/ui/Button.tsx
Normal file
44
components/ui/Button.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import { motion, HTMLMotionProps } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
// Extendemos de HTMLMotionProps para heredar todos los atributos nativos de un botón (incluyendo disabled)
|
||||
interface ButtonProps extends HTMLMotionProps<"button"> {
|
||||
children: ReactNode;
|
||||
variant?: "primary" | "outline" | "ocean";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
children,
|
||||
variant = "primary",
|
||||
className = "",
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
|
||||
const variants = {
|
||||
primary: "bg-navy text-white hover:bg-ocean disabled:bg-navy/50",
|
||||
outline: "border-2 border-navy text-navy hover:bg-navy hover:text-white disabled:opacity-50",
|
||||
ocean: "bg-ocean text-white hover:bg-sky disabled:bg-ocean/50",
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
// Los botones en sistemas tipo Together AI tienen interacciones táctiles sutiles
|
||||
whileHover={disabled ? {} : { scale: 1.02 }}
|
||||
whileTap={disabled ? {} : { scale: 0.98 }}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
px-8 py-4 rounded-full font-bold text-[14px]
|
||||
transition-all duration-300 uppercase tracking-tight
|
||||
disabled:cursor-not-allowed
|
||||
${variants[variant]}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
);
|
||||
};
|
||||
49
components/ui/Card.tsx
Normal file
49
components/ui/Card.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
// Utilidad para unir clases de Tailwind
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
variant?: "white" | "ice" | "navy" | "ocean" | "none";
|
||||
}
|
||||
|
||||
export const Card = ({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
variant = "white"
|
||||
}: CardProps) => {
|
||||
|
||||
const variants = {
|
||||
white: "bg-white border-crisp hover:border-sky",
|
||||
ice: "bg-ice/20 border-crisp hover:bg-ice/40",
|
||||
navy: "bg-navy border-navy text-white",
|
||||
ocean: "bg-ocean border-ocean text-white",
|
||||
none: "border-crisp"
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={cn(
|
||||
"rounded-[32px] border p-10 flex flex-col transition-all duration-500",
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
36
components/ui/Checkbox.tsx
Normal file
36
components/ui/Checkbox.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ label, error, className = "", ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
className={`
|
||||
mt-1 w-4 h-4 rounded border-crisp text-ocean
|
||||
focus:ring-ocean focus:ring-offset-0 cursor-pointer
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
<label className="text-[13px] text-navy/60 font-medium leading-tight cursor-pointer">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-red-500 text-xs font-bold">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Checkbox.displayName = "Checkbox";
|
||||
38
components/ui/Input.tsx
Normal file
38
components/ui/Input.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils"; // Ahora sí existe
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, type, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
{label && (
|
||||
<label className="text-[13px] font-extrabold text-navy uppercase tracking-widest">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Clases base: Flat Premium
|
||||
"w-full bg-ice/10 border rounded-[12px] px-4 py-3.5 text-[15px] font-medium text-navy transition-all duration-200",
|
||||
"placeholder:text-navy/30 focus:bg-white focus:border-ocean",
|
||||
// MATAMOS LA LÍNEA AZUL AQUÍ:
|
||||
"outline-none ring-0 focus:ring-0 focus:ring-offset-0 ring-offset-transparent",
|
||||
error ? "border-red-500" : "border-crisp",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className="text-red-500 text-xs font-bold">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
34
components/ui/Textarea.tsx
Normal file
34
components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ label, error, className = "", ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label className="text-[13px] font-extrabold text-navy uppercase tracking-widest">
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full bg-ice/10 border rounded-[12px] px-4 py-3.5 text-[15px]
|
||||
font-medium text-navy transition-all duration-200 resize-none
|
||||
placeholder:text-navy/30 outline-none
|
||||
focus:border-ocean focus:bg-white
|
||||
${error ? "border-red-500" : "border-crisp"}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <span className="text-red-500 text-xs font-bold">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
Reference in New Issue
Block a user