first commit
This commit is contained in:
99
app/contacto/page.tsx
Normal file
99
app/contacto/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { ContactoForm } from "@/components/forms/ContactoForm";
|
||||||
|
import { FiMapPin, FiMail, FiPhone } from "react-icons/fi";
|
||||||
|
import { useLanguage } from "@/context/LanguageContext";
|
||||||
|
|
||||||
|
export default function ContactoPage() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const infoContacto = [
|
||||||
|
{
|
||||||
|
icon: <FiMapPin />,
|
||||||
|
label: t.contacto.info.address,
|
||||||
|
value: "Av. de Madrid, 138, Sants-Montjuïc, 08028 Barcelona",
|
||||||
|
href: "https://www.google.com/maps/search/?api=1&query=Av.+de+Madrid,+138,+08028+Barcelona",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FiMail />,
|
||||||
|
label: t.contacto.info.email,
|
||||||
|
value: "jordi@itemopina.com",
|
||||||
|
href: "mailto:jordi@itemopina.com",
|
||||||
|
isEmail: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FiPhone />,
|
||||||
|
label: t.contacto.info.phone,
|
||||||
|
value: "650 066 923",
|
||||||
|
href: "tel:+34650066923",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Lógica para resaltar la última palabra del título en color ocean
|
||||||
|
const titleWords = t.contacto.heroTitle.split(" ");
|
||||||
|
const lastWord = titleWords.pop();
|
||||||
|
const mainTitle = titleWords.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full flex-grow py-16 md:py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 lg:px-12">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
||||||
|
|
||||||
|
{/* Columna de Información */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="lg:col-span-5 flex flex-col justify-start"
|
||||||
|
>
|
||||||
|
<Badge>{t.contacto.badge || "Contacto Directo"}</Badge>
|
||||||
|
|
||||||
|
<h1 className="text-[48px] md:text-[64px] font-extrabold text-navy leading-[1.05] tracking-tight mb-6">
|
||||||
|
{mainTitle} <br />
|
||||||
|
<span className="text-ocean">{lastWord}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-[16px] text-navy/70 leading-[1.6] font-medium mb-12 max-w-md">
|
||||||
|
{t.contacto.heroDesc}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-8 pt-8">
|
||||||
|
{infoContacto.map((item, idx) => (
|
||||||
|
<div key={idx}>
|
||||||
|
<h4 className="text-[13px] font-extrabold text-sky uppercase tracking-widest mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-ocean">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</h4>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
target={item.href.startsWith("http") ? "_blank" : undefined}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`text-navy font-bold leading-relaxed hover:text-ocean transition-colors ${
|
||||||
|
item.isEmail ? "text-[18px] text-ocean" : "text-[16px]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Columna del Formulario */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="lg:col-span-7 bg-white rounded-[32px] border border-crisp p-8 md:p-12 lg:p-16"
|
||||||
|
>
|
||||||
|
<h3 className="text-[24px] font-extrabold text-navy mb-8">
|
||||||
|
{t.contacto.form.title}
|
||||||
|
</h3>
|
||||||
|
<ContactoForm />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
@theme {
|
||||||
--background: #ffffff;
|
--color-navy: #03045E;
|
||||||
--foreground: #171717;
|
--color-ocean: #0077B6;
|
||||||
|
--color-sky: #00B4D8;
|
||||||
|
--color-powder: #90E0EF;
|
||||||
|
--color-ice: #CAF0F8;
|
||||||
|
--font-sans: 'Manrope', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@layer base {
|
||||||
--color-background: var(--background);
|
/* Esto elimina la línea azul en todos los navegadores */
|
||||||
--color-foreground: var(--foreground);
|
*, *:focus, *:active, *:focus-visible {
|
||||||
--font-sans: var(--font-geist-sans);
|
outline: none !important;
|
||||||
--font-mono: var(--font-geist-mono);
|
box-shadow: none !important;
|
||||||
}
|
ring: 0 !important;
|
||||||
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
input, textarea, select, button {
|
||||||
--background: #0a0a0a;
|
outline: none !important;
|
||||||
--foreground: #ededed;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@utility border-crisp {
|
||||||
background: var(--background);
|
border: 1px solid rgba(202, 240, 248, 0.6);
|
||||||
color: var(--foreground);
|
}
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
102
app/insights/page.tsx
Normal file
102
app/insights/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useLanguage } from "@/context/LanguageContext";
|
||||||
|
|
||||||
|
export default function InsightsPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
|
||||||
|
// Mapeo de meses para las fechas de los artículos
|
||||||
|
const months = {
|
||||||
|
es: { mayo: "Mayo", abril: "Abril", marzo: "Marzo" },
|
||||||
|
ca: { mayo: "Maig", abril: "Abril", marzo: "Març" },
|
||||||
|
en: { mayo: "May", abril: "April", marzo: "March" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const articulos = [
|
||||||
|
{
|
||||||
|
titulo: locale === 'en' ? "The new post-pandemic consumer in Spain" :
|
||||||
|
locale === 'ca' ? "El nou consumidor post-pandèmia a Espanya" :
|
||||||
|
"El nuevo consumidor post-pandemia en España",
|
||||||
|
categoria: t.insights.categories.tendencias,
|
||||||
|
imagen: "https://images.unsplash.com/photo-1551434678-e076c223a692?q=80&w=2070&auto=format&fit=crop",
|
||||||
|
fecha: `${months[locale].mayo} 2026`,
|
||||||
|
size: "large"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: locale === 'en' ? "Agile methodologies in fieldwork" :
|
||||||
|
locale === 'ca' ? "Metodologies àgils en el treball de camp" :
|
||||||
|
"Metodologías ágiles en el trabajo de campo",
|
||||||
|
categoria: t.insights.categories.innovacion,
|
||||||
|
imagen: "https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=2015&auto=format&fit=crop",
|
||||||
|
fecha: `${months[locale].abril} 2026`,
|
||||||
|
size: "small"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: locale === 'en' ? "Mystery Shopping: Beyond compliance" :
|
||||||
|
locale === 'ca' ? "Mistery Shopping: Més enllà del compliment" :
|
||||||
|
"Mistery Shopping: Más allá del cumplimiento",
|
||||||
|
categoria: t.insights.categories.estrategia,
|
||||||
|
imagen: "https://images.unsplash.com/photo-1534452203293-494d7ddbf7e0?q=80&w=2072&auto=format&fit=crop",
|
||||||
|
fecha: `${months[locale].marzo} 2026`,
|
||||||
|
size: "small"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dividimos el título para aplicar el color al final
|
||||||
|
const titleParts = t.insights.title.split('&');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="max-w-7xl mx-auto px-6 lg:px-12 py-20">
|
||||||
|
<div className="mb-16">
|
||||||
|
<Badge>{t.insights.badge}</Badge>
|
||||||
|
<h1 className="text-[48px] md:text-[64px] font-extrabold text-navy leading-[1.05] tracking-tight mt-6">
|
||||||
|
{titleParts[0]} & <br /> <span className="text-ocean">{titleParts[1]}</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-[18px] text-navy/70 font-medium mt-8 max-w-2xl">
|
||||||
|
{t.insights.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{articulos.map((item, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={idx}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: idx * 0.1 }}
|
||||||
|
className={item.size === "large" ? "md:col-span-2" : "md:col-span-1"}
|
||||||
|
>
|
||||||
|
<Card variant="white" className="p-0 overflow-hidden h-full flex flex-col group cursor-pointer border-crisp hover:border-ocean transition-all">
|
||||||
|
<div className="relative h-64 w-full overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={item.imagen}
|
||||||
|
alt={item.titulo}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-4 left-4">
|
||||||
|
<span className="bg-white/90 backdrop-blur text-ocean text-[10px] font-extrabold px-3 py-1 rounded-full uppercase tracking-widest">
|
||||||
|
{item.categoria}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-8">
|
||||||
|
<span className="text-navy/40 text-[12px] font-bold">{item.fecha}</span>
|
||||||
|
<h3 className="text-[22px] font-extrabold text-navy mt-2 leading-tight group-hover:text-ocean transition-colors">
|
||||||
|
{item.titulo}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-6 flex items-center text-ocean font-bold text-[14px]">
|
||||||
|
{t.insights.readMore}
|
||||||
|
<svg className="ml-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Manrope } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
import { LanguageProvider } from "@/context/LanguageContext";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const manrope = Manrope({ subsets: ["latin"] });
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "ITEM OPINA | Inteligencia de Mercado",
|
||||||
description: "Generated by create next app",
|
description: "Estudios de mercado y consultoría con rigor metodológico.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +18,16 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="es">
|
||||||
lang="en"
|
<body className={`${manrope.className} antialiased flex flex-col min-h-screen`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
<LanguageProvider>
|
||||||
>
|
<Navbar />
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<main className="flex-grow">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</LanguageProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
141
app/nosotros/page.tsx
Normal file
141
app/nosotros/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLanguage } from "@/context/LanguageContext";
|
||||||
|
|
||||||
|
export default function NosotrosPage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
|
||||||
|
// Dividimos el título para aplicar el color al final (Desde 2004 / Des de 2004 / Since 2004)
|
||||||
|
const titleParts = t.nosotros.title.split('.');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Hero Narrativo */}
|
||||||
|
<section className="max-w-5xl mx-auto px-6 lg:px-12 pt-24 pb-20 md:pt-32 md:pb-24 text-center">
|
||||||
|
<Badge>{t.nosotros.badge}</Badge>
|
||||||
|
|
||||||
|
<h1 className="text-[48px] md:text-[64px] lg:text-[80px] font-extrabold text-navy leading-[1.00] tracking-tight mb-8">
|
||||||
|
{titleParts[0]}.<br />
|
||||||
|
<span className="text-ocean">{titleParts[1]}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-[18px] md:text-[22px] text-navy/70 leading-[1.6] font-medium max-w-3xl mx-auto">
|
||||||
|
{t.nosotros.desc}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sección Historia (Layout Asimétrico) */}
|
||||||
|
<section className="w-full bg-ice/10 border-t border-crisp py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 lg:px-12 flex flex-col md:flex-row gap-16 lg:gap-24">
|
||||||
|
|
||||||
|
<div className="md:w-1/3">
|
||||||
|
<div className="sticky top-32">
|
||||||
|
<h2 className="text-[32px] md:text-[40px] font-extrabold text-navy leading-[1.1] mb-6">
|
||||||
|
{t.nosotros.history.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[16px] text-navy/60 font-medium leading-[1.6]">
|
||||||
|
{locale === 'en' ? "Get to know us. From our foundation in Barcelona to our national consolidation." :
|
||||||
|
locale === 'ca' ? "Coneix-nos. Des de la nostra fundació a Barcelona fins a la nostra consolidació nacional." :
|
||||||
|
"Conócenos. Desde nuestra fundación en Barcelona hasta nuestra consolidación a nivel nacional."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:w-2/3 space-y-10 text-[18px] text-navy/80 font-medium leading-[1.7]">
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
{t.nosotros.history.p1}
|
||||||
|
</motion.p>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{t.nosotros.history.p2}
|
||||||
|
</motion.p>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
{t.nosotros.history.p3}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sección Misión con Cards horizontales */}
|
||||||
|
<section className="w-full bg-white pt-24 pb-12 border-t border-crisp">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 lg:px-12">
|
||||||
|
<h2 className="text-[14px] font-extrabold tracking-widest uppercase text-ocean mb-12">
|
||||||
|
{locale === 'en' ? "Our Mission" : locale === 'ca' ? "La nostra Missió" : "Nuestra Misión"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card variant="none" className="flex-row items-center gap-12 group py-12" delay={0.1}>
|
||||||
|
<div className="text-[24px] font-extrabold text-sky group-hover:text-ocean transition-colors">01.</div>
|
||||||
|
<div className="flex-1 grid md:grid-cols-3 gap-6 items-center">
|
||||||
|
<h3 className="text-[28px] font-extrabold text-navy">{t.nosotros.mission.m1Title}</h3>
|
||||||
|
<p className="md:col-span-2 text-[16px] text-navy/70 font-medium leading-[1.6]">
|
||||||
|
{t.nosotros.mission.m1Desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="none" className="flex-row items-center gap-12 group py-12" delay={0.2}>
|
||||||
|
<div className="text-[24px] font-extrabold text-sky group-hover:text-ocean transition-colors">02.</div>
|
||||||
|
<div className="flex-1 grid md:grid-cols-3 gap-6 items-center">
|
||||||
|
<h3 className="text-[28px] font-extrabold text-navy">{t.nosotros.mission.m2Title}</h3>
|
||||||
|
<p className="md:col-span-2 text-[16px] text-navy/70 font-medium leading-[1.6]">
|
||||||
|
{t.nosotros.mission.m2Desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="none" className="flex-row items-center gap-12 group py-12 border-b border-crisp" delay={0.3}>
|
||||||
|
<div className="text-[24px] font-extrabold text-sky group-hover:text-ocean transition-colors">03.</div>
|
||||||
|
<div className="flex-1 grid md:grid-cols-3 gap-6 items-center">
|
||||||
|
<h3 className="text-[28px] font-extrabold text-navy">{t.nosotros.mission.m3Title}</h3>
|
||||||
|
<p className="md:col-span-2 text-[16px] text-navy/70 font-medium leading-[1.6]">
|
||||||
|
{t.nosotros.mission.m3Desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Final */}
|
||||||
|
<section className="w-full bg-white py-24">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 lg:px-12">
|
||||||
|
<div className="w-full bg-navy rounded-[32px] p-12 md:p-20 text-center flex flex-col items-center">
|
||||||
|
<h2 className="text-[40px] md:text-[56px] font-extrabold text-white tracking-tight leading-[1.1] mb-6 max-w-2xl">
|
||||||
|
{locale === 'en' ? "A team ready to understand your market." :
|
||||||
|
locale === 'ca' ? "Un equip disposat a entendre el teu mercat." :
|
||||||
|
"Un equipo dispuesto a entender tu mercado."}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[18px] text-white/70 font-medium mb-10 max-w-lg">
|
||||||
|
{locale === 'en' ? "Let's talk about your next research project. Tell us what you need to discover." :
|
||||||
|
locale === 'ca' ? "Parlem del teu proper projecte de recerca. Explica'ns què necessites descobrir." :
|
||||||
|
"Hablemos de tu próximo proyecto de investigación. Cuéntanos qué necesitas descubrir."}
|
||||||
|
</p>
|
||||||
|
<Link href="/contacto">
|
||||||
|
<Button variant="outline" className="bg-white border-white text-navy hover:bg-ice transition-colors">
|
||||||
|
{t.navbar.contacto}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
app/page.tsx
296
app/page.tsx
@@ -1,65 +1,247 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { BentoGrid } from "@/components/ui/BentoGrid";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLanguage } from "@/context/LanguageContext";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
|
||||||
|
// Helper para las etiquetas de los meses en el SVG según el idioma
|
||||||
|
const months = {
|
||||||
|
es: ["ENE", "MAR", "JUN", "SEP", "DIC"],
|
||||||
|
ca: ["GEN", "MAR", "JUN", "SET", "DES"],
|
||||||
|
en: ["JAN", "MAR", "JUN", "SEP", "DEC"]
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="w-full overflow-x-hidden">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
{/* Hero Section */}
|
||||||
<Image
|
<section className="max-w-7xl mx-auto px-6 lg:px-12 pt-12 md:pt-20 pb-20 md:pb-32">
|
||||||
className="dark:invert"
|
<div className="flex flex-col lg:flex-row items-center gap-10 lg:gap-12">
|
||||||
src="/next.svg"
|
<motion.div
|
||||||
alt="Next.js logo"
|
initial={{ opacity: 0, y: 20 }}
|
||||||
width={100}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
height={20}
|
transition={{ duration: 0.8 }}
|
||||||
priority
|
className="flex-1 w-full text-center lg:text-left"
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
<Image
|
<Badge>{t.home.badge}</Badge>
|
||||||
className="dark:invert"
|
<h1 className="text-[40px] sm:text-[56px] lg:text-[88px] font-extrabold text-navy leading-[1.1] lg:leading-[0.95] tracking-tight mb-6 md:mb-8">
|
||||||
src="/vercel.svg"
|
{/* Dividimos el título para mantener el salto de línea estético */}
|
||||||
alt="Vercel logomark"
|
{locale === 'en' ? "The value" : locale === 'ca' ? "El valor" : "El valor"} <br />
|
||||||
width={16}
|
<span className="text-ocean">
|
||||||
height={16}
|
{locale === 'en' ? "of knowing." : locale === 'ca' ? "de saber." : "de saber."}
|
||||||
/>
|
</span>
|
||||||
Deploy Now
|
</h1>
|
||||||
</a>
|
<p className="text-[16px] md:text-[20px] text-navy/70 leading-relaxed max-w-lg mx-auto lg:mx-0 mb-8 md:mb-10 font-medium">
|
||||||
<a
|
{t.home.description}
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
</p>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="flex flex-col sm:flex-row justify-center lg:justify-start gap-4">
|
||||||
target="_blank"
|
<Link href="/servicios" className="w-full sm:w-auto">
|
||||||
rel="noopener noreferrer"
|
<Button variant="primary" className="w-full">{t.home.btnServices}</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/nosotros" className="w-full sm:w-auto">
|
||||||
|
<Button variant="outline" className="w-full">{t.home.btnMethod}</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Gráfico Animado SVG */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 1, delay: 0.2 }}
|
||||||
|
className="flex-1 w-full max-w-[500px] lg:max-w-none shadow-2xl rounded-[20px] md:rounded-[28px] overflow-hidden border border-crisp"
|
||||||
>
|
>
|
||||||
Documentation
|
<svg
|
||||||
</a>
|
viewBox="0 0 800 420"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="w-full h-auto block"
|
||||||
|
role="img"
|
||||||
|
aria-label="Animación de estadísticas"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="barGradient" x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0%" stopColor="#03045E" />
|
||||||
|
<stop offset="100%" stopColor="#0077B6" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.bg-svg { fill: #ffffff; }
|
||||||
|
.axis-svg { stroke: #03045E; stroke-width: 2; stroke-linecap: round; opacity: 0.2; }
|
||||||
|
.grid-svg { stroke: #CAF0F8; stroke-width: 1; stroke-dasharray: 4 8; }
|
||||||
|
.bar-svg { fill: url(#barGradient); rx: 8; transform-box: fill-box; transform-origin: bottom; animation: mutateBars 7s ease-in-out infinite; }
|
||||||
|
.bar-svg:nth-of-type(1) { animation-delay: 0s; }
|
||||||
|
.bar-svg:nth-of-type(2) { animation-delay: .15s; }
|
||||||
|
.bar-svg:nth-of-type(3) { animation-delay: .3s; }
|
||||||
|
.bar-svg:nth-of-type(4) { animation-delay: .45s; }
|
||||||
|
.bar-svg:nth-of-type(5) { animation-delay: .6s; }
|
||||||
|
.line-svg { fill: none; stroke: #00B4D8; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 520; stroke-dashoffset: 520; animation: drawLine 7s ease-in-out infinite; }
|
||||||
|
.dot-svg { fill: #03045E; opacity: 0; transform-box: fill-box; transform-origin: center; animation: pulseDots 7s ease-in-out infinite; }
|
||||||
|
.dot-svg:nth-of-type(1) { animation-delay: .1s; }
|
||||||
|
.dot-svg:nth-of-type(2) { animation-delay: .2s; }
|
||||||
|
.dot-svg:nth-of-type(3) { animation-delay: .3s; }
|
||||||
|
.dot-svg:nth-of-type(4) { animation-delay: .4s; }
|
||||||
|
.dot-svg:nth-of-type(5) { animation-delay: .5s; }
|
||||||
|
.label-svg { font-family: 'Manrope', sans-serif; font-size: 14px; fill: #03045E; opacity: .5; font-weight: 600; }
|
||||||
|
.title-svg { font-family: 'Manrope', sans-serif; font-size: 20px; font-weight: 800; fill: #03045E; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
|
.metric-svg { font-family: 'Manrope', sans-serif; font-size: 42px; font-weight: 800; fill: #0077B6; animation: fadeMetric 7s ease-in-out infinite; }
|
||||||
|
.chip-svg { fill: #CAF0F8; opacity: 0.3; }
|
||||||
|
.chipText-svg { font-family: 'Manrope', sans-serif; font-size: 13px; font-weight: 700; fill: #0077B6; animation: chipShift 7s ease-in-out infinite; }
|
||||||
|
|
||||||
|
@keyframes mutateBars {
|
||||||
|
0%, 100% { transform: scaleY(.35); opacity: .4; }
|
||||||
|
20% { transform: scaleY(1); opacity: 1; }
|
||||||
|
40% { transform: scaleY(.62); opacity: .7; }
|
||||||
|
60% { transform: scaleY(.15); opacity: .3; }
|
||||||
|
80% { transform: scaleY(.9); opacity: .9; }
|
||||||
|
}
|
||||||
|
@keyframes drawLine {
|
||||||
|
0%, 22% { stroke-dashoffset: 520; opacity: 0; }
|
||||||
|
38%, 62% { stroke-dashoffset: 0; opacity: 1; }
|
||||||
|
78%, 100% { stroke-dashoffset: -520; opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes pulseDots {
|
||||||
|
0%, 25% { opacity: 0; transform: scale(.2); }
|
||||||
|
42%, 62% { opacity: 1; transform: scale(1); }
|
||||||
|
82%, 100% { opacity: 0; transform: scale(.2); }
|
||||||
|
}
|
||||||
|
@keyframes fadeMetric {
|
||||||
|
0%, 18% { opacity: .4; }
|
||||||
|
28%, 50% { opacity: 1; }
|
||||||
|
65%, 100% { opacity: .55; }
|
||||||
|
}
|
||||||
|
@keyframes chipShift {
|
||||||
|
0%, 100% { opacity: .45; }
|
||||||
|
35%, 65% { opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect className="bg-svg" width="800" height="420" rx="28" />
|
||||||
|
<text className="title-svg" x="64" y="72">
|
||||||
|
{locale === 'en' ? "REAL-TIME DATA" : locale === 'ca' ? "DADES EN TEMPS REAL" : "DATOS EN TIEMPO REAL"}
|
||||||
|
</text>
|
||||||
|
<text className="label-svg" x="64" y="98">
|
||||||
|
{locale === 'en' ? "Trends and Projections" : locale === 'ca' ? "Tendències i Projeccions" : "Tendencias y Proyecciones"}
|
||||||
|
</text>
|
||||||
|
<rect className="chip-svg" x="610" y="52" width="126" height="38" rx="19" />
|
||||||
|
<text className="chipText-svg" x="635" y="76">LIVE DATA</text>
|
||||||
|
<text className="metric-svg" x="64" y="165">92.4%</text>
|
||||||
|
<text className="label-svg" x="64" y="192">
|
||||||
|
{locale === 'en' ? "analysis precision" : locale === 'ca' ? "precisió d'anàlisi" : "precisión de análisis"}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<line className="grid-svg" x1="250" y1="120" x2="720" y2="120" />
|
||||||
|
<line className="grid-svg" x1="250" y1="180" x2="720" y2="180" />
|
||||||
|
<line className="grid-svg" x1="250" y1="240" x2="720" y2="240" />
|
||||||
|
<line className="grid-svg" x1="250" y1="300" x2="720" y2="300" />
|
||||||
|
|
||||||
|
<line className="axis-svg" x1="250" y1="330" x2="720" y2="330" />
|
||||||
|
<line className="axis-svg" x1="250" y1="110" x2="250" y2="330" />
|
||||||
|
|
||||||
|
<rect className="bar-svg" x="295" y="150" width="48" height="180" />
|
||||||
|
<rect className="bar-svg" x="380" y="205" width="48" height="125" />
|
||||||
|
<rect className="bar-svg" x="465" y="125" width="48" height="205" />
|
||||||
|
<rect className="bar-svg" x="550" y="185" width="48" height="145" />
|
||||||
|
<rect className="bar-svg" x="635" y="95" width="48" height="235" />
|
||||||
|
|
||||||
|
<path className="line-svg" d="M319 250 C350 210, 380 240, 404 220 C440 185, 470 160, 489 180 C525 215, 550 210, 574 190 C610 155, 640 120, 659 145" />
|
||||||
|
|
||||||
|
<circle className="dot-svg" cx="319" cy="250" r="7" />
|
||||||
|
<circle className="dot-svg" cx="404" cy="220" r="7" />
|
||||||
|
<circle className="dot-svg" cx="489" cy="180" r="7" />
|
||||||
|
<circle className="dot-svg" cx="574" cy="190" r="7" />
|
||||||
|
<circle className="dot-svg" cx="659" cy="145" r="7" />
|
||||||
|
|
||||||
|
{months[locale].map((month, i) => (
|
||||||
|
<text key={i} className="label-svg" x={304 + (i * 85)} y="360">{month}</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</section>
|
||||||
|
|
||||||
|
{/* Grid de Propuesta de Valor */}
|
||||||
|
<section className="bg-ice/10 py-16 md:py-24 border-t border-crisp">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 lg:px-12">
|
||||||
|
<div className="mb-12 md:mb-16 max-w-2xl text-center md:text-left mx-auto md:mx-0">
|
||||||
|
<h2 className="text-[32px] md:text-[40px] font-extrabold text-navy tracking-tight leading-[1.1] mb-4">
|
||||||
|
{t.home.whyUs.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[16px] md:text-[18px] text-navy/60 font-medium">
|
||||||
|
{t.home.whyUs.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BentoGrid>
|
||||||
|
{/* Card 1 */}
|
||||||
|
<Card variant="ice" className="md:col-span-2 min-h-[280px] md:min-h-[320px] justify-between group" delay={0.1}>
|
||||||
|
<div className="text-[12px] md:text-[14px] font-extrabold tracking-widest uppercase text-ocean">
|
||||||
|
01 / {locale === 'en' ? "Analysis" : locale === 'ca' ? "Anàlisi" : "Análisis"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[26px] md:text-[32px] font-extrabold text-navy mb-3 md:mb-4 leading-tight">
|
||||||
|
{t.home.whyUs.card1Title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[15px] md:text-[16px] text-navy/70 leading-relaxed max-w-md font-medium">
|
||||||
|
{t.home.whyUs.card1Desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Card 2 */}
|
||||||
|
<Card variant="white" className="md:col-span-1 min-h-[280px] md:min-h-[320px] justify-between" delay={0.2}>
|
||||||
|
<div className="text-[12px] md:text-[14px] font-extrabold tracking-widest uppercase text-sky">
|
||||||
|
02 / {locale === 'en' ? "Coverage" : locale === 'ca' ? "Cobertura" : "Cobertura"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[26px] md:text-[28px] font-extrabold text-navy mb-3 md:mb-4">
|
||||||
|
{t.home.whyUs.card2Title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[15px] md:text-[16px] text-navy/70 leading-relaxed font-medium">
|
||||||
|
{t.home.whyUs.card2Desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Card 3 */}
|
||||||
|
<Card variant="white" className="md:col-span-1 min-h-[280px] md:min-h-[320px] justify-between" delay={0.3}>
|
||||||
|
<div className="text-[12px] md:text-[14px] font-extrabold tracking-widest uppercase text-sky">
|
||||||
|
03 / {locale === 'en' ? "Flexibility" : locale === 'ca' ? "Flexibilitat" : "Flexibilidad"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[26px] md:text-[28px] font-extrabold text-navy mb-3 md:mb-4">
|
||||||
|
{t.home.whyUs.card3Title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[15px] md:text-[16px] text-navy/70 leading-relaxed font-medium">
|
||||||
|
{t.home.whyUs.card3Desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* CTA Card */}
|
||||||
|
<Card variant="navy" className="md:col-span-2 flex flex-col md:flex-row items-center justify-between gap-6 md:gap-8 p-8 md:p-10" delay={0.4}>
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
|
<h3 className="text-[26px] md:text-[32px] font-extrabold text-white mb-2 leading-tight">
|
||||||
|
{t.home.whyUs.ctaTitle}
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/70 font-medium max-w-sm mx-auto md:mx-0">
|
||||||
|
{t.home.whyUs.ctaDesc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/contacto" className="w-full md:w-auto">
|
||||||
|
<Button variant="ocean" className="w-full md:w-auto whitespace-nowrap">
|
||||||
|
{t.home.whyUs.ctaBtn}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</BentoGrid>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
91
app/participa/page.tsx
Normal file
91
app/participa/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { ParticipaForm } from "@/components/forms/ParticipaForm";
|
||||||
|
import { FiCheck } from "react-icons/fi";
|
||||||
|
import { useLanguage } from "@/context/LanguageContext";
|
||||||
|
|
||||||
|
export default function ParticipaPage() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
// Mapeo dinámico de los beneficios desde el diccionario
|
||||||
|
const beneficios = [
|
||||||
|
{
|
||||||
|
titulo: t.participa.benefits.b1Title,
|
||||||
|
desc: t.participa.benefits.b1Desc,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: t.participa.benefits.b2Title,
|
||||||
|
desc: t.participa.benefits.b2Desc,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: t.participa.benefits.b3Title,
|
||||||
|
desc: t.participa.benefits.b3Desc,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full flex-grow flex flex-col lg:flex-row min-h-[calc(100vh-80px)]">
|
||||||
|
|
||||||
|
{/* Columna Informativa (Izquierda) */}
|
||||||
|
<div className="w-full lg:w-5/12 bg-ice/20 border-r border-crisp p-8 md:p-16 lg:p-20 flex flex-col justify-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<Badge>{t.participa.badge}</Badge>
|
||||||
|
|
||||||
|
<h1 className="text-[40px] md:text-[56px] font-extrabold text-navy leading-[1.05] tracking-tight mb-6">
|
||||||
|
{/* Usamos el título del diccionario */}
|
||||||
|
{t.participa.heroTitle.split(' ').slice(0, -2).join(' ')} <br />
|
||||||
|
<span className="text-ocean">
|
||||||
|
{t.participa.heroTitle.split(' ').slice(-2).join(' ')}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-[16px] md:text-[18px] text-navy/70 leading-[1.6] font-medium mb-12 max-w-md">
|
||||||
|
{t.participa.heroDesc}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{beneficios.map((item, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={idx}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 + idx * 0.1 }}
|
||||||
|
className="flex items-start gap-4"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-white border border-crisp flex items-center justify-center shrink-0 mt-1">
|
||||||
|
<FiCheck className="text-ocean text-lg stroke-[3]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-extrabold text-[16px] text-navy">{item.titulo}</h4>
|
||||||
|
<p className="text-[14px] text-navy/60 font-medium mt-1">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columna Formulario (Derecha) */}
|
||||||
|
<div className="w-full lg:w-7/12 bg-white p-8 md:p-16 lg:p-20 flex flex-col justify-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
className="max-w-xl w-full mx-auto lg:mx-0"
|
||||||
|
>
|
||||||
|
<h3 className="text-[24px] font-extrabold text-navy mb-8">
|
||||||
|
{t.participa.formTitle}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ParticipaForm />
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
app/servicios/page.tsx
Normal file
133
app/servicios/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { BentoGrid } from "@/components/ui/BentoGrid";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useLanguage } from "@/context/LanguageContext";
|
||||||
|
|
||||||
|
export default function ServiciosPage() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Hero de Servicios */}
|
||||||
|
<section className="max-w-7xl mx-auto px-6 lg:px-12 pt-20 pb-24 md:pt-32 md:pb-24">
|
||||||
|
<Badge>{t.servicios.heroBadge || "Cartera de Servicios"}</Badge>
|
||||||
|
|
||||||
|
<h1 className="text-[40px] md:text-[56px] lg:text-[64px] font-extrabold text-ocean leading-[1.05] tracking-tight max-w-5xl mb-16">
|
||||||
|
{t.servicios.hero}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Descripción en dos columnas */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-20 pt-12">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-[14px] font-extrabold tracking-widest uppercase text-sky mb-4">
|
||||||
|
{t.servicios.cat1}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[16px] md:text-[18px] text-navy/70 font-medium leading-[1.7]">
|
||||||
|
{t.servicios.cat1Desc}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-[14px] font-extrabold tracking-widest uppercase text-sky mb-4">
|
||||||
|
{t.servicios.cat2}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[16px] md:text-[18px] text-navy/70 font-medium leading-[1.7]">
|
||||||
|
{t.servicios.cat2Desc}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Grid Bento de Servicios */}
|
||||||
|
<section className="w-full bg-white pb-32">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 lg:px-12">
|
||||||
|
<BentoGrid>
|
||||||
|
|
||||||
|
{/* Imagen 1 */}
|
||||||
|
<Card variant="none" className="p-0 overflow-hidden min-h-[300px] md:min-h-[400px] border border-crisp" delay={0.1}>
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<Image
|
||||||
|
src="https://images.unsplash.com/photo-1573164713988-8665fc963095?q=80&w=2069&auto=format&fit=crop"
|
||||||
|
alt={t.servicios.items.consultoria}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Consultoría */}
|
||||||
|
<Card variant="ice" className="min-h-[300px] md:min-h-[400px] justify-center group border border-crisp" delay={0.2}>
|
||||||
|
<h3 className="text-[32px] font-extrabold text-navy mb-4">{t.servicios.items.consultoria}</h3>
|
||||||
|
<p className="text-[16px] text-navy/70 font-medium leading-[1.6] mb-8">
|
||||||
|
{t.servicios.consultoriaDesc}
|
||||||
|
</p>
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Link href="/contacto">
|
||||||
|
<Button variant="primary" className="!py-3 !px-6 text-[13px] w-full sm:w-auto">
|
||||||
|
{t.servicios.btnGo || "Ir a la página"}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Red de Campo */}
|
||||||
|
<Card variant="white" className="min-h-[300px] md:min-h-[400px] justify-center border border-crisp" delay={0.3}>
|
||||||
|
<h3 className="text-[32px] font-extrabold text-navy mb-4">{t.servicios.items.red}</h3>
|
||||||
|
<p className="text-[16px] text-navy/70 font-medium leading-[1.6] mb-8">
|
||||||
|
{t.servicios.redDesc}
|
||||||
|
</p>
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Link href="/contacto">
|
||||||
|
<Button variant="primary" className="!py-3 !px-6 text-[13px] w-full sm:w-auto">
|
||||||
|
{t.servicios.btnGo || "Ir a la página"}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Estudios (Bloque destacado) */}
|
||||||
|
<Card variant="navy" className="md:col-span-2 min-h-[300px] md:min-h-[400px] justify-center border border-navy" delay={0.4}>
|
||||||
|
<h3 className="text-[40px] font-extrabold text-white mb-6">{t.servicios.items.estudios}</h3>
|
||||||
|
<p className="text-[18px] text-white/80 font-medium leading-[1.6] max-w-md mb-10">
|
||||||
|
{t.servicios.estudiosDesc}
|
||||||
|
</p>
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Link href="/contacto">
|
||||||
|
<Button variant="outline" className="bg-ocean border-ocean text-white hover:bg-white hover:text-ocean hover:border-white transition-all w-full sm:w-auto">
|
||||||
|
{t.servicios.btnGo || "Ir a la página"}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Imagen 2 */}
|
||||||
|
<Card variant="none" className="p-0 overflow-hidden min-h-[300px] md:min-h-[400px] border border-crisp" delay={0.5}>
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<Image
|
||||||
|
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?q=80&w=2070&auto=format&fit=crop"
|
||||||
|
alt={t.servicios.items.estudios}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</BentoGrid>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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";
|
||||||
48
context/LanguageContext.tsx
Normal file
48
context/LanguageContext.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
import { es } from "@/lang/es";
|
||||||
|
import { ca } from "@/lang/ca";
|
||||||
|
import { en } from "@/lang/en";
|
||||||
|
|
||||||
|
type Language = "es" | "ca" | "en";
|
||||||
|
|
||||||
|
const dictionaries = { es, ca, en };
|
||||||
|
|
||||||
|
interface LanguageContextType {
|
||||||
|
locale: Language;
|
||||||
|
setLocale: (lang: Language) => void;
|
||||||
|
t: typeof es;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const LanguageProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [locale, setLocaleState] = useState<Language>("es");
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedLang = localStorage.getItem("language") as Language;
|
||||||
|
if (savedLang && (savedLang === "es" || savedLang === "ca" || savedLang === "en")) {
|
||||||
|
setLocaleState(savedLang);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLocale = (lang: Language) => {
|
||||||
|
setLocaleState(lang);
|
||||||
|
localStorage.setItem("language", lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = dictionaries[locale];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider value={{ locale, setLocale, t }}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLanguage = () => {
|
||||||
|
const context = useContext(LanguageContext);
|
||||||
|
if (!context) throw new Error("useLanguage debe usarse dentro de un LanguageProvider");
|
||||||
|
return context;
|
||||||
|
};
|
||||||
30
hooks/useForm.ts
Normal file
30
hooks/useForm.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
|
||||||
|
export const useFormSubmit = (tableName: string) => {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (data: any) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error: sbError } = await supabase
|
||||||
|
.from(tableName)
|
||||||
|
.insert([data]);
|
||||||
|
|
||||||
|
if (sbError) throw sbError;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Error al enviar los datos');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { submit, isSubmitting, success, error };
|
||||||
|
};
|
||||||
140
lang/ca.ts
Normal file
140
lang/ca.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
export const ca = {
|
||||||
|
navbar: {
|
||||||
|
consultora: "La Consultora",
|
||||||
|
servicios: "Serveis",
|
||||||
|
participa: "Participa",
|
||||||
|
insights: "Insights",
|
||||||
|
contacto: "Iniciar diàleg",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
badge: "Intel·ligència de Mercat",
|
||||||
|
title: "El valor de saber.",
|
||||||
|
description: "Estudis de mercat i enquestes amb rigor metodològic. Entenem el teu client perquè la teva empresa prengui decisions amb absoluta certesa.",
|
||||||
|
btnServices: "Veure els nostres serveis",
|
||||||
|
btnMethod: "La nostra metodologia",
|
||||||
|
whyUs: {
|
||||||
|
title: "Per què treballar amb nosaltres?",
|
||||||
|
desc: "Combinem la capacitat operativa nacional amb el detall artesanal en l'anàlisi de dades.",
|
||||||
|
card1Title: "Anàlisis especialitzades",
|
||||||
|
card1Desc: "Realitzem tota classe d'estudis i posem en pràctica els nous conceptes i mètodes adaptats al seu sector.",
|
||||||
|
card2Title: "Ampli equip",
|
||||||
|
card2Desc: "La nostra àmplia xarxa cobreix tot el territori nacional i Portugal amb enquestadors supervisats.",
|
||||||
|
card3Title: "Serveis a mida",
|
||||||
|
card3Desc: "Altament flexibles i adaptats. Amb un alt grau de compromís i qualitat analítica.",
|
||||||
|
ctaTitle: "Enlaira't amb dades fiables",
|
||||||
|
ctaDesc: "El nostre equip està llest per estructurar el teu proper estudi d'opinió.",
|
||||||
|
ctaBtn: "Sol·licitar proposta",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nosotros: {
|
||||||
|
badge: "Trajectòria",
|
||||||
|
title: "Investigació de Mercats. Des de 2004.",
|
||||||
|
desc: "Amb més de 19 anys d'experiència en metodologies qualitatives i quantitatives adaptades al client.",
|
||||||
|
history: {
|
||||||
|
title: "Els nostres inicis i evolució.",
|
||||||
|
p1: "Els nostres inicis es remunten a l'any 2004, quan es funda la Societat Vox Populi Recerca S.L. a Barcelona.",
|
||||||
|
p2: "Després de consolidar la nostra xarxa a Barcelona, ens vam expandir fins a cobrir la totalitat del territori nacional i Portugal.",
|
||||||
|
p3: "Comptem amb els millors tècnics i analistes per donar resposta a totes les tipologies d'estudis.",
|
||||||
|
},
|
||||||
|
mission: {
|
||||||
|
m1Title: "Enfocament al client",
|
||||||
|
m1Desc: "Conèixer les necessitats específiques del mercat per a l'èxit de la seva empresa.",
|
||||||
|
m2Title: "Metodologia adaptada",
|
||||||
|
m2Desc: "Integració de mètodes qualitatius i quantitatius amb màxima precisió.",
|
||||||
|
m3Title: "Compromís transversal",
|
||||||
|
m3Desc: "Aportem visió detallada i certera en projectes de qualsevol envergadura.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
servicios: {
|
||||||
|
heroBadge: "Cartera de Serveis",
|
||||||
|
hero: "La nostra àmplia cartera de serveis l'ajudarà amb la presa de decisions i a assegurar l'èxit de la seva empresa.",
|
||||||
|
cat1: "Evolució metodològica",
|
||||||
|
cat1Desc: "Realitzem estudis de mercat utilitzant les metodologies quantitatives i qualitatives clàssiques d'investigació i així mateix, ens trobem en constant procés d'evolució.",
|
||||||
|
cat2: "Serveis a mida",
|
||||||
|
cat2Desc: "Els nostres serveis són flexibles i amb metodologia personalitzada. Amb un alt grau de compromís, qualitat i competitivitat. La nostra xarxa cobreix tot el territori nacional.",
|
||||||
|
items: {
|
||||||
|
consultoria: "Consultoria",
|
||||||
|
red: "Xarxa de camp",
|
||||||
|
estudios: "Estudis",
|
||||||
|
},
|
||||||
|
consultoriaDesc: "Volem conèixer les seves necessitats i producte per donar l'enfocament més beneficiós per a la seva empresa.",
|
||||||
|
redDesc: "Un personal altament implicat i qualificat en els estudis, és una de les nostres principals diferències.",
|
||||||
|
estudiosDesc: "Realitzem tota classe d'estudis i posem en pràctica els nous conceptes i mètodes d'investigació.",
|
||||||
|
btnGo: "Anar a la pàgina",
|
||||||
|
},
|
||||||
|
insights: {
|
||||||
|
badge: "Coneixement",
|
||||||
|
title: "Insights & Perspectives.",
|
||||||
|
description: "Anàlisis profundes sobre el mercat ibèric, tendències de consum i metodologies avançades d'investigació.",
|
||||||
|
readMore: "Llegir anàlisi",
|
||||||
|
categories: {
|
||||||
|
tendencias: "Tendències",
|
||||||
|
innovacion: "Innovació",
|
||||||
|
estrategia: "Estratègia"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
participa: {
|
||||||
|
badge: "Xarxa de Col·laboradors",
|
||||||
|
heroTitle: "La teva opinió té valor.",
|
||||||
|
heroDesc: "Uneix-te al nostre panell d'informadors i participa en estudis de mercat, enquestes d'opinió i projectes de Mistery Shopping remunerats.",
|
||||||
|
benefits: {
|
||||||
|
b1Title: "Compensació justa",
|
||||||
|
b1Desc: "Rep incentius econòmics o targetes regal per cada estudi completat.",
|
||||||
|
b2Title: "Flexibilitat total",
|
||||||
|
b2Desc: "Participa online, per telèfon o presencialment segons la teva disponibilitat.",
|
||||||
|
b3Title: "Privadesa garantida",
|
||||||
|
b3Desc: "Les teves dades són anonimitzades. Complim estrictament amb la normativa LOPD i RGPD.",
|
||||||
|
},
|
||||||
|
formTitle: "Completa el teu perfil per començar",
|
||||||
|
fields: {
|
||||||
|
nombre: "Nom",
|
||||||
|
apellidos: "Cognoms",
|
||||||
|
email: "Email",
|
||||||
|
telefono: "Telèfon",
|
||||||
|
provincia: "Província",
|
||||||
|
edad: "Edat",
|
||||||
|
interes: "Interès principal",
|
||||||
|
mensaje: "Missatge (Opcional)"
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
online: "Enquestes Online (Remot)",
|
||||||
|
telefonicas: "Enquestes Telefòniques",
|
||||||
|
presenciales: "Estudis Presencials (Focus Groups)",
|
||||||
|
mistery: "Mistery Shopping (Client Misteriós)"
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
title: "Gràcies per unir-te!",
|
||||||
|
desc: "Hem rebut la teva sol·licitud correctament. Ens posarem en contacte amb tu aviat.",
|
||||||
|
btn: "Enviar una altra sol·licitud"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contacto: {
|
||||||
|
badge: "Contacte Directe",
|
||||||
|
heroTitle: "Iniciem el diàleg.",
|
||||||
|
heroDesc: "Si vols saber més sobre els nostres serveis, sol·licitar pressupost o simplement posar-te en contacte amb nosaltres, omple el formulari.",
|
||||||
|
info: {
|
||||||
|
address: "Adreça",
|
||||||
|
email: "Email",
|
||||||
|
phone: "Telèfon",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
title: "Envia'ns la teva consulta",
|
||||||
|
name: "Nom",
|
||||||
|
email: "Email",
|
||||||
|
message: "Dubtes i/o comentaris",
|
||||||
|
privacy: "Accepto la política de privadesa i el tractament de les meves dades per a la gestió d'aquesta sol·licitud.",
|
||||||
|
submit: "Enviar missatge",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
title: "Missatge enviat!",
|
||||||
|
desc: "Et respondrem en la major brevetat possible."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
legal: "Avís Legal",
|
||||||
|
privacy: "Privadesa",
|
||||||
|
cookies: "Cookies",
|
||||||
|
contact: "Contacte",
|
||||||
|
rights: "Tots els drets reservats © 2026 per ITEM OPINA S.L"
|
||||||
|
}
|
||||||
|
};
|
||||||
140
lang/en.ts
Normal file
140
lang/en.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
export const en = {
|
||||||
|
navbar: {
|
||||||
|
consultora: "The Consultancy",
|
||||||
|
servicios: "Services",
|
||||||
|
participa: "Join Us",
|
||||||
|
insights: "Insights",
|
||||||
|
contacto: "Start Dialogue",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
badge: "Market Intelligence",
|
||||||
|
title: "The value of knowing.",
|
||||||
|
description: "Market research and surveys with methodological rigor. We understand your customer so your company can make decisions with absolute certainty.",
|
||||||
|
btnServices: "View our services",
|
||||||
|
btnMethod: "Our methodology",
|
||||||
|
whyUs: {
|
||||||
|
title: "Why work with us?",
|
||||||
|
desc: "We combine national operational capacity with artisanal detail in data analysis.",
|
||||||
|
card1Title: "Specialized Analysis",
|
||||||
|
card1Desc: "We conduct all kinds of studies and implement new concepts and methods adapted to your sector.",
|
||||||
|
card2Title: "Extensive Team",
|
||||||
|
card2Desc: "Our wide network covers the entire national territory and Portugal with supervised interviewers.",
|
||||||
|
card3Title: "Tailored Services",
|
||||||
|
card3Desc: "Highly flexible and adapted. With a high degree of commitment and analytical quality.",
|
||||||
|
ctaTitle: "Take off with reliable data",
|
||||||
|
ctaDesc: "Our team is ready to structure your next opinion study.",
|
||||||
|
ctaBtn: "Request proposal",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nosotros: {
|
||||||
|
badge: "Trajectory",
|
||||||
|
title: "Market Research. Since 2004.",
|
||||||
|
desc: "With more than 19 years of experience in qualitative and quantitative methodologies adapted to the client.",
|
||||||
|
history: {
|
||||||
|
title: "Our beginnings and evolution.",
|
||||||
|
p1: "Our beginnings date back to 2004, when the Society Vox Populi Recerca S.L. was founded in Barcelona.",
|
||||||
|
p2: "After consolidating our network in Barcelona, we expanded to cover the entire national territory and Portugal.",
|
||||||
|
p3: "We have the best technicians and analysts to respond to all types of studies.",
|
||||||
|
},
|
||||||
|
mission: {
|
||||||
|
m1Title: "Customer Focus",
|
||||||
|
m1Desc: "Understanding specific market needs for your company's success.",
|
||||||
|
m2Title: "Adapted Methodology",
|
||||||
|
m2Desc: "Integration of qualitative and quantitative methods with maximum precision.",
|
||||||
|
m3Title: "Transversal Commitment",
|
||||||
|
m3Desc: "We provide a detailed and accurate vision in projects of any size.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
servicios: {
|
||||||
|
heroBadge: "Service Portfolio",
|
||||||
|
hero: "Our wide portfolio of services will help you with decision making and ensure your company's success.",
|
||||||
|
cat1: "Methodological Evolution",
|
||||||
|
cat1Desc: "We conduct market research using classic quantitative and qualitative research methodologies and analysis of new methods.",
|
||||||
|
cat2: "Tailored Services",
|
||||||
|
cat2Desc: "Our services are flexible and with a personalized methodology. With a high degree of commitment, quality and competitiveness.",
|
||||||
|
items: {
|
||||||
|
consultoria: "Consultancy",
|
||||||
|
red: "Fieldwork Network",
|
||||||
|
estudios: "Studies",
|
||||||
|
},
|
||||||
|
consultoriaDesc: "We want to know your needs and product to give the most beneficial approach for your company.",
|
||||||
|
redDesc: "Highly involved and qualified staff in studies is one of our main differences.",
|
||||||
|
estudiosDesc: "We carry out all kinds of studies and put into practice new concepts and research methods.",
|
||||||
|
btnGo: "Go to page",
|
||||||
|
},
|
||||||
|
insights: {
|
||||||
|
badge: "Knowledge",
|
||||||
|
title: "Insights & Perspectives.",
|
||||||
|
description: "In-depth analysis of the Iberian market, consumer trends and advanced research methodologies.",
|
||||||
|
readMore: "Read analysis",
|
||||||
|
categories: {
|
||||||
|
tendencias: "Trends",
|
||||||
|
innovacion: "Innovation",
|
||||||
|
estrategia: "Strategy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
participa: {
|
||||||
|
badge: "Contributors Network",
|
||||||
|
heroTitle: "Your opinion matters.",
|
||||||
|
heroDesc: "Join our panel of informants and participate in market studies, opinion polls and Mystery Shopping projects.",
|
||||||
|
benefits: {
|
||||||
|
b1Title: "Fair Compensation",
|
||||||
|
b1Desc: "Receive financial incentives or gift cards for each completed study.",
|
||||||
|
b2Title: "Total Flexibility",
|
||||||
|
b2Desc: "Participate online, by phone or in person according to your availability.",
|
||||||
|
b3Title: "Guaranteed Privacy",
|
||||||
|
b3Desc: "Your data is anonymized. We strictly comply with GDPR regulations.",
|
||||||
|
},
|
||||||
|
formTitle: "Complete your profile to start",
|
||||||
|
fields: {
|
||||||
|
nombre: "First Name",
|
||||||
|
apellidos: "Last Name",
|
||||||
|
email: "Email",
|
||||||
|
telefono: "Phone",
|
||||||
|
provincia: "Province",
|
||||||
|
edad: "Age",
|
||||||
|
interes: "Main Interest",
|
||||||
|
mensaje: "Message (Optional)"
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
online: "Online Surveys (Remote)",
|
||||||
|
telefonicas: "Phone Surveys",
|
||||||
|
presenciales: "In-person Studies (Focus Groups)",
|
||||||
|
mistery: "Mystery Shopping (Secret Shopper)"
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
title: "Thank you for joining!",
|
||||||
|
desc: "We have received your application correctly. We will contact you soon.",
|
||||||
|
btn: "Send another application"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contacto: {
|
||||||
|
badge: "Direct Contact",
|
||||||
|
heroTitle: "Let's start the dialogue.",
|
||||||
|
heroDesc: "If you want to know more about our services or simply get in touch with us, fill out the form.",
|
||||||
|
info: {
|
||||||
|
address: "Address",
|
||||||
|
email: "Email",
|
||||||
|
phone: "Phone",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
title: "Send your inquiry",
|
||||||
|
name: "Name",
|
||||||
|
email: "Email",
|
||||||
|
message: "Questions and/or comments",
|
||||||
|
privacy: "I accept the privacy policy and the processing of my data for the management of this request.",
|
||||||
|
submit: "Send message",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
title: "Message sent!",
|
||||||
|
desc: "We will respond to you as soon as possible."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
legal: "Legal Notice",
|
||||||
|
privacy: "Privacy Policy",
|
||||||
|
cookies: "Cookies",
|
||||||
|
contact: "Contact",
|
||||||
|
rights: "All rights reserved © 2026 by ITEM OPINA S.L",
|
||||||
|
}
|
||||||
|
};
|
||||||
140
lang/es.ts
Normal file
140
lang/es.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
export const es = {
|
||||||
|
navbar: {
|
||||||
|
consultora: "La Consultora",
|
||||||
|
servicios: "Servicios",
|
||||||
|
participa: "Participa",
|
||||||
|
insights: "Insights",
|
||||||
|
contacto: "Iniciar diálogo",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
badge: "Inteligencia de Mercado",
|
||||||
|
title: "El valor de saber.",
|
||||||
|
description: "Estudios de mercado y encuestas con rigor metodológico. Entendemos a tu cliente para que tu empresa tome decisiones con absoluta certeza.",
|
||||||
|
btnServices: "Ver nuestros servicios",
|
||||||
|
btnMethod: "Nuestra metodología",
|
||||||
|
whyUs: {
|
||||||
|
title: "¿Por qué trabajar con nosotros?",
|
||||||
|
desc: "Combinamos la capacidad operativa nacional con el detalle artesanal en el análisis de datos.",
|
||||||
|
card1Title: "Análisis especializados",
|
||||||
|
card1Desc: "Realizamos toda clase de estudios y ponemos en práctica los nuevos conceptos y métodos adaptados a su sector.",
|
||||||
|
card2Title: "Amplio equipo",
|
||||||
|
card2Desc: "Nuestra amplia red cubre todo el territorio nacional y Portugal con encuestadores supervisados.",
|
||||||
|
card3Title: "Servicios a medida",
|
||||||
|
card3Desc: "Altamente flexibles y adaptados. Con un alto grado de compromiso y calidad analítica.",
|
||||||
|
ctaTitle: "Despega con datos fiables",
|
||||||
|
ctaDesc: "Nuestro equipo está listo para estructurar tu próximo estudio de opinión.",
|
||||||
|
ctaBtn: "Solicitar propuesta",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nosotros: {
|
||||||
|
badge: "Trayectoria",
|
||||||
|
title: "Investigación de Mercados. Desde 2004.",
|
||||||
|
desc: "Con más de 19 años de experiencia en metodologías cualitativas y cuantitativas adaptadas al cliente.",
|
||||||
|
history: {
|
||||||
|
title: "Nuestros inicios y evolución.",
|
||||||
|
p1: "Nuestros inicios se remontan al año 2004, cuando se funda la Sociedad Vox Populi Recerca S.L. en Barcelona.",
|
||||||
|
p2: "Tras consolidar nuestra red en Barcelona, nos expandimos hasta cubrir la totalidad del territorio nacional y Portugal.",
|
||||||
|
p3: "Contamos con los mejores técnicos y analistas para dar respuesta a todas las tipologías de estudios.",
|
||||||
|
},
|
||||||
|
mission: {
|
||||||
|
m1Title: "Enfoque al cliente",
|
||||||
|
m1Desc: "Conocer las necesidades específicas del mercado para el éxito de su empresa.",
|
||||||
|
m2Title: "Metodología adaptada",
|
||||||
|
m2Desc: "Integración de métodos cualitativos y cuantitativos con máxima precisión.",
|
||||||
|
m3Title: "Compromiso transversal",
|
||||||
|
m3Desc: "Aportamos visión detallada y certera en proyectos de cualquier envergadura.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
servicios: {
|
||||||
|
heroBadge: "Cartera de Servicios",
|
||||||
|
hero: "Nuestra amplia cartera de servicios le ayudará con la toma de decisiones y a asegurar el éxito de su empresa.",
|
||||||
|
cat1: "Evolución metodológica",
|
||||||
|
cat1Desc: "Realizamos estudios de mercado utilizando las metodologías cuantitativas y cualitativas clásicas de investigación y así mismo, nos encontramos en constante proceso de evolución y análisis de nuevos métodos o adaptándolos a las nuevas visiones del mercado.",
|
||||||
|
cat2: "Servicios a medida",
|
||||||
|
cat2Desc: "Nuestros servicios son flexibles y con metodología personalizada. Con un alto grado de compromiso, calidad y competitividad. Nuestra amplia red de campo cubre Portugal y todo el territorio nacional.",
|
||||||
|
items: {
|
||||||
|
consultoria: "Consultoría",
|
||||||
|
red: "Red de campo",
|
||||||
|
estudios: "Estudios",
|
||||||
|
},
|
||||||
|
consultoriaDesc: "Queremos conocer sus necesidades y producto para dar el enfoque más beneficioso para su empresa.",
|
||||||
|
redDesc: "Un personal altamente implicado y cualificado en los estudios, es una de nuestras principales diferencias.",
|
||||||
|
estudiosDesc: "Realizamos toda clase de estudios y ponemos en práctica los nuevos conceptos y métodos de investigación.",
|
||||||
|
btnGo: "Ir a la página",
|
||||||
|
},
|
||||||
|
insights: {
|
||||||
|
badge: "Conocimiento",
|
||||||
|
title: "Insights & Perspectivas.",
|
||||||
|
description: "Análisis profundos sobre el mercado ibérico, tendencias de consumo y metodologías avanzadas de investigación.",
|
||||||
|
readMore: "Leer análisis",
|
||||||
|
categories: {
|
||||||
|
tendencias: "Tendencias",
|
||||||
|
innovacion: "Innovación",
|
||||||
|
estrategia: "Estrategia"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
participa: {
|
||||||
|
badge: "Red de Colaboradores",
|
||||||
|
heroTitle: "Tu opinión tiene valor.",
|
||||||
|
heroDesc: "Únete a nuestro panel de informadores y participa en estudios de mercado, encuestas de opinión y proyectos de Mistery Shopping remunerados.",
|
||||||
|
benefits: {
|
||||||
|
b1Title: "Compensación justa",
|
||||||
|
b1Desc: "Recibe incentivos económicos o tarjetas regalo por cada estudio completado.",
|
||||||
|
b2Title: "Flexibilidad total",
|
||||||
|
b2Desc: "Participa online, por teléfono o presencialmente según tu disponibilidad.",
|
||||||
|
b3Title: "Privacidad garantizada",
|
||||||
|
b3Desc: "Tus datos son anonimizados. Cumplimos estrictamente con la normativa LOPD y RGPD.",
|
||||||
|
},
|
||||||
|
formTitle: "Completa tu perfil para empezar",
|
||||||
|
fields: {
|
||||||
|
nombre: "Nombre",
|
||||||
|
apellidos: "Apellidos",
|
||||||
|
email: "Email",
|
||||||
|
telefono: "Teléfono",
|
||||||
|
provincia: "Provincia",
|
||||||
|
edad: "Edad",
|
||||||
|
interes: "Interés principal",
|
||||||
|
mensaje: "Mensaje (Opcional)"
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
online: "Encuestas Online (Remoto)",
|
||||||
|
telefonicas: "Encuestas Telefónicas",
|
||||||
|
presenciales: "Estudios Presenciales (Focus Groups)",
|
||||||
|
mistery: "Mistery Shopping (Cliente Misterioso)"
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
title: "¡Gracias por unirte!",
|
||||||
|
desc: "Hemos recibido tu solicitud correctamente. Nos pondremos en contacto contigo pronto.",
|
||||||
|
btn: "Enviar otra solicitud"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contacto: {
|
||||||
|
badge: "Contacto Directo",
|
||||||
|
heroTitle: "Iniciemos el diálogo.",
|
||||||
|
heroDesc: "Si quieres saber más acerca de nuestros servicios, solicitar presupuesto o simplemente ponerte en contacto con nosotros, rellena el formulario.",
|
||||||
|
info: {
|
||||||
|
address: "Dirección",
|
||||||
|
email: "Email",
|
||||||
|
phone: "Teléfono",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
title: "Envíanos tu consulta",
|
||||||
|
name: "Nombre",
|
||||||
|
email: "Email",
|
||||||
|
message: "Dudas y/o comentarios",
|
||||||
|
privacy: "Acepto la política de privacidad y el tratamiento de mis datos para la gestión de esta solicitud.",
|
||||||
|
submit: "Enviar mensaje",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
title: "¡Mensaje enviado!",
|
||||||
|
desc: "Le responderemos en la mayor brevedad posible."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
legal: "Aviso Legal",
|
||||||
|
privacy: "Privacidad",
|
||||||
|
cookies: "Cookies",
|
||||||
|
contact: "Contacto",
|
||||||
|
rights: "Todos los derechos reservados © 2026 por ITEM OPINA S.L",
|
||||||
|
}
|
||||||
|
};
|
||||||
6
lang/index.ts
Normal file
6
lang/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { es } from './es';
|
||||||
|
import { ca } from './ca';
|
||||||
|
import { en } from './en';
|
||||||
|
|
||||||
|
export const dictionaries = { es, ca, en };
|
||||||
|
export type Locale = keyof typeof dictionaries;
|
||||||
13
lib/supabase/client.ts
Normal file
13
lib/supabase/client.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
|
console.warn("⚠️ Supabase: Faltan las variables de entorno en .env.local");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = createClient(
|
||||||
|
supabaseUrl || 'https://placeholder.supabase.co',
|
||||||
|
supabaseKey || 'placeholder'
|
||||||
|
);
|
||||||
0
lib/supabase/middleware.ts
Normal file
0
lib/supabase/middleware.ts
Normal file
0
lib/supabase/server.ts
Normal file
0
lib/supabase/server.ts
Normal file
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'images.unsplash.com',
|
||||||
|
pathname: '**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
218
package-lock.json
generated
218
package-lock.json
generated
@@ -8,9 +8,18 @@
|
|||||||
"name": "item_opina_frontend",
|
"name": "item_opina_frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@supabase/supabase-js": "^2.105.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-hook-form": "^7.75.0",
|
||||||
|
"react-icons": "^5.6.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -453,6 +462,18 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hookform/resolvers": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/utils": "^0.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-hook-form": "^7.55.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -1307,6 +1328,96 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.105.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.4.tgz",
|
||||||
|
"integrity": "sha512-Ejfa37M5xoIwoxVebxRahnwubPo8g22qkXQ4p50+N9MIvU9UZoN+A8dwVPtczzGf8oV/YXN80ZPxK4aWXuSN/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.105.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.4.tgz",
|
||||||
|
"integrity": "sha512-JVNKbBft3Qkja+WlGaE026AJ2AH9K0UTsxsfvEIHgd4zFrBor4BYRCrYFrv9IDsvVqkF72wKDsODJl5GY/C4tA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/phoenix": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.105.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.4.tgz",
|
||||||
|
"integrity": "sha512-SppIyLo/kTwIlz1qpv2HN1EQqBg0GVktrDDFsXygYROha3MgVn4rT7p5EjFHFqXQm2rdRGb/BI7bc+jr10m91w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.105.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.4.tgz",
|
||||||
|
"integrity": "sha512-6ov6c59+8D9h7q4M4Gy/uDJlC0Akxl9/714Y+6vJ+Sijuc16TS/p5DwhfRCLNcIhNiej1gEt+CQUwsjiPt4PxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/phoenix": "^0.4.2",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.105.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.4.tgz",
|
||||||
|
"integrity": "sha512-Jx+pzMP1Whjof2PWHoVBUA75/p7PQE9CqKBzn1oXVyJDOggMLSH2OzVWwsXYaxEpdC1K/KltwmOX44nL3LHl9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.105.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.4.tgz",
|
||||||
|
"integrity": "sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.105.4",
|
||||||
|
"@supabase/functions-js": "2.105.4",
|
||||||
|
"@supabase/postgrest-js": "2.105.4",
|
||||||
|
"@supabase/realtime-js": "2.105.4",
|
||||||
|
"@supabase/storage-js": "2.105.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -2715,6 +2826,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3706,6 +3826,33 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.38.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||||
|
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.38.0",
|
||||||
|
"motion-utils": "^12.36.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -4011,6 +4158,15 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4966,6 +5122,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -5033,6 +5198,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.38.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||||
|
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.36.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.36.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||||
|
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -5551,6 +5731,31 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.75.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz",
|
||||||
|
"integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -6173,6 +6378,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
@@ -6670,7 +6885,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -2,6 +2,9 @@
|
|||||||
"name": "item_opina_frontend",
|
"name": "item_opina_frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -9,9 +12,18 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@supabase/supabase-js": "^2.105.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-hook-form": "^7.75.0",
|
||||||
|
"react-icons": "^5.6.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
51
structure.sh
Normal file
51
structure.sh
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Crear estructura de carpetas principal dentro de app/
|
||||||
|
mkdir -p app/nosotros
|
||||||
|
mkdir -p app/servicios
|
||||||
|
mkdir -p app/participa
|
||||||
|
mkdir -p app/contacto
|
||||||
|
|
||||||
|
# Crear carpetas para componentes, hooks, tipos y utilidades
|
||||||
|
mkdir -p components/ui
|
||||||
|
mkdir -p components/layout
|
||||||
|
mkdir -p components/forms
|
||||||
|
mkdir -p components/sections
|
||||||
|
mkdir -p hooks
|
||||||
|
mkdir -p lib/supabase
|
||||||
|
mkdir -p types
|
||||||
|
mkdir -p utils
|
||||||
|
|
||||||
|
# --- Crear Archivos de Rutas ---
|
||||||
|
touch app/nosotros/page.tsx
|
||||||
|
touch app/servicios/page.tsx
|
||||||
|
touch app/participa/page.tsx
|
||||||
|
touch app/contacto/page.tsx
|
||||||
|
|
||||||
|
# --- Crear Componentes de Layout ---
|
||||||
|
touch components/layout/Navbar.tsx
|
||||||
|
touch components/layout/Footer.tsx
|
||||||
|
|
||||||
|
# --- Crear Componentes de UI (Base Flat Premium) ---
|
||||||
|
touch components/ui/Button.tsx
|
||||||
|
touch components/ui/Input.tsx
|
||||||
|
touch components/ui/Textarea.tsx
|
||||||
|
touch components/ui/Checkbox.tsx
|
||||||
|
touch components/ui/Card.tsx
|
||||||
|
touch components/ui/BentoGrid.tsx
|
||||||
|
|
||||||
|
# --- Crear Componentes de Formularios (Con lógica de validación) ---
|
||||||
|
touch components/forms/ParticipaForm.tsx
|
||||||
|
touch components/forms/ContactoForm.tsx
|
||||||
|
|
||||||
|
# --- Crear Configuración de Supabase ---
|
||||||
|
touch lib/supabase/client.ts
|
||||||
|
touch lib/supabase/server.ts
|
||||||
|
touch lib/supabase/middleware.ts
|
||||||
|
|
||||||
|
# --- Crear Archivos de Apoyo ---
|
||||||
|
touch types/index.ts
|
||||||
|
touch utils/validations.ts
|
||||||
|
touch hooks/useForm.ts
|
||||||
|
|
||||||
|
echo "Estructura de ITEM OPINA creada con éxito."
|
||||||
1
supabase/.temp/linked-project.json
Normal file
1
supabase/.temp/linked-project.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"ref":"lziefvcawykgpxqjksql","name":"vox-populi-setup","organization_id":"bgxfmcukiozhchitkokr","organization_slug":"bgxfmcukiozhchitkokr"}
|
||||||
0
types/index.ts
Normal file
0
types/index.ts
Normal file
38
utils/validations.ts
Normal file
38
utils/validations.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// --- ESQUEMA DE CONTACTO ---
|
||||||
|
export const ContactoSchema = z.object({
|
||||||
|
nombre: z.string().min(2, "El nombre es demasiado corto"),
|
||||||
|
email: z.string().email("Introduce un correo electrónico válido"),
|
||||||
|
mensaje: z.string().min(10, "Cuéntanos un poco más sobre tu proyecto"),
|
||||||
|
privacidad: z.boolean().refine((val) => val === true, {
|
||||||
|
message: "Debes aceptar la política de privacidad",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ContactoFormData = z.infer<typeof ContactoSchema>;
|
||||||
|
|
||||||
|
|
||||||
|
// --- ESQUEMA DE PARTICIPACIÓN ---
|
||||||
|
export const ParticipaSchema = z.object({
|
||||||
|
nombre: z.string().min(2, "El nombre es demasiado corto"),
|
||||||
|
apellidos: z.string().min(2, "Los apellidos son necesarios"),
|
||||||
|
email: z.string().email("Introduce un correo electrónico válido"),
|
||||||
|
telefono: z.string().min(9, "El teléfono no es válido"),
|
||||||
|
provincia: z.string().min(2, "Indica tu provincia"),
|
||||||
|
edad: z.string().refine((val) => !isNaN(Number(val)) && Number(val) > 0, {
|
||||||
|
message: "Introduce una edad válida",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// SOLUCIÓN: Ahora acepta un string genérico (el ID de Supabase) o vacío (opcional)
|
||||||
|
interes: z.string().optional(),
|
||||||
|
|
||||||
|
mensaje: z.string().optional(),
|
||||||
|
|
||||||
|
// Usar boolean().refine() es más seguro con checkboxes en React Hook Form
|
||||||
|
privacidad: z.boolean().refine((val) => val === true, {
|
||||||
|
message: "Debes aceptar la política de privacidad",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ParticipaFormData = z.infer<typeof ParticipaSchema>;
|
||||||
Reference in New Issue
Block a user