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

@@ -24,3 +24,29 @@ a {
color: inherit;
text-decoration: none;
}
/* 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

@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import { ProductCard } from '@/components/product-card';
import { AddToCart } from '@/components/add-to-cart';
import { getProductBySlug, searchProducts } from '@/lib/api';
import { formatRichText } from '@/lib/format-rich-text';
import { absoluteUrl, metadataTitle } from '@/lib/seo';
import { breadcrumbJsonLd, JsonLdScript, productJsonLd } from '@/lib/seo/json-ld';
@@ -26,7 +27,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const { slug } = await params;
const product = await loadProduct(slug);
const title = product.seoTitle ?? product.name;
const description = product.seoDescription ?? product.description ?? `Comprar ${product.name}`;
const description = (product.seoDescription ?? product.description ?? `Comprar ${product.name}`).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
const image = product.images?.find((item) => item.role === 'main') ?? product.images?.[0];
return {
@@ -85,13 +86,13 @@ export default async function ProductPage({ params }: PageProps) {
<h1 className="text-4xl font-bold tracking-tight text-emerald-950 md:text-5xl">
{product.name}
</h1>
<p className="text-lg leading-8 text-stone-700">
<div className="text-lg leading-8 text-stone-700">
{product.description ? (
<span dangerouslySetInnerHTML={{ __html: product.description }} />
<div className="rich-text" dangerouslySetInnerHTML={{ __html: formatRichText(product.description) }} />
) : (
'Producto del catálogo público de mercadodevida.'
)}
</p>
</div>
<dl className="grid gap-4 rounded-3xl border border-emerald-900/10 bg-white p-6 text-sm text-stone-700 sm:grid-cols-2">
<div>
<dt className="font-semibold text-emerald-950">URL pública</dt>

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('');
}