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

No files matched your search

-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { Icon } from '@iconify/react';
import { Button } from '@/app/(landing)/components/ui/button';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/app/(landing)/components/ui/empty';
export function NotFoundPage() {
useEffect(() => {
document.title = '404 — Página no encontrada | Rynext Technologies';
}, []);
return (
<main
id="main-content"
className="relative flex w-full items-center justify-center px-8 pb-24 pt-40"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-16 -left-20 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(59,130,246,0.16)' }}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-16 -right-20 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(139,92,246,0.16)' }}
/>
<Empty className="relative border-none">
<EmptyHeader>
<EmptyTitle
className="font-display font-black"
style={{ fontSize: 'clamp(6rem, 20vw, 10rem)', lineHeight: 1, letterSpacing: '-0.04em', color: 'var(--foreground)' }}
>
404
</EmptyTitle>
<EmptyDescription style={{ color: 'var(--muted-foreground)' }}>
La página que buscas no existe o fue movida.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex gap-3">
<Button asChild>
<Link href="/">
<Icon icon="ph:house" className="mr-2 size-4" />
Ir al inicio
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/portafolio">
<Icon icon="ph:compass" className="mr-2 size-4" />
Ver portafolio
</Link>
</Button>
</div>
</EmptyContent>
</Empty>
</main>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/(landing)/components/Loader';
import { useBlogPosts } from '@/app/(landing)/blog/services/blog';
const fadeUp = {
hidden: { opacity: 0, y: 28 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
},
};
function BlogContent() {
const { data, isLoading } = useBlogPosts();
useEffect(() => {
document.title = "Blog | Rynext Technologies";
}, []);
const posts = (data ?? []).map((p) => ({
id: p.id,
category: p.category.toUpperCase(),
title: p.title,
summary: p.summary,
publishedAt: p.publishedAt,
imageUrl: p.imageUrl,
slug: p.slug,
}));
return (
<main id="main-content">
<section
style={{
background: "var(--background)",
paddingTop: "120px",
paddingBottom: "16px",
}}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-7xl text-center">
<motion.h1
className="text-2xl md:text-3xl font-bold"
style={{ color: "var(--foreground)" }}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeUp}
>
Artículos
</motion.h1>
</div>
</section>
<section
style={{
background: "var(--background)",
padding: "0 0 100px",
minHeight: "60vh",
}}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-7xl">
{isLoading ? (
<Loader className="py-20" />
) : posts.length > 0 ? (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((art, i) => (
<motion.article
key={art.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
viewport={{ once: true, margin: "-40px" }}
transition={{
duration: 0.55,
delay: i * 0.08,
ease: [0.22, 1, 0.36, 1],
}}
style={{
borderRadius: 14,
border: "1px solid var(--border)",
background: "var(--card)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
opacity: art.slug ? 1 : 0.82,
}}
>
<Link
href={art.slug ? `/blog/${art.slug}` : "#"}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
textDecoration: "none",
color: "inherit",
pointerEvents: art.slug ? "auto" : "none",
}}
>
<div
style={{
aspectRatio: "16/9",
overflow: "hidden",
background: "rgba(0,0,0,0.03)",
borderBottom: "1px solid var(--border)",
}}
>
{art.imageUrl ? (
<img
src={art.imageUrl}
alt={art.title}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--muted-foreground)" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="m21 15-5-5L5 21" />
</svg>
</div>
)}
</div>
<div
style={{
padding: "24px 24px 28px",
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
{art.category && (
<span
className="text-xs font-semibold uppercase tracking-wider mb-2 block"
style={{ color: "#3b82f6" }}
>
{art.category}
</span>
)}
<h2
className="font-display font-bold mb-3"
style={{ fontSize: "1.1rem", color: "var(--foreground)", letterSpacing: "-0.01em" }}
>
{art.title}
</h2>
<p
className="text-sm leading-relaxed mb-5"
style={{ color: "var(--muted-foreground)", flex: 1 }}
>
{art.summary}
</p>
<p
className="text-xs mt-auto"
style={{ color: "var(--muted-foreground)" }}
>
{art.publishedAt
? new Date(art.publishedAt).toLocaleDateString(
"es-MX",
{ day: "2-digit", month: "long", year: "numeric" },
)
: "Próximamente"}
</p>
</div>
</Link>
</motion.article>
))}
</div>
) : (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Sin artículos publicados.
</p>
)}
</div>
</section>
</main>
);
}
export function BlogPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <BlogContent />;
}
@@ -0,0 +1,316 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { useBlogPost } from '@/app/(landing)/blog/services/blog';
import { assetUrl } from '@/app/(landing)/services/api';
import Loader from '@/app/(landing)/components/Loader';
function renderContent(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("# "))
return (
<h2
key={i}
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--foreground)",
marginTop: "2rem",
marginBottom: "0.5rem",
lineHeight: 1.3,
}}
>
{line.slice(2)}
</h2>
);
if (line.startsWith("## "))
return (
<h3
key={i}
style={{
fontSize: "1.15rem",
fontWeight: 600,
color: "var(--foreground)",
marginTop: "1.5rem",
marginBottom: "0.4rem",
}}
>
{line.slice(3)}
</h3>
);
if (line.startsWith("- "))
return (
<li
key={i}
style={{
color: "var(--muted-foreground)",
lineHeight: 1.8,
marginLeft: "1.2rem",
listStyleType: "disc",
}}
>
{line.slice(2)}
</li>
);
if (line.trim() === "") return <br key={i} />;
return (
<p
key={i}
style={{
color: "var(--muted-foreground)",
lineHeight: 1.85,
marginBottom: "0.25rem",
}}
>
{line}
</p>
);
});
}
export default function ArticlePage() {
const params = useParams();
const router = useRouter();
const slug =
typeof params.slug === "string" ? params.slug : (params.slug?.[0] ?? "");
const { data: post, isLoading, isError } = useBlogPost(slug);
useEffect(() => {
if (post) document.title = `${post.title} | Blog`;
}, [post]);
if (isLoading) return <Loader className="py-40" />;
if (isError || !post) {
return (
<main
style={{
minHeight: "80vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
}}
>
<p
style={{
color: "var(--muted-foreground)",
fontSize: 14,
fontFamily: "monospace",
letterSpacing: "0.1em",
}}
>
ARTÍCULO NO ENCONTRADO
</p>
<button
onClick={() => router.push("/blog")}
style={{
color: "#3b82f6",
fontSize: 14,
textDecoration: "underline",
background: "none",
border: "none",
cursor: "pointer",
}}
>
Volver al blog
</button>
</main>
);
}
const imageUrl = assetUrl(post.imageUrl);
const publishDate = post.publishedAt
? new Date(post.publishedAt).toLocaleDateString("es-MX", {
day: "2-digit",
month: "long",
year: "numeric",
})
: null;
return (
<main id="main-content">
{/* Hero */}
<section
style={{
background: "var(--background)",
paddingTop: "140px",
paddingBottom: "60px",
}}
className="px-8 md:px-14"
>
<div className="mx-auto" style={{ maxWidth: "780px" }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Volver */}
<Link
href="/blog"
style={{ display: "inline-flex", alignItems: "center", gap: 8, marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "#3b82f6", textDecoration: "none" }}
>
← VOLVER AL BLOG
</Link>
{post.category && (
<div style={{ marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
{post.category.toUpperCase()}
</div>
)}
{/* Título */}
<h1
style={{
fontSize: "clamp(1.8rem, 4vw, 2.8rem)",
fontWeight: 700,
color: "var(--foreground)",
lineHeight: 1.2,
marginBottom: 20,
}}
>
{post.title}
</h1>
{/* Resumen */}
{post.summary && (
<p
style={{
fontSize: "1.1rem",
color: "var(--muted-foreground)",
lineHeight: 1.7,
marginBottom: 28,
borderLeft: "3px solid var(--border)",
paddingLeft: 16,
}}
>
{post.summary}
</p>
)}
{/* Meta */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
paddingTop: 20,
borderTop: "1px solid var(--border)",
}}
>
{post.authorName && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "linear-gradient(135deg,#3b82f6,#6366f1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 13,
fontWeight: 700,
}}
>
{post.authorName.charAt(0).toUpperCase()}
</div>
<span
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{post.authorName}
</span>
</div>
)}
{publishDate && (
<>
{post.authorName && (
<span style={{ color: "var(--border)" }}>·</span>
)}
<span
style={{
fontFamily: "monospace",
fontSize: 11,
color: "var(--muted-foreground)",
letterSpacing: "0.1em",
}}
>
{publishDate.toUpperCase()}
</span>
</>
)}
</div>
</motion.div>
</div>
</section>
{/* Imagen de portada */}
{imageUrl && (
<div
style={{
background: "var(--muted)",
borderBottom: "1px solid var(--border)",
}}
>
<div
className="mx-auto"
style={{ maxWidth: "1100px", padding: "0 2rem" }}
>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.15 }}
style={{
overflow: "hidden",
borderRadius: 4,
aspectRatio: "16/7",
marginTop: -1,
}}
>
<img
src={imageUrl}
alt={post.title}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</motion.div>
</div>
</div>
)}
{/* Contenido */}
<section
style={{ background: "var(--background)", padding: "80px 0" }}
className="px-8 md:px-14"
>
<motion.div
className="mx-auto"
style={{ maxWidth: "780px" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<div
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 4,
padding: "48px 52px",
fontSize: "1rem",
lineHeight: 1.85,
}}
>
{renderContent(post.content)}
</div>
</motion.div>
</section>
</main>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ArticlePage from "./ArticlePage";
import { API_URL, assetUrl } from '@/app/(landing)/services/api';
import type { BlogPost } from '@/app/(landing)/blog/services/blog';
const BASE_URL = "https://rynext.com.mx";
/**
* null = la API respondió que el artículo no existe o no está publicado (404 real).
* undefined = no se pudo consultar la API (red, TLS local, etc.) — no es un 404,
* se deja que el cliente (ArticlePage) resuelva con su propio fetch.
*/
async function getPost(slug: string): Promise<BlogPost | null | undefined> {
try {
const res = await fetch(`${API_URL}/api/blog/${slug}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
const post = (await res.json()) as BlogPost;
return post.isPublished ? post : null;
} catch {
return undefined;
}
}
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return {};
}
const url = `${BASE_URL}/blog/${post.slug}`;
const image = assetUrl(post.imageUrl);
return {
title: `${post.title} | Blog | Rynext Technologies`,
description: post.summary,
alternates: { canonical: url },
openGraph: {
title: post.title,
description: post.summary,
type: "article",
url,
publishedTime: post.publishedAt,
images: image
? [{ url: image, width: 1200, height: 630, alt: post.title }]
: undefined,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.summary,
images: image ? [image] : undefined,
},
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
// Solo 404 cuando confirmamos que no existe/no está publicado; si la API
// no respondió, dejamos que ArticlePage lo resuelva del lado del cliente.
if (post === null) notFound();
return <ArticlePage />;
}
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
File renamed without changes.
+32
View File
@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from '@/app/(landing)/services/api';
export interface BlogPost {
id: string;
title: string;
slug: string;
summary: string;
content: string;
category: string;
imageUrl?: string;
authorName?: string;
isPublished: boolean;
publishedAt?: string;
}
export function useBlogPosts() {
return useQuery<BlogPost[]>({
queryKey: ["blog"],
queryFn: () => apiFetch<BlogPost[]>("/api/blog"),
staleTime: 1000 * 60 * 5,
});
}
export function useBlogPost(slug: string) {
return useQuery<BlogPost>({
queryKey: ["blog", slug],
queryFn: () => apiFetch<BlogPost>(`/api/blog/${slug}`),
staleTime: 1000 * 60 * 5,
enabled: !!slug,
});
}
File renamed without changes.
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { usePathname } from "next/navigation";
import { ContactProvider } from "@/app/(landing)/contexts/ContactContext";
import { useIsDark } from "@/app/(landing)/hooks/useIsDark";
import { ScrollToTop } from "./ScrollToTop";
import { Navbar } from "./Navbar";
import { Footer } from "./Footer";
import { FloatingButtons } from "./FloatingButtons";
import { CookieBanner } from "./CookieBanner";
import { MusicPlayer } from "./MusicPlayer";
const BARE_ROUTES = ["/demo-hero", "/admin"];
export const AppShell = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname();
const isBare = BARE_ROUTES.some((route) => pathname?.startsWith(route));
const isDark = useIsDark();
if (isBare) {
return <ContactProvider>{children}</ContactProvider>;
}
return (
<ContactProvider>
<div
className="min-h-dvh font-sans"
style={{ background: "var(--background)", overflowX: "clip", overflowY: "visible" }}
>
<ScrollToTop />
<Navbar />
{children}
<Footer isNavyBg={isDark} />
<FloatingButtons isNavyBg={isDark} setIsNavyBg={() => {}} />
<MusicPlayer />
<CookieBanner isNavyBg={isDark} />
</div>
</ContactProvider>
);
};
File renamed without changes.
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { X, Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import type { ModalProps } from '@/app/types/ui';
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';
@@ -51,12 +51,12 @@ export const ContactModal = ({ isOpen, onClose, isNavyBg }: ModalProps) => {
}
};
const card = isNavyBg ? '#0d1b35' : '#ffffff';
const card = isNavyBg ? 'var(--card)' : '#ffffff';
const bdr = isNavyBg ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)';
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
const text = isNavyBg ? 'var(--foreground)' : '#0a0a0a';
const sub = isNavyBg ? 'rgba(255,255,255,0.45)' : '#6a6866';
const inputStyle: React.CSSProperties = {
width: '100%', borderRadius: 8, padding: '11px 14px',
width: '100%', borderRadius: 12, padding: '11px 14px',
fontSize: 14, fontFamily: 'inherit', outline: 'none',
transition: 'border-color 150ms',
...(isNavyBg
@@ -91,14 +91,14 @@ export const ContactModal = ({ isOpen, onClose, isNavyBg }: ModalProps) => {
style={{ background: 'rgba(0,0,0,0.06)', color: sub, border: 'none', cursor: 'pointer' }}
aria-label="Cerrar"
>
<X size={16} />
<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">
<CheckCircle size={48} style={{ color: '#22c55e' }} />
<Icon icon="ph:check-circle" width={48} style={{ color: '#22c55e' }} />
<h4 className="font-display font-bold text-lg" style={{ color: text }}>
¡Mensaje enviado!
</h4>
@@ -187,7 +187,7 @@ export const ContactModal = ({ isOpen, onClose, isNavyBg }: ModalProps) => {
{status === 'error' && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)' }}>
<AlertCircle size={15} style={{ color: '#ef4444', flexShrink: 0 }} />
<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>
@@ -202,12 +202,12 @@ export const ContactModal = ({ isOpen, onClose, isNavyBg }: ModalProps) => {
>
{status === 'loading' ? (
<>
<Loader2 size={16} className="animate-spin" />
<Icon icon="ph:spinner-gap" width={16} className="animate-spin" />
Enviando...
</>
) : (
<>
<Send size={15} />
<Icon icon="ph:paper-plane-tilt" width={15} />
Enviar mensaje
</>
)}
@@ -1,8 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
import type { ThemeProps } from '@/app/types/ui';
import type { ThemeProps } from '@/app/(landing)/types/ui';
export const CookieBanner = ({ isNavyBg }: ThemeProps) => {
export const CookieBanner = (_props: ThemeProps) => {
const [visible, setVisible] = useState(false);
useEffect(() => {
@@ -19,34 +19,31 @@ export const CookieBanner = ({ isNavyBg }: ThemeProps) => {
if (!visible) return null;
const bg = isNavyBg ? 'rgba(0,16,64,0.97)' : 'rgba(10,10,10,0.97)';
const border = isNavyBg ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.08)';
return (
<div
role="dialog"
aria-label="Aviso de cookies"
className="fixed bottom-6 left-1/2 z-50 w-[calc(100%-3rem)] max-w-xl -translate-x-1/2 px-6 py-4 shadow-2xl"
style={{
background: bg,
border: `1px solid ${border}`,
borderRadius: 6,
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: 'rgba(255,255,255,0.6)', maxWidth: '42ch' }}>
<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:text-white" style={{ color: 'rgba(255,255,255,0.45)' }}>
<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: 3, border: 'none', cursor: 'pointer' }}
style={{ background: '#3b82f6', borderRadius: 999, border: 'none', cursor: 'pointer' }}
>
Aceptar
</button>
@@ -1,7 +1,7 @@
'use client';
import { ArrowUp } from 'lucide-react';
import { Icon } from '@iconify/react';
import { motion, AnimatePresence } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { type Dispatch, type SetStateAction, useState, useEffect } from 'react';
const WHATSAPP_NUMBER = '529984814889';
@@ -11,12 +11,12 @@ interface FloatingButtonsProps extends ThemeProps {
setIsNavyBg: Dispatch<SetStateAction<boolean>>;
}
export const FloatingButtons = ({ isNavyBg, setIsNavyBg: _setIsNavyBg }: FloatingButtonsProps) => {
export const FloatingButtons = ({ setIsNavyBg: _setIsNavyBg }: FloatingButtonsProps) => {
const [showTop, setShowTop] = useState(false);
const [showBubble, setShowBubble] = useState(false);
const toggleBg = isNavyBg ? 'rgba(0,31,91,0.95)' : 'rgba(255,255,255,0.95)';
const toggleBorder = isNavyBg ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)';
const toggleColor = isNavyBg ? '#fbbf24' : '#001f5b';
const toggleBg = 'color-mix(in srgb, var(--card) 85%, transparent)';
const toggleBorder = 'var(--border)';
const toggleColor = '#3b82f6';
useEffect(() => {
const onScroll = () => setShowTop(window.scrollY > 400);
@@ -32,7 +32,7 @@ export const FloatingButtons = ({ isNavyBg, setIsNavyBg: _setIsNavyBg }: Floatin
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
aria-label="Volver arriba"
className="fixed bottom-6 left-6 z-50 flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-transform hover:scale-110 active:scale-95"
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}`,
@@ -41,7 +41,7 @@ export const FloatingButtons = ({ isNavyBg, setIsNavyBg: _setIsNavyBg }: Floatin
color: toggleColor,
}}
>
<ArrowUp size={16} />
<Icon icon="ph:arrow-up" width={16} />
</button>
)}
@@ -1,42 +1,66 @@
"use client";
import { Mail, MapPin, MessageCircle } from "lucide-react";
import { Icon } from "@iconify/react";
import { FaFacebookF, FaLinkedinIn, FaMapMarkerAlt } from "react-icons/fa";
import Link from "next/link";
import type { ThemeProps } from "@/app/types/ui";
import { pageLinks } from '@/app/home/data/navigationData';
import 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 ? "#001040" : "#0a0a0a";
const sub = "rgba(255,255,255,0.45)";
const border = "rgba(255,255,255,0.08)";
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, borderTop: `1px solid ${border}` }}
className="px-14 pt-10 transition-colors duration-500"
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 className="mx-auto max-w-7xl">
<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-14 md:grid-cols-[1.8fr_1fr_1.2fr]">
<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="/icons/Logo_blanco.svg"
src={isNavyBg ? "/icons/Logo_blanco.svg" : "/icons/Logo_negro.svg"}
alt="Logo"
className="h-11 w-11 shrink-0 object-contain"
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: 19,
fontSize: 21,
letterSpacing: "-0.01em",
color: "#ffffff",
color: text,
}}
>
Rynext
@@ -45,9 +69,9 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
style={{
fontFamily: "'Quicksand', sans-serif",
fontWeight: 500,
fontSize: 12,
fontSize: 13,
letterSpacing: "0.04em",
color: "rgba(255,255,255,0.45)",
color: sub,
}}
>
Technologies
@@ -55,27 +79,27 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
</div>
</div>
<p
className="text-sm leading-relaxed mb-8"
style={{ color: sub, maxWidth: "30ch" }}
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-3">
<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-9 w-9 items-center justify-center rounded transition-colors"
style={{ background: "rgba(255,255,255,0.07)", color: sub }}
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 = "rgba(255,255,255,0.14)";
e.currentTarget.style.color = "#fff";
e.currentTarget.style.background = chipBgHv;
e.currentTarget.style.color = text;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.07)";
e.currentTarget.style.background = chipBg;
e.currentTarget.style.color = sub;
}}
>
@@ -87,14 +111,14 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
target="_blank"
rel="noopener noreferrer"
aria-label="Rynext en LinkedIn"
className="flex h-9 w-9 items-center justify-center rounded transition-colors"
style={{ background: "rgba(255,255,255,0.07)", color: sub }}
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 = "rgba(255,255,255,0.14)";
e.currentTarget.style.color = "#fff";
e.currentTarget.style.background = chipBgHv;
e.currentTarget.style.color = text;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.07)";
e.currentTarget.style.background = chipBg;
e.currentTarget.style.color = sub;
}}
>
@@ -105,14 +129,14 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
target="_blank"
rel="noopener noreferrer"
aria-label="Rynext en WhatsApp"
className="flex h-9 w-9 items-center justify-center rounded transition-colors"
style={{ background: "rgba(255,255,255,0.07)", color: sub }}
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 = "rgba(255,255,255,0.14)";
e.currentTarget.style.color = "#fff";
e.currentTarget.style.background = chipBgHv;
e.currentTarget.style.color = text;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.07)";
e.currentTarget.style.background = chipBg;
e.currentTarget.style.color = sub;
}}
>
@@ -126,7 +150,7 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
<p
className="text-mono-label mb-6"
style={{
color: "rgba(255,255,255,0.25)",
color: faint,
letterSpacing: "0.12em",
}}
>
@@ -139,7 +163,7 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
href={link.href}
className="text-sm font-medium transition-colors"
style={{ color: sub, textDecoration: "none" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "#fff")}
onMouseEnter={(e) => (e.currentTarget.style.color = text)}
onMouseLeave={(e) => (e.currentTarget.style.color = sub)}
>
{link.label}
@@ -154,7 +178,7 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
<p
className="text-mono-label mb-6"
style={{
color: "rgba(255,255,255,0.25)",
color: faint,
letterSpacing: "0.12em",
}}
>
@@ -162,18 +186,18 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
</p>
<ul className="flex flex-col gap-4">
<li className="flex items-center gap-3">
<FaMapMarkerAlt className="text-white text-center" />
<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-white text-center" />
<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 = "#fff")}
onMouseEnter={(e) => (e.currentTarget.style.color = text)}
onMouseLeave={(e) => (e.currentTarget.style.color = sub)}
>
contacto@rynext.com.mx
@@ -182,40 +206,41 @@ export const Footer = ({ isNavyBg }: ThemeProps) => {
<li className="mt-2">
<a
href="mailto:[email protected]"
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-bold text-white transition hover:opacity-90"
style={{ background: "#3b82f6", borderRadius: 3 }}
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>
</div>
{/* Bottom bar — full width */}
<div
className="mt-10 -mx-8 px-8 md:-mx-14 md:px-14"
style={{ borderTop: `1px solid ${border}` }}
>
<div className="mx-auto flex max-w-7xl flex-col items-center gap-2 py-5 sm:flex-row sm:justify-between">
<p className="text-xs" style={{ color: "rgba(255,255,255,0.25)" }}>
© {new Date().getFullYear()} Rynext Technologies — Todos los
derechos reservados.
</p>
<Link
href="/privacidad"
className="text-xs transition-colors"
style={{ color: "rgba(255,255,255,0.25)", textDecoration: "none" }}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "rgba(255,255,255,0.6)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "rgba(255,255,255,0.25)")
}
>
Aviso de Privacidad
</Link>
{/* Bottom bar */}
<div className="mt-10 border-t" style={{ borderColor: border }}>
<div className="flex flex-col items-center gap-2 py-5 sm:flex-row sm:justify-between">
<p className="text-xs" style={{ color: faint }}>
<Link
href="/admin/login"
aria-label="Acceso interno"
style={{ color: "inherit", textDecoration: "none" }}
>
©
</Link>{" "}
{new Date().getFullYear()} Rynext Technologies — Todos los
derechos reservados.
</p>
<Link
href="/privacidad"
className="text-xs transition-colors"
style={{ color: faint, textDecoration: "none" }}
onMouseEnter={(e) => (e.currentTarget.style.color = sub)}
onMouseLeave={(e) => (e.currentTarget.style.color = faint)}
>
Aviso de Privacidad
</Link>
</div>
</div>
</div>
</footer>
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/components/Loader";
@@ -0,0 +1,93 @@
"use client";
import { Icon } from "@iconify/react";
import { useEffect, useRef, useState } from "react";
const TRACK_SRC = "/audio/musica.mp3";
const PREF_KEY = "rynext_music_enabled";
export const MusicPlayer = ({
className = "bottom-6 left-6",
inline = false,
}: {
className?: string;
inline?: boolean;
}) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = 0.05;
// Respeta la última preferencia del usuario (guardada en localStorage):
// si la silenció, no la reinicia sola al recargar la página.
let muted = false;
try {
muted = localStorage.getItem(PREF_KEY) === "off";
} catch {}
if (muted) return;
// Si el navegador bloquea el autoplay, no la arrancamos con cualquier
// interacción (scroll, tecla, etc.) — solo el botón de reproducir debe
// encenderla, para que no "se mueva sola" al navegar la página.
audio.play().then(() => setPlaying(true)).catch(() => {});
}, []);
function toggle() {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
setPlaying(false);
try { localStorage.setItem(PREF_KEY, "off"); } catch {}
} else {
audio
.play()
.then(() => {
setPlaying(true);
try { localStorage.setItem(PREF_KEY, "on"); } catch {}
})
.catch(() => {});
}
}
return (
<>
<audio ref={audioRef} src={TRACK_SRC} loop preload="auto" />
{inline ? (
<button
onClick={toggle}
aria-label={playing ? "Silenciar música" : "Reproducir música"}
aria-pressed={playing}
className="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors shrink-0"
>
<Icon
icon={playing ? "ph:speaker-high-fill" : "ph:speaker-slash-fill"}
width={16}
/>
</button>
) : (
<button
onClick={toggle}
aria-label={playing ? "Silenciar música" : "Reproducir música"}
aria-pressed={playing}
className={`fixed ${className} z-50 flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-transform hover:scale-110 active:scale-95`}
style={{
background: "color-mix(in srgb, var(--card) 85%, transparent)",
border: "1px solid var(--border)",
backdropFilter: "blur(8px)",
cursor: "pointer",
color: "#3b82f6",
}}
>
<Icon
icon={playing ? "ph:speaker-high-fill" : "ph:speaker-slash-fill"}
width={18}
/>
</button>
)}
</>
);
};
+221
View File
@@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Icon } from '@iconify/react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { pageLinks } from '@/app/(landing)/home/data/navigationData';
import { useAuthStore } from '@/app/admin/store/authStore';
import ThemeToggle from './ui/hero-01-utils/theme-toggle';
export const Navbar = () => {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [hovered, setHovered] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const rawPathname = usePathname();
const pathname = rawPathname?.replace(/\/$/, '') || '/';
const links = pageLinks.filter(l => l.href !== '/privacidad');
useEffect(() => {
setMounted(true);
}, []);
const scrollToContact = () => {
setMenuOpen(false);
const el = document.getElementById('contacto');
if (el) el.scrollIntoView({ behavior: 'smooth' });
else window.location.href = '/#contacto';
};
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
return (
<>
<motion.header
className="fixed inset-x-0 top-0 z-50 flex justify-center px-4 pt-4 sm:px-6"
initial={{ y: -40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
role="banner"
>
<div
className="flex w-full max-w-7xl items-center justify-between gap-4 rounded-full px-4 py-2.5 transition-shadow duration-300"
style={{
background: 'color-mix(in srgb, var(--card) 72%, transparent)',
border: '1px solid var(--border)',
backdropFilter: 'blur(18px)',
WebkitBackdropFilter: 'blur(18px)',
boxShadow: scrolled
? '0 8px 30px rgba(0,0,0,0.12)'
: '0 1px 2px rgba(0,0,0,0.03)',
}}
>
{/* Logo */}
<Link href="/" aria-label="Rynext Technologies — inicio" className="flex shrink-0 items-center gap-2">
<img src="/icons/Logo_negro.svg" alt="Logo" className="h-8 w-8 shrink-0 object-contain dark:hidden" />
<img src="/icons/Logo_blanco.svg" alt="Logo" className="hidden h-8 w-8 shrink-0 object-contain dark:block" />
<span className="flex flex-col leading-none">
<span
style={{ fontFamily: "'Quicksand', sans-serif", fontWeight: 700, fontSize: 16, letterSpacing: '-0.01em', color: 'var(--foreground)' }}
>
Rynext
</span>
<span
style={{ fontFamily: "'Quicksand', sans-serif", fontWeight: 500, fontSize: 10, letterSpacing: '0.04em', color: 'var(--muted-foreground)' }}
>
Technologies
</span>
</span>
</Link>
{/* Desktop nav — pill with sliding highlight */}
<nav
className="relative hidden items-center gap-0.5 rounded-full p-1 md:flex"
style={{ background: 'var(--muted)' }}
aria-label="Navegación principal"
onMouseLeave={() => setHovered(null)}
>
{links.map((link) => {
const active = pathname === link.href;
const isHovered = hovered === link.href;
// Solo un link muestra la píldora a la vez: el hovered tiene prioridad
// sobre el activo, para que nunca existan dos motion.span con el mismo
// layoutId montados al mismo tiempo (eso causaba el lag al pasar el mouse).
const showPill = hovered ? isHovered : active;
return (
<Link
key={link.href}
href={link.href}
onMouseEnter={() => setHovered(link.href)}
className="relative z-10 rounded-full px-3.5 py-1.5 text-sm font-semibold no-underline transition-colors"
style={{ color: active || isHovered ? 'var(--foreground)' : 'var(--muted-foreground)' }}
>
{showPill && (
<motion.span
layoutId="nav-pill"
className="absolute inset-0 -z-10 rounded-full"
style={{ background: 'var(--background)', boxShadow: '0 1px 2px rgba(0,0,0,0.06)' }}
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
/>
)}
{link.label}
</Link>
);
})}
</nav>
{/* Right cluster */}
<div className="flex shrink-0 items-center gap-2">
<div className="hidden md:block">
<ThemeToggle />
</div>
{mounted && isAuthenticated && (
<Link
href="/admin/dashboard"
className="hidden items-center gap-1.5 rounded-full px-4 py-2 text-sm font-bold no-underline transition hover:scale-[1.03] active:scale-[0.98] sm:inline-flex"
style={{ color: 'var(--foreground)', background: 'var(--muted)', border: '1px solid var(--border)' }}
>
<Icon icon="ph:squares-four" width={14} />
Dashboard
</Link>
)}
<button
onClick={scrollToContact}
className="group hidden items-center gap-1.5 rounded-full px-4 py-2 text-sm font-bold text-white transition hover:scale-[1.03] active:scale-[0.98] sm:inline-flex"
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer' }}
>
Hablemos
<Icon icon="ph:arrow-up-right" width={14} className="transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</button>
{/* Mobile toggle */}
<div className="flex items-center gap-1 md:hidden">
<ThemeToggle />
<button
onClick={() => setMenuOpen((v) => !v)}
className="flex h-9 w-9 items-center justify-center rounded-full"
style={{ color: 'var(--foreground)', background: 'var(--muted)', border: 'none', cursor: 'pointer' }}
aria-label={menuOpen ? 'Cerrar menú' : 'Abrir menú'}
aria-expanded={menuOpen}
>
{menuOpen ? <Icon icon="ph:x" width={18} /> : <Icon icon="ph:list" width={18} />}
</button>
</div>
</div>
</div>
</motion.header>
{/* Mobile menu — floating card */}
<AnimatePresence>
{menuOpen && (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -12, scale: 0.97 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="fixed inset-x-4 top-[76px] z-40 rounded-3xl p-5 md:hidden"
style={{
background: 'color-mix(in srgb, var(--card) 96%, transparent)',
border: '1px solid var(--border)',
backdropFilter: 'blur(18px)',
WebkitBackdropFilter: 'blur(18px)',
boxShadow: '0 16px 40px rgba(0,0,0,0.18)',
}}
role="dialog"
aria-modal="true"
aria-label="Menú de navegación"
>
<nav className="flex flex-col gap-1">
{pageLinks.map((link) => {
const active = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className="rounded-xl px-3 py-2.5 text-base font-semibold no-underline"
style={{
color: active ? 'var(--foreground)' : 'var(--muted-foreground)',
background: active ? 'var(--muted)' : 'transparent',
}}
>
{link.label}
</Link>
);
})}
</nav>
<div className="mt-4 flex flex-col gap-2">
{mounted && isAuthenticated && (
<Link
href="/admin/dashboard"
className="flex w-full items-center justify-center gap-1.5 rounded-full py-3 text-sm font-bold no-underline"
style={{ color: 'var(--foreground)', background: 'var(--muted)', border: '1px solid var(--border)' }}
>
<Icon icon="ph:squares-four" width={14} />
Dashboard
</Link>
)}
<button
onClick={scrollToContact}
className="flex w-full items-center justify-center gap-1.5 rounded-full py-3 text-sm font-bold text-white"
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer' }}
>
Hablemos
<Icon icon="ph:arrow-up-right" width={14} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/app/(landing)/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-full px-3",
lg: "h-11 rounded-full px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/app/(landing)/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
@@ -0,0 +1,57 @@
"use client"
import { motion } from "framer-motion"
import { useCompanies } from "@/app/(landing)/home/services/companies"
import { assetUrl } from "@/app/(landing)/services/api"
export default function BrandSlider() {
const { data } = useCompanies()
const companies = (data ?? []).filter((c) => c.showInLanding && c.logoUrl)
if (companies.length === 0) return null
// Con pocas marcas reales, un número inflado ("+1000") se nota falso —
// mostramos un mensaje honesto en ese caso y solo presumimos la cifra
// cuando el catálogo ya tiene un volumen que la respalde.
const trustLabel =
companies.length >= 10
? `Con la confianza de +${Math.floor(companies.length / 10) * 10} marcas grandes y pequeñas`
: "Empresas que ya confían en nosotros"
return (
<motion.div
className="relative z-10 w-full pt-16"
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.4 }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
>
<div className="flex items-center gap-4 sm:gap-6">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-border" />
<p className="shrink-0 text-xs text-muted-foreground sm:text-sm">
{trustLabel}
</p>
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-border" />
</div>
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-10 gap-y-6">
{companies.map((brand) => (
<div
key={brand.id}
title={brand.name}
className="flex items-center opacity-80 transition hover:opacity-100"
>
<img
src={assetUrl(brand.logoUrl)}
alt={brand.name}
className="h-[150px] w-auto max-w-[150px] object-contain"
onError={(e) => {
(e.currentTarget.closest("[title]") as HTMLElement | null)?.style.setProperty("display", "none")
}}
/>
</div>
))}
</div>
</motion.div>
)
}
@@ -0,0 +1,144 @@
"use client"
import {
motion,
MotionConfig,
type Variants,
} from "framer-motion"
import { Icon } from "@iconify/react"
import { Button } from "@/app/(landing)/components/ui/button"
import BrandSlider from "./brand-slider"
export interface AvatarList {
image: string
}
interface HeroSectionProps {
avatarList?: AvatarList[]
}
const container: Variants = {
hidden: {},
show: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.15,
},
},
}
const item: Variants = {
hidden: { opacity: 0, y: 22, filter: "blur(4px)" },
show: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { type: "spring", stiffness: 300, damping: 28, mass: 0.9 },
},
}
const wordContainer: Variants = {
hidden: {},
show: {
transition: { staggerChildren: 0.055, delayChildren: 0.3 },
},
}
const word: Variants = {
hidden: { opacity: 0, y: 16, filter: "blur(6px)" },
show: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { type: "spring", stiffness: 260, damping: 24 },
},
}
const HEADLINE_LINE_1 = ["Construimos", "experiencias", "que"]
const HEADLINE_LINE_2 = ["impulsan", "tu", "negocio"]
export default function HeroSection({}: HeroSectionProps) {
return (
<MotionConfig reducedMotion="user">
<section
className="relative isolate flex min-h-dvh flex-col justify-center px-4 pb-20 pt-32 sm:px-6 sm:pb-28 sm:pt-40 lg:px-8"
>
<div
aria-hidden="true"
className="animate-hero-blob-a pointer-events-none absolute -bottom-24 -left-32 -z-20 h-[24rem] w-[24rem] rounded-full bg-accent/[0.12] blur-[110px]"
/>
<div
aria-hidden="true"
className="animate-hero-blob-b pointer-events-none absolute -top-24 -right-32 -z-20 h-[24rem] w-[24rem] rounded-full bg-[#8b5cf6]/[0.12] blur-[110px]"
/>
<div
className="mx-auto flex max-w-4xl flex-col items-start text-left"
>
<motion.div variants={container} initial="hidden" animate="show">
<motion.span
variants={item}
className="inline-flex items-center gap-1.5 rounded-full border border-accent/20 bg-accent/10 px-3 py-1 text-xs font-semibold text-accent"
>
<Icon icon="ph:sparkle" className="h-3.5 w-3.5" />
Diseño &amp; desarrollo digital
</motion.span>
<motion.h1
variants={wordContainer}
className="text-display mt-6 max-w-2xl text-foreground"
>
<span className="block">
{HEADLINE_LINE_1.map((w, i) => (
<motion.span key={i} variants={word} className="inline-block">
{w}
{i < HEADLINE_LINE_1.length - 1 ? " " : ""}
</motion.span>
))}
</span>
<span className="block text-accent">
{HEADLINE_LINE_2.map((w, i) => (
<motion.span key={i} variants={word} className="inline-block">
{w}
{i < HEADLINE_LINE_2.length - 1 ? " " : ""}
</motion.span>
))}
</span>
</motion.h1>
<motion.p
variants={item}
className="mt-6 max-w-xl text-balance text-base text-muted-foreground sm:text-lg"
>
Diseño, desarrollo y estrategia digital para marcas que quieren
crecer. De la idea al producto en producción.
</motion.p>
<motion.div variants={item} className="mt-8 flex flex-col gap-3 sm:flex-row">
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button
size="lg"
className="group shadow-lg shadow-blue-500/20 transition-colors hover:bg-[#2563eb]"
>
Empezar un proyecto
<Icon icon="ph:arrow-right" className="ml-2 h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" />
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button
size="lg"
variant="outline"
className="border-primary/15 text-primary hover:border-primary hover:bg-transparent hover:text-primary"
>
Ver portafolio
</Button>
</motion.div>
</motion.div>
</motion.div>
<BrandSlider />
</div>
</section>
</MotionConfig>
)
}
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import { Icon } from "@iconify/react"
import { Button } from "@/app/(landing)/components/ui/button"
export default function ThemeToggle() {
const [isDark, setIsDark] = React.useState(false)
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
const stored = localStorage.getItem("theme")
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
const dark = stored ? stored === "dark" : prefersDark
setIsDark(dark)
document.documentElement.classList.toggle("dark", dark)
}, [])
const toggle = () => {
const next = !isDark
setIsDark(next)
document.documentElement.classList.toggle("dark", next)
localStorage.setItem("theme", next ? "dark" : "light")
}
if (!mounted) {
return (
<Button
variant="ghost"
size="icon"
aria-label="Cambiar tema"
disabled
className="text-foreground hover:bg-foreground/10"
>
<Icon icon="ph:sun" className="h-5 w-5" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon"
onClick={toggle}
aria-label="Cambiar tema"
className="text-foreground hover:bg-foreground/10"
>
{isDark ? <Icon icon="ph:sun" className="h-5 w-5" /> : <Icon icon="ph:moon" className="h-5 w-5" />}
</Button>
)
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/app/(landing)/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/app/(landing)/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
@@ -0,0 +1,128 @@
import * as React from "react"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@/app/(landing)/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}
@@ -1,6 +1,7 @@
'use client';
import { createContext, useContext, useState } from 'react';
import { ContactModal } from '@/app/components/ContactModal';
import { ContactModal } from '@/app/(landing)/components/ContactModal';
import { useIsDark } from '@/app/(landing)/hooks/useIsDark';
interface ContactCtx {
open: () => void;
@@ -10,10 +11,11 @@ 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={false} />
<ContactModal isOpen={isOpen} onClose={() => setIsOpen(false)} isNavyBg={isDark} />
</ContactContext.Provider>
);
};
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useIsDark } from "@/app/(landing)/hooks/useIsDark";
import HeroSection from "@/app/(landing)/components/ui/hero-01-utils/hero";
import type { AvatarList } from "@/app/(landing)/components/ui/hero-01-utils/hero";
import { Problem } from "@/app/(landing)/home/sections/Problem";
import { Machinery } from "@/app/(landing)/home/sections/Machinery";
import { Services } from "@/app/(landing)/home/sections/Services";
import { FAQ } from "@/app/(landing)/home/sections/FAQ";
import { Partners } from "@/app/(landing)/home/sections/Partners";
import { ContactSection } from "@/app/(landing)/home/sections/ContactSection";
const avatarList: AvatarList[] = [
{
image:
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=128&h=128&fit=crop&crop=faces",
},
{
image:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=128&h=128&fit=crop&crop=faces",
},
{
image:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=128&h=128&fit=crop&crop=faces",
},
{
image:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=128&h=128&fit=crop&crop=faces",
},
];
export function HomePage() {
const isDark = useIsDark();
return (
<main id="main-content" className="relative">
<HeroSection avatarList={avatarList} />
<Problem isNavyBg={isDark} />
<Machinery isNavyBg={isDark} />
<Services isNavyBg={isDark} />
<FAQ isNavyBg={isDark} />
<Partners isNavyBg={isDark} />
<ContactSection isNavyBg={isDark} />
</main>
);
}
File renamed without changes.
+22
View File
@@ -0,0 +1,22 @@
export const faqs = [
{
q: '¿Cuánto tiempo tarda un proyecto típico?',
a: 'Un sistema funcional suele estar en producción entre 8 y 16 semanas, trabajando en sprints cortos con entregables medibles.',
},
{
q: '¿Trabajan con empresas fuera de Cancún o de México?',
a: 'Sí, trabajamos de forma remota con empresas en todo México y en otros países.',
},
{
q: '¿Qué pasa con el mantenimiento y soporte después de la entrega?',
a: 'Se entrega documentado y en producción, con acompañamiento post-lanzamiento según el acuerdo de cada proyecto.',
},
{
q: '¿Pueden integrarse con el ERP o los sistemas que ya tenemos?',
a: 'Sí, la integración con SAP, Odoo, sistemas legacy y APIs internas es parte de nuestro núcleo de trabajo.',
},
{
q: '¿Cómo es el primer paso para arrancar un proyecto?',
a: 'Agendamos una sesión técnica sin costo para analizar tu operación y presentar una ruta concreta de construcción.',
},
];
@@ -0,0 +1,42 @@
export const machineryData = [
{
id: 1,
num: '01',
title: 'OPERACIONES',
description: 'Plataformas internas y control de procesos críticos, sin fricción.',
tags: ['Portales de gestión', 'Automatización de flujos', 'Reportes operacionales', 'Control de acceso por rol'],
icon: "ph:squares-four",
},
{
id: 2,
num: '02',
title: 'VENTAS',
description: 'Del contacto con el cliente al comprobante fiscal, todo integrado.',
tags: ['E-commerce a medida', 'Integración de pagos', 'Facturación y CFDI', 'Recuperación de carritos'],
icon: "ph:shopping-cart",
},
{
id: 3,
num: '03',
title: 'INTEGRACIONES',
description: 'Conectamos sistemas heterogéneos en un ecosistema coherente.',
tags: ['ERP / SAP / Odoo', 'APIs de terceros', 'Sincronización bidireccional', 'Webhooks y eventos'],
icon: "ph:flow-arrow",
},
{
id: 4,
num: '04',
title: 'AUTOMATIZACIÓN',
description: 'Tareas repetitivas corriendo solas, para que tu equipo se enfoque en lo que sí requiere criterio humano.',
tags: ['Bots y pipelines', 'Procesamiento de datos', 'Notificaciones inteligentes', 'Triggers y colas'],
icon: "ph:lightning",
},
{
id: 5,
num: '05',
title: 'DATOS',
description: 'Decisiones con evidencia: dashboards, modelos y alertas a tiempo.',
tags: ['Dashboards ejecutivos', 'Pipelines de datos', 'KPIs e instrumentación', 'Alertas y umbrales'],
icon: "ph:chart-bar",
},
];
@@ -1,13 +1,12 @@
export const pageLinks = [
{ label: 'Sobre Nosotros', href: '/sobre-nosotros' },
{ label: 'Productos', href: '/productos' },
{ 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: 'Soluciones', href: '#showcase' },
{ label: 'Portafolio', href: '#portafolio' },
{ label: 'Sistemas', href: '#machinery' },
{ label: 'Proceso', href: '#proceso' },
@@ -1,5 +1,3 @@
import { Building2, Layers3, ShieldCheck } from 'lucide-react';
export const comparisonRows = [
{
criterion: 'Adaptación al negocio',
@@ -27,16 +25,16 @@ export const proofCards = [
{
title: 'Diseño de experiencia',
description: 'UX orientada a adopción, claridad y flujo de trabajo real.',
icon: Layers3,
icon: "ph:stack-simple",
},
{
title: 'Arquitectura robusta',
description: 'Infraestructura, seguridad y escalabilidad definida desde el inicio.',
icon: ShieldCheck,
icon: "ph:shield-check",
},
{
title: 'Equipo senior',
description: 'Product owner, ingeniería y operación trabajando con criterio empresarial.',
icon: Building2,
icon: "ph:buildings",
},
];
File renamed without changes.
@@ -1,5 +1,4 @@
import { Code, Cloud, ShieldCheck, TrendingUp } from 'lucide-react';
import type { ServiceItem } from '@/app/types/ui';
import type { ServiceItem } from '@/app/(landing)/types/ui';
export const servicesData: ServiceItem[] = [
{
@@ -7,7 +6,7 @@ export const servicesData: ServiceItem[] = [
title: 'Desarrollo de software',
description: 'Plataformas web y APIs construidas con arquitectura limpia, código mantenible y enfoque en rendimiento real.',
features: ['APIs REST & GraphQL', 'Microservicios y monolitos modulares', 'Code review y auditorías técnicas'],
icon: Code,
icon: "ph:code",
gridCols: 'md:col-span-7',
accent: 'blue',
},
@@ -16,7 +15,7 @@ export const servicesData: ServiceItem[] = [
title: 'Infraestructura en la nube',
description: 'Diseño, migración y operación de infraestructura cloud escalable con alta disponibilidad.',
features: ['AWS, GCP y Azure', 'CI/CD y DevOps', 'Monitoreo y alertas 24/7'],
icon: Cloud,
icon: "ph:cloud",
gridCols: 'md:col-span-5',
accent: 'blue',
},
@@ -25,7 +24,7 @@ export const servicesData: ServiceItem[] = [
title: 'Ciberseguridad aplicada',
description: 'Protección de datos, auditorías de seguridad y protocolos que cumplen estándares empresariales.',
features: ['Pruebas de penetración', 'Cifrado end-to-end', 'Cumplimiento GDPR / ISO'],
icon: ShieldCheck,
icon: "ph:shield-check",
gridCols: 'md:col-span-5',
accent: 'red',
},
@@ -34,7 +33,7 @@ export const servicesData: ServiceItem[] = [
title: 'Producto y crecimiento',
description: 'Estrategia tecnológica alineada al negocio para lanzar, medir y escalar con datos reales.',
features: ['Roadmap técnico y de producto', 'Analytics e instrumentación', 'Optimización de conversión'],
icon: TrendingUp,
icon: "ph:trend-up",
gridCols: 'md:col-span-7',
accent: 'red',
},
@@ -5,9 +5,3 @@ export const valores = [
{ num: '04', titulo: 'Iteración con propósito', desc: 'Construimos en ciclos cortos y medibles. Cada entrega aporta valor real y permite ajustar la dirección.' },
{ num: '05', titulo: 'Responsabilidad completa', desc: 'Desde el análisis hasta la producción, somos dueños del resultado. No transferimos riesgo al cliente.' },
];
export const porQueRynext = [
{ num: '01', titulo: 'Diseño desde la raíz', desc: 'Cada sistema empieza con un análisis profundo de la operación. No adaptamos plantillas, diseñamos desde cero para el contexto específico del negocio.' },
{ num: '02', titulo: 'Equipo técnico propio', desc: 'Sin intermediarios. El equipo que analiza tu operación es el mismo que diseña la arquitectura y escribe el código.' },
{ num: '03', titulo: 'Enfoque en producción', desc: 'No entregamos prototipos. Entregamos sistemas listos para operar con carga real, datos reales y usuarios reales.' },
];
File renamed without changes.
@@ -1,11 +1,11 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { anatomyLayers as layers } from '@/app/home/data/anatomyData';
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 ? '#0d1117' : '#f5f5f3';
const text = isNavyBg ? '#eef0f4' : '#0a0a0a';
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';
@@ -35,7 +35,7 @@ export const Anatomy = ({ isNavyBg }: ThemeProps) => {
</div>
{/* Layers — always visible */}
<div style={{ border: `1px solid ${border}`, borderRadius: 4, overflow: 'hidden' }}>
<div style={{ border: `1px solid ${border}`, borderRadius: 20, overflow: 'hidden' }}>
{layers.map((layer, i) => (
<motion.div
key={layer.id}
@@ -1,15 +1,15 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/components/Loader';
import { useCompanies } from "@/app/home/services/companies";
import { assetUrl } from "@/app/services/api";
import type { ThemeProps } from "@/app/types/ui";
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 ? "#0a0f1e" : "#f0efec";
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)";
@@ -58,12 +58,15 @@ function ClientsInner({ isNavyBg }: ThemeProps) {
width: "auto",
maxWidth: 200,
objectFit: "contain",
filter: isNavyBg
? "brightness(0) invert(1) grayscale(1)"
: "grayscale(1)",
// 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")
}
@@ -1,8 +1,8 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Send, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
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';
@@ -13,13 +13,12 @@ export const ContactSection = ({ isNavyBg }: ThemeProps) => {
const [form, setForm] = useState(initialForm);
const [status, setStatus] = useState<Status>('idle');
const bg = isNavyBg ? '#080c14' : '#f5f5f3';
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
const 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: 6, padding: '12px 14px',
width: '100%', borderRadius: 12, padding: '12px 14px',
fontSize: 14, fontFamily: 'inherit', outline: 'none',
transition: 'border-color 150ms',
...(isNavyBg
@@ -60,10 +59,18 @@ export const ContactSection = ({ isNavyBg }: ThemeProps) => {
<section
id="contacto"
aria-labelledby="contact-heading"
style={{ background: bg, borderTop: `1px solid ${border}` }}
className="px-8 py-28 md:px-14 transition-colors duration-500"
className="relative px-8 py-20 md:px-14 transition-colors duration-500"
>
<div className="mx-auto max-w-7xl grid gap-16 lg:grid-cols-[1fr_1.2fr] lg:items-start">
<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
@@ -72,9 +79,6 @@ export const ContactSection = ({ isNavyBg }: ThemeProps) => {
viewport={{ once: true }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
>
<span className="block text-xs font-semibold uppercase tracking-[0.14em] mb-4" style={{ color: '#3b82f6' }}>
Contacto
</span>
<h2 id="contact-heading" className="text-headline mb-6" style={{ color: text, maxWidth: '20ch' }}>
Hablemos de tu proyecto.
</h2>
@@ -102,11 +106,11 @@ export const ContactSection = ({ isNavyBg }: ThemeProps) => {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1] }}
style={{ background: card, border: `1px solid ${border}`, borderRadius: 8, padding: '40px 36px' }}
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">
<CheckCircle size={48} style={{ color: '#22c55e' }} />
<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.
@@ -156,7 +160,7 @@ export const ContactSection = ({ isNavyBg }: ThemeProps) => {
{status === 'error' && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)' }}>
<AlertCircle size={15} style={{ color: '#ef4444', flexShrink: 0 }} />
<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>
@@ -170,9 +174,9 @@ export const ContactSection = ({ isNavyBg }: ThemeProps) => {
style={{ background: '#3b82f6', border: 'none', cursor: 'pointer' }}
>
{status === 'loading' ? (
<><Loader2 size={16} className="animate-spin" />Enviando...</>
<><Icon icon="ph:spinner-gap" width={16} className="animate-spin" />Enviando...</>
) : (
<><Send size={15} />Enviar mensaje</>
<><Icon icon="ph:paper-plane-tilt" width={15} />Enviar mensaje</>
)}
</button>
@@ -1,16 +1,16 @@
"use client";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { Button } from '@/app/components/Button';
import type { ThemeProps } from "@/app/types/ui";
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 ? "#001f5b" : "#f9f9f7";
const text = isNavyBg ? "#ffffff" : "#0a0a0a";
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 (
@@ -62,7 +62,7 @@ export const CtaBanner = ({ isNavyBg }: ThemeProps) => {
isNavyBg ? "shadow-xl shadow-red-900/30" : "rounded-md"
}
>
Hablemos de tu proyecto <ArrowRight size={16} />
Hablemos de tu proyecto <Icon icon="ph:arrow-right" width={16} />
</Button>
</div>
</motion.div>
@@ -1,15 +1,14 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Plus } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
import { faqs } from '@/app/home/data/faqData';
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 bg = isNavyBg ? '#080c14' : '#f5f5f3';
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
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';
@@ -18,10 +17,14 @@ export const FAQ = ({ isNavyBg }: ThemeProps) => {
<section
id="faq"
aria-labelledby="faq-heading"
style={{ background: bg, color: text, borderBottom: `1px solid ${border}` }}
className="px-8 py-28 md:px-14 transition-colors duration-500"
style={{ color: text }}
className="relative px-8 py-20 md:px-14 transition-colors duration-500"
>
<div className="mx-auto max-w-4xl">
<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 }}
@@ -38,7 +41,7 @@ export const FAQ = ({ isNavyBg }: ThemeProps) => {
</h2>
</motion.div>
<div style={{ border: `1px solid ${border}`, borderRadius: 8, overflow: 'hidden' }}>
<div style={{ border: `1px solid ${border}`, borderRadius: 20, overflow: 'hidden' }}>
{faqs.map((faq, i) => {
const isOpen = open === i;
return (
@@ -81,7 +84,7 @@ export const FAQ = ({ isNavyBg }: ThemeProps) => {
marginTop: 2,
}}
>
<Plus size={14} />
<Icon icon="ph:plus" width={14} />
</span>
</button>
@@ -1,10 +1,10 @@
'use client';
import { motion } from 'framer-motion';
import { ArrowRight } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
import { Button } from '@/app/components/Button';
import { 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/contexts/ContactContext';
import { useContact } from '@/app/(landing)/contexts/ContactContext';
const ease = [0.22, 1, 0.36, 1] as const;
@@ -70,7 +70,7 @@ export const Hero = ({ isNavyBg }: ThemeProps) => {
className="flex flex-wrap items-center gap-6"
>
<Button className="rounded-md" variant="primary" onClick={openContact}>
Hablemos de tu proyecto <ArrowRight size={16} />
Hablemos de tu proyecto <Icon icon="ph:arrow-right" width={16} />
</Button>
<button
onClick={() => document.getElementById('machinery')?.scrollIntoView({ behavior: 'smooth' })}
@@ -78,7 +78,7 @@ export const Hero = ({ isNavyBg }: ThemeProps) => {
style={{ color: sub }}
>
Explorar la maquinaria
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
<Icon icon="ph:arrow-right" width={14} className="transition-transform group-hover:translate-x-0.5" />
</button>
</motion.div>
</div>
@@ -1,14 +1,14 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { machineryData } from '@/app/home/data/machineryData';
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 bg = isNavyBg ? '#001f5b' : '#ffffff';
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
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)';
@@ -18,11 +18,20 @@ export const Machinery = ({ isNavyBg }: ThemeProps) => {
<section
id="machinery"
aria-labelledby="machinery-heading"
style={{ background: bg, color: text, borderBottom: `1px solid ${cardBdr}` }}
className="px-8 py-28 md:px-14 transition-colors duration-500"
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="mx-auto max-w-7xl"
className="relative mx-auto max-w-7xl"
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.1 }}
@@ -30,7 +39,7 @@ export const Machinery = ({ isNavyBg }: ThemeProps) => {
>
{/* Header */}
<div className="mb-16">
<div className="mb-10">
<h2
id="machinery-heading"
className="text-headline"
@@ -61,7 +70,7 @@ export const Machinery = ({ isNavyBg }: ThemeProps) => {
? isNavyBg ? 'rgba(59,130,246,0.06)' : 'rgba(59,130,246,0.03)'
: cardBg,
border: `1px solid ${isHov ? 'rgba(59,130,246,0.25)' : cardBdr}`,
borderRadius: 6,
borderRadius: 20,
padding: '28px 28px 32px',
position: 'relative',
overflow: 'hidden',
@@ -111,23 +120,23 @@ export const Machinery = ({ isNavyBg }: ThemeProps) => {
{/* Icon */}
<div style={{ position: 'relative', marginBottom: 20 }}>
{(() => { const Icon = item.icon; return Icon ? (
{item.icon ? (
<span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
borderRadius: 10,
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 size={20} />
<Icon icon={item.icon} width={20} />
</span>
) : null; })()}
) : null}
</div>
{/* Content */}
@@ -144,11 +153,29 @@ export const Machinery = ({ isNavyBg }: ThemeProps) => {
{item.title}
</h3>
<p
className="text-sm leading-relaxed"
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>
);
@@ -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>
);
};
@@ -1,28 +1,33 @@
'use client';
import { motion } from 'framer-motion';
import { X, Check } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
import { comparisonRows, proofCards } from '@/app/home/data/platformAdvantageData';
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 px-6 py-28 md:px-12 transition-colors duration-500 ${
isNavyBg ? 'bg-[#001749] text-white' : 'bg-slate-50 text-brand-black'
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 className="mx-auto max-w-7xl">
<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-sky-400' : 'text-brand-blue'
isNavyBg ? 'text-accent' : 'text-brand-blue'
}`}>
Por qué Rynext
</span>
<h2 className="text-section-title">
Detrás de cada plataforma hay un equipo senior, una arquitectura clara y una ruta con sentido.
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'}`}>
@@ -45,7 +50,7 @@ export const PlatformAdvantage = ({ isNavyBg }: ThemeProps) => {
Licencia SaaS
</div>
<div className={`border-t px-6 py-4 font-bold md:border-t-0 md:border-l ${
isNavyBg ? 'border-white/8 text-sky-400' : 'border-gray-100 text-brand-blue'
isNavyBg ? 'border-white/8 text-accent' : 'border-gray-100 text-brand-blue'
}`}>
Rynext software
</div>
@@ -67,13 +72,13 @@ export const PlatformAdvantage = ({ isNavyBg }: ThemeProps) => {
<div className={`flex items-start gap-2.5 border-t px-6 py-5 md:border-t-0 md:border-l ${
isNavyBg ? 'border-white/5 text-slate-400' : 'border-gray-100 text-gray-500'
}`}>
<X size={13} className="mt-0.5 shrink-0 text-gray-400" />
<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'
}`}>
<Check size={13} className={`mt-0.5 shrink-0 ${isNavyBg ? 'text-sky-400' : 'text-brand-blue'}`} />
<Icon icon="ph:check" width={13} className={`mt-0.5 shrink-0 ${isNavyBg ? "text-accent" : "text-brand-blue"}`} />
{row.ry}
</div>
</div>
@@ -83,7 +88,7 @@ export const PlatformAdvantage = ({ isNavyBg }: ThemeProps) => {
{/* Proof cards */}
<div className="mt-6 grid gap-4 lg:grid-cols-3">
{proofCards.map((card, index) => {
const Icon = card.icon;
const cardIcon = card.icon;
return (
<motion.article
key={card.title}
@@ -98,9 +103,9 @@ export const PlatformAdvantage = ({ isNavyBg }: ThemeProps) => {
}`}
>
<div className={`mb-5 inline-flex rounded-xl p-3 ${
isNavyBg ? 'bg-brand-blue/15 text-sky-300' : 'bg-brand-blue/8 text-brand-blue'
isNavyBg ? 'bg-brand-blue/15 text-accent' : 'bg-brand-blue/8 text-brand-blue'
}`}>
<Icon size={20} />
<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'}`}>
@@ -1,26 +1,26 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/components/Loader';
import { usePortfolio } from "@/app/portafolio/services/portfolio";
import { assetUrl } from "@/app/services/api";
import type { ThemeProps } from "@/app/types/ui";
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 ? "#080c14" : "#f0efec";
const text = isNavyBg ? "#ffffff" : "#0a0a0a";
const sub = isNavyBg ? "rgba(255,255,255,0.45)" : "#6a6866";
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 card = isNavyBg ? "rgba(255,255,255,0.03)" : "#ffffff";
const tagBg = isNavyBg ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.05)";
const projects = data ?? [];
if (isLoading) {
return (
<section style={{ background: bg, borderBottom: `1px solid ${border}` }} className="px-8 py-28 md:px-14">
<section style={{ background: bg }} className="px-8 py-28 md:px-14">
<Loader className="py-20" />
</section>
);
@@ -32,7 +32,7 @@ function PortfolioInner({ isNavyBg }: ThemeProps) {
<section
id="portafolio"
aria-labelledby="portfolio-heading"
style={{ background: bg, borderBottom: `1px solid ${border}` }}
style={{ background: bg }}
className="px-8 py-28 md:px-14 transition-colors duration-500"
>
<div className="mx-auto max-w-7xl">
@@ -54,25 +54,36 @@ function PortfolioInner({ isNavyBg }: ThemeProps) {
className="text-headline"
style={{ maxWidth: "28ch", color: text }}
>
Construimos el sistema. <span style={{ color: "#3b82f6" }}>Tú operas el negocio.</span>
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
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];
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] }}
transition={{
duration: 0.55,
delay: i * 0.1,
ease: [0.22, 1, 0.36, 1],
}}
style={{
borderRadius: 14,
borderRadius: 20,
border: `1px solid ${border}`,
background: card,
overflow: "hidden",
@@ -85,7 +96,9 @@ function PortfolioInner({ isNavyBg }: ThemeProps) {
style={{
aspectRatio: "16/9",
overflow: "hidden",
background: isNavyBg ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.03)",
background: isNavyBg
? "rgba(255,255,255,0.04)"
: "rgba(0,0,0,0.03)",
borderBottom: `1px solid ${border}`,
}}
>
@@ -93,11 +106,31 @@ function PortfolioInner({ isNavyBg }: ThemeProps) {
<img
src={assetUrl(cover.url)}
alt={p.title}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
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">
<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" />
@@ -107,20 +140,37 @@ function PortfolioInner({ isNavyBg }: ThemeProps) {
</div>
{/* Content */}
<div style={{ padding: "24px 24px 28px", display: "flex", flexDirection: "column", flex: 1 }}>
<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" }}>
<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" }}
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
className="text-sm leading-relaxed mb-5"
style={{ color: sub }}
>
{p.publicSummary}
</p>
)}
@@ -1,11 +1,11 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { principles } from '@/app/home/data/principlesData';
import type { ThemeProps } from '@/app/(landing)/types/ui';
import { principles } from '@/app/(landing)/home/data/principlesData';
export const Principles = ({ isNavyBg }: ThemeProps) => {
const bg = isNavyBg ? '#0d1117' : '#f5f5f3';
const text = isNavyBg ? '#eef0f4' : '#0a0a0a';
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)';
@@ -40,7 +40,7 @@ export const Principles = ({ isNavyBg }: ThemeProps) => {
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5"
style={{
border: `1px solid ${gridGap}`,
borderRadius: 4,
borderRadius: 20,
overflow: 'hidden',
}}
>
+106
View File
@@ -0,0 +1,106 @@
'use client';
import { motion } from 'framer-motion';
import { Icon } from '@iconify/react';
import type { ThemeProps } from '@/app/(landing)/types/ui';
const symptoms = [
{ icon: 'ph:lock', label: 'Software que impone sus reglas', detail: 'en vez de adaptarse a cómo operas' },
{ icon: 'ph:shuffle', label: 'Procesos que siguen siendo manuales', detail: 'porque nadie los diseñó para escalar' },
{ icon: 'ph:eye-slash', label: 'Sin visibilidad real', detail: 'decisiones a ciegas sobre lo que pasa en el negocio' },
];
export const Problem = ({ isNavyBg }: ThemeProps) => {
const text = isNavyBg ? 'var(--foreground)' : '#0a0a0a';
const sub = isNavyBg ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.4)';
const border = isNavyBg ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const cardBg = isNavyBg ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.8)';
const rowBdr = isNavyBg ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
return (
<section
aria-labelledby="problem-heading"
className="relative px-8 py-16 md:px-14 transition-colors duration-500"
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-dot-grid text-border opacity-40"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -right-32 -top-24 h-[24rem] w-[24rem] rounded-full blur-[110px]"
style={{ background: 'rgba(139,92,246,0.12)' }}
/>
<div className="relative mx-auto grid max-w-7xl gap-16 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
<motion.div
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.15 }}
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
>
<h2
id="problem-heading"
className="text-display"
style={{ lineHeight: 1.0, letterSpacing: '-0.02em', color: text }}
>
{['La mayoría', 'de los problemas', 'operativos son'].map((line, i) => (
<motion.span
key={line}
className="block"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: i * 0.07, ease: [0.22, 1, 0.36, 1] }}
>
{line}
</motion.span>
))}
<motion.span
className="block text-brand-red"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
problemas de sistema.
</motion.span>
</h2>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.97 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="relative rounded-[28px] p-2"
style={{ background: cardBg, border: `1px solid ${border}`, backdropFilter: 'blur(12px)' }}
>
<div className="flex flex-col gap-1">
{symptoms.map((s, i) => (
<motion.div
key={s.label}
initial={{ opacity: 0, x: 16 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: 0.15 + i * 0.1, ease: [0.22, 1, 0.36, 1] }}
className="flex items-center gap-4 rounded-3xl px-5 py-5"
style={{ borderBottom: i < symptoms.length - 1 ? `1px solid ${rowBdr}` : 'none' }}
>
<span
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl"
style={{ background: 'rgba(59,130,246,0.12)', color: '#3b82f6' }}
>
<Icon icon={s.icon} width={19} />
</span>
<div className="min-w-0">
<p className="text-sm font-bold" style={{ color: text }}>{s.label}</p>
<p className="mt-0.5 text-xs leading-snug" style={{ color: sub }}>{s.detail}</p>
</div>
</motion.div>
))}
</div>
</motion.div>
</div>
</section>
);
};
@@ -1,11 +1,11 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { processSteps as steps } from '@/app/home/data/processData';
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 ? '#001f5b' : '#ffffff';
const text = isNavyBg ? '#ffffff' : '#0a0a0a';
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';
@@ -36,7 +36,7 @@ export const Process = ({ isNavyBg }: ThemeProps) => {
</div>
{/* Steps */}
<div className="grid gap-0 md:grid-cols-3 lg:grid-cols-5" style={{ border: `1px solid ${border}`, borderRadius: 4, overflow: 'hidden' }}>
<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}
@@ -1,26 +1,25 @@
'use client';
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
import type { ThemeProps } from '@/app/types/ui';
import { servicesData } from '@/app/home/data/servicesData';
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={`section-wave px-6 py-28 md:px-12 transition-colors duration-500 ${
isNavyBg ? 'bg-[#001749] text-white' : 'bg-slate-50 text-brand-black'
className={`relative px-6 py-20 md:px-12 transition-colors duration-500 ${
isNavyBg ? 'text-[var(--foreground)]' : 'text-brand-black'
}`}
>
<div className="mx-auto max-w-7xl">
<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-16 flex flex-col justify-between gap-8 md:flex-row md:items-end">
<div className="mb-10 flex flex-col justify-between gap-8 md:flex-row md:items-end">
<div>
<span className={`mb-3 block text-xs font-semibold uppercase tracking-[0.14em] ${
isNavyBg ? 'text-sky-400' : 'text-brand-blue'
}`}>
Servicios
</span>
<h2 className="max-w-2xl text-section-title">
Soluciones que conectan producto, infraestructura y crecimiento.
</h2>
@@ -33,10 +32,10 @@ export const Services = ({ isNavyBg }: ThemeProps) => {
{/* Cards grid */}
<div className="grid gap-4 md:grid-cols-12">
{servicesData.map((service, index) => {
const Icon = service.icon;
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-sky-300' : 'bg-brand-blue/10 text-brand-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 (
@@ -58,7 +57,7 @@ export const Services = ({ isNavyBg }: ThemeProps) => {
{/* Header row */}
<div className="mb-6 flex items-start justify-between">
<div className={`rounded-xl p-3 ${iconBgClass}`}>
<Icon size={22} />
<Icon icon={serviceIcon} width={22} />
</div>
<span className={`font-mono text-xs font-bold ${isNavyBg ? 'text-white/20' : 'text-gray-200'}`}>
0{service.id}
@@ -74,10 +73,11 @@ export const Services = ({ isNavyBg }: ThemeProps) => {
<ul className="space-y-2">
{service.features.map((f: string) => (
<li key={f} className="flex items-center gap-2.5">
<Check
size={13}
<Icon
icon="ph:check"
width={13}
className={`shrink-0 ${service.accent === 'blue'
? (isNavyBg ? 'text-sky-400' : 'text-brand-blue')
? (isNavyBg ? 'text-accent' : 'text-brand-blue')
: (isNavyBg ? 'text-blue-400' : 'text-brand-red')
}`}
/>
@@ -1,7 +1,7 @@
'use client';
import { motion } from 'framer-motion';
import type { ThemeProps } from '@/app/types/ui';
import { stackGroups } from '@/app/home/data/stackData';
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';
@@ -1,9 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Loader from '@/app/components/Loader';
import { useTestimonials } from "@/app/home/services/testimonials";
import type { ThemeProps } from "@/app/types/ui";
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();
@@ -21,17 +21,17 @@ export const Testimonial = ({ isNavyBg }: ThemeProps) => {
}, [testimonials.length]);
const item = testimonials[current];
const bg = isNavyBg ? "#001f5b" : "#ffffff";
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 ? "#ffffff" : "#0a0a0a";
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, borderBottom: `1px solid ${border}` }}
style={{ background: bg }}
className="px-8 py-24 md:px-14 transition-colors duration-500"
>
<div className="mx-auto max-w-4xl">
@@ -101,7 +101,7 @@ export const Testimonial = ({ isNavyBg }: ThemeProps) => {
style={{
width: i === current ? 24 : 8,
height: 8,
borderRadius: 4,
borderRadius: 20,
background: i === current ? "#3b82f6" : sub,
border: "none",
cursor: "pointer",
@@ -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,
});
}
File renamed without changes.
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { useEffect, useState } from 'react';
export function useIsDark() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const root = document.documentElement;
setIsDark(root.classList.contains('dark'));
const observer = new MutationObserver(() => {
setIsDark(root.classList.contains('dark'));
});
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, []);
return isDark;
}
File renamed without changes.
+10
View File
@@ -0,0 +1,10 @@
import { Providers } from './providers';
import { AppShell } from './components/AppShell';
export default function LandingLayout({ children }: { children: React.ReactNode }) {
return (
<Providers>
<AppShell>{children}</AppShell>
</Providers>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
File renamed without changes.
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { HomePage } from '@/app/home/HomePage';
import { HomePage } from '@/app/(landing)/home/HomePage';
export const metadata: Metadata = {
title: 'Rynext Technologies | Software Empresarial 100% Mexicano',
@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { motion } from "framer-motion";
import Loader from '@/app/(landing)/components/Loader';
import { usePortfolio } from '@/app/(landing)/portafolio/services/portfolio';
import { assetUrl } from '@/app/(landing)/services/api';
const bg = "var(--background)";
const text = "var(--foreground)";
const sub = "var(--muted-foreground)";
const border = "var(--border)";
const card = "var(--card)";
const tagBg = "rgba(59,130,246,0.08)";
function PortafolioContent() {
const { data, isLoading } = usePortfolio();
const projects = data ?? [];
return (
<main id="main-content">
{/* Hero */}
<section
style={{ background: bg, paddingTop: 120, paddingBottom: 16 }}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-7xl text-center">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
>
<h1
className="text-2xl md:text-3xl font-bold"
style={{ color: text }}
>
Casos de éxito
</h1>
</motion.div>
</div>
</section>
{/* Grid */}
<section style={{ background: bg }} className="px-8 pt-0 pb-20 md:px-14">
<div className="mx-auto max-w-7xl">
{isLoading ? (
<Loader className="py-20" />
) : projects.length === 0 ? (
<p className="text-center py-20 text-sm" style={{ color: sub }}>
Próximamente casos de éxito.
</p>
) : (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p, i) => {
const cover =
p.images.find((img) => img.isCover) ??
p.images.sort((a, b) => a.order - b.order)[0];
return (
<motion.article
key={p.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
viewport={{ once: true, margin: "-40px" }}
transition={{
duration: 0.55,
delay: i * 0.08,
ease: [0.22, 1, 0.36, 1],
}}
style={{
borderRadius: 14,
border: `1px solid ${border}`,
background: card,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<Link
href={`/portafolio/${p.id}`}
style={{ display: "flex", flexDirection: "column", flex: 1, textDecoration: "none" }}
>
{/* Cover */}
<div
style={{
aspectRatio: "16/9",
overflow: "hidden",
background: "rgba(0,0,0,0.03)",
borderBottom: `1px solid ${border}`,
}}
>
{cover ? (
<img
src={assetUrl(cover.url)}
alt={p.title}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke={sub}
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="m21 15-5-5L5 21" />
</svg>
</div>
)}
</div>
{/* Content */}
<div
style={{
padding: "24px 24px 28px",
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
{p.clientCompanyName && (
<span
className="text-xs font-semibold uppercase tracking-wider mb-2 block"
style={{ color: "#3b82f6" }}
>
{p.clientCompanyName}
</span>
)}
<h2
className="font-display font-bold mb-3"
style={{
fontSize: "1.1rem",
color: text,
letterSpacing: "-0.01em",
}}
>
{p.title}
</h2>
{p.publicSummary && (
<p
className="text-sm leading-relaxed mb-5"
style={{ color: sub }}
>
{p.publicSummary}
</p>
)}
{p.technologies.length > 0 && (
<div className="flex flex-wrap gap-2 mt-auto">
{p.technologies.map((t) => (
<span
key={t.id}
style={{
fontSize: 10.5,
fontWeight: 600,
padding: "3px 9px",
borderRadius: 99,
background: tagBg,
color: t.color ?? sub,
letterSpacing: "0.01em",
}}
>
{t.name}
</span>
))}
</div>
)}
</div>
</Link>
</motion.article>
);
})}
</div>
)}
</div>
</section>
</main>
);
}
export function PortafolioPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted)
return (
<div
className="flex min-h-dvh items-center justify-center"
style={{ background: bg }}
>
<Loader />
</div>
);
return <PortafolioContent />;
}
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { usePortfolio } from "@/app/(landing)/portafolio/services/portfolio";
import { assetUrl } from "@/app/(landing)/services/api";
import Loader from "@/app/(landing)/components/Loader";
function siteHref(url: string) {
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
}
function siteLabel(url: string) {
const host = url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
return /^www\./i.test(host) ? host : `www.${host}`;
}
function renderRichText(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("# "))
return (
<h2 key={i} style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--foreground)", marginTop: "2rem", marginBottom: "0.5rem", lineHeight: 1.3 }}>
{line.slice(2)}
</h2>
);
if (line.startsWith("## "))
return (
<h3 key={i} style={{ fontSize: "1.15rem", fontWeight: 600, color: "var(--foreground)", marginTop: "1.5rem", marginBottom: "0.4rem" }}>
{line.slice(3)}
</h3>
);
if (line.startsWith("- "))
return (
<li key={i} style={{ color: "var(--muted-foreground)", lineHeight: 1.8, marginLeft: "1.2rem", listStyleType: "disc" }}>
{line.slice(2)}
</li>
);
if (line.trim() === "") return <br key={i} />;
return (
<p key={i} style={{ color: "var(--muted-foreground)", lineHeight: 1.85, marginBottom: "0.25rem" }}>
{line}
</p>
);
});
}
export default function CaseStudyPage() {
const params = useParams();
const router = useRouter();
const id = typeof params.id === "string" ? params.id : (params.id?.[0] ?? "");
const { data: projects, isLoading, isError } = usePortfolio();
const project = projects?.find((p) => p.id === id);
const [activeImage, setActiveImage] = useState(0);
useEffect(() => {
if (project) document.title = `${project.title} | Portafolio`;
}, [project]);
if (isLoading) return <Loader className="py-40" />;
if (isError || !project) {
return (
<main style={{ minHeight: "80vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 16 }}>
<p style={{ color: "var(--muted-foreground)", fontSize: 14, fontFamily: "monospace", letterSpacing: "0.1em" }}>
CASO NO ENCONTRADO
</p>
<button
onClick={() => router.push("/portafolio")}
style={{ color: "#3b82f6", fontSize: 14, textDecoration: "underline", background: "none", border: "none", cursor: "pointer" }}
>
Volver al portafolio
</button>
</main>
);
}
const images = [...project.images].sort((a, b) => a.order - b.order);
const cover = images.find((img) => img.isCover) ?? images[0];
const gallery = images.length > 0 ? images : cover ? [cover] : [];
const activeUrl = assetUrl(gallery[activeImage]?.url ?? cover?.url);
return (
<main id="main-content">
{/* Hero */}
<section style={{ background: "var(--background)", paddingTop: "140px", paddingBottom: "60px" }} className="px-8 md:px-14">
<div className="mx-auto" style={{ maxWidth: "900px" }}>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
{/* Volver */}
<Link
href="/portafolio"
style={{ display: "inline-flex", alignItems: "center", gap: 8, marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "#3b82f6", textDecoration: "none" }}
>
← VOLVER AL PORTAFOLIO
</Link>
{project.clientCompanyName && (
<div style={{ marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
{project.clientCompanyName.toUpperCase()}
</div>
)}
{/* Título */}
<h1 style={{ fontSize: "clamp(1.8rem, 4vw, 2.8rem)", fontWeight: 700, color: "var(--foreground)", lineHeight: 1.2, marginBottom: 20 }}>
{project.title}
</h1>
{/* Resumen */}
{project.publicSummary && (
<p style={{ fontSize: "1.1rem", color: "var(--muted-foreground)", lineHeight: 1.7, marginBottom: 28, borderLeft: "3px solid var(--border)", paddingLeft: 16 }}>
{project.publicSummary}
</p>
)}
{/* Meta: tecnologías + sitio */}
<div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap", paddingTop: 20, borderTop: "1px solid var(--border)" }}>
{project.technologies.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{project.technologies.map((t) => (
<span
key={t.id}
style={{
fontSize: 10.5, fontWeight: 600, padding: "3px 9px", borderRadius: 99,
background: "rgba(59,130,246,0.08)", color: t.color ?? "var(--muted-foreground)", letterSpacing: "0.01em",
}}
>
{t.name}
</span>
))}
</div>
)}
{project.siteUrl && (
<a
href={siteHref(project.siteUrl)}
target="_blank"
rel="noopener noreferrer"
style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 13, color: "#3b82f6", textDecoration: "none", marginLeft: "auto" }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20Z" />
</svg>
{siteLabel(project.siteUrl)}
</a>
)}
</div>
</motion.div>
</div>
</section>
{/* Galería */}
{activeUrl && (
<div style={{ background: "var(--muted)", borderBottom: "1px solid var(--border)" }}>
<div className="mx-auto" style={{ maxWidth: "1100px", padding: "0 2rem" }}>
<motion.div
key={activeUrl}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
style={{ overflow: "hidden", borderRadius: 4, aspectRatio: "16/7", marginTop: -1 }}
>
<img src={activeUrl} alt={project.title} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</motion.div>
{gallery.length > 1 && (
<div style={{ display: "flex", gap: 10, padding: "16px 0 24px" }}>
{gallery.map((img, i) => (
<button
key={img.id}
onClick={() => setActiveImage(i)}
style={{
width: 72, height: 48, borderRadius: 4, overflow: "hidden", padding: 0, cursor: "pointer",
border: i === activeImage ? "2px solid #3b82f6" : "2px solid transparent",
opacity: i === activeImage ? 1 : 0.6,
}}
>
<img src={assetUrl(img.url)} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
</button>
))}
</div>
)}
</div>
</div>
)}
{/* Caso de estudio + solución */}
<section style={{ background: "var(--background)", padding: "80px 0" }} className="px-8 md:px-14">
<motion.div
className="mx-auto"
style={{ maxWidth: "780px" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
{project.caseStudy && (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: 4, padding: "48px 52px", fontSize: "1rem", lineHeight: 1.85, marginBottom: project.solution ? 32 : 0 }}>
<span style={{ fontFamily: "monospace", fontSize: 10, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
CASO DE ESTUDIO
</span>
<div style={{ marginTop: 8 }}>{renderRichText(project.caseStudy)}</div>
</div>
)}
{project.solution && (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: 4, padding: "48px 52px", fontSize: "1rem", lineHeight: 1.85 }}>
<span style={{ fontFamily: "monospace", fontSize: 10, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
SOLUCIÓN
</span>
<div style={{ marginTop: 8 }}>{renderRichText(project.solution)}</div>
</div>
)}
{!project.caseStudy && !project.solution && (
<p style={{ color: "var(--muted-foreground)", fontSize: 14 }}>
Aún no hay detalle publicado para este caso.
</p>
)}
</motion.div>
</section>
</main>
);
}
@@ -0,0 +1,74 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import CaseStudyPage from "./CaseStudyPage";
import { API_URL, assetUrl } from "@/app/(landing)/services/api";
import type { PortfolioProject } from "@/app/(landing)/portafolio/types/portfolio";
const BASE_URL = "https://rynext.com.mx";
/**
* null = la API respondió pero el caso no existe/no está publicado (404 real).
* undefined = no se pudo consultar la API (red, TLS local, etc.) — no es un 404,
* se deja que el cliente (CaseStudyPage) resuelva con su propio fetch.
*/
async function getProject(id: string): Promise<PortfolioProject | null | undefined> {
try {
const res = await fetch(`${API_URL}/api/portfolio/public`, {
next: { revalidate: 3600 },
});
if (!res.ok) return undefined;
const projects = (await res.json()) as PortfolioProject[];
return projects.find((p) => p.id === id) ?? null;
} catch {
return undefined;
}
}
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const project = await getProject(id);
if (!project) {
return {};
}
const url = `${BASE_URL}/portafolio/${project.id}`;
const cover =
project.images.find((img) => img.isCover) ??
[...project.images].sort((a, b) => a.order - b.order)[0];
const image = assetUrl(cover?.url);
return {
title: `${project.title} | Portafolio | Rynext Technologies`,
description: project.publicSummary,
alternates: { canonical: url },
openGraph: {
title: project.title,
description: project.publicSummary,
type: "article",
url,
images: image
? [{ url: image, width: 1200, height: 630, alt: project.title }]
: undefined,
},
twitter: {
card: "summary_large_image",
title: project.title,
description: project.publicSummary,
images: image ? [image] : undefined,
},
};
}
export default async function Page({ params }: Props) {
const { id } = await params;
const project = await getProject(id);
// Solo 404 cuando confirmamos que no existe; si la API no respondió,
// dejamos que CaseStudyPage lo resuelva del lado del cliente.
if (project === null) notFound();
return <CaseStudyPage />;
}
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
File renamed without changes.
@@ -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,
});
}
@@ -9,21 +9,20 @@ export function PrivacidadPage() {
return (
<main
id="main-content"
style={{ background: "#ffffff", minHeight: "100vh" }}
style={{ background: "var(--background)", minHeight: "100vh" }}
>
<section
style={{
paddingTop: "140px",
paddingBottom: "100px",
borderBottom: "1px solid #dddbd5",
}}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-3xl">
<h1 className="text-headline mb-4" style={{ color: "#0a0a0a" }}>
<h1 className="text-headline mb-4" style={{ color: "var(--foreground)" }}>
Aviso de Privacidad Integral
</h1>
<p className="text-sm mb-16" style={{ color: "#aaa8a4" }}>
<p className="text-sm mb-16" style={{ color: "var(--muted-foreground)" }}>
Última actualización: agosto de 2026
</p>
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
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}`);
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>;
}
@@ -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>
);
}
File renamed without changes.
@@ -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);
}
@@ -1,5 +1,3 @@
import type React from 'react';
export interface ThemeProps {
isNavyBg: boolean;
}
@@ -22,7 +20,7 @@ export interface ServiceItem {
title: string;
description: string;
features: string[];
icon: React.ElementType;
icon: string;
accent: 'blue' | 'red';
gridCols: string;
}
-47
View File
@@ -1,47 +0,0 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
export function NotFoundPage() {
useEffect(() => {
document.title = '404 — Página no encontrada | Rynext Technologies';
}, []);
return (
<main
id="main-content"
className="flex min-h-screen flex-col items-center justify-center px-8 text-center"
style={{ background: '#f9f9f7' }}
>
<p
className="font-display font-black mb-6"
style={{ fontSize: 'clamp(6rem, 20vw, 12rem)', lineHeight: 1, color: '#3b82f6', letterSpacing: '-0.06em', opacity: 0.15 }}
aria-hidden="true"
>
404
</p>
<div style={{ marginTop: '-2rem' }}>
<h1
className="font-display font-black mb-4"
style={{ fontSize: 'clamp(1.5rem, 4vw, 2.5rem)', color: '#0a0a0a', letterSpacing: '-0.03em' }}
>
Página no encontrada.
</h1>
<p className="text-base mb-10 max-w-sm mx-auto" style={{ color: '#6a6866', lineHeight: 1.7 }}>
La página que buscas no existe o fue movida. Regresa al inicio para continuar.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-3 text-sm font-bold text-white transition hover:opacity-90"
style={{ background: '#3b82f6', borderRadius: 6 }}
>
<ArrowLeft size={15} />
Volver al inicio
</Link>
</div>
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
/* Estilos propios del panel admin (Mod-Rynext).
No redefine tokens compartidos (--background, --foreground, --primary, etc.)
para no chocar con el resto de la Landing. */
:root {
--nav-bg: #001f5b;
--nav-hover: rgba(255, 255, 255, 0.08);
--nav-active: rgba(255, 255, 255, 0.15);
--nav-border: rgba(255, 255, 255, 0.12);
}
/* Dropdown animation */
@keyframes dropdown-in {
from {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes dropdown-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
}
@keyframes dropdown-in-up {
from {
opacity: 0;
transform: translateY(calc(-100% + 6px)) scale(0.97);
}
to {
opacity: 1;
transform: translateY(-100%) scale(1);
}
}
@keyframes dropdown-out-up {
from {
opacity: 1;
transform: translateY(-100%) scale(1);
}
to {
opacity: 0;
transform: translateY(calc(-100% + 6px)) scale(0.97);
}
}
.animate-dropdown {
animation: dropdown-in 0.15s ease-out forwards;
}
.animate-dropdown-out {
animation: dropdown-out 0.12s ease-in forwards;
}
.animate-dropdown-up {
animation: dropdown-in-up 0.15s ease-out forwards;
}
.animate-dropdown-out-up {
animation: dropdown-out-up 0.12s ease-in forwards;
}
/* Custom scrollbar (solo dentro del admin) */
.admin-root .scrollbar-thin::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.admin-root .scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.admin-root .scrollbar-thin::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 9999px;
}
.admin-root .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Hide number input spinners (solo dentro del admin) */
.admin-root input[type="number"]::-webkit-inner-spin-button,
.admin-root input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.admin-root input[type="number"] {
-moz-appearance: textfield;
}
.admin-root {
font-family: var(--font-quicksand), var(--font-dm-sans), sans-serif;
}
@@ -0,0 +1,43 @@
import type { ILoginResponse } from "@/app/admin/auth/types/Auth/Response/ILoginResponse";
import http from "@/app/admin/services/http";
export const authService = {
login: async (email: string, password: string): Promise<ILoginResponse> => {
const { data } = await http.post<ILoginResponse>("/api/auth/login", {
email,
password,
});
return data;
},
refresh: async (refreshToken: string): Promise<ILoginResponse> => {
const { data } = await http.post<ILoginResponse>("/api/auth/refresh", {
refreshToken,
});
return data;
},
logout: async (refreshToken: string): Promise<void> => {
await http.post<void>("/api/auth/logout", { refreshToken });
},
forgotPassword: async (email: string): Promise<void> => {
await http.post("/api/auth/forgot-password", { email });
},
resetPassword: async (
email: string,
code: string,
newPassword: string,
): Promise<void> => {
await http.post("/api/auth/reset-password", { email, code, newPassword });
},
requestPasswordChange: async (): Promise<void> => {
await http.post("/api/auth/request-password-change");
},
confirmPasswordChange: async (
code: string,
newPassword: string,
): Promise<void> => {
await http.post("/api/auth/confirm-password-change", { code, newPassword });
},
setInitialPassword: async (newPassword: string): Promise<void> => {
await http.post("/api/auth/set-initial-password", { newPassword });
},
};
@@ -0,0 +1,16 @@
import type { IAuthUser } from "./IAuthUser";
export interface IAuthState {
user: IAuthUser | null;
token: string | null;
expiresAt: string | null;
refreshToken: string | null;
refreshTokenExpiresAt: string | null;
isAuthenticated: boolean;
login: (
email: string,
password: string,
) => Promise<{ ok: boolean; error?: string; mustChangePassword?: boolean }>;
logout: () => Promise<void>;
refresh: () => Promise<boolean>;
}
@@ -0,0 +1,9 @@
import type { UserRole } from "./Response/IMeResponse";
export interface IAuthUser {
userId: string;
email: string;
username: string;
companyId: string;
role: UserRole;
}
@@ -0,0 +1,8 @@
export interface ILoginResponse {
token: string;
expiresAt: string;
refreshToken: string;
refreshTokenExpiresAt: string;
roles: string[];
mustChangePassword: boolean;
}
@@ -0,0 +1,9 @@
export type UserRole = "owner" | "admin" | "developer";
export interface IMeResponse {
userId: string;
email: string;
username: string;
companyId: string;
role: UserRole;
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { useParams } from "next/navigation";
import BlogEditorPage from "../../_components/BlogEditorPage";
export default function EditBlogPage() {
const params = useParams();
const id = typeof params.id === "string" ? params.id : params.id?.[0];
return <BlogEditorPage postId={id} />;
}
+8
View File
@@ -0,0 +1,8 @@
import type { Metadata } from "next";
import BlogEditor from "./_page";
export const metadata: Metadata = { title: "Editar artículo" };
export default function Page() {
return <BlogEditor />;
}
@@ -0,0 +1,558 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { z } from "zod";
import { Icon } from "@iconify/react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/app/admin/components/ui/Button";
import { Input } from "@/app/admin/components/ui/Input";
import { Textarea } from "@/app/admin/components/ui/Textarea";
import Toggle from "@/app/admin/components/Toggle";
import {
useBlogPost,
useCreateBlogPost,
useUpdateBlogPost,
useUploadBlogImage,
type CreateBlogPostRequest,
} from "@/app/admin/blog/services/blog.service";
import { assetUrl } from "@/app/admin/services/http";
import { ERR_MSG } from "@/app/admin/lib/errorMessage";
const blogPostSchema = z.object({
title: z.string().trim().min(1, "El título es requerido").max(200, "Máximo 200 caracteres"),
category: z.string().trim().min(1, "La categoría es requerida").max(80, "Máximo 80 caracteres"),
});
type BlogPostFormErrors = Partial<Record<keyof z.infer<typeof blogPostSchema>, string>>;
function toSlug(text: string) {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
}
// Replica el markdown básico tal cual lo renderiza el artículo público
// (src/app/(landing)/blog/[slug]/ArticlePage.tsx) para que el preview del
// editor no diverja del resultado real en la landing.
function renderPreviewContent(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("# "))
return (
<h2
key={i}
style={{
fontSize: "1.3rem",
fontWeight: 700,
color: "var(--foreground)",
marginTop: "1.5rem",
marginBottom: "0.4rem",
lineHeight: 1.3,
}}
>
{line.slice(2)}
</h2>
);
if (line.startsWith("## "))
return (
<h3
key={i}
style={{
fontSize: "1.05rem",
fontWeight: 600,
color: "var(--foreground)",
marginTop: "1.1rem",
marginBottom: "0.3rem",
}}
>
{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>
);
});
}
function BlogPreview({
title,
summary,
content,
category,
authorName,
imagePreview,
isPublished,
}: {
title: string;
summary: string;
content: string;
category: string;
authorName: string;
imagePreview: string | null;
isPublished: boolean;
}) {
const today = new Date()
.toLocaleDateString("es-MX", { day: "2-digit", month: "long", year: "numeric" })
.toUpperCase();
return (
<div
style={{
position: "relative",
borderRadius: 12,
overflow: "hidden",
border: "1px solid var(--border)",
background: "var(--background)",
}}
>
<span
style={{
position: "absolute",
top: 10,
left: 10,
zIndex: 1,
display: "inline-block",
background: isPublished ? "rgba(16,185,129,0.15)" : "var(--muted)",
color: isPublished ? "#10b981" : "var(--muted-foreground)",
fontSize: 10,
letterSpacing: "0.1em",
padding: "2px 8px",
borderRadius: 4,
}}
>
{isPublished ? "PUBLICADO" : "BORRADOR"}
</span>
{/* Hero — mismo layout que ArticlePage.tsx, a escala reducida */}
<section style={{ padding: "28px 28px 24px" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, marginBottom: 16, fontFamily: "monospace", fontSize: 10, letterSpacing: "0.14em", color: "#3b82f6" }}>
← VOLVER AL BLOG
</div>
<div style={{ marginBottom: 14, fontFamily: "monospace", fontSize: 10, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
{category ? category.toUpperCase() : "CATEGORÍA"}
</div>
<h1
style={{
fontSize: "1.6rem",
fontWeight: 700,
color: title ? "var(--foreground)" : "var(--muted-foreground)",
lineHeight: 1.2,
marginBottom: 14,
}}
>
{title || "Título del artículo"}
</h1>
{summary && (
<p
style={{
fontSize: "0.9rem",
color: "var(--muted-foreground)",
lineHeight: 1.7,
marginBottom: 20,
borderLeft: "3px solid var(--border)",
paddingLeft: 14,
}}
>
{summary}
</p>
)}
<div style={{ display: "flex", alignItems: "center", gap: 12, paddingTop: 14, borderTop: "1px solid var(--border)" }}>
{authorName && (
<>
<div
style={{
width: 26, height: 26, borderRadius: "50%",
background: "linear-gradient(135deg,#3b82f6,#6366f1)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontSize: 11, fontWeight: 700,
}}
>
{authorName.charAt(0).toUpperCase()}
</div>
<span style={{ fontSize: 12, fontWeight: 500, color: "var(--foreground)" }}>{authorName}</span>
<span style={{ color: "var(--border)" }}>·</span>
</>
)}
<span style={{ fontFamily: "monospace", fontSize: 10, color: "var(--muted-foreground)", letterSpacing: "0.1em" }}>
{today}
</span>
</div>
</section>
{/* Imagen de portada */}
{imagePreview && (
<div style={{ background: "var(--muted)", borderBottom: "1px solid var(--border)" }}>
<div style={{ padding: "0 16px" }}>
<div style={{ overflow: "hidden", borderRadius: 4, aspectRatio: "16/7", marginTop: -1 }}>
<img src={imagePreview} alt="Portada" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
</div>
</div>
)}
{/* Contenido */}
<section style={{ padding: "28px" }}>
<div
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 4,
padding: "24px 26px",
fontSize: "0.9rem",
lineHeight: 1.85,
}}
>
{content ? (
renderPreviewContent(content)
) : (
<p style={{ color: "var(--muted-foreground)", fontStyle: "italic", fontSize: 11, letterSpacing: "0.08em" }}>
EL CONTENIDO APARECERÁ AQUÍ...
</p>
)}
</div>
</section>
</div>
);
}
export default function BlogEditorPage({ postId }: { postId?: string }) {
const router = useRouter();
const { data: existingPost } = useBlogPost(postId ?? "");
const createPost = useCreateBlogPost();
const updatePost = useUpdateBlogPost(postId ?? "");
const uploadImage = useUploadBlogImage();
const isPending = createPost.isPending || updatePost.isPending;
const [title, setTitle] = useState("");
const [slug, setSlug] = useState("");
const [summary, setSummary] = useState("");
const [content, setContent] = useState("");
const [category, setCategory] = useState("");
const [authorName, setAuthorName] = useState("");
const [isPublished, setIsPublished] = useState(false);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [slugTouched, setSlugTouched] = useState(false);
const [errors, setErrors] = useState<BlogPostFormErrors>({});
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!existingPost) return;
setTitle(existingPost.title);
setSlug(existingPost.slug);
setSummary(existingPost.summary);
setContent(existingPost.content);
setCategory(existingPost.category);
setAuthorName(existingPost.authorName ?? "");
setIsPublished(existingPost.isPublished);
setImagePreview(assetUrl(existingPost.imageUrl) ?? null);
setSlugTouched(true);
}, [existingPost]);
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
const v = e.target.value;
setTitle(v);
if (!slugTouched) setSlug(toSlug(v));
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setPendingFile(file);
setImagePreview(URL.createObjectURL(file));
e.target.value = "";
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const result = blogPostSchema.safeParse({ title, category });
if (!result.success) {
const fieldErrors: BlogPostFormErrors = {};
for (const issue of result.error.issues) {
const key = issue.path[0] as keyof BlogPostFormErrors;
if (!fieldErrors[key]) fieldErrors[key] = issue.message;
}
setErrors(fieldErrors);
toast.error("Revisa los campos marcados en rojo");
return;
}
setErrors({});
const payload: CreateBlogPostRequest = {
title,
slug,
summary,
content,
category,
authorName: authorName || undefined,
isPublished,
};
if (postId) {
updatePost.mutate(payload, {
onSuccess: async () => {
if (pendingFile) {
await uploadImage
.mutateAsync({ postId, file: pendingFile })
.catch(() => toast.error(ERR_MSG));
}
toast.success("Artículo actualizado");
router.push("/admin/blog");
},
onError: () => toast.error(ERR_MSG),
});
} else {
createPost.mutate(payload, {
onSuccess: async (created) => {
if (pendingFile && created?.id) {
await uploadImage
.mutateAsync({ postId: created.id, file: pendingFile })
.catch(() => toast.error(ERR_MSG));
}
toast.success("Artículo creado");
router.push("/admin/blog");
},
onError: () => toast.error(ERR_MSG),
});
}
}
return (
<div className="p-6 space-y-6">
{/* Encabezado */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/admin/blog")}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Icon icon="ph:arrow-left" className="w-4 h-4"/>
Blog
</button>
<span className="text-muted-foreground">/</span>
<h1 className="text-xl font-semibold text-foreground">
{postId ? "Editar artículo" : "Nuevo artículo"}
</h1>
</div>
<div className="flex items-center gap-3">
<Toggle
value={isPublished}
onChange={setIsPublished}
label="Publicar"
description=""
/>
<Button
type="submit"
form="blog-editor-form"
disabled={isPending}
loading={isPending}
>
<Icon icon="ph:check" className="w-4 h-4 mr-1.5"/>
{postId ? "Guardar cambios" : "Crear artículo"}
</Button>
</div>
</div>
{/* Layout: editor + preview */}
<form id="blog-editor-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-2 gap-6 items-start">
{/* Panel izquierdo — formulario */}
<div className="rounded-2xl border border-border bg-card p-6 space-y-5">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{/* Imagen de portada */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1.5 uppercase tracking-wide">
Imagen de portada
</label>
{imagePreview ? (
<div
className="relative overflow-hidden rounded-xl border border-border"
style={{ aspectRatio: "16/9" }}
>
<img
src={imagePreview}
alt="Preview"
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={() => {
setImagePreview(null);
setPendingFile(null);
}}
className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-muted-foreground shadow hover:bg-white"
>
<Icon icon="ph:x" width={14}/>
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-lg bg-white/90 px-2.5 py-1.5 text-xs font-semibold text-foreground shadow hover:bg-white"
>
<Icon icon="ph:upload-simple" width={12}/> Cambiar
</button>
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
className="flex w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-muted-foreground hover:border-blue-400 hover:text-blue-500 transition-colors"
style={{ aspectRatio: "16/9" }}
>
<Icon icon="ph:upload-simple" width={22}/>
<span className="text-xs font-medium">
Subir imagen de portada
</span>
<span className="text-xs text-muted-foreground">
16:9 recomendado
</span>
</button>
)}
</div>
{/* Título */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Título *
</label>
<Input
value={title}
onChange={handleTitleChange}
placeholder="Título del artículo"
className={errors.title ? "text-base font-medium border-red-500 focus:ring-red-500/30" : "text-base font-medium"}
/>
{errors.title && <p className="mt-1 text-xs text-red-500">{errors.title}</p>}
</div>
{/* Slug */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Slug
</label>
<Input
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder="url-del-articulo"
className="font-mono text-sm"
/>
</div>
{/* Categoría + Autor */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Categoría *
</label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="ej. Tecnología"
className={errors.category ? "border-red-500 focus:ring-red-500/30" : ""}
/>
{errors.category && <p className="mt-1 text-xs text-red-500">{errors.category}</p>}
</div>
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Autor
</label>
<Input
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
placeholder="Nombre del autor"
/>
</div>
</div>
{/* Resumen */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Resumen
</label>
<Textarea
rows={2}
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="Breve descripción visible en la lista de artículos"
/>
</div>
{/* Contenido */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Contenido
</label>
<p className="text-[11px] text-muted-foreground mb-1.5">
Soporta Markdown básico: # Título, ## Subtítulo, - Lista
</p>
<Textarea
rows={18}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Escribe el contenido del artículo..."
className="font-mono text-sm"
/>
</div>
</div>
{/* Panel derecho — preview */}
<div className="space-y-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Preview
</p>
<BlogPreview
title={title}
summary={summary}
content={content}
category={category}
authorName={authorName}
imagePreview={imagePreview}
isPublished={isPublished}
/>
</div>
</div>
</form>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useRouter } from "next/navigation";
import { Icon } from "@iconify/react";
import { toast } from "sonner";
import { Table } from "@/app/admin/components/Table";
import type { TableColumn } from "@/app/admin/components/Table";
import { Button } from "@/app/admin/components/ui/Button";
import {
useBlogPosts,
useDeleteBlogPost,
type BlogPost,
} from "@/app/admin/blog/services/blog.service";
import { ERR_MSG } from "@/app/admin/lib/errorMessage";
export default function BlogPosts() {
const router = useRouter();
const { data: posts = [], isLoading } = useBlogPosts();
const deletePost = useDeleteBlogPost();
const columns: TableColumn<BlogPost>[] = [
{
key: "title", header: "Título",
cell: (p) => (
<div>
<p className="font-medium text-foreground dark:text-slate-100">{p.title}</p>
<p className="text-xs text-slate-400">{p.slug}</p>
</div>
),
},
{
key: "category", header: "Categoría",
cell: (p) => <span className="text-muted-foreground dark:text-slate-300">{p.category}</span>,
},
{
key: "isPublished", header: "Estado",
cell: (p) => p.isPublished ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700">
<Icon icon="ph:eye" className="w-3 h-3"/> Publicado
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-muted-foreground">
<Icon icon="ph:eye-slash" className="w-3 h-3"/> Borrador
</span>
),
},
{
key: "createdAt", header: "Creado",
cell: (p) => (
<span className="text-muted-foreground text-sm">
{new Date(p.createdAt).toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" })}
</span>
),
},
{
key: "actions", header: "",
cell: (p) => (
<div className="flex items-center gap-2 justify-end">
<button onClick={() => router.push(`/admin/blog/${p.id}/edit`)}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-indigo-500/10 hover:text-indigo-500 transition-colors">
<Icon icon="ph:pencil-simple" className="w-4 h-4"/>
</button>
<button onClick={() => deletePost.mutate(p.id, {
onSuccess: () => toast.success("Eliminado"),
onError: () => toast.error(ERR_MSG),
})} className="rounded-lg p-1.5 text-muted-foreground hover:bg-rose-500/10 hover:text-rose-500 transition-colors">
<Icon icon="ph:trash" className="w-4 h-4"/>
</button>
</div>
),
},
];
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Blog</h1>
</div>
<Button onClick={() => router.push("/admin/blog/new")} className="flex items-center gap-2">
<Icon icon="ph:plus" className="w-4 h-4"/> Nuevo artículo
</Button>
</div>
<Table columns={columns} data={posts} rowKey={(p) => p.id} loading={isLoading} emptyText="Sin artículos" />
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
"use client";
import BlogEditorPage from "../_components/BlogEditorPage";
export default function NewBlogPage() {
return <BlogEditorPage />;
}
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff. Show more