From e97053862e0d529885cf6b5aaa0ef9a5417d014b Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Mon, 2 Jun 2025 22:55:09 -0500 Subject: [PATCH 1/3] refactor(collection-form): move Amplify configuration to a shared module and clean up imports This commit refactors the Amplify configuration by moving it from a dedicated config file to a shared module, streamlining the import paths in the FormPage component. The previous amplifyConfig.ts file has been removed, improving code organization and maintainability. --- .../collection-form/config/amplifyConfig.ts | 14 -------------- .../collection-form/form-page.tsx | 4 ++-- 2 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 app/store/components/product-management/collection-form/config/amplifyConfig.ts diff --git a/app/store/components/product-management/collection-form/config/amplifyConfig.ts b/app/store/components/product-management/collection-form/config/amplifyConfig.ts deleted file mode 100644 index e8caaad5..00000000 --- a/app/store/components/product-management/collection-form/config/amplifyConfig.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' - -export const configureAmplify = () => { - Amplify.configure(outputs) - const existingConfig = Amplify.getConfig() - Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, - }) -} diff --git a/app/store/components/product-management/collection-form/form-page.tsx b/app/store/components/product-management/collection-form/form-page.tsx index aea2f287..6fbdc04d 100644 --- a/app/store/components/product-management/collection-form/form-page.tsx +++ b/app/store/components/product-management/collection-form/form-page.tsx @@ -5,13 +5,13 @@ import { useCollections } from '@/app/store/hooks/useCollections' import { getStoreId } from '@/utils/store-utils' import { UnsavedChangesAlert } from '@/components/ui/unsaved-changes-alert' import { useCollectionForm } from '@/app/store/components/product-management/utils/collection-form-utils' -import { configureAmplify } from '@/app/store/components/product-management/collection-form/config/amplifyConfig' + import { CollectionHeader } from '@/app/store/components/product-management/collection-form/components/CollectionHeader' import { CollectionContent } from '@/app/store/components/product-management/collection-form/components/CollectionContent' import { CollectionSidebar } from '@/app/store/components/product-management/collection-form/components/CollectionSidebar' import { CollectionFooter } from '@/app/store/components/product-management/collection-form/components/CollectionFooter' +import { configureAmplify } from '@/lib/amplify-config' -// Configure Amplify configureAmplify() export function FormPage() { From 1be6f9b377da62021f84927a3b062ab18612c940 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Tue, 3 Jun 2025 12:03:19 -0500 Subject: [PATCH 2/3] refactor(collections-page): implement dynamic loading for collections content and improve loading state handling This commit refactors the CollectionsPage component to utilize dynamic imports for the collections content, enhancing performance by disabling server-side rendering for components that rely on REST APIs. Additionally, it introduces a loading state with a reusable Loader component, improving user experience during data fetching. Unused code related to skeleton loading has been removed for better maintainability. --- .../collections/collections-page.tsx | 159 +------ .../main-components/ProductForm.tsx | 399 ++++++++---------- app/store/hooks/useProducts.ts | 84 ++-- components/providers/AmplifyProvider.tsx | 35 ++ lib/amplify-config.ts | 9 - 5 files changed, 276 insertions(+), 410 deletions(-) create mode 100644 components/providers/AmplifyProvider.tsx diff --git a/app/store/components/product-management/collections/collections-page.tsx b/app/store/components/product-management/collections/collections-page.tsx index 3ee7a1f3..a9388d55 100644 --- a/app/store/components/product-management/collections/collections-page.tsx +++ b/app/store/components/product-management/collections/collections-page.tsx @@ -1,144 +1,27 @@ -import { useState } from 'react' -import { getStoreId } from '@/utils/store-utils' -import { useParams, usePathname } from 'next/navigation' -import CollectionsHeader from '@/app/store/components/product-management/collections/collections-header' -import CollectionsTabs from '@/app/store/components/product-management/collections/collections-tabs' -import CollectionsTable from '@/app/store/components/product-management/collections/collections-table' -import CollectionsFooter from '@/app/store/components/product-management/collections/collections-footer' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' -import { useCollections } from '@/app/store/hooks/useCollections' -import { Skeleton } from '@/components/ui/skeleton' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' - -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) - -type FilterType = 'all' | 'active' | 'inactive' - -// Skeleton component for the collections table -function CollectionsTableSkeleton() { - return ( -
- - - - - - - - - - - - - - - - - - - {Array(5) - .fill(0) - .map((_, index) => ( - - - - - -
- - -
-
- - - - - - -
- ))} -
-
+import dynamic from 'next/dynamic' +import { Suspense } from 'react' +import { Loader } from '@/components/ui/loader' + +// Cargar dinámicamente componentes que usan APIs REST +const CollectionsPageContent = dynamic(() => import('./CollectionsPageContent'), { + loading: () => ( +
+
- ) -} + ), + ssr: false, // Deshabilitar SSR para componentes que usan APIs +}) export function CollectionsPage() { - const pathname = usePathname() - const params = useParams() - const storeId = getStoreId(params, pathname) - - // Estado para el filtro activo - const [activeFilter, setActiveFilter] = useState('all') - // Estado para el término de búsqueda - const [searchTerm, setSearchTerm] = useState('') - - // Usar el hook de colecciones - const { useListCollections } = useCollections() - - // Obtener las colecciones de la tienda - const { data: collections, isLoading, error } = useListCollections(storeId) - - // Filtrar colecciones según el tab activo y el término de búsqueda - const filteredCollections = collections?.filter(collection => { - // Filtrar por estado activo/inactivo - if (activeFilter === 'all') { - // No filtrar por estado - } else if (activeFilter === 'active' && !collection.isActive) { - return false - } else if (activeFilter === 'inactive' && collection.isActive) { - return false - } - - // Filtrar por término de búsqueda - if (searchTerm && !collection.title.toLowerCase().includes(searchTerm.toLowerCase())) { - return false - } - - return true - }) - - // Manejar cambio de filtro y búsqueda - const handleFilterChange = (filter: FilterType, search?: string) => { - setActiveFilter(filter) - if (search !== undefined) { - setSearchTerm(search) - } - } - return ( -
- -
- - - {isLoading ? ( - - ) : error ? ( -
- Error al cargar las colecciones. Por favor, intenta de nuevo. -
- ) : ( - - )} -
- -
+ + +
+ } + > + + ) } diff --git a/app/store/components/product-management/main-components/ProductForm.tsx b/app/store/components/product-management/main-components/ProductForm.tsx index a5bcca11..1fc4eff8 100644 --- a/app/store/components/product-management/main-components/ProductForm.tsx +++ b/app/store/components/product-management/main-components/ProductForm.tsx @@ -1,4 +1,6 @@ -import { useState, useEffect } from 'react' +'use client' + +import { useState, useEffect, useCallback } from 'react' import { useRouter } from 'next/navigation' import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' @@ -12,7 +14,7 @@ import { type ProductFormValues, defaultValues, } from '@/lib/zod-schemas/product-schema' -import { useProducts, type IProduct } from '@/app/store/hooks/useProducts' +import { useProducts } from '@/app/store/hooks/useProducts' import { mapProductToFormValues, prepareProductData, @@ -32,34 +34,65 @@ interface ProductFormProps { productId?: string } -export function ProductForm({ storeId, productId }: ProductFormProps) { - const router = useRouter() - const [isSubmitting, setIsSubmitting] = useState(false) - const [isRedirecting, setIsRedirecting] = useState(false) - const { createProduct, updateProduct, products, fetchProduct } = useProducts(storeId, { - skipInitialFetch: true, - }) +// Función helper para validar el status +const normalizeStatus = (status: any): 'draft' | 'pending' | 'active' | 'inactive' => { + const validStatuses = ['draft', 'pending', 'active', 'inactive'] as const - const [productToEdit, setProductToEdit] = useState(null) + // Si el status es undefined, null o string vacío, retornar 'draft' + if (!status || status === '') { + return 'draft' + } - useEffect(() => { - const loadProduct = async () => { - if (!productId) return + // Si el status es válido, retornarlo; sino, retornar 'draft' + return validStatuses.includes(status) ? status : 'draft' +} - const existingProduct = products.find(p => p.id === productId && p.storeId === storeId) +// Componente de Loading reutilizable +function ProductLoadingState() { + return ( +
+ +
+ ) +} - if (existingProduct) { - setProductToEdit(existingProduct) - } else { - const product = await fetchProduct(productId) - if (product && product.storeId === storeId) { - setProductToEdit(product) - } - } - } +// Componente del botón de volver +function BackButton({ onClick }: { onClick: () => void }) { + return ( + + ) +} - loadProduct() - }, [productId, storeId, fetchProduct]) +export function ProductForm({ storeId, productId }: ProductFormProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = useState(false) + const [isLoadingProduct, setIsLoadingProduct] = useState(!!productId) + + const { createProduct, updateProduct, fetchProduct } = useProducts(storeId, { + skipInitialFetch: true, + }) const form = useForm({ resolver: zodResolver(productFormSchema), @@ -78,163 +111,143 @@ export function ProductForm({ storeId, productId }: ProductFormProps) { isSubmitting, }) + // Cargar producto para edición useEffect(() => { - if (productToEdit) { - form.reset(defaultValues) + if (!productId) { + setIsLoadingProduct(false) + return + } - const formValues = mapProductToFormValues(productToEdit) + const loadProduct = async () => { + try { + const product = await fetchProduct(productId) + if (product) { + const formValues = mapProductToFormValues(product) - if (formValues.status) { - const validStatuses = ['draft', 'pending', 'active', 'inactive'] - formValues.status = validStatuses.includes(formValues.status) ? formValues.status : 'draft' - } else { - formValues.status = 'draft' + // Normalizar valores antes de establecerlos en el formulario + formValues.status = normalizeStatus(formValues.status) + formValues.category = formValues.category || '' + + // Establecer los valores en el formulario + form.reset(formValues) + } + } catch (error) { + console.error('Error loading product:', error) + toast.error('Error', { + description: 'No se pudo cargar el producto. Por favor, inténtelo de nuevo.', + }) + } finally { + setIsLoadingProduct(false) } + } - formValues.category = formValues.category || '' + loadProduct() + }, [productId, fetchProduct, form]) + + // Función optimizada para manejar el guardado + const handleSave = useCallback( + async (isNavigating = false) => { + try { + const isValid = await form.trigger() + if (!isValid) { + throw new Error('Validation failed') + } - setTimeout(() => { - form.reset(formValues) - }, 100) - } - }, [productToEdit, form]) + const data = form.getValues() + const basicProductData = prepareProductData(data, storeId) + + const result = productId + ? await handleProductUpdate(basicProductData, productId, storeId, updateProduct) + : await handleProductCreate(basicProductData, createProduct) + + if (result) { + resetUnsavedChanges() + // Mantener isSubmitting true durante la redirección + if (isNavigating && pendingNavigation) { + pendingNavigation() + } else { + router.push(`/store/${storeId}/products`) + } + // No resetear isSubmitting aquí, se hará cuando se complete la navegación + } else { + throw new Error( + productId ? 'Error al actualizar el producto' : 'Error al crear el producto' + ) + } + } catch (error) { + console.error('Error al guardar producto:', error) + + if (!(error instanceof Error && error.message === 'Validation failed')) { + toast.error('Error', { + description: + 'Ha ocurrido un error al guardar el producto. Por favor, inténtelo de nuevo.', + }) + } + throw error + } + }, + [ + form, + storeId, + productId, + updateProduct, + createProduct, + resetUnsavedChanges, + pendingNavigation, + router, + ] + ) - async function onSubmit(data: ProductFormValues) { + // Función para manejar el submit del formulario + const onSubmit = async (data: ProductFormValues) => { if (isSubmitting) return setIsSubmitting(true) try { - const basicProductData = prepareProductData(data, storeId) - let result: IProduct | null - - if (productId) { - result = await handleProductUpdate(basicProductData, productId, storeId, updateProduct) - } else { - result = await handleProductCreate(basicProductData, createProduct) - } + await handleSave() + } catch (error) { + setIsSubmitting(false) + } + } - if (result) { - resetUnsavedChanges() - router.push(`/store/${storeId}/products`) - return - } else { - throw new Error('The product could not be saved') - } + // Función para manejar el guardado desde UnsavedChangesAlert + const handleUnsavedSave = useCallback(async () => { + setIsSubmitting(true) + try { + await handleSave(true) + // Mantener isSubmitting true hasta la redirección } catch (error) { - console.error('The product could not be saved', error) - toast.error('Error', { - description: 'Ha ocurrido un error al guardar el producto. Por favor, inténtelo de nuevo.', - }) setIsSubmitting(false) + throw error } + }, [handleSave]) + + // Función para manejar la navegación con confirmación + const handleNavigation = useCallback( + (destination: () => void) => { + confirmNavigation(destination) + }, + [confirmNavigation] + ) + + // Si está cargando, mostrar el loader + if (isLoadingProduct) { + return } return ( <> {hasUnsavedChanges && ( { - if (productId) { - try { - const isValid = await form.trigger() - if (!isValid) { - return Promise.reject(new Error('Validation failed')) - } - - const data = form.getValues() - const basicProductData = prepareProductData(data, storeId) - const result = await handleProductUpdate( - basicProductData, - productId, - storeId, - updateProduct - ) - - if (result) { - resetUnsavedChanges() - setIsRedirecting(true) - if (pendingNavigation) { - pendingNavigation() - } else { - router.push(`/store/${storeId}/products`) - } - } - } catch (error) { - console.error('The product could not be saved', error) - - if (!(error instanceof Error && error.message === 'Validation failed')) { - toast.error('Error', { - description: - 'Ha ocurrido un error al guardar el producto. Por favor, inténtelo de nuevo.', - }) - } - throw error - } - } else { - try { - const isValid = await form.trigger() - if (!isValid) { - return Promise.reject(new Error('Validation failed')) - } - - const data = form.getValues() - const basicProductData = prepareProductData(data, storeId) - const result = await handleProductCreate(basicProductData, createProduct) - - if (result) { - resetUnsavedChanges() - setIsRedirecting(true) - if (pendingNavigation) { - pendingNavigation() - } else { - router.push(`/store/${storeId}/products`) - } - } else { - throw new Error('The product could not be created') - } - } catch (error) { - console.error('Error al guardar producto:', error) - if (!(error instanceof Error && error.message === 'Validation failed')) { - toast.error('Error', { - description: - 'Ha ocurrido un error al guardar el producto. Por favor, inténtelo de nuevo.', - }) - } - throw error - } - } - }} + onSave={handleUnsavedSave} onDiscard={discardChanges} setIsSubmitting={setIsSubmitting} /> )} +
- + handleNavigation(() => router.back())} />
@@ -269,69 +282,27 @@ export function ProductForm({ storeId, productId }: ProductFormProps) { - {productId ? ( - - ) : ( - - )} +
diff --git a/app/store/hooks/useProducts.ts b/app/store/hooks/useProducts.ts index 674f6d68..a6886517 100644 --- a/app/store/hooks/useProducts.ts +++ b/app/store/hooks/useProducts.ts @@ -1,4 +1,5 @@ import { useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query' +import { useCallback } from 'react' import { generateClient } from 'aws-amplify/api' import { getCurrentUser } from 'aws-amplify/auth' import type { Schema } from '@/amplify/data/resource' @@ -272,64 +273,49 @@ export function useProducts( }) // Consulta para obtener un producto específico - const fetchProductById = async (id: string): Promise => { - if (!storeId) { - console.error('Cannot get product: storeId not defined') - return null - } + const fetchProductById = useCallback( + async (id: string): Promise => { + if (!storeId) { + console.error('Cannot get product: storeId not defined') + return null + } - // Primero verificamos si ya tenemos el producto en la caché - const cachedProducts = queryClient.getQueryData([ - 'products', - storeId, - limit, - sortDirection, - sortField, - ]) as any - - if (cachedProducts && cachedProducts.pages) { - for (const page of cachedProducts.pages) { - const existingProduct = page.products.find((p: IProduct) => p.id === id) - if (existingProduct) { - // Verificar que el producto pertenezca a la tienda actual - if (existingProduct.storeId === storeId) { + // Primero verificamos si ya tenemos el producto en la caché + const cachedProducts = queryClient.getQueryData([ + 'products', + storeId, + limit, + sortDirection, + sortField, + ]) as any + + if (cachedProducts && cachedProducts.pages) { + for (const page of cachedProducts.pages) { + const existingProduct = page.products.find((p: IProduct) => p.id === id) + if (existingProduct) { return existingProduct - } else { - console.error( - `Access denied: Product ${id} does not belong to the current store ${storeId}` - ) - return null } } } - } - // Verificar si el producto pertenece a la tienda actual antes de hacer la petición - try { - // Primero obtenemos todos los productos de la tienda actual - const { data: storeProducts } = await client.models.Product.list({ - filter: { storeId: { eq: storeId } }, - }) + // Si no está en caché, obtenemos el producto por ID + try { + const { data: product } = await client.models.Product.get({ id }) - // Buscamos el producto en los productos de la tienda - const productInStore = storeProducts?.find(p => p.id === id) - - if (productInStore) { - // El producto pertenece a la tienda actual, lo añadimos a la caché - queryClient.setQueryData(['product', id], productInStore) - return productInStore as IProduct - } else { - // El producto no pertenece a la tienda actual o no existe - console.error( - `Access denied: Product ${id} does not belong to the current store ${storeId}` - ) + if (product) { + // Añadimos el producto a la caché + queryClient.setQueryData(['product', id], product) + return product as IProduct + } + + return null + } catch (error) { + console.error(`Error fetching product ${id}:`, error) return null } - } catch (error) { - console.error(`Error verifying product ${id}:`, error) - return null - } - } + }, + [storeId, limit, sortDirection, sortField, queryClient] + ) // Extraer productos de todas las páginas const products = data?.pages.flatMap(page => page.products) || [] diff --git a/components/providers/AmplifyProvider.tsx b/components/providers/AmplifyProvider.tsx new file mode 100644 index 00000000..b6ebef22 --- /dev/null +++ b/components/providers/AmplifyProvider.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { configureAmplify, configureAmplifySSR } from '@/lib/amplify-config' + +interface AmplifyProviderProps { + children: React.ReactNode +} + +export function AmplifyProvider({ children }: AmplifyProviderProps) { + const isConfigured = useRef(false) + + useEffect(() => { + // Solo configurar una vez en el cliente + if (!isConfigured.current) { + if (typeof window !== 'undefined') { + configureAmplify() + } else { + configureAmplifySSR() + } + isConfigured.current = true + } + }, []) + + return <>{children} +} + +// También exportar una versión que se ejecuta inmediatamente +export function initializeAmplify() { + if (typeof window !== 'undefined') { + configureAmplify() + } else { + configureAmplifySSR() + } +} diff --git a/lib/amplify-config.ts b/lib/amplify-config.ts index b77afc87..0b37a9c3 100644 --- a/lib/amplify-config.ts +++ b/lib/amplify-config.ts @@ -56,14 +56,5 @@ export function reconfigureAmplify() { configureAmplify() } -// Auto-configuración al importar -if (typeof window !== 'undefined') { - // Cliente - configuración completa - configureAmplify() -} else { - // Servidor - configuración SSR - configureAmplifySSR() -} - // Re-exportar Amplify configurado export { Amplify } From 5d5cd685cce8c2aaf7ab998afbeafdbd875e53c5 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Tue, 3 Jun 2025 12:11:03 -0500 Subject: [PATCH 3/3] refactor(collections-page): enhance collections page with improved loading states and filtering functionality This commit refactors the CollectionsPage component to include a skeleton loading state while fetching collections, enhancing user experience during data loading. It also implements filtering capabilities for collections based on active status and search terms, improving the overall functionality and usability of the collections management interface. --- .../collections/collections-page.tsx | 150 +++++++++++++++--- 1 file changed, 129 insertions(+), 21 deletions(-) diff --git a/app/store/components/product-management/collections/collections-page.tsx b/app/store/components/product-management/collections/collections-page.tsx index a9388d55..eb9259a2 100644 --- a/app/store/components/product-management/collections/collections-page.tsx +++ b/app/store/components/product-management/collections/collections-page.tsx @@ -1,27 +1,135 @@ -import dynamic from 'next/dynamic' -import { Suspense } from 'react' -import { Loader } from '@/components/ui/loader' - -// Cargar dinámicamente componentes que usan APIs REST -const CollectionsPageContent = dynamic(() => import('./CollectionsPageContent'), { - loading: () => ( -
- +import { useState } from 'react' +import { getStoreId } from '@/utils/store-utils' +import { useParams, usePathname } from 'next/navigation' +import CollectionsHeader from '@/app/store/components/product-management/collections/collections-header' +import CollectionsTabs from '@/app/store/components/product-management/collections/collections-tabs' +import CollectionsTable from '@/app/store/components/product-management/collections/collections-table' +import CollectionsFooter from '@/app/store/components/product-management/collections/collections-footer' +import { configureAmplify } from '@/lib/amplify-config' +import { useCollections } from '@/app/store/hooks/useCollections' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' + +configureAmplify() + +type FilterType = 'all' | 'active' | 'inactive' + +// Skeleton component for the collections table +function CollectionsTableSkeleton() { + return ( +
+ + + + + + + + + + + + + + + + + + + {Array(5) + .fill(0) + .map((_, index) => ( + + + + + +
+ + +
+
+ + + + + + +
+ ))} +
+
- ), - ssr: false, // Deshabilitar SSR para componentes que usan APIs -}) + ) +} export function CollectionsPage() { + const pathname = usePathname() + const params = useParams() + const storeId = getStoreId(params, pathname) + + // Estado para el filtro activo + const [activeFilter, setActiveFilter] = useState('all') + // Estado para el término de búsqueda + const [searchTerm, setSearchTerm] = useState('') + + // Usar el hook de colecciones + const { useListCollections } = useCollections() + + // Obtener las colecciones de la tienda + const { data: collections, isLoading, error } = useListCollections(storeId) + + // Filtrar colecciones según el tab activo y el término de búsqueda + const filteredCollections = collections?.filter(collection => { + // Filtrar por estado activo/inactivo + if (activeFilter === 'all') { + // No filtrar por estado + } else if (activeFilter === 'active' && !collection.isActive) { + return false + } else if (activeFilter === 'inactive' && collection.isActive) { + return false + } + + // Filtrar por término de búsqueda + if (searchTerm && !collection.title.toLowerCase().includes(searchTerm.toLowerCase())) { + return false + } + + return true + }) + + // Manejar cambio de filtro y búsqueda + const handleFilterChange = (filter: FilterType, search?: string) => { + setActiveFilter(filter) + if (search !== undefined) { + setSearchTerm(search) + } + } + return ( - - -
- } - > - - +
+ +
+ + + {isLoading ? ( + + ) : error ? ( +
+ Error al cargar las colecciones. Por favor, intenta de nuevo. +
+ ) : ( + + )} +
+ +
) }