diff --git a/app/api/stores/[storeId]/products/filter/route.ts b/app/api/stores/[storeId]/products/filter/route.ts index e7d56ad7..f427fcef 100644 --- a/app/api/stores/[storeId]/products/filter/route.ts +++ b/app/api/stores/[storeId]/products/filter/route.ts @@ -167,6 +167,7 @@ function transformProduct(product: any) { const images = dataTransformer.transformImages(product.images, product.name); const handle = dataTransformer.createHandle(product.name); const attributes = dataTransformer.transformAttributes(product.attributes); + const tags = dataTransformer.transformTags(product.tags); return { id: product.id, @@ -180,7 +181,7 @@ function transformProduct(product: any) { status: product.status, slug: handle, featured: product.featured, - tags: product.tags, + tags: tags, createdAt: product.createdAt, updatedAt: product.updatedAt, vendor: product.supplier, diff --git a/app/store/components/product-management/products/components/form/BasicInfoSection.tsx b/app/store/components/product-management/products/components/form/BasicInfoSection.tsx index 5a17100a..27eeb3f8 100644 --- a/app/store/components/product-management/products/components/form/BasicInfoSection.tsx +++ b/app/store/components/product-management/products/components/form/BasicInfoSection.tsx @@ -2,7 +2,7 @@ import { AIGenerateButton } from '@/app/store/components/product-management/prod import { useProductDescription } from '@/app/store/components/product-management/products/hooks/useProductDescription'; import { useToast } from '@/app/store/context/ToastContext'; import type { ProductFormValues } from '@/lib/zod-schemas/product-schema'; -import { Banner, BlockStack, Button, ButtonGroup, FormLayout, Text, TextField } from '@shopify/polaris'; +import { Banner, BlockStack, Button, ButtonGroup, Card, FormLayout, Text, TextField } from '@shopify/polaris'; import { useState } from 'react'; import type { UseFormReturn } from 'react-hook-form'; import { Controller } from 'react-hook-form'; @@ -51,115 +51,117 @@ export function BasicInfoSection({ form }: BasicInfoSectionProps) { }; return ( - - - Información Básica - - - ( - - )} - /> - -
- - Descripción - - -
- {previewDescription && ( - - - {previewDescription} - - - - - - - - )} + + + + Información Básica + + ( )} /> - - - - ( - { - if (!dateString) { - field.onChange(null); - return; - } - const date = new Date(dateString); - const userTimezoneOffset = date.getTimezoneOffset() * 60000; - field.onChange(new Date(date.getTime() + userTimezoneOffset)); - }} - onBlur={field.onBlur} - name={field.name} - error={fieldState.error?.message} - autoComplete="off" - helpText="Cuando se creó este producto." + +
+ + Descripción + + +
+ {previewDescription && ( + + + {previewDescription} + + + + + + + )} - /> + ( + + )} + /> +
- ( - - )} - /> -
-
-
+ + ( + { + if (!dateString) { + field.onChange(null); + return; + } + const date = new Date(dateString); + const userTimezoneOffset = date.getTimezoneOffset() * 60000; + field.onChange(new Date(date.getTime() + userTimezoneOffset)); + }} + onBlur={field.onBlur} + name={field.name} + error={fieldState.error?.message} + autoComplete="off" + helpText="Cuando se creó este producto." + /> + )} + /> + + ( + + )} + /> + + + + ); } diff --git a/app/store/components/product-management/products/components/form/InventoryOnlySection.tsx b/app/store/components/product-management/products/components/form/InventoryOnlySection.tsx new file mode 100644 index 00000000..198c9091 --- /dev/null +++ b/app/store/components/product-management/products/components/form/InventoryOnlySection.tsx @@ -0,0 +1,69 @@ +import type { ProductFormValues } from '@/lib/zod-schemas/product-schema'; +import { BlockStack, Card, Grid, Text, TextField } from '@shopify/polaris'; +import type { UseFormReturn } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; + +interface InventoryOnlySectionProps { + form: UseFormReturn; +} + +export function InventoryOnlySection({ form }: InventoryOnlySectionProps) { + return ( + + + + Inventario + + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + ); +} diff --git a/app/store/components/product-management/products/components/form/PricingOnlySection.tsx b/app/store/components/product-management/products/components/form/PricingOnlySection.tsx new file mode 100644 index 00000000..e8b56b57 --- /dev/null +++ b/app/store/components/product-management/products/components/form/PricingOnlySection.tsx @@ -0,0 +1,126 @@ +import { + usePriceSuggestion, + type PriceSuggestionResult, +} from '@/app/store/components/product-management/products/hooks/usePriceSuggestion'; +import { useToast } from '@/app/store/context/ToastContext'; +import type { ProductFormValues } from '@/lib/zod-schemas/product-schema'; +import { BlockStack, Card, FormLayout, Text } from '@shopify/polaris'; +import { useState } from 'react'; +import type { UseFormReturn } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; +import { PriceField } from './PriceField'; +import { PriceSuggestionPanel } from './PriceSuggestion'; + +interface PricingOnlySectionProps { + form: UseFormReturn; +} + +export function PricingOnlySection({ form }: PricingOnlySectionProps) { + const { + generatePriceSuggestion, + loading: isGeneratingPrice, + result: priceResult, + reset: resetPriceSuggestion, + } = usePriceSuggestion(); + + const [localPriceResult, setLocalPriceResult] = useState(null); + const { showToast } = useToast(); + + const displayResult = localPriceResult || priceResult; + + const handleGeneratePrice = async () => { + const productName = form.getValues('name'); + const category = form.getValues('category'); + + if (!productName) { + showToast('Por favor, ingrese un nombre de producto primero.', true); + return; + } + + try { + const rawResult = await generatePriceSuggestion({ productName, category: category || undefined }); + let parsedResult: any = rawResult; + if (typeof rawResult === 'string') { + try { + parsedResult = JSON.parse(rawResult); + } catch (e) { + throw new Error('Formato de respuesta inválido'); + } + } + if (parsedResult) { + setLocalPriceResult(parsedResult); + showToast('Se ha generado una sugerencia de precio basada en el mercado.'); + } + } catch (error) { + showToast('No se pudo generar la sugerencia de precio. Inténtelo de nuevo más tarde.', true); + } + }; + + const acceptPrice = () => { + const resultToUse = localPriceResult || priceResult; + if (resultToUse) { + form.setValue('price', resultToUse.suggestedPrice || 0, { shouldDirty: true, shouldTouch: true }); + if (resultToUse.maxPrice && resultToUse.suggestedPrice && resultToUse.maxPrice > resultToUse.suggestedPrice) { + form.setValue('compareAtPrice', resultToUse.maxPrice, { shouldDirty: true, shouldTouch: true }); + } + showToast('El precio sugerido ha sido aplicado al producto.'); + resetPriceSuggestion(); + setLocalPriceResult(null); + } + }; + + const rejectPrice = () => { + resetPriceSuggestion(); + setLocalPriceResult(null); + showToast('La sugerencia de precio ha sido descartada.'); + }; + + return ( + + + + Precios + + + + + + ( + + )} + /> + ( + + )} + /> + + + + + ); +} diff --git a/app/store/components/product-management/products/components/form/ProductForm.tsx b/app/store/components/product-management/products/components/form/ProductForm.tsx index aae13247..ef016331 100644 --- a/app/store/components/product-management/products/components/form/ProductForm.tsx +++ b/app/store/components/product-management/products/components/form/ProductForm.tsx @@ -4,8 +4,10 @@ import { AttributesForm } from '@/app/store/components/product-management/produc import { BasicInfoSection } from '@/app/store/components/product-management/products/components/form/BasicInfoSection'; import { CollectionSelector } from '@/app/store/components/product-management/products/components/form/CollectionSelector'; import { ImageUpload } from '@/app/store/components/product-management/products/components/form/ImageUpload'; -import { PricingInventorySection } from '@/app/store/components/product-management/products/components/form/PricingSection'; +import { InventoryOnlySection } from '@/app/store/components/product-management/products/components/form/InventoryOnlySection'; +import { PricingOnlySection } from '@/app/store/components/product-management/products/components/form/PricingOnlySection'; import { PublicationSection } from '@/app/store/components/product-management/products/components/form/PublicationSection'; +import { TagsSection } from '@/app/store/components/product-management/products/components/form/TagsSection'; import { handleProductCreate, handleProductUpdate, @@ -191,7 +193,8 @@ export function ProductForm({ storeId, productId }: ProductFormProps) { /> - + + + diff --git a/app/store/components/product-management/products/components/form/PublicationSection.tsx b/app/store/components/product-management/products/components/form/PublicationSection.tsx index 8c542a42..0267e30a 100644 --- a/app/store/components/product-management/products/components/form/PublicationSection.tsx +++ b/app/store/components/product-management/products/components/form/PublicationSection.tsx @@ -1,7 +1,7 @@ -import { UseFormReturn } from 'react-hook-form'; import { ProductFormValues } from '@/lib/zod-schemas/product-schema'; -import { BlockStack, Text, ChoiceList } from '@shopify/polaris'; +import { BlockStack, Card, ChoiceList, Text } from '@shopify/polaris'; import { useState } from 'react'; +import { UseFormReturn } from 'react-hook-form'; interface PublicationSectionProps { form: UseFormReturn; @@ -12,46 +12,48 @@ export function PublicationSection({ form }: PublicationSectionProps) { const [markets, setMarkets] = useState(['colombia-international']); return ( - -
- - Canales de ventas - - -
-
- - Mercados - - -
-
+ + +
+ + Canales de ventas + + +
+
+ + Mercados + + +
+
+
); } diff --git a/app/store/components/product-management/products/components/form/TagsSection.tsx b/app/store/components/product-management/products/components/form/TagsSection.tsx new file mode 100644 index 00000000..3a80d35c --- /dev/null +++ b/app/store/components/product-management/products/components/form/TagsSection.tsx @@ -0,0 +1,64 @@ +import type { ProductFormValues } from '@/lib/zod-schemas/product-schema'; +import { BlockStack, Button, Card, InlineStack, Tag, Text, TextField } from '@shopify/polaris'; +import { useState } from 'react'; +import type { UseFormReturn } from 'react-hook-form'; + +interface TagsSectionProps { + form: UseFormReturn; +} + +export function TagsSection({ form }: TagsSectionProps) { + const [newTag, setNewTag] = useState(''); + const tags = form.watch('tags') || []; + + const addTag = () => { + const value = newTag.trim(); + if (!value) return; + if (tags.includes(value)) { + setNewTag(''); + return; + } + const updated = [...tags, value]; + form.setValue('tags', updated, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + setNewTag(''); + }; + + const removeTag = (value: string) => { + const updated = tags.filter((t) => t !== value); + form.setValue('tags', updated, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + }; + + return ( + + + + Etiquetas + + + + Agregar + + } + /> + + {tags.length > 0 && ( + + {tags.map((tag) => ( + removeTag(tag)}> + {tag} + + ))} + + )} + + + ); +} diff --git a/app/store/hooks/data/useProducts.ts b/app/store/hooks/data/useProducts.ts index c3498561..ed5cfbb9 100644 --- a/app/store/hooks/data/useProducts.ts +++ b/app/store/hooks/data/useProducts.ts @@ -1,5 +1,5 @@ import type { Schema } from '@/amplify/data/resource'; -import { normalizeAttributesField, withLowercaseName } from '@/app/store/hooks/utils/productUtils'; +import { normalizeAttributesField, normalizeTagsField, withLowercaseName } from '@/app/store/hooks/utils/productUtils'; import { useCacheInvalidation } from '@/hooks/cache/useCacheInvalidation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { generateClient } from 'aws-amplify/api'; @@ -163,6 +163,7 @@ export function useProducts(storeId: string | undefined, options?: UseProductsOp attributes: normalizeAttributesField( productData.attributes as string | { name?: string; values?: string[] }[] | undefined ), + tags: normalizeTagsField(productData.tags as string[] | string | undefined), storeId: storeId || '', owner: username, status: productData.status || 'DRAFT', @@ -190,6 +191,7 @@ export function useProducts(storeId: string | undefined, options?: UseProductsOp attributes: normalizeAttributesField( productData.attributes as string | { name?: string; values?: string[] }[] | undefined ), + tags: normalizeTagsField(productData.tags as string[] | string | undefined), }); const { data } = await client.models.Product.update(dataToSend); diff --git a/app/store/hooks/utils/productUtils.ts b/app/store/hooks/utils/productUtils.ts index 90822e43..4bb9e376 100644 --- a/app/store/hooks/utils/productUtils.ts +++ b/app/store/hooks/utils/productUtils.ts @@ -33,3 +33,28 @@ export function normalizeAttributesField( } return JSON.stringify(normalizeAttributesToLowercase(arr)); } + +/** + * Normaliza el campo de etiquetas para ser enviado a la API. + * Admite string (JSON serializado) o array de strings y devuelve un string JSON. + */ +export function normalizeTagsField(tags: string | string[] | undefined): string { + if (typeof tags === 'string') { + // Si ya viene como string, se asume JSON serializado válido + try { + JSON.parse(tags); + return tags; + } catch { + // Si no es un JSON válido, intentar dividir por comas como fallback básico + const arr = tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + return JSON.stringify(arr); + } + } + if (Array.isArray(tags)) { + return JSON.stringify(tags); + } + return JSON.stringify([]); +} diff --git a/renderer-engine/services/core/data-transformer.ts b/renderer-engine/services/core/data-transformer.ts index b8c330b4..5ea6f94b 100644 --- a/renderer-engine/services/core/data-transformer.ts +++ b/renderer-engine/services/core/data-transformer.ts @@ -110,6 +110,36 @@ export class DataTransformer { })) : []; } + + /** + * Transforma tags desde JSON string o array y normaliza a string[] + */ + public static transformTags(tags: any): string[] { + let tagsArray: any[] = []; + if (tags) { + if (typeof tags === 'string') { + try { + tagsArray = JSON.parse(tags); + } catch (error) { + console.warn('Error parsing product tags JSON:', error); + // intentar CSV como fallback + tagsArray = tags + .split(',') + .map((t: string) => t.trim()) + .filter((t: string) => t.length > 0); + } + } else if (Array.isArray(tags)) { + tagsArray = tags; + } + } + + if (!Array.isArray(tagsArray)) return []; + + const normalized = tagsArray.map((t) => (typeof t === 'string' ? t.trim() : '')).filter((t) => t.length > 0); + + // remover duplicados preservando orden + return Array.from(new Set(normalized)); + } } export const dataTransformer = DataTransformer; diff --git a/renderer-engine/services/fetchers/product-fetcher.ts b/renderer-engine/services/fetchers/product-fetcher.ts index d411e3cc..bc065bfd 100644 --- a/renderer-engine/services/fetchers/product-fetcher.ts +++ b/renderer-engine/services/fetchers/product-fetcher.ts @@ -244,6 +244,7 @@ export class ProductFetcher { const transformedImages = dataTransformer.transformImages(product.images, product.name); const variants = dataTransformer.transformVariants(product.variants, product.price); const attributes: ProductAttribute[] = dataTransformer.transformAttributes(product.attributes); + const tags: string[] = dataTransformer.transformTags(product.tags); const featured_image = transformedImages.length > 0 ? transformedImages[0].url : undefined; const images = transformedImages.map((img) => img.url || img); const url = collectionHandle ? `/collections/${collectionHandle}/products/${handle}` : `/products/${handle}`; @@ -267,6 +268,7 @@ export class ProductFetcher { category: product.category, createdAt: product.createdAt, updatedAt: product.updatedAt, + tags, }; } } diff --git a/template/assets/preview.png b/template/assets/preview.png deleted file mode 100644 index d6c66a32..00000000 Binary files a/template/assets/preview.png and /dev/null differ diff --git a/template/assets/preview.webp b/template/assets/preview.webp new file mode 100644 index 00000000..2dd360d6 Binary files /dev/null and b/template/assets/preview.webp differ