feat(F-103): completed feature

This commit is contained in:
chattie
2026-08-21 07:55:18 +02:00
parent 5177a851aa
commit c07776822d
40 changed files with 886 additions and 156 deletions

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchBrandBySlug, fetchProducts, formatPrice } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
interface Props {
params: Promise<{ slug: string }>;
@@ -92,8 +93,8 @@ export default async function BrandPage({ params }: Props) {
</h3>
{product.description && (
<div
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchCategoryBySlug, fetchProducts, fetchBrands } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
interface Props {
params: Promise<{ slug: string }>;
@@ -13,7 +14,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
if (!category) return { title: 'Categoría no encontrada' };
return {
title: category.seoTitle ?? category.name,
description: category.seoDescription ?? `${category.name} — Productos naturales y orgánicos en mercadodevida.`,
description: category.seoDescription ?? category.description ?? `${category.name} — Productos naturales y orgánicos en mercadodevida.`,
};
}
@@ -68,8 +69,8 @@ export default async function CategoryPage({ params }: Props) {
<h1 className="text-3xl font-bold text-gray-900" style={{ fontFamily: 'var(--font-heading)' }}>
{category.name}
</h1>
{category.seoDescription && (
<p className="mt-2 text-gray-600">{category.seoDescription}</p>
{(category.description || category.seoDescription) && (
<p className="mt-2 text-gray-600">{category.description || category.seoDescription}</p>
)}
<p className="mt-1 text-sm text-gray-500">{products.length} producto{products.length !== 1 ? 's' : ''}</p>
</div>
@@ -105,8 +106,8 @@ export default async function CategoryPage({ params }: Props) {
</h3>
{product.description && (
<div
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -39,14 +39,18 @@ export default async function CategoriesPage() {
{tree.map((cat, i) => {
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
const colorClass = cat.color ?? colors[i % colors.length];
const customColors = Boolean(cat.bgColor || cat.textColor);
return (
<Link key={cat.id} href={`/categories/${cat.slug}`} className="group block">
<div className={`relative overflow-hidden rounded-2xl bg-gradient-to-br ${colorClass} p-6 text-white min-h-[140px] flex flex-col justify-between`}>
<div
className={`relative overflow-hidden rounded-2xl ${customColors ? '' : `bg-gradient-to-br ${colorClass} text-white`} p-6 min-h-[140px] flex flex-col justify-between`}
style={customColors ? { backgroundColor: cat.bgColor ?? '#F1F8E9', color: cat.textColor ?? '#2D6A4F' } : undefined}
>
<div className="absolute top-4 right-4 text-5xl opacity-20">{emoji}</div>
<div>
<h2 className="text-xl font-bold group-hover:underline">{cat.name}</h2>
{cat.seoDescription && (
<p className="mt-1 text-sm text-white/80 line-clamp-2">{cat.seoDescription}</p>
<p className="mt-1 text-sm opacity-80 line-clamp-2">{cat.description || cat.seoDescription}</p>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">

View File

@@ -33,3 +33,29 @@ body {
color: var(--foreground);
font-family: var(--font-sans), system-ui, sans-serif;
}
/* Texto enriquecido de descripciones generadas por IA o editadas en el admin */
.rich-text p {
margin: 0 0 0.75rem;
}
.rich-text p:last-child {
margin-bottom: 0;
}
.rich-text ul,
.rich-text ol {
margin: 0 0 0.75rem;
padding-left: 1.25rem;
}
.rich-text ul {
list-style: disc;
}
.rich-text ol {
list-style: decimal;
}
.rich-text li {
margin-bottom: 0.25rem;
}
.rich-text strong {
font-weight: 600;
color: inherit;
}

View File

@@ -13,6 +13,7 @@ import {
} from '@/lib/api';
import ProductAddToCart from '@/components/cart/ProductAddToCart';
import ProductAttributes from '@/components/product/ProductAttributes';
import { formatRichText } from '@/lib/format-rich-text';
interface Props {
params: Promise<{ slug: string }>;
@@ -199,8 +200,8 @@ export default async function ProductPage({ params }: Props) {
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Descripción</h2>
<div
className="text-gray-600 leading-relaxed prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-600 leading-relaxed rich-text prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
</div>
)}

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
export const metadata: Metadata = {
title: 'Productos — mercadodevida',
@@ -73,8 +74,8 @@ export default async function ProductsPage() {
</h3>
{product.description && (
<div
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { fetchProducts, fetchBrands, fetchCategories, formatPrice } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
interface Props {
searchParams: Promise<{ q?: string; brand?: string; category?: string }>;
@@ -108,8 +109,8 @@ export default async function SearchPage({ searchParams }: Props) {
</h3>
{product.description && (
<div
className="text-gray-500 text-xs mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-xs mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -43,11 +43,15 @@ export default async function CategoriesGrid() {
const totalProducts = cat.children ? cat.children.length * 5 : 5;
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
const colorClass = cat.color ?? colors[i % colors.length];
const customColors = Boolean(cat.bgColor || cat.textColor);
return (
<Link key={cat.id} href={`/categories/${cat.slug}`}>
<div className={`p-5 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md bg-white text-center ${colorClass}`}>
<div
className={`p-5 rounded-xl border border-gray-200 hover:border-[#70ad47] transition-all hover:shadow-md text-center ${customColors ? 'bg-white' : `bg-white ${colorClass}`}`}
style={customColors ? { backgroundColor: cat.bgColor ?? '#F1F8E9', color: cat.textColor ?? '#2D6A4F' } : undefined}
>
<div className="text-4xl mb-2">{emoji}</div>
<h3 className="font-semibold text-gray-900 text-sm">{cat.name}</h3>
<h3 className="font-semibold text-sm">{cat.name}</h3>
<p className="text-xs text-gray-500 mt-1">{totalProducts} productos</p>
{cat.children && cat.children.length > 0 && (
<div className="mt-2 flex flex-wrap justify-center gap-1">

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import type { Product } from '@/types/api';
import { fetchProducts } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
function formatPrice(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
@@ -46,8 +47,8 @@ export default async function FeaturedProducts() {
<h3 className="font-semibold text-gray-900 group-hover:text-[#70ad47] transition-colors line-clamp-2">{product.name}</h3>
{product.description && (
<div
className="text-gray-500 text-sm mt-1 line-clamp-2 prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
className="text-gray-500 text-sm mt-1 line-clamp-2 rich-text prose prose-xs max-w-none"
dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }}
/>
)}
<div className="mt-3 pr-2">

View File

@@ -0,0 +1,44 @@
/**
* Ensures a description renders nicely as HTML in the storefront.
* - If it already contains HTML tags, it is returned as-is (sanitized).
* - Plain text (e.g. older AI-generated descriptions) is converted into
* paragraphs and lists so it doesn't render as a wall of text.
*/
export function formatRichText(raw: string | null | undefined): string {
const text = (raw ?? '').trim();
if (!text) return '';
if (/<\s*(p|ul|ol|li|h[1-6]|div|br|strong|em|b|i|a|table|blockquote)\b/i.test(text)) {
return text
.replace(/<\s*(script|style|iframe|object|embed|form)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*(script|style|iframe|object|embed|form|link|meta)\b[^>]*\/?>/gi, '')
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
.replace(/javascript\s*:/gi, '');
}
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const blocks: string[] = [];
let listItems: string[] = [];
const flushList = () => {
if (listItems.length) {
blocks.push(`<ul>${listItems.map((li) => `<li>${li}</li>`).join('')}</ul>`);
listItems = [];
}
};
for (const paragraph of text.split(/\n{2,}/)) {
const lines = paragraph.split('\n').map((l) => l.trim()).filter(Boolean);
if (!lines.length) continue;
if (lines.every((l) => /^[-*•·]\s+/.test(l))) {
for (const line of lines) listItems.push(escape(line.replace(/^[-*•·]\s+/, '')));
continue;
}
flushList();
const inline = lines.map((line) => escape(line.replace(/^[-*•·]\s+/, ''))).join('<br/>');
blocks.push(`<p>${inline.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')}</p>`);
}
flushList();
return blocks.join('');
}

View File

@@ -51,6 +51,8 @@ export interface Category {
isParent?: boolean;
emoji?: string;
color?: string;
bgColor?: string;
textColor?: string;
children?: Category[];
}