feat(F-071): completed feature
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface Category {
|
||||
seoDescription?: string;
|
||||
imageUrl?: string;
|
||||
description?: string;
|
||||
isParent?: boolean;
|
||||
emoji?: string;
|
||||
color?: string;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
|
||||
21
project/migrations/030_category_emoji_color.js
Normal file
21
project/migrations/030_category_emoji_color.js
Normal 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
|
||||
`);
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user