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

+190
View File
@@ -0,0 +1,190 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import Loader from '@/app/(landing)/components/Loader';
import { useBlogPosts } from '@/app/(landing)/blog/services/blog';
const fadeUp = {
hidden: { opacity: 0, y: 28 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
},
};
function BlogContent() {
const { data, isLoading } = useBlogPosts();
useEffect(() => {
document.title = "Blog | Rynext Technologies";
}, []);
const posts = (data ?? []).map((p) => ({
id: p.id,
category: p.category.toUpperCase(),
title: p.title,
summary: p.summary,
publishedAt: p.publishedAt,
imageUrl: p.imageUrl,
slug: p.slug,
}));
return (
<main id="main-content">
<section
style={{
background: "var(--background)",
paddingTop: "120px",
paddingBottom: "16px",
}}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-7xl text-center">
<motion.h1
className="text-2xl md:text-3xl font-bold"
style={{ color: "var(--foreground)" }}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={fadeUp}
>
Artículos
</motion.h1>
</div>
</section>
<section
style={{
background: "var(--background)",
padding: "0 0 100px",
minHeight: "60vh",
}}
className="px-8 md:px-14"
>
<div className="mx-auto max-w-7xl">
{isLoading ? (
<Loader className="py-20" />
) : posts.length > 0 ? (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((art, i) => (
<motion.article
key={art.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
viewport={{ once: true, margin: "-40px" }}
transition={{
duration: 0.55,
delay: i * 0.08,
ease: [0.22, 1, 0.36, 1],
}}
style={{
borderRadius: 14,
border: "1px solid var(--border)",
background: "var(--card)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
opacity: art.slug ? 1 : 0.82,
}}
>
<Link
href={art.slug ? `/blog/${art.slug}` : "#"}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
textDecoration: "none",
color: "inherit",
pointerEvents: art.slug ? "auto" : "none",
}}
>
<div
style={{
aspectRatio: "16/9",
overflow: "hidden",
background: "rgba(0,0,0,0.03)",
borderBottom: "1px solid var(--border)",
}}
>
{art.imageUrl ? (
<img
src={art.imageUrl}
alt={art.title}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--muted-foreground)" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="m21 15-5-5L5 21" />
</svg>
</div>
)}
</div>
<div
style={{
padding: "24px 24px 28px",
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
{art.category && (
<span
className="text-xs font-semibold uppercase tracking-wider mb-2 block"
style={{ color: "#3b82f6" }}
>
{art.category}
</span>
)}
<h2
className="font-display font-bold mb-3"
style={{ fontSize: "1.1rem", color: "var(--foreground)", letterSpacing: "-0.01em" }}
>
{art.title}
</h2>
<p
className="text-sm leading-relaxed mb-5"
style={{ color: "var(--muted-foreground)", flex: 1 }}
>
{art.summary}
</p>
<p
className="text-xs mt-auto"
style={{ color: "var(--muted-foreground)" }}
>
{art.publishedAt
? new Date(art.publishedAt).toLocaleDateString(
"es-MX",
{ day: "2-digit", month: "long", year: "numeric" },
)
: "Próximamente"}
</p>
</div>
</Link>
</motion.article>
))}
</div>
) : (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Sin artículos publicados.
</p>
)}
</div>
</section>
</main>
);
}
export function BlogPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <BlogContent />;
}
@@ -0,0 +1,316 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { useBlogPost } from '@/app/(landing)/blog/services/blog';
import { assetUrl } from '@/app/(landing)/services/api';
import Loader from '@/app/(landing)/components/Loader';
function renderContent(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("# "))
return (
<h2
key={i}
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--foreground)",
marginTop: "2rem",
marginBottom: "0.5rem",
lineHeight: 1.3,
}}
>
{line.slice(2)}
</h2>
);
if (line.startsWith("## "))
return (
<h3
key={i}
style={{
fontSize: "1.15rem",
fontWeight: 600,
color: "var(--foreground)",
marginTop: "1.5rem",
marginBottom: "0.4rem",
}}
>
{line.slice(3)}
</h3>
);
if (line.startsWith("- "))
return (
<li
key={i}
style={{
color: "var(--muted-foreground)",
lineHeight: 1.8,
marginLeft: "1.2rem",
listStyleType: "disc",
}}
>
{line.slice(2)}
</li>
);
if (line.trim() === "") return <br key={i} />;
return (
<p
key={i}
style={{
color: "var(--muted-foreground)",
lineHeight: 1.85,
marginBottom: "0.25rem",
}}
>
{line}
</p>
);
});
}
export default function ArticlePage() {
const params = useParams();
const router = useRouter();
const slug =
typeof params.slug === "string" ? params.slug : (params.slug?.[0] ?? "");
const { data: post, isLoading, isError } = useBlogPost(slug);
useEffect(() => {
if (post) document.title = `${post.title} | Blog`;
}, [post]);
if (isLoading) return <Loader className="py-40" />;
if (isError || !post) {
return (
<main
style={{
minHeight: "80vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
}}
>
<p
style={{
color: "var(--muted-foreground)",
fontSize: 14,
fontFamily: "monospace",
letterSpacing: "0.1em",
}}
>
ARTÍCULO NO ENCONTRADO
</p>
<button
onClick={() => router.push("/blog")}
style={{
color: "#3b82f6",
fontSize: 14,
textDecoration: "underline",
background: "none",
border: "none",
cursor: "pointer",
}}
>
Volver al blog
</button>
</main>
);
}
const imageUrl = assetUrl(post.imageUrl);
const publishDate = post.publishedAt
? new Date(post.publishedAt).toLocaleDateString("es-MX", {
day: "2-digit",
month: "long",
year: "numeric",
})
: null;
return (
<main id="main-content">
{/* Hero */}
<section
style={{
background: "var(--background)",
paddingTop: "140px",
paddingBottom: "60px",
}}
className="px-8 md:px-14"
>
<div className="mx-auto" style={{ maxWidth: "780px" }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Volver */}
<Link
href="/blog"
style={{ display: "inline-flex", alignItems: "center", gap: 8, marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "#3b82f6", textDecoration: "none" }}
>
← VOLVER AL BLOG
</Link>
{post.category && (
<div style={{ marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
{post.category.toUpperCase()}
</div>
)}
{/* Título */}
<h1
style={{
fontSize: "clamp(1.8rem, 4vw, 2.8rem)",
fontWeight: 700,
color: "var(--foreground)",
lineHeight: 1.2,
marginBottom: 20,
}}
>
{post.title}
</h1>
{/* Resumen */}
{post.summary && (
<p
style={{
fontSize: "1.1rem",
color: "var(--muted-foreground)",
lineHeight: 1.7,
marginBottom: 28,
borderLeft: "3px solid var(--border)",
paddingLeft: 16,
}}
>
{post.summary}
</p>
)}
{/* Meta */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
paddingTop: 20,
borderTop: "1px solid var(--border)",
}}
>
{post.authorName && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "linear-gradient(135deg,#3b82f6,#6366f1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 13,
fontWeight: 700,
}}
>
{post.authorName.charAt(0).toUpperCase()}
</div>
<span
style={{
fontSize: 14,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{post.authorName}
</span>
</div>
)}
{publishDate && (
<>
{post.authorName && (
<span style={{ color: "var(--border)" }}>·</span>
)}
<span
style={{
fontFamily: "monospace",
fontSize: 11,
color: "var(--muted-foreground)",
letterSpacing: "0.1em",
}}
>
{publishDate.toUpperCase()}
</span>
</>
)}
</div>
</motion.div>
</div>
</section>
{/* Imagen de portada */}
{imageUrl && (
<div
style={{
background: "var(--muted)",
borderBottom: "1px solid var(--border)",
}}
>
<div
className="mx-auto"
style={{ maxWidth: "1100px", padding: "0 2rem" }}
>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.15 }}
style={{
overflow: "hidden",
borderRadius: 4,
aspectRatio: "16/7",
marginTop: -1,
}}
>
<img
src={imageUrl}
alt={post.title}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</motion.div>
</div>
</div>
)}
{/* Contenido */}
<section
style={{ background: "var(--background)", padding: "80px 0" }}
className="px-8 md:px-14"
>
<motion.div
className="mx-auto"
style={{ maxWidth: "780px" }}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<div
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 4,
padding: "48px 52px",
fontSize: "1rem",
lineHeight: 1.85,
}}
>
{renderContent(post.content)}
</div>
</motion.div>
</section>
</main>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ArticlePage from "./ArticlePage";
import { API_URL, assetUrl } from '@/app/(landing)/services/api';
import type { BlogPost } from '@/app/(landing)/blog/services/blog';
const BASE_URL = "https://rynext.com.mx";
/**
* null = la API respondió que el artículo no existe o no está publicado (404 real).
* undefined = no se pudo consultar la API (red, TLS local, etc.) — no es un 404,
* se deja que el cliente (ArticlePage) resuelva con su propio fetch.
*/
async function getPost(slug: string): Promise<BlogPost | null | undefined> {
try {
const res = await fetch(`${API_URL}/api/blog/${slug}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
const post = (await res.json()) as BlogPost;
return post.isPublished ? post : null;
} catch {
return undefined;
}
}
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return {};
}
const url = `${BASE_URL}/blog/${post.slug}`;
const image = assetUrl(post.imageUrl);
return {
title: `${post.title} | Blog | Rynext Technologies`,
description: post.summary,
alternates: { canonical: url },
openGraph: {
title: post.title,
description: post.summary,
type: "article",
url,
publishedTime: post.publishedAt,
images: image
? [{ url: image, width: 1200, height: 630, alt: post.title }]
: undefined,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.summary,
images: image ? [image] : undefined,
},
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
// Solo 404 cuando confirmamos que no existe/no está publicado; si la API
// no respondió, dejamos que ArticlePage lo resuelva del lado del cliente.
if (post === null) notFound();
return <ArticlePage />;
}
+9
View File
@@ -0,0 +1,9 @@
import Loader from '@/app/(landing)/components/Loader';
export default function Loading() {
return (
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
<Loader />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import { BlogPage } from './BlogPage';
export const metadata: Metadata = {
title: 'Blog | Rynext Technologies',
description: 'Artículos sobre arquitectura de software, sistemas empresariales y tecnología. Perspectivas del equipo de Rynext Technologies.',
alternates: { canonical: 'https://rynext.com.mx/blog' },
};
export default function Page() {
return <BlogPage />;
}
+32
View File
@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from '@/app/(landing)/services/api';
export interface BlogPost {
id: string;
title: string;
slug: string;
summary: string;
content: string;
category: string;
imageUrl?: string;
authorName?: string;
isPublished: boolean;
publishedAt?: string;
}
export function useBlogPosts() {
return useQuery<BlogPost[]>({
queryKey: ["blog"],
queryFn: () => apiFetch<BlogPost[]>("/api/blog"),
staleTime: 1000 * 60 * 5,
});
}
export function useBlogPost(slug: string) {
return useQuery<BlogPost>({
queryKey: ["blog", slug],
queryFn: () => apiFetch<BlogPost>(`/api/blog/${slug}`),
staleTime: 1000 * 60 * 5,
enabled: !!slug,
});
}
+12
View File
@@ -0,0 +1,12 @@
export interface BlogPost {
id: string;
title: string;
slug: string;
summary: string;
content: string;
category: string;
imageUrl?: string;
authorName?: string;
isPublished: boolean;
publishedAt?: string;
}