refactor: reorganizar modulos dentro de app

Coloca las secciones, datos, componentes, servicios y tipos dentro de sus dominios de app y normaliza los imports con el alias @.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Emmanuel RojasandCopilot committed 2026-10-02 15:47:34 -05:00
1 parent efba699905
commit 794a659369
73 files changed
+172 -172

No files matched your search

+2 -2
View File
@@ -2,8 +2,8 @@
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Loader from '../../components/Loader';
import { useBlogPosts } from '../../services/blog';
import Loader from '@/app/components/Loader';
import { useBlogPosts } from '@/app/blog/services/blog';
const fadeUp = {
hidden: { opacity: 0, y: 28 },
+3 -3
View File
@@ -3,9 +3,9 @@ import { useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useBlogPost } from '../../../services/blog';
import { assetUrl } from '../../../services/api';
import Loader from '../../../components/Loader';
import { useBlogPost } from '@/app/blog/services/blog';
import { assetUrl } from '@/app/services/api';
import Loader from '@/app/components/Loader';
function renderContent(content: string) {
return content.split('\n').map((line, i) => {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import ArticlePage from './ArticlePage';
import { API_URL, assetUrl } from '../../../services/api';
import type { BlogPost } from '../../../services/blog';
import { API_URL, assetUrl } from '@/app/services/api';
import type { BlogPost } from '@/app/blog/types/blog';
const BASE_URL = 'https://rynext.com.mx';
+1 -1
View File
@@ -1,4 +1,4 @@
import Loader from '../../components/Loader';
import Loader from '@/app/components/Loader';
export default function Loading() {
return (
+20
View File
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/app/services/api';
import type { BlogPost } from '@/app/blog/types/blog';
export function useBlogPosts() {
return useQuery<BlogPost[]>({
queryKey: ['blog'],
queryFn: () => apiFetch<BlogPost[]>('/api/blog'),
staleTime: 1000 * 60 * 5,
});
}
export function useBlogPost(slug: string) {
return useQuery<BlogPost>({
queryKey: ['blog', slug],
queryFn: () => apiFetch<BlogPost>(`/api/blog/${slug}`),
staleTime: 1000 * 60 * 5,
enabled: !!slug,
});
}
+12
View File
@@ -0,0 +1,12 @@
export interface BlogPost {
id: string;
title: string;
slug: string;
summary: string;
content: string;
category: string;
imageUrl?: string;
authorName?: string;
isPublished: boolean;
publishedAt?: string;
}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { ContactProvider } from '@/app/contexts/ContactContext';
import { ScrollProgress } from './ScrollProgress';
import { ScrollToTop } from './ScrollToTop';
import { Navbar } from './Navbar';
import { Footer } from './Footer';
import { FloatingButtons } from './FloatingButtons';
import { CookieBanner } from './CookieBanner';
export const AppShell = ({ children }: { children: React.ReactNode }) => (
<ContactProvider>
<div className="min-h-screen overflow-x-hidden font-sans" style={{ background: '#f9f9f7' }}>
<ScrollProgress />
<ScrollToTop />
<Navbar />
{children}
<Footer isNavyBg={false} />
<FloatingButtons isNavyBg={false} setIsNavyBg={() => {}} />
<CookieBanner isNavyBg={false} />
</div>
</ContactProvider>
);
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { type ReactNode } from "react";
interface ButtonProps {
children: ReactNode;
onClick?: () => void;
href?: string;
variant?: "primary" | "secondary";
className?: string;
isNavyBg?: boolean;
}
export const Button = ({
children,
onClick,
href,
variant = "primary",
className = "",
isNavyBg = false,
}: ButtonProps) => {
const base =
"inline-flex items-center gap-3 px-8 py-4 text-base font-bold transition hover:scale-[1.02] cursor-pointer";
const styles = {
primary:
"bg-brand-red text-white shadow-lg shadow-blue-500/20 hover:bg-[#2563eb]",
secondary: isNavyBg
? "border border-white/15 bg-white/5 text-white hover:bg-white/10"
: "border border-brand-navy/15 text-brand-navy hover:border-brand-blue hover:text-brand-blue",
};
const cls = `${base} ${styles[variant]} ${className}`;
if (href) {
return (
<a href={href} className={cls}>
{children}
</a>
);
}
return (
<button onClick={onClick} className={cls}>
{children}
</button>
);
};
+224
View File
@@ -0,0 +1,224 @@
'use client';
import { useState } from 'react';
import { X, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import type { ModalProps } from '@/app/types/ui';
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 ContactModal = ({ isOpen, onClose, isNavyBg }: ModalProps) => {
const [form, setForm] = useState(initialForm);
const [status, setStatus] = useState<Status>('idle');
if (!isOpen) return null;
const set = (k: keyof typeof form) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleClose = () => {
if (status !== 'loading') {
onClose();
setTimeout(() => { setForm(initialForm); setStatus('idle'); }, 300);
}
};
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('Error del servidor');
setStatus('success');
} catch {
setStatus('error');
}
};
const card = isNavyBg ? '#0d1b35' : '#ffffff';
const bdr = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)';
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
const sub = isNavyBg ? 'rgba(255,255,255,0.45)' : '#6a6866';
const inputStyle: React.CSSProperties = {
width: '100%', borderRadius: 8, padding: '11px 14px',
fontSize: 14, fontFamily: 'inherit', outline: 'none',
transition: 'border-color 150ms',
...(isNavyBg
? { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)', color: '#fff' }
: { background: '#f8fafc', border: '1px solid rgba(0,0,0,0.1)', color: '#0a0a0a' }),
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(6px)' }}
onClick={e => { if (e.target === e.currentTarget) handleClose(); }}
>
<div
className="relative w-full max-w-xl rounded-2xl shadow-2xl"
style={{ background: card, border: `1px solid ${bdr}` }}
>
{/* Header */}
<div className="flex items-center justify-between px-8 pt-8 pb-6" style={{ borderBottom: `1px solid ${bdr}` }}>
<div>
<h3 className="font-display font-bold text-xl" style={{ color: text, letterSpacing: '-0.02em' }}>
Hablemos de tu proyecto
</h3>
<p className="text-sm mt-0.5" style={{ color: sub }}>
Te respondemos en menos de 24 h
</p>
</div>
<button
onClick={handleClose}
disabled={status === 'loading'}
className="flex h-8 w-8 items-center justify-center rounded-full transition-colors"
style={{ background: 'rgba(0,0,0,0.06)', color: sub, border: 'none', cursor: 'pointer' }}
aria-label="Cerrar"
>
<X size={16} />
</button>
</div>
{/* Success state */}
{status === 'success' ? (
<div className="flex flex-col items-center justify-center gap-4 px-8 py-14 text-center">
<CheckCircle size={48} style={{ color: '#22c55e' }} />
<h4 className="font-display font-bold text-lg" style={{ color: text }}>
¡Mensaje enviado!
</h4>
<p className="text-sm leading-relaxed" style={{ color: sub, maxWidth: '32ch' }}>
Recibimos tu solicitud. Un miembro del equipo te contactará pronto.
</p>
<button
onClick={handleClose}
className="mt-2 px-6 py-2.5 text-sm font-bold text-white rounded-xl transition hover:opacity-90"
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer' }}
>
Cerrar
</button>
</div>
) : (
/* Form */
<form onSubmit={handleSubmit} className="px-8 py-7 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-xl 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.7 }}>
Sin spam. Solo te contactamos para hablar de tu proyecto.
</p>
</form>
)}
</div>
</div>
);
};
+56
View File
@@ -0,0 +1,56 @@
'use client';
import { useState, useEffect } from 'react';
import type { ThemeProps } from '@/app/types/ui';
export const CookieBanner = ({ isNavyBg }: ThemeProps) => {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!localStorage.getItem('rynext_cookies')) {
const t = setTimeout(() => setVisible(true), 1200);
return () => clearTimeout(t);
}
}, []);
const accept = () => {
localStorage.setItem('rynext_cookies', 'accepted');
setVisible(false);
};
if (!visible) return null;
const bg = isNavyBg ? 'rgba(0,16,64,0.97)' : 'rgba(10,10,10,0.97)';
const border = isNavyBg ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.08)';
return (
<div
role="dialog"
aria-label="Aviso de cookies"
className="fixed bottom-6 left-1/2 z-50 w-[calc(100%-3rem)] max-w-xl -translate-x-1/2 px-6 py-4 shadow-2xl"
style={{
background: bg,
border: `1px solid ${border}`,
borderRadius: 6,
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
}}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm leading-relaxed" style={{ color: 'rgba(255,255,255,0.6)', maxWidth: '42ch' }}>
Usamos cookies para mejorar tu experiencia en el sitio.
Al continuar, aceptas su uso.{' '}
<a href="/privacidad" className="underline transition hover:text-white" style={{ color: 'rgba(255,255,255,0.45)' }}>
Política de privacidad
</a>
</p>
<button
onClick={accept}
className="shrink-0 px-5 py-2 text-sm font-bold text-white transition hover:opacity-90"
style={{ background: '#3b82f6', borderRadius: 3, border: 'none', cursor: 'pointer' }}
>
Aceptar
</button>
</div>
</div>
);
};
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { ArrowUp } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { type Dispatch, type SetStateAction, useState, useEffect } from 'react';
const WHATSAPP_NUMBER = '529984814889';
const WHATSAPP_MSG = encodeURIComponent('¡Hola!');
interface FloatingButtonsProps extends ThemeProps {
setIsNavyBg: Dispatch<SetStateAction<boolean>>;
}
export const FloatingButtons = ({ isNavyBg, setIsNavyBg: _setIsNavyBg }: FloatingButtonsProps) => {
const [showTop, setShowTop] = useState(false);
const [showBubble, setShowBubble] = useState(false);
const toggleBg = isNavyBg ? 'rgba(0,31,91,0.95)' : 'rgba(255,255,255,0.95)';
const toggleBorder = isNavyBg ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)';
const toggleColor = isNavyBg ? '#fbbf24' : '#001f5b';
useEffect(() => {
const onScroll = () => setShowTop(window.scrollY > 400);
window.addEventListener('scroll', onScroll, { passive: true });
const t = setTimeout(() => setShowBubble(true), 3000);
return () => { window.removeEventListener('scroll', onScroll); clearTimeout(t); };
}, []);
return (
<>
{/* Scroll to top — izquierda */}
{showTop && (
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
aria-label="Volver arriba"
className="fixed bottom-6 left-6 z-50 flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-transform hover:scale-110 active:scale-95"
style={{
background: toggleBg,
border: `1px solid ${toggleBorder}`,
backdropFilter: 'blur(8px)',
cursor: 'pointer',
color: toggleColor,
}}
>
<ArrowUp size={16} />
</button>
)}
<div
className="fixed bottom-6 right-6 z-50 flex flex-col items-center gap-3"
aria-label="Acciones rápidas"
>
{/* WhatsApp + bubble wrapper */}
<div style={{ position: 'relative' }}>
<AnimatePresence>
{showBubble && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.95 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
style={{
position: 'absolute',
bottom: 'calc(100% + 10px)',
right: 0,
filter: 'drop-shadow(0 4px 16px rgba(0,0,0,0.12))',
}}>
<div style={{
background: '#fff',
borderRadius: 16,
padding: '12px 36px 12px 16px',
fontSize: 14,
fontWeight: 500,
color: '#1a1a2e',
whiteSpace: 'nowrap',
position: 'relative',
}}>
¡Hola! ¿En qué podemos ayudarte?
<button
onClick={() => setShowBubble(false)}
aria-label="Cerrar"
style={{
position: 'absolute', top: 6, right: 8,
width: 20, height: 20, cursor: 'pointer', fontSize: 10,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#888',
}}
>✕</button>
</div>
{/* tail apuntando al botón */}
<div style={{
width: 14, height: 10,
background: '#fff',
clipPath: 'polygon(0 0, 100% 0, 100% 100%)',
position: 'absolute',
bottom: -9,
right: 16,
}} />
</motion.div>
)}
</AnimatePresence>
{/* WhatsApp */}
<a
href={`https://wa.me/${WHATSAPP_NUMBER}?text=${WHATSAPP_MSG}`}
target="_blank"
rel="noopener noreferrer"
aria-label="Contactar por WhatsApp"
className="flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-transform hover:scale-110 active:scale-95"
style={{ background: '#25D366' }}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white" aria-hidden="true">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/>
<path d="M12 0C5.373 0 0 5.373 0 12c0 2.122.554 4.112 1.523 5.837L.057 23.882a.5.5 0 0 0 .611.61l6.109-1.457A11.945 11.945 0 0 0 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 21.75a9.721 9.721 0 0 1-4.965-1.362l-.356-.211-3.687.88.897-3.583-.231-.369A9.718 9.718 0 0 1 2.25 12C2.25 6.615 6.615 2.25 12 2.25S21.75 6.615 21.75 12 17.385 21.75 12 21.75z"/>
</svg>
</a>
</div>
</div>
</>
);
};
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { Mail, MapPin, MessageCircle } from "lucide-react";
import { FaFacebookF, FaLinkedinIn, FaMapMarkerAlt } from "react-icons/fa";
import Link from "next/link";
import type { ThemeProps } from "@/app/types/ui";
import { pageLinks } from '@/app/home/data/navigationData';
import { FaRegMessage } from "react-icons/fa6";
import { MdEmail } from "react-icons/md";
export const Footer = ({ isNavyBg }: ThemeProps) => {
const bg = isNavyBg ? "#001040" : "#0a0a0a";
const sub = "rgba(255,255,255,0.45)";
const border = "rgba(255,255,255,0.08)";
return (
<footer
style={{ background: bg, borderTop: `1px solid ${border}` }}
className="px-14 pt-10 transition-colors duration-500"
role="contentinfo"
>
<div className="mx-auto max-w-7xl">
{/* Main grid */}
<div className="grid gap-14 md:grid-cols-[1.8fr_1fr_1.2fr]">
{/* Brand */}
<div>
<div className="mb-6 flex items-center gap-2.5">
<img
src="/icons/Logo_blanco.svg"
alt="Logo"
className="h-11 w-11 shrink-0 object-contain"
/>
<div className="flex flex-col leading-none">
<span
style={{
fontFamily: "'Quicksand', sans-serif",
fontWeight: 700,
fontSize: 19,
letterSpacing: "-0.01em",
color: "#ffffff",
}}
>
Rynext
</span>
<span
style={{
fontFamily: "'Quicksand', sans-serif",
fontWeight: 500,
fontSize: 12,
letterSpacing: "0.04em",
color: "rgba(255,255,255,0.45)",
}}
>
Technologies
</span>
</div>
</div>
<p
className="text-sm leading-relaxed mb-8"
style={{ color: sub, maxWidth: "30ch" }}
>
Diseñamos y construimos sistemas empresariales a medida,
arquitectura real, sin atajos técnicos.
</p>
{/* Social */}
<div className="flex items-center gap-3">
<a
href="https://www.facebook.com/RynextTechnologies/"
target="_blank"
rel="noopener noreferrer"
aria-label="Rynext en Facebook"
className="flex h-9 w-9 items-center justify-center rounded transition-colors"
style={{ background: "rgba(255,255,255,0.07)", color: sub }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.14)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.07)";
e.currentTarget.style.color = sub;
}}
>
<FaFacebookF />
</a>
<a
href="https://linkedin.com/company/rynext"
target="_blank"
rel="noopener noreferrer"
aria-label="Rynext en LinkedIn"
className="flex h-9 w-9 items-center justify-center rounded transition-colors"
style={{ background: "rgba(255,255,255,0.07)", color: sub }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.14)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.07)";
e.currentTarget.style.color = sub;
}}
>
<FaLinkedinIn />
</a>
<a
href={`https://wa.me/529984814889?text=${encodeURIComponent("¿En qué podemos ayudarte?")}`}
target="_blank"
rel="noopener noreferrer"
aria-label="Rynext en WhatsApp"
className="flex h-9 w-9 items-center justify-center rounded transition-colors"
style={{ background: "rgba(255,255,255,0.07)", color: sub }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.14)";
e.currentTarget.style.color = "#fff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.07)";
e.currentTarget.style.color = sub;
}}
>
<FaRegMessage />
</a>
</div>
</div>
{/* Páginas */}
<div>
<p
className="text-mono-label mb-6"
style={{
color: "rgba(255,255,255,0.25)",
letterSpacing: "0.12em",
}}
>
Páginas
</p>
<ul className="flex flex-col gap-3.5">
{pageLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm font-medium transition-colors"
style={{ color: sub, textDecoration: "none" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "#fff")}
onMouseLeave={(e) => (e.currentTarget.style.color = sub)}
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Contacto */}
<div>
<p
className="text-mono-label mb-6"
style={{
color: "rgba(255,255,255,0.25)",
letterSpacing: "0.12em",
}}
>
Contacto
</p>
<ul className="flex flex-col gap-4">
<li className="flex items-center gap-3">
<FaMapMarkerAlt className="text-white text-center" />
<span className="text-sm leading-snug" style={{ color: sub }}>
Cancún, Quintana Roo. México
</span>
</li>
<li className="flex items-center gap-3">
<MdEmail className="text-white text-center" />
<a
href="mailto:[email protected]"
className="text-sm font-medium transition-colors"
style={{ color: sub }}
onMouseEnter={(e) => (e.currentTarget.style.color = "#fff")}
onMouseLeave={(e) => (e.currentTarget.style.color = sub)}
>
contacto@rynext.com.mx
</a>
</li>
<li className="mt-2">
<a
href="mailto:[email protected]"
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-bold text-white transition hover:opacity-90"
style={{ background: "#3b82f6", borderRadius: 3 }}
>
Hablemos de tu proyecto
</a>
</li>
</ul>
</div>
</div>
</div>
{/* Bottom bar — full width */}
<div
className="mt-10 -mx-8 px-8 md:-mx-14 md:px-14"
style={{ borderTop: `1px solid ${border}` }}
>
<div className="mx-auto flex max-w-7xl flex-col items-center gap-2 py-5 sm:flex-row sm:justify-between">
<p className="text-xs" style={{ color: "rgba(255,255,255,0.25)" }}>
© {new Date().getFullYear()} Rynext Technologies — Todos los
derechos reservados.
</p>
<Link
href="/privacidad"
className="text-xs transition-colors"
style={{ color: "rgba(255,255,255,0.25)", textDecoration: "none" }}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "rgba(255,255,255,0.6)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "rgba(255,255,255,0.25)")
}
>
Aviso de Privacidad
</Link>
</div>
</div>
</footer>
);
};
+29
View File
@@ -0,0 +1,29 @@
'use client';
const SQUARES = [0, 1, 2, 3];
const DELAYS = [0, 0.15, 0.15, 0.3];
export default function Loader({ className = "" }: { className?: string }) {
return (
<div className={`flex flex-col items-center justify-center gap-3 ${className}`}>
<div className="grid grid-cols-2 gap-1">
{SQUARES.map((i) => (
<div
key={i}
className="h-3 w-3 rounded-sm bg-blue-500"
style={{
animation: `loaderPulse 1.2s ease-in-out ${DELAYS[i]}s infinite`,
opacity: 0.2,
}}
/>
))}
</div>
<span className="text-xs font-medium text-slate-400 tracking-wide">Cargando...</span>
<style>{`
@keyframes loaderPulse {
0%, 100% { opacity: 0.15; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
`}</style>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, X } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Button } from './Button';
import { pageLinks } from '@/app/home/data/navigationData';
export const Navbar = () => {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const rawPathname = usePathname();
const pathname = rawPathname?.replace(/\/$/, '') || '/';
const scrollToContact = () => {
setMenuOpen(false);
const el = document.getElementById('contacto');
if (el) el.scrollIntoView({ behavior: 'smooth' });
else window.location.href = '/#contacto';
};
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 80);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
return (
<>
<motion.header
className="fixed top-0 left-0 right-0 z-50"
initial={{ y: -72, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
role="banner"
>
<div
style={{
background: scrolled ? 'rgba(255,255,255,0.97)' : 'transparent',
borderBottom: scrolled ? '1px solid rgba(0,0,0,0.08)' : '1px solid transparent',
backdropFilter: scrolled ? 'blur(16px)' : 'none',
WebkitBackdropFilter: scrolled ? 'blur(16px)' : 'none',
transition: 'background 200ms, border-color 200ms',
}}
>
<div className="mx-auto flex max-w-7xl items-center justify-between px-8 py-4 md:px-14">
<Link href="/" aria-label="Rynext Technologies — inicio" className="flex items-center gap-2.5">
<img
src="/icons/Logo_negro.svg"
alt="Logo"
className="h-11 w-11 shrink-0 object-contain"
/>
<div className="flex flex-col leading-none">
<span style={{ fontFamily: "'Quicksand', sans-serif", fontWeight: 700, fontSize: 19, letterSpacing: '-0.01em', color: '#111827' }}>
Rynext
</span>
<span style={{ fontFamily: "'Quicksand', sans-serif", fontWeight: 500, fontSize: 12, letterSpacing: '0.04em', color: '#6b7280' }}>
Technologies
</span>
</div>
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-8 md:flex" aria-label="Navegación principal">
{pageLinks.filter(l => l.href !== '/privacidad').map((link) => {
const active = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className="text-sm font-semibold transition-colors"
style={{ color: active ? '#0a0a0a' : '#6a6866', textDecoration: 'none' }}
onMouseEnter={(e) => (e.currentTarget.style.color = '#0a0a0a')}
onMouseLeave={(e) => (e.currentTarget.style.color = active ? '#0a0a0a' : '#6a6866')}
>
{link.label}
</Link>
);
})}
<button
onClick={scrollToContact}
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-bold text-white transition hover:scale-[1.02] hover:opacity-90"
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer', borderRadius: 3 }}
>
Hablemos
</button>
</nav>
{/* Mobile menu button */}
<button
onClick={() => setMenuOpen((v) => !v)}
className="flex items-center justify-center rounded p-2 md:hidden"
style={{ color: '#0a0a0a', background: 'none', border: 'none', cursor: 'pointer' }}
aria-label={menuOpen ? 'Cerrar menú' : 'Abrir menú'}
aria-expanded={menuOpen}
>
{menuOpen ? <X size={22} /> : <Menu size={22} />}
</button>
</div>
</div>
</motion.header>
{/* Mobile menu */}
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
className="fixed inset-x-0 top-[73px] z-40 px-8 py-6 md:hidden"
style={{ background: '#ffffff', borderBottom: '1px solid #dddbd5' }}
role="dialog"
aria-modal="true"
aria-label="Menú de navegación"
>
<nav className="flex flex-col gap-5">
{pageLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-left text-base font-semibold"
style={{ color: pathname === link.href ? '#0a0a0a' : '#6a6866', textDecoration: 'none' }}
>
{link.label}
</Link>
))}
<div className="pt-2">
<Button onClick={scrollToContact} className="w-full py-3 text-center text-sm font-bold text-white">
Hablemos
</Button>
</div>
</nav>
</motion.div>
)}
</AnimatePresence>
</>
);
};
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { useEffect, useState } from 'react';
export const ScrollProgress = () => {
const [pct, setPct] = useState(0);
useEffect(() => {
const onScroll = () => {
const el = document.documentElement;
const scrolled = el.scrollTop || document.body.scrollTop;
const total = el.scrollHeight - el.clientHeight;
setPct(total > 0 ? (scrolled / total) * 100 : 0);
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
aria-hidden="true"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: 2,
zIndex: 100,
background: 'transparent',
pointerEvents: 'none',
}}
>
<div
style={{
height: '100%',
width: `${pct}%`,
background: '#3b82f6',
transition: 'width 80ms linear',
boxShadow: '0 0 8px rgba(59,130,246,0.6)',
}}
/>
</div>
);
};
+13
View File
@@ -0,0 +1,13 @@
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export const ScrollToTop = () => {
const pathname = usePathname();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
};
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { createContext, useContext, useState } from 'react';
import { ContactModal } from '@/app/components/ContactModal';
interface ContactCtx {
open: () => void;
}
const ContactContext = createContext<ContactCtx>({ open: () => {} });
export const ContactProvider = ({ children }: { children: React.ReactNode }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<ContactContext.Provider value={{ open: () => setIsOpen(true) }}>
{children}
<ContactModal isOpen={isOpen} onClose={() => setIsOpen(false)} isNavyBg={false} />
</ContactContext.Provider>
);
};
export const useContact = () => useContext(ContactContext);
@@ -1,32 +1,32 @@
"use client";
import { Suspense } from "react";
import dynamic from "next/dynamic";
import { Hero } from "../sections/Hero";
import { Problem } from "../sections/Problem";
import { Machinery } from "../sections/Machinery";
import { Showcase } from "../sections/Showcase";
import { PlatformAdvantage } from "../sections/PlatformAdvantage";
import { Services } from "../sections/Services";
import { Testimonial } from "../sections/Testimonial";
import { Anatomy } from "../sections/Anatomy";
import { Process } from "../sections/Process";
import { Principles } from "../sections/Principles";
import { FAQ } from "../sections/FAQ";
import { CtaBanner } from "../sections/CtaBanner";
import { ContactSection } from "../sections/ContactSection";
import { Hero } from '@/app/home/sections/Hero';
import { Problem } from '@/app/home/sections/Problem';
import { Machinery } from '@/app/home/sections/Machinery';
import { Showcase } from '@/app/home/sections/Showcase';
import { PlatformAdvantage } from '@/app/home/sections/PlatformAdvantage';
import { Services } from '@/app/home/sections/Services';
import { Testimonial } from '@/app/home/sections/Testimonial';
import { Anatomy } from '@/app/home/sections/Anatomy';
import { Process } from '@/app/home/sections/Process';
import { Principles } from '@/app/home/sections/Principles';
import { FAQ } from '@/app/home/sections/FAQ';
import { CtaBanner } from '@/app/home/sections/CtaBanner';
import { ContactSection } from '@/app/home/sections/ContactSection';
const Clients = dynamic(
() => import("../sections/Clients").then((m) => ({ default: m.Clients })),
() => import("@/app/home/sections/Clients").then((m) => ({ default: m.Clients })),
{ ssr: false },
);
const Partners = dynamic(
() => import("../sections/Partners").then((m) => ({ default: m.Partners })),
() => import("@/app/home/sections/Partners").then((m) => ({ default: m.Partners })),
{ ssr: false },
);
const Portfolio = dynamic(
() => import("../sections/Portfolio").then((m) => ({ default: m.Portfolio })),
() => import("@/app/home/sections/Portfolio").then((m) => ({ default: m.Portfolio })),
{ ssr: false },
);
+47
View File
@@ -0,0 +1,47 @@
export const anatomyLayers = [
{
id: 'presentation',
num: '01',
title: 'PRESENTACIÓN',
subtitle: 'La interfaz donde opera el usuario',
description:
'Portales de administración, apps web, paneles de monitoreo y puntos de venta. Construidos para adopción real, no para demos.',
tech: ['React', 'Next.js', 'TypeScript', 'Tailwind', 'Mobile Web'],
},
{
id: 'logic',
num: '02',
title: 'LÓGICA DE NEGOCIO',
subtitle: 'Las reglas que gobiernan la operación',
description:
'Validaciones, cálculos, flujos de aprobación y orquestación de procesos. El código que representa cómo funciona tu negocio.',
tech: ['Node.js', 'Python', 'REST APIs', 'GraphQL', 'Microservicios'],
},
{
id: 'data',
num: '03',
title: 'DATOS',
subtitle: 'Persistencia y acceso a la información',
description:
'Modelo de datos diseñado para tu operación. Bases relacionales, NoSQL, caché y búsqueda de texto. Una sola fuente de verdad.',
tech: ['PostgreSQL', 'MongoDB', 'Redis', 'Elasticsearch', 'Prisma'],
},
{
id: 'infra',
num: '04',
title: 'INFRAESTRUCTURA',
subtitle: 'La base que soporta el sistema',
description:
'Cloud, contenedores, pipelines de CI/CD y observabilidad. Diseñada para que el sistema esté siempre disponible y sea fácil de operar.',
tech: ['AWS', 'Docker', 'GitHub Actions', 'Terraform', 'Monitoring'],
},
{
id: 'integrations',
num: '05',
title: 'INTEGRACIONES',
subtitle: 'Conexión con el ecosistema externo',
description:
'ERPs, sistemas de pago, servicios de mensajería, proveedores y APIs externas. El sistema habla con todo lo que el negocio necesita.',
tech: ['Stripe', 'SAP / Odoo', 'OAuth 2.0', 'Webhooks', 'SMS / Email'],
},
];
+26
View File
@@ -0,0 +1,26 @@
export const faqs = [
{
q: '¿Cuánto tiempo tarda un proyecto típico?',
a: 'Depende del alcance, pero un sistema funcional suele estar en producción entre 8 y 16 semanas. Trabajamos en sprints cortos con entregables medibles, por lo que el cliente tiene visibilidad y control desde la primera semana.',
},
{
q: '¿Trabajan con empresas fuera de Cancún o de México?',
a: 'Sí. Trabajamos de forma remota con empresas en toda México y en otros países. Nuestro proceso está diseñado para operar de manera distribuida sin perder calidad ni comunicación.',
},
{
q: '¿Qué pasa con el mantenimiento y soporte después de la entrega?',
a: 'El sistema se entrega documentado y en producción. Ofrecemos acompañamiento post-lanzamiento: corrección de bugs, evolución del sistema y soporte técnico según el acuerdo de cada proyecto.',
},
{
q: '¿Pueden integrarse con el ERP o los sistemas que ya tenemos?',
a: 'Sí. La integración con sistemas existentes —SAP, Odoo, sistemas legacy, APIs internas— forma parte de nuestro núcleo de trabajo. Antes de proponer cualquier solución auditamos tu stack actual.',
},
{
q: '¿Cómo es el primer paso para arrancar un proyecto?',
a: 'Agendamos una sesión técnica sin costo. En esa llamada analizamos tu operación, identificamos los cuellos de botella y presentamos una ruta concreta: qué se construye, en qué orden y por qué.',
},
{
q: '¿Qué diferencia a Rynext de una agencia de desarrollo tradicional?',
a: 'Las agencias entregan código. Nosotros diseñamos sistemas. Eso significa que antes de escribir una línea, entendemos el negocio, modelamos la arquitectura y validamos que lo que vamos a construir resuelve el problema correcto. El resultado es software que opera, no software que se entrega.',
},
];
+44
View File
@@ -0,0 +1,44 @@
import { LayoutDashboard, ShoppingCart, Workflow, Zap, BarChart3 } from 'lucide-react';
export const machineryData = [
{
id: 1,
num: '01',
title: 'OPERACIONES',
description: 'Plataformas internas, automatización de flujos y control de procesos críticos. Sistemas que eliminan la fricción operativa y dan visibilidad en tiempo real.',
tags: ['Portales de gestión', 'Automatización de flujos', 'Reportes operacionales', 'Control de acceso por rol'],
icon: LayoutDashboard,
},
{
id: 2,
num: '02',
title: 'VENTAS',
description: 'Sistemas de venta, cobro y facturación integrados al núcleo del negocio. Desde el punto de contacto con el cliente hasta el comprobante fiscal.',
tags: ['E-commerce a medida', 'Integración de pagos', 'Facturación y CFDI', 'Recuperación de carritos'],
icon: ShoppingCart,
},
{
id: 3,
num: '03',
title: 'INTEGRACIONES',
description: 'Conectamos sistemas heterogéneos en un ecosistema coherente: ERPs, CRMs, proveedores y APIs de terceros. Sin silos, sin duplicidad de datos.',
tags: ['ERP / SAP / Odoo', 'APIs de terceros', 'Sincronización bidireccional', 'Webhooks y eventos'],
icon: Workflow,
},
{
id: 4,
num: '04',
title: 'AUTOMATIZACIÓN',
description: 'Eliminamos intervención humana donde no aporta valor: aprobaciones, notificaciones, sincronizaciones y pipelines de datos que corren solos.',
tags: ['Bots y pipelines', 'Procesamiento de datos', 'Notificaciones inteligentes', 'Triggers y colas'],
icon: Zap,
},
{
id: 5,
num: '05',
title: 'DATOS',
description: 'Instrumentación del negocio para tomar decisiones con evidencia. Dashboards ejecutivos, modelos analíticos y alertas que activan antes de que el problema escale.',
tags: ['Dashboards ejecutivos', 'Pipelines de datos', 'KPIs e instrumentación', 'Alertas y umbrales'],
icon: BarChart3,
},
];
+15
View File
@@ -0,0 +1,15 @@
export const pageLinks = [
{ label: 'Sobre Nosotros', href: '/sobre-nosotros' },
{ label: 'Productos', href: '/productos' },
{ label: 'Portafolio', href: '/portafolio' },
{ label: 'Blog', href: '/blog' },
{ label: 'Aviso de Privacidad', href: '/privacidad' },
];
export const navLinks = [
{ label: 'Soluciones', href: '#showcase' },
{ label: 'Portafolio', href: '#portafolio' },
{ label: 'Sistemas', href: '#machinery' },
{ label: 'Proceso', href: '#proceso' },
{ label: 'Principios', href: '#principios' },
];
@@ -0,0 +1,42 @@
import { Building2, Layers3, ShieldCheck } from 'lucide-react';
export const comparisonRows = [
{
criterion: 'Adaptación al negocio',
saas: 'Configuración limitada y procesos estándar.',
ry: 'Plataforma diseñada para tus reglas y operaciones.',
},
{
criterion: 'Propiedad y evolución',
saas: 'Depende del proveedor y su roadmap.',
ry: 'Roadmap compartido con autonomía técnica real.',
},
{
criterion: 'Integraciones',
saas: 'APIs restringidas y costos extra por conectividad.',
ry: 'Arquitectura abierta para sistemas, ERPs y herramientas.',
},
{
criterion: 'Soporte y continuidad',
saas: 'Respuesta general, soporte tipo ticket.',
ry: 'Equipo senior con acompañamiento directo.',
},
];
export const proofCards = [
{
title: 'Diseño de experiencia',
description: 'UX orientada a adopción, claridad y flujo de trabajo real.',
icon: Layers3,
},
{
title: 'Arquitectura robusta',
description: 'Infraestructura, seguridad y escalabilidad definida desde el inicio.',
icon: ShieldCheck,
},
{
title: 'Equipo senior',
description: 'Product owner, ingeniería y operación trabajando con criterio empresarial.',
icon: Building2,
},
];
+22
View File
@@ -0,0 +1,22 @@
export const principles = [
{
title: 'Simple por diseño',
description: 'La complejidad existe solo cuando aporta valor real. Todo lo demás se elimina.',
},
{
title: 'Diseñado para fallar',
description: 'Los sistemas asumen que algo fallará. La resiliencia es diseñada, no improvisada.',
},
{
title: 'Una sola fuente de verdad',
description: 'La información crítica tiene una única fuente confiable. Sin duplicidad de datos.',
},
{
title: 'Automatiza lo repetitivo',
description: 'Si una persona lo hace manualmente diez veces, el sistema debe hacerlo.',
},
{
title: 'Construido para cambiar',
description: 'El software debe poder evolucionar. La arquitectura anticipa el cambio inevitable.',
},
];
+27
View File
@@ -0,0 +1,27 @@
export const processSteps = [
{
num: '01',
title: 'ENTENDER',
desc: 'Auditamos la operación, el stack y la fricción real antes de proponer nada. Sin suposiciones.',
},
{
num: '02',
title: 'MODELAR',
desc: 'Diseñamos la arquitectura del sistema: flujos, interfaces, datos y dependencias.',
},
{
num: '03',
title: 'CONSTRUIR',
desc: 'Desarrollamos en sprints con entregables medibles y revisión técnica constante.',
},
{
num: '04',
title: 'CONECTAR',
desc: 'Integramos el sistema con el ecosistema del negocio: ERPs, pagos, APIs externas.',
},
{
num: '05',
title: 'OPERAR',
desc: 'Despliegue a producción, monitoreo, soporte y evolución continua del sistema.',
},
];
+41
View File
@@ -0,0 +1,41 @@
import { Code, Cloud, ShieldCheck, TrendingUp } from 'lucide-react';
import type { ServiceItem } from '@/app/types/ui';
export const servicesData: ServiceItem[] = [
{
id: 1,
title: 'Desarrollo de software',
description: 'Plataformas web y APIs construidas con arquitectura limpia, código mantenible y enfoque en rendimiento real.',
features: ['APIs REST & GraphQL', 'Microservicios y monolitos modulares', 'Code review y auditorías técnicas'],
icon: Code,
gridCols: 'md:col-span-7',
accent: 'blue',
},
{
id: 2,
title: 'Infraestructura en la nube',
description: 'Diseño, migración y operación de infraestructura cloud escalable con alta disponibilidad.',
features: ['AWS, GCP y Azure', 'CI/CD y DevOps', 'Monitoreo y alertas 24/7'],
icon: Cloud,
gridCols: 'md:col-span-5',
accent: 'blue',
},
{
id: 3,
title: 'Ciberseguridad aplicada',
description: 'Protección de datos, auditorías de seguridad y protocolos que cumplen estándares empresariales.',
features: ['Pruebas de penetración', 'Cifrado end-to-end', 'Cumplimiento GDPR / ISO'],
icon: ShieldCheck,
gridCols: 'md:col-span-5',
accent: 'red',
},
{
id: 4,
title: 'Producto y crecimiento',
description: 'Estrategia tecnológica alineada al negocio para lanzar, medir y escalar con datos reales.',
features: ['Roadmap técnico y de producto', 'Analytics e instrumentación', 'Optimización de conversión'],
icon: TrendingUp,
gridCols: 'md:col-span-7',
accent: 'red',
},
];
+13
View File
@@ -0,0 +1,13 @@
export const valores = [
{ num: '01', titulo: 'Arquitectura primero', desc: 'Cada sistema empieza con un diseño estructural claro antes de escribir la primera línea de código.' },
{ num: '02', titulo: 'Sin atajos técnicos', desc: 'No usamos soluciones genéricas donde la operación exige algo específico. La deuda técnica cuesta más a largo plazo.' },
{ num: '03', titulo: 'Código como contrato', desc: 'El software que entregamos es la documentación viva de la operación del cliente. Tiene que ser legible y mantenible.' },
{ num: '04', titulo: 'Iteración con propósito', desc: 'Construimos en ciclos cortos y medibles. Cada entrega aporta valor real y permite ajustar la dirección.' },
{ num: '05', titulo: 'Responsabilidad completa', desc: 'Desde el análisis hasta la producción, somos dueños del resultado. No transferimos riesgo al cliente.' },
];
export const porQueRynext = [
{ num: '01', titulo: 'Diseño desde la raíz', desc: 'Cada sistema empieza con un análisis profundo de la operación. No adaptamos plantillas, diseñamos desde cero para el contexto específico del negocio.' },
{ num: '02', titulo: 'Equipo técnico propio', desc: 'Sin intermediarios. El equipo que analiza tu operación es el mismo que diseña la arquitectura y escribe el código.' },
{ num: '03', titulo: 'Enfoque en producción', desc: 'No entregamos prototipos. Entregamos sistemas listos para operar con carga real, datos reales y usuarios reales.' },
];
+22
View File
@@ -0,0 +1,22 @@
export const stackGroups = [
{
label: 'FRONTEND',
items: ['React', 'TypeScript', 'Next.js', 'Tailwind CSS', 'Vite'],
},
{
label: 'BACKEND',
items: ['Node.js', 'Python', 'Express', 'FastAPI', 'NestJS'],
},
{
label: 'DATOS',
items: ['PostgreSQL', 'MongoDB', 'Redis', 'Prisma', 'Elasticsearch'],
},
{
label: 'CLOUD & OPS',
items: ['AWS', 'Docker', 'GitHub Actions', 'Terraform', 'Linux'],
},
{
label: 'INTEGRACIONES',
items: ['REST', 'GraphQL', 'Stripe', 'OAuth 2.0', 'Webhooks'],
},
];
+100
View File
@@ -0,0 +1,100 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { anatomyLayers as layers } from '@/app/home/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>
);
};
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/components/Loader';
import { useCompanies } from "@/app/home/services/companies";
import { assetUrl } from "@/app/services/api";
import type { ThemeProps } from "@/app/types/ui";
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} />;
};
+188
View File
@@ -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 '@/app/types/ui';
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>
);
};
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { Button } from '@/app/components/Button';
import type { ThemeProps } from "@/app/types/ui";
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>
);
};
+108
View File
@@ -0,0 +1,108 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Plus } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
import { faqs } from '@/app/home/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>
);
};
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { motion } from 'framer-motion';
import { ArrowRight } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
import { Button } from '@/app/components/Button';
import { SystemMap } from './SystemMap';
import { useContact } from '@/app/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>
);
};
+161
View File
@@ -0,0 +1,161 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { machineryData } from '@/app/home/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>
);
};
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/components/Loader';
import { useTechPartners } from "@/app/home/services/techPartners";
import { assetUrl } from "@/app/services/api";
import type { ThemeProps } from "@/app/types/ui";
function PartnersInner({ isNavyBg }: ThemeProps) {
const { data, isLoading } = useTechPartners();
const bg = isNavyBg ? "#071233" : "#f7f6f3";
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 partners = (data ?? []).filter((p) => p.isActive && p.logoUrl).sort((a, b) => a.displayOrder - b.displayOrder);
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 (partners.length === 0) return null;
return (
<section
aria-label="Partners tecnológicos de 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-5xl"
initial={{ opacity: 0, y: 14 }}
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 }}
>
Partners tecnológicos
</p>
<div className="flex flex-wrap items-center justify-center gap-8 md:gap-12">
{partners.map((p, i) => {
const inner = (
<img
src={assetUrl(p.logoUrl!)}
alt={p.name}
style={{
height: "auto",
width: "auto",
maxHeight: 150,
maxWidth: 200,
objectFit: "contain",
filter: "grayscale(1)",
opacity: 0.55,
transition: "filter 300ms, opacity 300ms",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLImageElement).style.filter = "grayscale(0)";
(e.currentTarget as HTMLImageElement).style.opacity = "1";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLImageElement).style.filter = "grayscale(1)";
(e.currentTarget as HTMLImageElement).style.opacity = "0.55";
}}
/>
);
return (
<motion.div
key={p.id}
title={p.name}
initial={{ opacity: 0, scale: 0.92 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.07, ease: [0.22, 1, 0.36, 1] }}
whileHover={{ scale: 1.06 }}
style={{ lineHeight: 1 }}
>
{p.websiteUrl
? <a href={p.websiteUrl} target="_blank" rel="noopener noreferrer">{inner}</a>
: inner}
</motion.div>
);
})}
</div>
</motion.div>
</section>
);
}
export const Partners = (props: ThemeProps) => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <PartnersInner {...props} />;
};
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { motion } from 'framer-motion';
import { X, Check } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
import { comparisonRows, proofCards } from '@/app/home/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>
);
};
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/components/Loader';
import { usePortfolio } from "@/app/portafolio/services/portfolio";
import { assetUrl } from "@/app/services/api";
import type { ThemeProps } from "@/app/types/ui";
function PortfolioInner({ isNavyBg }: ThemeProps) {
const { data, isLoading } = usePortfolio();
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 card = isNavyBg ? "rgba(255,255,255,0.03)" : "#ffffff";
const tagBg = isNavyBg ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.05)";
const projects = data ?? [];
if (isLoading) {
return (
<section style={{ background: bg, borderBottom: `1px solid ${border}` }} className="px-8 py-28 md:px-14">
<Loader className="py-20" />
</section>
);
}
if (projects.length === 0) return null;
return (
<section
id="portafolio"
aria-labelledby="portfolio-heading"
style={{ background: bg, 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" }}
>
Casos de éxito
</span>
<h2
id="portfolio-heading"
className="text-headline"
style={{ maxWidth: "28ch", color: text }}
>
Construimos el sistema. <span style={{ color: "#3b82f6" }}>Tú operas el negocio.</span>
</h2>
<p className="mt-4 max-w-xl text-base leading-relaxed" style={{ color: sub }}>
Proyectos reales, resultados medibles. Cada implementación parte de entender la operación antes de escribir una sola línea de código.
</p>
</motion.div>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p, i) => {
const cover = p.images.find((img) => img.isCover) ?? p.images.sort((a, b) => a.order - b.order)[0];
return (
<motion.article
key={p.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: card,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
{/* Cover image */}
<div
style={{
aspectRatio: "16/9",
overflow: "hidden",
background: isNavyBg ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.03)",
borderBottom: `1px solid ${border}`,
}}
>
{cover ? (
<img
src={assetUrl(cover.url)}
alt={p.title}
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>
{/* Content */}
<div style={{ padding: "24px 24px 28px", display: "flex", flexDirection: "column", flex: 1 }}>
{p.clientCompanyName && (
<span className="text-xs font-semibold uppercase tracking-wider mb-2 block" style={{ color: "#3b82f6" }}>
{p.clientCompanyName}
</span>
)}
<h3
className="font-display font-bold mb-3"
style={{ fontSize: "1.05rem", color: text, letterSpacing: "-0.01em" }}
>
{p.title}
</h3>
{p.publicSummary && (
<p className="text-sm leading-relaxed mb-5" style={{ color: sub }}>
{p.publicSummary}
</p>
)}
{p.technologies.length > 0 && (
<div className="flex flex-wrap gap-2 mt-auto">
{p.technologies.map((t) => (
<span
key={t.id}
style={{
fontSize: 10.5,
fontWeight: 600,
padding: "3px 9px",
borderRadius: 99,
background: tagBg,
color: t.color ?? sub,
letterSpacing: "0.01em",
}}
>
{t.name}
</span>
))}
</div>
)}
</div>
</motion.article>
);
})}
</div>
</div>
</section>
);
}
export const Portfolio = (props: ThemeProps) => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <PortfolioInner {...props} />;
};
+84
View File
@@ -0,0 +1,84 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { principles } from '@/app/home/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>
);
};
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
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>
);
};
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { processSteps as steps } from '@/app/home/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>
);
};
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
import { servicesData } from '@/app/home/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>
);
};
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { motion } from "framer-motion";
import Loader from '@/app/components/Loader';
import { useProducts } from "@/app/productos/services/products";
import { assetUrl } from "@/app/services/api";
import type { ThemeProps } from "@/app/types/ui";
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" }}
>
Nuestras soluciones
</span>
<h2
id="showcase-heading"
className="text-headline"
style={{ maxWidth: "28ch" }}
>
Software diseñado para tu operación.
</h2>
<p
className="mt-4 max-w-xl text-base leading-relaxed"
style={{ color: sub }}
>
Cada módulo resuelve un problema real. Sin funciones de relleno, sin licencias por usuario, sin límites artificiales.
</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>
);
};
+95
View File
@@ -0,0 +1,95 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { stackGroups } from '@/app/home/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>
);
};
+227
View File
@@ -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>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Loader from '@/app/components/Loader';
import { useTestimonials } from "@/app/home/services/testimonials";
import type { ThemeProps } from "@/app/types/ui";
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>
);
};
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/app/services/api';
import type { Company } from '@/app/home/types/companies';
export function useCompanies() {
return useQuery<Company[]>({
queryKey: ['companies'],
queryFn: () => apiFetch<Company[]>('/api/companies'),
staleTime: 1000 * 60 * 5,
});
}
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/app/services/api';
import type { TechPartner } from '@/app/home/types/techPartners';
export function useTechPartners() {
return useQuery<TechPartner[]>({
queryKey: ['tech-partners'],
queryFn: () => apiFetch<TechPartner[]>('/api/tech-partners'),
staleTime: 1000 * 60 * 5,
});
}
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/app/services/api';
import type { Testimonial } from '@/app/home/types/testimonials';
interface OnboardingCompleted {
clientCompanyName: string;
testimonialQuote?: string;
testimonialAuthor?: string;
}
export function useTestimonials() {
return useQuery<Testimonial[]>({
queryKey: ['testimonials'],
queryFn: async () => {
const data = await apiFetch<OnboardingCompleted[]>('/api/onboarding/completed');
return data
.filter(o => o.testimonialQuote)
.map(o => ({
quote: o.testimonialQuote!,
author: o.testimonialAuthor ?? '',
company: o.clientCompanyName,
}));
},
staleTime: 1000 * 60 * 5,
});
}
+6
View File
@@ -0,0 +1,6 @@
export interface Company {
id: string;
name: string;
logoUrl?: string;
showInLanding?: boolean;
}
+9
View File
@@ -0,0 +1,9 @@
export interface TechPartner {
id: string;
name: string;
logoUrl?: string;
category?: string;
websiteUrl?: string;
displayOrder: number;
isActive: boolean;
}
+5
View File
@@ -0,0 +1,5 @@
export interface Testimonial {
quote: string;
author: string;
company: string;
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react';
export function useScrollSnap() {
const locked = useRef(false);
const current = useRef(0);
useEffect(() => {
const getSections = () =>
Array.from(document.querySelectorAll('section, footer')) as HTMLElement[];
const goTo = (index: number) => {
const sections = getSections();
if (index < 0 || index >= sections.length) return;
current.current = index;
locked.current = true;
sections[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(() => { locked.current = false; }, 900);
};
const updateCurrent = () => {
const sections = getSections();
const mid = window.innerHeight / 2;
sections.forEach((s, i) => {
const rect = s.getBoundingClientRect();
if (rect.top <= mid && rect.bottom > mid) current.current = i;
});
};
const onWheel = (e: WheelEvent) => {
if (locked.current) { e.preventDefault(); return; }
updateCurrent();
const dir = e.deltaY > 0 ? 1 : -1;
const next = current.current + dir;
const sections = getSections();
if (next >= 0 && next < sections.length) {
e.preventDefault();
goTo(next);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (locked.current) return;
updateCurrent();
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
e.preventDefault();
goTo(current.current + 1);
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault();
goTo(current.current - 1);
}
};
window.addEventListener('wheel', onWheel, { passive: false });
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('wheel', onWheel);
window.removeEventListener('keydown', onKeyDown);
};
}, []);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Metadata, Viewport } from 'next';
import './globals.css';
import { Providers } from './providers';
import { AppShell } from '../components/AppShell';
import { AppShell } from '@/app/components/AppShell';
export const metadata: Metadata = {
metadataBase: new URL('https://rynext.com.mx'),
+1 -1
View File
@@ -1,4 +1,4 @@
import Loader from '../components/Loader';
import Loader from '@/app/components/Loader';
export default function Loading() {
return (
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { HomePage } from './HomePage';
import { HomePage } from '@/app/home/HomePage';
export const metadata: Metadata = {
title: 'Rynext Technologies | Software Empresarial 100% Mexicano',
+3 -3
View File
@@ -1,9 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Loader from '../../components/Loader';
import { usePortfolio } from '../../services/portfolio';
import { assetUrl } from '../../services/api';
import Loader from '@/app/components/Loader';
import { usePortfolio } from '@/app/portafolio/services/portfolio';
import { assetUrl } from '@/app/services/api';
const bg = '#f9f9f7';
const text = '#0a0a0a';
+1 -1
View File
@@ -1,4 +1,4 @@
import Loader from '../../components/Loader';
import Loader from '@/app/components/Loader';
export default function Loading() {
return (
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/app/services/api';
import type { PortfolioProject } from '@/app/portafolio/types/portfolio';
export function usePortfolio() {
return useQuery<PortfolioProject[]>({
queryKey: ['portfolio'],
queryFn: () => apiFetch<PortfolioProject[]>('/api/portfolio/public'),
staleTime: 1000 * 60 * 5,
});
}
+29
View File
@@ -0,0 +1,29 @@
export interface PortfolioImage {
id: string;
portfolioEntryId: string;
url: string;
order: number;
isCover: boolean;
caption?: string;
}
export interface PortfolioTechnology {
id: string;
name: string;
iconUrl?: string;
color?: string;
}
export interface PortfolioProject {
id: string;
title: string;
clientCompanyName?: string;
publicSummary?: string;
caseStudy?: string;
solution?: string;
siteUrl?: string;
isPublished: boolean;
displayOrder: number;
technologies: PortfolioTechnology[];
images: PortfolioImage[];
}
+1 -1
View File
@@ -1,4 +1,4 @@
import Loader from '../../components/Loader';
import Loader from '@/app/components/Loader';
export default function Loading() {
return (
+3 -3
View File
@@ -1,9 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Loader from '../../components/Loader';
import { useProducts } from '../../services/products';
import { assetUrl } from '../../services/api';
import Loader from '@/app/components/Loader';
import { useProducts } from '@/app/productos/services/products';
import { assetUrl } from '@/app/services/api';
const fadeUp = {
hidden: { opacity: 0, y: 28 },
+1 -1
View File
@@ -1,4 +1,4 @@
import Loader from '../../components/Loader';
import Loader from '@/app/components/Loader';
export default function Loading() {
return (
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/app/services/api';
import type { Product } from '@/app/productos/types/products';
export function useProducts() {
return useQuery<Product[]>({
queryKey: ['products'],
queryFn: () => apiFetch<Product[]>('/api/products'),
staleTime: 1000 * 60 * 5,
});
}
+8
View File
@@ -0,0 +1,8 @@
export interface Product {
id: string;
name: string;
description?: string;
imageUrl?: string;
badges: string[];
isActive: boolean;
}
+13
View File
@@ -0,0 +1,13 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7030";
export async function apiFetch<T>(path: string): Promise<T> {
const res = await fetch(`${API_URL}${path}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
export function assetUrl(path?: string | null): string | undefined {
if (!path) return undefined;
if (path.startsWith('http')) return path;
return `${API_URL}${path}`;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { MetadataRoute } from 'next';
import { API_URL } from '../services/api';
import type { BlogPost } from '../services/blog';
import { API_URL } from '@/app/services/api';
import type { BlogPost } from '@/app/blog/types/blog';
export const revalidate = 3600;
+3 -3
View File
@@ -1,9 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Loader from '../../components/Loader';
import { useTeam, groupByDepartment } from '../../services/team';
import { valores, porQueRynext } from '../../data/sobreNosotrosData';
import Loader from '@/app/components/Loader';
import { useTeam, groupByDepartment } from '@/app/sobre-nosotros/services/team';
import { valores, porQueRynext } from '@/app/home/data/sobreNosotrosData';
const TEAM_IMAGE = 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1200&q=80';
+1 -1
View File
@@ -1,4 +1,4 @@
import Loader from '../../components/Loader';
import Loader from '@/app/components/Loader';
export default function Loading() {
return (
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/app/services/api';
import type { Department, TeamMember } from '@/app/sobre-nosotros/types/team';
export function useTeam() {
return useQuery<TeamMember[]>({
queryKey: ['team'],
queryFn: () => apiFetch<TeamMember[]>('/api/employees/public'),
staleTime: 1000 * 60 * 5,
});
}
export function groupByDepartment(members: TeamMember[]): Department[] {
const map = new Map<string, Department>();
for (const m of members) {
const key = m.departmentName ?? 'Sin departamento';
if (!map.has(key)) {
map.set(key, { name: key, order: m.departmentOrder ?? 99, members: [] });
}
map.get(key)!.members.push(m);
}
return Array.from(map.values()).sort((a, b) => a.order - b.order);
}
+15
View File
@@ -0,0 +1,15 @@
export interface TeamMember {
id: string;
firstName: string;
lastName: string;
positionName?: string;
departmentName?: string;
departmentOrder?: number;
positionOrder?: number;
}
export interface Department {
name: string;
order: number;
members: TeamMember[];
}
+28
View File
@@ -0,0 +1,28 @@
import type React from 'react';
export interface ThemeProps {
isNavyBg: boolean;
}
export interface NavbarProps {
// no props — uses ContactContext internally
}
export interface HeroProps extends ThemeProps {
// onOpenContact removed — uses ContactContext internally
}
export interface ModalProps extends ThemeProps {
isOpen: boolean;
onClose: () => void;
}
export interface ServiceItem {
id: number;
title: string;
description: string;
features: string[];
icon: React.ElementType;
accent: 'blue' | 'red';
gridCols: string;
}