feat(F-103): completed feature
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
44
project/frontend/src/lib/format-rich-text.ts
Normal file
44
project/frontend/src/lib/format-rich-text.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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('');
|
||||
}
|
||||
@@ -51,6 +51,8 @@ export interface Category {
|
||||
isParent?: boolean;
|
||||
emoji?: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
textColor?: string;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user