feat(F-071): completed feature

This commit is contained in:
chattie
2026-08-19 19:04:41 +02:00
parent 16e4e86fbe
commit cf67f51d07
22 changed files with 495 additions and 74 deletions

View File

@@ -17,7 +17,7 @@ function CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Cat
<tr className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{cat.children && cat.children.length > 0 && <span className="text-gray-300">📁</span>}
{cat.children && cat.children.length > 0 && <span className="text-gray-300">{cat.emoji ?? '📁'}</span>}
<div>
<p className="text-sm font-medium text-gray-900">{cat.name}</p>
<p className="text-xs text-gray-400">/{cat.slug}</p>
@@ -59,6 +59,8 @@ interface FormState {
description: string;
parentId: string | null;
isParent: boolean;
emoji: string;
color: string;
seoTitle: string;
seoTitleManual: boolean;
seoDescription: string;
@@ -72,6 +74,8 @@ const EMPTY_FORM: FormState = {
description: '',
parentId: null,
isParent: false,
emoji: '',
color: '',
seoTitle: '',
seoTitleManual: false,
seoDescription: '',
@@ -140,6 +144,8 @@ export default function CategoriesPage() {
description: cat.description ?? '',
parentId: cat.parentId,
isParent: cat.isParent ?? false,
emoji: cat.emoji ?? '',
color: cat.color ?? '',
seoTitle: (cat as any).seoTitle ?? '',
seoTitleManual: true,
seoDescription: (cat as any).seoDescription ?? '',
@@ -167,6 +173,8 @@ export default function CategoriesPage() {
description: form.description || undefined,
parentId: form.parentId,
isParent: form.isParent,
emoji: form.emoji || undefined,
color: form.color || undefined,
seoTitle: form.seoTitle || undefined,
seoDescription: form.seoDescription || undefined,
};
@@ -286,6 +294,35 @@ export default function CategoriesPage() {
/>
</div>
{/* Emoji y color de la tarjeta */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Emoji</label>
<div className="flex items-center gap-2">
<input
value={form.emoji}
onChange={(e) => setForm((f) => ({ ...f, emoji: e.target.value }))}
maxLength={10}
placeholder="🥜"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm text-2xl text-center focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
{form.emoji && <span className="text-3xl">{form.emoji}</span>}
</div>
<p className="text-xs text-gray-400 mt-1">Emoji identificativo que aparecerá en las tarjetas de categoría.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Color de tarjeta</label>
<input
value={form.color}
onChange={(e) => setForm((f) => ({ ...f, color: e.target.value }))}
maxLength={200}
placeholder="bg-[#70ad47]/10 text-[#70ad47] ó from-[#70ad47] to-[#40916C]"
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
/>
<p className="text-xs text-gray-400 mt-1">Clase Tailwind para el color de la tarjeta (fondo o gradiente).</p>
</div>
</div>
{/* SEO Title */}
<div>
<div className="flex items-center justify-between mb-1">

View File

@@ -161,6 +161,8 @@ export interface Category {
imageUrl?: string;
description?: string;
isParent?: boolean;
emoji?: string;
color?: string;
children?: Category[];
}

File diff suppressed because one or more lines are too long

View File

@@ -36,10 +36,13 @@ export default async function CategoriesPage() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 justify-center">
{tree.map((cat, i) => (
<Link key={cat.id} href={`/categories/${cat.slug}`} className="group block">
<div className={`relative overflow-hidden rounded-2xl bg-gradient-to-br ${colors[i % colors.length]} p-6 text-white min-h-[140px] flex flex-col justify-between`}>
<div className="absolute top-4 right-4 text-5xl opacity-20">{icons[cat.slug] ?? '📦'}</div>
{tree.map((cat, i) => {
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
const colorClass = cat.color ?? colors[i % colors.length];
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="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 && (
@@ -60,7 +63,8 @@ export default async function CategoriesPage() {
</div>
</div>
</Link>
))}
);
})}
</div>
</div>
);

View File

@@ -41,10 +41,12 @@ export default async function CategoriesGrid() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{categories.map((cat, i) => {
const totalProducts = cat.children ? cat.children.length * 5 : 5;
const emoji = cat.emoji ?? icons[cat.slug] ?? '📦';
const colorClass = cat.color ?? colors[i % colors.length];
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 ${colors[i % colors.length]}`}>
<div className="text-4xl mb-2">{icons[cat.slug] ?? '📦'}</div>
<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="text-4xl mb-2">{emoji}</div>
<h3 className="font-semibold text-gray-900 text-sm">{cat.name}</h3>
<p className="text-xs text-gray-500 mt-1">{totalProducts} productos</p>
{cat.children && cat.children.length > 0 && (

View File

@@ -48,6 +48,9 @@ export interface Category {
seoDescription?: string;
imageUrl?: string;
description?: string;
isParent?: boolean;
emoji?: string;
color?: string;
children?: Category[];
}

View File

@@ -0,0 +1,21 @@
/* eslint-disable camelcase */
/**
* F-071: Adds emoji and color columns to categories_categories.
* Allows admin to set a representative emoji and card color per category.
* Both columns are nullable — existing categories remain unaffected.
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE categories_categories
ADD COLUMN IF NOT EXISTS emoji VARCHAR(10) NULL DEFAULT NULL,
ADD COLUMN IF NOT EXISTS color TEXT NULL DEFAULT NULL
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE categories_categories
DROP COLUMN IF EXISTS emoji,
DROP COLUMN IF EXISTS color
`);
};

View File

@@ -42,6 +42,8 @@ const newCategorySchema = z.object({
seoTitle: z.string().min(1).max(200).optional().nullable(),
seoDescription: z.string().min(1).max(500).optional().nullable(),
isParent: z.boolean().optional(),
emoji: z.string().max(10).optional().nullable(),
color: z.string().max(200).optional().nullable(),
});
const categoryPatchSchema = newCategorySchema
@@ -182,6 +184,8 @@ function serializeCategory(category: Category) {
seoTitle: category.seoTitle,
seoDescription: category.seoDescription,
isParent: category.isParent,
emoji: category.emoji,
color: category.color,
createdAt: category.createdAt.toISOString(),
updatedAt: category.updatedAt.toISOString(),
};

View File

@@ -10,6 +10,8 @@ export interface Category {
seoDescription: string | null;
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
isParent: boolean;
emoji?: string | null;
color?: string | null;
createdAt: Date;
updatedAt: Date;
}
@@ -21,6 +23,8 @@ export interface NewCategory {
seoTitle?: string | null;
seoDescription?: string | null;
isParent?: boolean;
emoji?: string | null;
color?: string | null;
}
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */

View File

@@ -11,6 +11,8 @@ interface CategoryRow {
seo_title: string | null;
seo_description: string | null;
is_parent: boolean;
emoji: string | null;
color: string | null;
created_at: Date;
updated_at: Date;
}
@@ -24,6 +26,8 @@ const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
['seoTitle', 'seo_title'],
['seoDescription', 'seo_description'],
['isParent', 'is_parent'],
['emoji', 'emoji'],
['color', 'color'],
];
export class PgCategoryRepository implements CategoryRepository {
@@ -57,8 +61,8 @@ export class PgCategoryRepository implements CategoryRepository {
async create(input: NewCategory): Promise<Category> {
try {
const result = await this.pool.query<CategoryRow>(
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, is_parent)
VALUES ($1, $2, $3, $4, $5, $6)
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, is_parent, emoji, color)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
input.parentId ?? null,
@@ -67,6 +71,8 @@ export class PgCategoryRepository implements CategoryRepository {
input.seoTitle ?? null,
input.seoDescription ?? null,
input.isParent ?? false,
input.emoji ?? null,
input.color ?? null,
],
);
const row = result.rows[0];
@@ -150,6 +156,8 @@ function toCategory(row: CategoryRow): Category {
seoTitle: row.seo_title,
seoDescription: row.seo_description,
isParent: row.is_parent,
emoji: row.emoji,
color: row.color,
createdAt: row.created_at,
updatedAt: row.updated_at,
};

View File

@@ -69,7 +69,7 @@ export default async function CategoryPage({ params }: PageProps) {
Categoría
</p>
<h1 className="text-4xl font-bold tracking-tight text-emerald-950 md:text-5xl">
{category.name}
{category.emoji ? `${category.emoji} ${category.name}` : category.name}
</h1>
<p className="text-lg leading-8 text-stone-700">
{category.seoDescription ?? 'Descubrí productos seleccionados dentro de esta categoría.'}

View File

@@ -20,6 +20,8 @@ export interface CategoryDto {
url: string;
seoTitle: string | null;
seoDescription: string | null;
emoji?: string | null;
color?: string | null;
createdAt: string;
updatedAt: string;
}

File diff suppressed because one or more lines are too long