From 400cbb8bba35406b6b133d568346493fdc3ac308 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Sun, 30 Mar 2025 18:19:04 -0500 Subject: [PATCH 01/38] feat: add getStoreData and getStoreProducts functions with APIs This commit introduces two new Lambda functions, `getStoreData` and `getStoreProducts`, along with their respective REST APIs. These functions allow fetching store data and products by store ID. Additionally, the data schema is updated to include secondary indexes and authorization rules for these functions. The APIs are configured with CORS support and integrated into the backend stack. --- amplify/auth/post-authentication/handler.ts | 1 - amplify/backend.ts | 59 +++++++++++++++ amplify/data/resource.ts | 5 ++ .../functions/createSubscription/handler.ts | 2 +- amplify/functions/getStoreData/handler.ts | 74 +++++++++++++++++++ amplify/functions/getStoreData/resource.ts | 6 ++ amplify/functions/getStoreProducts/handler.ts | 62 ++++++++++++++++ .../functions/getStoreProducts/resource.ts | 6 ++ 8 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 amplify/functions/getStoreData/handler.ts create mode 100644 amplify/functions/getStoreData/resource.ts create mode 100644 amplify/functions/getStoreProducts/handler.ts create mode 100644 amplify/functions/getStoreProducts/resource.ts diff --git a/amplify/auth/post-authentication/handler.ts b/amplify/auth/post-authentication/handler.ts index 994a930c..cde11f63 100644 --- a/amplify/auth/post-authentication/handler.ts +++ b/amplify/auth/post-authentication/handler.ts @@ -17,7 +17,6 @@ export const handler: PostAuthenticationTriggerHandler = async event => { const isGoogleLogin = identities.some((identity: any) => identity.providerName === 'Google') if (!isGoogleLogin) { - console.log('El usuario no inició sesión con Google. Terminando ejecución.') return event } diff --git a/amplify/backend.ts b/amplify/backend.ts index fded7c05..66bfde87 100644 --- a/amplify/backend.ts +++ b/amplify/backend.ts @@ -10,6 +10,8 @@ import { checkStoreName } from './functions/checkStoreName/resource' import { checkStoreDomain } from './functions/checkStoreDomain/resource' import { postConfirmation } from './auth/post-confirmation/resource' import { apiKeyManager } from './functions/LambdaEncryptKeys/resource' +import { getStoreProducts } from './functions/getStoreProducts/resource' +import { getStoreData } from './functions/getStoreData/resource' import { data, generateHaikuFunction, @@ -43,6 +45,8 @@ const backend = defineBackend({ generateProductDescriptionFunction, generatePriceSuggestionFunction, templates, + getStoreProducts, + getStoreData, }) backend.generateHaikuFunction.resources.lambda.addToRolePolicy( @@ -281,6 +285,49 @@ const apiKeyManagerIntegration = new LambdaIntegration(backend.apiKeyManager.res const apiKeyManagerResource = apiKeyManagerApi.root.addResource('api-keys') apiKeyManagerResource.addMethod('POST', apiKeyManagerIntegration) +/** + * + * API para Obtener Productos de Tienda + * + */ + +const getStoreProductsApi = new RestApi(apiStack, 'GetStoreProductsApi', { + restApiName: 'GetStoreProductsApi', + deploy: true, + deployOptions: { stageName: 'dev' }, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: Cors.DEFAULT_HEADERS, + }, +}) + +const getStoreProductsIntegration = new LambdaIntegration(backend.getStoreProducts.resources.lambda) + +const getStoreProductsResource = getStoreProductsApi.root.addResource('get-store-products') +getStoreProductsResource.addMethod('GET', getStoreProductsIntegration) + +/** + * + * API para Obtener Datos de Tienda + * + */ + +const getStoreDataApi = new RestApi(apiStack, 'GetStoreDataApi', { + restApiName: 'GetStoreDataApi', + deploy: true, + deployOptions: { stageName: 'dev' }, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: Cors.DEFAULT_HEADERS, + }, +}) +const getStoreDataIntegration = new LambdaIntegration(backend.getStoreData.resources.lambda) + +const getStoreDataResource = getStoreDataApi.root.addResource('get-store-data') +getStoreDataResource.addMethod('GET', getStoreDataIntegration) + /** * * Política de IAM para Invocar las APIs @@ -298,6 +345,8 @@ const apiRestPolicy = new Policy(apiStack, 'RestApiPolicy', { `${checkStoreNameApi.arnForExecuteApi('*', '/check-store-name', 'dev')}`, `${checkStoreDomainApi.arnForExecuteApi('*', '/check-store-domain', 'dev')}`, `${apiKeyManagerApi.arnForExecuteApi('*', '/api-keys', 'dev')}`, + `${getStoreProductsApi.arnForExecuteApi('*', '/get-store-products', 'dev')}`, + `${getStoreDataApi.arnForExecuteApi('*', '/get-store-data', 'dev')}`, ], }), ], @@ -350,6 +399,16 @@ backend.addOutput({ region: Stack.of(apiKeyManagerApi).region, apiName: apiKeyManagerApi.restApiName, }, + GetStoreProductsApi: { + endpoint: getStoreProductsApi.url, + region: Stack.of(getStoreProductsApi).region, + apiName: getStoreProductsApi.restApiName, + }, + GetStoreDataApi: { + endpoint: getStoreDataApi.url, + region: Stack.of(getStoreDataApi).region, + apiName: getStoreDataApi.restApiName, + }, }, }, }) diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index ee6bd3aa..5196993a 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -6,6 +6,8 @@ import { planScheduler } from '../functions/planScheduler/resource' import { checkStoreName } from '../functions/checkStoreName/resource' import { checkStoreDomain } from '../functions/checkStoreDomain/resource' import { apiKeyManager } from '../functions/LambdaEncryptKeys/resource' +import { getStoreProducts } from '../functions/getStoreProducts/resource' +import { getStoreData } from '../functions/getStoreData/resource' export const MODEL_ID = 'us.anthropic.claude-3-haiku-20240307-v1:0' @@ -109,6 +111,7 @@ const schema = a onboardingCompleted: a.boolean().required(), onboardingData: a.json(), }) + .secondaryIndexes(index => [index('storeId')]) .authorization(allow => [allow.authenticated().to(['read', 'update', 'delete', 'create'])]), Product: a @@ -147,6 +150,8 @@ const schema = a allow.resource(checkStoreName), allow.resource(checkStoreDomain), allow.resource(apiKeyManager), + allow.resource(getStoreProducts), + allow.resource(getStoreData), ]) export type Schema = ClientSchema diff --git a/amplify/functions/createSubscription/handler.ts b/amplify/functions/createSubscription/handler.ts index b306aece..df76da00 100644 --- a/amplify/functions/createSubscription/handler.ts +++ b/amplify/functions/createSubscription/handler.ts @@ -1,5 +1,5 @@ import { APIGatewayProxyHandler } from 'aws-lambda' -import { env } from '../../../.amplify/generated/env/createSubscription' +import { env } from '$amplify/env/createSubscription' const MERCADOPAGO_API_URL = 'https://api.mercadopago.com/preapproval' diff --git a/amplify/functions/getStoreData/handler.ts b/amplify/functions/getStoreData/handler.ts new file mode 100644 index 00000000..dab78003 --- /dev/null +++ b/amplify/functions/getStoreData/handler.ts @@ -0,0 +1,74 @@ +import { Amplify } from 'aws-amplify' +import { generateClient } from 'aws-amplify/data' +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' +import { env } from '$amplify/env/getStoreData' +import { type Schema } from '../../data/resource' + +// Lazy-load del cliente para evitar reconfiguración en cada invocación +let clientSchema: ReturnType> | null = null + +const initializeClient = async () => { + if (!clientSchema) { + const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) + Amplify.configure(resourceConfig, libraryOptions) + clientSchema = generateClient() + } + return clientSchema +} +export const handler = async (event: any) => { + const storeId = event.queryStringParameters?.storeId + + if (!storeId) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Store ID is required' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + try { + const client = await initializeClient() + const { data: store } = await client.models.UserStore.list({ + filter: { + storeId: { + eq: storeId, + }, + }, + }) + + if (!store) { + return { + statusCode: 404, + body: JSON.stringify({ message: 'Store not found' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + return { + statusCode: 200, + body: JSON.stringify({ + store: store, + }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error fetching store data:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error fetching store data' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} diff --git a/amplify/functions/getStoreData/resource.ts b/amplify/functions/getStoreData/resource.ts new file mode 100644 index 00000000..c35f24bb --- /dev/null +++ b/amplify/functions/getStoreData/resource.ts @@ -0,0 +1,6 @@ +import { defineFunction } from '@aws-amplify/backend' + +export const getStoreData = defineFunction({ + name: 'getStoreData', + entry: 'handler.ts', +}) diff --git a/amplify/functions/getStoreProducts/handler.ts b/amplify/functions/getStoreProducts/handler.ts new file mode 100644 index 00000000..6f9f5670 --- /dev/null +++ b/amplify/functions/getStoreProducts/handler.ts @@ -0,0 +1,62 @@ +import { Amplify } from 'aws-amplify' +import { generateClient } from 'aws-amplify/data' +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' +import { env } from '$amplify/env/getStoreProducts' +import { type Schema } from '../../data/resource' + +let clientSchema: ReturnType> | null = null + +const initializeClient = async () => { + if (!clientSchema) { + const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) + Amplify.configure(resourceConfig, libraryOptions) + clientSchema = generateClient() + } + return clientSchema +} + +export const handler = async (event: any) => { + const storeId = event.queryStringParameters?.storeId + + if (!storeId) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Store ID is required' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + try { + const client = await initializeClient() + const { data: products } = await client.models.Product.list({ + filter: { + storeId: { eq: storeId }, + status: { eq: 'active' }, // Opcional: filtrar solo productos activos + }, + }) + + return { + statusCode: 200, + body: JSON.stringify({ + products: products, + }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error fetching store products:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error fetching store products' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} diff --git a/amplify/functions/getStoreProducts/resource.ts b/amplify/functions/getStoreProducts/resource.ts new file mode 100644 index 00000000..904b113b --- /dev/null +++ b/amplify/functions/getStoreProducts/resource.ts @@ -0,0 +1,6 @@ +import { defineFunction } from '@aws-amplify/backend' + +export const getStoreProducts = defineFunction({ + name: 'getStoreProducts', + entry: 'handler.ts', +}) From 95112666174b03c30e9b5adf2d33e9597a10a8db Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Mon, 31 Mar 2025 21:19:54 -0500 Subject: [PATCH 02/38] feat(collections): add collection management feature with CRUD operations This commit introduces a comprehensive collection management feature, including: - New collection model in Amplify schema - Collection CRUD operations with React Query - Collection pages for listing, creating, and editing collections - Integration with S3 for image storage - Product selection and management within collections - UI components for collection management The feature supports full lifecycle management of product collections, including image handling, product association, and SEO optimization. --- amplify/backend.ts | 46 ++- amplify/data/resource.ts | 19 + amplify/functions/storeImages/handler.ts | 243 ++++++++++++ amplify/functions/storeImages/resource.ts | 10 + app/store/[slug]/collections/new/page.tsx | 19 + app/store/[slug]/collections/page.tsx | 19 + .../[slug]/{products => }/inventory/page.tsx | 0 .../images-selector/image-selector-modal.tsx | 371 ++++++++++++++++++ .../product-management/InventoryTracking.tsx | 2 +- .../product-management/ProductList.tsx | 2 +- .../collection-form/description-editor.tsx | 143 +++++++ .../collection-form/form-page.tsx | 145 +++++++ .../collection-form/image-section.tsx | 100 +++++ .../collection-form/product-section.tsx | 302 ++++++++++++++ .../collection-form/publication-section.tsx | 71 ++++ .../collection-form/search-input.tsx | 27 ++ .../collections/collections-footer.tsx | 12 + .../collections/collections-header.tsx | 28 ++ .../collections/collections-page.tsx | 35 ++ .../collections/collections-table.tsx | 42 ++ .../collections/collections-tabs.tsx | 50 +++ .../components/search-bar/SearchRoutes.tsx | 2 +- app/store/components/sidebar/app-sidebar.tsx | 8 +- app/store/hooks/useCollections.ts | 236 +++++++++++ app/store/hooks/useS3Images.ts | 172 ++++++++ components/ui/switch.tsx | 27 ++ package-lock.json | 30 ++ package.json | 1 + utils/routes.ts | 7 +- 29 files changed, 2156 insertions(+), 13 deletions(-) create mode 100644 amplify/functions/storeImages/handler.ts create mode 100644 amplify/functions/storeImages/resource.ts create mode 100644 app/store/[slug]/collections/new/page.tsx create mode 100644 app/store/[slug]/collections/page.tsx rename app/store/[slug]/{products => }/inventory/page.tsx (100%) create mode 100644 app/store/components/images-selector/image-selector-modal.tsx create mode 100644 app/store/components/product-management/collection-form/description-editor.tsx create mode 100644 app/store/components/product-management/collection-form/form-page.tsx create mode 100644 app/store/components/product-management/collection-form/image-section.tsx create mode 100644 app/store/components/product-management/collection-form/product-section.tsx create mode 100644 app/store/components/product-management/collection-form/publication-section.tsx create mode 100644 app/store/components/product-management/collection-form/search-input.tsx create mode 100644 app/store/components/product-management/collections/collections-footer.tsx create mode 100644 app/store/components/product-management/collections/collections-header.tsx create mode 100644 app/store/components/product-management/collections/collections-page.tsx create mode 100644 app/store/components/product-management/collections/collections-table.tsx create mode 100644 app/store/components/product-management/collections/collections-tabs.tsx create mode 100644 app/store/hooks/useCollections.ts create mode 100644 app/store/hooks/useS3Images.ts create mode 100644 components/ui/switch.tsx diff --git a/amplify/backend.ts b/amplify/backend.ts index 66bfde87..5cb854f6 100644 --- a/amplify/backend.ts +++ b/amplify/backend.ts @@ -12,6 +12,7 @@ import { postConfirmation } from './auth/post-confirmation/resource' import { apiKeyManager } from './functions/LambdaEncryptKeys/resource' import { getStoreProducts } from './functions/getStoreProducts/resource' import { getStoreData } from './functions/getStoreData/resource' +import { storeImages } from './functions/storeImages/resource' import { data, generateHaikuFunction, @@ -47,6 +48,7 @@ const backend = defineBackend({ templates, getStoreProducts, getStoreData, + storeImages, }) backend.generateHaikuFunction.resources.lambda.addToRolePolicy( @@ -110,13 +112,16 @@ backend.postConfirmation.resources.lambda.addToRolePolicy( }) ) -const s3Bucket = backend.templates.resources.bucket - -const cfnBucket = s3Bucket.node.defaultChild as s3.CfnBucket - -cfnBucket.accelerateConfiguration = { - accelerationStatus: 'Enabled', -} +backend.storeImages.resources.lambda.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3:ListBucket', 's3:GetObject', 's3:PutObject', 's3:DeleteObject'], + resources: [ + backend.productsImages.resources.bucket.bucketArn, + `${backend.productsImages.resources.bucket.bucketArn}/*`, + ], + }) +) const apiStack = backend.createStack('api-stack') @@ -328,6 +333,27 @@ const getStoreDataIntegration = new LambdaIntegration(backend.getStoreData.resou const getStoreDataResource = getStoreDataApi.root.addResource('get-store-data') getStoreDataResource.addMethod('GET', getStoreDataIntegration) +/** + * + * API para Almacenar Imágenes + * + */ + +const storeImagesApi = new RestApi(apiStack, 'StoreImagesApi', { + restApiName: 'StoreImagesApi', + deploy: true, + deployOptions: { stageName: 'dev' }, + defaultCorsPreflightOptions: { + allowOrigins: Cors.ALL_ORIGINS, + allowMethods: Cors.ALL_METHODS, + allowHeaders: Cors.DEFAULT_HEADERS, + }, +}) +const storeImagesIntegration = new LambdaIntegration(backend.storeImages.resources.lambda) +const storeImagesResource = storeImagesApi.root.addResource('store-images') + +storeImagesResource.addMethod('POST', storeImagesIntegration) + /** * * Política de IAM para Invocar las APIs @@ -347,6 +373,7 @@ const apiRestPolicy = new Policy(apiStack, 'RestApiPolicy', { `${apiKeyManagerApi.arnForExecuteApi('*', '/api-keys', 'dev')}`, `${getStoreProductsApi.arnForExecuteApi('*', '/get-store-products', 'dev')}`, `${getStoreDataApi.arnForExecuteApi('*', '/get-store-data', 'dev')}`, + `${storeImagesApi.arnForExecuteApi('*', '/store-images', 'dev')}`, ], }), ], @@ -409,6 +436,11 @@ backend.addOutput({ region: Stack.of(getStoreDataApi).region, apiName: getStoreDataApi.restApiName, }, + StoreImagesApi: { + endpoint: storeImagesApi.url, + region: Stack.of(storeImagesApi).region, + apiName: storeImagesApi.restApiName, + }, }, }, }) diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 5196993a..f1d871c4 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -8,6 +8,7 @@ import { checkStoreDomain } from '../functions/checkStoreDomain/resource' import { apiKeyManager } from '../functions/LambdaEncryptKeys/resource' import { getStoreProducts } from '../functions/getStoreProducts/resource' import { getStoreData } from '../functions/getStoreData/resource' +import { SortOrder } from '@aws-sdk/client-bedrock-runtime' export const MODEL_ID = 'us.anthropic.claude-3-haiku-20240307-v1:0' @@ -141,6 +142,24 @@ const schema = a allow.ownerDefinedIn('owner').to(['update', 'delete', 'read', 'create']), // Solo el creador puede editar y eliminar allow.guest().to(['read']), ]), + + Collection: a + .model({ + storeId: a.string().required(), // Relaciona la colección con la tienda + title: a.string().required(), // Nombre de la colección + description: a.string(), // Descripción de la colección + image: a.string(), // URL de la imagen de la colección + slug: a.string(), // URL amigable de la colección + isActive: a.boolean().required(), + sortOrder: a.integer(), // Orden de la colección + owner: a.string().required(), // Usuario que creo la colección + products: a.json(), // Array de productos [{id: string, quantity: number}] + }) + .secondaryIndexes(index => [index('storeId')]) + .authorization(allow => [ + allow.ownerDefinedIn('owner').to(['update', 'delete', 'read', 'create']), // Solo el creador puede editar y eliminar + allow.guest().to(['read']), // Visitantes pueden ver las colecciones + ]), }) .authorization(allow => [ allow.resource(postConfirmation), diff --git a/amplify/functions/storeImages/handler.ts b/amplify/functions/storeImages/handler.ts new file mode 100644 index 00000000..679eb5e8 --- /dev/null +++ b/amplify/functions/storeImages/handler.ts @@ -0,0 +1,243 @@ +import { + S3Client, + ListObjectsV2Command, + GetObjectCommand, + PutObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import { env } from '$amplify/env/storeImages' + +// Inicializar el cliente S3 +const s3Client = new S3Client() + +const bucketName = env.BUCKET_NAME + +export const handler = async (event: any) => { + try { + const body = event.body ? JSON.parse(event.body) : {} + const { action, storeId } = body + + if (!storeId) { + return { + statusCode: 400, + body: JSON.stringify({ message: 'Store ID is required' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + // Manejar diferentes acciones + switch (action) { + case 'list': + return await listImages(storeId, body.limit, body.prefix) + case 'upload': + return await uploadImage(storeId, body.filename, body.contentType, body.fileContent) + case 'delete': + return await deleteImage(body.key) + default: + return { + statusCode: 400, + body: JSON.stringify({ message: 'Invalid action' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + } catch (error) { + console.error('Error processing request:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error processing request' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} + +// Función para listar imágenes +async function listImages(storeId: string, limit: number = 1000, prefix: string = '') { + try { + // Configurar el prefijo para las imágenes de la tienda + const storePrefix = prefix ? `products/${storeId}/${prefix}` : `products/${storeId}/` + + // Listar objetos en el bucket con el prefijo de la tienda + const listCommand = new ListObjectsV2Command({ + Bucket: bucketName, + Prefix: storePrefix, + MaxKeys: limit, + }) + + const listResponse = await s3Client.send(listCommand) + + if (!listResponse.Contents) { + return { + statusCode: 200, + body: JSON.stringify({ images: [] }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } + + // Generar URLs firmadas para cada objeto + const imagePromises = listResponse.Contents.map(async item => { + if (!item.Key) return null + + // Omitir objetos de carpeta + if (item.Key.endsWith('/')) return null + + // Generar una URL firmada para el objeto + const command = new GetObjectCommand({ + Bucket: bucketName, + Key: item.Key, + }) + + const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }) + + // Extraer el nombre del archivo de la clave + const keyParts = item.Key.split('/') + const filename = keyParts[keyParts.length - 1] + + // Determinar el tipo de archivo a partir de la extensión + const fileExtension = filename.split('.').pop()?.toLowerCase() || '' + let fileType = 'application/octet-stream' + + if (fileExtension === 'jpg' || fileExtension === 'jpeg') fileType = 'image/jpeg' + else if (fileExtension === 'png') fileType = 'image/png' + else if (fileExtension === 'gif') fileType = 'image/gif' + else if (fileExtension === 'webp') fileType = 'image/webp' + + return { + key: item.Key, + url, + filename, + lastModified: item.LastModified, + size: item.Size, + type: fileType, + } + }) + + const imageResults = await Promise.all(imagePromises) + const validImages = imageResults.filter((img): img is NonNullable => img !== null) + + return { + statusCode: 200, + body: JSON.stringify({ images: validImages }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error listing images:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error listing images' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} + +// Función para subir una imagen +async function uploadImage( + storeId: string, + filename: string, + contentType: string, + fileContent: string +) { + try { + // Decodificar el contenido del archivo de base64 + const buffer = Buffer.from(fileContent, 'base64') + const timestamp = new Date().getTime() + const key = `products/${storeId}/${timestamp}-${filename}` + + // Subir el archivo a S3 + const putCommand = new PutObjectCommand({ + Bucket: bucketName, + Key: key, + Body: buffer, + ContentType: contentType, + }) + + await s3Client.send(putCommand) + + // Generar una URL firmada para el objeto subido + const getCommand = new GetObjectCommand({ + Bucket: bucketName, + Key: key, + }) + + const url = await getSignedUrl(s3Client, getCommand, { expiresIn: 3600 }) + + // Crear un objeto de imagen para devolver + const image = { + key, + url, + filename, + lastModified: new Date(), + size: buffer.length, + type: contentType, + } + + return { + statusCode: 200, + body: JSON.stringify({ image }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error uploading image:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error uploading image' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} + +// Función para eliminar una imagen +async function deleteImage(key: string) { + try { + // Eliminar el objeto de S3 + const deleteCommand = new DeleteObjectCommand({ + Bucket: bucketName, + Key: key, + }) + + await s3Client.send(deleteCommand) + + return { + statusCode: 200, + body: JSON.stringify({ success: true }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } catch (error) { + console.error('Error deleting image:', error) + return { + statusCode: 500, + body: JSON.stringify({ message: 'Error deleting image' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + } + } +} diff --git a/amplify/functions/storeImages/resource.ts b/amplify/functions/storeImages/resource.ts new file mode 100644 index 00000000..e128d4d6 --- /dev/null +++ b/amplify/functions/storeImages/resource.ts @@ -0,0 +1,10 @@ +import { defineFunction, secret } from '@aws-amplify/backend' + +export const storeImages = defineFunction({ + name: 'storeImages', + entry: 'handler.ts', + resourceGroupName: 'storeImages', + environment: { + BUCKET_NAME: secret('BUCKET_NAME'), + }, +}) diff --git a/app/store/[slug]/collections/new/page.tsx b/app/store/[slug]/collections/new/page.tsx new file mode 100644 index 00000000..faba237a --- /dev/null +++ b/app/store/[slug]/collections/new/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import { FormPage } from '@/app/store/components/product-management/collection-form/form-page' +import { Amplify } from 'aws-amplify' +import outputs from '@/amplify_outputs.json' + +Amplify.configure(outputs) +const existingConfig = Amplify.getConfig() +Amplify.configure({ + ...existingConfig, + API: { + ...existingConfig.API, + REST: outputs.custom.APIs, + }, +}) + +export default function CollectionPage() { + return +} diff --git a/app/store/[slug]/collections/page.tsx b/app/store/[slug]/collections/page.tsx new file mode 100644 index 00000000..39f22b17 --- /dev/null +++ b/app/store/[slug]/collections/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import { CollectionsPage } from '@/app/store/components/product-management/collections/collections-page' +import { Amplify } from 'aws-amplify' +import outputs from '@/amplify_outputs.json' + +Amplify.configure(outputs) +const existingConfig = Amplify.getConfig() +Amplify.configure({ + ...existingConfig, + API: { + ...existingConfig.API, + REST: outputs.custom.APIs, + }, +}) + +export default function CollectionsPages() { + return +} diff --git a/app/store/[slug]/products/inventory/page.tsx b/app/store/[slug]/inventory/page.tsx similarity index 100% rename from app/store/[slug]/products/inventory/page.tsx rename to app/store/[slug]/inventory/page.tsx diff --git a/app/store/components/images-selector/image-selector-modal.tsx b/app/store/components/images-selector/image-selector-modal.tsx new file mode 100644 index 00000000..16216621 --- /dev/null +++ b/app/store/components/images-selector/image-selector-modal.tsx @@ -0,0 +1,371 @@ +import { useState, useRef, useCallback } from 'react' +import { Search, Grid, List, Upload, Trash2, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu' +import { useS3Images, type S3Image } from '@/app/store/hooks/useS3Images' +import Image from 'next/image' + +interface ImageSelectorModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSelect?: (image: S3Image | null) => void + initialSelectedImage?: string | null +} + +export default function ImageSelectorModal({ + open, + onOpenChange, + onSelect, + initialSelectedImage = null, +}: ImageSelectorModalProps) { + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [selectedImage, setSelectedImage] = useState(initialSelectedImage) + const [searchTerm, setSearchTerm] = useState('') + const fileInputRef = useRef(null) + const [isUploading, setIsUploading] = useState(false) + const [uploadPreview, setUploadPreview] = useState(null) + // Usar el hook useS3Images para obtener, cargar y eliminar imágenes + const { images, loading, error, uploadImage, deleteImage } = useS3Images({ + limit: 100, + }) + // Filtrar imágenes según el término de búsqueda + const filteredImages = images.filter(img => + img.filename.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + // Manejar la selección de imágenes + const handleImageSelect = (image: S3Image) => { + const newSelectedKey = selectedImage === image.key ? null : image.key + setSelectedImage(newSelectedKey) + } + + // Manejar la confirmación de selección + const handleConfirm = () => { + const selected = images.find(img => img.key === selectedImage) || null + if (onSelect) { + onSelect(selected) + } + onOpenChange(false) + } + + // Manejar la carga de archivos + const handleFileUpload = async (event: React.ChangeEvent) => { + const files = event.target.files + if (!files || files.length === 0) return + + const file = files[0] + setIsUploading(true) + + // Crear una vista previa de la imagen + const reader = new FileReader() + reader.onload = e => { + if (e.target?.result) { + setUploadPreview(e.target.result as string) + } + } + reader.readAsDataURL(file) + + try { + const uploadedImage = await uploadImage(file) + if (uploadedImage) { + setSelectedImage(uploadedImage.key) + } + } catch (error) { + console.error('Error uploading image:', error) + } finally { + setIsUploading(false) + setUploadPreview(null) + // Limpiar el input de archivo + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + + // Manejar la eliminación de imágenes + const handleDeleteImage = async (key: string) => { + try { + const success = await deleteImage(key) + if (success) { + if (selectedImage === key) { + setSelectedImage(null) + } + } + } catch (error) {} + } + + // Manejar el arrastrar y soltar + const onDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const file = e.dataTransfer.files[0] + + try { + const uploadedImage = await uploadImage(file) + if (uploadedImage) { + setSelectedImage(uploadedImage.key) + } + } catch (error) {} + } + }, + [uploadImage] + ) + + const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + return ( + + + +
+ Seleccionar imagen +
+
+ +
+ {/* Search and filters */} +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + + + + + setViewMode('grid')}> + + Cuadrícula + + setViewMode('list')}> + + Lista + + + +
+
+ + {/* Drop zone */} +
+ + +

