cambios
This commit is contained in:
1 parent
794a659369
commit
5f99b65611
349 files changed
+32252
-2180
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
@@ -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.' },
|
||||
];
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NotFoundPage } from './NotFoundPage';
|
||||
|
||||
export default function NotFound() {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user