first commit

This commit is contained in:
gestions-UO
2026-05-11 20:50:44 +02:00
parent f7ba09d405
commit 838bec205e
36 changed files with 2514 additions and 99 deletions

99
app/contacto/page.tsx Normal file
View 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>
);
}

View File

@@ -1,26 +1,28 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
@theme {
--color-navy: #03045E;
--color-ocean: #0077B6;
--color-sky: #00B4D8;
--color-powder: #90E0EF;
--color-ice: #CAF0F8;
--font-sans: 'Manrope', sans-serif;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@layer base {
/* Esto elimina la línea azul en todos los navegadores */
*, *:focus, *:active, *:focus-visible {
outline: none !important;
box-shadow: none !important;
ring: 0 !important;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
input, textarea, select, button {
outline: none !important;
box-shadow: none !important;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@utility border-crisp {
border: 1px solid rgba(202, 240, 248, 0.6);
}

102
app/insights/page.tsx Normal file
View 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>
);
}

View File

@@ -1,20 +1,15 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Manrope } from "next/font/google";
import "./globals.css";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { LanguageProvider } from "@/context/LanguageContext";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const manrope = Manrope({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "ITEM OPINA | Inteligencia de Mercado",
description: "Estudios de mercado y consultoría con rigor metodológico.",
};
export default function RootLayout({
@@ -23,11 +18,16 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="es">
<body className={`${manrope.className} antialiased flex flex-col min-h-screen`}>
<LanguageProvider>
<Navbar />
<main className="flex-grow">
{children}
</main>
<Footer />
</LanguageProvider>
</body>
</html>
);
}

141
app/nosotros/page.tsx Normal file
View 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>
);
}

View File

@@ -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() {
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 (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<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">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<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.
<div className="w-full overflow-x-hidden">
{/* Hero Section */}
<section className="max-w-7xl mx-auto px-6 lg:px-12 pt-12 md:pt-20 pb-20 md:pb-32">
<div className="flex flex-col lg:flex-row items-center gap-10 lg:gap-12">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="flex-1 w-full text-center lg:text-left"
>
<Badge>{t.home.badge}</Badge>
<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">
{/* Dividimos el título para mantener el salto de línea estético */}
{locale === 'en' ? "The value" : locale === 'ca' ? "El valor" : "El valor"} <br />
<span className="text-ocean">
{locale === 'en' ? "of knowing." : locale === 'ca' ? "de saber." : "de saber."}
</span>
</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"
<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">
{t.home.description}
</p>
<div className="flex flex-col sm:flex-row justify-center lg:justify-start gap-4">
<Link href="/servicios" className="w-full sm:w-auto">
<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"
>
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"
<svg
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"
>
Learning
</a>{" "}
center.
<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>
</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>
<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
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
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]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<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>
</main>
<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>
);
}

91
app/participa/page.tsx Normal file
View 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
View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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>
);
};

View 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
View 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
View 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>
);
};

View 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
View 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";

View 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";

View 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
View 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
View 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
View 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
View 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
View 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
View 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'
);

View File

0
lib/supabase/server.ts Normal file
View File

6
lib/utils.ts Normal file
View 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));
}

View File

@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
pathname: '**',
},
],
},
};
export default nextConfig;

218
package-lock.json generated
View File

@@ -8,9 +8,18 @@
"name": "item_opina_frontend",
"version": "0.1.0",
"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",
"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": {
"@tailwindcss/postcss": "^4",
@@ -453,6 +462,18 @@
"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": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1307,6 +1328,96 @@
"dev": true,
"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": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2715,6 +2826,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3706,6 +3826,33 @@
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -4011,6 +4158,15 @@
"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": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4966,6 +5122,15 @@
"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": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5033,6 +5198,21 @@
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5551,6 +5731,31 @@
"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": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -6173,6 +6378,16 @@
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -6670,7 +6885,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -2,6 +2,9 @@
"name": "item_opina_frontend",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -9,9 +12,18 @@
"lint": "eslint"
},
"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",
"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": {
"@tailwindcss/postcss": "^4",

51
structure.sh Normal file
View 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."

View File

@@ -0,0 +1 @@
{"ref":"lziefvcawykgpxqjksql","name":"vox-populi-setup","organization_id":"bgxfmcukiozhchitkokr","organization_slug":"bgxfmcukiozhchitkokr"}

0
types/index.ts Normal file
View File

38
utils/validations.ts Normal file
View 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>;