Arrastrar y soltar imágenes

+
+ + {/* Upload preview */} + {isUploading && uploadPreview && ( +
+
+ Preview +
+
+ + Subiendo imagen... +
+
+ )} + + {/* Loading state */} + {loading && ( +
+ + Cargando imágenes... +
+ )} + + {/* Error state */} + {error && ( +
+ Error al cargar las imágenes. Por favor, intenta de nuevo. +
+ )} + + {/* Empty state */} + {!loading && filteredImages.length === 0 && ( +
+ No hay imágenes disponibles. Sube algunas imágenes para comenzar. +
+ )} + + {/* Image gallery - Grid view */} + {viewMode === 'grid' && ( +
+ {filteredImages.map((image, index) => ( +
handleImageSelect(image)} + > +
+ handleImageSelect(image)} + onClick={e => e.stopPropagation()} + /> +
+
+ +
+
+ {image.filename} +
+ +
+
{image.filename}
+
+ {image.type?.split('/')[1]?.toUpperCase() || 'IMG'} +
+
+
+ ))} +
+ )} + + {/* Image gallery - List view */} + {viewMode === 'list' && ( +
+ {filteredImages.map((image, index) => ( +
handleImageSelect(image)} + > +
+ handleImageSelect(image)} + onClick={e => e.stopPropagation()} + /> +
+
+ {image.filename} +
+
+
{image.filename}
+
+ {image.type?.split('/')[1]?.toUpperCase() || 'IMG'} • + {image.size ? ` ${Math.round(image.size / 1024)} KB` : ''} +
+
+ +
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/app/store/components/product-management/InventoryTracking.tsx b/app/store/components/product-management/InventoryTracking.tsx index 88f08f89..a2720cfc 100644 --- a/app/store/components/product-management/InventoryTracking.tsx +++ b/app/store/components/product-management/InventoryTracking.tsx @@ -5,7 +5,7 @@ import { Icons } from '@/app/store/icons/index' export function InventoryTracking() { return ( -
+

Inventario

diff --git a/app/store/components/product-management/ProductList.tsx b/app/store/components/product-management/ProductList.tsx index 3b81b239..9e20d38d 100644 --- a/app/store/components/product-management/ProductList.tsx +++ b/app/store/components/product-management/ProductList.tsx @@ -100,7 +100,7 @@ export function ProductList({
{/* Header con título y acciones */}
-

Productos

+

Productos

(null) + + useEffect(() => { + const handleSelectionChange = () => { + if (window.getSelection()?.toString()) { + // Update formatting states based on current selection + setIsBold(document.queryCommandState('bold')) + setIsItalic(document.queryCommandState('italic')) + setIsUnderline(document.queryCommandState('underline')) + } + } + + document.addEventListener('selectionchange', handleSelectionChange) + return () => { + document.removeEventListener('selectionchange', handleSelectionChange) + } + }, []) + + return ( +
+
+ +
+ + + + +
+
+ + +
+
+ + +
+ ) +} diff --git a/app/store/components/product-management/collections/collections-tabs.tsx b/app/store/components/product-management/collections/collections-tabs.tsx new file mode 100644 index 00000000..99f6ade3 --- /dev/null +++ b/app/store/components/product-management/collections/collections-tabs.tsx @@ -0,0 +1,50 @@ +'use client' + +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Input } from '@/components/ui/input' +import { Search } from 'lucide-react' +import { useState } from 'react' + +export default function CollectionsTabs({ + onFilterChange, +}: { + onFilterChange?: (filter: string, search: string) => void +}) { + const [activeTab, setActiveTab] = useState('all') + const [searchTerm, setSearchTerm] = useState('') + + const handleTabChange = (value: string) => { + setActiveTab(value) + if (onFilterChange) { + onFilterChange(value, searchTerm) + } + } + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value) + if (onFilterChange) { + onFilterChange(activeTab, e.target.value) + } + } + + return ( +
+ + + Todas + Activas + Inactivas + + +
+ + +
+
+ ) +} diff --git a/app/store/components/search-bar/SearchRoutes.tsx b/app/store/components/search-bar/SearchRoutes.tsx index 771c6c36..571f3b42 100644 --- a/app/store/components/search-bar/SearchRoutes.tsx +++ b/app/store/components/search-bar/SearchRoutes.tsx @@ -57,7 +57,7 @@ export function generateSearchRoutes(storeId: string): SearchRoute[] { keywords: ['nuevo', 'crear', 'agregar'], }, { - path: routes.store.products.categories(storeId), + path: routes.store.categories(storeId), label: 'Categorías', icon: Package, section: 'Productos', diff --git a/app/store/components/sidebar/app-sidebar.tsx b/app/store/components/sidebar/app-sidebar.tsx index f18d3cc7..e07cc8ae 100644 --- a/app/store/components/sidebar/app-sidebar.tsx +++ b/app/store/components/sidebar/app-sidebar.tsx @@ -53,13 +53,17 @@ export function AppSidebar({ ...props }: React.ComponentProps) { url: routes.store.products.main(storeId), icon: ShoppingBag, items: [ + { + title: 'Colecciones', + url: routes.store.collections(storeId), + }, { title: 'Inventario', - url: routes.store.products.list(storeId), + url: routes.store.inventory(storeId), }, { title: 'Categorías', - url: routes.store.products.categories(storeId), + url: routes.store.categories(storeId), }, ], }, diff --git a/app/store/hooks/useCollections.ts b/app/store/hooks/useCollections.ts new file mode 100644 index 00000000..3a6d5295 --- /dev/null +++ b/app/store/hooks/useCollections.ts @@ -0,0 +1,236 @@ +import { useState } from 'react' +import { generateClient } from 'aws-amplify/data' +import type { Schema } from '@/amplify/data/resource' +import { useQuery, useMutation, useQueryClient, UseQueryResult } from '@tanstack/react-query' + +const client = generateClient() + +// Clave base para las consultas de colecciones +const COLLECTIONS_KEY = 'collections' + +/** + * Interfaz para los datos de entrada de una colección + */ +export interface CollectionInput { + storeId: string // ID de la tienda a la que pertenece la colección + title: string // Título de la colección + description?: string // Descripción opcional de la colección + image?: string // URL de la imagen de la colección (opcional) + products?: any[] // Array de productos en la colección (opcional) + slug?: string // URL amigable para la colección (opcional) + isActive: boolean // Indica si la colección está activa + sortOrder?: number // Orden de clasificación (opcional) + owner: string // Usuario propietario de la colección +} + +/** + * Hook personalizado para gestionar colecciones de productos con React Query + * + * Este hook proporciona funciones para crear, leer, actualizar y eliminar colecciones, + * así como para gestionar los productos dentro de las colecciones. + * Utiliza React Query para mantener los datos en caché durante 5 minutos. + */ +export const useCollections = () => { + const [error, setError] = useState(null) // Estado de error + const queryClient = useQueryClient() + + /** + * Función auxiliar para ejecutar operaciones con manejo de errores + * @param operation - Función que realiza la operación con la API + * @returns Los datos resultantes o null en caso de error + */ + const performOperation = async ( + operation: () => Promise<{ data: T; errors?: any[] }> + ): Promise => { + setError(null) + try { + const result = await operation() + if (result.errors && result.errors.length > 0) { + setError(result.errors) + throw new Error(result.errors[0].message || 'Error en la operación') + } + return result.data + } catch (err) { + setError(err) + throw err + } + } + + /** + * Obtiene una colección por su ID usando React Query + * @param id - ID de la colección a obtener + * @returns Resultado de la consulta con la colección + */ + const useGetCollection = (id: string): UseQueryResult => { + return useQuery({ + queryKey: [COLLECTIONS_KEY, id], + queryFn: () => + performOperation(() => client.models.Collection.get({ id }, { authMode: 'userPool' })), + staleTime: 5 * 60 * 1000, // 5 minutos en caché + enabled: !!id, + }) + } + + /** + * Lista todas las colecciones, opcionalmente filtradas por tienda usando React Query + * @param storeId - ID de la tienda para filtrar (opcional) + * @returns Resultado de la consulta con el array de colecciones + */ + const useListCollections = (storeId?: string): UseQueryResult => { + return useQuery({ + queryKey: [COLLECTIONS_KEY, 'list', storeId], + queryFn: () => { + const filter = storeId ? { storeId: { eq: storeId } } : undefined + return performOperation(() => + client.models.Collection.list({ + filter, + authMode: 'userPool', + }) + ) + }, + staleTime: 5 * 60 * 1000, // 5 minutos en caché + }) + } + + /** + * Crea una nueva colección usando React Query + */ + const useCreateCollection = () => { + return useMutation({ + mutationFn: (collectionInput: CollectionInput) => + performOperation(() => + client.models.Collection.create(collectionInput, { + authMode: 'userPool', + }) + ), + onSuccess: () => { + // Invalidar consultas para actualizar la lista + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY, 'list'] }) + }, + }) + } + + /** + * Actualiza una colección existente usando React Query + */ + const useUpdateCollection = () => { + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial }) => + performOperation(() => + client.models.Collection.update( + { + id, + ...data, + }, + { + authMode: 'userPool', + } + ) + ), + onSuccess: data => { + // Actualizar la colección en caché + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY, data?.id] }) + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY, 'list'] }) + }, + }) + } + + /** + * Elimina una colección usando React Query + */ + const useDeleteCollection = () => { + return useMutation({ + mutationFn: (id: string) => + performOperation(() => client.models.Collection.delete({ id }, { authMode: 'userPool' })), + onSuccess: (_, id) => { + // Eliminar la colección de la caché + queryClient.removeQueries({ queryKey: [COLLECTIONS_KEY, id] }) + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY, 'list'] }) + }, + }) + } + + /** + * Añade un producto a una colección + * @param collectionId - ID de la colección + * @param product - Datos del producto a añadir + * @returns La colección actualizada o null en caso de error + */ + const addProductToCollection = async (collectionId: string, product: any) => { + // Obtener la colección actual de la caché o de la API + const collection = await queryClient.fetchQuery({ + queryKey: [COLLECTIONS_KEY, collectionId], + queryFn: () => + performOperation(() => + client.models.Collection.get({ id: collectionId }, { authMode: 'userPool' }) + ), + }) + + if (!collection) throw new Error('Colección no encontrada') + + // Obtenemos los productos actuales o inicializamos un array vacío + const currentProducts = collection.products || [] + + // Verificamos si el producto ya existe en la colección + if (Array.isArray(currentProducts) && currentProducts.some((p: any) => p.id === product.id)) { + return collection + } + + // Añadimos el nuevo producto + const updatedProducts = Array.isArray(currentProducts) + ? [...currentProducts, product] + : [product] + + // Actualizamos la colección + const updateMutation = useUpdateCollection() + return updateMutation.mutateAsync({ + id: collectionId, + data: { products: updatedProducts }, + }) + } + + /** + * Elimina un producto de una colección + * @param collectionId - ID de la colección + * @param productId - ID del producto a eliminar + * @returns La colección actualizada o null en caso de error + */ + const removeProductFromCollection = async (collectionId: string, productId: string) => { + // Obtener la colección actual de la caché o de la API + const collection = await queryClient.fetchQuery({ + queryKey: [COLLECTIONS_KEY, collectionId], + queryFn: () => + performOperation(() => + client.models.Collection.get({ id: collectionId }, { authMode: 'userPool' }) + ), + }) + + if (!collection) throw new Error('Colección no encontrada') + + // Obtenemos los productos actuales o inicializamos un array vacío + const currentProducts = collection.products || [] + + // Filtramos el producto a eliminar + const updatedProducts = Array.isArray(currentProducts) + ? currentProducts.filter((p: any) => p.id !== productId) + : [] + + // Actualizamos la colección + const updateMutation = useUpdateCollection() + return updateMutation.mutateAsync({ + id: collectionId, + data: { products: updatedProducts }, + }) + } + + return { + error, + useGetCollection, + useListCollections, + useCreateCollection, + useUpdateCollection, + useDeleteCollection, + addProductToCollection, + removeProductFromCollection, + } +} diff --git a/app/store/hooks/useS3Images.ts b/app/store/hooks/useS3Images.ts new file mode 100644 index 00000000..816b92cf --- /dev/null +++ b/app/store/hooks/useS3Images.ts @@ -0,0 +1,172 @@ +import { useState, useEffect } from 'react' +import { post } from 'aws-amplify/api' +import useStoreDataStore from '@/zustand-states/storeDataStore' + +export interface S3Image { + key: string + url: string + filename: string + lastModified?: Date + size?: number + type?: string +} + +interface UseS3ImagesOptions { + limit?: number + prefix?: string +} + +// Definir el tipo de la respuesta esperada del API +interface S3ImagesResponse { + images?: S3Image[] + success?: boolean + image?: S3Image +} + +export function useS3Images(options: UseS3ImagesOptions = {}) { + const [images, setImages] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const { storeId } = useStoreDataStore() + + useEffect(() => { + const fetchImages = async () => { + if (!storeId) { + setLoading(false) + setImages([]) + return + } + + setLoading(true) + try { + const restOperation = post({ + apiName: 'StoreImagesApi', + path: 'store-images', + options: { + body: { + action: 'list', + storeId, + limit: options.limit || 1000, + prefix: options.prefix || '', + }, + }, + }) + + const { body } = await restOperation.response + const response = (await body.json()) as S3ImagesResponse + + if (!response.images) { + setImages([]) + return + } + + const processedImages = response.images.map(img => ({ + ...img, + lastModified: img.lastModified ? new Date(img.lastModified) : undefined, + })) + + setImages(processedImages) + } catch (err) { + console.error('Error fetching S3 images:', err) + setError(err instanceof Error ? err : new Error('Unknown error occurred')) + } finally { + setLoading(false) + } + } + + fetchImages() + }, [storeId, options.prefix, options.limit]) + + const uploadImage = async (file: File): Promise => { + if (!storeId || !file) return null + + try { + const base64File = await fileToBase64(file) + + const restOperation = post({ + apiName: 'StoreImagesApi', + path: 'store-images', + options: { + body: { + action: 'upload', + storeId, + filename: file.name, + contentType: file.type, + fileContent: base64File, + }, + }, + }) + + const { body } = await restOperation.response + const response = (await body.json()) as S3ImagesResponse + + if (!response.image) { + throw new Error('Failed to upload image') + } + + const newImage = { + ...response.image, + lastModified: response.image.lastModified + ? new Date(response.image.lastModified) + : new Date(), + } + + setImages(prev => [newImage, ...prev]) + + return newImage + } catch (err) { + console.error('Error uploading image:', err) + return null + } + } + + const deleteImage = async (key: string): Promise => { + if (!storeId) return false + + try { + const restOperation = post({ + apiName: 'StoreImagesApi', + path: 'store-images', + options: { + body: { + action: 'delete', + storeId, + key, + }, + }, + }) + + const { body } = await restOperation.response + const response = (await body.json()) as S3ImagesResponse + + if (!response.success) { + throw new Error('Failed to delete image') + } + + setImages(prev => prev.filter(img => img.key !== key)) + + return true + } catch (err) { + console.error('Error deleting image:', err) + return false + } + } + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => { + if (typeof reader.result === 'string') { + const base64 = reader.result.split(',')[1] + resolve(base64) + } else { + reject(new Error('Failed to convert file to base64')) + } + } + reader.onerror = error => reject(error) + }) + } + + return { images, loading, error, uploadImage, deleteImage } +} diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx new file mode 100644 index 00000000..654ff1bf --- /dev/null +++ b/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from 'react' +import * as SwitchPrimitives from '@radix-ui/react-switch' + +import { cn } from '@/lib/utils' + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/package-lock.json b/package-lock.json index f157d1cf..94156925 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", @@ -24127,6 +24128,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", + "integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", diff --git a/package.json b/package.json index 7442abf2..19a6f7d6 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", diff --git a/utils/routes.ts b/utils/routes.ts index def3b242..50205c9d 100644 --- a/utils/routes.ts +++ b/utils/routes.ts @@ -11,11 +11,16 @@ export const routes = { list: (storeId: string) => `/store/${storeId}/products/inventory `, add: (storeId: string) => `/store/${storeId}/products/new`, edit: (storeId: string, productId: string) => `/store/${storeId}/products/${productId}`, - categories: (storeId: string) => `/store/${storeId}/products/categories`, }, orders: (storeId: string) => `/store/${storeId}/orders`, customers: (storeId: string) => `/store/${storeId}/customers`, masterShop: (storeId: string) => `/store/${storeId}/mastershop`, + collections: (storeId: string) => `/store/${storeId}/collections`, + categories: (storeId: string) => `/store/${storeId}/categories`, + inventory: (storeId: string) => `/store/${storeId}/inventory`, + collectionsNew: (storeId: string) => `/store/${storeId}/collections/new`, + collectionsEdit: (storeId: string, collectionId: string) => + `/store/${storeId}/collections/${collectionId}`, setup: { main: (storeId: string) => `/store/${storeId}/setup`, From cbf88b22dcfce7b91313284dc5130f5b5ed8f0d3 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Tue, 1 Apr 2025 13:38:47 -0500 Subject: [PATCH 03/38] feat(store): implement collection management with form, table, and CloudFront integration - Added collection form with CRUD operations, product selection, and unsaved changes alert - Implemented collections table with filtering, sorting, and CloudFront URLs for images - Integrated CloudFront for image URLs in product uploads and collections - Enhanced UI components for collection management with loading states and error handling --- amplify/data/resource.ts | 4 +- amplify/functions/storeImages/handler.ts | 26 +- .../hooks/useUpdateProfilePicture.ts | 3 + .../my-store/hooks/useUserStores.ts | 12 - .../collections/[collectionId]/page.tsx | 7 + .../images-selector/image-selector-modal.tsx | 4 +- .../collection-form/description-editor.tsx | 66 ++-- .../collection-form/form-page.tsx | 135 +++++-- .../collection-form/image-section.tsx | 35 +- .../collection-form/product-section.tsx | 6 +- .../collection-form/publication-section.tsx | 10 +- .../collections/collections-page.tsx | 115 +++++- .../collections/collections-table.tsx | 58 ++- .../collections/collections-tabs.tsx | 24 +- .../utils/collection-form-utils.ts | 343 ++++++++++++++++++ app/store/hooks/useCollections.ts | 139 +++---- app/store/hooks/useProductImageUpload.ts | 3 +- components/ui/unsaved-changes-alert.tsx | 2 +- next.config.js | 5 + 19 files changed, 807 insertions(+), 190 deletions(-) create mode 100644 app/store/[slug]/collections/[collectionId]/page.tsx create mode 100644 app/store/components/product-management/utils/collection-form-utils.ts diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index f1d871c4..2ff49893 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -135,7 +135,9 @@ const schema = a featured: a.boolean(), // Producto destacado tags: a.json(), // Array de etiquetas variants: a.json(), // Variantes del producto + collectionId: a.string(), // ID de la colección supplier: a.string(), // Proveedor del producto + collection: a.belongsTo('Collection', 'collectionId'), // Relación con la colección owner: a.string().required(), // Usuario que creo el producto }) .authorization(allow => [ @@ -153,7 +155,7 @@ const schema = a isActive: a.boolean().required(), sortOrder: a.integer(), // Orden de la colección owner: a.string().required(), // Usuario que creo la colección - products: a.json(), // Array de productos [{id: string, quantity: number}] + products: a.hasMany('Product', 'collectionId'), // Relación con productos }) .secondaryIndexes(index => [index('storeId')]) .authorization(allow => [ diff --git a/amplify/functions/storeImages/handler.ts b/amplify/functions/storeImages/handler.ts index 679eb5e8..06830f2c 100644 --- a/amplify/functions/storeImages/handler.ts +++ b/amplify/functions/storeImages/handler.ts @@ -12,6 +12,8 @@ import { env } from '$amplify/env/storeImages' const s3Client = new S3Client() const bucketName = env.BUCKET_NAME +// URL base de CloudFront +const cloudFrontDomain = 'https://d1etr7t5j9fzio.cloudfront.net' export const handler = async (event: any) => { try { @@ -86,20 +88,15 @@ async function listImages(storeId: string, limit: number = 1000, prefix: string } } - // Generar URLs firmadas para cada objeto + // Generar URLs para cada objeto usando CloudFront const imagePromises = listResponse.Contents.map(async item => { if (!item.Key) return null // Omitir objetos de carpeta if (item.Key.endsWith('/')) return null - // Generar una URL firmada para el objeto - const command = new GetObjectCommand({ - Bucket: bucketName, - Key: item.Key, - }) - - const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }) + // Construir la URL de CloudFront + const cloudFrontUrl = `${cloudFrontDomain}/${item.Key}` // Extraer el nombre del archivo de la clave const keyParts = item.Key.split('/') @@ -116,7 +113,7 @@ async function listImages(storeId: string, limit: number = 1000, prefix: string return { key: item.Key, - url, + url: cloudFrontUrl, filename, lastModified: item.LastModified, size: item.Size, @@ -171,18 +168,13 @@ async function uploadImage( await s3Client.send(putCommand) - // Generar una URL firmada para el objeto subido - const getCommand = new GetObjectCommand({ - Bucket: bucketName, - Key: key, - }) - - const url = await getSignedUrl(s3Client, getCommand, { expiresIn: 3600 }) + // Construir la URL de CloudFront + const cloudFrontUrl = `${cloudFrontDomain}/${key}` // Crear un objeto de imagen para devolver const image = { key, - url, + url: cloudFrontUrl, filename, lastModified: new Date(), size: buffer.length, diff --git a/app/(with-navbar)/account-settings/hooks/useUpdateProfilePicture.ts b/app/(with-navbar)/account-settings/hooks/useUpdateProfilePicture.ts index 2fac29d3..bdb27c7e 100644 --- a/app/(with-navbar)/account-settings/hooks/useUpdateProfilePicture.ts +++ b/app/(with-navbar)/account-settings/hooks/useUpdateProfilePicture.ts @@ -24,6 +24,9 @@ export function useUpdateProfilePicture() { const result = await uploadData({ path: `public/profile-pictures/${userData?.sub}/${uniqueFileName}`, data: file, + options: { + contentType: file.type, + }, }).result // 2. Construir la URL pública manualmente. diff --git a/app/(without-navbar)/my-store/hooks/useUserStores.ts b/app/(without-navbar)/my-store/hooks/useUserStores.ts index 25d4a1d1..09e993af 100644 --- a/app/(without-navbar)/my-store/hooks/useUserStores.ts +++ b/app/(without-navbar)/my-store/hooks/useUserStores.ts @@ -29,7 +29,6 @@ export const useUserStores = (userId: string | null, userPlan?: string) => { useEffect(() => { const fetchStores = async () => { if (!userId) { - console.log('useUserStores: No userId provided, returning empty stores array') setStores([]) setAllStores([]) setCanCreateStore(false) @@ -38,8 +37,6 @@ export const useUserStores = (userId: string | null, userPlan?: string) => { } try { - console.log(`useUserStores: Fetching stores for userId: ${userId}`) - // Obtener todas las tiendas del usuario (para verificar límites) const { data: allUserStores } = await client.models.UserStore.list({ authMode: 'userPool', @@ -49,9 +46,6 @@ export const useUserStores = (userId: string | null, userPlan?: string) => { selectionSet: ['storeId', 'storeName', 'storeType', 'onboardingCompleted'], }) - console.log(`useUserStores: Found ${allUserStores?.length || 0} total stores for user`) - console.log('useUserStores: All stores data:', JSON.stringify(allUserStores)) - // Guardar todas las tiendas setAllStores(allUserStores || []) @@ -60,16 +54,10 @@ export const useUserStores = (userId: string | null, userPlan?: string) => { allUserStores?.filter(store => store.onboardingCompleted === true) || [] setStores(completedStores) - console.log(`useUserStores: Found ${completedStores.length} completed stores for user`) - // Verificar límite de tiendas según el plan const currentCount = allUserStores?.length || 0 const limit = userPlan ? STORE_LIMITS[userPlan as keyof typeof STORE_LIMITS] || 0 : 0 setCanCreateStore(currentCount < limit) - - console.log( - `useUserStores: Store limit check - Current: ${currentCount}, Limit: ${limit}, Can create: ${currentCount < limit}` - ) } catch (err) { console.error('useUserStores: Error fetching stores:', err) setError(err) diff --git a/app/store/[slug]/collections/[collectionId]/page.tsx b/app/store/[slug]/collections/[collectionId]/page.tsx new file mode 100644 index 00000000..8fff0c4b --- /dev/null +++ b/app/store/[slug]/collections/[collectionId]/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import { FormPage } from '@/app/store/components/product-management/collection-form/form-page' + +export default function CollectionEditPage() { + return +} diff --git a/app/store/components/images-selector/image-selector-modal.tsx b/app/store/components/images-selector/image-selector-modal.tsx index 16216621..dba3cb64 100644 --- a/app/store/components/images-selector/image-selector-modal.tsx +++ b/app/store/components/images-selector/image-selector-modal.tsx @@ -277,7 +277,7 @@ export default function ImageSelectorModal({ priority={selectedImage === image.key || index < 12} className="object-cover w-full h-full hover:scale-105 transition-transform duration-200" style={{ objectFit: 'cover' }} - loading={index < 12 ? 'eager' : 'lazy'} + loading={selectedImage === image.key || index < 12 ? undefined : 'lazy'} placeholder="blur" blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjBmMGYwIi8+PC9zdmc+" /> @@ -324,7 +324,7 @@ export default function ImageSelectorModal({ priority={selectedImage === image.key || index < 20} className="object-cover w-full h-full rounded" style={{ objectFit: 'cover' }} - loading={index < 20 ? 'eager' : 'lazy'} + loading={selectedImage === image.key || index < 20 ? undefined : 'lazy'} placeholder="blur" blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOTYiIGhlaWdodD0iOTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0iI2YwZjBmMCIvPjwvc3ZnPg==" /> diff --git a/app/store/components/product-management/collection-form/description-editor.tsx b/app/store/components/product-management/collection-form/description-editor.tsx index e2769628..89d2c594 100644 --- a/app/store/components/product-management/collection-form/description-editor.tsx +++ b/app/store/components/product-management/collection-form/description-editor.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react' -import { ChevronDown, Code, ImageIcon, Link, MoreHorizontal, Table } from 'lucide-react' +import { ChevronDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { Select, @@ -9,13 +9,28 @@ import { SelectValue, } from '@/components/ui/select' -export function DescriptionEditor() { - const [editorContent, setEditorContent] = useState('') +export function DescriptionEditor({ + initialValue = '', + onChange, +}: { + initialValue?: string + onChange: (content: string) => void +}) { + // Implementar lógica para manejar el cambio y pasar el valor al componente padre + const [editorContent, setEditorContent] = useState(initialValue) const [isBold, setIsBold] = useState(false) const [isItalic, setIsItalic] = useState(false) const [isUnderline, setIsUnderline] = useState(false) const editorRef = useRef(null) + // Inicializar el contenido del editor con el valor inicial + useEffect(() => { + if (editorRef.current && initialValue) { + editorRef.current.innerHTML = initialValue + setEditorContent(initialValue) + } + }, [initialValue]) + useEffect(() => { const handleSelectionChange = () => { if (window.getSelection()?.toString()) { @@ -32,6 +47,12 @@ export function DescriptionEditor() { } }, []) + // Función para actualizar el contenido y notificar al componente padre + const updateContent = (content: string) => { + setEditorContent(content) + onChange(content) + } + return (
@@ -83,40 +104,8 @@ export function DescriptionEditor() {
-
- - -
-
- - -
+ ) +} + 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/collections/collections-table.tsx b/app/store/components/product-management/collections/collections-table.tsx index 2eca4b36..378255f1 100644 --- a/app/store/components/product-management/collections/collections-table.tsx +++ b/app/store/components/product-management/collections/collections-table.tsx @@ -8,8 +8,16 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import Link from 'next/link' +import { routes } from '@/utils/routes' -export default function CollectionsTable() { +// Definir la interfaz para las props +interface CollectionsTableProps { + collections: any[] + storeId: string +} + +export default function CollectionsTable({ collections, storeId }: CollectionsTableProps) { return (
@@ -24,17 +32,43 @@ export default function CollectionsTable() { - - - - - - - Página de inicio - - 1 - - + {collections.length === 0 ? ( + + + No hay colecciones disponibles + + + ) : ( + collections.map(collection => ( + + + + + + + + + {collection.title || 'Sin título'} + + + {collection.products?.length || 0} + + {collection.isActive ? ( + + Activa + + ) : ( + + Borrador + + )} + + + )) + )}
diff --git a/app/store/components/product-management/collections/collections-tabs.tsx b/app/store/components/product-management/collections/collections-tabs.tsx index 99f6ade3..13440a58 100644 --- a/app/store/components/product-management/collections/collections-tabs.tsx +++ b/app/store/components/product-management/collections/collections-tabs.tsx @@ -1,35 +1,41 @@ -'use client' - import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Input } from '@/components/ui/input' import { Search } from 'lucide-react' import { useState } from 'react' +type FilterType = 'all' | 'active' | 'inactive' + export default function CollectionsTabs({ + activeFilter, onFilterChange, }: { - onFilterChange?: (filter: string, search: string) => void + activeFilter?: FilterType + onFilterChange?: (filter: FilterType, search?: string) => void }) { - const [activeTab, setActiveTab] = useState('all') const [searchTerm, setSearchTerm] = useState('') - const handleTabChange = (value: string) => { - setActiveTab(value) + const handleTabChange = (value: FilterType) => { if (onFilterChange) { onFilterChange(value, searchTerm) } } const handleSearchChange = (e: React.ChangeEvent) => { - setSearchTerm(e.target.value) + const newSearchTerm = e.target.value + setSearchTerm(newSearchTerm) if (onFilterChange) { - onFilterChange(activeTab, e.target.value) + onFilterChange(activeFilter || 'all', newSearchTerm) } } return (
- + void} + > Todas Activas diff --git a/app/store/components/product-management/utils/collection-form-utils.ts b/app/store/components/product-management/utils/collection-form-utils.ts new file mode 100644 index 00000000..5933914e --- /dev/null +++ b/app/store/components/product-management/utils/collection-form-utils.ts @@ -0,0 +1,343 @@ +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' +import { IProduct } from '@/app/store/hooks/useProducts' +import { CollectionInput } from '@/app/store/hooks/useCollections' +import { routes } from '@/utils/routes' + +export interface CollectionFormState { + title: string + description: string + slug: string + isActive: boolean + imageUrl: string + selectedProducts: IProduct[] +} + +export interface CollectionFormActions { + setTitle: (title: string) => void + setDescription: (description: string) => void + setSlug: (slug: string) => void + setIsActive: (isActive: boolean) => void + setImageUrl: (imageUrl: string) => void + handleAddProduct: (product: IProduct) => void + handleRemoveProduct: (productId: string) => void +} + +export interface UseCollectionFormProps { + isEditing: boolean + collectionId: string + storeId: string + collectionData: any + currentStore: any + user: any + useCreateCollection: any + useUpdateCollection: any + useDeleteCollection: any + addProductToCollection: (collectionId: string, productId: string) => Promise + removeProductFromCollection: (productId: string) => Promise +} + +export const useCollectionForm = ({ + isEditing, + collectionId, + storeId, + collectionData, + currentStore, + user, + useCreateCollection, + useUpdateCollection, + useDeleteCollection, + addProductToCollection, + removeProductFromCollection, +}: UseCollectionFormProps) => { + const router = useRouter() + + const [isDataLoaded, setIsDataLoaded] = useState(false) + + // Estados para el formulario + const [title, setTitle] = useState('Página de inicio') + const [description, setDescription] = useState('') + const [slug, setSlug] = useState('') + const [isActive, setIsActive] = useState(true) + const [imageUrl, setImageUrl] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + // Estado para controlar cambios sin guardar + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [initialFormState, setInitialFormState] = useState({ + title: '', + description: '', + slug: '', + isActive: true, + imageUrl: '', + }) + + // Estado para almacenar los productos seleccionados + const [selectedProducts, setSelectedProducts] = useState([]) + const [initialSelectedProducts, setInitialSelectedProducts] = useState([]) + + // Mutaciones para crear, actualizar y eliminar colecciones + const createCollection = useCreateCollection() + const updateCollection = useUpdateCollection() + const deleteCollection = useDeleteCollection() + + // Cargar datos de la colección si estamos editando + useEffect(() => { + if (isEditing && collectionData) { + const title = collectionData.title || '' + const description = collectionData.description || '' + const slug = collectionData.slug || '' + const isActive = collectionData.isActive + const imageUrl = collectionData.image || '' + + // Establecer valores iniciales + setTitle(title) + setDescription(description) + setSlug(slug) + setIsActive(isActive) + setImageUrl(imageUrl) + + // Guardar estado inicial del formulario + setInitialFormState({ + title, + description, + slug, + isActive, + imageUrl, + }) + + // Si hay productos en la colección, establecerlos como seleccionados + if (collectionData.products && collectionData.products.length > 0) { + setSelectedProducts(collectionData.products) + setInitialSelectedProducts(collectionData.products) + } + + // Mark data as loaded + setIsDataLoaded(true) + } else if (!isEditing) { + // Para nueva colección, establecer valores por defecto + const defaultTitle = 'Página de inicio' + setInitialFormState({ + title: defaultTitle, + description: '', + slug: '', + isActive: true, + imageUrl: '', + }) + + // Mark data as loaded for new collections too + setIsDataLoaded(true) + } + }, [isEditing, collectionData]) + + // Detectar cambios en el formulario + useEffect(() => { + // Only check for changes if data is loaded and not submitting + if (isDataLoaded && !isSubmitting) { + const currentState = { + title, + description, + slug, + isActive, + imageUrl, + } + + const productsChanged = + JSON.stringify(selectedProducts) !== JSON.stringify(initialSelectedProducts) + + const formChanged = + currentState.title !== initialFormState.title || + currentState.description !== initialFormState.description || + currentState.slug !== initialFormState.slug || + currentState.isActive !== initialFormState.isActive || + currentState.imageUrl !== initialFormState.imageUrl || + productsChanged + + setHasUnsavedChanges(formChanged) + } + }, [ + title, + description, + slug, + isActive, + imageUrl, + selectedProducts, + initialFormState, + initialSelectedProducts, + isSubmitting, + isDataLoaded, // Add this dependency + ]) + + // Función para añadir un producto a la selección + const handleAddProduct = (product: IProduct) => { + setSelectedProducts(prev => [...prev, product]) + } + + // Función para eliminar un producto de la selección + const handleRemoveProduct = (productId: string) => { + setSelectedProducts(prev => prev.filter(p => p.id !== productId)) + } + + // Función para manejar la descripción desde el editor + const handleDescriptionChange = (content: string) => { + setDescription(content) + } + + // Función para manejar la imagen seleccionada + const handleImageChange = (url: string) => { + setImageUrl(url) + } + + // Función para guardar la colección + const handleSaveCollection = async () => { + if (!currentStore?.id || !user?.userId) { + toast.error('No se pudo identificar la tienda o el usuario') + return + } + + setIsSubmitting(true) + + try { + const collectionData: CollectionInput = { + storeId: currentStore.storeId, + title, + description, + image: imageUrl, + slug: slug || title.toLowerCase().replace(/\s+/g, '-'), + isActive, + sortOrder: 0, + owner: user.userId, + } + + let savedCollection + + if (isEditing) { + // Actualizar colección existente + savedCollection = await updateCollection.mutateAsync({ + id: collectionId, + data: collectionData, + }) + + // Obtener los productos actuales de la colección + const currentProductIds = new Set(initialSelectedProducts.map(p => p.id)) + const newProductIds = new Set(selectedProducts.map(p => p.id)) + + // Productos a eliminar (están en currentProductIds pero no en newProductIds) + for (const product of initialSelectedProducts) { + if (!newProductIds.has(product.id)) { + // Eliminar producto de la colección + await removeProductFromCollection(product.id) + } + } + + // Productos a añadir (están en newProductIds pero no en currentProductIds) + for (const product of selectedProducts) { + if (!currentProductIds.has(product.id)) { + // Añadir producto a la colección + await addProductToCollection(savedCollection.id, product.id) + } + } + } else { + // Crear nueva colección + savedCollection = await createCollection.mutateAsync(collectionData) + + // Añadir todos los productos seleccionados a la nueva colección + for (const product of selectedProducts) { + await addProductToCollection(savedCollection.id, product.id) + } + } + + // Actualizar estado inicial para reflejar el estado actual + setInitialFormState({ + title, + description, + slug, + isActive, + imageUrl, + }) + setInitialSelectedProducts([...selectedProducts]) + setHasUnsavedChanges(false) + + toast.success( + isEditing ? 'Colección actualizada correctamente' : 'Colección creada correctamente' + ) + + // Redirigir a la lista de colecciones + router.push(routes.store.collections(storeId)) + // No desactivamos isSubmitting para mantener el botón deshabilitado hasta la redirección + } catch (error) { + console.error('Error al guardar la colección:', error) + toast.error('Ocurrió un error al guardar la colección') + setIsSubmitting(false) // Solo desactivamos en caso de error + } + } + + // Función para eliminar la colección + const handleDeleteCollection = async () => { + if (!isEditing) return + + if (confirm('¿Estás seguro de que deseas eliminar esta colección?')) { + setIsSubmitting(true) + try { + await deleteCollection.mutateAsync(collectionId) + toast.success('Colección eliminada correctamente') + router.push(routes.store.collections(storeId)) + // No desactivamos isSubmitting para mantener el botón deshabilitado hasta la redirección + } catch (error) { + console.error('Error al eliminar la colección:', error) + toast.error('Ocurrió un error al eliminar la colección') + setIsSubmitting(false) // Solo desactivamos en caso de error + } + } + } + + // Función para descartar cambios + const handleDiscardChanges = () => { + if (isEditing && collectionData) { + setTitle(initialFormState.title) + setDescription(initialFormState.description) + setSlug(initialFormState.slug) + setIsActive(initialFormState.isActive) + setImageUrl(initialFormState.imageUrl) + setSelectedProducts(initialSelectedProducts) + } else { + // Para nueva colección, restablecer a valores por defecto + setTitle('Página de inicio') + setDescription('') + setSlug('') + setIsActive(true) + setImageUrl('') + setSelectedProducts([]) + } + setHasUnsavedChanges(false) + } + + return { + // Estado + title, + description, + slug, + isActive, + imageUrl, + isSubmitting, + hasUnsavedChanges, + selectedProducts, + isDataLoaded, // Add this to the returned object + + // Acciones + setTitle, + setDescription, + setSlug, + setIsActive, + setImageUrl, + setIsSubmitting, + handleAddProduct, + handleRemoveProduct, + handleDescriptionChange, + handleImageChange, + handleSaveCollection, + handleDeleteCollection, + handleDiscardChanges, + } +} diff --git a/app/store/hooks/useCollections.ts b/app/store/hooks/useCollections.ts index 3a6d5295..e3e5779a 100644 --- a/app/store/hooks/useCollections.ts +++ b/app/store/hooks/useCollections.ts @@ -7,6 +7,7 @@ const client = generateClient() // Clave base para las consultas de colecciones const COLLECTIONS_KEY = 'collections' +const PRODUCTS_KEY = 'products' /** * Interfaz para los datos de entrada de una colección @@ -16,7 +17,6 @@ export interface CollectionInput { title: string // Título de la colección description?: string // Descripción opcional de la colección image?: string // URL de la imagen de la colección (opcional) - products?: any[] // Array de productos en la colección (opcional) slug?: string // URL amigable para la colección (opcional) isActive: boolean // Indica si la colección está activa sortOrder?: number // Orden de clasificación (opcional) @@ -64,8 +64,31 @@ export const useCollections = () => { const useGetCollection = (id: string): UseQueryResult => { return useQuery({ queryKey: [COLLECTIONS_KEY, id], - queryFn: () => - performOperation(() => client.models.Collection.get({ id }, { authMode: 'userPool' })), + queryFn: async () => { + // Obtener la colección + const collection = await performOperation(() => + client.models.Collection.get({ id }, { authMode: 'userPool' }) + ) + + // Si la colección existe, obtener sus productos + if (collection) { + // Obtener productos de la colección + const productsData = await performOperation(() => + client.models.Product.list({ + filter: { collectionId: { eq: id } }, + authMode: 'userPool', + }) + ) + + // Añadir productos a la colección + return { + ...collection, + products: productsData, + } + } + + return collection + }, staleTime: 5 * 60 * 1000, // 5 minutos en caché enabled: !!id, }) @@ -92,6 +115,27 @@ export const useCollections = () => { }) } + /** + * Obtiene los productos de una colección específica + * @param collectionId - ID de la colección + * @returns Resultado de la consulta con los productos de la colección + */ + const useGetCollectionProducts = (collectionId: string): UseQueryResult => { + return useQuery({ + queryKey: [COLLECTIONS_KEY, collectionId, 'products'], + queryFn: () => { + return performOperation(() => + client.models.Product.list({ + filter: { collectionId: { eq: collectionId } }, + authMode: 'userPool', + }) + ) + }, + staleTime: 5 * 60 * 1000, // 5 minutos en caché + enabled: !!collectionId, + }) + } + /** * Crea una nueva colección usando React Query */ @@ -153,80 +197,45 @@ export const useCollections = () => { /** * Añade un producto a una colección * @param collectionId - ID de la colección - * @param product - Datos del producto a añadir - * @returns La colección actualizada o null en caso de error + * @param productId - ID del producto a añadir + * @returns El producto actualizado o null en caso de error */ - const addProductToCollection = async (collectionId: string, product: any) => { - // Obtener la colección actual de la caché o de la API - const collection = await queryClient.fetchQuery({ - queryKey: [COLLECTIONS_KEY, collectionId], - queryFn: () => - performOperation(() => - client.models.Collection.get({ id: collectionId }, { authMode: 'userPool' }) - ), - }) - - if (!collection) throw new Error('Colección no encontrada') - - // Obtenemos los productos actuales o inicializamos un array vacío - const currentProducts = collection.products || [] - - // Verificamos si el producto ya existe en la colección - if (Array.isArray(currentProducts) && currentProducts.some((p: any) => p.id === product.id)) { - return collection - } - - // Añadimos el nuevo producto - const updatedProducts = Array.isArray(currentProducts) - ? [...currentProducts, product] - : [product] - - // Actualizamos la colección - const updateMutation = useUpdateCollection() - return updateMutation.mutateAsync({ - id: collectionId, - data: { products: updatedProducts }, - }) + const addProductToCollection = async (collectionId: string, productId: string) => { + // Actualizar el producto para asignarle la colección + return performOperation(() => + client.models.Product.update( + { + id: productId, + collectionId: collectionId, + }, + { authMode: 'userPool' } + ) + ) } /** * Elimina un producto de una colección - * @param collectionId - ID de la colección - * @param productId - ID del producto a eliminar - * @returns La colección actualizada o null en caso de error + * @param productId - ID del producto a eliminar de la colección + * @returns El producto actualizado o null en caso de error */ - const removeProductFromCollection = async (collectionId: string, productId: string) => { - // Obtener la colección actual de la caché o de la API - const collection = await queryClient.fetchQuery({ - queryKey: [COLLECTIONS_KEY, collectionId], - queryFn: () => - performOperation(() => - client.models.Collection.get({ id: collectionId }, { authMode: 'userPool' }) - ), - }) - - if (!collection) throw new Error('Colección no encontrada') - - // Obtenemos los productos actuales o inicializamos un array vacío - const currentProducts = collection.products || [] - - // Filtramos el producto a eliminar - const updatedProducts = Array.isArray(currentProducts) - ? currentProducts.filter((p: any) => p.id !== productId) - : [] - - // Actualizamos la colección - const updateMutation = useUpdateCollection() - return updateMutation.mutateAsync({ - id: collectionId, - data: { products: updatedProducts }, - }) + const removeProductFromCollection = async (productId: string) => { + // Actualizar el producto para eliminar la referencia a la colección + return performOperation(() => + client.models.Product.update( + { + id: productId, + collectionId: null, // Eliminar la referencia a la colección + }, + { authMode: 'userPool' } + ) + ) } return { error, useGetCollection, useListCollections, + useGetCollectionProducts, useCreateCollection, useUpdateCollection, useDeleteCollection, diff --git a/app/store/hooks/useProductImageUpload.ts b/app/store/hooks/useProductImageUpload.ts index ce4fc21e..f8ee0614 100644 --- a/app/store/hooks/useProductImageUpload.ts +++ b/app/store/hooks/useProductImageUpload.ts @@ -35,13 +35,14 @@ export function useProductImageUpload() { const result = await uploadData({ options: { bucket: 'productsImages', + contentType: file.type, }, path: `products/${storeId}/${uniqueFileName}`, data: file, }).result // Construir la URL pública correcta usando el nombre del bucket - const publicUrl = `https://${productBucket.bucket_name}.s3.${aws_region}.amazonaws.com/${result.path}` + const publicUrl = `https://d1etr7t5j9fzio.cloudfront.net/${result.path}` return { url: publicUrl, diff --git a/components/ui/unsaved-changes-alert.tsx b/components/ui/unsaved-changes-alert.tsx index 83e7bed7..4b2658a9 100644 --- a/components/ui/unsaved-changes-alert.tsx +++ b/components/ui/unsaved-changes-alert.tsx @@ -60,7 +60,7 @@ export function UnsavedChangesAlert({ >
- Producto no guardado + Cambios no guardados