This commit is contained in:
Emmanuel Rojas committed 2026-09-18 00:15:28 -05:00
1 parent d7f584059e
commit 409d9455f6
17 files changed
+412 -171

No files matched your search

+25 -21
View File
@@ -1,4 +1,5 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import Loader from '../../components/Loader';
@@ -65,28 +66,31 @@ function BlogContent() {
<motion.article key={art.id}
initial="hidden" whileInView="visible" viewport={{ once: true }}
variants={{ hidden: { opacity: 0, y: 24 }, visible: { opacity: 1, y: 0, transition: { duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1] } } }}
style={{ background: '#ffffff', border: '1px solid #dddbd5', borderRadius: 4, overflow: 'hidden', display: 'flex', flexDirection: 'column', opacity: art.slug ? 1 : 0.82 }}
style={{ background: '#ffffff', border: '1px solid #dddbd5', borderRadius: 4, overflow: 'hidden', display: 'flex', flexDirection: 'column', opacity: art.slug ? 1 : 0.82, cursor: art.slug ? 'pointer' : 'default' }}
>
<div style={{ aspectRatio: '16/9', background: '#f0efec', overflow: 'hidden', position: 'relative' }}>
{art.imageUrl && (
<img src={art.imageUrl} alt={art.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span style={{ position: 'absolute', top: 14, left: 14, background: '#3b82f6', color: '#fff', fontFamily: 'monospace', fontSize: 10, letterSpacing: '0.12em', padding: '3px 8px', borderRadius: 2 }}>
{art.category}
</span>
</div>
<div style={{ padding: '28px 28px 24px', display: 'flex', flexDirection: 'column', gap: 12, flexGrow: 1 }}>
<h2 className="font-display font-bold text-lg leading-snug" style={{ color: '#0a0a0a' }}>{art.title}</h2>
<p className="text-sm leading-relaxed" style={{ color: '#6a6866', flexGrow: 1 }}>{art.summary}</p>
<p className="text-mono-label" style={{ color: '#aaa8a4', letterSpacing: '0.12em', marginTop: 4 }}>
{art.publishedAt
? new Date(art.publishedAt).toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' })
: 'Próximamente'}
</p>
</div>
<Link href={art.slug ? `/blog/${art.slug}` : '#'}
style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, textDecoration: 'none', color: 'inherit', pointerEvents: art.slug ? 'auto' : 'none' }}>
<div style={{ aspectRatio: '16/9', background: '#f0efec', overflow: 'hidden', position: 'relative' }}>
{art.imageUrl && (
<img src={art.imageUrl} alt={art.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span style={{ position: 'absolute', top: 14, left: 14, background: '#3b82f6', color: '#fff', fontFamily: 'monospace', fontSize: 10, letterSpacing: '0.12em', padding: '3px 8px', borderRadius: 2 }}>
{art.category}
</span>
</div>
<div style={{ padding: '28px 28px 24px', display: 'flex', flexDirection: 'column', gap: 12, flexGrow: 1 }}>
<h2 className="font-display font-bold text-lg leading-snug" style={{ color: '#0a0a0a' }}>{art.title}</h2>
<p className="text-sm leading-relaxed" style={{ color: '#6a6866', flexGrow: 1 }}>{art.summary}</p>
<p className="text-mono-label" style={{ color: '#aaa8a4', letterSpacing: '0.12em', marginTop: 4 }}>
{art.publishedAt
? new Date(art.publishedAt).toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' })
: 'Próximamente'}
</p>
</div>
</Link>
</motion.article>
))}
</div>
+141
View File
@@ -0,0 +1,141 @@
'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 '../../../services/blog';
import { assetUrl } from '../../../services/api';
import Loader from '../../../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: '#0a0a0a', 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: '#1a1a1a', marginTop: '1.5rem', marginBottom: '0.4rem' }}>{line.slice(3)}</h3>;
if (line.startsWith('- '))
return <li key={i} style={{ color: '#4a4744', 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: '#4a4744', 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: '#aaa8a4', 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: '#ffffff', borderBottom: '1px solid #dddbd5', 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 }}>
{/* Breadcrumb */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 28, fontFamily: 'monospace', fontSize: 11, letterSpacing: '0.14em', color: '#aaa8a4' }}>
<Link href="/blog" style={{ color: '#3b82f6', textDecoration: 'none' }}>BLOG</Link>
<span>/</span>
<span style={{ color: '#aaa8a4', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '40ch' }}>
{post.category.toUpperCase()}
</span>
</div>
{/* Categoría */}
<span style={{ display: 'inline-block', background: '#3b82f6', color: '#fff', fontFamily: 'monospace', fontSize: 10, letterSpacing: '0.14em', padding: '3px 10px', borderRadius: 2, marginBottom: 20 }}>
{post.category.toUpperCase()}
</span>
{/* Título */}
<h1 style={{ fontSize: 'clamp(1.8rem, 4vw, 2.8rem)', fontWeight: 700, color: '#0a0a0a', lineHeight: 1.2, marginBottom: 20 }}>
{post.title}
</h1>
{/* Resumen */}
{post.summary && (
<p style={{ fontSize: '1.1rem', color: '#6a6866', lineHeight: 1.7, marginBottom: 28, borderLeft: '3px solid #dddbd5', paddingLeft: 16 }}>
{post.summary}
</p>
)}
{/* Meta */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, paddingTop: 20, borderTop: '1px solid #dddbd5' }}>
{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: '#2a2a2a' }}>{post.authorName}</span>
</div>
)}
{publishDate && (
<>
{post.authorName && <span style={{ color: '#dddbd5' }}>·</span>}
<span style={{ fontFamily: 'monospace', fontSize: 11, color: '#aaa8a4', letterSpacing: '0.1em' }}>
{publishDate.toUpperCase()}
</span>
</>
)}
</div>
</motion.div>
</div>
</section>
{/* Imagen de portada */}
{imageUrl && (
<div style={{ background: '#f0efec', borderBottom: '1px solid #dddbd5' }}>
<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: '#f5f5f3', borderBottom: '1px solid #dddbd5', 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: '#ffffff', border: '1px solid #dddbd5', borderRadius: 4, padding: '48px 52px', fontSize: '1rem', lineHeight: 1.85 }}>
{renderContent(post.content)}
</div>
{/* Volver */}
<div style={{ marginTop: 48, paddingTop: 32, borderTop: '1px solid #dddbd5' }}>
<Link href="/blog"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontFamily: 'monospace', fontSize: 11, letterSpacing: '0.14em', color: '#3b82f6', textDecoration: 'none' }}>
← VOLVER AL BLOG
</Link>
</div>
</motion.div>
</section>
</main>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import ArticlePage from './ArticlePage';
import { API_URL, assetUrl } from '../../../services/api';
import type { BlogPost } from '../../../services/blog';
const BASE_URL = 'https://rynext.com.mx';
async function getPost(slug: string): Promise<BlogPost | null> {
try {
const res = await fetch(`${API_URL}/api/blog/${slug}`, { next: { revalidate: 3600 } });
if (!res.ok) return null;
return (await res.json()) as BlogPost;
} catch {
return null;
}
}
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 || !post.isPublished) {
return { title: 'Artículo no encontrado | Blog', robots: { index: false } };
}
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);
if (!post || !post.isPublished) notFound();
return <ArticlePage />;
}
+36 -8
View File
@@ -1,12 +1,40 @@
export const dynamic = 'force-static';
import type { MetadataRoute } from 'next';
import { API_URL } from '../services/api';
import type { BlogPost } from '../services/blog';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: 'https://rynext.com.mx', changeFrequency: 'monthly', priority: 1 },
{ url: 'https://rynext.com.mx/sobre-nosotros', changeFrequency: 'monthly', priority: 0.8 },
{ url: 'https://rynext.com.mx/productos', changeFrequency: 'monthly', priority: 0.8 },
{ url: 'https://rynext.com.mx/blog', changeFrequency: 'weekly', priority: 0.6 },
{ url: 'https://rynext.com.mx/privacidad', changeFrequency: 'yearly', priority: 0.3 },
export const revalidate = 3600;
const BASE_URL = 'https://rynext.com.mx';
async function getPublishedPosts(): Promise<BlogPost[]> {
try {
const res = await fetch(`${API_URL}/api/blog`, { next: { revalidate } });
if (!res.ok) return [];
const posts = (await res.json()) as BlogPost[];
return posts.filter(p => p.isPublished && p.slug);
} catch {
return [];
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPublishedPosts();
const staticRoutes: MetadataRoute.Sitemap = [
{ url: BASE_URL, changeFrequency: 'monthly', priority: 1 },
{ url: `${BASE_URL}/sobre-nosotros`, changeFrequency: 'monthly', priority: 0.8 },
{ url: `${BASE_URL}/productos`, changeFrequency: 'monthly', priority: 0.8 },
{ url: `${BASE_URL}/portafolio`, changeFrequency: 'monthly', priority: 0.8 },
{ url: `${BASE_URL}/blog`, changeFrequency: 'weekly', priority: 0.6 },
{ url: `${BASE_URL}/privacidad`, changeFrequency: 'yearly', priority: 0.3 },
];
const blogRoutes: MetadataRoute.Sitemap = posts.map(p => ({
url: `${BASE_URL}/blog/${p.slug}`,
lastModified: p.publishedAt ? new Date(p.publishedAt) : undefined,
changeFrequency: 'monthly',
priority: 0.5,
}));
return [...staticRoutes, ...blogRoutes];
}
+25 -24
View File
@@ -2,18 +2,18 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from "../components/Loader";
import { useCompanies } from "../services/companies";
import { useTechPartners } from "../services/techPartners";
import { assetUrl } from "../services/api";
import type { ThemeProps } from "../types";
function PartnersInner({ isNavyBg }: ThemeProps) {
const { data, isLoading } = useCompanies();
const { data, isLoading } = useTechPartners();
const bg = isNavyBg ? "#071233" : "#f7f6f3";
const border = isNavyBg ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.07)";
const label = isNavyBg ? "rgba(255,255,255,0.28)" : "rgba(0,0,0,0.28)";
const partners = (data ?? []).filter((c) => c.isPartner && c.logoUrl);
const partners = (data ?? []).filter((p) => p.isActive && p.logoUrl).sort((a, b) => a.displayOrder - b.displayOrder);
if (isLoading) {
return (
@@ -49,21 +49,8 @@ function PartnersInner({ isNavyBg }: ThemeProps) {
</p>
<div className="flex flex-wrap items-center justify-center gap-8 md:gap-12">
{partners.map((p, i) => (
<motion.div
key={p.id}
title={p.name}
initial={{ opacity: 0, scale: 0.92 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{
duration: 0.4,
delay: i * 0.07,
ease: [0.22, 1, 0.36, 1],
}}
whileHover={{ scale: 1.06 }}
style={{ lineHeight: 1 }}
>
{partners.map((p, i) => {
const inner = (
<img
src={assetUrl(p.logoUrl!)}
alt={p.name}
@@ -78,18 +65,32 @@ function PartnersInner({ isNavyBg }: ThemeProps) {
transition: "filter 300ms, opacity 300ms",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLImageElement).style.filter =
"grayscale(0)";
(e.currentTarget as HTMLImageElement).style.filter = "grayscale(0)";
(e.currentTarget as HTMLImageElement).style.opacity = "1";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLImageElement).style.filter =
"grayscale(1)";
(e.currentTarget as HTMLImageElement).style.filter = "grayscale(1)";
(e.currentTarget as HTMLImageElement).style.opacity = "0.55";
}}
/>
</motion.div>
))}
);
return (
<motion.div
key={p.id}
title={p.name}
initial={{ opacity: 0, scale: 0.92 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.07, ease: [0.22, 1, 0.36, 1] }}
whileHover={{ scale: 1.06 }}
style={{ lineHeight: 1 }}
>
{p.websiteUrl
? <a href={p.websiteUrl} target="_blank" rel="noopener noreferrer">{inner}</a>
: inner}
</motion.div>
);
})}
</div>
</motion.div>
</section>
-1
View File
@@ -6,7 +6,6 @@ export async function apiFetch<T>(path: string): Promise<T> {
return res.json() as Promise<T>;
}
/** Convierte rutas relativas del API (/uploads/...) a URL absoluta */
export function assetUrl(path?: string | null): string | undefined {
if (!path) return undefined;
if (path.startsWith('http')) return path;
+9
View File
@@ -21,3 +21,12 @@ export function useBlogPosts() {
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,
});
}
-1
View File
@@ -6,7 +6,6 @@ export interface Company {
name: string;
logoUrl?: string;
showInLanding?: boolean;
isPartner?: boolean;
}
export function useCompanies() {
+20
View File
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from './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,
});
}