all changes
This commit is contained in:
1 parent
cba8d4055b
commit
9dfced04f1
103 files changed
+7378
-31250
No files matched your search
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { anatomyLayers as layers } from '../data/anatomyData';
|
||||
|
||||
export const Anatomy = ({ isNavyBg }: ThemeProps) => {
|
||||
const bg = isNavyBg ? '#0d1117' : '#f5f5f3';
|
||||
const text = isNavyBg ? '#eef0f4' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.45)' : '#6a6866';
|
||||
const border = isNavyBg ? 'rgba(255,255,255,0.08)' : '#dddbd5';
|
||||
|
||||
return (
|
||||
<section
|
||||
id="anatomy"
|
||||
aria-labelledby="anatomy-heading"
|
||||
style={{ background: bg, color: text, borderBottom: `1px solid ${border}` }}
|
||||
className="px-8 py-28 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<motion.div
|
||||
className="mx-auto max-w-7xl"
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.1 }}
|
||||
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-16">
|
||||
<h2
|
||||
id="anatomy-heading"
|
||||
className="text-headline"
|
||||
style={{ maxWidth: '24ch' }}
|
||||
>
|
||||
Todo sistema tiene<br /> las mismas capas.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Layers — always visible */}
|
||||
<div style={{ border: `1px solid ${border}`, borderRadius: 4, overflow: 'hidden' }}>
|
||||
{layers.map((layer, i) => (
|
||||
<motion.div
|
||||
key={layer.id}
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.07 }}
|
||||
style={{
|
||||
borderBottom: i < layers.length - 1 ? `1px solid ${border}` : 'none',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '120px 1fr',
|
||||
gap: '0 32px',
|
||||
alignItems: 'start',
|
||||
padding: '32px 32px',
|
||||
}}
|
||||
>
|
||||
{/* Number */}
|
||||
<span
|
||||
className="font-display font-black"
|
||||
style={{ fontSize: 64, lineHeight: 1, color: '#3b82f6', letterSpacing: '-0.04em' }}
|
||||
>
|
||||
{layer.num}
|
||||
</span>
|
||||
|
||||
{/* Title + description + tech */}
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<p
|
||||
className="font-display font-black mb-1"
|
||||
style={{ fontSize: 13, letterSpacing: '0.04em', color: text }}
|
||||
>
|
||||
{layer.title}
|
||||
</p>
|
||||
<p className="text-xs mb-3" style={{ color: sub }}>
|
||||
{layer.subtitle}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed mb-4" style={{ color: sub, maxWidth: '60ch' }}>
|
||||
{layer.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{layer.tech.map(t => (
|
||||
<span key={t} style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 99,
|
||||
border: `1px solid ${border}`,
|
||||
color: isNavyBg ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.5)',
|
||||
background: isNavyBg ? 'rgba(255,255,255,0.04)' : 'rgba(59,130,246,0.05)',
|
||||
letterSpacing: '0.01em',
|
||||
}}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Loader from "../components/Loader";
|
||||
import { useCompanies } from "../services/companies";
|
||||
import { assetUrl } from "../services/api";
|
||||
import type { ThemeProps } from "../types";
|
||||
|
||||
function ClientsInner({ isNavyBg }: ThemeProps) {
|
||||
const { data, isLoading } = useCompanies();
|
||||
|
||||
const bg = isNavyBg ? "#0a0f1e" : "#f0efec";
|
||||
const border = isNavyBg ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.07)";
|
||||
const label = isNavyBg ? "rgba(255,255,255,0.28)" : "rgba(0,0,0,0.28)";
|
||||
|
||||
const companies = (data ?? []).filter((c) => c.showInLanding && c.logoUrl);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section
|
||||
style={{ background: bg, borderBottom: `1px solid ${border}` }}
|
||||
className="px-8 py-14 md:px-14"
|
||||
>
|
||||
<Loader className="py-8" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (companies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Empresas que confían en Rynext"
|
||||
style={{ background: bg, borderBottom: `1px solid ${border}` }}
|
||||
className="px-8 py-14 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<motion.div
|
||||
className="mx-auto max-w-7xl"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<p
|
||||
className="mb-10 text-center text-xs font-semibold uppercase tracking-[0.18em]"
|
||||
style={{ color: label }}
|
||||
>
|
||||
Empresas que confían en Rynext
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-10 md:gap-16">
|
||||
{companies.map((c) => (
|
||||
<div key={c.id} title={c.name} style={{ lineHeight: 1 }}>
|
||||
<img
|
||||
src={assetUrl(c.logoUrl)}
|
||||
alt={c.name}
|
||||
style={{
|
||||
height: 150,
|
||||
width: "auto",
|
||||
maxWidth: 200,
|
||||
objectFit: "contain",
|
||||
filter: isNavyBg
|
||||
? "brightness(0) invert(1) grayscale(1)"
|
||||
: "grayscale(1)",
|
||||
opacity: 0.55,
|
||||
transition: "opacity 250ms",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
((e.currentTarget as HTMLImageElement).style.opacity = "1")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
((e.currentTarget as HTMLImageElement).style.opacity = "0.6")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export const Clients = (props: ThemeProps) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null;
|
||||
return <ClientsInner {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import type { ThemeProps } from '../types';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'https://localhost:7001';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'success' | 'error';
|
||||
const initialForm = { nombre: '', empresa: '', email: '', telefono: '', mensaje: '' };
|
||||
|
||||
export const ContactSection = ({ isNavyBg }: ThemeProps) => {
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
|
||||
const bg = isNavyBg ? '#080c14' : '#f5f5f3';
|
||||
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.45)' : '#6a6866';
|
||||
const border = isNavyBg ? 'rgba(255,255,255,0.08)' : '#dddbd5';
|
||||
const card = isNavyBg ? 'rgba(255,255,255,0.03)' : '#ffffff';
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', borderRadius: 6, padding: '12px 14px',
|
||||
fontSize: 14, fontFamily: 'inherit', outline: 'none',
|
||||
transition: 'border-color 150ms',
|
||||
...(isNavyBg
|
||||
? { background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff' }
|
||||
: { background: '#f9f9f7', border: `1px solid ${border}`, color: '#0a0a0a' }),
|
||||
};
|
||||
|
||||
const set = (k: keyof typeof form) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.nombre.trim() || !form.email.trim()) return;
|
||||
setStatus('loading');
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/contact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: form.nombre,
|
||||
email: form.email,
|
||||
phone: form.telefono || undefined,
|
||||
company: form.empresa || undefined,
|
||||
message: form.mensaje || 'Sin mensaje adicional.',
|
||||
source: 'landing',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
setStatus('success');
|
||||
setForm(initialForm);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="contacto"
|
||||
aria-labelledby="contact-heading"
|
||||
style={{ background: bg, borderTop: `1px solid ${border}` }}
|
||||
className="px-8 py-28 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl grid gap-16 lg:grid-cols-[1fr_1.2fr] lg:items-start">
|
||||
|
||||
{/* Left — copy */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<span className="block text-xs font-semibold uppercase tracking-[0.14em] mb-4" style={{ color: '#3b82f6' }}>
|
||||
Contacto
|
||||
</span>
|
||||
<h2 id="contact-heading" className="text-headline mb-6" style={{ color: text, maxWidth: '20ch' }}>
|
||||
Hablemos de tu proyecto.
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed mb-10" style={{ color: sub, maxWidth: '38ch' }}>
|
||||
Agenda una sesión técnica sin costo. Analizamos tu operación, identificamos los cuellos de botella y presentamos una ruta concreta de implementación.
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-col gap-4">
|
||||
{[
|
||||
{ label: 'Respuesta en menos de 24 h' },
|
||||
{ label: 'Sin compromisos ni presupuesto automático' },
|
||||
{ label: 'Sesión técnica, no comercial' },
|
||||
].map((item) => (
|
||||
<li key={item.label} className="flex items-center gap-3 text-sm" style={{ color: sub }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#3b82f6', flexShrink: 0, display: 'inline-block' }} />
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
{/* Right — form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ background: card, border: `1px solid ${border}`, borderRadius: 8, padding: '40px 36px' }}
|
||||
>
|
||||
{status === 'success' ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-10 text-center">
|
||||
<CheckCircle size={48} style={{ color: '#22c55e' }} />
|
||||
<h3 className="font-display font-bold text-lg" style={{ color: text }}>¡Mensaje enviado!</h3>
|
||||
<p className="text-sm leading-relaxed" style={{ color: sub, maxWidth: '32ch' }}>
|
||||
Recibimos tu solicitud. Un miembro del equipo te contactará pronto.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="mt-2 px-6 py-2.5 text-sm font-bold text-white rounded-lg transition hover:opacity-90"
|
||||
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
Enviar otro mensaje
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5" style={{ color: sub, letterSpacing: '0.04em' }}>Nombre *</label>
|
||||
<input type="text" placeholder="Tu nombre" value={form.nombre} onChange={set('nombre')} required style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5" style={{ color: sub, letterSpacing: '0.04em' }}>Empresa</label>
|
||||
<input type="text" placeholder="Nombre de tu empresa" value={form.empresa} onChange={set('empresa')} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5" style={{ color: sub, letterSpacing: '0.04em' }}>Correo electrónico *</label>
|
||||
<input type="email" placeholder="[email protected]" value={form.email} onChange={set('email')} required style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5" style={{ color: sub, letterSpacing: '0.04em' }}>Teléfono</label>
|
||||
<input type="tel" placeholder="+52 998 000 0000" value={form.telefono} onChange={set('telefono')} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5" style={{ color: sub, letterSpacing: '0.04em' }}>Cuéntanos tu proyecto</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="¿Qué sistema necesitas? ¿Cuál es el problema que quieres resolver?"
|
||||
value={form.mensaje}
|
||||
onChange={set('mensaje')}
|
||||
style={{ ...inputStyle, resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)' }}>
|
||||
<AlertCircle size={15} style={{ color: '#ef4444', flexShrink: 0 }} />
|
||||
<p className="text-sm" style={{ color: '#ef4444' }}>
|
||||
Hubo un error al enviar. Intenta de nuevo o escríbenos a contacto@rynext.com.mx
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading' || !form.nombre.trim() || !form.email.trim()}
|
||||
className="inline-flex items-center justify-center gap-2 w-full py-3.5 text-sm font-bold text-white rounded-lg transition hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<><Loader2 size={16} className="animate-spin" />Enviando...</>
|
||||
) : (
|
||||
<><Send size={15} />Enviar mensaje</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs" style={{ color: sub, opacity: 0.6 }}>
|
||||
Sin spam. Solo te contactamos para hablar de tu proyecto.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "../components/Button";
|
||||
import type { ThemeProps } from "../types";
|
||||
|
||||
export const CtaBanner = ({ isNavyBg }: ThemeProps) => {
|
||||
const scrollToContact = () => {
|
||||
const el = document.getElementById("contacto");
|
||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
const bg = isNavyBg ? "#001f5b" : "#f9f9f7";
|
||||
const text = isNavyBg ? "#ffffff" : "#0a0a0a";
|
||||
const sub = isNavyBg ? "rgba(255,255,255,0.45)" : "rgba(0,0,0,0.45)";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative overflow-hidden px-8 py-32 md:px-14 transition-colors duration-500"
|
||||
style={{
|
||||
background: bg,
|
||||
color: text,
|
||||
borderBottom: `1px solid ${isNavyBg ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)"}`,
|
||||
}}
|
||||
aria-labelledby="cta-heading"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-grid-lines text-white/[0.03]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.7 }}
|
||||
className="text-center"
|
||||
>
|
||||
<h2
|
||||
id="cta-heading"
|
||||
className="text-display mb-8"
|
||||
style={{ color: text }}
|
||||
>
|
||||
Construimos el sistema.
|
||||
<br /> Tú operas el negocio.
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className="mx-auto mb-12 max-w-md text-base leading-relaxed"
|
||||
style={{ color: sub }}
|
||||
>
|
||||
Agenda una sesión técnica con nuestro equipo. Analizamos tu
|
||||
operación, identificamos los cuellos de botella y proponemos una
|
||||
ruta concreta de implementación.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-5">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={scrollToContact}
|
||||
className={
|
||||
isNavyBg ? "shadow-xl shadow-red-900/30" : "rounded-md"
|
||||
}
|
||||
>
|
||||
Hablemos de tu proyecto <ArrowRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Plus } from 'lucide-react';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { faqs } from '../data/faqData';
|
||||
|
||||
export const FAQ = ({ isNavyBg }: ThemeProps) => {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
|
||||
const bg = isNavyBg ? '#080c14' : '#f5f5f3';
|
||||
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.5)' : '#6a6866';
|
||||
const border = isNavyBg ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
const rowBg = isNavyBg ? 'rgba(255,255,255,0.03)' : '#ffffff';
|
||||
|
||||
return (
|
||||
<section
|
||||
id="faq"
|
||||
aria-labelledby="faq-heading"
|
||||
style={{ background: bg, color: text, borderBottom: `1px solid ${border}` }}
|
||||
className="px-8 py-28 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<motion.div
|
||||
className="mb-14"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<span className="mb-3 block text-xs font-semibold uppercase tracking-[0.14em]"
|
||||
style={{ color: '#3b82f6' }}>
|
||||
Preguntas frecuentes
|
||||
</span>
|
||||
<h2 id="faq-heading" className="text-headline" style={{ maxWidth: '24ch' }}>
|
||||
Respuestas antes de la primera llamada.
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden' }}>
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.05 }}
|
||||
style={{
|
||||
borderBottom: i < faqs.length - 1 ? `1px solid ${border}` : 'none',
|
||||
background: rowBg,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="w-full flex items-start justify-between gap-6 text-left transition-colors"
|
||||
style={{ padding: '24px 28px', cursor: 'pointer', background: 'none', border: 'none' }}
|
||||
>
|
||||
<span
|
||||
className="font-display font-bold leading-snug"
|
||||
style={{ fontSize: '1rem', color: text, letterSpacing: '-0.01em' }}
|
||||
>
|
||||
{faq.q}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
border: `1.5px solid ${isOpen ? '#3b82f6' : border}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isOpen ? '#3b82f6' : sub,
|
||||
transition: 'transform 250ms, border-color 200ms, color 200ms',
|
||||
transform: isOpen ? 'rotate(45deg)' : 'rotate(0deg)',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="anatomy-body"
|
||||
style={{ ...(isOpen ? { gridTemplateRows: '1fr' } : {}) }}
|
||||
>
|
||||
<div className="anatomy-body-inner">
|
||||
<p
|
||||
className="text-base leading-relaxed"
|
||||
style={{ padding: '0 28px 24px', color: sub }}
|
||||
>
|
||||
{faq.a}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { Button } from '../components/Button';
|
||||
import { SystemMap } from './SystemMap';
|
||||
import { useContact } from '../contexts/ContactContext';
|
||||
|
||||
const ease = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
function RevealLine({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
|
||||
return (
|
||||
<motion.span
|
||||
className="block"
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.75, delay, ease }}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export const Hero = ({ isNavyBg }: ThemeProps) => {
|
||||
const { open: openContact } = useContact();
|
||||
const bg = isNavyBg ? '#001f5b' : '#ffffff';
|
||||
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.45)';
|
||||
const gridLine = isNavyBg ? 'rgba(255,255,255,0.025)' : 'rgba(0,0,0,0.04)';
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative flex min-h-screen flex-col overflow-hidden transition-colors duration-500"
|
||||
style={{ background: bg, color: text, borderBottom: isNavyBg ? '1px solid rgba(255,255,255,0.1)' : '1px solid rgba(0,0,0,0.1)' }}
|
||||
aria-label="Rynext Technologies — Hero"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 bg-grid-lines"
|
||||
style={{ color: gridLine }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto grid w-full max-w-7xl flex-1 items-center gap-12 px-8 pb-24 pt-36 md:px-14 lg:grid-cols-[1fr_1fr] lg:gap-20">
|
||||
<div>
|
||||
<h1 className="text-display mb-8" style={{ color: text }}>
|
||||
<RevealLine delay={0.08}>Construimos</RevealLine>
|
||||
<RevealLine delay={0.16}>el sistema</RevealLine>
|
||||
<RevealLine delay={0.24}>
|
||||
<span className="text-brand-red">detrás</span> del
|
||||
</RevealLine>
|
||||
<RevealLine delay={0.32}>negocio.</RevealLine>
|
||||
</h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.5, ease }}
|
||||
className="mb-10 max-w-sm text-base leading-relaxed"
|
||||
style={{ color: sub }}
|
||||
>
|
||||
Ingeniería de software empresarial diseñada desde la arquitectura.
|
||||
No herramientas genéricas, sistemas construidos para
|
||||
la operación real de tu negocio.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.65, ease }}
|
||||
className="flex flex-wrap items-center gap-6"
|
||||
>
|
||||
<Button className="rounded-md" variant="primary" onClick={openContact}>
|
||||
Hablemos de tu proyecto <ArrowRight size={16} />
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => document.getElementById('machinery')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="group flex items-center gap-2 text-sm font-semibold transition"
|
||||
style={{ color: sub }}
|
||||
>
|
||||
Explorar la maquinaria
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.4, ease }}
|
||||
className="hidden lg:block"
|
||||
>
|
||||
<SystemMap isNavyBg={isNavyBg} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { machineryData } from '../data/machineryData';
|
||||
|
||||
export const Machinery = ({ isNavyBg }: ThemeProps) => {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
|
||||
const bg = isNavyBg ? '#001f5b' : '#ffffff';
|
||||
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.45)' : '#6a6866';
|
||||
const cardBg = isNavyBg ? 'rgba(255,255,255,0.06)' : '#fafaf9';
|
||||
const cardBdr = isNavyBg ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.09)';
|
||||
const numColor = isNavyBg ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)';
|
||||
|
||||
return (
|
||||
<section
|
||||
id="machinery"
|
||||
aria-labelledby="machinery-heading"
|
||||
style={{ background: bg, color: text, borderBottom: `1px solid ${cardBdr}` }}
|
||||
className="px-8 py-28 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<motion.div
|
||||
className="mx-auto max-w-7xl"
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.1 }}
|
||||
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-16">
|
||||
<h2
|
||||
id="machinery-heading"
|
||||
className="text-headline"
|
||||
style={{ maxWidth: '22ch', color: text }}
|
||||
>
|
||||
Los sistemas que<br className="hidden sm:block" /> mueven la operación.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Cards grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{machineryData.map((item, i) => {
|
||||
const isHov = hovered === item.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.45, delay: i * 0.07, ease: [0.22, 1, 0.36, 1] }}
|
||||
onMouseEnter={() => setHovered(item.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
role="article"
|
||||
aria-label={item.title}
|
||||
style={{
|
||||
background: isHov
|
||||
? isNavyBg ? 'rgba(59,130,246,0.06)' : 'rgba(59,130,246,0.03)'
|
||||
: cardBg,
|
||||
border: `1px solid ${isHov ? 'rgba(59,130,246,0.25)' : cardBdr}`,
|
||||
borderRadius: 6,
|
||||
padding: '28px 28px 32px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'default',
|
||||
transition: 'background 220ms, border-color 220ms, transform 220ms, box-shadow 220ms',
|
||||
transform: isHov ? 'translateY(-2px)' : 'translateY(0)',
|
||||
boxShadow: isHov
|
||||
? '0 8px 28px rgba(0,0,0,0.10)'
|
||||
: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
{/* Faint background number */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: 12,
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontSize: 96,
|
||||
fontWeight: 900,
|
||||
lineHeight: 1,
|
||||
color: isHov ? 'rgba(59,130,246,0.07)' : numColor,
|
||||
transition: 'color 220ms',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{item.num}
|
||||
</span>
|
||||
|
||||
{/* Red accent top bar on hover */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 2,
|
||||
background: '#3b82f6',
|
||||
opacity: isHov ? 1 : 0,
|
||||
transition: 'opacity 220ms',
|
||||
borderRadius: '6px 6px 0 0',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div style={{ position: 'relative', marginBottom: 20 }}>
|
||||
{(() => { const Icon = item.icon; return Icon ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: isHov
|
||||
? 'rgba(59,130,246,0.15)'
|
||||
: isNavyBg ? 'rgba(255,255,255,0.07)' : 'rgba(59,130,246,0.07)',
|
||||
color: isHov ? '#3b82f6' : isNavyBg ? 'rgba(255,255,255,0.5)' : '#3b82f6',
|
||||
transition: 'background 220ms, color 220ms',
|
||||
}}>
|
||||
<Icon size={20} />
|
||||
</span>
|
||||
) : null; })()}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<h3
|
||||
className="font-display font-black tracking-tight mb-3"
|
||||
style={{
|
||||
fontSize: 'clamp(1.1rem, 2vw, 1.3rem)',
|
||||
letterSpacing: '-0.02em',
|
||||
color: isHov ? '#3b82f6' : text,
|
||||
transition: 'color 220ms',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
style={{ color: sub, transition: 'color 220ms' }}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Check } from 'lucide-react';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { comparisonRows, proofCards } from '../data/platformAdvantageData';
|
||||
|
||||
export const PlatformAdvantage = ({ isNavyBg }: ThemeProps) => {
|
||||
return (
|
||||
<section
|
||||
id="conveniencia"
|
||||
className={`section-wave px-6 py-28 md:px-12 transition-colors duration-500 ${
|
||||
isNavyBg ? 'bg-[#001749] text-white' : 'bg-slate-50 text-brand-black'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-14 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<span className={`mb-3 block text-xs font-semibold uppercase tracking-[0.14em] ${
|
||||
isNavyBg ? 'text-sky-400' : 'text-brand-blue'
|
||||
}`}>
|
||||
Por qué Rynext
|
||||
</span>
|
||||
<h2 className="text-section-title">
|
||||
Detrás de cada plataforma hay un equipo senior, una arquitectura clara y una ruta con sentido.
|
||||
</h2>
|
||||
</div>
|
||||
<p className={`max-w-sm text-base leading-relaxed ${isNavyBg ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
Un sistema no solo se construye para operar: se diseña para sostener negocio, seguridad, adopción y evolución continua.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comparison table */}
|
||||
<div className={`overflow-hidden rounded-2xl border ${
|
||||
isNavyBg ? 'border-white/8 bg-white/[0.03]' : 'border-gray-200 bg-white'
|
||||
}`}>
|
||||
{/* Table header */}
|
||||
<div className={`grid grid-cols-1 text-xs font-semibold uppercase tracking-[0.1em] md:grid-cols-[1.2fr_1fr_1fr] ${
|
||||
isNavyBg ? 'border-b border-white/8 bg-white/[0.04]' : 'border-b border-gray-100 bg-gray-50'
|
||||
}`}>
|
||||
<div className={`px-6 py-4 ${isNavyBg ? 'text-slate-400' : 'text-gray-400'}`}>Criterio</div>
|
||||
<div className={`border-t px-6 py-4 md:border-t-0 md:border-l ${
|
||||
isNavyBg ? 'border-white/8 text-slate-400' : 'border-gray-100 text-gray-400'
|
||||
}`}>
|
||||
Licencia SaaS
|
||||
</div>
|
||||
<div className={`border-t px-6 py-4 font-bold md:border-t-0 md:border-l ${
|
||||
isNavyBg ? 'border-white/8 text-sky-400' : 'border-gray-100 text-brand-blue'
|
||||
}`}>
|
||||
Rynext software
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{comparisonRows.map((row, index) => (
|
||||
<div
|
||||
key={row.criterion}
|
||||
className={`grid grid-cols-1 text-sm md:grid-cols-[1.2fr_1fr_1fr] ${
|
||||
index < comparisonRows.length - 1
|
||||
? isNavyBg ? 'border-b border-white/5' : 'border-b border-gray-100'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className={`px-6 py-5 font-semibold ${isNavyBg ? 'text-white' : 'text-brand-navy'}`}>
|
||||
{row.criterion}
|
||||
</div>
|
||||
<div className={`flex items-start gap-2.5 border-t px-6 py-5 md:border-t-0 md:border-l ${
|
||||
isNavyBg ? 'border-white/5 text-slate-400' : 'border-gray-100 text-gray-500'
|
||||
}`}>
|
||||
<X size={13} className="mt-0.5 shrink-0 text-gray-400" />
|
||||
{row.saas}
|
||||
</div>
|
||||
<div className={`flex items-start gap-2.5 border-t px-6 py-5 md:border-t-0 md:border-l ${
|
||||
isNavyBg ? 'border-white/5 text-slate-200' : 'border-gray-100 text-brand-navy'
|
||||
}`}>
|
||||
<Check size={13} className={`mt-0.5 shrink-0 ${isNavyBg ? 'text-sky-400' : 'text-brand-blue'}`} />
|
||||
{row.ry}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Proof cards */}
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
{proofCards.map((card, index) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<motion.article
|
||||
key={card.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.08 }}
|
||||
className={`rounded-2xl border p-6 ${
|
||||
isNavyBg
|
||||
? 'border-white/8 bg-white/[0.04]'
|
||||
: 'border-gray-200 bg-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-5 inline-flex rounded-xl p-3 ${
|
||||
isNavyBg ? 'bg-brand-blue/15 text-sky-300' : 'bg-brand-blue/8 text-brand-blue'
|
||||
}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<h3 className="mb-2 font-display text-lg font-bold">{card.title}</h3>
|
||||
<p className={`text-sm leading-relaxed ${isNavyBg ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{card.description}
|
||||
</p>
|
||||
</motion.article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { principles } from '../data/principlesData';
|
||||
|
||||
export const Principles = ({ isNavyBg }: ThemeProps) => {
|
||||
const bg = isNavyBg ? '#0d1117' : '#f5f5f3';
|
||||
const text = isNavyBg ? '#eef0f4' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)';
|
||||
const gridGap = isNavyBg ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)';
|
||||
|
||||
return (
|
||||
<section
|
||||
id="principios"
|
||||
aria-labelledby="principles-heading"
|
||||
style={{ background: bg, color: text, borderBottom: `1px solid ${gridGap}` }}
|
||||
className="px-8 py-28 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<motion.div
|
||||
className="mx-auto max-w-7xl"
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.1 }}
|
||||
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-16">
|
||||
<h2
|
||||
id="principles-heading"
|
||||
className="text-headline"
|
||||
style={{ maxWidth: '24ch', color: text }}
|
||||
>
|
||||
Principios, no valores<br className="hidden sm:block" /> corporativos.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Principles — responsive grid */}
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5"
|
||||
style={{
|
||||
border: `1px solid ${gridGap}`,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{principles.map((p, i) => (
|
||||
<motion.div
|
||||
key={p.title}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.06 }}
|
||||
style={{
|
||||
padding: '32px 28px 36px',
|
||||
borderTop: '3px solid #3b82f6',
|
||||
borderBottom: `1px solid ${gridGap}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-display font-black block mb-5"
|
||||
style={{ fontSize: 56, lineHeight: 1, color: '#3b82f6', letterSpacing: '-0.04em' }}
|
||||
>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h3
|
||||
className="font-display font-black mb-3"
|
||||
style={{ fontSize: 15, letterSpacing: '-0.01em', color: text, lineHeight: 1.25 }}
|
||||
>
|
||||
{p.title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
style={{ color: sub }}
|
||||
>
|
||||
{p.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ThemeProps } from '../types';
|
||||
|
||||
export const Problem = ({ isNavyBg }: ThemeProps) => {
|
||||
const bg = isNavyBg ? '#0d1117' : '#f5f5f3';
|
||||
const text = isNavyBg ? '#eef0f4' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.4)';
|
||||
const border = isNavyBg ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="problem-heading"
|
||||
className="px-8 py-24 md:px-14 transition-colors duration-500"
|
||||
style={{ background: bg, borderBottom: `1px solid ${border}` }}
|
||||
>
|
||||
<motion.div
|
||||
className="mx-auto max-w-7xl"
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.15 }}
|
||||
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
id="problem-heading"
|
||||
className="text-display"
|
||||
style={{ lineHeight: 1.0, letterSpacing: '-0.02em', color: text }}
|
||||
>
|
||||
{['La mayoría', 'de los problemas', 'operativos son'].map((line, i) => (
|
||||
<motion.span
|
||||
key={line}
|
||||
className="block"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: i * 0.07, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{line}
|
||||
</motion.span>
|
||||
))}
|
||||
<motion.span
|
||||
className="block text-brand-red"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.24, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
problemas de sistema.
|
||||
</motion.span>
|
||||
</h2>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.45 }}
|
||||
className="mt-12 flex gap-4"
|
||||
style={{ maxWidth: '56ch' }}
|
||||
>
|
||||
<span
|
||||
className="mt-1 shrink-0 block"
|
||||
style={{ background: '#3b82f6', minHeight: '100%', width: 2, borderRadius: 2, alignSelf: 'stretch', opacity: 0.7 }}
|
||||
/>
|
||||
<p className="text-base leading-relaxed" style={{ color: sub }}>
|
||||
Software que impone sus propias reglas en lugar de adaptarse
|
||||
a la operación. Procesos que permanecen manuales porque nadie
|
||||
los diseñó para escalar. Ambos tienen la misma raíz: ausencia
|
||||
de arquitectura.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { processSteps as steps } from '../data/processData';
|
||||
|
||||
export const Process = ({ isNavyBg }: ThemeProps) => {
|
||||
const bg = isNavyBg ? '#001f5b' : '#ffffff';
|
||||
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.4)' : '#6a6866';
|
||||
const border = isNavyBg ? 'rgba(255,255,255,0.1)' : '#dddbd5';
|
||||
|
||||
return (
|
||||
<section
|
||||
id="proceso"
|
||||
aria-labelledby="process-heading"
|
||||
style={{ background: bg, color: text, borderBottom: `1px solid ${border}` }}
|
||||
className="px-8 py-28 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<motion.div
|
||||
className="mx-auto max-w-7xl"
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: false, amount: 0.1 }}
|
||||
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-16">
|
||||
<h2
|
||||
id="process-heading"
|
||||
className="text-headline"
|
||||
style={{ maxWidth: '28ch' }}
|
||||
>
|
||||
De la problemática<br className="hidden sm:block" /> al sistema en producción.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="grid gap-0 md:grid-cols-3 lg:grid-cols-5" style={{ border: `1px solid ${border}`, borderRadius: 4, overflow: 'hidden' }}>
|
||||
{steps.map((step, i) => (
|
||||
<motion.div
|
||||
key={step.num}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.08 }}
|
||||
style={{
|
||||
padding: '32px 28px',
|
||||
borderRight: i < steps.length - 1 ? `1px solid ${border}` : 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-display font-black block mb-6"
|
||||
style={{ fontSize: 52, lineHeight: 1, color: '#3b82f6', letterSpacing: '-0.04em' }}
|
||||
>
|
||||
{step.num}
|
||||
</span>
|
||||
<h3
|
||||
className="font-display font-black mb-3"
|
||||
style={{ fontSize: 13, letterSpacing: '0.04em', color: text }}
|
||||
>
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed" style={{ color: sub }}>
|
||||
{step.desc}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { servicesData } from '../data/servicesData';
|
||||
|
||||
export const Services = ({ isNavyBg }: ThemeProps) => {
|
||||
return (
|
||||
<section
|
||||
id="servicios"
|
||||
className={`section-wave px-6 py-28 md:px-12 transition-colors duration-500 ${
|
||||
isNavyBg ? 'bg-[#001749] text-white' : 'bg-slate-50 text-brand-black'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-16 flex flex-col justify-between gap-8 md:flex-row md:items-end">
|
||||
<div>
|
||||
<span className={`mb-3 block text-xs font-semibold uppercase tracking-[0.14em] ${
|
||||
isNavyBg ? 'text-sky-400' : 'text-brand-blue'
|
||||
}`}>
|
||||
Servicios
|
||||
</span>
|
||||
<h2 className="max-w-2xl text-section-title">
|
||||
Soluciones que conectan producto, infraestructura y crecimiento.
|
||||
</h2>
|
||||
</div>
|
||||
<p className={`max-w-sm text-base leading-relaxed ${isNavyBg ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
No trabajamos con plantillas. Cada ecosistema tecnológico se diseña para la operación específica del cliente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards grid */}
|
||||
<div className="grid gap-4 md:grid-cols-12">
|
||||
{servicesData.map((service, index) => {
|
||||
const Icon = service.icon;
|
||||
const accentClass = service.accent === 'blue' ? 'bg-brand-blue' : 'bg-brand-red';
|
||||
const iconBgClass = service.accent === 'blue'
|
||||
? (isNavyBg ? 'bg-brand-blue/15 text-sky-300' : 'bg-brand-blue/10 text-brand-blue')
|
||||
: (isNavyBg ? 'bg-brand-red/15 text-blue-300' : 'bg-brand-red/10 text-brand-red');
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
key={service.id}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-40px' }}
|
||||
transition={{ duration: 0.5, delay: index * 0.08 }}
|
||||
className={`group relative overflow-hidden rounded-2xl border p-7 transition-all duration-300 hover:-translate-y-0.5 ${service.gridCols} ${
|
||||
isNavyBg
|
||||
? 'border-white/8 bg-white/[0.04] hover:border-white/15 hover:bg-white/[0.07]'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-xl hover:shadow-gray-100'
|
||||
}`}
|
||||
>
|
||||
{/* Top accent line */}
|
||||
<div className={`absolute inset-x-0 top-0 h-[2px] ${accentClass} opacity-60`} />
|
||||
|
||||
{/* Header row */}
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div className={`rounded-xl p-3 ${iconBgClass}`}>
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
<span className={`font-mono text-xs font-bold ${isNavyBg ? 'text-white/20' : 'text-gray-200'}`}>
|
||||
0{service.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-3 font-display text-2xl font-bold">{service.title}</h3>
|
||||
<p className={`mb-6 text-sm leading-relaxed ${isNavyBg ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{service.description}
|
||||
</p>
|
||||
|
||||
{/* Feature list */}
|
||||
<ul className="space-y-2">
|
||||
{service.features.map((f: string) => (
|
||||
<li key={f} className="flex items-center gap-2.5">
|
||||
<Check
|
||||
size={13}
|
||||
className={`shrink-0 ${service.accent === 'blue'
|
||||
? (isNavyBg ? 'text-sky-400' : 'text-brand-blue')
|
||||
: (isNavyBg ? 'text-blue-400' : 'text-brand-red')
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs font-medium ${isNavyBg ? 'text-slate-300' : 'text-gray-600'}`}>
|
||||
{f}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
import { motion } from "framer-motion";
|
||||
import Loader from "../components/Loader";
|
||||
import { useProducts } from "../services/products";
|
||||
import { assetUrl } from "../services/api";
|
||||
import type { ThemeProps } from "../types";
|
||||
|
||||
export const Showcase = ({ isNavyBg }: ThemeProps) => {
|
||||
const { data, isLoading } = useProducts();
|
||||
|
||||
const products = (data ?? []).filter((p) => p.isActive);
|
||||
|
||||
const bg = isNavyBg ? "#080c14" : "#f0efec";
|
||||
const text = isNavyBg ? "#ffffff" : "#0a0a0a";
|
||||
const sub = isNavyBg ? "rgba(255,255,255,0.45)" : "#6a6866";
|
||||
const border = isNavyBg ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
|
||||
const tagBg = isNavyBg ? "rgba(59,130,246,0.1)" : "rgba(59,130,246,0.07)";
|
||||
|
||||
return (
|
||||
<section
|
||||
id="showcase"
|
||||
aria-labelledby="showcase-heading"
|
||||
style={{
|
||||
background: bg,
|
||||
color: text,
|
||||
borderBottom: `1px solid ${border}`,
|
||||
}}
|
||||
className="px-8 py-28 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<motion.div
|
||||
className="mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<span
|
||||
className="mb-3 block text-xs font-semibold uppercase tracking-[0.14em]"
|
||||
style={{ color: "#3b82f6" }}
|
||||
>
|
||||
Lo que construimos
|
||||
</span>
|
||||
<h2
|
||||
id="showcase-heading"
|
||||
className="text-headline"
|
||||
style={{ maxWidth: "28ch" }}
|
||||
>
|
||||
Sistemas reales para operaciones reales.
|
||||
</h2>
|
||||
<p
|
||||
className="mt-4 max-w-xl text-base leading-relaxed"
|
||||
style={{ color: sub }}
|
||||
>
|
||||
No plantillas ni demos genéricos. Cada sistema sale de entender la
|
||||
operación y construir la arquitectura correcta desde cero.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader className="py-20" />
|
||||
) : products.length > 0 ? (
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{products.map(({ id, imageUrl, name, description, badges }, i) => (
|
||||
<motion.article
|
||||
key={id}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-40px" }}
|
||||
transition={{
|
||||
duration: 0.55,
|
||||
delay: i * 0.1,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${border}`,
|
||||
background: isNavyBg ? "rgba(255,255,255,0.03)" : "#ffffff",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: isNavyBg
|
||||
? "rgba(255,255,255,0.04)"
|
||||
: "rgba(0,0,0,0.03)",
|
||||
borderBottom: `1px solid ${border}`,
|
||||
aspectRatio: "16/9",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={assetUrl(imageUrl)}
|
||||
alt={name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={sub}
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="m21 15-5-5L5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "24px 24px 28px" }}>
|
||||
<h3
|
||||
className="font-display font-bold mb-2"
|
||||
style={{
|
||||
fontSize: "1.05rem",
|
||||
color: text,
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed mb-5"
|
||||
style={{ color: sub }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{badges && badges.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
key={badge}
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
fontWeight: 600,
|
||||
padding: "3px 9px",
|
||||
borderRadius: 99,
|
||||
background: tagBg,
|
||||
color: "#3b82f6",
|
||||
letterSpacing: "0.01em",
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm" style={{ color: sub }}>
|
||||
Sin productos registrados.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { ThemeProps } from '../types';
|
||||
import { stackGroups } from '../data/stackData';
|
||||
|
||||
export const Stack = ({ isNavyBg }: ThemeProps) => {
|
||||
const bg = isNavyBg ? '#0f1520' : '#f9f9f7';
|
||||
const text = isNavyBg ? '#eef0f4' : '#0a0a0a';
|
||||
const sub = isNavyBg ? 'rgba(255,255,255,0.35)' : '#6a6866';
|
||||
const border = isNavyBg ? 'rgba(255,255,255,0.07)' : '#dddbd5';
|
||||
const tagBg = isNavyBg ? 'rgba(255,255,255,0.04)' : '#f0efec';
|
||||
|
||||
return (
|
||||
<section
|
||||
id="stack"
|
||||
aria-labelledby="stack-heading"
|
||||
style={{ background: bg, color: text }}
|
||||
className="px-8 py-28 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-16 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span className="h-px w-8 bg-brand-red" />
|
||||
<span className="text-mono-label" style={{ color: '#3b82f6' }}>Las herramientas son el medio</span>
|
||||
</div>
|
||||
<h2
|
||||
id="stack-heading"
|
||||
className="text-headline"
|
||||
style={{ maxWidth: '26ch' }}
|
||||
>
|
||||
Las herramientas son el medio.<br className="hidden sm:block" /> El sistema es el fin.
|
||||
</h2>
|
||||
</div>
|
||||
<p className="max-w-xs text-sm leading-relaxed" style={{ color: sub }}>
|
||||
Elegimos la tecnología correcta para cada problema. No hay un stack universal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stack groups */}
|
||||
<div
|
||||
style={{ borderTop: `1px solid ${border}` }}
|
||||
>
|
||||
{stackGroups.map((group, i) => (
|
||||
<motion.div
|
||||
key={group.label}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: i * 0.06 }}
|
||||
className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:gap-10"
|
||||
style={{ borderBottom: `1px solid ${border}` }}
|
||||
>
|
||||
<span
|
||||
className="text-mono-label shrink-0"
|
||||
style={{ color: sub, width: 120 }}
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.items.map(item => (
|
||||
<span
|
||||
key={item}
|
||||
className="text-sm font-medium px-3 py-1 rounded-sm"
|
||||
style={{
|
||||
background: tagBg,
|
||||
border: `1px solid ${border}`,
|
||||
color: text,
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footnote */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-10 text-xs leading-relaxed"
|
||||
style={{ color: sub, maxWidth: '60ch' }}
|
||||
>
|
||||
Estas son las herramientas con las que trabajamos actualmente. La elección depende siempre del problema, no de la preferencia.
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
type: 'main' | 'side';
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
from: string;
|
||||
to: string;
|
||||
type: 'main' | 'side';
|
||||
}
|
||||
|
||||
const NODES: Node[] = [
|
||||
{ id: 'frontend', label: 'FRONTEND', x: 80, y: 16, w: 160, h: 38, type: 'main', desc: 'Portales, apps y paneles:interfaces donde el usuario opera' },
|
||||
{ id: 'api', label: 'API GATEWAY', x: 80, y: 108, w: 160, h: 38, type: 'main', desc: 'Seguridad, ruteo, rate limiting y versionado unificados' },
|
||||
{ id: 'logic', label: 'LÓGICA DE NEGOCIO', x: 80, y: 200, w: 160, h: 38, type: 'main', desc: 'Reglas de negocio, validaciones, procesamiento y orquestación' },
|
||||
{ id: 'db', label: 'BASE DE DATOS', x: 80, y: 292, w: 160, h: 38, type: 'main', desc: 'Persistencia diseñada para tu modelo:SQL, NoSQL, caché' },
|
||||
{ id: 'infra', label: 'INFRAESTRUCTURA', x: 80, y: 384, w: 160, h: 36, type: 'main', desc: 'Nube, contenedores, CI/CD y observabilidad operacional' },
|
||||
{ id: 'payments', label: 'PAGOS', x: 8, y: 172, w: 62, h: 28, type: 'side', desc: 'Stripe, Conekta:flujo de cobro integrado' },
|
||||
{ id: 'erp', label: 'ERP / CRM', x: 8, y: 222, w: 62, h: 28, type: 'side', desc: 'SAP, Odoo:sincronización bidireccional' },
|
||||
{ id: 'auth', label: 'ACCESO', x: 250, y: 84, w: 62, h: 28, type: 'side', desc: 'SSO, JWT, OAuth 2.0:identidad y permisos por rol' },
|
||||
{ id: 'external', label: 'EXTERNOS', x: 250, y: 134, w: 62, h: 28, type: 'side', desc: 'APIs de terceros:logística, notificaciones, reportes' },
|
||||
];
|
||||
|
||||
const EDGES: Edge[] = [
|
||||
{ from: 'frontend', to: 'api', type: 'main' },
|
||||
{ from: 'api', to: 'logic', type: 'main' },
|
||||
{ from: 'logic', to: 'db', type: 'main' },
|
||||
{ from: 'db', to: 'infra', type: 'main' },
|
||||
{ from: 'payments', to: 'logic', type: 'side' },
|
||||
{ from: 'erp', to: 'logic', type: 'side' },
|
||||
{ from: 'auth', to: 'api', type: 'side' },
|
||||
{ from: 'external', to: 'api', type: 'side' },
|
||||
];
|
||||
|
||||
function nodeCenter(n: Node) {
|
||||
return { cx: n.x + n.w / 2, cy: n.y + n.h / 2 };
|
||||
}
|
||||
|
||||
function edgeLine(edge: Edge): { x1: number; y1: number; x2: number; y2: number } {
|
||||
const from = NODES.find(n => n.id === edge.from)!;
|
||||
const to = NODES.find(n => n.id === edge.to)!;
|
||||
const fc = nodeCenter(from);
|
||||
const tc = nodeCenter(to);
|
||||
if (edge.type === 'main') {
|
||||
return { x1: fc.cx, y1: from.y + from.h, x2: tc.cx, y2: to.y };
|
||||
}
|
||||
if (from.x < to.x) {
|
||||
return { x1: from.x + from.w, y1: fc.cy, x2: to.x, y2: tc.cy };
|
||||
}
|
||||
return { x1: from.x, y1: fc.cy, x2: to.x + to.w, y2: tc.cy };
|
||||
}
|
||||
|
||||
function isConnected(nodeId: string, activeId: string | null) {
|
||||
if (!activeId || nodeId === activeId) return false;
|
||||
return EDGES.some(e =>
|
||||
(e.from === activeId && e.to === nodeId) ||
|
||||
(e.to === activeId && e.from === nodeId),
|
||||
);
|
||||
}
|
||||
|
||||
function isEdgeActive(edge: Edge, activeId: string | null) {
|
||||
if (!activeId) return false;
|
||||
return edge.from === activeId || edge.to === activeId;
|
||||
}
|
||||
|
||||
const TOOLTIP_W = 172;
|
||||
const TAIL_H = 9;
|
||||
const TAIL_W = 14;
|
||||
|
||||
function tooltipHeight(desc: string): number {
|
||||
const charsPerLine = Math.floor((TOOLTIP_W - 26) / 6.3); // ~aprox chars por línea a 10.5px
|
||||
const lines = Math.ceil(desc.length / charsPerLine);
|
||||
return 14 + 16 + lines * 17 + 14; // paddingTop + label + líneas + paddingBottom
|
||||
}
|
||||
|
||||
export function SystemMap({ isNavyBg = true }: { isNavyBg?: boolean }) {
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
|
||||
const RED = '#3b82f6';
|
||||
const dimStroke = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)';
|
||||
const defStroke = isNavyBg ? 'rgba(255,255,255,0.25)' : 'rgba(0,31,91,0.3)';
|
||||
const connStroke = 'rgba(59,130,246,0.45)';
|
||||
const descText = isNavyBg ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.65)';
|
||||
const tooltipBg = isNavyBg ? '#0a1628' : '#ffffff';
|
||||
const tooltipBorder = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,31,91,0.15)';
|
||||
void tooltipBorder;
|
||||
const activeDesc = NODES.find(n => n.id === active);
|
||||
|
||||
return (
|
||||
<div className="w-full" style={{ overflow: 'visible' }}>
|
||||
<svg
|
||||
viewBox="0 0 320 434"
|
||||
width="100%"
|
||||
aria-label="Mapa de sistema interactivo:hover para explorar capas"
|
||||
role="img"
|
||||
style={{ maxWidth: 340, display: 'block', margin: '0 auto', overflow: 'visible' }}
|
||||
onMouseLeave={() => setActive(null)}
|
||||
>
|
||||
<defs>
|
||||
<filter id="bubble-shadow" x="-15%" y="-15%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="3" stdDeviation="4" floodColor="rgba(0,0,0,0.18)" />
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Edges */}
|
||||
{EDGES.map((edge, i) => {
|
||||
const { x1, y1, x2, y2 } = edgeLine(edge);
|
||||
const edgeActive = isEdgeActive(edge, active);
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={x1} y1={y1} x2={x2} y2={y2}
|
||||
stroke={edgeActive ? RED : edge.type === 'side' ? connStroke : defStroke}
|
||||
strokeWidth={edgeActive ? 1.5 : 1}
|
||||
strokeDasharray={edge.type === 'side' ? '3 3' : undefined}
|
||||
style={{ transition: 'stroke 200ms, stroke-width 200ms' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{NODES.map(node => {
|
||||
const isActive = active === node.id;
|
||||
const isConn = isConnected(node.id, active);
|
||||
const isDim = active !== null && !isActive && !isConn;
|
||||
const stroke = isActive ? RED : isConn ? 'rgba(59,130,246,0.5)' : dimStroke;
|
||||
const fillOpacity = isActive ? 0.12 : 0;
|
||||
const textColor = isActive ? RED : isDim
|
||||
? (isNavyBg ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)')
|
||||
: (isNavyBg ? 'rgba(255,255,255,0.65)' : 'rgba(0,31,91,0.75)');
|
||||
const { cx } = nodeCenter(node);
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onMouseEnter={() => setActive(node.id)}
|
||||
onFocus={() => setActive(node.id)}
|
||||
onBlur={() => setActive(null)}
|
||||
style={{ cursor: 'default', outline: 'none' }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`${node.label}: ${node.desc}`}
|
||||
>
|
||||
<rect
|
||||
x={node.x} y={node.y}
|
||||
width={node.w} height={node.h}
|
||||
fill={`rgba(59,130,246,${fillOpacity})`}
|
||||
stroke={stroke}
|
||||
strokeWidth={isActive ? 1.5 : 1}
|
||||
rx={2}
|
||||
style={{ transition: 'fill 200ms, stroke 200ms' }}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={node.y + node.h / 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={textColor}
|
||||
fontFamily="'SF Mono','Fira Code',Consolas,monospace"
|
||||
fontSize={node.type === 'main' ? 10 : 8}
|
||||
fontWeight={600}
|
||||
letterSpacing={node.type === 'main' ? 1.2 : 0.8}
|
||||
style={{ transition: 'fill 200ms', pointerEvents: 'none' }}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tooltip burbuja estilo chat */}
|
||||
{activeDesc && (() => {
|
||||
const node = NODES.find(n => n.id === active)!;
|
||||
const TOOLTIP_H = tooltipHeight(activeDesc.desc);
|
||||
const cy = node.y + node.h / 2;
|
||||
|
||||
// nodos izquierda → burbuja a la derecha
|
||||
// nodos derecha → burbuja a la izquierda
|
||||
// nodos centro → burbuja a la derecha
|
||||
const showLeft = node.x > 160; // solo los nodos de la derecha van a la izquierda
|
||||
|
||||
let tx: number, ty: number, tailPoints: string;
|
||||
|
||||
if (showLeft) {
|
||||
tx = node.x - TOOLTIP_W - TAIL_H - 4;
|
||||
ty = Math.min(Math.max(cy - TOOLTIP_H / 2, 4), 430 - TOOLTIP_H);
|
||||
const tailCy = Math.min(Math.max(cy, ty + 16), ty + TOOLTIP_H - 16);
|
||||
tailPoints = `${tx + TOOLTIP_W},${tailCy - TAIL_W / 2} ${tx + TOOLTIP_W},${tailCy + TAIL_W / 2} ${tx + TOOLTIP_W + TAIL_H},${tailCy}`;
|
||||
} else {
|
||||
tx = node.x + node.w + TAIL_H + 4;
|
||||
ty = Math.min(Math.max(cy - TOOLTIP_H / 2, 4), 430 - TOOLTIP_H);
|
||||
const tailCy = Math.min(Math.max(cy, ty + 16), ty + TOOLTIP_H - 16);
|
||||
tailPoints = `${tx},${tailCy - TAIL_W / 2} ${tx},${tailCy + TAIL_W / 2} ${tx - TAIL_H},${tailCy}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<g style={{ pointerEvents: 'none' }}>
|
||||
<g filter="url(#bubble-shadow)">
|
||||
<rect x={tx} y={ty} width={TOOLTIP_W} height={TOOLTIP_H} fill={tooltipBg} rx={12} />
|
||||
<polygon points={tailPoints} fill={tooltipBg} />
|
||||
</g>
|
||||
<foreignObject x={tx} y={ty} width={TOOLTIP_W} height={TOOLTIP_H} style={{ overflow: 'visible' }}>
|
||||
<div style={{ padding: '10px 13px' }}>
|
||||
<p style={{ fontFamily: "'SF Mono',Consolas,monospace", fontSize: 8, letterSpacing: '0.12em', textTransform: 'uppercase', color: RED, marginBottom: 4, fontWeight: 600 }}>
|
||||
{activeDesc.label}
|
||||
</p>
|
||||
<p style={{ fontSize: 10.5, color: descText, lineHeight: 1.5, margin: 0 }}>
|
||||
{activeDesc.desc}
|
||||
</p>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Loader from "../components/Loader";
|
||||
import { useTestimonials } from "../services/testimonials";
|
||||
import type { ThemeProps } from "../types";
|
||||
|
||||
export const Testimonial = ({ isNavyBg }: ThemeProps) => {
|
||||
const { data, isLoading } = useTestimonials();
|
||||
const [current, setCurrent] = useState(0);
|
||||
|
||||
const testimonials = data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (testimonials.length < 2) return;
|
||||
const t = setInterval(
|
||||
() => setCurrent((i) => (i + 1) % testimonials.length),
|
||||
6000,
|
||||
);
|
||||
return () => clearInterval(t);
|
||||
}, [testimonials.length]);
|
||||
|
||||
const item = testimonials[current];
|
||||
const bg = isNavyBg ? "#001f5b" : "#ffffff";
|
||||
const border = isNavyBg ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
|
||||
const sub = isNavyBg ? "rgba(255,255,255,0.45)" : "#6a6866";
|
||||
const text = isNavyBg ? "#ffffff" : "#0a0a0a";
|
||||
|
||||
if (!isLoading && testimonials.length === 0) return null; // sección se oculta si no hay testimonios
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Testimonios de clientes"
|
||||
style={{ background: bg, borderBottom: `1px solid ${border}` }}
|
||||
className="px-8 py-24 md:px-14 transition-colors duration-500"
|
||||
>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto mb-8 flex h-12 w-12 items-center justify-center rounded-full"
|
||||
style={{ background: "rgba(59,130,246,0.1)" }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#3b82f6">
|
||||
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z" />
|
||||
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader className="py-12" />
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={current}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="text-center"
|
||||
>
|
||||
<blockquote>
|
||||
<p
|
||||
className="font-display font-bold leading-snug"
|
||||
style={{
|
||||
fontSize: "clamp(1.25rem, 2.5vw, 1.75rem)",
|
||||
color: text,
|
||||
letterSpacing: "-0.02em",
|
||||
maxWidth: "36ch",
|
||||
margin: "0 auto 2.5rem",
|
||||
}}
|
||||
>
|
||||
"{item.quote}"
|
||||
</p>
|
||||
</blockquote>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{item.author && (
|
||||
<p
|
||||
className="font-display font-bold"
|
||||
style={{ color: text, fontSize: "0.95rem" }}
|
||||
>
|
||||
{item.author}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "#3b82f6" }}
|
||||
>
|
||||
{item.company}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{testimonials.length > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-10">
|
||||
{testimonials.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrent(i)}
|
||||
style={{
|
||||
width: i === current ? 24 : 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: i === current ? "#3b82f6" : sub,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
transition: "width 300ms, background 300ms",
|
||||
}}
|
||||
aria-label={`Testimonio ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user