This commit is contained in:
Emmanuel Rojas committed 2026-10-06 02:00:11 -05:00
1 parent 794a659369
commit 5f99b65611
349 files changed
+32252 -2180

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import { Button } from '@/app/(landing)/components/ui/button';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/app/(landing)/components/ui/empty';
export function NotFoundPage() {
useEffect(() => {
document.title = '404 — Página no encontrada | Rynext Technologies';
}, []);
return (
<main
id="main-content"
className="relative flex w-full items-center justify-center px-8 pb-24 pt-40"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-16 -left-20 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(59,130,246,0.16)' }}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-16 -right-20 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(139,92,246,0.16)' }}
/>
<Empty className="relative border-none">
<EmptyHeader>
<EmptyTitle
className="font-display font-black"
style={{ fontSize: 'clamp(6rem, 20vw, 10rem)', lineHeight: 1, letterSpacing: '-0.04em', color: 'var(--foreground)' }}
>
404
</EmptyTitle>
<EmptyDescription style={{ color: 'var(--muted-foreground)' }}>
La página que buscas no existe o fue movida.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex gap-3">
<Button asChild>
<Link href="/">
<Icon icon="ph:house" className="mr-2 size-4" />
Ir al inicio
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/portafolio">
<Icon icon="ph:compass" className="mr-2 size-4" />
Ver portafolio
</Link>
</Button>
</div>
</EmptyContent>
</Empty>
</main>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/(landing)/components/Loader';
import { useBlogPosts } from '@/app/(landing)/blog/services/blog';
const fadeUp = {
hidden: { opacity: 0, y: 28 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
},
};
function BlogContent() {
const { data, isLoading } = useBlogPosts();
useEffect(() => {
document.title = "Blog | Rynext Technologies";
}, []);
const posts = (data ?? []).map((p) => ({
id: p.id,
category: p.category.toUpperCase(),
title: p.title,
summary: p.summary,
publishedAt: p.publishedAt,
imageUrl: p.imageUrl,
slug: p.slug,
}));
return (
<main id="main-content">
<section
style={{
background: "var(--background)",
paddingTop: "120px",
paddingBottom: "16px",
}}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-7xl text-center">
<motion.h1
className="text-2xl md:text-3xl font-bold"
style={{ color: "var(--foreground)" }}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeUp}
>
Artículos
</motion.h1>
</div>
</section>
<section
style={{
background: "var(--background)",
padding: "0 0 100px",
minHeight: "60vh",
}}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-7xl">
{isLoading ? (
<Loader className="py-20" />
) : posts.length > 0 ? (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((art, i) => (
<motion.article
key={art.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
viewport={{ once: true, margin: "-40px" }}
transition={{
duration: 0.55,
delay: i * 0.08,
ease: [0.22, 1, 0.36, 1],
}}
style={{
borderRadius: 14,
border: "1px solid var(--border)",
background: "var(--card)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
opacity: art.slug ? 1 : 0.82,
}}
>
<Link
href={art.slug ? `/blog/${art.slug}` : "#"}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
textDecoration: "none",
color: "inherit",
pointerEvents: art.slug ? "auto" : "none",
}}
>
<div
style={{
aspectRatio: "16/9",
overflow: "hidden",
background: "rgba(0,0,0,0.03)",
borderBottom: "1px solid var(--border)",
}}
>
{art.imageUrl ? (
<img
src={art.imageUrl}
alt={art.title}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<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="var(--muted-foreground)" 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",
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
{art.category && (
<span
className="text-xs font-semibold uppercase tracking-wider mb-2 block"
style={{ color: "#3b82f6" }}
>
{art.category}
</span>
)}
<h2
className="font-display font-bold mb-3"
style={{ fontSize: "1.1rem", color: "var(--foreground)", letterSpacing: "-0.01em" }}
>
{art.title}
</h2>
<p
className="text-sm leading-relaxed mb-5"
style={{ color: "var(--muted-foreground)", flex: 1 }}
>
{art.summary}
</p>
<p
className="text-xs mt-auto"
style={{ color: "var(--muted-foreground)" }}
>
{art.publishedAt
? new Date(art.publishedAt).toLocaleDateString(
"es-MX",
{ day: "2-digit", month: "long", year: "numeric" },
)
: "Próximamente"}
</p>
</div>
</Link>
</motion.article>
))}
</div>
) : (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Sin artículos publicados.
</p>
)}
</div>
</section>
</main>
);
}
export function BlogPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <BlogContent />;
}
@@ -0,0 +1,316 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { useBlogPost } from '@/app/(landing)/blog/services/blog';
import { assetUrl } from '@/app/(landing)/services/api';
import Loader from '@/app/(landing)/components/Loader';
function renderContent(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("# "))
return (
<h2
key={i}
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--foreground)",
marginTop: "2rem",
marginBottom: "0.5rem",
lineHeight: 1.3,
}}
>
{line.slice(2)}
</h2>
);
if (line.startsWith("## "))
return (
<h3
key={i}
style={{
fontSize: "1.15rem",
fontWeight: 600,
color: "var(--foreground)",
marginTop: "1.5rem",
marginBottom: "0.4rem",
}}
>
{line.slice(3)}
</h3>
);
if (line.startsWith("- "))
return (
<li
key={i}
style={{
color: "var(--muted-foreground)",
lineHeight: 1.8,
marginLeft: "1.2rem",
listStyleType: "disc",
}}
>
{line.slice(2)}
</li>
);
if (line.trim() === "") return <br key={i} />;
return (
<p
key={i}
style={{
color: "var(--muted-foreground)",
lineHeight: 1.85,
marginBottom: "0.25rem",
}}
>
{line}
</p>
);
});
}
export default function ArticlePage() {
const params = useParams();
const router = useRouter();
const slug =
typeof params.slug === "string" ? params.slug : (params.slug?.[0] ?? "");
const { data: post, isLoading, isError } = useBlogPost(slug);
useEffect(() => {
if (post) document.title = `${post.title} | Blog`;
}, [post]);
if (isLoading) return <Loader className="py-40" />;
if (isError || !post) {
return (
<main
style={{
minHeight: "80vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
}}
>
<p
style={{
color: "var(--muted-foreground)",
fontSize: 14,
fontFamily: "monospace",
letterSpacing: "0.1em",
}}
>
ARTÍCULO NO ENCONTRADO
</p>
<button
onClick={() => router.push("/blog")}
style={{
color: "#3b82f6",
fontSize: 14,
textDecoration: "underline",
background: "none",
border: "none",
cursor: "pointer",
}}
>
Volver al blog
</button>
</main>
);
}
const imageUrl = assetUrl(post.imageUrl);
const publishDate = post.publishedAt
? new Date(post.publishedAt).toLocaleDateString("es-MX", {
day: "2-digit",
month: "long",
year: "numeric",
})
: null;
return (
<main id="main-content">
{/* Hero */}
<section
style={{
background: "var(--background)",
paddingTop: "140px",
paddingBottom: "60px",
}}
className="px-8 md:px-14"
>
<div className="mx-auto" style={{ maxWidth: "780px" }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Volver */}
<Link
href="/blog"
style={{ display: "inline-flex", alignItems: "center", gap: 8, marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "#3b82f6", textDecoration: "none" }}
>
← VOLVER AL BLOG
</Link>
{post.category && (
<div style={{ marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
{post.category.toUpperCase()}
</div>
)}
{/* Título */}
<h1
style={{
fontSize: "clamp(1.8rem, 4vw, 2.8rem)",
fontWeight: 700,
color: "var(--foreground)",
lineHeight: 1.2,
marginBottom: 20,
}}
>
{post.title}
</h1>
{/* Resumen */}
{post.summary && (
<p
style={{
fontSize: "1.1rem",
color: "var(--muted-foreground)",
lineHeight: 1.7,
marginBottom: 28,
borderLeft: "3px solid var(--border)",
paddingLeft: 16,
}}
>
{post.summary}
</p>
)}
{/* Meta */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
paddingTop: 20,
borderTop: "1px solid var(--border)",
}}
>
{post.authorName && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "linear-gradient(135deg,#3b82f6,#6366f1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 13,
fontWeight: 700,
}}
>
{post.authorName.charAt(0).toUpperCase()}
</div>
<span
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{post.authorName}
</span>
</div>
)}
{publishDate && (
<>
{post.authorName && (
<span style={{ color: "var(--border)" }}>·</span>
)}
<span
style={{
fontFamily: "monospace",
fontSize: 11,
color: "var(--muted-foreground)",
letterSpacing: "0.1em",
}}
>
{publishDate.toUpperCase()}
</span>
</>
)}
</div>
</motion.div>
</div>
</section>
{/* Imagen de portada */}
{imageUrl && (
<div
style={{
background: "var(--muted)",
borderBottom: "1px solid var(--border)",
}}
>
<div
className="mx-auto"
style={{ maxWidth: "1100px", padding: "0 2rem" }}
>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.15 }}
style={{
overflow: "hidden",
borderRadius: 4,
aspectRatio: "16/7",
marginTop: -1,
}}
>
<img
src={imageUrl}
alt={post.title}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</motion.div>
</div>
</div>
)}
{/* Contenido */}
<section
style={{ background: "var(--background)", padding: "80px 0" }}
className="px-8 md:px-14"
>
<motion.div
className="mx-auto"
style={{ maxWidth: "780px" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<div
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 4,
padding: "48px 52px",
fontSize: "1rem",
lineHeight: 1.85,
}}
>
{renderContent(post.content)}
</div>
</motion.div>
</section>
</main>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ArticlePage from "./ArticlePage";
import { API_URL, assetUrl } from '@/app/(landing)/services/api';
import type { BlogPost } from '@/app/(landing)/blog/services/blog';
const BASE_URL = "https://rynext.com.mx";
/**
* null = la API respondió que el artículo no existe o no está publicado (404 real).
* undefined = no se pudo consultar la API (red, TLS local, etc.) — no es un 404,
* se deja que el cliente (ArticlePage) resuelva con su propio fetch.
*/
async function getPost(slug: string): Promise<BlogPost | null | undefined> {
try {
const res = await fetch(`${API_URL}/api/blog/${slug}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
const post = (await res.json()) as BlogPost;
return post.isPublished ? post : null;
} catch {
return undefined;
}
}
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return {};
}
const url = `${BASE_URL}/blog/${post.slug}`;
const image = assetUrl(post.imageUrl);
return {
title: `${post.title} | Blog | Rynext Technologies`,
description: post.summary,
alternates: { canonical: url },
openGraph: {
title: post.title,
description: post.summary,
type: "article",
url,
publishedTime: post.publishedAt,
images: image
? [{ url: image, width: 1200, height: 630, alt: post.title }]
: undefined,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.summary,
images: image ? [image] : undefined,
},
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
// Solo 404 cuando confirmamos que no existe/no está publicado; si la API
// no respondió, dejamos que ArticlePage lo resuelva del lado del cliente.
if (post === null) notFound();
return <ArticlePage />;
}
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { BlogPage } from './BlogPage';
export const metadata: Metadata = {
title: 'Blog | Rynext Technologies',
description: 'Artículos sobre arquitectura de software, sistemas empresariales y tecnología. Perspectivas del equipo de Rynext Technologies.',
alternates: { canonical: 'https://rynext.com.mx/blog' },
};
export default function Page() {
return <BlogPage />;
}
+32
View File
@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from '@/app/(landing)/services/api';
export interface BlogPost {
id: string;
title: string;
slug: string;
summary: string;
content: string;
category: string;
imageUrl?: string;
authorName?: string;
isPublished: boolean;
publishedAt?: string;
}
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;
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { usePathname } from "next/navigation";
import { ContactProvider } from "@/app/(landing)/contexts/ContactContext";
import { useIsDark } from "@/app/(landing)/hooks/useIsDark";
import { ScrollToTop } from "./ScrollToTop";
import { Navbar } from "./Navbar";
import { Footer } from "./Footer";
import { FloatingButtons } from "./FloatingButtons";
import { CookieBanner } from "./CookieBanner";
import { MusicPlayer } from "./MusicPlayer";
const BARE_ROUTES = ["/demo-hero", "/admin"];
export const AppShell = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname();
const isBare = BARE_ROUTES.some((route) => pathname?.startsWith(route));
const isDark = useIsDark();
if (isBare) {
return <ContactProvider>{children}</ContactProvider>;
}
return (
<ContactProvider>
<div
className="min-h-dvh font-sans"
style={{ background: "var(--background)", overflowX: "clip", overflowY: "visible" }}
>
<ScrollToTop />
<Navbar />
{children}
<Footer isNavyBg={isDark} />
<FloatingButtons isNavyBg={isDark} setIsNavyBg={() => {}} />
<MusicPlayer />
<CookieBanner isNavyBg={isDark} />
</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>
);
};
@@ -0,0 +1,224 @@
'use client';
import { useState } from 'react';
import { Icon } from '@iconify/react';
import type { ModalProps } from '@/app/(landing)/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 ? 'var(--card)' : '#ffffff';
const bdr = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)';
const text = isNavyBg ? 'var(--foreground)' : '#0a0a0a';
const sub = isNavyBg ? 'rgba(255,255,255,0.45)' : '#6a6866';
const inputStyle: React.CSSProperties = {
width: '100%', borderRadius: 12, 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"
>
<Icon icon="ph:x" width={16} />
</button>
</div>
{/* Success state */}
{status === 'success' ? (
<div className="flex flex-col items-center justify-center gap-4 px-8 py-14 text-center">
<Icon icon="ph:check-circle" width={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)' }}>
<Icon icon="ph:warning-circle" width={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' ? (
<>
<Icon icon="ph:spinner-gap" width={16} className="animate-spin" />
Enviando...
</>
) : (
<>
<Icon icon="ph:paper-plane-tilt" width={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>
);
};
@@ -0,0 +1,53 @@
'use client';
import { useState, useEffect } from 'react';
import type { ThemeProps } from '@/app/(landing)/types/ui';
export const CookieBanner = (_props: 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;
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: 'color-mix(in srgb, var(--card) 97%, transparent)',
border: '1px solid var(--border)',
borderRadius: 16,
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: 'var(--muted-foreground)', maxWidth: '42ch' }}>
Usamos cookies para mejorar tu experiencia en el sitio.
Al continuar, aceptas su uso.{' '}
<a href="/privacidad" className="underline transition hover:opacity-80" style={{ color: 'var(--foreground)' }}>
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: 999, border: 'none', cursor: 'pointer' }}
>
Aceptar
</button>
</div>
</div>
);
};
@@ -0,0 +1,121 @@
'use client';
import { Icon } from '@iconify/react';
import { motion, AnimatePresence } from 'framer-motion';
import type { ThemeProps } from '@/app/(landing)/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 = ({ setIsNavyBg: _setIsNavyBg }: FloatingButtonsProps) => {
const [showTop, setShowTop] = useState(false);
const [showBubble, setShowBubble] = useState(false);
const toggleBg = 'color-mix(in srgb, var(--card) 85%, transparent)';
const toggleBorder = 'var(--border)';
const toggleColor = '#3b82f6';
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-24 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,
}}
>
<Icon icon="ph:arrow-up" width={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>
</>
);
};
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { Icon } from "@iconify/react";
import { FaFacebookF, FaLinkedinIn, FaMapMarkerAlt } from "react-icons/fa";
import Link from "next/link";
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { pageLinks } from "@/app/(landing)/home/data/navigationData";
import { FaRegMessage } from "react-icons/fa6";
import { MdEmail } from "react-icons/md";
export const Footer = ({ isNavyBg }: ThemeProps) => {
const bg = isNavyBg ? "var(--background)" : "#f9f9f7";
const text = isNavyBg ? "#ffffff" : "#0a0a0a";
const sub = isNavyBg ? "rgba(255,255,255,0.45)" : "rgba(0,0,0,0.5)";
const faint = isNavyBg ? "rgba(255,255,255,0.25)" : "rgba(0,0,0,0.35)";
const border = isNavyBg ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)";
const cardBg = isNavyBg ? "color-mix(in srgb, var(--card) 72%, transparent)" : "color-mix(in srgb, #ffffff 72%, transparent)";
const chipBg = isNavyBg ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.05)";
const chipBgHv = isNavyBg ? "rgba(255,255,255,0.14)" : "rgba(0,0,0,0.1)";
return (
<footer
style={{ background: bg, color: text }}
className="relative overflow-hidden px-4 py-12 transition-colors duration-500 sm:px-6 sm:py-16"
role="contentinfo"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-dot-grid text-border opacity-40"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-24 -left-32 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: "rgba(59,130,246,0.12)" }}
/>
<div
className="relative mx-auto max-w-7xl rounded-[32px] px-8 pt-14 pb-2 sm:px-14 sm:pt-16"
style={{
background: cardBg,
border: `1px solid ${border}`,
backdropFilter: "blur(18px)",
WebkitBackdropFilter: "blur(18px)",
boxShadow: isNavyBg ? "0 20px 60px rgba(0,0,0,0.3)" : "0 20px 60px rgba(0,0,0,0.06)",
}}
>
{/* Main grid */}
<div className="grid gap-16 md:grid-cols-[1.8fr_1fr_1.2fr]">
{/* Brand */}
<div>
<div className="mb-6 flex items-center gap-2.5">
<img
src={isNavyBg ? "/icons/Logo_blanco.svg" : "/icons/Logo_negro.svg"}
alt="Logo"
className="h-12 w-12 shrink-0 object-contain"
/>
<div className="flex flex-col leading-none">
<span
style={{
fontFamily: "'Quicksand', sans-serif",
fontWeight: 700,
fontSize: 21,
letterSpacing: "-0.01em",
color: text,
}}
>
Rynext
</span>
<span
style={{
fontFamily: "'Quicksand', sans-serif",
fontWeight: 500,
fontSize: 13,
letterSpacing: "0.04em",
color: sub,
}}
>
Technologies
</span>
</div>
</div>
<p
className="text-base leading-relaxed mb-8"
style={{ color: sub, maxWidth: "32ch" }}
>
Diseñamos y construimos sistemas empresariales a medida,
arquitectura real, sin atajos técnicos.
</p>
{/* Social */}
<div className="flex items-center gap-2.5">
<a
href="https://www.facebook.com/RynextTechnologies/"
target="_blank"
rel="noopener noreferrer"
aria-label="Rynext en Facebook"
className="flex h-11 w-11 items-center justify-center rounded-full transition-colors"
style={{ background: chipBg, color: sub }}
onMouseEnter={(e) => {
e.currentTarget.style.background = chipBgHv;
e.currentTarget.style.color = text;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = chipBg;
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-11 w-11 items-center justify-center rounded-full transition-colors"
style={{ background: chipBg, color: sub }}
onMouseEnter={(e) => {
e.currentTarget.style.background = chipBgHv;
e.currentTarget.style.color = text;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = chipBg;
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-11 w-11 items-center justify-center rounded-full transition-colors"
style={{ background: chipBg, color: sub }}
onMouseEnter={(e) => {
e.currentTarget.style.background = chipBgHv;
e.currentTarget.style.color = text;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = chipBg;
e.currentTarget.style.color = sub;
}}
>
<FaRegMessage />
</a>
</div>
</div>
{/* Páginas */}
<div>
<p
className="text-mono-label mb-6"
style={{
color: faint,
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 = text)}
onMouseLeave={(e) => (e.currentTarget.style.color = sub)}
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
{/* Contacto */}
<div>
<p
className="text-mono-label mb-6"
style={{
color: faint,
letterSpacing: "0.12em",
}}
>
Contacto
</p>
<ul className="flex flex-col gap-4">
<li className="flex items-center gap-3">
<FaMapMarkerAlt className="text-center" style={{ color: text }} />
<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-center" style={{ color: text }} />
<a
href="mailto:[email protected]"
className="text-sm font-medium transition-colors"
style={{ color: sub }}
onMouseEnter={(e) => (e.currentTarget.style.color = text)}
onMouseLeave={(e) => (e.currentTarget.style.color = sub)}
>
contacto@rynext.com.mx
</a>
</li>
<li className="mt-2">
<a
href="mailto:[email protected]"
className="group inline-flex items-center gap-2 px-6 py-3 text-base font-bold text-white transition hover:scale-[1.03] active:scale-[0.98]"
style={{ background: "#3b82f6", borderRadius: 999 }}
>
Hablemos de tu proyecto
<Icon icon="ph:arrow-up-right" width={14} className="transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
</li>
</ul>
</div>
</div>
{/* Bottom bar */}
<div className="mt-10 border-t" style={{ borderColor: border }}>
<div className="flex flex-col items-center gap-2 py-5 sm:flex-row sm:justify-between">
<p className="text-xs" style={{ color: faint }}>
<Link
href="/admin/login"
aria-label="Acceso interno"
style={{ color: "inherit", textDecoration: "none" }}
>
©
</Link>{" "}
{new Date().getFullYear()} Rynext Technologies — Todos los
derechos reservados.
</p>
<Link
href="/privacidad"
className="text-xs transition-colors"
style={{ color: faint, textDecoration: "none" }}
onMouseEnter={(e) => (e.currentTarget.style.color = sub)}
onMouseLeave={(e) => (e.currentTarget.style.color = faint)}
>
Aviso de Privacidad
</Link>
</div>
</div>
</div>
</footer>
);
};
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/components/Loader";
@@ -0,0 +1,93 @@
"use client";
import { Icon } from "@iconify/react";
import { useEffect, useRef, useState } from "react";
const TRACK_SRC = "/audio/musica.mp3";
const PREF_KEY = "rynext_music_enabled";
export const MusicPlayer = ({
className = "bottom-6 left-6",
inline = false,
}: {
className?: string;
inline?: boolean;
}) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = 0.05;
// Respeta la última preferencia del usuario (guardada en localStorage):
// si la silenció, no la reinicia sola al recargar la página.
let muted = false;
try {
muted = localStorage.getItem(PREF_KEY) === "off";
} catch {}
if (muted) return;
// Si el navegador bloquea el autoplay, no la arrancamos con cualquier
// interacción (scroll, tecla, etc.) — solo el botón de reproducir debe
// encenderla, para que no "se mueva sola" al navegar la página.
audio.play().then(() => setPlaying(true)).catch(() => {});
}, []);
function toggle() {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
setPlaying(false);
try { localStorage.setItem(PREF_KEY, "off"); } catch {}
} else {
audio
.play()
.then(() => {
setPlaying(true);
try { localStorage.setItem(PREF_KEY, "on"); } catch {}
})
.catch(() => {});
}
}
return (
<>
<audio ref={audioRef} src={TRACK_SRC} loop preload="auto" />
{inline ? (
<button
onClick={toggle}
aria-label={playing ? "Silenciar música" : "Reproducir música"}
aria-pressed={playing}
className="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors shrink-0"
>
<Icon
icon={playing ? "ph:speaker-high-fill" : "ph:speaker-slash-fill"}
width={16}
/>
</button>
) : (
<button
onClick={toggle}
aria-label={playing ? "Silenciar música" : "Reproducir música"}
aria-pressed={playing}
className={`fixed ${className} 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: "color-mix(in srgb, var(--card) 85%, transparent)",
border: "1px solid var(--border)",
backdropFilter: "blur(8px)",
cursor: "pointer",
color: "#3b82f6",
}}
>
<Icon
icon={playing ? "ph:speaker-high-fill" : "ph:speaker-slash-fill"}
width={18}
/>
</button>
)}
</>
);
};
+221
View File
@@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { pageLinks } from '@/app/(landing)/home/data/navigationData';
import { useAuthStore } from '@/app/admin/store/authStore';
import ThemeToggle from './ui/hero-01-utils/theme-toggle';
export const Navbar = () => {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [hovered, setHovered] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const rawPathname = usePathname();
const pathname = rawPathname?.replace(/\/$/, '') || '/';
const links = pageLinks.filter(l => l.href !== '/privacidad');
useEffect(() => {
setMounted(true);
}, []);
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 > 40);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
return (
<>
<motion.header
className="fixed inset-x-0 top-0 z-50 flex justify-center px-4 pt-4 sm:px-6"
initial={{ y: -40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
role="banner"
>
<div
className="flex w-full max-w-7xl items-center justify-between gap-4 rounded-full px-4 py-2.5 transition-shadow duration-300"
style={{
background: 'color-mix(in srgb, var(--card) 72%, transparent)',
border: '1px solid var(--border)',
backdropFilter: 'blur(18px)',
WebkitBackdropFilter: 'blur(18px)',
boxShadow: scrolled
? '0 8px 30px rgba(0,0,0,0.12)'
: '0 1px 2px rgba(0,0,0,0.03)',
}}
>
{/* Logo */}
<Link href="/" aria-label="Rynext Technologies — inicio" className="flex shrink-0 items-center gap-2">
<img src="/icons/Logo_negro.svg" alt="Logo" className="h-8 w-8 shrink-0 object-contain dark:hidden" />
<img src="/icons/Logo_blanco.svg" alt="Logo" className="hidden h-8 w-8 shrink-0 object-contain dark:block" />
<span className="flex flex-col leading-none">
<span
style={{ fontFamily: "'Quicksand', sans-serif", fontWeight: 700, fontSize: 16, letterSpacing: '-0.01em', color: 'var(--foreground)' }}
>
Rynext
</span>
<span
style={{ fontFamily: "'Quicksand', sans-serif", fontWeight: 500, fontSize: 10, letterSpacing: '0.04em', color: 'var(--muted-foreground)' }}
>
Technologies
</span>
</span>
</Link>
{/* Desktop nav — pill with sliding highlight */}
<nav
className="relative hidden items-center gap-0.5 rounded-full p-1 md:flex"
style={{ background: 'var(--muted)' }}
aria-label="Navegación principal"
onMouseLeave={() => setHovered(null)}
>
{links.map((link) => {
const active = pathname === link.href;
const isHovered = hovered === link.href;
// Solo un link muestra la píldora a la vez: el hovered tiene prioridad
// sobre el activo, para que nunca existan dos motion.span con el mismo
// layoutId montados al mismo tiempo (eso causaba el lag al pasar el mouse).
const showPill = hovered ? isHovered : active;
return (
<Link
key={link.href}
href={link.href}
onMouseEnter={() => setHovered(link.href)}
className="relative z-10 rounded-full px-3.5 py-1.5 text-sm font-semibold no-underline transition-colors"
style={{ color: active || isHovered ? 'var(--foreground)' : 'var(--muted-foreground)' }}
>
{showPill && (
<motion.span
layoutId="nav-pill"
className="absolute inset-0 -z-10 rounded-full"
style={{ background: 'var(--background)', boxShadow: '0 1px 2px rgba(0,0,0,0.06)' }}
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
/>
)}
{link.label}
</Link>
);
})}
</nav>
{/* Right cluster */}
<div className="flex shrink-0 items-center gap-2">
<div className="hidden md:block">
<ThemeToggle />
</div>
{mounted && isAuthenticated && (
<Link
href="/admin/dashboard"
className="hidden items-center gap-1.5 rounded-full px-4 py-2 text-sm font-bold no-underline transition hover:scale-[1.03] active:scale-[0.98] sm:inline-flex"
style={{ color: 'var(--foreground)', background: 'var(--muted)', border: '1px solid var(--border)' }}
>
<Icon icon="ph:squares-four" width={14} />
Dashboard
</Link>
)}
<button
onClick={scrollToContact}
className="group hidden items-center gap-1.5 rounded-full px-4 py-2 text-sm font-bold text-white transition hover:scale-[1.03] active:scale-[0.98] sm:inline-flex"
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer' }}
>
Hablemos
<Icon icon="ph:arrow-up-right" width={14} className="transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</button>
{/* Mobile toggle */}
<div className="flex items-center gap-1 md:hidden">
<ThemeToggle />
<button
onClick={() => setMenuOpen((v) => !v)}
className="flex h-9 w-9 items-center justify-center rounded-full"
style={{ color: 'var(--foreground)', background: 'var(--muted)', border: 'none', cursor: 'pointer' }}
aria-label={menuOpen ? 'Cerrar menú' : 'Abrir menú'}
aria-expanded={menuOpen}
>
{menuOpen ? <Icon icon="ph:x" width={18} /> : <Icon icon="ph:list" width={18} />}
</button>
</div>
</div>
</div>
</motion.header>
{/* Mobile menu — floating card */}
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -12, scale: 0.97 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="fixed inset-x-4 top-[76px] z-40 rounded-3xl p-5 md:hidden"
style={{
background: 'color-mix(in srgb, var(--card) 96%, transparent)',
border: '1px solid var(--border)',
backdropFilter: 'blur(18px)',
WebkitBackdropFilter: 'blur(18px)',
boxShadow: '0 16px 40px rgba(0,0,0,0.18)',
}}
role="dialog"
aria-modal="true"
aria-label="Menú de navegación"
>
<nav className="flex flex-col gap-1">
{pageLinks.map((link) => {
const active = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className="rounded-xl px-3 py-2.5 text-base font-semibold no-underline"
style={{
color: active ? 'var(--foreground)' : 'var(--muted-foreground)',
background: active ? 'var(--muted)' : 'transparent',
}}
>
{link.label}
</Link>
);
})}
</nav>
<div className="mt-4 flex flex-col gap-2">
{mounted && isAuthenticated && (
<Link
href="/admin/dashboard"
className="flex w-full items-center justify-center gap-1.5 rounded-full py-3 text-sm font-bold no-underline"
style={{ color: 'var(--foreground)', background: 'var(--muted)', border: '1px solid var(--border)' }}
>
<Icon icon="ph:squares-four" width={14} />
Dashboard
</Link>
)}
<button
onClick={scrollToContact}
className="flex w-full items-center justify-center gap-1.5 rounded-full py-3 text-sm font-bold text-white"
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer' }}
>
Hablemos
<Icon icon="ph:arrow-up-right" width={14} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
@@ -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>
);
};
@@ -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;
};
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/app/(landing)/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-full px-3",
lg: "h-11 rounded-full px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/app/(landing)/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
@@ -0,0 +1,57 @@
"use client"
import { motion } from "framer-motion"
import { useCompanies } from "@/app/(landing)/home/services/companies"
import { assetUrl } from "@/app/(landing)/services/api"
export default function BrandSlider() {
const { data } = useCompanies()
const companies = (data ?? []).filter((c) => c.showInLanding && c.logoUrl)
if (companies.length === 0) return null
// Con pocas marcas reales, un número inflado ("+1000") se nota falso —
// mostramos un mensaje honesto en ese caso y solo presumimos la cifra
// cuando el catálogo ya tiene un volumen que la respalde.
const trustLabel =
companies.length >= 10
? `Con la confianza de +${Math.floor(companies.length / 10) * 10} marcas grandes y pequeñas`
: "Empresas que ya confían en nosotros"
return (
<motion.div
className="relative z-10 w-full pt-16"
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.4 }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
>
<div className="flex items-center gap-4 sm:gap-6">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-border" />
<p className="shrink-0 text-xs text-muted-foreground sm:text-sm">
{trustLabel}
</p>
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-border" />
</div>
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-10 gap-y-6">
{companies.map((brand) => (
<div
key={brand.id}
title={brand.name}
className="flex items-center opacity-80 transition hover:opacity-100"
>
<img
src={assetUrl(brand.logoUrl)}
alt={brand.name}
className="h-[150px] w-auto max-w-[150px] object-contain"
onError={(e) => {
(e.currentTarget.closest("[title]") as HTMLElement | null)?.style.setProperty("display", "none")
}}
/>
</div>
))}
</div>
</motion.div>
)
}
@@ -0,0 +1,144 @@
"use client"
import {
motion,
MotionConfig,
type Variants,
} from "framer-motion"
import { Icon } from "@iconify/react"
import { Button } from "@/app/(landing)/components/ui/button"
import BrandSlider from "./brand-slider"
export interface AvatarList {
image: string
}
interface HeroSectionProps {
avatarList?: AvatarList[]
}
const container: Variants = {
hidden: {},
show: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.15,
},
},
}
const item: Variants = {
hidden: { opacity: 0, y: 22, filter: "blur(4px)" },
show: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { type: "spring", stiffness: 300, damping: 28, mass: 0.9 },
},
}
const wordContainer: Variants = {
hidden: {},
show: {
transition: { staggerChildren: 0.055, delayChildren: 0.3 },
},
}
const word: Variants = {
hidden: { opacity: 0, y: 16, filter: "blur(6px)" },
show: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { type: "spring", stiffness: 260, damping: 24 },
},
}
const HEADLINE_LINE_1 = ["Construimos", "experiencias", "que"]
const HEADLINE_LINE_2 = ["impulsan", "tu", "negocio"]
export default function HeroSection({}: HeroSectionProps) {
return (
<MotionConfig reducedMotion="user">
<section
className="relative isolate flex min-h-dvh flex-col justify-center px-4 pb-20 pt-32 sm:px-6 sm:pb-28 sm:pt-40 lg:px-8"
>
<div
aria-hidden="true"
className="animate-hero-blob-a pointer-events-none absolute -bottom-24 -left-32 -z-20 h-[24rem] w-[24rem] rounded-full bg-accent/[0.12] blur-[110px]"
/>
<div
aria-hidden="true"
className="animate-hero-blob-b pointer-events-none absolute -top-24 -right-32 -z-20 h-[24rem] w-[24rem] rounded-full bg-[#8b5cf6]/[0.12] blur-[110px]"
/>
<div
className="mx-auto flex max-w-4xl flex-col items-start text-left"
>
<motion.div variants={container} initial="hidden" animate="show">
<motion.span
variants={item}
className="inline-flex items-center gap-1.5 rounded-full border border-accent/20 bg-accent/10 px-3 py-1 text-xs font-semibold text-accent"
>
<Icon icon="ph:sparkle" className="h-3.5 w-3.5" />
Diseño &amp; desarrollo digital
</motion.span>
<motion.h1
variants={wordContainer}
className="text-display mt-6 max-w-2xl text-foreground"
>
<span className="block">
{HEADLINE_LINE_1.map((w, i) => (
<motion.span key={i} variants={word} className="inline-block">
{w}
{i < HEADLINE_LINE_1.length - 1 ? " " : ""}
</motion.span>
))}
</span>
<span className="block text-accent">
{HEADLINE_LINE_2.map((w, i) => (
<motion.span key={i} variants={word} className="inline-block">
{w}
{i < HEADLINE_LINE_2.length - 1 ? " " : ""}
</motion.span>
))}
</span>
</motion.h1>
<motion.p
variants={item}
className="mt-6 max-w-xl text-balance text-base text-muted-foreground sm:text-lg"
>
Diseño, desarrollo y estrategia digital para marcas que quieren
crecer. De la idea al producto en producción.
</motion.p>
<motion.div variants={item} className="mt-8 flex flex-col gap-3 sm:flex-row">
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button
size="lg"
className="group shadow-lg shadow-blue-500/20 transition-colors hover:bg-[#2563eb]"
>
Empezar un proyecto
<Icon icon="ph:arrow-right" className="ml-2 h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" />
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button
size="lg"
variant="outline"
className="border-primary/15 text-primary hover:border-primary hover:bg-transparent hover:text-primary"
>
Ver portafolio
</Button>
</motion.div>
</motion.div>
</motion.div>
<BrandSlider />
</div>
</section>
</MotionConfig>
)
}
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import { Icon } from "@iconify/react"
import { Button } from "@/app/(landing)/components/ui/button"
export default function ThemeToggle() {
const [isDark, setIsDark] = React.useState(false)
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
const stored = localStorage.getItem("theme")
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
const dark = stored ? stored === "dark" : prefersDark
setIsDark(dark)
document.documentElement.classList.toggle("dark", dark)
}, [])
const toggle = () => {
const next = !isDark
setIsDark(next)
document.documentElement.classList.toggle("dark", next)
localStorage.setItem("theme", next ? "dark" : "light")
}
if (!mounted) {
return (
<Button
variant="ghost"
size="icon"
aria-label="Cambiar tema"
disabled
className="text-foreground hover:bg-foreground/10"
>
<Icon icon="ph:sun" className="h-5 w-5" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon"
onClick={toggle}
aria-label="Cambiar tema"
className="text-foreground hover:bg-foreground/10"
>
{isDark ? <Icon icon="ph:sun" className="h-5 w-5" /> : <Icon icon="ph:moon" className="h-5 w-5" />}
</Button>
)
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/app/(landing)/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/app/(landing)/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
@@ -0,0 +1,128 @@
import * as React from "react"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@/app/(landing)/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}
@@ -0,0 +1,23 @@
'use client';
import { createContext, useContext, useState } from 'react';
import { ContactModal } from '@/app/(landing)/components/ContactModal';
import { useIsDark } from '@/app/(landing)/hooks/useIsDark';
interface ContactCtx {
open: () => void;
}
const ContactContext = createContext<ContactCtx>({ open: () => {} });
export const ContactProvider = ({ children }: { children: React.ReactNode }) => {
const [isOpen, setIsOpen] = useState(false);
const isDark = useIsDark();
return (
<ContactContext.Provider value={{ open: () => setIsOpen(true) }}>
{children}
<ContactModal isOpen={isOpen} onClose={() => setIsOpen(false)} isNavyBg={isDark} />
</ContactContext.Provider>
);
};
export const useContact = () => useContext(ContactContext);
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useIsDark } from "@/app/(landing)/hooks/useIsDark";
import HeroSection from "@/app/(landing)/components/ui/hero-01-utils/hero";
import type { AvatarList } from "@/app/(landing)/components/ui/hero-01-utils/hero";
import { Problem } from "@/app/(landing)/home/sections/Problem";
import { Machinery } from "@/app/(landing)/home/sections/Machinery";
import { Services } from "@/app/(landing)/home/sections/Services";
import { FAQ } from "@/app/(landing)/home/sections/FAQ";
import { Partners } from "@/app/(landing)/home/sections/Partners";
import { ContactSection } from "@/app/(landing)/home/sections/ContactSection";
const avatarList: AvatarList[] = [
{
image:
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=128&h=128&fit=crop&crop=faces",
},
{
image:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=128&h=128&fit=crop&crop=faces",
},
{
image:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=128&h=128&fit=crop&crop=faces",
},
{
image:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=128&h=128&fit=crop&crop=faces",
},
];
export function HomePage() {
const isDark = useIsDark();
return (
<main id="main-content" className="relative">
<HeroSection avatarList={avatarList} />
<Problem isNavyBg={isDark} />
<Machinery isNavyBg={isDark} />
<Services isNavyBg={isDark} />
<FAQ isNavyBg={isDark} />
<Partners isNavyBg={isDark} />
<ContactSection isNavyBg={isDark} />
</main>
);
}
@@ -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'],
},
];
+22
View File
@@ -0,0 +1,22 @@
export const faqs = [
{
q: '¿Cuánto tiempo tarda un proyecto típico?',
a: 'Un sistema funcional suele estar en producción entre 8 y 16 semanas, trabajando en sprints cortos con entregables medibles.',
},
{
q: '¿Trabajan con empresas fuera de Cancún o de México?',
a: 'Sí, trabajamos de forma remota con empresas en todo México y en otros países.',
},
{
q: '¿Qué pasa con el mantenimiento y soporte después de la entrega?',
a: 'Se entrega documentado y en producción, con acompañamiento post-lanzamiento 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 SAP, Odoo, sistemas legacy y APIs internas es parte de nuestro núcleo de trabajo.',
},
{
q: '¿Cómo es el primer paso para arrancar un proyecto?',
a: 'Agendamos una sesión técnica sin costo para analizar tu operación y presentar una ruta concreta de construcción.',
},
];
@@ -0,0 +1,42 @@
export const machineryData = [
{
id: 1,
num: '01',
title: 'OPERACIONES',
description: 'Plataformas internas y control de procesos críticos, sin fricción.',
tags: ['Portales de gestión', 'Automatización de flujos', 'Reportes operacionales', 'Control de acceso por rol'],
icon: "ph:squares-four",
},
{
id: 2,
num: '02',
title: 'VENTAS',
description: 'Del contacto con el cliente al comprobante fiscal, todo integrado.',
tags: ['E-commerce a medida', 'Integración de pagos', 'Facturación y CFDI', 'Recuperación de carritos'],
icon: "ph:shopping-cart",
},
{
id: 3,
num: '03',
title: 'INTEGRACIONES',
description: 'Conectamos sistemas heterogéneos en un ecosistema coherente.',
tags: ['ERP / SAP / Odoo', 'APIs de terceros', 'Sincronización bidireccional', 'Webhooks y eventos'],
icon: "ph:flow-arrow",
},
{
id: 4,
num: '04',
title: 'AUTOMATIZACIÓN',
description: 'Tareas repetitivas corriendo solas, para que tu equipo se enfoque en lo que sí requiere criterio humano.',
tags: ['Bots y pipelines', 'Procesamiento de datos', 'Notificaciones inteligentes', 'Triggers y colas'],
icon: "ph:lightning",
},
{
id: 5,
num: '05',
title: 'DATOS',
description: 'Decisiones con evidencia: dashboards, modelos y alertas a tiempo.',
tags: ['Dashboards ejecutivos', 'Pipelines de datos', 'KPIs e instrumentación', 'Alertas y umbrales'],
icon: "ph:chart-bar",
},
];
@@ -0,0 +1,14 @@
export const pageLinks = [
{ label: 'Inicio', href: '/' },
{ label: 'Nosotros', href: '/sobre-nosotros' },
{ label: 'Portafolio', href: '/portafolio' },
{ label: 'Blog', href: '/blog' },
{ label: 'Aviso de Privacidad', href: '/privacidad' },
];
export const navLinks = [
{ label: 'Portafolio', href: '#portafolio' },
{ label: 'Sistemas', href: '#machinery' },
{ label: 'Proceso', href: '#proceso' },
{ label: 'Principios', href: '#principios' },
];
@@ -0,0 +1,40 @@
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: "ph:stack-simple",
},
{
title: 'Arquitectura robusta',
description: 'Infraestructura, seguridad y escalabilidad definida desde el inicio.',
icon: "ph:shield-check",
},
{
title: 'Equipo senior',
description: 'Product owner, ingeniería y operación trabajando con criterio empresarial.',
icon: "ph:buildings",
},
];
@@ -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.',
},
];
@@ -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.',
},
];
@@ -0,0 +1,40 @@
import type { ServiceItem } from '@/app/(landing)/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: "ph: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: "ph: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: "ph:shield-check",
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: "ph:trend-up",
gridCols: 'md:col-span-7',
accent: 'red',
},
];
@@ -0,0 +1,7 @@
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.' },
];
+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/(landing)/types/ui';
import { anatomyLayers as layers } from '@/app/(landing)/home/data/anatomyData';
export const Anatomy = ({ isNavyBg }: ThemeProps) => {
const bg = isNavyBg ? 'var(--background)' : '#f5f5f3';
const text = isNavyBg ? 'var(--foreground)' : '#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: 20, overflow: 'hidden' }}>
{layers.map((layer, i) => (
<motion.div
key={layer.id}
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.07 }}
style={{
borderBottom: i < layers.length - 1 ? `1px solid ${border}` : 'none',
display: 'grid',
gridTemplateColumns: '120px 1fr',
gap: '0 32px',
alignItems: 'start',
padding: '32px 32px',
}}
>
{/* Number */}
<span
className="font-display font-black"
style={{ fontSize: 64, lineHeight: 1, color: '#3b82f6', letterSpacing: '-0.04em' }}
>
{layer.num}
</span>
{/* Title + description + tech */}
<div style={{ paddingTop: 8 }}>
<p
className="font-display font-black mb-1"
style={{ fontSize: 13, letterSpacing: '0.04em', color: text }}
>
{layer.title}
</p>
<p className="text-xs mb-3" style={{ color: sub }}>
{layer.subtitle}
</p>
<p className="text-sm leading-relaxed mb-4" style={{ color: sub, maxWidth: '60ch' }}>
{layer.description}
</p>
<div className="flex flex-wrap gap-2">
{layer.tech.map(t => (
<span key={t} style={{
fontSize: 11,
fontWeight: 600,
padding: '3px 10px',
borderRadius: 99,
border: `1px solid ${border}`,
color: isNavyBg ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.5)',
background: isNavyBg ? 'rgba(255,255,255,0.04)' : 'rgba(59,130,246,0.05)',
letterSpacing: '0.01em',
}}>
{t}
</span>
))}
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
</section>
);
};
@@ -0,0 +1,90 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/(landing)/components/Loader';
import { useCompanies } from '@/app/(landing)/home/services/companies';
import { assetUrl } from '@/app/(landing)/services/api';
import type { ThemeProps } from '@/app/(landing)/types/ui';
function ClientsInner({ isNavyBg }: ThemeProps) {
const { data, isLoading } = useCompanies();
const bg = isNavyBg ? "var(--background)" : "#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",
// grayscale solo: invert+brightness(0) aplanaba logos a color
// (fotos/JPEG) a un bloque negro o blanco ilegible en dark.
filter: "grayscale(1)",
opacity: 0.55,
transition: "opacity 250ms",
}}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
onMouseEnter={(e) =>
((e.currentTarget as HTMLImageElement).style.opacity = "1")
}
onMouseLeave={(e) =>
((e.currentTarget as HTMLImageElement).style.opacity = "0.6")
}
/>
</div>
))}
</div>
</motion.div>
</section>
);
}
export const Clients = (props: ThemeProps) => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <ClientsInner {...props} />;
};
@@ -0,0 +1,192 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Icon } from '@iconify/react';
import type { ThemeProps } from '@/app/(landing)/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 text = isNavyBg ? 'var(--foreground)' : '#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: 12, 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"
className="relative px-8 py-20 md:px-14 transition-colors duration-500"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-dot-grid text-border opacity-40"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-32 -top-24 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(139,92,246,0.12)' }}
/>
<div className="relative 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] }}
>
<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: 24, padding: '40px 36px' }}
>
{status === 'success' ? (
<div className="flex flex-col items-center justify-center gap-4 py-10 text-center">
<Icon icon="ph:check-circle" width={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)' }}>
<Icon icon="ph:warning-circle" width={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' ? (
<><Icon icon="ph:spinner-gap" width={16} className="animate-spin" />Enviando...</>
) : (
<><Icon icon="ph:paper-plane-tilt" width={15} />Enviar mensaje</>
)}
</button>
<p className="text-center text-xs" style={{ color: sub, opacity: 0.6 }}>
Sin spam. Solo te contactamos para hablar de tu proyecto.
</p>
</form>
)}
</motion.div>
</div>
</section>
);
};
@@ -0,0 +1,72 @@
"use client";
import { motion } from "framer-motion";
import { Icon } from "@iconify/react";
import { Button } from '@/app/(landing)/components/Button';
import type { ThemeProps } from "@/app/(landing)/types/ui";
export const CtaBanner = ({ isNavyBg }: ThemeProps) => {
const scrollToContact = () => {
const el = document.getElementById("contacto");
if (el) el.scrollIntoView({ behavior: "smooth" });
};
const bg = isNavyBg ? "var(--background)" : "#f9f9f7";
const text = isNavyBg ? "var(--foreground)" : "#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 <Icon icon="ph:arrow-right" width={16} />
</Button>
</div>
</motion.div>
</div>
</section>
);
};
+111
View File
@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Icon } from '@iconify/react';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { faqs } from '@/app/(landing)/home/data/faqData';
export const FAQ = ({ isNavyBg }: ThemeProps) => {
const [open, setOpen] = useState<number | null>(null);
const text = isNavyBg ? 'var(--foreground)' : '#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={{ color: text }}
className="relative px-8 py-20 md:px-14 transition-colors duration-500"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-dot-grid text-border opacity-40"
/>
<div className="relative 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: 20, 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,
}}
>
<Icon icon="ph:plus" width={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 { Icon } from '@iconify/react';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { Button } from '@/app/(landing)/components/Button';
import { SystemMap } from './SystemMap';
import { useContact } from '@/app/(landing)/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 <Icon icon="ph:arrow-right" width={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
<Icon icon="ph:arrow-right" width={14} className="transition-transform group-hover:translate-x-0.5" />
</button>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.4, ease }}
className="hidden lg:block"
>
<SystemMap isNavyBg={isNavyBg} />
</motion.div>
</div>
</section>
);
};
@@ -0,0 +1,188 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Icon } from '@iconify/react';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { machineryData } from '@/app/(landing)/home/data/machineryData';
export const Machinery = ({ isNavyBg }: ThemeProps) => {
const [hovered, setHovered] = useState<number | null>(null);
const text = isNavyBg ? 'var(--foreground)' : '#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={{ color: text }}
className="relative px-8 py-20 md:px-14 transition-colors duration-500"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-dot-grid text-border opacity-40"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-24 -left-32 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(59,130,246,0.12)' }}
/>
<motion.div
className="relative 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-10">
<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: 20,
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 }}>
{item.icon ? (
<span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
borderRadius: 12,
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 icon={item.icon} width={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 mb-4"
style={{ color: sub, transition: 'color 220ms' }}
>
{item.description}
</p>
<div className="flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
<span
key={tag}
style={{
fontSize: 11,
fontWeight: 600,
padding: '3px 9px',
borderRadius: 99,
border: `1px solid ${cardBdr}`,
color: isNavyBg ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.5)',
letterSpacing: '0.01em',
}}
>
{tag}
</span>
))}
</div>
</div>
</motion.div>
);
})}
</div>
</motion.div>
</section>
);
};
@@ -0,0 +1,90 @@
"use client";
import { motion } from "framer-motion";
import { useTechPartners } from "@/app/(landing)/home/services/techPartners";
import { assetUrl } from "@/app/(landing)/services/api";
import type { ThemeProps } from '@/app/(landing)/types/ui';
export const Partners = ({ isNavyBg }: ThemeProps) => {
const { data } = useTechPartners();
const label = isNavyBg ? "rgba(255,255,255,0.28)" : "rgba(0,0,0,0.28)";
const text = isNavyBg ? "var(--foreground)" : "#0a0a0a";
const partners = (data ?? [])
.filter((p) => p.isActive)
.sort((a, b) => a.displayOrder - b.displayOrder);
if (partners.length === 0) return null;
return (
<section
aria-label="Partners tecnológicos de Rynext"
className="relative px-8 py-14 md:px-14 transition-colors duration-500"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-dot-grid text-border opacity-40"
/>
<motion.div
className="relative 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-x-10 gap-y-6">
{partners.map((p, i) => {
const content = p.logoUrl ? (
<img
src={assetUrl(p.logoUrl)}
alt={p.name}
className="h-[150px] w-auto max-w-[150px] object-contain"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<span
className="text-lg font-bold tracking-tight"
style={{ color: text }}
>
{p.name}
</span>
);
return (
<motion.div
key={p.id}
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.05 }}
className="flex items-center opacity-70 transition hover:opacity-100"
>
{p.websiteUrl ? (
<a
href={p.websiteUrl}
target="_blank"
rel="noopener noreferrer"
title={p.name}
>
{content}
</a>
) : (
<span title={p.name}>{content}</span>
)}
</motion.div>
);
})}
</div>
</motion.div>
</section>
);
};
@@ -0,0 +1,121 @@
'use client';
import { motion } from 'framer-motion';
import { Icon } from '@iconify/react';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { comparisonRows, proofCards } from '@/app/(landing)/home/data/platformAdvantageData';
export const PlatformAdvantage = ({ isNavyBg }: ThemeProps) => {
return (
<section
id="conveniencia"
className={`section-wave relative overflow-hidden px-6 py-28 md:px-12 transition-colors duration-500 ${
isNavyBg ? 'bg-[var(--background)] text-[var(--foreground)]' : 'bg-[#f9f9f7] text-brand-black'
}`}
>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-32 bottom-0 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(59,130,246,0.10)' }}
/>
<div className="relative 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-accent' : 'text-brand-blue'
}`}>
Por qué Rynext
</span>
<h2 className="text-section-title">
Rentar una licencia es fácil. Ser dueño de tu sistema, no.
</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-accent' : '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'
}`}>
<Icon icon="ph:x" width={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'
}`}>
<Icon icon="ph:check" width={13} className={`mt-0.5 shrink-0 ${isNavyBg ? "text-accent" : "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 cardIcon = 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-accent' : 'bg-brand-blue/8 text-brand-blue'
}`}>
<Icon icon={cardIcon} width={20} />
</div>
<h3 className="mb-2 font-display text-lg font-bold">{card.title}</h3>
<p className={`text-sm leading-relaxed ${isNavyBg ? 'text-slate-400' : 'text-gray-500'}`}>
{card.description}
</p>
</motion.article>
);
})}
</div>
</div>
</section>
);
};
@@ -0,0 +1,212 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/(landing)/components/Loader';
import { usePortfolio } from '@/app/(landing)/portafolio/services/portfolio';
import { assetUrl } from '@/app/(landing)/services/api';
import type { ThemeProps } from '@/app/(landing)/types/ui';
function PortfolioInner({ isNavyBg }: ThemeProps) {
const { data, isLoading } = usePortfolio();
const bg = isNavyBg ? "var(--background)" : "#f9f9f7";
const text = isNavyBg ? "var(--foreground)" : "#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 }} 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 }}
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: 20,
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} />;
};
@@ -0,0 +1,84 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { principles } from '@/app/(landing)/home/data/principlesData';
export const Principles = ({ isNavyBg }: ThemeProps) => {
const bg = isNavyBg ? 'var(--background)' : '#f5f5f3';
const text = isNavyBg ? 'var(--foreground)' : '#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: 20,
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>
);
};
+106
View File
@@ -0,0 +1,106 @@
'use client';
import { motion } from 'framer-motion';
import { Icon } from '@iconify/react';
import type { ThemeProps } from '@/app/(landing)/types/ui';
const symptoms = [
{ icon: 'ph:lock', label: 'Software que impone sus reglas', detail: 'en vez de adaptarse a cómo operas' },
{ icon: 'ph:shuffle', label: 'Procesos que siguen siendo manuales', detail: 'porque nadie los diseñó para escalar' },
{ icon: 'ph:eye-slash', label: 'Sin visibilidad real', detail: 'decisiones a ciegas sobre lo que pasa en el negocio' },
];
export const Problem = ({ isNavyBg }: ThemeProps) => {
const text = isNavyBg ? 'var(--foreground)' : '#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)';
const cardBg = isNavyBg ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
const rowBdr = isNavyBg ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
return (
<section
aria-labelledby="problem-heading"
className="relative px-8 py-16 md:px-14 transition-colors duration-500"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-dot-grid text-border opacity-40"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-32 -top-24 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(139,92,246,0.12)' }}
/>
<div className="relative mx-auto grid max-w-7xl gap-16 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<motion.div
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] }}
>
<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>
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.97 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="relative rounded-[28px] p-2"
style={{ background: cardBg, border: `1px solid ${border}`, backdropFilter: 'blur(12px)' }}
>
<div className="flex flex-col gap-1">
{symptoms.map((s, i) => (
<motion.div
key={s.label}
initial={{ opacity: 0, x: 16 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: 0.15 + i * 0.1, ease: [0.22, 1, 0.36, 1] }}
className="flex items-center gap-4 rounded-3xl px-5 py-5"
style={{ borderBottom: i < symptoms.length - 1 ? `1px solid ${rowBdr}` : 'none' }}
>
<span
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl"
style={{ background: 'rgba(59,130,246,0.12)', color: '#3b82f6' }}
>
<Icon icon={s.icon} width={19} />
</span>
<div className="min-w-0">
<p className="text-sm font-bold" style={{ color: text }}>{s.label}</p>
<p className="mt-0.5 text-xs leading-snug" style={{ color: sub }}>{s.detail}</p>
</div>
</motion.div>
))}
</div>
</motion.div>
</div>
</section>
);
};
@@ -0,0 +1,73 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { processSteps as steps } from '@/app/(landing)/home/data/processData';
export const Process = ({ isNavyBg }: ThemeProps) => {
const bg = isNavyBg ? 'var(--background)' : '#ffffff';
const text = isNavyBg ? 'var(--foreground)' : '#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: 20, overflow: 'hidden' }}>
{steps.map((step, i) => (
<motion.div
key={step.num}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.08 }}
style={{
padding: '32px 28px',
borderRight: i < steps.length - 1 ? `1px solid ${border}` : 'none',
}}
>
<span
className="font-display font-black block mb-6"
style={{ fontSize: 52, lineHeight: 1, color: '#3b82f6', letterSpacing: '-0.04em' }}
>
{step.num}
</span>
<h3
className="font-display font-black mb-3"
style={{ fontSize: 13, letterSpacing: '0.04em', color: text }}
>
{step.title}
</h3>
<p className="text-sm leading-relaxed" style={{ color: sub }}>
{step.desc}
</p>
</motion.div>
))}
</div>
</motion.div>
</section>
);
};
@@ -0,0 +1,97 @@
'use client';
import { motion } from 'framer-motion';
import { Icon } from '@iconify/react';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { servicesData } from '@/app/(landing)/home/data/servicesData';
export const Services = ({ isNavyBg }: ThemeProps) => {
return (
<section
id="servicios"
className={`relative px-6 py-20 md:px-12 transition-colors duration-500 ${
isNavyBg ? 'text-[var(--foreground)]' : 'text-brand-black'
}`}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-dot-grid text-border opacity-40"
/>
<div className="relative mx-auto max-w-7xl">
{/* Header */}
<div className="mb-10 flex flex-col justify-between gap-8 md:flex-row md:items-end">
<div>
<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 serviceIcon = service.icon;
const accentClass = service.accent === 'blue' ? 'bg-brand-blue' : 'bg-brand-red';
const iconBgClass = service.accent === 'blue'
? (isNavyBg ? 'bg-brand-blue/15 text-accent' : '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 icon={serviceIcon} width={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">
<Icon
icon="ph:check"
width={13}
className={`shrink-0 ${service.accent === 'blue'
? (isNavyBg ? 'text-accent' : '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>
);
};
+95
View File
@@ -0,0 +1,95 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { stackGroups } from '@/app/(landing)/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>
);
};
@@ -0,0 +1,227 @@
'use client';
import { useState } from 'react';
interface Node {
id: string;
label: string;
x: number;
y: number;
w: number;
h: number;
type: 'main' | 'side';
desc: string;
}
interface Edge {
from: string;
to: string;
type: 'main' | 'side';
}
const NODES: Node[] = [
{ id: 'frontend', label: 'FRONTEND', x: 80, y: 16, w: 160, h: 38, type: 'main', desc: 'Portales, apps y paneles:interfaces donde el usuario opera' },
{ id: 'api', label: 'API GATEWAY', x: 80, y: 108, w: 160, h: 38, type: 'main', desc: 'Seguridad, ruteo, rate limiting y versionado unificados' },
{ id: 'logic', label: 'LÓGICA DE NEGOCIO', x: 80, y: 200, w: 160, h: 38, type: 'main', desc: 'Reglas de negocio, validaciones, procesamiento y orquestación' },
{ id: 'db', label: 'BASE DE DATOS', x: 80, y: 292, w: 160, h: 38, type: 'main', desc: 'Persistencia diseñada para tu modelo:SQL, NoSQL, caché' },
{ id: 'infra', label: 'INFRAESTRUCTURA', x: 80, y: 384, w: 160, h: 36, type: 'main', desc: 'Nube, contenedores, CI/CD y observabilidad operacional' },
{ id: 'payments', label: 'PAGOS', x: 8, y: 172, w: 62, h: 28, type: 'side', desc: 'Stripe, Conekta:flujo de cobro integrado' },
{ id: 'erp', label: 'ERP / CRM', x: 8, y: 222, w: 62, h: 28, type: 'side', desc: 'SAP, Odoo:sincronización bidireccional' },
{ id: 'auth', label: 'ACCESO', x: 250, y: 84, w: 62, h: 28, type: 'side', desc: 'SSO, JWT, OAuth 2.0:identidad y permisos por rol' },
{ id: 'external', label: 'EXTERNOS', x: 250, y: 134, w: 62, h: 28, type: 'side', desc: 'APIs de terceros:logística, notificaciones, reportes' },
];
const EDGES: Edge[] = [
{ from: 'frontend', to: 'api', type: 'main' },
{ from: 'api', to: 'logic', type: 'main' },
{ from: 'logic', to: 'db', type: 'main' },
{ from: 'db', to: 'infra', type: 'main' },
{ from: 'payments', to: 'logic', type: 'side' },
{ from: 'erp', to: 'logic', type: 'side' },
{ from: 'auth', to: 'api', type: 'side' },
{ from: 'external', to: 'api', type: 'side' },
];
function nodeCenter(n: Node) {
return { cx: n.x + n.w / 2, cy: n.y + n.h / 2 };
}
function edgeLine(edge: Edge): { x1: number; y1: number; x2: number; y2: number } {
const from = NODES.find(n => n.id === edge.from)!;
const to = NODES.find(n => n.id === edge.to)!;
const fc = nodeCenter(from);
const tc = nodeCenter(to);
if (edge.type === 'main') {
return { x1: fc.cx, y1: from.y + from.h, x2: tc.cx, y2: to.y };
}
if (from.x < to.x) {
return { x1: from.x + from.w, y1: fc.cy, x2: to.x, y2: tc.cy };
}
return { x1: from.x, y1: fc.cy, x2: to.x + to.w, y2: tc.cy };
}
function isConnected(nodeId: string, activeId: string | null) {
if (!activeId || nodeId === activeId) return false;
return EDGES.some(e =>
(e.from === activeId && e.to === nodeId) ||
(e.to === activeId && e.from === nodeId),
);
}
function isEdgeActive(edge: Edge, activeId: string | null) {
if (!activeId) return false;
return edge.from === activeId || edge.to === activeId;
}
const TOOLTIP_W = 172;
const TAIL_H = 9;
const TAIL_W = 14;
function tooltipHeight(desc: string): number {
const charsPerLine = Math.floor((TOOLTIP_W - 26) / 6.3); // ~aprox chars por línea a 10.5px
const lines = Math.ceil(desc.length / charsPerLine);
return 14 + 16 + lines * 17 + 14; // paddingTop + label + líneas + paddingBottom
}
export function SystemMap({ isNavyBg = true }: { isNavyBg?: boolean }) {
const [active, setActive] = useState<string | null>(null);
const RED = '#3b82f6';
const dimStroke = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)';
const defStroke = isNavyBg ? 'rgba(255,255,255,0.25)' : 'rgba(0,31,91,0.3)';
const connStroke = 'rgba(59,130,246,0.45)';
const descText = isNavyBg ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.65)';
const tooltipBg = isNavyBg ? '#0a1628' : '#ffffff';
const tooltipBorder = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,31,91,0.15)';
void tooltipBorder;
const activeDesc = NODES.find(n => n.id === active);
return (
<div className="w-full" style={{ overflow: 'visible' }}>
<svg
viewBox="0 0 320 434"
width="100%"
aria-label="Mapa de sistema interactivo:hover para explorar capas"
role="img"
style={{ maxWidth: 340, display: 'block', margin: '0 auto', overflow: 'visible' }}
onMouseLeave={() => setActive(null)}
>
<defs>
<filter id="bubble-shadow" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="3" stdDeviation="4" floodColor="rgba(0,0,0,0.18)" />
</filter>
</defs>
{/* Edges */}
{EDGES.map((edge, i) => {
const { x1, y1, x2, y2 } = edgeLine(edge);
const edgeActive = isEdgeActive(edge, active);
return (
<line
key={i}
x1={x1} y1={y1} x2={x2} y2={y2}
stroke={edgeActive ? RED : edge.type === 'side' ? connStroke : defStroke}
strokeWidth={edgeActive ? 1.5 : 1}
strokeDasharray={edge.type === 'side' ? '3 3' : undefined}
style={{ transition: 'stroke 200ms, stroke-width 200ms' }}
/>
);
})}
{/* Nodes */}
{NODES.map(node => {
const isActive = active === node.id;
const isConn = isConnected(node.id, active);
const isDim = active !== null && !isActive && !isConn;
const stroke = isActive ? RED : isConn ? 'rgba(59,130,246,0.5)' : dimStroke;
const fillOpacity = isActive ? 0.12 : 0;
const textColor = isActive ? RED : isDim
? (isNavyBg ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)')
: (isNavyBg ? 'rgba(255,255,255,0.65)' : 'rgba(0,31,91,0.75)');
const { cx } = nodeCenter(node);
return (
<g
key={node.id}
onMouseEnter={() => setActive(node.id)}
onFocus={() => setActive(node.id)}
onBlur={() => setActive(null)}
style={{ cursor: 'default', outline: 'none' }}
tabIndex={0}
role="button"
aria-label={`${node.label}: ${node.desc}`}
>
<rect
x={node.x} y={node.y}
width={node.w} height={node.h}
fill={`rgba(59,130,246,${fillOpacity})`}
stroke={stroke}
strokeWidth={isActive ? 1.5 : 1}
rx={2}
style={{ transition: 'fill 200ms, stroke 200ms' }}
/>
<text
x={cx}
y={node.y + node.h / 2 + 1}
textAnchor="middle"
dominantBaseline="middle"
fill={textColor}
fontFamily="'SF Mono','Fira Code',Consolas,monospace"
fontSize={node.type === 'main' ? 10 : 8}
fontWeight={600}
letterSpacing={node.type === 'main' ? 1.2 : 0.8}
style={{ transition: 'fill 200ms', pointerEvents: 'none' }}
>
{node.label}
</text>
</g>
);
})}
{/* Tooltip burbuja estilo chat */}
{activeDesc && (() => {
const node = NODES.find(n => n.id === active)!;
const TOOLTIP_H = tooltipHeight(activeDesc.desc);
const cy = node.y + node.h / 2;
// nodos izquierda → burbuja a la derecha
// nodos derecha → burbuja a la izquierda
// nodos centro → burbuja a la derecha
const showLeft = node.x > 160; // solo los nodos de la derecha van a la izquierda
let tx: number, ty: number, tailPoints: string;
if (showLeft) {
tx = node.x - TOOLTIP_W - TAIL_H - 4;
ty = Math.min(Math.max(cy - TOOLTIP_H / 2, 4), 430 - TOOLTIP_H);
const tailCy = Math.min(Math.max(cy, ty + 16), ty + TOOLTIP_H - 16);
tailPoints = `${tx + TOOLTIP_W},${tailCy - TAIL_W / 2} ${tx + TOOLTIP_W},${tailCy + TAIL_W / 2} ${tx + TOOLTIP_W + TAIL_H},${tailCy}`;
} else {
tx = node.x + node.w + TAIL_H + 4;
ty = Math.min(Math.max(cy - TOOLTIP_H / 2, 4), 430 - TOOLTIP_H);
const tailCy = Math.min(Math.max(cy, ty + 16), ty + TOOLTIP_H - 16);
tailPoints = `${tx},${tailCy - TAIL_W / 2} ${tx},${tailCy + TAIL_W / 2} ${tx - TAIL_H},${tailCy}`;
}
return (
<g style={{ pointerEvents: 'none' }}>
<g filter="url(#bubble-shadow)">
<rect x={tx} y={ty} width={TOOLTIP_W} height={TOOLTIP_H} fill={tooltipBg} rx={12} />
<polygon points={tailPoints} fill={tooltipBg} />
</g>
<foreignObject x={tx} y={ty} width={TOOLTIP_W} height={TOOLTIP_H} style={{ overflow: 'visible' }}>
<div style={{ padding: '10px 13px' }}>
<p style={{ fontFamily: "'SF Mono',Consolas,monospace", fontSize: 8, letterSpacing: '0.12em', textTransform: 'uppercase', color: RED, marginBottom: 4, fontWeight: 600 }}>
{activeDesc.label}
</p>
<p style={{ fontSize: 10.5, color: descText, lineHeight: 1.5, margin: 0 }}>
{activeDesc.desc}
</p>
</div>
</foreignObject>
</g>
);
})()}
</svg>
</div>
);
}
@@ -0,0 +1,121 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Loader from '@/app/(landing)/components/Loader';
import { useTestimonials } from '@/app/(landing)/home/services/testimonials';
import type { ThemeProps } from '@/app/(landing)/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 ? "var(--background)" : "#f9f9f7";
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 ? "var(--foreground)" : "#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 }}
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: 20,
background: i === current ? "#3b82f6" : sub,
border: "none",
cursor: "pointer",
padding: 0,
transition: "width 300ms, background 300ms",
}}
aria-label={`Testimonio ${i + 1}`}
/>
))}
</div>
)}
</>
)}
</div>
</section>
);
};
@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from '@/app/(landing)/services/api';
export interface Company {
id: string;
name: string;
logoUrl?: string;
showInLanding?: boolean;
}
export function useCompanies() {
return useQuery<Company[]>({
queryKey: ["companies"],
queryFn: () => apiFetch<Company[]>("/api/companies"),
staleTime: 1000 * 60 * 5,
});
}
@@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from '@/app/(landing)/services/api';
export interface TechPartner {
id: string;
name: string;
logoUrl?: string;
category?: string;
websiteUrl?: string;
displayOrder: number;
isActive: boolean;
}
export function useTechPartners() {
return useQuery<TechPartner[]>({
queryKey: ["tech-partners"],
queryFn: () => apiFetch<TechPartner[]>("/api/tech-partners"),
staleTime: 1000 * 60 * 5,
});
}
@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from '@/app/(landing)/services/api';
interface OnboardingCompleted {
clientCompanyName: string;
testimonialQuote?: string;
testimonialAuthor?: string;
}
export interface Testimonial {
quote: string;
author: string;
company: 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,
});
}
@@ -0,0 +1,6 @@
export interface Company {
id: string;
name: string;
logoUrl?: string;
showInLanding?: boolean;
}
@@ -0,0 +1,9 @@
export interface TechPartner {
id: string;
name: string;
logoUrl?: string;
category?: string;
websiteUrl?: string;
displayOrder: number;
isActive: boolean;
}
@@ -0,0 +1,5 @@
export interface Testimonial {
quote: string;
author: string;
company: string;
}
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { useEffect, useState } from 'react';
export function useIsDark() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const root = document.documentElement;
setIsDark(root.classList.contains('dark'));
const observer = new MutationObserver(() => {
setIsDark(root.classList.contains('dark'));
});
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return isDark;
}
+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);
};
}, []);
}
+10
View File
@@ -0,0 +1,10 @@
import { Providers } from './providers';
import { AppShell } from './components/AppShell';
export default function LandingLayout({ children }: { children: React.ReactNode }) {
return (
<Providers>
<AppShell>{children}</AppShell>
</Providers>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundPage } from './NotFoundPage';
export default function NotFound() {
return <NotFoundPage />;
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from 'next';
import { HomePage } from '@/app/(landing)/home/HomePage';
export const metadata: Metadata = {
title: 'Rynext Technologies | Software Empresarial 100% Mexicano',
description: 'Diseñamos y construimos sistemas empresariales a medida. Arquitectura real, sin atajos técnicos. Cancún, México.',
alternates: { canonical: 'https://rynext.com.mx' },
};
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareCompany',
name: 'Rynext Technologies',
url: 'https://rynext.com.mx',
logo: 'https://rynext.com.mx/icons/Logo_negro.svg',
description: 'Diseñamos y construimos sistemas empresariales a medida. Arquitectura real, sin atajos técnicos.',
address: {
'@type': 'PostalAddress',
addressLocality: 'Cancún',
addressRegion: 'Quintana Roo',
addressCountry: 'MX',
},
contactPoint: {
'@type': 'ContactPoint',
email: '[email protected]',
contactType: 'customer support',
availableLanguage: 'Spanish',
},
sameAs: [
'https://www.facebook.com/RynextTechnologies/',
'https://linkedin.com/company/rynext',
],
};
export default function Page() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HomePage />
</>
);
}
@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import Loader from '@/app/(landing)/components/Loader';
import { usePortfolio } from '@/app/(landing)/portafolio/services/portfolio';
import { assetUrl } from '@/app/(landing)/services/api';
const bg = "var(--background)";
const text = "var(--foreground)";
const sub = "var(--muted-foreground)";
const border = "var(--border)";
const card = "var(--card)";
const tagBg = "rgba(59,130,246,0.08)";
function PortafolioContent() {
const { data, isLoading } = usePortfolio();
const projects = data ?? [];
return (
<main id="main-content">
{/* Hero */}
<section
style={{ background: bg, paddingTop: 120, paddingBottom: 16 }}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-7xl text-center">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
>
<h1
className="text-2xl md:text-3xl font-bold"
style={{ color: text }}
>
Casos de éxito
</h1>
</motion.div>
</div>
</section>
{/* Grid */}
<section style={{ background: bg }} className="px-8 pt-0 pb-20 md:px-14">
<div className="mx-auto max-w-7xl">
{isLoading ? (
<Loader className="py-20" />
) : projects.length === 0 ? (
<p className="text-center py-20 text-sm" style={{ color: sub }}>
Próximamente casos de éxito.
</p>
) : (
<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 }}
whileHover={{ y: -4 }}
viewport={{ once: true, margin: "-40px" }}
transition={{
duration: 0.55,
delay: i * 0.08,
ease: [0.22, 1, 0.36, 1],
}}
style={{
borderRadius: 14,
border: `1px solid ${border}`,
background: card,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<Link
href={`/portafolio/${p.id}`}
style={{ display: "flex", flexDirection: "column", flex: 1, textDecoration: "none" }}
>
{/* Cover */}
<div
style={{
aspectRatio: "16/9",
overflow: "hidden",
background: "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>
)}
<h2
className="font-display font-bold mb-3"
style={{
fontSize: "1.1rem",
color: text,
letterSpacing: "-0.01em",
}}
>
{p.title}
</h2>
{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>
</Link>
</motion.article>
);
})}
</div>
)}
</div>
</section>
</main>
);
}
export function PortafolioPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted)
return (
<div
className="flex min-h-dvh items-center justify-center"
style={{ background: bg }}
>
<Loader />
</div>
);
return <PortafolioContent />;
}
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { usePortfolio } from "@/app/(landing)/portafolio/services/portfolio";
import { assetUrl } from "@/app/(landing)/services/api";
import Loader from "@/app/(landing)/components/Loader";
function siteHref(url: string) {
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
}
function siteLabel(url: string) {
const host = url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
return /^www\./i.test(host) ? host : `www.${host}`;
}
function renderRichText(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("# "))
return (
<h2 key={i} style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--foreground)", marginTop: "2rem", marginBottom: "0.5rem", lineHeight: 1.3 }}>
{line.slice(2)}
</h2>
);
if (line.startsWith("## "))
return (
<h3 key={i} style={{ fontSize: "1.15rem", fontWeight: 600, color: "var(--foreground)", marginTop: "1.5rem", marginBottom: "0.4rem" }}>
{line.slice(3)}
</h3>
);
if (line.startsWith("- "))
return (
<li key={i} style={{ color: "var(--muted-foreground)", lineHeight: 1.8, marginLeft: "1.2rem", listStyleType: "disc" }}>
{line.slice(2)}
</li>
);
if (line.trim() === "") return <br key={i} />;
return (
<p key={i} style={{ color: "var(--muted-foreground)", lineHeight: 1.85, marginBottom: "0.25rem" }}>
{line}
</p>
);
});
}
export default function CaseStudyPage() {
const params = useParams();
const router = useRouter();
const id = typeof params.id === "string" ? params.id : (params.id?.[0] ?? "");
const { data: projects, isLoading, isError } = usePortfolio();
const project = projects?.find((p) => p.id === id);
const [activeImage, setActiveImage] = useState(0);
useEffect(() => {
if (project) document.title = `${project.title} | Portafolio`;
}, [project]);
if (isLoading) return <Loader className="py-40" />;
if (isError || !project) {
return (
<main style={{ minHeight: "80vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16 }}>
<p style={{ color: "var(--muted-foreground)", fontSize: 14, fontFamily: "monospace", letterSpacing: "0.1em" }}>
CASO NO ENCONTRADO
</p>
<button
onClick={() => router.push("/portafolio")}
style={{ color: "#3b82f6", fontSize: 14, textDecoration: "underline", background: "none", border: "none", cursor: "pointer" }}
>
Volver al portafolio
</button>
</main>
);
}
const images = [...project.images].sort((a, b) => a.order - b.order);
const cover = images.find((img) => img.isCover) ?? images[0];
const gallery = images.length > 0 ? images : cover ? [cover] : [];
const activeUrl = assetUrl(gallery[activeImage]?.url ?? cover?.url);
return (
<main id="main-content">
{/* Hero */}
<section style={{ background: "var(--background)", paddingTop: "140px", paddingBottom: "60px" }} className="px-8 md:px-14">
<div className="mx-auto" style={{ maxWidth: "900px" }}>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
{/* Volver */}
<Link
href="/portafolio"
style={{ display: "inline-flex", alignItems: "center", gap: 8, marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "#3b82f6", textDecoration: "none" }}
>
← VOLVER AL PORTAFOLIO
</Link>
{project.clientCompanyName && (
<div style={{ marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
{project.clientCompanyName.toUpperCase()}
</div>
)}
{/* Título */}
<h1 style={{ fontSize: "clamp(1.8rem, 4vw, 2.8rem)", fontWeight: 700, color: "var(--foreground)", lineHeight: 1.2, marginBottom: 20 }}>
{project.title}
</h1>
{/* Resumen */}
{project.publicSummary && (
<p style={{ fontSize: "1.1rem", color: "var(--muted-foreground)", lineHeight: 1.7, marginBottom: 28, borderLeft: "3px solid var(--border)", paddingLeft: 16 }}>
{project.publicSummary}
</p>
)}
{/* Meta: tecnologías + sitio */}
<div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap", paddingTop: 20, borderTop: "1px solid var(--border)" }}>
{project.technologies.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{project.technologies.map((t) => (
<span
key={t.id}
style={{
fontSize: 10.5, fontWeight: 600, padding: "3px 9px", borderRadius: 99,
background: "rgba(59,130,246,0.08)", color: t.color ?? "var(--muted-foreground)", letterSpacing: "0.01em",
}}
>
{t.name}
</span>
))}
</div>
)}
{project.siteUrl && (
<a
href={siteHref(project.siteUrl)}
target="_blank"
rel="noopener noreferrer"
style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 13, color: "#3b82f6", textDecoration: "none", marginLeft: "auto" }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20Z" />
</svg>
{siteLabel(project.siteUrl)}
</a>
)}
</div>
</motion.div>
</div>
</section>
{/* Galería */}
{activeUrl && (
<div style={{ background: "var(--muted)", borderBottom: "1px solid var(--border)" }}>
<div className="mx-auto" style={{ maxWidth: "1100px", padding: "0 2rem" }}>
<motion.div
key={activeUrl}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
style={{ overflow: "hidden", borderRadius: 4, aspectRatio: "16/7", marginTop: -1 }}
>
<img src={activeUrl} alt={project.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</motion.div>
{gallery.length > 1 && (
<div style={{ display: "flex", gap: 10, padding: "16px 0 24px" }}>
{gallery.map((img, i) => (
<button
key={img.id}
onClick={() => setActiveImage(i)}
style={{
width: 72, height: 48, borderRadius: 4, overflow: "hidden", padding: 0, cursor: "pointer",
border: i === activeImage ? "2px solid #3b82f6" : "2px solid transparent",
opacity: i === activeImage ? 1 : 0.6,
}}
>
<img src={assetUrl(img.url)} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
</button>
))}
</div>
)}
</div>
</div>
)}
{/* Caso de estudio + solución */}
<section style={{ background: "var(--background)", padding: "80px 0" }} className="px-8 md:px-14">
<motion.div
className="mx-auto"
style={{ maxWidth: "780px" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
{project.caseStudy && (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: 4, padding: "48px 52px", fontSize: "1rem", lineHeight: 1.85, marginBottom: project.solution ? 32 : 0 }}>
<span style={{ fontFamily: "monospace", fontSize: 10, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
CASO DE ESTUDIO
</span>
<div style={{ marginTop: 8 }}>{renderRichText(project.caseStudy)}</div>
</div>
)}
{project.solution && (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: 4, padding: "48px 52px", fontSize: "1rem", lineHeight: 1.85 }}>
<span style={{ fontFamily: "monospace", fontSize: 10, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
SOLUCIÓN
</span>
<div style={{ marginTop: 8 }}>{renderRichText(project.solution)}</div>
</div>
)}
{!project.caseStudy && !project.solution && (
<p style={{ color: "var(--muted-foreground)", fontSize: 14 }}>
Aún no hay detalle publicado para este caso.
</p>
)}
</motion.div>
</section>
</main>
);
}
@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import CaseStudyPage from "./CaseStudyPage";
import { API_URL, assetUrl } from "@/app/(landing)/services/api";
import type { PortfolioProject } from "@/app/(landing)/portafolio/types/portfolio";
const BASE_URL = "https://rynext.com.mx";
/**
* null = la API respondió pero el caso no existe/no está publicado (404 real).
* undefined = no se pudo consultar la API (red, TLS local, etc.) — no es un 404,
* se deja que el cliente (CaseStudyPage) resuelva con su propio fetch.
*/
async function getProject(id: string): Promise<PortfolioProject | null | undefined> {
try {
const res = await fetch(`${API_URL}/api/portfolio/public`, {
next: { revalidate: 3600 },
});
if (!res.ok) return undefined;
const projects = (await res.json()) as PortfolioProject[];
return projects.find((p) => p.id === id) ?? null;
} catch {
return undefined;
}
}
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const project = await getProject(id);
if (!project) {
return {};
}
const url = `${BASE_URL}/portafolio/${project.id}`;
const cover =
project.images.find((img) => img.isCover) ??
[...project.images].sort((a, b) => a.order - b.order)[0];
const image = assetUrl(cover?.url);
return {
title: `${project.title} | Portafolio | Rynext Technologies`,
description: project.publicSummary,
alternates: { canonical: url },
openGraph: {
title: project.title,
description: project.publicSummary,
type: "article",
url,
images: image
? [{ url: image, width: 1200, height: 630, alt: project.title }]
: undefined,
},
twitter: {
card: "summary_large_image",
title: project.title,
description: project.publicSummary,
images: image ? [image] : undefined,
},
};
}
export default async function Page({ params }: Props) {
const { id } = await params;
const project = await getProject(id);
// Solo 404 cuando confirmamos que no existe; si la API no respondió,
// dejamos que CaseStudyPage lo resuelva del lado del cliente.
if (project === null) notFound();
return <CaseStudyPage />;
}
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { PortafolioPage } from './PortafolioPage';
export const metadata: Metadata = {
title: 'Portafolio | Rynext Technologies',
description: 'Casos de éxito reales. Proyectos que transformaron operaciones empresariales con software diseñado a medida.',
alternates: { canonical: 'https://rynext.com.mx/portafolio' },
};
export default function Page() {
return <PortafolioPage />;
}
@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from '@/app/(landing)/services/api';
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[];
}
export function usePortfolio() {
return useQuery<PortfolioProject[]>({
queryKey: ["portfolio"],
queryFn: () => apiFetch<PortfolioProject[]>("/api/portfolio/public"),
staleTime: 1000 * 60 * 5,
});
}
@@ -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[];
}
@@ -0,0 +1,201 @@
"use client";
import { useEffect } from "react";
export function PrivacidadPage() {
useEffect(() => {
document.title = "Aviso de Privacidad | Rynext Technologies";
}, []);
return (
<main
id="main-content"
style={{ background: "var(--background)", minHeight: "100vh" }}
>
<section
style={{
paddingTop: "140px",
paddingBottom: "100px",
}}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-3xl">
<h1 className="text-headline mb-4" style={{ color: "var(--foreground)" }}>
Aviso de Privacidad Integral
</h1>
<p className="text-sm mb-16" style={{ color: "var(--muted-foreground)" }}>
Última actualización: agosto de 2026
</p>
<div className="prose-rynext">
<h2>1. Responsable del tratamiento de datos personales</h2>
<p>
<strong>Rynext Technologies</strong> (en adelante "Rynext"), con
domicilio en Cancún, Quintana Roo, México, es responsable del
tratamiento de sus datos personales conforme a lo establecido en
la Ley Federal de Protección de Datos Personales en Posesión de
los Particulares (LFPDPPP) y su Reglamento.
</p>
<p>
Para cualquier asunto relacionado con el presente Aviso de
Privacidad puede contactarnos en:{" "}
<a href="mailto:[email protected]">contacto@rynext.com.mx</a>
</p>
<h2>2. Datos personales que recabamos</h2>
<p>
Rynext puede recabar las siguientes categorías de datos
personales:
</p>
<ul>
<li>
<strong>Datos de identificación:</strong> nombre completo,
empresa o razón social.
</li>
<li>
<strong>Datos de contacto:</strong> correo electrónico, número
de teléfono.
</li>
<li>
<strong>Datos de navegación:</strong> dirección IP, tipo de
navegador, páginas visitadas y duración de la sesión (mediante
cookies técnicas).
</li>
</ul>
<p>
Rynext <strong>no recaba datos personales sensibles</strong> tales
como origen racial, estado de salud, creencias religiosas, datos
biométricos ni información financiera a través de este sitio web.
</p>
<h2>3. Finalidades del tratamiento</h2>
<p>
Sus datos personales son utilizados para las siguientes{" "}
<strong>finalidades primarias</strong>:
</p>
<ul>
<li>
Atender solicitudes de contacto, cotizaciones y propuestas
comerciales.
</li>
<li>
Dar seguimiento a proyectos de desarrollo de software y
consultoría.
</li>
<li>Cumplir con obligaciones legales y contractuales.</li>
</ul>
<p>
De manera secundaria, y solo con su consentimiento expreso,
podemos utilizar sus datos para:
</p>
<ul>
<li>
Envío de comunicaciones sobre nuevos servicios, actualizaciones
o contenido de interés.
</li>
<li>
Estadísticas internas y mejora de la experiencia en el sitio.
</li>
</ul>
<p>
Si no desea que sus datos sean tratados para las finalidades
secundarias, puede manifestarlo en cualquier momento
escribiéndonos a{" "}
<a href="mailto:[email protected]">contacto@rynext.com.mx</a>
.
</p>
<h2>4. Transferencia de datos personales</h2>
<p>
Rynext no vende, renta ni comercializa sus datos personales a
terceros. Únicamente podremos transferirlos en los siguientes
casos:
</p>
<ul>
<li>
A proveedores de servicios tecnológicos que actúen como
encargados bajo instrucción de Rynext y con las mismas
obligaciones de confidencialidad.
</li>
<li>
Cuando sea requerido por autoridades competentes en cumplimiento
de disposiciones legales.
</li>
</ul>
<p>
En ningún caso sus datos serán transferidos a terceros con fines
distintos a los aquí establecidos sin su consentimiento previo.
</p>
<h2>5. Derechos ARCO</h2>
<p>
Usted tiene derecho a{" "}
<strong>Acceder, Rectificar, Cancelar u Oponerse</strong>{" "}
(derechos ARCO) al tratamiento de sus datos personales, así como a
revocar el consentimiento que haya otorgado para dicho
tratamiento.
</p>
<p>Para ejercer sus derechos ARCO debe enviar una solicitud a:</p>
<p>
<strong>Correo:</strong>{" "}
<a href="mailto:[email protected]">contacto@rynext.com.mx</a>
<br />
<strong>Asunto:</strong> Ejercicio de Derechos ARCO
</p>
<p>Su solicitud deberá contener:</p>
<ul>
<li>
Nombre completo y correo electrónico con el que se registró o
contactó.
</li>
<li>
Descripción clara del derecho que desea ejercer y los datos a
los que se refiere.
</li>
<li>
Cualquier documento que facilite la localización de sus datos.
</li>
</ul>
<p>
Rynext responderá a su solicitud en un plazo máximo de{" "}
<strong>20 días hábiles{" "}</strong>a partir de su recepción.
</p>
<h2>6. Uso de cookies</h2>
<p>
Este sitio utiliza cookies técnicas estrictamente necesarias para
su funcionamiento. No utilizamos cookies de rastreo ni publicidad
comportamental sin su consentimiento. Puede configurar su
navegador para bloquear o eliminar las cookies; sin embargo,
algunas funcionalidades del sitio podrían verse afectadas.
</p>
<h2>7. Cambios al aviso de privacidad</h2>
<p>
Rynext se reserva el derecho de modificar este Aviso de Privacidad
en cualquier momento para reflejar cambios legislativos,
jurisprudenciales o en nuestras prácticas de privacidad. Las
modificaciones serán publicadas en esta página con la fecha de
actualización correspondiente. Se recomienda revisar
periódicamente este aviso.
</p>
<h2>8. Autoridad competente</h2>
<p>
Si considera que sus derechos han sido vulnerados, tiene derecho a
acudir ante el Instituto Nacional de Transparencia, Acceso a la
Información y Protección de Datos Personales (INAI) en{" "}
<a
href="https://www.inai.org.mx"
target="_blank"
rel="noopener noreferrer"
>
www.inai.org.mx
</a>
.
</p>
</div>
</div>
</section>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { PrivacidadPage } from './PrivacidadPage';
export const metadata: Metadata = {
title: 'Aviso de Privacidad | Rynext Technologies',
description: 'Aviso de privacidad de Rynext Technologies. Conoce cómo tratamos y protegemos tus datos personales.',
alternates: { canonical: 'https://rynext.com.mx/privacidad' },
robots: { index: false },
};
export default function Page() {
return <PrivacidadPage />;
}
+10
View File
@@ -0,0 +1,10 @@
'use client';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 60_000 } },
}));
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+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}`, { signal: AbortSignal.timeout(8000) });
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}`;
}
@@ -0,0 +1,434 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Icon } from "@iconify/react";
import Loader from '@/app/(landing)/components/Loader';
import { useTeam, groupByDepartment } from '@/app/(landing)/sobre-nosotros/services/team';
import { valores } from "@/app/(landing)/home/data/sobreNosotrosData";
const TEAM_IMAGE =
"https://images.unsplash.com/photo-1522071820081-009f0129c71c?w=1200&q=80";
const VALOR_ICONS: string[] = [
"ph:stack",
"ph:shield-check",
"ph:file-code",
"ph:arrows-clockwise",
"ph:seal-check",
];
function SobreNosotrosContent() {
const { data: members, isLoading: loadingTeam } = useTeam();
const departments = groupByDepartment(members ?? []);
useEffect(() => {
document.title = "Sobre Nosotros | Rynext Technologies";
}, []);
return (
<main id="main-content">
{/* Hero */}
<section
style={{
background: "var(--background)",
paddingTop: "140px",
paddingBottom: "100px",
}}
className="relative overflow-hidden px-8 md:px-14"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-32 -top-24 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: "rgba(59,130,246,0.12)" }}
/>
<div className="relative mx-auto max-w-7xl grid gap-16 lg:grid-cols-2 lg:items-center">
<div>
<motion.h1
className="text-display mb-8"
style={{ color: "var(--foreground)" }}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={{
hidden: { opacity: 0, y: 32 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.7,
delay: 0.1,
ease: [0.22, 1, 0.36, 1],
},
},
}}
>
Software 100%{" "}
<span style={{ position: "relative", display: "inline-block" }}>
<span style={{ color: "#3b82f6" }}>Mexicano</span>
<svg
viewBox="0 0 200 12"
preserveAspectRatio="none"
aria-hidden="true"
style={{
position: "absolute",
bottom: -4,
left: 0,
width: "100%",
height: 12,
}}
>
<path
d="M2 8 C50 2, 150 2, 198 8"
stroke="#3b82f6"
strokeWidth="3"
strokeLinecap="round"
fill="none"
/>
</svg>
</span>{" "}
de clase mundial.
</motion.h1>
<motion.p
className="text-lg leading-relaxed"
style={{ color: "var(--muted-foreground)" }}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={{
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
delay: 0.2,
ease: [0.22, 1, 0.36, 1],
},
},
}}
>
Rynext Technologies nació de una convicción: México tiene el
talento para construir software empresarial que compita con
cualquier firma en el mundo. No adaptamos herramientas extranjeras
diseñamos sistemas propios, con las tecnologías más actuales, para
la realidad operativa de los negocios mexicanos.
</motion.p>
</div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={{
hidden: { opacity: 0, x: 24 },
visible: {
opacity: 1,
x: 0,
transition: {
duration: 0.7,
delay: 0.15,
ease: [0.22, 1, 0.36, 1],
},
},
}}
style={{ position: "relative" }}
>
<div
style={{
position: "relative",
borderRadius: 28,
overflow: "hidden",
border: "1px solid var(--border)",
boxShadow: "0 24px 60px -16px rgba(0,0,0,0.25)",
}}
>
<img
src={TEAM_IMAGE}
alt="Equipo Rynext Technologies"
style={{
width: "100%",
aspectRatio: "4/3",
objectFit: "cover",
display: "block",
}}
/>
</div>
</motion.div>
</div>
</section>
{/* Valores */}
<section
style={{ background: "var(--background)" }}
className="px-8 pb-24 md:px-14 md:pb-28"
>
<div className="mx-auto max-w-7xl">
<motion.h2
className="text-headline mb-16"
style={{ color: "var(--foreground)" }}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={{
hidden: { opacity: 0, y: 28 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
delay: 0.1,
ease: [0.22, 1, 0.36, 1],
},
},
}}
>
Los principios que guían cada proyecto.
</motion.h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{valores.map((v, i) => {
const iconName = VALOR_ICONS[i % VALOR_ICONS.length];
return (
<motion.div
key={i}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={{
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
delay: i * 0.08,
ease: [0.22, 1, 0.36, 1],
},
},
}}
whileHover={{ y: -3 }}
className="transition-shadow duration-200 hover:shadow-md"
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 20,
padding: 28,
}}
>
<div className="mb-5 flex items-center justify-between">
<span
className="flex h-11 w-11 items-center justify-center rounded-xl"
style={{
background: "rgba(59,130,246,0.1)",
color: "#3b82f6",
}}
>
<Icon icon={iconName} width={20} />
</span>
<span
className="font-mono text-xs font-semibold"
style={{
color: "var(--muted-foreground)",
letterSpacing: "0.06em",
}}
>
{v.num}
</span>
</div>
<h3
className="font-display font-bold text-base mb-2.5"
style={{ color: "var(--foreground)" }}
>
{v.titulo}
</h3>
<p
className="text-sm leading-relaxed"
style={{ color: "var(--muted-foreground)" }}
>
{v.desc}
</p>
</motion.div>
);
})}
</div>
</div>
</section>
{/* Equipo */}
<section
style={{ background: "var(--background)" }}
className="px-8 pb-24 md:px-14 md:pb-28"
>
<div className="mx-auto max-w-7xl">
<motion.h2
className="text-headline mb-16"
style={{ color: "var(--foreground)" }}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
El equipo detrás del código.
</motion.h2>
{loadingTeam ? (
<Loader className="py-16" />
) : departments.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Sin miembros registrados.
</p>
) : (
<div className="flex flex-col gap-14">
{departments.map((dept, di) => (
<motion.div
key={dept.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
duration: 0.5,
delay: di * 0.08,
ease: [0.22, 1, 0.36, 1],
}}
>
{/* Dept header */}
<div className="flex items-center gap-3 mb-0">
<span
style={{
width: 3,
height: 16,
background: "#3b82f6",
borderRadius: 99,
flexShrink: 0,
display: "block",
}}
/>
<p
className="text-xs font-semibold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
{dept.name}
</p>
</div>
{/* Hierarchy connector */}
<div
style={{
position: "relative",
paddingLeft: 20,
marginLeft: 1,
}}
>
{/* Vertical line */}
<span
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 24,
width: 1,
background: "var(--border)",
}}
/>
{/* Horizontal branch */}
<span
style={{
position: "absolute",
left: 0,
top: 24,
width: 16,
height: 1,
background: "var(--border)",
}}
/>
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 pt-6">
{dept.members.map((m, mi) => (
<motion.div
key={m.id}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
duration: 0.4,
delay: mi * 0.05,
ease: [0.22, 1, 0.36, 1],
}}
whileHover={{ y: -2 }}
className="transition-shadow duration-200 hover:shadow-md"
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 14,
padding: "20px",
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<div
style={{
width: 44,
height: 44,
borderRadius: "50%",
background:
"linear-gradient(135deg,#3b82f6,#1d4ed8)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<span
style={{
color: "#fff",
fontWeight: 700,
fontSize: 14,
letterSpacing: "0.02em",
}}
>
{m.firstName[0]}
{m.lastName[0]}
</span>
</div>
<div style={{ minWidth: 0 }}>
<p
className="font-display font-bold text-sm"
style={{
color: "var(--foreground)",
lineHeight: 1.3,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{m.firstName} {m.lastName}
</p>
{m.positionName && (
<p
className="text-xs mt-0.5"
style={{ color: "var(--muted-foreground)" }}
>
{m.positionName}
</p>
)}
</div>
</motion.div>
))}
</div>
</div>
</motion.div>
))}
</div>
)}
</div>
</section>
</main>
);
}
export function SobreNosotrosPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <SobreNosotrosContent />;
}
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { SobreNosotrosPage } from './SobreNosotrosPage';
export const metadata: Metadata = {
title: 'Sobre Nosotros | Rynext Technologies',
description: 'Conoce al equipo detrás de Rynext Technologies. Software empresarial 100% mexicano de clase mundial, construido con arquitectura real.',
alternates: { canonical: 'https://rynext.com.mx/sobre-nosotros' },
};
export default function Page() {
return <SobreNosotrosPage />;
}
@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from '@/app/(landing)/services/api';
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[];
}
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);
}
@@ -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[];
}
+26
View File
@@ -0,0 +1,26 @@
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: string;
accent: 'blue' | 'red';
gridCols: string;
}