From f107faaceb37ea3b5fa6a28242318b9917308b56 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Sun, 1 Jun 2025 01:15:16 -0500 Subject: [PATCH 1/4] refactor(middleware): remove subscription middleware and enhance store access validation This commit removes the subscription middleware from the main middleware function and updates the store access middleware to include a check for valid subscription plans before allowing access to the store. This change improves the overall access control logic and ensures users have the appropriate subscription level for store access. --- middleware.ts | 5 ----- middlewares/store-access/storeAccess.ts | 15 ++++++++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/middleware.ts b/middleware.ts index 46066bc3..aa816b53 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' import { handleAuthenticationMiddleware } from './middlewares/auth/auth' -import { handleSubscriptionMiddleware } from './middlewares/subscription/subscription' import { handleStoreMiddleware } from './middlewares/store-access/store' import { handleStoreAccessMiddleware } from './middlewares/store-access/storeAccess' import { handleProductOwnershipMiddleware } from './middlewares/ownership/productOwnership' @@ -31,10 +30,6 @@ export async function middleware(request: NextRequest) { return handleStoreAccessMiddleware(request) } - if (path === '/subscription-success') { - return handleSubscriptionMiddleware(request, NextResponse.next()) - } - if (path === '/account-settings') { return handleAuthenticationMiddleware(request, NextResponse.next()) } diff --git a/middlewares/store-access/storeAccess.ts b/middlewares/store-access/storeAccess.ts index 0663f348..a72a760a 100644 --- a/middlewares/store-access/storeAccess.ts +++ b/middlewares/store-access/storeAccess.ts @@ -4,7 +4,7 @@ import { cookiesClient } from '@/utils/AmplifyUtils' /** * Middleware para proteger las rutas de tienda - * Verifica que el usuario tenga acceso a la tienda solicitada + * Verifica que el usuario tenga acceso a la tienda solicitada y un plan de suscripción válido */ export async function handleStoreAccessMiddleware(request: NextRequest) { // Obtener la sesión del usuario @@ -14,6 +14,16 @@ export async function handleStoreAccessMiddleware(request: NextRequest) { return NextResponse.redirect(new URL('/login', request.url)) } + // Verificar plan de suscripción válido ANTES de verificar acceso a tienda + const userPlan: string | undefined = session.tokens?.idToken?.payload?.['custom:plan'] as + | string + | undefined + const allowedPlans = ['Royal', 'Majestic', 'Imperial'] + + if (!userPlan || !allowedPlans.includes(userPlan)) { + return NextResponse.redirect(new URL('/pricing', request.url)) + } + // Obtener el ID del usuario desde la sesión const userId = session.tokens?.idToken?.payload?.['cognito:username'] @@ -50,8 +60,7 @@ export async function handleStoreAccessMiddleware(request: NextRequest) { return NextResponse.redirect(new URL('/my-store', request.url)) } - // Si todo está bien, permitir el acceso - + // Si todo está bien (plan válido y tienda pertenece al usuario), permitir el acceso return NextResponse.next() } catch (error) { console.error('Error verificando acceso a tienda:', error) From f81c49bd02608c5008554d7eea4d1ca2fe7fabe1 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Sun, 1 Jun 2025 16:06:48 -0500 Subject: [PATCH 2/4] refactor(components): reorganize component imports and remove unused files This commit refactors the import paths for several components to follow a more consistent structure by moving them into a 'components' subdirectory. Additionally, it removes unused components related to the AI chat feature, including AiInput, ChatTrigger, GradientSparkles, and others, streamlining the codebase and improving maintainability. --- amplify/functions/cancelPlan/handler.ts | 10 +- .../functions/storeImages/config/config.ts | 74 ++++ amplify/functions/storeImages/handler.ts | 297 ++------------- .../storeImages/services/image-controller.ts | 148 ++++++++ .../storeImages/services/s3-service.ts | 200 ++++++++++ .../functions/storeImages/services/utils.ts | 83 +++++ amplify/functions/storeImages/types/types.ts | 58 +++ app/store/[slug]/dashboard/page.tsx | 2 +- .../[slug]/dashboard/statistics/page.tsx | 2 +- app/store/[slug]/orders/page.tsx | 2 +- app/store/[slug]/orders/processing/page.tsx | 2 +- app/store/[slug]/setup/apps/page.tsx | 2 +- app/store/[slug]/setup/domain/page.tsx | 2 +- app/store/[slug]/setup/page.tsx | 2 +- app/store/[slug]/setup/payments/page.tsx | 2 +- .../ai-chat/{ => components}/AiInput.tsx | 1 - .../ai-chat/{ => components}/ChatTrigger.tsx | 4 +- .../{ => components}/GradientSparkles.tsx | 0 .../{ => components}/MessageLoading.tsx | 0 .../ai-chat/{ => components}/Orb.tsx | 0 .../{ => components}/RefinedAiAssistant.tsx | 10 +- .../ai-chat/{ => components}/TypingEffect.tsx | 0 .../{ => components}/TypingMessage.tsx | 2 +- .../{ => components}/AppIntegrationPage.tsx | 2 +- .../{ => components}/ConnectModal.tsx | 0 .../{ => components}/ChangeDomainDialog.tsx | 0 .../{ => components}/DomainManagement.tsx | 4 +- .../EditStoreProfileDialog.tsx | 0 .../{ => components}/ImageGallery.tsx | 0 .../components/ModalFooter.tsx | 24 ++ .../components/SearchAndFilters.tsx | 56 +++ .../components/UploadDropZone.tsx | 42 +++ .../components/UploadPreview.tsx | 25 ++ .../components/image-selector-modal.tsx | 198 ++++++++++ .../hooks/useImageSelection.ts | 83 +++++ .../images-selector/hooks/useImageUpload.ts | 122 +++++++ .../images-selector/image-selector-modal.tsx | 345 ------------------ app/store/components/images-selector/index.ts | 16 + .../{ => components}/NotificationPopover.tsx | 0 .../orders/{ => components}/InProgress.tsx | 0 .../orders/{ => components}/Orders.tsx | 0 .../payments/{ => components}/ApiKeyModal.tsx | 0 .../{ => components}/MercadoPagoGuide.tsx | 0 .../PaymentCaptureSection.tsx | 0 .../{ => components}/PaymentGatewayCard.tsx | 2 +- .../{ => components}/PaymentMethodIcons.tsx | 0 .../PaymentMethodsSection.tsx | 2 +- .../PaymentProvidersSection.tsx | 4 +- .../{ => components}/PaymentSettings.tsx | 10 +- .../PaymentSettingsSkeleton.tsx | 0 .../payments/{ => components}/WompiGuide.tsx | 0 .../collection-form/image-section.tsx | 2 +- .../main-components/ImageUpload.tsx | 2 +- .../{ => components}/SearchNavigation.tsx | 3 +- .../{ => components}/SearchRoutes.tsx | 0 .../{ => components}/AppAccessGuard.tsx | 0 .../sidebar/{ => components}/app-sidebar.tsx | 6 +- .../sidebar/{ => components}/nav-apps.tsx | 0 .../sidebar/{ => components}/nav-main.tsx | 0 .../sidebar/{ => components}/nav-user.tsx | 2 +- .../{ => components}/ChartComponents.tsx | 0 .../{ => components}/MetricCards.tsx | 6 +- .../{ => components}/SalesDashboard.tsx | 0 .../{ => components}/LogoUploader.tsx | 0 .../{ => components}/ThemePreview.tsx | 2 +- .../{ => components}/EcommerceSetup.tsx | 4 +- .../{ => components}/PricingDrawer.tsx | 0 .../{ => utils}/StoreSetup-tasks.ts | 0 app/store/config/StoreLayoutClient.tsx | 8 +- app/store/hooks/useS3Images.ts | 264 +++++++------- 70 files changed, 1356 insertions(+), 781 deletions(-) create mode 100644 amplify/functions/storeImages/config/config.ts create mode 100644 amplify/functions/storeImages/services/image-controller.ts create mode 100644 amplify/functions/storeImages/services/s3-service.ts create mode 100644 amplify/functions/storeImages/services/utils.ts create mode 100644 amplify/functions/storeImages/types/types.ts rename app/store/components/ai-chat/{ => components}/AiInput.tsx (98%) rename app/store/components/ai-chat/{ => components}/ChatTrigger.tsx (90%) rename app/store/components/ai-chat/{ => components}/GradientSparkles.tsx (100%) rename app/store/components/ai-chat/{ => components}/MessageLoading.tsx (100%) rename app/store/components/ai-chat/{ => components}/Orb.tsx (100%) rename app/store/components/ai-chat/{ => components}/RefinedAiAssistant.tsx (95%) rename app/store/components/ai-chat/{ => components}/TypingEffect.tsx (100%) rename app/store/components/ai-chat/{ => components}/TypingMessage.tsx (97%) rename app/store/components/app-integration/{ => components}/AppIntegrationPage.tsx (99%) rename app/store/components/app-integration/{ => components}/ConnectModal.tsx (100%) rename app/store/components/domains/{ => components}/ChangeDomainDialog.tsx (100%) rename app/store/components/domains/{ => components}/DomainManagement.tsx (99%) rename app/store/components/domains/{ => components}/EditStoreProfileDialog.tsx (100%) rename app/store/components/images-selector/{ => components}/ImageGallery.tsx (100%) create mode 100644 app/store/components/images-selector/components/ModalFooter.tsx create mode 100644 app/store/components/images-selector/components/SearchAndFilters.tsx create mode 100644 app/store/components/images-selector/components/UploadDropZone.tsx create mode 100644 app/store/components/images-selector/components/UploadPreview.tsx create mode 100644 app/store/components/images-selector/components/image-selector-modal.tsx create mode 100644 app/store/components/images-selector/hooks/useImageSelection.ts create mode 100644 app/store/components/images-selector/hooks/useImageUpload.ts delete mode 100644 app/store/components/images-selector/image-selector-modal.tsx create mode 100644 app/store/components/images-selector/index.ts rename app/store/components/notifications/{ => components}/NotificationPopover.tsx (100%) rename app/store/components/orders/{ => components}/InProgress.tsx (100%) rename app/store/components/orders/{ => components}/Orders.tsx (100%) rename app/store/components/payments/{ => components}/ApiKeyModal.tsx (100%) rename app/store/components/payments/{ => components}/MercadoPagoGuide.tsx (100%) rename app/store/components/payments/{ => components}/PaymentCaptureSection.tsx (100%) rename app/store/components/payments/{ => components}/PaymentGatewayCard.tsx (97%) rename app/store/components/payments/{ => components}/PaymentMethodIcons.tsx (100%) rename app/store/components/payments/{ => components}/PaymentMethodsSection.tsx (97%) rename app/store/components/payments/{ => components}/PaymentProvidersSection.tsx (87%) rename app/store/components/payments/{ => components}/PaymentSettings.tsx (84%) rename app/store/components/payments/{ => components}/PaymentSettingsSkeleton.tsx (100%) rename app/store/components/payments/{ => components}/WompiGuide.tsx (100%) rename app/store/components/search-bar/{ => components}/SearchNavigation.tsx (97%) rename app/store/components/search-bar/{ => components}/SearchRoutes.tsx (100%) rename app/store/components/sidebar/{ => components}/AppAccessGuard.tsx (100%) rename app/store/components/sidebar/{ => components}/app-sidebar.tsx (94%) rename app/store/components/sidebar/{ => components}/nav-apps.tsx (100%) rename app/store/components/sidebar/{ => components}/nav-main.tsx (100%) rename app/store/components/sidebar/{ => components}/nav-user.tsx (99%) rename app/store/components/statistics/{ => components}/ChartComponents.tsx (100%) rename app/store/components/statistics/{ => components}/MetricCards.tsx (93%) rename app/store/components/statistics/{ => components}/SalesDashboard.tsx (100%) rename app/store/components/store-config/{ => components}/LogoUploader.tsx (100%) rename app/store/components/store-config/{ => components}/ThemePreview.tsx (99%) rename app/store/components/store-setup/{ => components}/EcommerceSetup.tsx (99%) rename app/store/components/store-setup/{ => components}/PricingDrawer.tsx (100%) rename app/store/components/store-setup/{ => utils}/StoreSetup-tasks.ts (100%) diff --git a/amplify/functions/cancelPlan/handler.ts b/amplify/functions/cancelPlan/handler.ts index 285d9061..befcac5c 100644 --- a/amplify/functions/cancelPlan/handler.ts +++ b/amplify/functions/cancelPlan/handler.ts @@ -6,11 +6,9 @@ import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtim import { env } from '$amplify/env/hookPlan' import { type Schema } from '../../data/resource' -// Configurar Amplify para acceso a datos const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) Amplify.configure(resourceConfig, libraryOptions) -// Inicializar el cliente para DynamoDB (Amplify Data) const clientSchema = generateClient() export const handler = async (event: any) => { @@ -50,22 +48,18 @@ export const handler = async (event: any) => { } ) } catch (error: any) { - // Si se recibe error, verificamos si es por intentar modificar una preaprobación ya cancelada. if ( error.response && error.response.data && typeof error.response.data.message === 'string' && error.response.data.message.toLowerCase().includes('cancelled preapproval') ) { - // Tratamos el error como si fuera exitoso. - response = error.response // Usamos el objeto de error.response para continuar. + response = error.response } else { - throw error // Para otros errores, relanzamos. + throw error } } - // Si la respuesta no tiene status 200, pero ya fue capturado el error específico, - // podemos continuar. const data = response.data if (response.status !== 200 && response.status !== 400) { return { diff --git a/amplify/functions/storeImages/config/config.ts b/amplify/functions/storeImages/config/config.ts new file mode 100644 index 00000000..8def8530 --- /dev/null +++ b/amplify/functions/storeImages/config/config.ts @@ -0,0 +1,74 @@ +import { env } from '$amplify/env/storeImages' +import { S3Config } from '../types/types' + +export class ConfigService { + private static instance: ConfigService + private config: S3Config + + private constructor() { + this.config = this.initializeConfig() + this.validateConfig() + } + + public static getInstance(): ConfigService { + if (!ConfigService.instance) { + ConfigService.instance = new ConfigService() + } + return ConfigService.instance + } + + private initializeConfig(): S3Config { + const bucketName = env.BUCKET_NAME || '' + const awsRegion = env.AWS_REGION_BUCKET || 'us-east-2' + + let cloudFrontDomainBase = '' + if ( + env.APP_ENV === 'production' && + env.CLOUDFRONT_DOMAIN_NAME && + env.CLOUDFRONT_DOMAIN_NAME.trim() !== '' + ) { + cloudFrontDomainBase = env.CLOUDFRONT_DOMAIN_NAME.trim() + } + + return { + bucketName, + awsRegion, + cloudFrontDomainBase, + } + } + + private validateConfig(): void { + if (!this.config.bucketName) { + console.error( + 'Error: BUCKET_NAME is not defined in the environment variables of the storeImages function.' + ) + throw new Error('BUCKET_NAME is required') + } + + // Advertencia si no hay región y no se usa CloudFront + if (!this.config.awsRegion && !this.config.cloudFrontDomainBase) { + console.warn( + "Warning: AWS_REGION_BUCKET is not defined. S3 URLs may default to 'us-east-2' if CloudFront is not used or not configured." + ) + } + + // Advertencia específica para producción sin CloudFront + if (env.APP_ENV === 'production' && !this.config.cloudFrontDomainBase) { + console.warn( + 'Warning: APP_ENV is "production" but CLOUDFRONT_DOMAIN_NAME is not set. Image URLs will use S3 direct links.' + ) + } + } + + public getConfig(): S3Config { + return { ...this.config } + } + + public generateImageUrl(s3Key: string): string { + if (this.config.cloudFrontDomainBase) { + return `https://${this.config.cloudFrontDomainBase}/${s3Key}` + } + + return `https://${this.config.bucketName}.s3.${this.config.awsRegion}.amazonaws.com/${s3Key}` + } +} diff --git a/amplify/functions/storeImages/handler.ts b/amplify/functions/storeImages/handler.ts index 2d78788b..ee523692 100644 --- a/amplify/functions/storeImages/handler.ts +++ b/amplify/functions/storeImages/handler.ts @@ -1,271 +1,48 @@ -import { - S3Client, - ListObjectsV2Command, - PutObjectCommand, - DeleteObjectCommand, -} from '@aws-sdk/client-s3' -import { env } from '$amplify/env/storeImages' -import { getCorsHeaders } from '../shared/cors' +import { APIGatewayEvent, APIGatewayResponse } from './types/types' +import { ImageController } from './services/image-controller' + +// Instancia única del controlador para reutilización +const imageController = new ImageController() + +/** + * Handler principal de la Lambda function para manejo de imágenes + * Arquitectura refactorizada con separación de responsabilidades: + * - handler.ts: Punto de entrada y manejo de eventos + * - imageController.ts: Lógica de negocio y orquestación + * - s3Service.ts: Operaciones específicas de S3 + * - config.ts: Manejo de configuración y variables de entorno + * - utils.ts: Funciones auxiliares y validaciones + * - types.ts: Definiciones de tipos TypeScript + */ +export const handler = async (event: APIGatewayEvent): Promise => { + console.log('Processing request:', { + httpMethod: event.httpMethod, + hasBody: !!event.body, + timestamp: new Date().toISOString(), + }) -const s3Client = new S3Client() - -const bucketName = env.BUCKET_NAME -const awsRegion = env.AWS_REGION_BUCKET - -// Determinar el dominio de CloudFront a usar, si aplica. -// cloudFrontDomainBase contendrá solo el nombre de host (ej: d123.cloudfront.net) si está en producción y configurado. -// De lo contrario, permanecerá vacío, y se usará la URL directa de S3. -let cloudFrontDomainBase = '' -if ( - env.APP_ENV === 'production' && - env.CLOUDFRONT_DOMAIN_NAME && - env.CLOUDFRONT_DOMAIN_NAME.trim() !== '' -) { - cloudFrontDomainBase = env.CLOUDFRONT_DOMAIN_NAME -} - -if (!bucketName) { - console.error( - 'Error: BUCKET_NAME is not defined in the environment variables of the storeImages function.' - ) -} -// AWS_REGION_BUCKET es necesario si no se usa CloudFront (no producción o CloudFront no configurado) -if (!awsRegion && (!cloudFrontDomainBase || cloudFrontDomainBase.trim() === '')) { - console.warn( - "Warning: AWS_REGION_BUCKET is not defined. S3 URLs may default to 'us-east-2' if CloudFront is not used or not configured." - ) -} - -// Advertencia específica si es producción pero CLOUDFRONT_DOMAIN_NAME no está configurado -if ( - env.APP_ENV === 'production' && - (!env.CLOUDFRONT_DOMAIN_NAME || env.CLOUDFRONT_DOMAIN_NAME.trim() === '') -) { - console.warn( - 'Warning: APP_ENV is "production" but CLOUDFRONT_DOMAIN_NAME is not set. Image URLs will use S3 direct links.' - ) -} - -export const handler = async (event: any) => { - const origin = event.headers?.origin || event.headers?.Origin - - if (event.httpMethod === 'OPTIONS') { - return { - statusCode: 200, - headers: getCorsHeaders(origin), - body: '', - } - } 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: getCorsHeaders(origin), - } + // Manejar solicitudes OPTIONS para CORS preflight + if (event.httpMethod === 'OPTIONS') { + const origin = event.headers?.origin || event.headers?.Origin + return imageController.handleOptions(origin) } - // Manejar diferentes acciones - switch (action) { - case 'list': - return await listImages(storeId, origin, body.limit, body.prefix, body.continuationToken) - case 'upload': - return await uploadImage(storeId, origin, body.filename, body.contentType, body.fileContent) - case 'delete': - return await deleteImage(body.key, origin) - default: - return { - statusCode: 400, - body: JSON.stringify({ message: 'Invalid action' }), - headers: getCorsHeaders(origin), - } - } + // Procesar la solicitud principal + return await imageController.processRequest(event) } catch (error) { - console.error('Error processing request:', error) - return { - statusCode: 500, - body: JSON.stringify({ message: 'Error processing request' }), - headers: getCorsHeaders(origin), - } - } -} - -// Función para listar imágenes -async function listImages( - storeId: string, - origin: string | undefined, - limit: number = 18, - prefix: string = '', - continuationToken?: 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, - ContinuationToken: continuationToken, - }) - - const listResponse = await s3Client.send(listCommand) - - if (!listResponse.Contents) { - return { - statusCode: 200, - body: JSON.stringify({ images: [] }), - headers: getCorsHeaders(origin), - } - } - - // Generar URLs para cada objeto usando CloudFront - const imagePromises = listResponse.Contents.map(async item => { - if (!item.Key) return null - if (item.Key.endsWith('/')) return null - - let imageUrl: string - const s3Key = item.Key - - if (cloudFrontDomainBase && cloudFrontDomainBase.trim() !== '') { - // Usar CloudFront para producción - imageUrl = `https://${cloudFrontDomainBase}/${s3Key}` - } else { - // Fallback a la URL de S3 para otros entornos o si CloudFront no está configurado - const regionForS3Url = awsRegion || 'us-east-2' - imageUrl = `https://${bucketName}.s3.${regionForS3Url}.amazonaws.com/${s3Key}` - } - - // 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: imageUrl, // Usar la URL construida dinámicamente - 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) + console.error('Unhandled error in handler:', error) - return { - statusCode: 200, - body: JSON.stringify({ - images: validImages, - nextContinuationToken: listResponse.NextContinuationToken, - }), - headers: getCorsHeaders(origin), - } - } catch (error) { - console.error('Error listing images:', error) - return { - statusCode: 500, - body: JSON.stringify({ message: 'Error listing images' }), - headers: getCorsHeaders(origin), - } - } -} - -// Función para subir una imagen -async function uploadImage( - storeId: string, - origin: string | undefined, - 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) - - let imageUrl: string - const s3Key = key - - if (cloudFrontDomainBase && cloudFrontDomainBase.trim() !== '') { - // Usar CloudFront para producción - imageUrl = `https://${cloudFrontDomainBase}/${s3Key}` - } else { - // Fallback a la URL de S3 para otros entornos o si CloudFront no está configurado - const regionForS3Url = awsRegion || 'us-east-2' - imageUrl = `https://${bucketName}.s3.${regionForS3Url}.amazonaws.com/${s3Key}` - } - - const image = { - key, - url: imageUrl, // Usar la URL construida dinámicamente - filename, - lastModified: new Date(), - size: buffer.length, - type: contentType, - } - - return { - statusCode: 200, - body: JSON.stringify({ image }), - headers: getCorsHeaders(origin), - } - } catch (error) { - console.error('Error uploading image:', error) - return { - statusCode: 500, - body: JSON.stringify({ message: 'Error uploading image' }), - headers: getCorsHeaders(origin), - } - } -} - -// Función para eliminar una imagen -async function deleteImage(key: string, origin: string | undefined) { - 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: getCorsHeaders(origin), - } - } catch (error) { - console.error('Error deleting image:', error) + // Respuesta de emergencia en caso de fallo catastrófico return { statusCode: 500, - body: JSON.stringify({ message: 'Error deleting image' }), - headers: getCorsHeaders(origin), + body: JSON.stringify({ message: 'Internal server error' }), + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + }, } } } diff --git a/amplify/functions/storeImages/services/image-controller.ts b/amplify/functions/storeImages/services/image-controller.ts new file mode 100644 index 00000000..3d0311df --- /dev/null +++ b/amplify/functions/storeImages/services/image-controller.ts @@ -0,0 +1,148 @@ +import { + APIGatewayEvent, + APIGatewayResponse, + RequestBody, + ListImagesResponse, + UploadImageResponse, + DeleteImageResponse, + ErrorResponse, +} from '../types/types' +import { S3Service } from './s3-service' +import { ValidationUtils } from './utils' +import { getCorsHeaders } from '../../shared/cors' + +export class ImageController { + private readonly s3Service: S3Service + + constructor() { + this.s3Service = S3Service.getInstance() + } + + /** + * Maneja las solicitudes OPTIONS para CORS + */ + public handleOptions(origin?: string): APIGatewayResponse { + return { + statusCode: 200, + headers: getCorsHeaders(origin), + body: '', + } + } + + /** + * Procesa la solicitud principal y delega a la acción correspondiente + */ + public async processRequest(event: APIGatewayEvent): Promise { + const origin = event.headers?.origin || event.headers?.Origin + + try { + const body: RequestBody = event.body ? JSON.parse(event.body) : {} + + // Validaciones tempranas + ValidationUtils.validateStoreId(body.storeId) + ValidationUtils.validateAction(body.action) + + // Delegar a la acción correspondiente + switch (body.action) { + case 'list': + return await this.handleListImages(body, origin) + case 'upload': + return await this.handleUploadImage(body, origin) + case 'delete': + return await this.handleDeleteImage(body, origin) + default: + return this.createErrorResponse(400, 'Invalid action', origin) + } + } catch (error) { + console.error('Error processing request:', error) + const message = error instanceof Error ? error.message : 'Error processing request' + return this.createErrorResponse(500, message, origin) + } + } + + /** + * Maneja la operación de listar imágenes + */ + private async handleListImages(body: RequestBody, origin?: string): Promise { + try { + const result: ListImagesResponse = await this.s3Service.listImages( + body.storeId, + body.limit || 18, + body.prefix || '', + body.continuationToken + ) + + return this.createSuccessResponse(result, origin) + } catch (error) { + console.error('Error listing images:', error) + return this.createErrorResponse(500, 'Error listing images', origin) + } + } + + /** + * Maneja la operación de subir imagen + */ + private async handleUploadImage(body: RequestBody, origin?: string): Promise { + try { + ValidationUtils.validateUploadParams(body.filename, body.contentType, body.fileContent) + + const result: UploadImageResponse = await this.s3Service.uploadImage( + body.storeId, + body.filename!, + body.contentType!, + body.fileContent! + ) + + return this.createSuccessResponse(result, origin) + } catch (error) { + console.error('Error uploading image:', error) + const message = error instanceof Error ? error.message : 'Error uploading image' + return this.createErrorResponse(500, message, origin) + } + } + + /** + * Maneja la operación de eliminar imagen + */ + private async handleDeleteImage(body: RequestBody, origin?: string): Promise { + try { + ValidationUtils.validateDeleteParams(body.key) + + const result: DeleteImageResponse = await this.s3Service.deleteImage(body.key!) + + return this.createSuccessResponse(result, origin) + } catch (error) { + console.error('Error deleting image:', error) + const message = error instanceof Error ? error.message : 'Error deleting image' + return this.createErrorResponse(500, message, origin) + } + } + + /** + * Crea una respuesta de éxito estandarizada + */ + private createSuccessResponse(data: any, origin?: string): APIGatewayResponse { + return { + statusCode: 200, + body: JSON.stringify(data), + headers: getCorsHeaders(origin), + } + } + + /** + * Crea una respuesta de error estandarizada + */ + private createErrorResponse( + statusCode: number, + message: string, + origin?: string + ): APIGatewayResponse { + const errorResponse: ErrorResponse = { message } + + return { + statusCode, + body: JSON.stringify(errorResponse), + headers: getCorsHeaders(origin), + } + } +} diff --git a/amplify/functions/storeImages/services/s3-service.ts b/amplify/functions/storeImages/services/s3-service.ts new file mode 100644 index 00000000..476f4622 --- /dev/null +++ b/amplify/functions/storeImages/services/s3-service.ts @@ -0,0 +1,200 @@ +import { + S3Client, + ListObjectsV2Command, + PutObjectCommand, + DeleteObjectCommand, + ListObjectsV2CommandOutput, + _Object, +} from '@aws-sdk/client-s3' +import { + ImageItem, + ListImagesResponse, + UploadImageResponse, + DeleteImageResponse, +} from '../types/types' +import { ConfigService } from '../config/config' +import { FileUtils } from './utils' + +export class S3Service { + private static instance: S3Service + private readonly s3Client: S3Client + private readonly configService: ConfigService + + private constructor() { + this.s3Client = new S3Client() + this.configService = ConfigService.getInstance() + } + + public static getInstance(): S3Service { + if (!S3Service.instance) { + S3Service.instance = new S3Service() + } + return S3Service.instance + } + + /** + * Lista imágenes de una tienda con paginación optimizada + */ + public async listImages( + storeId: string, + limit: number = 18, + prefix: string = '', + continuationToken?: string + ): Promise { + try { + const config = this.configService.getConfig() + const storePrefix = FileUtils.generateStorePrefix(storeId, prefix) + + const listCommand = new ListObjectsV2Command({ + Bucket: config.bucketName, + Prefix: storePrefix, + MaxKeys: limit, + ContinuationToken: continuationToken, + }) + + const listResponse: ListObjectsV2CommandOutput = await this.s3Client.send(listCommand) + + if (!listResponse.Contents || listResponse.Contents.length === 0) { + return { images: [] } + } + + // Procesamiento optimizado con filtrado temprano + const validContents = listResponse.Contents.filter( + item => item.Key && !item.Key.endsWith('/') + ) + + const images: ImageItem[] = validContents.map(item => this.createImageItemFromS3Object(item)) + + return { + images, + nextContinuationToken: listResponse.NextContinuationToken, + } + } catch (error) { + console.error('Error listing images:', error) + throw new Error('Failed to list images') + } + } + + /** + * Sube una imagen optimizada con validación previa + */ + public async uploadImage( + storeId: string, + filename: string, + contentType: string, + fileContent: string + ): Promise { + try { + const config = this.configService.getConfig() + + // Validación temprana del contenido base64 + if (!this.isValidBase64(fileContent)) { + throw new Error('Invalid file content format') + } + + const buffer = Buffer.from(fileContent, 'base64') + const key = FileUtils.generateS3Key(storeId, filename) + + const putCommand = new PutObjectCommand({ + Bucket: config.bucketName, + Key: key, + Body: buffer, + ContentType: contentType, + CacheControl: 'max-age=31536000', // 1 año de cache + Metadata: { + storeId, + originalFilename: filename, + uploadDate: new Date().toISOString(), + }, + }) + + await this.s3Client.send(putCommand) + + const image = this.createImageItem(key, { + filename, + lastModified: new Date(), + size: buffer.length, + type: contentType, + }) + + return { image } + } catch (error) { + console.error('Error uploading image:', error) + throw new Error('Failed to upload image') + } + } + + /** + * Elimina una imagen con validación de seguridad + */ + public async deleteImage(key: string): Promise { + try { + const config = this.configService.getConfig() + + // Validación de seguridad: asegurar que la clave pertenece a productos + if (!key.startsWith('products/')) { + throw new Error('Invalid key: can only delete product images') + } + + const deleteCommand = new DeleteObjectCommand({ + Bucket: config.bucketName, + Key: key, + }) + + await this.s3Client.send(deleteCommand) + + return { success: true } + } catch (error) { + console.error('Error deleting image:', error) + throw new Error('Failed to delete image') + } + } + + /** + * Crea un objeto ImageItem desde un objeto S3 con metadatos completos + */ + private createImageItemFromS3Object(s3Object: _Object): ImageItem { + const key = s3Object.Key! + const filename = FileUtils.extractFilename(key) + const url = this.configService.generateImageUrl(key) + const fileType = FileUtils.getFileType(filename) + + return { + key, + url, + filename, + lastModified: s3Object.LastModified || new Date(), + size: s3Object.Size || 0, + type: fileType, + } + } + + /** + * Crea un objeto ImageItem optimizado para nuevas subidas + */ + private createImageItem(key: string, override?: Partial): ImageItem { + const filename = FileUtils.extractFilename(key) + const url = this.configService.generateImageUrl(key) + const fileType = FileUtils.getFileType(filename) + + return { + key, + url, + filename, + lastModified: override?.lastModified || new Date(), + size: override?.size || 0, + type: override?.type || fileType, + } + } + + /** + * Valida si el contenido es base64 válido + */ + private isValidBase64(str: string): boolean { + try { + return Buffer.from(str, 'base64').toString('base64') === str + } catch (error) { + return false + } + } +} diff --git a/amplify/functions/storeImages/services/utils.ts b/amplify/functions/storeImages/services/utils.ts new file mode 100644 index 00000000..2d99aae8 --- /dev/null +++ b/amplify/functions/storeImages/services/utils.ts @@ -0,0 +1,83 @@ +export class FileUtils { + private static readonly MIME_TYPES: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + } + + /** + * Determina el tipo MIME basado en la extensión del archivo + */ + public static getFileType(filename: string): string { + const extension = filename.split('.').pop()?.toLowerCase() || '' + return this.MIME_TYPES[extension] || 'application/octet-stream' + } + + /** + * Extrae el nombre del archivo de una clave S3 + */ + public static extractFilename(s3Key: string): string { + const keyParts = s3Key.split('/') + return keyParts[keyParts.length - 1] + } + + /** + * Genera una clave S3 única con timestamp + */ + public static generateS3Key(storeId: string, filename: string): string { + const timestamp = new Date().getTime() + return `products/${storeId}/${timestamp}-${filename}` + } + + /** + * Genera el prefijo para búsquedas en S3 + */ + public static generateStorePrefix(storeId: string, prefix?: string): string { + return prefix ? `products/${storeId}/${prefix}` : `products/${storeId}/` + } +} + +export class ValidationUtils { + /** + * Valida que el storeId esté presente + */ + public static validateStoreId(storeId?: string): void { + if (!storeId) { + throw new Error('Store ID is required') + } + } + + /** + * Valida que la acción sea válida + */ + public static validateAction(action?: string): void { + const validActions = ['list', 'upload', 'delete'] + if (!action || !validActions.includes(action)) { + throw new Error('Invalid action') + } + } + + /** + * Valida los parámetros requeridos para upload + */ + public static validateUploadParams( + filename?: string, + contentType?: string, + fileContent?: string + ): void { + if (!filename || !contentType || !fileContent) { + throw new Error('filename, contentType, and fileContent are required for upload') + } + } + + /** + * Valida los parámetros requeridos para delete + */ + public static validateDeleteParams(key?: string): void { + if (!key) { + throw new Error('key is required for delete') + } + } +} diff --git a/amplify/functions/storeImages/types/types.ts b/amplify/functions/storeImages/types/types.ts new file mode 100644 index 00000000..bb12bed2 --- /dev/null +++ b/amplify/functions/storeImages/types/types.ts @@ -0,0 +1,58 @@ +export interface APIGatewayEvent { + httpMethod: string + headers?: { [key: string]: string } + body?: string +} + +export interface APIGatewayResponse { + statusCode: number + body: string + headers: { [key: string]: string } +} + +export interface RequestBody { + action: 'list' | 'upload' | 'delete' + storeId: string + // Lista + limit?: number + prefix?: string + continuationToken?: string + // Upload + filename?: string + contentType?: string + fileContent?: string + // Delete + key?: string +} + +export interface ImageItem { + key: string + url: string + filename: string + lastModified: Date + size: number + type: string +} + +export interface ListImagesResponse { + images: ImageItem[] + nextContinuationToken?: string +} + +export interface UploadImageResponse { + image: ImageItem +} + +export interface DeleteImageResponse { + success: boolean +} + +export interface S3Config { + bucketName: string + awsRegion: string + cloudFrontDomainBase: string +} + +export interface ErrorResponse { + message: string +} diff --git a/app/store/[slug]/dashboard/page.tsx b/app/store/[slug]/dashboard/page.tsx index 0614fea1..97993696 100644 --- a/app/store/[slug]/dashboard/page.tsx +++ b/app/store/[slug]/dashboard/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { EcommerceSetup } from '@/app/store/components/store-setup/EcommerceSetup' +import { EcommerceSetup } from '@/app/store/components/store-setup/components/EcommerceSetup' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/[slug]/dashboard/statistics/page.tsx b/app/store/[slug]/dashboard/statistics/page.tsx index ce998f72..9e96d3f0 100644 --- a/app/store/[slug]/dashboard/statistics/page.tsx +++ b/app/store/[slug]/dashboard/statistics/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { SalesDashboard } from '@/app/store/components/statistics/SalesDashboard' +import { SalesDashboard } from '@/app/store/components/statistics/components/SalesDashboard' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/[slug]/orders/page.tsx b/app/store/[slug]/orders/page.tsx index 2599b60c..c97ea78c 100644 --- a/app/store/[slug]/orders/page.tsx +++ b/app/store/[slug]/orders/page.tsx @@ -1,4 +1,4 @@ -import { Orders } from '@/app/store/components/orders/Orders' +import { Orders } from '@/app/store/components/orders/components/Orders' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/[slug]/orders/processing/page.tsx b/app/store/[slug]/orders/processing/page.tsx index e930eb9b..693953ad 100644 --- a/app/store/[slug]/orders/processing/page.tsx +++ b/app/store/[slug]/orders/processing/page.tsx @@ -1,4 +1,4 @@ -import { InProgress } from '@/app/store/components/orders/InProgress' +import { InProgress } from '@/app/store/components/orders/components/InProgress' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/[slug]/setup/apps/page.tsx b/app/store/[slug]/setup/apps/page.tsx index 03736193..ae99af7e 100644 --- a/app/store/[slug]/setup/apps/page.tsx +++ b/app/store/[slug]/setup/apps/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { AppIntegrationPage } from '@/app/store/components/app-integration/AppIntegrationPage' +import { AppIntegrationPage } from '@/app/store/components/app-integration/components/AppIntegrationPage' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/[slug]/setup/domain/page.tsx b/app/store/[slug]/setup/domain/page.tsx index 1772af25..f133126c 100644 --- a/app/store/[slug]/setup/domain/page.tsx +++ b/app/store/[slug]/setup/domain/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { DomainManagement } from '@/app/store/components/domains/DomainManagement' +import { DomainManagement } from '@/app/store/components/domains/components/DomainManagement' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/[slug]/setup/page.tsx b/app/store/[slug]/setup/page.tsx index 8bb2018b..07b3aa9e 100644 --- a/app/store/[slug]/setup/page.tsx +++ b/app/store/[slug]/setup/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { ThemePreview } from '@/app/store/components/store-config/ThemePreview' +import { ThemePreview } from '@/app/store/components/store-config/components/ThemePreview' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/[slug]/setup/payments/page.tsx b/app/store/[slug]/setup/payments/page.tsx index f70c05b6..fe06df95 100644 --- a/app/store/[slug]/setup/payments/page.tsx +++ b/app/store/[slug]/setup/payments/page.tsx @@ -1,4 +1,4 @@ -import { PaymentSettings } from '@/app/store/components/payments/PaymentSettings' +import { PaymentSettings } from '@/app/store/components/payments/components/PaymentSettings' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/components/ai-chat/AiInput.tsx b/app/store/components/ai-chat/components/AiInput.tsx similarity index 98% rename from app/store/components/ai-chat/AiInput.tsx rename to app/store/components/ai-chat/components/AiInput.tsx index 9991f5ca..048986e8 100644 --- a/app/store/components/ai-chat/AiInput.tsx +++ b/app/store/components/ai-chat/components/AiInput.tsx @@ -3,7 +3,6 @@ import { Send } from 'lucide-react' import { useState } from 'react' import { Textarea } from '@/components/ui/textarea' -import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' import { useAutoResizeTextarea } from '@/hooks/ui/use-auto-resize-textare' diff --git a/app/store/components/ai-chat/ChatTrigger.tsx b/app/store/components/ai-chat/components/ChatTrigger.tsx similarity index 90% rename from app/store/components/ai-chat/ChatTrigger.tsx rename to app/store/components/ai-chat/components/ChatTrigger.tsx index 263bbcd9..343c6a00 100644 --- a/app/store/components/ai-chat/ChatTrigger.tsx +++ b/app/store/components/ai-chat/components/ChatTrigger.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' -import { RefinedAIAssistantSheet } from '@/app/store/components/ai-chat/RefinedAiAssistant' -import { GradientSparkles } from '@/app/store/components/ai-chat/GradientSparkles' +import { RefinedAIAssistantSheet } from '@/app/store/components/ai-chat/components/RefinedAiAssistant' +import { GradientSparkles } from '@/app/store/components/ai-chat/components/GradientSparkles' export function ChatTrigger() { const [isOpen, setIsOpen] = useState(false) diff --git a/app/store/components/ai-chat/GradientSparkles.tsx b/app/store/components/ai-chat/components/GradientSparkles.tsx similarity index 100% rename from app/store/components/ai-chat/GradientSparkles.tsx rename to app/store/components/ai-chat/components/GradientSparkles.tsx diff --git a/app/store/components/ai-chat/MessageLoading.tsx b/app/store/components/ai-chat/components/MessageLoading.tsx similarity index 100% rename from app/store/components/ai-chat/MessageLoading.tsx rename to app/store/components/ai-chat/components/MessageLoading.tsx diff --git a/app/store/components/ai-chat/Orb.tsx b/app/store/components/ai-chat/components/Orb.tsx similarity index 100% rename from app/store/components/ai-chat/Orb.tsx rename to app/store/components/ai-chat/components/Orb.tsx diff --git a/app/store/components/ai-chat/RefinedAiAssistant.tsx b/app/store/components/ai-chat/components/RefinedAiAssistant.tsx similarity index 95% rename from app/store/components/ai-chat/RefinedAiAssistant.tsx rename to app/store/components/ai-chat/components/RefinedAiAssistant.tsx index c746b76a..209ab88b 100644 --- a/app/store/components/ai-chat/RefinedAiAssistant.tsx +++ b/app/store/components/ai-chat/components/RefinedAiAssistant.tsx @@ -2,15 +2,15 @@ import { useState, useRef, useEffect } from 'react' import { ChevronLeft } from 'lucide-react' import { ScrollArea } from '@/components/ui/scroll-area' import { useAutoScroll } from '@/app/store/components/ai-chat/hooks/useAutoScroll' -import { MessageLoading } from '@/app/store/components/ai-chat/MessageLoading' -import { TypingMessage } from '@/app/store/components/ai-chat/TypingMessage' -import { AIInputWithSearch } from '@/app/store/components/ai-chat/AiInput' +import { MessageLoading } from '@/app/store/components/ai-chat/components/MessageLoading' +import { TypingMessage } from '@/app/store/components/ai-chat/components/TypingMessage' +import { AIInputWithSearch } from '@/app/store/components/ai-chat/components/AiInput' import { useChat } from '@/app/store/components/ai-chat/hooks/useChat' import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/ui/use-media-query' -import { GradientSparkles } from '@/app/store/components/ai-chat/GradientSparkles' +import { GradientSparkles } from '@/app/store/components/ai-chat/components/GradientSparkles' import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' -import Orb from '@/app/store/components/ai-chat/Orb' +import Orb from '@/app/store/components/ai-chat/components/Orb' interface Suggestion { id: string diff --git a/app/store/components/ai-chat/TypingEffect.tsx b/app/store/components/ai-chat/components/TypingEffect.tsx similarity index 100% rename from app/store/components/ai-chat/TypingEffect.tsx rename to app/store/components/ai-chat/components/TypingEffect.tsx diff --git a/app/store/components/ai-chat/TypingMessage.tsx b/app/store/components/ai-chat/components/TypingMessage.tsx similarity index 97% rename from app/store/components/ai-chat/TypingMessage.tsx rename to app/store/components/ai-chat/components/TypingMessage.tsx index 9c3dab21..c2d6450d 100644 --- a/app/store/components/ai-chat/TypingMessage.tsx +++ b/app/store/components/ai-chat/components/TypingMessage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { TypingEffect } from './TypingEffect' +import { TypingEffect } from '@/app/store/components/ai-chat/components/TypingEffect' import { cn } from '@/lib/utils' import { ChevronDown, ChevronUp } from 'lucide-react' diff --git a/app/store/components/app-integration/AppIntegrationPage.tsx b/app/store/components/app-integration/components/AppIntegrationPage.tsx similarity index 99% rename from app/store/components/app-integration/AppIntegrationPage.tsx rename to app/store/components/app-integration/components/AppIntegrationPage.tsx index 6e03ec7f..0e3d96bf 100644 --- a/app/store/components/app-integration/AppIntegrationPage.tsx +++ b/app/store/components/app-integration/components/AppIntegrationPage.tsx @@ -9,7 +9,7 @@ import { CardTitle, } from '@/components/ui/card' import Image from 'next/image' -import { ConnectModal } from '@/app/store/components/app-integration/ConnectModal' +import { ConnectModal } from '@/app/store/components/app-integration/components/ConnectModal' import useStoreDataStore from '@/context/core/storeDataStore' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/components/app-integration/ConnectModal.tsx b/app/store/components/app-integration/components/ConnectModal.tsx similarity index 100% rename from app/store/components/app-integration/ConnectModal.tsx rename to app/store/components/app-integration/components/ConnectModal.tsx diff --git a/app/store/components/domains/ChangeDomainDialog.tsx b/app/store/components/domains/components/ChangeDomainDialog.tsx similarity index 100% rename from app/store/components/domains/ChangeDomainDialog.tsx rename to app/store/components/domains/components/ChangeDomainDialog.tsx diff --git a/app/store/components/domains/DomainManagement.tsx b/app/store/components/domains/components/DomainManagement.tsx similarity index 99% rename from app/store/components/domains/DomainManagement.tsx rename to app/store/components/domains/components/DomainManagement.tsx index 60728966..8cbd0563 100644 --- a/app/store/components/domains/DomainManagement.tsx +++ b/app/store/components/domains/components/DomainManagement.tsx @@ -1,8 +1,8 @@ import { Search, Globe, Store, MapPin } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' -import { ChangeDomainDialog } from '@/app/store/components/domains/ChangeDomainDialog' -import { EditStoreProfileDialog } from '@/app/store/components/domains/EditStoreProfileDialog' +import { ChangeDomainDialog } from '@/app/store/components/domains/components/ChangeDomainDialog' +import { EditStoreProfileDialog } from '@/app/store/components/domains/components/EditStoreProfileDialog' import { Skeleton } from '@/components/ui/skeleton' import useStoreDataStore from '@/context/core/storeDataStore' import { Amplify } from 'aws-amplify' diff --git a/app/store/components/domains/EditStoreProfileDialog.tsx b/app/store/components/domains/components/EditStoreProfileDialog.tsx similarity index 100% rename from app/store/components/domains/EditStoreProfileDialog.tsx rename to app/store/components/domains/components/EditStoreProfileDialog.tsx diff --git a/app/store/components/images-selector/ImageGallery.tsx b/app/store/components/images-selector/components/ImageGallery.tsx similarity index 100% rename from app/store/components/images-selector/ImageGallery.tsx rename to app/store/components/images-selector/components/ImageGallery.tsx diff --git a/app/store/components/images-selector/components/ModalFooter.tsx b/app/store/components/images-selector/components/ModalFooter.tsx new file mode 100644 index 00000000..8b1352ab --- /dev/null +++ b/app/store/components/images-selector/components/ModalFooter.tsx @@ -0,0 +1,24 @@ +import { Button } from '@/components/ui/button' + +interface ModalFooterProps { + onCancel: () => void + onConfirm: () => void + hasSelection: boolean +} + +export default function ModalFooter({ onCancel, onConfirm, hasSelection }: ModalFooterProps) { + return ( +
+ + +
+ ) +} diff --git a/app/store/components/images-selector/components/SearchAndFilters.tsx b/app/store/components/images-selector/components/SearchAndFilters.tsx new file mode 100644 index 00000000..11a561db --- /dev/null +++ b/app/store/components/images-selector/components/SearchAndFilters.tsx @@ -0,0 +1,56 @@ +import { Search, Grid, List } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu' + +interface SearchAndFiltersProps { + searchTerm: string + onSearchChange: (value: string) => void + viewMode: 'grid' | 'list' + onViewModeChange: (mode: 'grid' | 'list') => void +} + +export default function SearchAndFilters({ + searchTerm, + onSearchChange, + viewMode, + onViewModeChange, +}: SearchAndFiltersProps) { + return ( +
+
+ + onSearchChange(e.target.value)} + /> +
+
+ + + + + + onViewModeChange('grid')}> + + Cuadrícula + + onViewModeChange('list')}> + + Lista + + + +
+
+ ) +} diff --git a/app/store/components/images-selector/components/UploadDropZone.tsx b/app/store/components/images-selector/components/UploadDropZone.tsx new file mode 100644 index 00000000..f0afd56a --- /dev/null +++ b/app/store/components/images-selector/components/UploadDropZone.tsx @@ -0,0 +1,42 @@ +import { Upload } from 'lucide-react' +import { Button } from '@/components/ui/button' + +interface UploadDropZoneProps { + onDrop: (e: React.DragEvent) => void + onDragOver: (e: React.DragEvent) => void + onFileUpload: (e: React.ChangeEvent) => void + triggerFileSelect: () => void + fileInputRef: React.RefObject + allowMultipleSelection: boolean +} + +export default function UploadDropZone({ + onDrop, + onDragOver, + onFileUpload, + triggerFileSelect, + fileInputRef, + allowMultipleSelection, +}: UploadDropZoneProps) { + return ( +
+ + +

Arrastrar y soltar imágenes aquí

+
+ ) +} diff --git a/app/store/components/images-selector/components/UploadPreview.tsx b/app/store/components/images-selector/components/UploadPreview.tsx new file mode 100644 index 00000000..170e23d2 --- /dev/null +++ b/app/store/components/images-selector/components/UploadPreview.tsx @@ -0,0 +1,25 @@ +import Image from 'next/image' +import { Loader } from '@/components/ui/loader' + +interface UploadPreviewProps { + uploadPreview: string + isUploading: boolean +} + +export default function UploadPreview({ uploadPreview, isUploading }: UploadPreviewProps) { + if (!isUploading || !uploadPreview) { + return null + } + + return ( +
+
+ Preview +
+
+ + Subiendo imagen... +
+
+ ) +} diff --git a/app/store/components/images-selector/components/image-selector-modal.tsx b/app/store/components/images-selector/components/image-selector-modal.tsx new file mode 100644 index 00000000..e075ad7b --- /dev/null +++ b/app/store/components/images-selector/components/image-selector-modal.tsx @@ -0,0 +1,198 @@ +import { useState, useCallback, useMemo } from 'react' +import { Loader } from '@/components/ui/loader' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useS3Images, type S3Image } from '@/app/store/hooks/useS3Images' +import ImageGallery from '@/app/store/components/images-selector/components/ImageGallery' + +// Hooks +import { useImageSelection } from '@/app/store/components/images-selector/hooks/useImageSelection' +import { useImageUpload } from '@/app/store/components/images-selector/hooks/useImageUpload' + +// Componentes modulares +import SearchAndFilters from '@/app/store/components/images-selector/components/SearchAndFilters' +import UploadDropZone from '@/app/store/components/images-selector/components/UploadDropZone' +import UploadPreview from '@/app/store/components/images-selector/components/UploadPreview' +import ModalFooter from '@/app/store/components/images-selector/components/ModalFooter' + +interface ImageSelectorModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSelect?: (images: S3Image | S3Image[] | null) => void + initialSelectedImage?: string | null + allowMultipleSelection?: boolean +} + +export default function ImageSelectorModal({ + open, + onOpenChange, + onSelect, + initialSelectedImage = null, + allowMultipleSelection = false, +}: ImageSelectorModalProps) { + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [searchTerm, setSearchTerm] = useState('') + + // Memoizar las opciones para evitar re-renders del hook + const s3Options = useMemo(() => ({ limit: 18 }), []) + + // Hook principal de S3 + const { + images, + loading, + error, + uploadImage, + deleteImage, + fetchMoreImages, + loadingMore, + nextContinuationToken, + } = useS3Images(s3Options) + + // Hook para manejo de selección + const { + selectedImage, + handleImageSelect, + getSelectedImages, + removeFromSelection, + addToSelection, + } = useImageSelection({ + initialSelectedImage, + allowMultipleSelection, + images, + }) + + // Hook para manejo de upload + const { + isUploading, + uploadPreview, + fileInputRef, + handleFileUpload, + handleDrop, + handleDragOver, + triggerFileSelect, + } = useImageUpload({ + uploadImage, + allowMultipleSelection, + onImagesUploaded: uploadedImages => { + const keys = uploadedImages.map(img => img.key) + addToSelection(keys) + }, + }) + + // Filtrado de imágenes + const filteredImages = useMemo( + () => images.filter(img => img.filename.toLowerCase().includes(searchTerm.toLowerCase())), + [images, searchTerm] + ) + + // Manejo de confirmación + const handleConfirm = useCallback(() => { + const selectedImages = getSelectedImages() + + if (allowMultipleSelection) { + onSelect?.(selectedImages.length > 0 ? selectedImages : null) + } else { + onSelect?.(selectedImages[0] || null) + } + + onOpenChange(false) + }, [allowMultipleSelection, getSelectedImages, onSelect, onOpenChange]) + + // Manejo de eliminación de imágenes + const handleDeleteImage = useCallback( + async (key: string) => { + try { + const success = await deleteImage(key) + if (success) { + removeFromSelection(key) + } + } catch (error) { + console.error('Error deleting image:', error) + } + }, + [deleteImage, removeFromSelection] + ) + + // Manejo del scroll infinito + const handleScroll = useCallback( + (e: React.UIEvent) => { + const target = e.target as HTMLDivElement + const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100 + + if (isAtBottom && nextContinuationToken && !loadingMore && !loading) { + fetchMoreImages() + } + }, + [nextContinuationToken, loadingMore, loading, fetchMoreImages] + ) + + // Verificar si hay selección + const hasSelection = useMemo(() => { + if (allowMultipleSelection) { + return Array.isArray(selectedImage) && selectedImage.length > 0 + } + return selectedImage !== null + }, [selectedImage, allowMultipleSelection]) + + return ( + + + +
+ Seleccionar imagen +
+
+ +
+ {/* Search and filters */} + + + {/* Upload drop zone */} + + + {/* Upload preview */} + + + {/* Image gallery */} + + + {/* Loading indicator for more images */} + {loadingMore && ( +
+ + Cargando más imágenes... +
+ )} +
+ + {/* Footer */} + onOpenChange(false)} + onConfirm={handleConfirm} + hasSelection={hasSelection} + /> +
+
+ ) +} diff --git a/app/store/components/images-selector/hooks/useImageSelection.ts b/app/store/components/images-selector/hooks/useImageSelection.ts new file mode 100644 index 00000000..9e71c13c --- /dev/null +++ b/app/store/components/images-selector/hooks/useImageSelection.ts @@ -0,0 +1,83 @@ +import { useState, useCallback } from 'react' +import type { S3Image } from '@/app/store/hooks/useS3Images' + +interface UseImageSelectionProps { + initialSelectedImage?: string | null + allowMultipleSelection?: boolean + images: S3Image[] +} + +export function useImageSelection({ + initialSelectedImage = null, + allowMultipleSelection = false, + images, +}: UseImageSelectionProps) { + const [selectedImage, setSelectedImage] = useState( + allowMultipleSelection + ? initialSelectedImage + ? [initialSelectedImage] + : [] + : initialSelectedImage + ) + + const handleImageSelect = useCallback( + (image: S3Image) => { + if (allowMultipleSelection) { + setSelectedImage(prev => { + const selectedKeys = Array.isArray(prev) ? prev : [] + const isSelected = selectedKeys.includes(image.key) + if (isSelected) { + return selectedKeys.filter(key => key !== image.key) + } else { + return [...selectedKeys, image.key] + } + }) + } else { + setSelectedImage(prev => (prev === image.key ? null : image.key)) + } + }, + [allowMultipleSelection] + ) + + const getSelectedImages = useCallback(() => { + if (allowMultipleSelection) { + const selectedKeys = Array.isArray(selectedImage) ? selectedImage : [] + return images.filter(img => selectedKeys.includes(img.key)) + } else { + const selected = images.find(img => img.key === selectedImage) || null + return selected ? [selected] : [] + } + }, [allowMultipleSelection, selectedImage, images]) + + const removeFromSelection = useCallback((key: string) => { + setSelectedImage(prev => { + if (Array.isArray(prev)) { + return prev.filter(selectedKey => selectedKey !== key) + } + return prev === key ? null : prev + }) + }, []) + + const addToSelection = useCallback( + (keys: string[]) => { + if (allowMultipleSelection) { + setSelectedImage(prev => { + const currentSelected = Array.isArray(prev) ? prev : prev ? [prev] : [] + const uniqueNewKeys = keys.filter(key => !currentSelected.includes(key)) + return [...currentSelected, ...uniqueNewKeys] + }) + } else if (keys.length > 0) { + setSelectedImage(keys[0]) + } + }, + [allowMultipleSelection] + ) + + return { + selectedImage, + handleImageSelect, + getSelectedImages, + removeFromSelection, + addToSelection, + } +} diff --git a/app/store/components/images-selector/hooks/useImageUpload.ts b/app/store/components/images-selector/hooks/useImageUpload.ts new file mode 100644 index 00000000..34219c8f --- /dev/null +++ b/app/store/components/images-selector/hooks/useImageUpload.ts @@ -0,0 +1,122 @@ +import { useState, useCallback, useRef } from 'react' +import type { S3Image } from '@/app/store/hooks/useS3Images' + +interface UseImageUploadProps { + uploadImage: (files: File[]) => Promise + allowMultipleSelection?: boolean + onImagesUploaded?: (images: S3Image[]) => void +} + +export function useImageUpload({ + uploadImage, + allowMultipleSelection = false, + onImagesUploaded, +}: UseImageUploadProps) { + const [isUploading, setIsUploading] = useState(false) + const [uploadPreview, setUploadPreview] = useState(null) + const fileInputRef = useRef(null) + + const fileToBase64 = useCallback((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) + }) + }, []) + + const processFiles = useCallback( + async (files: File[]) => { + const imageFiles = files.filter(file => file.type.startsWith('image/')) + + if (imageFiles.length === 0) { + console.warn('No image files found.') + return null + } + + setIsUploading(true) + setUploadPreview(null) + + // Set preview for first image + try { + const reader = new FileReader() + reader.onload = e => { + if (e.target?.result) { + setUploadPreview(e.target.result as string) + } + } + reader.readAsDataURL(imageFiles[0]) + } catch (error) { + console.error('Error setting preview:', error) + } + + try { + const uploadedImages = await uploadImage(imageFiles) + if (uploadedImages && uploadedImages.length > 0) { + onImagesUploaded?.(uploadedImages) + return uploadedImages + } + return null + } catch (error) { + console.error('Error uploading images:', error) + return null + } finally { + setIsUploading(false) + setUploadPreview(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + }, + [uploadImage, onImagesUploaded] + ) + + const handleFileUpload = useCallback( + async (event: React.ChangeEvent) => { + const files = event.target.files + if (!files || files.length === 0) return + + const filesArray = Array.from(files) + await processFiles(filesArray) + }, + [processFiles] + ) + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const filesArray = Array.from(e.dataTransfer.files) + await processFiles(filesArray) + } + }, + [processFiles] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + const triggerFileSelect = useCallback(() => { + fileInputRef.current?.click() + }, []) + + return { + isUploading, + uploadPreview, + fileInputRef, + handleFileUpload, + handleDrop, + handleDragOver, + triggerFileSelect, + allowMultipleSelection, + } +} diff --git a/app/store/components/images-selector/image-selector-modal.tsx b/app/store/components/images-selector/image-selector-modal.tsx deleted file mode 100644 index 8f305c45..00000000 --- a/app/store/components/images-selector/image-selector-modal.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { useState, useRef, useCallback } from 'react' -import { Search, Grid, List, Upload } from 'lucide-react' -import { Loader } from '@/components/ui/loader' -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' -import ImageGallery from '@/app/store/components/images-selector/ImageGallery' - -interface ImageSelectorModalProps { - open: boolean - onOpenChange: (open: boolean) => void - onSelect?: (images: S3Image | S3Image[] | null) => void - initialSelectedImage?: string | null - allowMultipleSelection?: boolean -} - -export default function ImageSelectorModal({ - open, - onOpenChange, - onSelect, - initialSelectedImage = null, - allowMultipleSelection = false, -}: ImageSelectorModalProps) { - const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') - const [selectedImage, setSelectedImage] = useState( - allowMultipleSelection - ? initialSelectedImage - ? [initialSelectedImage] - : [] - : initialSelectedImage - ) - const [searchTerm, setSearchTerm] = useState('') - const fileInputRef = useRef(null) - const [isUploading, setIsUploading] = useState(false) - const [uploadPreview, setUploadPreview] = useState(null) - - const { - images, - loading, - error, - uploadImage, - deleteImage, - fetchMoreImages, - loadingMore, - nextContinuationToken, - } = useS3Images({ - limit: 18, - }) - const filteredImages = images.filter(img => - img.filename.toLowerCase().includes(searchTerm.toLowerCase()) - ) - - // Manejar la selección de imágenes - const handleImageSelect = (image: S3Image) => { - if (allowMultipleSelection) { - const selectedKeys = Array.isArray(selectedImage) ? selectedImage : [] - const isSelected = selectedKeys.includes(image.key) - if (isSelected) { - setSelectedImage(selectedKeys.filter(key => key !== image.key)) - } else { - setSelectedImage([...selectedKeys, image.key]) - } - } else { - const newSelectedKey = selectedImage === image.key ? null : image.key - setSelectedImage(newSelectedKey) - } - } - - // Manejar la confirmación de selección - const handleConfirm = () => { - if (allowMultipleSelection) { - const selectedKeys = Array.isArray(selectedImage) ? selectedImage : [] - const selectedImages = images.filter(img => selectedKeys.includes(img.key)) - if (onSelect) { - onSelect(selectedImages.length > 0 ? selectedImages : null) - } - } else { - const selected = images.find(img => img.key === selectedImage) || null - if (onSelect) { - onSelect(selected) - } - } - onOpenChange(false) - } - - const handleFileUpload = async (event: React.ChangeEvent) => { - const files = event.target.files - if (!files || files.length === 0) return - - const filesArray = Array.from(files) - setIsUploading(true) - - const previews: string[] = [] - for (const file of filesArray) { - const reader = new FileReader() - reader.onload = e => { - if (e.target?.result) { - previews.push(e.target.result as string) - - if (previews.length === filesArray.length) { - setUploadPreview(previews[0]) - } - } - } - reader.readAsDataURL(file) - } - - try { - const uploadedImages = await uploadImage(filesArray) - if (uploadedImages && uploadedImages.length > 0) { - if (allowMultipleSelection) { - setSelectedImage(prev => { - const currentSelected = Array.isArray(prev) ? prev : prev ? [prev] : [] - const newKeys = uploadedImages.map(img => img.key) - const uniqueNewKeys = newKeys.filter(key => !currentSelected.includes(key)) - return [...currentSelected, ...uniqueNewKeys] - }) - } else { - setSelectedImage(uploadedImages[0].key) - } - } - } catch (error) { - console.error('Error uploading image(s):', error) - } finally { - setIsUploading(false) - setUploadPreview(null) - - 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 filesArray = Array.from(e.dataTransfer.files) - - const imageFiles = filesArray.filter(file => file.type.startsWith('image/')) - - if (imageFiles.length === 0) { - console.warn('Dropped files are not images.') - return - } - - setIsUploading(true) - setUploadPreview(null) - - const reader = new FileReader() - reader.onload = e => { - if (e.target?.result) { - setUploadPreview(e.target.result as string) - } - } - reader.readAsDataURL(imageFiles[0]) - - try { - const uploadedImages = await uploadImage(imageFiles) - if (uploadedImages && uploadedImages.length > 0) { - if (allowMultipleSelection) { - setSelectedImage(prev => { - const currentSelected = Array.isArray(prev) ? prev : prev ? [prev] : [] - const newKeys = uploadedImages.map(img => img.key) - const uniqueNewKeys = newKeys.filter(key => !currentSelected.includes(key)) - return [...currentSelected, ...uniqueNewKeys] - }) - } else { - setSelectedImage(uploadedImages[0].key) - } - } - } catch (error) { - console.error('Error uploading image(s) on drop:', error) - } finally { - setIsUploading(false) - setUploadPreview(null) - } - } - }, - [uploadImage, allowMultipleSelection, selectedImage] - ) - - const onDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - }, []) - - const handleScroll = (e: React.UIEvent) => { - const target = e.target as HTMLDivElement - - const isAtBottom = target.scrollHeight - target.scrollTop <= target.clientHeight + 100 - - if (isAtBottom && nextContinuationToken && !loadingMore && !loading) { - fetchMoreImages() - } - } - - 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... -
-
- )} - - {/* Render the extracted ImageGallery component */} - - - {/* Loading indicator for more images */} - {loadingMore && ( -
- - Cargando más imágenes... -
- )} -
- - {/* Footer */} -
- - -
-
-
- ) -} diff --git a/app/store/components/images-selector/index.ts b/app/store/components/images-selector/index.ts new file mode 100644 index 00000000..cab2c3f7 --- /dev/null +++ b/app/store/components/images-selector/index.ts @@ -0,0 +1,16 @@ +// Componente principal +export { default as ImageSelectorModal } from '@/app/store/components/images-selector/components/image-selector-modal' + +// Hooks personalizados +export { useImageSelection } from '@/app/store/components/images-selector/hooks/useImageSelection' +export { useImageUpload } from '@/app/store/components/images-selector/hooks/useImageUpload' + +// Componentes modulares +export { default as SearchAndFilters } from '@/app/store/components/images-selector/components/SearchAndFilters' +export { default as UploadDropZone } from '@/app/store/components/images-selector/components/UploadDropZone' +export { default as UploadPreview } from '@/app/store/components/images-selector/components/UploadPreview' +export { default as ModalFooter } from '@/app/store/components/images-selector/components/ModalFooter' +export { default as ImageGallery } from '@/app/store/components/images-selector/components/ImageGallery' + +// Re-exportar tipos del hook principal +export type { S3Image } from '@/app/store/hooks/useS3Images' diff --git a/app/store/components/notifications/NotificationPopover.tsx b/app/store/components/notifications/components/NotificationPopover.tsx similarity index 100% rename from app/store/components/notifications/NotificationPopover.tsx rename to app/store/components/notifications/components/NotificationPopover.tsx diff --git a/app/store/components/orders/InProgress.tsx b/app/store/components/orders/components/InProgress.tsx similarity index 100% rename from app/store/components/orders/InProgress.tsx rename to app/store/components/orders/components/InProgress.tsx diff --git a/app/store/components/orders/Orders.tsx b/app/store/components/orders/components/Orders.tsx similarity index 100% rename from app/store/components/orders/Orders.tsx rename to app/store/components/orders/components/Orders.tsx diff --git a/app/store/components/payments/ApiKeyModal.tsx b/app/store/components/payments/components/ApiKeyModal.tsx similarity index 100% rename from app/store/components/payments/ApiKeyModal.tsx rename to app/store/components/payments/components/ApiKeyModal.tsx diff --git a/app/store/components/payments/MercadoPagoGuide.tsx b/app/store/components/payments/components/MercadoPagoGuide.tsx similarity index 100% rename from app/store/components/payments/MercadoPagoGuide.tsx rename to app/store/components/payments/components/MercadoPagoGuide.tsx diff --git a/app/store/components/payments/PaymentCaptureSection.tsx b/app/store/components/payments/components/PaymentCaptureSection.tsx similarity index 100% rename from app/store/components/payments/PaymentCaptureSection.tsx rename to app/store/components/payments/components/PaymentCaptureSection.tsx diff --git a/app/store/components/payments/PaymentGatewayCard.tsx b/app/store/components/payments/components/PaymentGatewayCard.tsx similarity index 97% rename from app/store/components/payments/PaymentGatewayCard.tsx rename to app/store/components/payments/components/PaymentGatewayCard.tsx index 70c4dea6..e3d501dc 100644 --- a/app/store/components/payments/PaymentGatewayCard.tsx +++ b/app/store/components/payments/components/PaymentGatewayCard.tsx @@ -5,7 +5,7 @@ import { PaymentGatewayType } from '@/app/(setup-layout)/first-steps/hooks/useUs import { WompiPaymentIcons, MercadoPagoIcons, -} from '@/app/store/components/payments/PaymentMethodIcons' +} from '@/app/store/components/payments/components/PaymentMethodIcons' interface PaymentGatewayCardProps { gateway: PaymentGatewayType diff --git a/app/store/components/payments/PaymentMethodIcons.tsx b/app/store/components/payments/components/PaymentMethodIcons.tsx similarity index 100% rename from app/store/components/payments/PaymentMethodIcons.tsx rename to app/store/components/payments/components/PaymentMethodIcons.tsx diff --git a/app/store/components/payments/PaymentMethodsSection.tsx b/app/store/components/payments/components/PaymentMethodsSection.tsx similarity index 97% rename from app/store/components/payments/PaymentMethodsSection.tsx rename to app/store/components/payments/components/PaymentMethodsSection.tsx index 02a7b722..3c2bb62e 100644 --- a/app/store/components/payments/PaymentMethodsSection.tsx +++ b/app/store/components/payments/components/PaymentMethodsSection.tsx @@ -1,5 +1,5 @@ import { PaymentGatewayType } from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' -import { PaymentGatewayCard } from '@/app/store/components/payments/PaymentGatewayCard' +import { PaymentGatewayCard } from '@/app/store/components/payments/components/PaymentGatewayCard' interface PaymentMethodsSectionProps { configuredGateways: PaymentGatewayType[] diff --git a/app/store/components/payments/PaymentProvidersSection.tsx b/app/store/components/payments/components/PaymentProvidersSection.tsx similarity index 87% rename from app/store/components/payments/PaymentProvidersSection.tsx rename to app/store/components/payments/components/PaymentProvidersSection.tsx index 5487a03a..cbd55209 100644 --- a/app/store/components/payments/PaymentProvidersSection.tsx +++ b/app/store/components/payments/components/PaymentProvidersSection.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' -import { WompiGuide } from '@/app/store/components/payments/WompiGuide' -import { MercadoPagoGuide } from '@/app/store/components/payments/MercadoPagoGuide' +import { WompiGuide } from '@/app/store/components/payments/components/WompiGuide' +import { MercadoPagoGuide } from '@/app/store/components/payments/components/MercadoPagoGuide' export function PaymentProvidersSection() { return ( diff --git a/app/store/components/payments/PaymentSettings.tsx b/app/store/components/payments/components/PaymentSettings.tsx similarity index 84% rename from app/store/components/payments/PaymentSettings.tsx rename to app/store/components/payments/components/PaymentSettings.tsx index b811b2d9..8eb81896 100644 --- a/app/store/components/payments/PaymentSettings.tsx +++ b/app/store/components/payments/components/PaymentSettings.tsx @@ -1,10 +1,10 @@ 'use client' -import { PaymentSettingsSkeleton } from '@/app/store/components/payments/PaymentSettingsSkeleton' -import { ApiKeyModal } from '@/app/store/components/payments/ApiKeyModal' -import { PaymentProvidersSection } from '@/app/store/components/payments/PaymentProvidersSection' -import { PaymentMethodsSection } from '@/app/store/components/payments/PaymentMethodsSection' -import { PaymentCaptureSection } from '@/app/store/components/payments/PaymentCaptureSection' +import { PaymentSettingsSkeleton } from '@/app/store/components/payments/components/PaymentSettingsSkeleton' +import { ApiKeyModal } from '@/app/store/components/payments/components/ApiKeyModal' +import { PaymentProvidersSection } from '@/app/store/components/payments/components/PaymentProvidersSection' +import { PaymentMethodsSection } from '@/app/store/components/payments/components/PaymentMethodsSection' +import { PaymentCaptureSection } from '@/app/store/components/payments/components/PaymentCaptureSection' import { usePaymentSettings } from '@/app/store/components/payments/hooks/usePaymentSettings' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/components/payments/PaymentSettingsSkeleton.tsx b/app/store/components/payments/components/PaymentSettingsSkeleton.tsx similarity index 100% rename from app/store/components/payments/PaymentSettingsSkeleton.tsx rename to app/store/components/payments/components/PaymentSettingsSkeleton.tsx diff --git a/app/store/components/payments/WompiGuide.tsx b/app/store/components/payments/components/WompiGuide.tsx similarity index 100% rename from app/store/components/payments/WompiGuide.tsx rename to app/store/components/payments/components/WompiGuide.tsx diff --git a/app/store/components/product-management/collection-form/image-section.tsx b/app/store/components/product-management/collection-form/image-section.tsx index fbc71c43..71065c15 100644 --- a/app/store/components/product-management/collection-form/image-section.tsx +++ b/app/store/components/product-management/collection-form/image-section.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { Edit, Image as ImageIcon, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import Image from 'next/image' -import ImageSelectorModal from '@/app/store/components/images-selector/image-selector-modal' +import ImageSelectorModal from '@/app/store/components/images-selector/components/image-selector-modal' import { S3Image } from '@/app/store/hooks/useS3Images' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/components/product-management/main-components/ImageUpload.tsx b/app/store/components/product-management/main-components/ImageUpload.tsx index 8ab5c16b..34200c42 100644 --- a/app/store/components/product-management/main-components/ImageUpload.tsx +++ b/app/store/components/product-management/main-components/ImageUpload.tsx @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' import { Dialog, DialogContent } from '@/components/ui/dialog' -import ImageSelectorModal from '@/app/store/components/images-selector/image-selector-modal' +import ImageSelectorModal from '@/app/store/components/images-selector/components/image-selector-modal' interface ImageFile { url: string diff --git a/app/store/components/search-bar/SearchNavigation.tsx b/app/store/components/search-bar/components/SearchNavigation.tsx similarity index 97% rename from app/store/components/search-bar/SearchNavigation.tsx rename to app/store/components/search-bar/components/SearchNavigation.tsx index cbba4875..b5b93627 100644 --- a/app/store/components/search-bar/SearchNavigation.tsx +++ b/app/store/components/search-bar/components/SearchNavigation.tsx @@ -11,11 +11,10 @@ import { CommandList, } from '@/components/ui/command' import { Button } from '@/components/ui/button' -import { ScrollArea } from '@/components/ui/scroll-area' import { Search } from 'lucide-react' import { cn } from '@/lib/utils' import { getStoreId } from '@/utils/store-utils' -import { generateSearchRoutes } from '@/app/store/components/search-bar/SearchRoutes' +import { generateSearchRoutes } from '@/app/store/components/search-bar/components/SearchRoutes' export function SearchNavigation({ className }: { className?: string }) { const [open, setOpen] = React.useState(false) diff --git a/app/store/components/search-bar/SearchRoutes.tsx b/app/store/components/search-bar/components/SearchRoutes.tsx similarity index 100% rename from app/store/components/search-bar/SearchRoutes.tsx rename to app/store/components/search-bar/components/SearchRoutes.tsx diff --git a/app/store/components/sidebar/AppAccessGuard.tsx b/app/store/components/sidebar/components/AppAccessGuard.tsx similarity index 100% rename from app/store/components/sidebar/AppAccessGuard.tsx rename to app/store/components/sidebar/components/AppAccessGuard.tsx diff --git a/app/store/components/sidebar/app-sidebar.tsx b/app/store/components/sidebar/components/app-sidebar.tsx similarity index 94% rename from app/store/components/sidebar/app-sidebar.tsx rename to app/store/components/sidebar/components/app-sidebar.tsx index 90a1d393..8a2a86db 100644 --- a/app/store/components/sidebar/app-sidebar.tsx +++ b/app/store/components/sidebar/components/app-sidebar.tsx @@ -8,14 +8,14 @@ import { PuzzleIcon as PuzzlePiece, } from 'lucide-react' import { useState, useEffect } from 'react' -import { NavMain } from '@/app/store/components/sidebar/nav-main' -import { NavUser } from '@/app/store/components/sidebar/nav-user' +import { NavMain } from '@/app/store/components/sidebar/components/nav-main' +import { NavUser } from '@/app/store/components/sidebar/components/nav-user' import { Sidebar, SidebarContent, SidebarFooter, SidebarRail } from '@/components/ui/sidebar' import { useAuth } from '@/context/hooks/useAuth' import { routes } from '@/utils/routes' import { getStoreId } from '@/utils/store-utils' import { useParams, usePathname } from 'next/navigation' -import { NavApps } from '@/app/store/components/sidebar/nav-apps' +import { NavApps } from '@/app/store/components/sidebar/components/nav-apps' import useUserStore from '@/context/core/userStore' export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/app/store/components/sidebar/nav-apps.tsx b/app/store/components/sidebar/components/nav-apps.tsx similarity index 100% rename from app/store/components/sidebar/nav-apps.tsx rename to app/store/components/sidebar/components/nav-apps.tsx diff --git a/app/store/components/sidebar/nav-main.tsx b/app/store/components/sidebar/components/nav-main.tsx similarity index 100% rename from app/store/components/sidebar/nav-main.tsx rename to app/store/components/sidebar/components/nav-main.tsx diff --git a/app/store/components/sidebar/nav-user.tsx b/app/store/components/sidebar/components/nav-user.tsx similarity index 99% rename from app/store/components/sidebar/nav-user.tsx rename to app/store/components/sidebar/components/nav-user.tsx index af592498..950c6857 100644 --- a/app/store/components/sidebar/nav-user.tsx +++ b/app/store/components/sidebar/components/nav-user.tsx @@ -20,7 +20,7 @@ import { routes } from '@/utils/routes' import { useState, useEffect } from 'react' import useStoreDataStore from '@/context/core/storeDataStore' import Link from 'next/link' -import { PricingDrawer } from '@/app/store/components/store-setup/PricingDrawer' +import { PricingDrawer } from '@/app/store/components/store-setup/components/PricingDrawer' interface User { picture?: string diff --git a/app/store/components/statistics/ChartComponents.tsx b/app/store/components/statistics/components/ChartComponents.tsx similarity index 100% rename from app/store/components/statistics/ChartComponents.tsx rename to app/store/components/statistics/components/ChartComponents.tsx diff --git a/app/store/components/statistics/MetricCards.tsx b/app/store/components/statistics/components/MetricCards.tsx similarity index 93% rename from app/store/components/statistics/MetricCards.tsx rename to app/store/components/statistics/components/MetricCards.tsx index 20690310..2f5ac240 100644 --- a/app/store/components/statistics/MetricCards.tsx +++ b/app/store/components/statistics/components/MetricCards.tsx @@ -1,7 +1,11 @@ 'use client' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' -import { MetricLineChart, DistributionPieChart, ConversionFunnel } from './ChartComponents' +import { + MetricLineChart, + DistributionPieChart, + ConversionFunnel, +} from '@/app/store/components/statistics/components/ChartComponents' interface MetricCardProps { title: string diff --git a/app/store/components/statistics/SalesDashboard.tsx b/app/store/components/statistics/components/SalesDashboard.tsx similarity index 100% rename from app/store/components/statistics/SalesDashboard.tsx rename to app/store/components/statistics/components/SalesDashboard.tsx diff --git a/app/store/components/store-config/LogoUploader.tsx b/app/store/components/store-config/components/LogoUploader.tsx similarity index 100% rename from app/store/components/store-config/LogoUploader.tsx rename to app/store/components/store-config/components/LogoUploader.tsx diff --git a/app/store/components/store-config/ThemePreview.tsx b/app/store/components/store-config/components/ThemePreview.tsx similarity index 99% rename from app/store/components/store-config/ThemePreview.tsx rename to app/store/components/store-config/components/ThemePreview.tsx index 4e2886a5..0ebd2fe1 100644 --- a/app/store/components/store-config/ThemePreview.tsx +++ b/app/store/components/store-config/components/ThemePreview.tsx @@ -1,7 +1,7 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { MoreHorizontal } from 'lucide-react' -import { LogoUploader } from '@/app/store/components/store-config/LogoUploader' +import { LogoUploader } from '@/app/store/components/store-config/components/LogoUploader' import useStoreDataStore from '@/context/core/storeDataStore' import Link from 'next/link' import Image from 'next/image' diff --git a/app/store/components/store-setup/EcommerceSetup.tsx b/app/store/components/store-setup/components/EcommerceSetup.tsx similarity index 99% rename from app/store/components/store-setup/EcommerceSetup.tsx rename to app/store/components/store-setup/components/EcommerceSetup.tsx index 58dd846a..d63155bb 100644 --- a/app/store/components/store-setup/EcommerceSetup.tsx +++ b/app/store/components/store-setup/components/EcommerceSetup.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button' import Image from 'next/image' import Link from 'next/link' import { Progress } from '@/components/ui/progress' -import { Task, defaultStoreTasks } from '@/app/store/components/store-setup/StoreSetup-tasks' +import { Task, defaultStoreTasks } from '@/app/store/components/store-setup/utils/StoreSetup-tasks' import { Accordion, AccordionContent, @@ -14,7 +14,7 @@ import { import { useParams, usePathname } from 'next/navigation' import { getStoreId } from '@/utils/store-utils' import { useUserStoreData } from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' -import { PricingDrawer } from '@/app/store/components/store-setup/PricingDrawer' +import { PricingDrawer } from '@/app/store/components/store-setup/components/PricingDrawer' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' import useStoreDataStore from '@/context/core/storeDataStore' diff --git a/app/store/components/store-setup/PricingDrawer.tsx b/app/store/components/store-setup/components/PricingDrawer.tsx similarity index 100% rename from app/store/components/store-setup/PricingDrawer.tsx rename to app/store/components/store-setup/components/PricingDrawer.tsx diff --git a/app/store/components/store-setup/StoreSetup-tasks.ts b/app/store/components/store-setup/utils/StoreSetup-tasks.ts similarity index 100% rename from app/store/components/store-setup/StoreSetup-tasks.ts rename to app/store/components/store-setup/utils/StoreSetup-tasks.ts diff --git a/app/store/config/StoreLayoutClient.tsx b/app/store/config/StoreLayoutClient.tsx index 0cc992cf..9d1264fe 100644 --- a/app/store/config/StoreLayoutClient.tsx +++ b/app/store/config/StoreLayoutClient.tsx @@ -1,16 +1,16 @@ 'use client' -import { AppSidebar } from '@/app/store/components/sidebar/app-sidebar' +import { AppSidebar } from '@/app/store/components/sidebar/components/app-sidebar' import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar' import { Separator } from '@/components/ui/separator' import { useEffect, useState } from 'react' -import { SearchNavigation } from '@/app/store/components/search-bar/SearchNavigation' -import { NotificationPopover } from '@/app/store/components/notifications/NotificationPopover' +import { SearchNavigation } from '@/app/store/components/search-bar/components/SearchNavigation' +import { NotificationPopover } from '@/app/store/components/notifications/components/NotificationPopover' import { PageTransition } from '@/components/ui/page-transition' import { getStoreId } from '@/utils/store-utils' import { useParams, usePathname } from 'next/navigation' import { useStore } from '@/app/store/hooks/useStore' -import { ChatTrigger } from '@/app/store/components/ai-chat/ChatTrigger' +import { ChatTrigger } from '@/app/store/components/ai-chat/components/ChatTrigger' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' diff --git a/app/store/hooks/useS3Images.ts b/app/store/hooks/useS3Images.ts index 2e31c114..de5a7ac2 100644 --- a/app/store/hooks/useS3Images.ts +++ b/app/store/hooks/useS3Images.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { post } from 'aws-amplify/api' import useStoreDataStore from '@/context/core/storeDataStore' @@ -32,97 +32,159 @@ export function useS3Images(options: UseS3ImagesOptions = {}) { const [nextContinuationToken, setNextContinuationToken] = useState(undefined) const [loadingMore, setLoadingMore] = useState(false) - const fetchImages = async (token?: string) => { - if (!storeId) { - setLoading(false) - setImages([]) - setNextContinuationToken(undefined) - return - } - - if (!token) { - setLoading(true) - setImages([]) - } else { - setLoadingMore(true) - } - setError(null) - - try { - const restOperation = post({ - apiName: 'StoreImagesApi', - path: 'store-images', - options: { - body: { - action: 'list', - storeId, - limit: options.limit || 18, - prefix: options.prefix || '', - continuationToken: token, - } as any, - }, - }) - - const { body } = await restOperation.response - const response = (await body.json()) as S3ImagesResponse - - if (!response.images) { - if (!token) { - setImages([]) - } + // Memoizar las opciones para evitar re-renders innecesarios + const memoizedOptions = useMemo( + () => ({ + limit: options.limit || 18, + prefix: options.prefix || '', + }), + [options.limit, options.prefix] + ) + + const fetchImages = useCallback( + async (token?: string) => { + if (!storeId) { + setLoading(false) + setImages([]) setNextContinuationToken(undefined) return } - const processedImages = response.images.map(img => ({ - ...img, - lastModified: img.lastModified ? new Date(img.lastModified) : undefined, - })) - - setImages(prev => (token ? [...prev, ...processedImages] : processedImages)) - setNextContinuationToken(response.nextContinuationToken) - } catch (err) { - console.error(token ? 'Error fetching more S3 images:' : 'Error fetching S3 images:', err) - setError(err instanceof Error ? err : new Error('Unknown error occurred')) - setNextContinuationToken(undefined) - } finally { if (!token) { - setLoading(false) + setLoading(true) + setImages([]) } else { - setLoadingMore(false) + setLoadingMore(true) } - } - } + setError(null) + + try { + const restOperation = post({ + apiName: 'StoreImagesApi', + path: 'store-images', + options: { + body: { + action: 'list', + storeId, + limit: memoizedOptions.limit, + prefix: memoizedOptions.prefix, + continuationToken: token, + } as any, + }, + }) + + const { body } = await restOperation.response + const response = (await body.json()) as S3ImagesResponse + + if (!response.images) { + if (!token) { + setImages([]) + } + setNextContinuationToken(undefined) + return + } + const processedImages = response.images.map(img => ({ + ...img, + lastModified: img.lastModified ? new Date(img.lastModified) : undefined, + })) + + setImages(prev => (token ? [...prev, ...processedImages] : processedImages)) + setNextContinuationToken(response.nextContinuationToken) + } catch (err) { + console.error(token ? 'Error fetching more S3 images:' : 'Error fetching S3 images:', err) + setError(err instanceof Error ? err : new Error('Unknown error occurred')) + setNextContinuationToken(undefined) + } finally { + if (!token) { + setLoading(false) + } else { + setLoadingMore(false) + } + } + }, + [storeId, memoizedOptions.limit, memoizedOptions.prefix] + ) + + // useEffect optimizado con dependencias estables useEffect(() => { fetchImages() - }, [storeId, options.prefix]) + }, [fetchImages]) - const fetchMoreImages = () => { + const fetchMoreImages = useCallback(() => { if (nextContinuationToken && !loadingMore && !loading) { fetchImages(nextContinuationToken) } - } + }, [nextContinuationToken, loadingMore, loading, fetchImages]) + + const uploadImage = useCallback( + async (files: File[]): Promise => { + if (!storeId || files.length === 0) return null + + const uploadedImages: S3Image[] = [] + + for (const file of files) { + 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, + } as any, + }, + }) + + const { body } = await restOperation.response + const response = (await body.json()) as S3ImagesResponse + + if (!response.image) { + console.error('Failed to upload image:', file.name) + continue + } + + const newImage = { + ...response.image, + lastModified: response.image.lastModified + ? new Date(response.image.lastModified) + : new Date(), + } + + uploadedImages.push(newImage) + } catch (err) { + console.error('Error uploading image:', file.name, err) + continue + } + } - const uploadImage = async (files: File[]): Promise => { - if (!storeId || files.length === 0) return null + if (uploadedImages.length > 0) { + setImages(prev => [...uploadedImages, ...prev]) + } - const uploadedImages: S3Image[] = [] + return uploadedImages.length > 0 ? uploadedImages : null + }, + [storeId] + ) - for (const file of files) { - try { - const base64File = await fileToBase64(file) + const deleteImage = useCallback( + async (key: string): Promise => { + if (!storeId) return false + try { const restOperation = post({ apiName: 'StoreImagesApi', path: 'store-images', options: { body: { - action: 'upload', + action: 'delete', storeId, - filename: file.name, - contentType: file.type, - fileContent: base64File, + key, } as any, }, }) @@ -130,66 +192,22 @@ export function useS3Images(options: UseS3ImagesOptions = {}) { const { body } = await restOperation.response const response = (await body.json()) as S3ImagesResponse - if (!response.image) { - console.error('Failed to upload image:', file.name) - continue + if (!response.success) { + throw new Error('Failed to delete image') } - const newImage = { - ...response.image, - lastModified: response.image.lastModified - ? new Date(response.image.lastModified) - : new Date(), - } + setImages(prev => prev.filter(img => img.key !== key)) - uploadedImages.push(newImage) + return true } catch (err) { - console.error('Error uploading image:', file.name, err) - - continue + console.error('Error deleting image:', err) + return false } - } - - if (uploadedImages.length > 0) { - setImages(prev => [...uploadedImages, ...prev]) - } + }, + [storeId] + ) - return uploadedImages.length > 0 ? uploadedImages : 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, - } as any, - }, - }) - - 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 => { + const fileToBase64 = useCallback((file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader() reader.readAsDataURL(file) @@ -203,7 +221,7 @@ export function useS3Images(options: UseS3ImagesOptions = {}) { } reader.onerror = error => reject(error) }) - } + }, []) return { images, From 231fd761a6b02bf2682a96ce3e14ebdf9de614fa Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Sun, 1 Jun 2025 16:40:14 -0500 Subject: [PATCH 3/4] chore(.gitignore): add .cursor to ignore list for cleaner repository --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c6de68b5..3f63d21a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .pnp.js .yarn/install-state.gz .pnpm-store +.cursor # testing /coverage From adf75b927d6bb0b770a79d4b819d037db189a120 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Mon, 2 Jun 2025 22:46:09 -0500 Subject: [PATCH 4/4] feat(s3-service): add unique ID generation for images and enhance S3 key creation This commit introduces a new method to generate a consistent unique ID for images based on their S3 key and timestamp, ensuring that the same file always receives the same ID. Additionally, the S3 key generation method has been updated to include a short unique ID, improving the uniqueness of the keys. The ImageItem interface has been updated to include the new ID field, and utility functions for generating UUIDs and short IDs have been added to enhance image handling capabilities. --- .../storeImages/services/s3-service.ts | 30 ++ .../functions/storeImages/services/utils.ts | 24 +- amplify/functions/storeImages/types/types.ts | 1 + .../components/AccountSettings.tsx | 5 +- .../components/UserAvatar.tsx | 5 +- .../hooks/useUpdateProfilePicture.ts | 5 +- app/(main-layout)/account-settings/page.tsx | 13 +- .../landing/components/DocsLanding.tsx | 13 +- .../landing/components/NavBar.tsx | 13 +- .../pricing/components/PricingCard.tsx | 15 +- app/(main-layout)/pricing/page.tsx | 13 +- .../first-steps/components/StoreInfo.tsx | 15 +- app/layout.tsx | 16 +- app/store/[slug]/dashboard/page.tsx | 13 +- .../[slug]/dashboard/statistics/page.tsx | 13 +- app/store/[slug]/orders/page.tsx | 13 +- app/store/[slug]/orders/processing/page.tsx | 13 +- .../[slug]/products/[productId]/page.tsx | 13 +- .../[slug]/products/collections/new/page.tsx | 13 +- .../[slug]/products/collections/page.tsx | 13 +- app/store/[slug]/products/inventory/page.tsx | 13 +- app/store/[slug]/products/new/page.tsx | 14 +- app/store/[slug]/products/page.tsx | 13 +- app/store/[slug]/setup/apps/page.tsx | 13 +- app/store/[slug]/setup/domain/page.tsx | 13 +- app/store/[slug]/setup/page.tsx | 13 +- app/store/[slug]/setup/payments/page.tsx | 13 +- .../ai-chat/components/ChatHeader.tsx | 26 ++ .../ai-chat/components/EmptyState.tsx | 27 ++ .../ai-chat/components/MessageList.tsx | 43 ++ .../ai-chat/components/RefinedAiAssistant.tsx | 223 ++++------ .../ai-chat/components/TypingEffect.tsx | 7 +- .../ai-chat/components/TypingMessage.tsx | 2 - .../ai-chat/constants/chat-constants.ts | 9 + .../ai-chat/hooks/useAutoScroll.tsx | 165 ++++--- app/store/components/ai-chat/index.ts | 30 ++ .../components/ai-chat/types/chat-types.ts | 32 ++ .../components/AppIntegrationPage.tsx | 13 +- .../components/ConnectModal.tsx | 419 ++++++------------ .../components/steps/ConfigStep.tsx | 123 +++++ .../components/steps/IntroStep.tsx | 38 ++ .../components/steps/SuccessStep.tsx | 27 ++ .../app-integration/constants/connectModal.ts | 19 + .../app-integration/hooks/useConnectModal.ts | 91 ++++ .../domains/components/DomainManagement.tsx | 15 +- .../components/EditStoreProfileDialog.tsx | 14 +- .../components/ImageGallery.tsx | 13 +- .../payments/components/ApiKeyModal.tsx | 14 +- .../payments/components/PaymentSettings.tsx | 13 +- .../components/CollectionContent.tsx | 82 ++++ .../components/CollectionFooter.tsx | 45 ++ .../components/CollectionHeader.tsx | 28 ++ .../components/CollectionSidebar.tsx | 46 ++ .../components/ProductControls.tsx | 57 +++ .../components/ProductItem.tsx | 40 ++ .../components/ProductSelectionDialog.tsx | 124 ++++++ .../components/SelectedProductsList.tsx | 34 ++ .../collection-form/config/amplifyConfig.ts | 14 + .../collection-form/form-page.tsx | 175 ++------ .../hooks/useProductSelection.ts | 94 ++++ .../collection-form/index.ts | 31 ++ .../collection-form/product-section.tsx | 327 ++------------ .../collection-form/types/productTypes.ts | 25 ++ .../collection-form/utils/productUtils.ts | 54 +++ .../statistics/components/SalesDashboard.tsx | 2 +- .../store-setup/components/EcommerceSetup.tsx | 13 +- app/store/hooks/useS3Images.ts | 25 ++ context/core/storeDataStore.ts | 30 +- lib/amplify-config.ts | 69 +++ 69 files changed, 1766 insertions(+), 1263 deletions(-) create mode 100644 app/store/components/ai-chat/components/ChatHeader.tsx create mode 100644 app/store/components/ai-chat/components/EmptyState.tsx create mode 100644 app/store/components/ai-chat/components/MessageList.tsx create mode 100644 app/store/components/ai-chat/constants/chat-constants.ts create mode 100644 app/store/components/ai-chat/index.ts create mode 100644 app/store/components/ai-chat/types/chat-types.ts create mode 100644 app/store/components/app-integration/components/steps/ConfigStep.tsx create mode 100644 app/store/components/app-integration/components/steps/IntroStep.tsx create mode 100644 app/store/components/app-integration/components/steps/SuccessStep.tsx create mode 100644 app/store/components/app-integration/constants/connectModal.ts create mode 100644 app/store/components/app-integration/hooks/useConnectModal.ts create mode 100644 app/store/components/product-management/collection-form/components/CollectionContent.tsx create mode 100644 app/store/components/product-management/collection-form/components/CollectionFooter.tsx create mode 100644 app/store/components/product-management/collection-form/components/CollectionHeader.tsx create mode 100644 app/store/components/product-management/collection-form/components/CollectionSidebar.tsx create mode 100644 app/store/components/product-management/collection-form/components/ProductControls.tsx create mode 100644 app/store/components/product-management/collection-form/components/ProductItem.tsx create mode 100644 app/store/components/product-management/collection-form/components/ProductSelectionDialog.tsx create mode 100644 app/store/components/product-management/collection-form/components/SelectedProductsList.tsx create mode 100644 app/store/components/product-management/collection-form/config/amplifyConfig.ts create mode 100644 app/store/components/product-management/collection-form/hooks/useProductSelection.ts create mode 100644 app/store/components/product-management/collection-form/index.ts create mode 100644 app/store/components/product-management/collection-form/types/productTypes.ts create mode 100644 app/store/components/product-management/collection-form/utils/productUtils.ts create mode 100644 lib/amplify-config.ts diff --git a/amplify/functions/storeImages/services/s3-service.ts b/amplify/functions/storeImages/services/s3-service.ts index 476f4622..833fa4a0 100644 --- a/amplify/functions/storeImages/services/s3-service.ts +++ b/amplify/functions/storeImages/services/s3-service.ts @@ -159,6 +159,9 @@ export class S3Service { const url = this.configService.generateImageUrl(key) const fileType = FileUtils.getFileType(filename) + // Generar ID único basado en la clave S3 y timestamp para consistencia + const id = this.generateConsistentId(key) + return { key, url, @@ -166,6 +169,7 @@ export class S3Service { lastModified: s3Object.LastModified || new Date(), size: s3Object.Size || 0, type: fileType, + id, } } @@ -177,6 +181,9 @@ export class S3Service { const url = this.configService.generateImageUrl(key) const fileType = FileUtils.getFileType(filename) + // Generar ID único basado en la clave S3 y timestamp para consistencia + const id = this.generateConsistentId(key) + return { key, url, @@ -184,9 +191,32 @@ export class S3Service { lastModified: override?.lastModified || new Date(), size: override?.size || 0, type: override?.type || fileType, + id, } } + /** + * Genera un ID consistente basado en la clave S3 + * Esto asegura que el mismo archivo siempre tenga el mismo ID + */ + private generateConsistentId(key: string): string { + // Usar una combinación de hash simple de la clave + timestamp para ID único + let hash = 0 + for (let i = 0; i < key.length; i++) { + const char = key.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + + // Combinar con timestamp de la clave si está disponible + const keyParts = key.split('/') + const filenamePart = keyParts[keyParts.length - 1] + const timestampMatch = filenamePart.match(/^(\d+)-/) + const timestamp = timestampMatch ? timestampMatch[1] : Date.now().toString() + + return `img_${Math.abs(hash)}_${timestamp}` + } + /** * Valida si el contenido es base64 válido */ diff --git a/amplify/functions/storeImages/services/utils.ts b/amplify/functions/storeImages/services/utils.ts index 2d99aae8..acc02768 100644 --- a/amplify/functions/storeImages/services/utils.ts +++ b/amplify/functions/storeImages/services/utils.ts @@ -7,6 +7,24 @@ export class FileUtils { webp: 'image/webp', } + /** + * Genera un UUID v4 simple sin dependencias externas + */ + public static generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + } + + /** + * Genera un ID único corto para evitar nombres muy largos + */ + public static generateShortId(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + } + /** * Determina el tipo MIME basado en la extensión del archivo */ @@ -24,11 +42,13 @@ export class FileUtils { } /** - * Genera una clave S3 única con timestamp + * Genera una clave S3 única con timestamp y UUID para garantizar unicidad */ public static generateS3Key(storeId: string, filename: string): string { const timestamp = new Date().getTime() - return `products/${storeId}/${timestamp}-${filename}` + const uniqueId = this.generateShortId() + // Formato: products/storeId/timestamp-uniqueId-filename + return `products/${storeId}/${timestamp}-${uniqueId}-${filename}` } /** diff --git a/amplify/functions/storeImages/types/types.ts b/amplify/functions/storeImages/types/types.ts index bb12bed2..00cfeaf8 100644 --- a/amplify/functions/storeImages/types/types.ts +++ b/amplify/functions/storeImages/types/types.ts @@ -32,6 +32,7 @@ export interface ImageItem { lastModified: Date size: number type: string + id: string } export interface ListImagesResponse { diff --git a/app/(main-layout)/account-settings/components/AccountSettings.tsx b/app/(main-layout)/account-settings/components/AccountSettings.tsx index b1954682..813069f3 100644 --- a/app/(main-layout)/account-settings/components/AccountSettings.tsx +++ b/app/(main-layout)/account-settings/components/AccountSettings.tsx @@ -12,7 +12,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' -import { Amplify } from 'aws-amplify' import { deleteUser } from 'aws-amplify/auth' import { useRouter } from 'next/navigation' import { LoadingIndicator } from '@/components/ui/loading-indicator' @@ -20,10 +19,10 @@ import { UserAvatar } from '@/app/(main-layout)/account-settings/components/User import { ChangePasswordDialog } from '@/app/(main-layout)/account-settings/components/ChangePasswordDialog' import { ChangeEmailDialog } from '@/app/(main-layout)/account-settings/components/ChangeEmailDialog' import useUserStore from '@/context/core/userStore' -import outputs from '@/amplify_outputs.json' import CustomToolTip from '@/components/ui/custom-tooltip' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) +configureAmplify() export function AccountSettings() { const [isProfileOpen, setIsProfileOpen] = useState(false) diff --git a/app/(main-layout)/account-settings/components/UserAvatar.tsx b/app/(main-layout)/account-settings/components/UserAvatar.tsx index cbc6cf7f..652dcec2 100644 --- a/app/(main-layout)/account-settings/components/UserAvatar.tsx +++ b/app/(main-layout)/account-settings/components/UserAvatar.tsx @@ -3,10 +3,9 @@ import { ImagePlus } from 'lucide-react' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { cn } from '@/lib/utils' import { useUpdateProfilePicture } from '@/app/(main-layout)/account-settings/hooks/useUpdateProfilePicture' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) +configureAmplify() interface UserAvatarProps { imageUrl?: string diff --git a/app/(main-layout)/account-settings/hooks/useUpdateProfilePicture.ts b/app/(main-layout)/account-settings/hooks/useUpdateProfilePicture.ts index a7224dc8..f0e5e966 100644 --- a/app/(main-layout)/account-settings/hooks/useUpdateProfilePicture.ts +++ b/app/(main-layout)/account-settings/hooks/useUpdateProfilePicture.ts @@ -2,11 +2,10 @@ import { useState } from 'react' import { uploadData } from 'aws-amplify/storage' import { updateUserAttributes } from 'aws-amplify/auth' import { useAuthUser } from '@/hooks/auth/useAuthUser' -import { Amplify } from 'aws-amplify' import { v4 as uuidv4 } from 'uuid' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) +configureAmplify() export function useUpdateProfilePicture() { const [isLoading, setIsLoading] = useState(false) diff --git a/app/(main-layout)/account-settings/page.tsx b/app/(main-layout)/account-settings/page.tsx index ffb62e2c..76f5b88d 100644 --- a/app/(main-layout)/account-settings/page.tsx +++ b/app/(main-layout)/account-settings/page.tsx @@ -5,20 +5,11 @@ import { AccountSettings } from '@/app/(main-layout)/account-settings/components import { PaymentSettings } from '@/app/(main-layout)/account-settings/components/PaymentSettings' import { ActiveSessions } from '@/app/(main-layout)/account-settings/components/ActiveSessions' import { useState, useEffect, Suspense } from 'react' -import { Amplify } from 'aws-amplify' import { useSearchParams } from 'next/navigation' import useUserStore from '@/context/core/userStore' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() function AccountSettingsContent() { const searchParams = useSearchParams() diff --git a/app/(main-layout)/landing/components/DocsLanding.tsx b/app/(main-layout)/landing/components/DocsLanding.tsx index e9b1cb85..afebd91a 100644 --- a/app/(main-layout)/landing/components/DocsLanding.tsx +++ b/app/(main-layout)/landing/components/DocsLanding.tsx @@ -9,18 +9,9 @@ import { Feature } from '@/app/(main-layout)/landing/components/Feature' import { Testimonials } from '@/app/(main-layout)/landing/components/Testimonials' import { MarqueeLogos } from '@/app/(main-layout)/landing/components/MarqueeLogos' import { LogoCarousell } from '@/app/(main-layout)/landing/components/LogoCarousell' -import outputs from '@/amplify_outputs.json' -import { Amplify } from 'aws-amplify' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function LandingPage() { return ( diff --git a/app/(main-layout)/landing/components/NavBar.tsx b/app/(main-layout)/landing/components/NavBar.tsx index 12a5bfb3..d6f3def0 100644 --- a/app/(main-layout)/landing/components/NavBar.tsx +++ b/app/(main-layout)/landing/components/NavBar.tsx @@ -5,7 +5,6 @@ import Link from 'next/link' import Image from 'next/image' import { Menu, ChevronDown } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Amplify } from 'aws-amplify' import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import { NavigationMenu, @@ -22,17 +21,9 @@ import { UserMenu } from '@/app/(main-layout)/landing/components/UserMenu' import { Skeleton } from '@/components/ui/skeleton' import { navItems } from '@/app/(main-layout)/landing/components/navigation' import useUserStore from '@/context/core/userStore' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export function Navbar() { const { user, loading, clearUser } = useUserStore() diff --git a/app/(main-layout)/pricing/components/PricingCard.tsx b/app/(main-layout)/pricing/components/PricingCard.tsx index a1161d05..ecb90534 100644 --- a/app/(main-layout)/pricing/components/PricingCard.tsx +++ b/app/(main-layout)/pricing/components/PricingCard.tsx @@ -8,19 +8,10 @@ import { useRouter } from 'next/navigation' import { useToast } from '@/hooks/ui/use-toasts' import { Toast } from '@/components/ui/toasts' import { useAuth } from '@/context/hooks/useAuth' -import { Amplify } from 'aws-amplify' import useUserStore from '@/context/core/userStore' -import outputs from '@/amplify_outputs.json' - -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +import { configureAmplify } from '@/lib/amplify-config' + +configureAmplify() interface PricingCardProps { plan: { diff --git a/app/(main-layout)/pricing/page.tsx b/app/(main-layout)/pricing/page.tsx index e336ac05..ef8f2702 100644 --- a/app/(main-layout)/pricing/page.tsx +++ b/app/(main-layout)/pricing/page.tsx @@ -5,20 +5,11 @@ import { PricingCard } from '@/app/(main-layout)/pricing/components/PricingCard' import { Footer } from '@/app/(main-layout)/landing/components/Footer' import { FAQSection } from '@/app/(main-layout)/pricing/components/FAQSection' import { faqItems } from '@/app/(main-layout)/pricing/components/FAQItem' -import { Amplify } from 'aws-amplify' import { FeatureComparison } from '@/app/(main-layout)/pricing/components/FeatureComparison' -import outputs from '@/amplify_outputs.json' import { plans } from '@/app/(main-layout)/pricing/components/plans' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function PricingPage() { return ( diff --git a/app/(setup-layout)/first-steps/components/StoreInfo.tsx b/app/(setup-layout)/first-steps/components/StoreInfo.tsx index cb646934..edc769fb 100644 --- a/app/(setup-layout)/first-steps/components/StoreInfo.tsx +++ b/app/(setup-layout)/first-steps/components/StoreInfo.tsx @@ -11,18 +11,9 @@ import { SelectValue, } from '@/components/ui/select' import { useStoreNameValidator } from '@/app/(setup-layout)/first-steps/hooks/useStoreNameValidator' -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, - }, -}) +import { configureAmplify } from '@/lib/amplify-config' + +configureAmplify() interface StoreData { storeName: string diff --git a/app/layout.tsx b/app/layout.tsx index ad2e2ee1..d2943041 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,20 +2,12 @@ import type { Metadata } from 'next' import { inter } from '@/config/fonts' import { ReactQueryProvider } from '@/utils/ReactQueryProvider' import { Toaster } from '@/components/ui/sonner' -import { Amplify } from 'aws-amplify' import ConfigureAmplifyClientSide from '@/utils/ConfigureAmplify' -import outputs from '@/amplify_outputs.json' -import './global.css' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() + +import './global.css' export const metadata: Metadata = { metadataBase: new URL('https://www.fasttify.com'), diff --git a/app/store/[slug]/dashboard/page.tsx b/app/store/[slug]/dashboard/page.tsx index 97993696..4e0d1cea 100644 --- a/app/store/[slug]/dashboard/page.tsx +++ b/app/store/[slug]/dashboard/page.tsx @@ -1,18 +1,9 @@ 'use client' import { EcommerceSetup } from '@/app/store/components/store-setup/components/EcommerceSetup' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function DashboardPage() { return diff --git a/app/store/[slug]/dashboard/statistics/page.tsx b/app/store/[slug]/dashboard/statistics/page.tsx index 9e96d3f0..ebb4e4cb 100644 --- a/app/store/[slug]/dashboard/statistics/page.tsx +++ b/app/store/[slug]/dashboard/statistics/page.tsx @@ -1,18 +1,9 @@ 'use client' import { SalesDashboard } from '@/app/store/components/statistics/components/SalesDashboard' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function GeneralStatistics() { return diff --git a/app/store/[slug]/orders/page.tsx b/app/store/[slug]/orders/page.tsx index c97ea78c..06ad21d2 100644 --- a/app/store/[slug]/orders/page.tsx +++ b/app/store/[slug]/orders/page.tsx @@ -1,16 +1,7 @@ import { Orders } from '@/app/store/components/orders/components/Orders' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function OrdersPage() { return diff --git a/app/store/[slug]/orders/processing/page.tsx b/app/store/[slug]/orders/processing/page.tsx index 693953ad..1b40bd19 100644 --- a/app/store/[slug]/orders/processing/page.tsx +++ b/app/store/[slug]/orders/processing/page.tsx @@ -1,16 +1,7 @@ import { InProgress } from '@/app/store/components/orders/components/InProgress' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function ProcessingPage() { return diff --git a/app/store/[slug]/products/[productId]/page.tsx b/app/store/[slug]/products/[productId]/page.tsx index bfa7ad39..8222957d 100644 --- a/app/store/[slug]/products/[productId]/page.tsx +++ b/app/store/[slug]/products/[productId]/page.tsx @@ -3,18 +3,9 @@ import { ProductForm } from '@/app/store/components/product-management/main-components/ProductForm' import { useParams, usePathname } from 'next/navigation' import { getStoreId } from '@/utils/store-utils' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function EditProductPage() { const params = useParams() diff --git a/app/store/[slug]/products/collections/new/page.tsx b/app/store/[slug]/products/collections/new/page.tsx index faba237a..7a1863ba 100644 --- a/app/store/[slug]/products/collections/new/page.tsx +++ b/app/store/[slug]/products/collections/new/page.tsx @@ -1,18 +1,9 @@ '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' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function CollectionPage() { return diff --git a/app/store/[slug]/products/collections/page.tsx b/app/store/[slug]/products/collections/page.tsx index 39f22b17..2e047309 100644 --- a/app/store/[slug]/products/collections/page.tsx +++ b/app/store/[slug]/products/collections/page.tsx @@ -1,18 +1,9 @@ 'use client' import { CollectionsPage } from '@/app/store/components/product-management/collections/collections-page' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function CollectionsPages() { return diff --git a/app/store/[slug]/products/inventory/page.tsx b/app/store/[slug]/products/inventory/page.tsx index 269ff5b9..72473255 100644 --- a/app/store/[slug]/products/inventory/page.tsx +++ b/app/store/[slug]/products/inventory/page.tsx @@ -1,20 +1,11 @@ 'use client' import { InventoryManager } from '@/app/store/components/product-management/main-components/InventoryManager' -import { Amplify } from 'aws-amplify' +import { configureAmplify } from '@/lib/amplify-config' import { getStoreId } from '@/utils/store-utils' import { useParams, usePathname } from 'next/navigation' -import outputs from '@/amplify_outputs.json' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function InventoryPage() { const pathname = usePathname() diff --git a/app/store/[slug]/products/new/page.tsx b/app/store/[slug]/products/new/page.tsx index 136caaca..5ea00c65 100644 --- a/app/store/[slug]/products/new/page.tsx +++ b/app/store/[slug]/products/new/page.tsx @@ -3,19 +3,9 @@ import { ProductForm } from '@/app/store/components/product-management/main-components/ProductForm' import { useParams, usePathname } from 'next/navigation' import { getStoreId } from '@/utils/store-utils' -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, - }, -}) +import { configureAmplify } from '@/lib/amplify-config' +configureAmplify() export default function AddProductPage() { const params = useParams() const pathname = usePathname() diff --git a/app/store/[slug]/products/page.tsx b/app/store/[slug]/products/page.tsx index 19da51cf..a28c5ea3 100644 --- a/app/store/[slug]/products/page.tsx +++ b/app/store/[slug]/products/page.tsx @@ -3,18 +3,9 @@ import { ProductManager } from '@/app/store/components/product-management/main-components/ProductManager' import { getStoreId } from '@/utils/store-utils' import { useParams, usePathname } from 'next/navigation' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function StoreProductsPage() { const pathname = usePathname() diff --git a/app/store/[slug]/setup/apps/page.tsx b/app/store/[slug]/setup/apps/page.tsx index ae99af7e..74055467 100644 --- a/app/store/[slug]/setup/apps/page.tsx +++ b/app/store/[slug]/setup/apps/page.tsx @@ -1,18 +1,9 @@ 'use client' import { AppIntegrationPage } from '@/app/store/components/app-integration/components/AppIntegrationPage' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function AppIntegration() { return diff --git a/app/store/[slug]/setup/domain/page.tsx b/app/store/[slug]/setup/domain/page.tsx index f133126c..6e83ae5f 100644 --- a/app/store/[slug]/setup/domain/page.tsx +++ b/app/store/[slug]/setup/domain/page.tsx @@ -1,18 +1,9 @@ 'use client' import { DomainManagement } from '@/app/store/components/domains/components/DomainManagement' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function DomainManagementPage() { return diff --git a/app/store/[slug]/setup/page.tsx b/app/store/[slug]/setup/page.tsx index 07b3aa9e..d3e195d1 100644 --- a/app/store/[slug]/setup/page.tsx +++ b/app/store/[slug]/setup/page.tsx @@ -1,18 +1,9 @@ 'use client' import { ThemePreview } from '@/app/store/components/store-config/components/ThemePreview' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function SetupPage() { return diff --git a/app/store/[slug]/setup/payments/page.tsx b/app/store/[slug]/setup/payments/page.tsx index fe06df95..924a7707 100644 --- a/app/store/[slug]/setup/payments/page.tsx +++ b/app/store/[slug]/setup/payments/page.tsx @@ -1,16 +1,7 @@ import { PaymentSettings } from '@/app/store/components/payments/components/PaymentSettings' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export default function PaymentsPage() { return diff --git a/app/store/components/ai-chat/components/ChatHeader.tsx b/app/store/components/ai-chat/components/ChatHeader.tsx new file mode 100644 index 00000000..90f20612 --- /dev/null +++ b/app/store/components/ai-chat/components/ChatHeader.tsx @@ -0,0 +1,26 @@ +import { ChevronLeft } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { GradientSparkles } from '@/app/store/components/ai-chat/components/GradientSparkles' +import { ChatHeaderProps } from '@/app/store/components/ai-chat/types/chat-types' + +export function ChatHeader({ isMobile, onClose }: ChatHeaderProps) { + return ( + + {isMobile && ( + + )} +
+ + FastBot +
+
+ ) +} diff --git a/app/store/components/ai-chat/components/EmptyState.tsx b/app/store/components/ai-chat/components/EmptyState.tsx new file mode 100644 index 00000000..71bed5f9 --- /dev/null +++ b/app/store/components/ai-chat/components/EmptyState.tsx @@ -0,0 +1,27 @@ +import { EmptyStateProps } from '@/app/store/components/ai-chat/types/chat-types' +import { SUGGESTIONS } from '@/app/store/components/ai-chat/constants/chat-constants' +import Orb from '@/app/store/components/ai-chat/components/Orb' + +export function EmptyState({ onSuggestionClick }: EmptyStateProps) { + return ( +
+
+ +
+

+ ¿Qué te gustaría saber sobre ecommerce o dropshipping? +

+
+ {SUGGESTIONS.map(suggestion => ( + + ))} +
+
+ ) +} diff --git a/app/store/components/ai-chat/components/MessageList.tsx b/app/store/components/ai-chat/components/MessageList.tsx new file mode 100644 index 00000000..3f171d50 --- /dev/null +++ b/app/store/components/ai-chat/components/MessageList.tsx @@ -0,0 +1,43 @@ +import { useCallback } from 'react' +import { MessageListProps } from '@/app/store/components/ai-chat/types/chat-types' +import { LONG_MESSAGE_THRESHOLD } from '@/app/store/components/ai-chat/constants/chat-constants' +import { MessageLoading } from '@/app/store/components/ai-chat/components/MessageLoading' +import { TypingMessage } from '@/app/store/components/ai-chat/components/TypingMessage' + +export function MessageList({ + messages, + loading, + expandedMessages, + onToggleExpansion, + scrollRef, + messagesEndRef, +}: MessageListProps) { + const isMessageExpanded = useCallback( + (messageId: string) => { + return !!expandedMessages[messageId] + }, + [expandedMessages] + ) + + return ( +
+ {messages.map(message => ( + + ))} + {loading && ( +
+ +
+ )} +
+
+ ) +} diff --git a/app/store/components/ai-chat/components/RefinedAiAssistant.tsx b/app/store/components/ai-chat/components/RefinedAiAssistant.tsx index 209ab88b..4adcb5f1 100644 --- a/app/store/components/ai-chat/components/RefinedAiAssistant.tsx +++ b/app/store/components/ai-chat/components/RefinedAiAssistant.tsx @@ -1,110 +1,88 @@ -import { useState, useRef, useEffect } from 'react' -import { ChevronLeft } from 'lucide-react' +'use client' + +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { ScrollArea } from '@/components/ui/scroll-area' import { useAutoScroll } from '@/app/store/components/ai-chat/hooks/useAutoScroll' -import { MessageLoading } from '@/app/store/components/ai-chat/components/MessageLoading' -import { TypingMessage } from '@/app/store/components/ai-chat/components/TypingMessage' import { AIInputWithSearch } from '@/app/store/components/ai-chat/components/AiInput' import { useChat } from '@/app/store/components/ai-chat/hooks/useChat' -import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/ui/use-media-query' -import { GradientSparkles } from '@/app/store/components/ai-chat/components/GradientSparkles' -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' -import Orb from '@/app/store/components/ai-chat/components/Orb' - -interface Suggestion { - id: string - text: string -} - -interface RefinedAIAssistantSheetProps { - open: boolean - onOpenChange: (open: boolean) => void -} - -const LONG_MESSAGE_THRESHOLD = 280 +import { Sheet, SheetContent } from '@/components/ui/sheet' +import { ChatHeader } from '@/app/store/components/ai-chat/components/ChatHeader' +import { EmptyState } from '@/app/store/components/ai-chat/components/EmptyState' +import { MessageList } from '@/app/store/components/ai-chat/components/MessageList' +import { RefinedAIAssistantSheetProps } from '@/app/store/components/ai-chat/types/chat-types' export function RefinedAIAssistantSheet({ open, onOpenChange }: RefinedAIAssistantSheetProps) { const { messages: chatMessages, loading, chat } = useChat() - const [inputValue, setInputValue] = useState('') const [expandedMessages, setExpandedMessages] = useState>({}) const messagesEndRef = useRef(null) + const isMobile = useMediaQuery('(max-width: 640px)') + const { scrollRef, scrollToBottom } = useAutoScroll({ smooth: true, content: chatMessages.length, }) - const textareaRef = useRef(null) - const isMobile = useMediaQuery('(max-width: 640px)') - - const suggestions: Suggestion[] = [ - { id: '1', text: 'Generar resumen para estrategias de ecommerce' }, - { id: '3', text: '¿Encaja en mi tienda de dropshipping?' }, - { id: '2', text: '¿Cuál es su estilo de capacitación en ecommerce?' }, - ] - - useEffect(() => { - if (!loading && chatMessages.length > 0 && open) { - setTimeout(scrollToBottom, 200) - } - }, [loading, chatMessages.length, scrollToBottom, open]) - - useEffect(() => { - if (chatMessages.length > 0 && open) { - setTimeout(scrollToBottom, 100) - } - }, [chatMessages, scrollToBottom, open]) - - useEffect(() => { - const textarea = textareaRef.current - if (!textarea) return - textarea.style.height = 'auto' - const newHeight = Math.max(44, Math.min(textarea.scrollHeight, 96)) - textarea.style.height = `${newHeight}px` - }, [inputValue]) - - const toggleMessageExpansion = (messageId: string) => { - setExpandedMessages(prev => ({ - ...prev, - [messageId]: !prev[messageId], - })) + const transformedMessages = useMemo( + () => + chatMessages.map((msg, index) => ({ + id: index.toString(), + content: msg.content, + type: msg.role === 'user' ? ('user' as const) : ('ai' as const), + timestamp: new Date(), + })), + [chatMessages] + ) - setTimeout(() => { - if (scrollRef.current) { - const messageElement = scrollRef.current.querySelector(`[data-message-id="${messageId}"]`) - messageElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) - } - }, 100) - } + const handleToggleMessageExpansion = useCallback( + (messageId: string) => { + setExpandedMessages(prev => ({ + ...prev, + [messageId]: !prev[messageId], + })) + + requestAnimationFrame(() => { + if (scrollRef.current) { + const messageElement = scrollRef.current.querySelector(`[data-message-id="${messageId}"]`) + messageElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }) + }, + [scrollRef] + ) - const isMessageExpanded = (messageId: string) => { - return !!expandedMessages[messageId] - } + const handleSuggestionClick = useCallback( + async (suggestion: string) => { + await chat(suggestion) + requestAnimationFrame(() => scrollToBottom()) + }, + [chat, scrollToBottom] + ) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!inputValue.trim()) return + const handleSubmit = useCallback( + async (value: string, withSearch?: boolean) => { + if (!value.trim()) return + await chat(value) + requestAnimationFrame(() => scrollToBottom()) + }, + [chat, scrollToBottom] + ) - await chat(inputValue) - setInputValue('') - setTimeout(scrollToBottom, 100) - } + const handleClose = useCallback(() => { + onOpenChange(false) + }, [onOpenChange]) - const handleSuggestionClick = async (suggestion: string) => { - await chat(suggestion) - setTimeout(scrollToBottom, 100) - } + useEffect(() => { + if (!open) return - const handleClose = () => { - onOpenChange(false) - } + const shouldScroll = (!loading && chatMessages.length > 0) || chatMessages.length > 0 + if (shouldScroll) { + const timeout = setTimeout(scrollToBottom, loading ? 200 : 100) + return () => clearTimeout(timeout) + } + }, [loading, chatMessages.length, scrollToBottom, open]) - const transformedMessages = chatMessages.map((msg, index) => ({ - id: index.toString(), - content: msg.content, - type: msg.role === 'user' ? 'user' : 'ai', - timestamp: new Date(), - })) + const hasMessages = chatMessages.length > 0 return ( @@ -112,80 +90,31 @@ export function RefinedAIAssistantSheet({ open, onOpenChange }: RefinedAIAssista side="right" className="p-0 sm:max-w-md w-full flex flex-col h-full rounded-t-2xl" > - - {isMobile && ( - - )} -
- - FastBot -
-
+
- {chatMessages.length === 0 ? ( -
-
- -
-

- ¿Qué te gustaría saber sobre ecommerce o dropshipping? -

-
- {suggestions.map(suggestion => ( - - ))} -
-
+ {!hasMessages ? ( + ) : ( -
- {transformedMessages.map(message => ( - - ))} - {loading && ( -
- -
- )} -
-
+ )}
-
+
{ - if (value.trim()) { - chat(value) - setTimeout(scrollToBottom, 100) - } - }} + onSubmit={handleSubmit} className="py-2" />
diff --git a/app/store/components/ai-chat/components/TypingEffect.tsx b/app/store/components/ai-chat/components/TypingEffect.tsx index cad32cdb..13c88cdf 100644 --- a/app/store/components/ai-chat/components/TypingEffect.tsx +++ b/app/store/components/ai-chat/components/TypingEffect.tsx @@ -11,7 +11,7 @@ interface TypingEffectProps { export function TypingEffect({ text, - typingSpeed = 10, // Reduced from 10 to 5ms + typingSpeed = 10, delay = 0, className = '', onComplete, @@ -22,7 +22,6 @@ export function TypingEffect({ const [startTyping, setStartTyping] = useState(false) const timeoutRef = useRef(null) - // Clear timeout on unmount to prevent memory leaks useEffect(() => { return () => { if (timeoutRef.current) { @@ -31,7 +30,6 @@ export function TypingEffect({ } }, []) - // Handle initial delay before typing starts useEffect(() => { if (delay > 0) { const delayTimeout = setTimeout(() => { @@ -44,17 +42,14 @@ export function TypingEffect({ } }, [delay]) - // Handle the typing animation with batch processing useEffect(() => { if (!startTyping || isComplete) return if (displayedText.length < text.length) { timeoutRef.current = setTimeout(() => { - // Add multiple characters at once (3-5 characters per update) const charsToAdd = Math.min(4, text.length - displayedText.length) setDisplayedText(text.substring(0, displayedText.length + charsToAdd)) - // Call the callback after characters are typed if (onCharacterTyped) onCharacterTyped() }, typingSpeed) } else if (!isComplete) { diff --git a/app/store/components/ai-chat/components/TypingMessage.tsx b/app/store/components/ai-chat/components/TypingMessage.tsx index c2d6450d..aff246c5 100644 --- a/app/store/components/ai-chat/components/TypingMessage.tsx +++ b/app/store/components/ai-chat/components/TypingMessage.tsx @@ -3,8 +3,6 @@ import { TypingEffect } from '@/app/store/components/ai-chat/components/TypingEf import { cn } from '@/lib/utils' import { ChevronDown, ChevronUp } from 'lucide-react' -// Create a Map to store which messages have completed typing -// This will persist across component remounts const completedTypingMessages = new Map() interface TypingMessageProps { diff --git a/app/store/components/ai-chat/constants/chat-constants.ts b/app/store/components/ai-chat/constants/chat-constants.ts new file mode 100644 index 00000000..dde56c64 --- /dev/null +++ b/app/store/components/ai-chat/constants/chat-constants.ts @@ -0,0 +1,9 @@ +import { Suggestion } from '../types/chat-types' + +export const LONG_MESSAGE_THRESHOLD = 280 + +export const SUGGESTIONS: Suggestion[] = [ + { id: '1', text: 'Generar resumen para estrategias de ecommerce' }, + { id: '3', text: '¿Encaja en mi tienda de dropshipping?' }, + { id: '2', text: '¿Cuál es su estilo de capacitación en ecommerce?' }, +] diff --git a/app/store/components/ai-chat/hooks/useAutoScroll.tsx b/app/store/components/ai-chat/hooks/useAutoScroll.tsx index 7e098f89..6898c25b 100644 --- a/app/store/components/ai-chat/hooks/useAutoScroll.tsx +++ b/app/store/components/ai-chat/hooks/useAutoScroll.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect, useCallback } from 'react' +import { useRef, useState, useEffect, useCallback, useMemo } from 'react' interface UseAutoScrollOptions { smooth?: boolean @@ -9,80 +9,121 @@ export function useAutoScroll({ smooth = true, content }: UseAutoScrollOptions = const scrollRef = useRef(null) const [isAtBottom, setIsAtBottom] = useState(true) const [autoScrollEnabled, setAutoScrollEnabled] = useState(true) + const scrollTimeoutRef = useRef(null) - // More reliable way to check if at bottom - const checkIfAtBottom = useCallback(() => { - if (scrollRef.current) { - const { scrollTop, scrollHeight, clientHeight } = scrollRef.current - // Consider "at bottom" if within 30px of the bottom - const isBottom = scrollHeight - scrollTop - clientHeight < 30 - setIsAtBottom(isBottom) - return isBottom - } - return false + // Memoizar la configuración de scroll para evitar recreación + const scrollConfig = useMemo( + () => ({ + top: 0, + behavior: smooth ? ('smooth' as const) : ('auto' as const), + }), + [smooth] + ) + + // Función optimizada para encontrar el elemento scrollable + const getScrollableElement = useCallback(() => { + if (!scrollRef.current) return null + + const scrollElement = scrollRef.current + const scrollAreaViewport = scrollElement.closest('[data-radix-scroll-area-viewport]') + return (scrollAreaViewport as HTMLElement) || scrollElement }, []) + // Función optimizada para verificar si está en el bottom + const checkIfAtBottom = useCallback(() => { + const element = getScrollableElement() + if (!element) return false + + const { scrollTop, scrollHeight, clientHeight } = element + // Consider "at bottom" if within 30px of the bottom + const isBottom = scrollHeight - scrollTop - clientHeight < 30 + setIsAtBottom(isBottom) + return isBottom + }, [getScrollableElement]) + + // Función principal de scroll optimizada const scrollToBottom = useCallback(() => { - if (scrollRef.current) { - const scrollElement = scrollRef.current - - // For ScrollArea component, we need to find the actual scrollable element - const scrollAreaViewport = scrollElement.closest('[data-radix-scroll-area-viewport]') - const targetElement = scrollAreaViewport || scrollElement - - // Use multiple approaches to ensure scrolling works - const performScroll = () => { - // Method 1: scrollTo with smooth behavior - targetElement.scrollTo({ - top: targetElement.scrollHeight, - behavior: smooth ? 'smooth' : 'auto', - }) - - // Method 2: Direct scrollTop assignment (fallback) - if (!smooth) { - targetElement.scrollTop = targetElement.scrollHeight - } - - setIsAtBottom(true) - setAutoScrollEnabled(true) + const element = getScrollableElement() + if (!element) return + + // Limpiar timeout anterior si existe + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current) + } + + const performScroll = () => { + element.scrollTo({ + ...scrollConfig, + top: element.scrollHeight, + }) + + // Fallback para navegadores que no soportan smooth scroll + if (!smooth) { + element.scrollTop = element.scrollHeight } - // Execute scroll with slight delays to ensure it works after DOM updates - performScroll() - setTimeout(performScroll, 50) - setTimeout(performScroll, 150) + setIsAtBottom(true) + setAutoScrollEnabled(true) } - }, [smooth]) - // Force scroll to bottom on mount and when content changes - useEffect(() => { - if (autoScrollEnabled) { - // Use a timeout to ensure DOM has updated - const timer = setTimeout(scrollToBottom, 100) - return () => clearTimeout(timer) + // Scroll inmediato + performScroll() + + // Scroll de respaldo para asegurar que funcione después de actualizaciones del DOM + scrollTimeoutRef.current = setTimeout(performScroll, 100) + }, [getScrollableElement, scrollConfig, smooth]) + + // Handler optimizado para el evento de scroll + const handleScroll = useCallback(() => { + const isBottom = checkIfAtBottom() + if (isBottom && !autoScrollEnabled) { + setAutoScrollEnabled(true) } + }, [checkIfAtBottom, autoScrollEnabled]) + + // Efecto para scroll automático cuando cambia el contenido + useEffect(() => { + if (!autoScrollEnabled) return + + const timer = setTimeout(() => { + scrollToBottom() + }, 50) // Tiempo reducido para mejor responsividad + + return () => clearTimeout(timer) }, [content, autoScrollEnabled, scrollToBottom]) - // Re-enable auto-scroll if user manually scrolls to bottom + // Efecto para manejar el evento de scroll useEffect(() => { - const scrollElement = scrollRef.current - if (scrollElement) { - const handleScroll = () => { - const isBottom = checkIfAtBottom() - if (isBottom && !autoScrollEnabled) { - setAutoScrollEnabled(true) - } - } + const element = getScrollableElement() + if (!element) return + + element.addEventListener('scroll', handleScroll, { passive: true }) - scrollElement.addEventListener('scroll', handleScroll, { passive: true }) - return () => scrollElement.removeEventListener('scroll', handleScroll) + return () => { + element.removeEventListener('scroll', handleScroll) } - }, [checkIfAtBottom, autoScrollEnabled]) + }, [handleScroll, getScrollableElement]) + + // Cleanup general al desmontar + useEffect(() => { + return () => { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current) + } + } + }, []) + + // API memoizada del hook + const api = useMemo( + () => ({ + scrollRef, + isAtBottom, + autoScrollEnabled, + scrollToBottom, + setAutoScrollEnabled, + }), + [isAtBottom, autoScrollEnabled, scrollToBottom] + ) - return { - scrollRef, - isAtBottom, - autoScrollEnabled, - scrollToBottom, - } + return api } diff --git a/app/store/components/ai-chat/index.ts b/app/store/components/ai-chat/index.ts new file mode 100644 index 00000000..2700cdb4 --- /dev/null +++ b/app/store/components/ai-chat/index.ts @@ -0,0 +1,30 @@ +export { RefinedAIAssistantSheet } from '@/app/store/components/ai-chat/components/RefinedAiAssistant' +export { ChatHeader } from '@/app/store/components/ai-chat/components/ChatHeader' +export { EmptyState } from '@/app/store/components/ai-chat/components/EmptyState' +export { MessageList } from '@/app/store/components/ai-chat/components/MessageList' +export { ChatTrigger } from '@/app/store/components/ai-chat/components/ChatTrigger' +export { AIInputWithSearch } from '@/app/store/components/ai-chat/components/AiInput' +export { GradientSparkles } from '@/app/store/components/ai-chat/components/GradientSparkles' +export { MessageLoading } from '@/app/store/components/ai-chat/components/MessageLoading' +export { TypingMessage } from '@/app/store/components/ai-chat/components/TypingMessage' +export { TypingEffect } from '@/app/store/components/ai-chat/components/TypingEffect' +export { default as Orb } from '@/app/store/components/ai-chat/components/Orb' + +// Hooks +export { useAutoScroll } from '@/app/store/components/ai-chat/hooks/useAutoScroll' +export { useChat } from '@/app/store/components/ai-chat/hooks/useChat' + +// Types +export type { + Suggestion, + RefinedAIAssistantSheetProps, + ChatHeaderProps, + EmptyStateProps, + MessageListProps, +} from '@/app/store/components/ai-chat/types/chat-types' + +// Constants +export { + LONG_MESSAGE_THRESHOLD, + SUGGESTIONS, +} from '@/app/store/components/ai-chat/constants/chat-constants' diff --git a/app/store/components/ai-chat/types/chat-types.ts b/app/store/components/ai-chat/types/chat-types.ts new file mode 100644 index 00000000..f914250a --- /dev/null +++ b/app/store/components/ai-chat/types/chat-types.ts @@ -0,0 +1,32 @@ +export interface Suggestion { + id: string + text: string +} + +export interface RefinedAIAssistantSheetProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export interface ChatHeaderProps { + isMobile: boolean + onClose: () => void +} + +export interface EmptyStateProps { + onSuggestionClick: (suggestion: string) => void +} + +export interface MessageListProps { + messages: Array<{ + id: string + content: string + type: 'user' | 'ai' + timestamp: Date + }> + loading: boolean + expandedMessages: Record + onToggleExpansion: (messageId: string) => void + scrollRef: React.RefObject + messagesEndRef: React.RefObject +} diff --git a/app/store/components/app-integration/components/AppIntegrationPage.tsx b/app/store/components/app-integration/components/AppIntegrationPage.tsx index 0e3d96bf..0f2d5867 100644 --- a/app/store/components/app-integration/components/AppIntegrationPage.tsx +++ b/app/store/components/app-integration/components/AppIntegrationPage.tsx @@ -11,18 +11,9 @@ import { import Image from 'next/image' import { ConnectModal } from '@/app/store/components/app-integration/components/ConnectModal' import useStoreDataStore from '@/context/core/storeDataStore' -import { Amplify } from 'aws-amplify' -import outputs from '@/amplify_outputs.json' +import { configureAmplify } from '@/lib/amplify-config' -Amplify.configure(outputs) -const existingConfig = Amplify.getConfig() -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}) +configureAmplify() export function AppIntegrationPage() { const [isModalOpen, setIsModalOpen] = useState(false) diff --git a/app/store/components/app-integration/components/ConnectModal.tsx b/app/store/components/app-integration/components/ConnectModal.tsx index fa1c56be..6b466d4a 100644 --- a/app/store/components/app-integration/components/ConnectModal.tsx +++ b/app/store/components/app-integration/components/ConnectModal.tsx @@ -1,14 +1,7 @@ -import { useState, useEffect } from 'react' -import Image from 'next/image' -import { - ExternalLink, - Check, - AlertCircle, - ArrowLeft, - ArrowRight, - Key, - PlusCircle, -} from 'lucide-react' +'use client' + +import { useEffect, useCallback, useMemo } from 'react' +import { ArrowLeft, ArrowRight, Check } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, @@ -18,112 +11,66 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { useUserStoreData } from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' import useStoreDataStore from '@/context/core/storeDataStore' -import { useApiKeyEncryption } from '@/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption' -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, - }, -}) - -type Step = 1 | 2 | 3 +import { useConnectModal } from '@/app/store/components/app-integration/hooks/useConnectModal' +import { IntroStep } from '@/app/store/components/app-integration/components/steps/IntroStep' +import { ConfigStep } from '@/app/store/components/app-integration/components/steps/ConfigStep' +import { SuccessStep } from '@/app/store/components/app-integration/components/steps/SuccessStep' +import { + ConnectModalProps, + MASTER_SHOP_LOGIN_URL, +} from '@/app/store/components/app-integration/constants/connectModal' +import { configureAmplify } from '@/lib/amplify-config' +import useUserStore from '@/context/core/userStore' -interface ConnectModalProps { - open: boolean - onOpenChange: (open: boolean) => void -} +configureAmplify() export function ConnectModal({ open, onOpenChange }: ConnectModalProps) { - const [step, setStep] = useState(1) - const [option, setOption] = useState<'existing' | 'new' | null>(null) - const [apiKey, setApiKey] = useState('') - const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') - const [errorMessage, setErrorMessage] = useState('') - const { updateUserStore, loading: updateLoading, error: updateError } = useUserStoreData() - const { encryptApiKey } = useApiKeyEncryption() const { currentStore, hasMasterShopApiKey, checkMasterShopApiKey } = useStoreDataStore() - - // Si ya tiene API key configurada, mostrar el paso 3 directamente + const { user } = useUserStore() + const handleClose = useCallback(() => { + onOpenChange(false) + }, [onOpenChange]) + + const { + step, + setStep, + option, + setOption, + apiKey, + setApiKey, + status, + setStatus, + errorMessage, + updateLoading, + updateError, + resetState, + handleApiKeyConnection, + } = useConnectModal(currentStore, handleClose) + + // Efecto para mostrar paso 3 si ya tiene API key useEffect(() => { if (open && hasMasterShopApiKey) { setStep(3) } - }, [open, hasMasterShopApiKey]) + }, [open, hasMasterShopApiKey, setStep]) - const handleNext = async () => { + // Handlers optimizados con useCallback + const handleNext = useCallback(async () => { if (step === 1) { setStep(2) } else if (step === 2 && option === 'existing') { - setStatus('loading') - - if (apiKey.length < 5) { - setStatus('error') - setErrorMessage( - 'La API Key proporcionada no es válida. Por favor verifica e intenta nuevamente.' - ) - return - } - - if (currentStore?.storeId) { - try { - const encryptedKey = await encryptApiKey( - apiKey, - 'mastershop', - undefined, - currentStore.storeId - ) - - if (!encryptedKey) { - console.error('Error encrypting the Master Shop API Key') - setStatus('error') - setErrorMessage('No se pudo configurar la integración. Por favor intenta nuevamente.') - return - } - - // Actualizamos la tienda con la API Key encriptada de Master Shop - const result = await updateUserStore({ - storeId: currentStore.storeId, - mastershopApiKey: encryptedKey, - }) - - if (result) { - setStatus('success') - setStep(3) - - checkMasterShopApiKey(currentStore.storeId) - } else { - setStatus('error') - setErrorMessage('No se pudo guardar la configuración. Por favor intenta nuevamente.') - } - } catch (error) { - setStatus('error') - setErrorMessage( - 'Ocurrió un error al guardar la configuración. Por favor intenta nuevamente.' - ) - console.error('Error saving API Key:', error) + const success = await handleApiKeyConnection() + if (success) { + setStep(3) + if (currentStore?.storeId && user?.userId) { + checkMasterShopApiKey(currentStore.storeId, user.userId) } - } else { - setTimeout(() => { - setStatus('success') - setStep(3) - }, 1500) } } - } + }, [step, option, handleApiKeyConnection, setStep, currentStore?.storeId, checkMasterShopApiKey]) - const handleBack = () => { + const handleBack = useCallback(() => { if (step === 2) { setStep(1) setOption(null) @@ -131,30 +78,86 @@ export function ConnectModal({ open, onOpenChange }: ConnectModalProps) { setStep(2) setStatus('idle') } - } + }, [step, setStep, setOption, setStatus]) - const handleOpenChange = (open: boolean) => { - if (!open) { - setTimeout(() => { - setStep(1) - setOption(null) - setApiKey('') - setStatus('idle') - setErrorMessage('') - }, 300) - } - onOpenChange(open) - } + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + setTimeout(resetState, 300) + } + onOpenChange(open) + }, + [onOpenChange, resetState] + ) - const handleExternalRedirect = () => { - window.open('https://app.mastershop.com/login', '_blank') + const handleExternalRedirect = useCallback(() => { + window.open(MASTER_SHOP_LOGIN_URL, '_blank') setStatus('loading') setTimeout(() => { setStatus('success') setStep(3) }, 3000) - } + }, [setStatus, setStep]) + + // Memoizar el contenido de cada paso + const stepContent = useMemo(() => { + switch (step) { + case 1: + return + case 2: + return ( + + ) + case 3: + return + default: + return null + } + }, [ + step, + option, + setOption, + apiKey, + setApiKey, + status, + errorMessage, + updateLoading, + updateError, + handleExternalRedirect, + ]) + + // Memoizar la lógica del botón + const nextButtonDisabled = useMemo(() => { + return ( + (step === 2 && !option) || + (step === 2 && option === 'existing' && !apiKey) || + status === 'loading' + ) + }, [step, option, apiKey, status]) + + const nextButtonContent = useMemo(() => { + if (step === 1) return 'Continuar' + if (hasMasterShopApiKey) { + return ( + <> + + Master Shop Activo + + ) + } + return 'Conectar' + }, [step, hasMasterShopApiKey]) return ( @@ -170,164 +173,7 @@ export function ConnectModal({ open, onOpenChange }: ConnectModalProps) { - {step === 1 && ( -
-
-
- Master Shop Logo -
-
-

Master Shop

-

- Plataforma líder para gestión de productos e inventario -

-
-
- -
-

Beneficios de la integración:

-
    -
  • - - Importación automática de productos desde Master Shop -
  • -
  • - - Sincronización de inventario en tiempo real -
  • -
  • - - Actualización automática de precios y descripciones -
  • -
  • - - Gestión centralizada de tu catálogo de productos -
  • -
-
-
- )} - - {step === 2 && ( -
- setOption(value as 'existing' | 'new')} - > -
-
- -
- -

- Conecta con tu cuenta existente usando una API Key -

- - {option === 'existing' && ( -
-
- - setApiKey(e.target.value)} - disabled={status === 'loading'} - /> -

- Puedes encontrar tu API Key en la configuración de tu cuenta de Master - Shop -

-
- - {status === 'error' && ( - - - Error - {errorMessage} - - )} -
- )} -
-
- -
- -
- -

- Serás redirigido al sitio web de Master Shop para crear una cuenta -

- - {option === 'new' && ( - - )} -
-
-
-
- - {status === 'loading' && ( -
-
- - {updateLoading ? 'Guardando configuración...' : 'Verificando conexión...'} - -
- )} - - {updateError && status === 'error' && ( - - - Error - - {errorMessage || 'Ocurrió un error al guardar la configuración.'} - - - )} -
- )} - - {step === 3 && ( -
-
-
- -
-

¡Conexión Exitosa!

-

- Tu tienda Fasttify ha sido conectada correctamente con Master Shop. -

-
- - - - Integración Activa - - Ahora puedes importar y sincronizar productos desde Master Shop. Ve a la sección de - Productos para comenzar. - - -
- )} + {stepContent} {step > 1 ? ( @@ -348,28 +194,19 @@ export function ConnectModal({ open, onOpenChange }: ConnectModalProps) { ) : ( + )} +
+
+
+ + + {status === 'loading' && ( +
+
+ + {updateLoading ? 'Guardando configuración...' : 'Verificando conexión...'} + +
+ )} + + {updateError && status === 'error' && ( + + + Error + + {errorMessage || 'Ocurrió un error al guardar la configuración.'} + + + )} + + ) +} diff --git a/app/store/components/app-integration/components/steps/IntroStep.tsx b/app/store/components/app-integration/components/steps/IntroStep.tsx new file mode 100644 index 00000000..1f7d8cf9 --- /dev/null +++ b/app/store/components/app-integration/components/steps/IntroStep.tsx @@ -0,0 +1,38 @@ +import Image from 'next/image' +import { Check } from 'lucide-react' +import { BENEFITS } from '@/app/store/components/app-integration/constants/connectModal' + +export function IntroStep() { + return ( +
+
+
+ Master Shop Logo +
+
+

Master Shop

+

+ Plataforma líder para gestión de productos e inventario +

+
+
+ +
+

Beneficios de la integración:

+
    + {BENEFITS.map((benefit, index) => ( +
  • + + {benefit} +
  • + ))} +
+
+
+ ) +} diff --git a/app/store/components/app-integration/components/steps/SuccessStep.tsx b/app/store/components/app-integration/components/steps/SuccessStep.tsx new file mode 100644 index 00000000..d7e8d768 --- /dev/null +++ b/app/store/components/app-integration/components/steps/SuccessStep.tsx @@ -0,0 +1,27 @@ +import { Check } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' + +export function SuccessStep() { + return ( +
+
+
+ +
+

¡Conexión Exitosa!

+

+ Tu tienda Fasttify ha sido conectada correctamente con Master Shop. +

+
+ + + + Integración Activa + + Ahora puedes importar y sincronizar productos desde Master Shop. Ve a la sección de + Productos para comenzar. + + +
+ ) +} diff --git a/app/store/components/app-integration/constants/connectModal.ts b/app/store/components/app-integration/constants/connectModal.ts new file mode 100644 index 00000000..7d3a3520 --- /dev/null +++ b/app/store/components/app-integration/constants/connectModal.ts @@ -0,0 +1,19 @@ +export type Step = 1 | 2 | 3 +export type Option = 'existing' | 'new' | null +export type Status = 'idle' | 'loading' | 'success' | 'error' + +export const BENEFITS = [ + 'Importación automática de productos desde Master Shop', + 'Sincronización de inventario en tiempo real', + 'Actualización automática de precios y descripciones', + 'Gestión centralizada de tu catálogo de productos', +] as const + +export const MIN_API_KEY_LENGTH = 5 + +export const MASTER_SHOP_LOGIN_URL = 'https://app.mastershop.com/login' + +export interface ConnectModalProps { + open: boolean + onOpenChange: (open: boolean) => void +} diff --git a/app/store/components/app-integration/hooks/useConnectModal.ts b/app/store/components/app-integration/hooks/useConnectModal.ts new file mode 100644 index 00000000..631f88a5 --- /dev/null +++ b/app/store/components/app-integration/hooks/useConnectModal.ts @@ -0,0 +1,91 @@ +import { useState, useCallback } from 'react' +import { useUserStoreData } from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' +import { useApiKeyEncryption } from '@/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption' +import { + Step, + Option, + Status, + MIN_API_KEY_LENGTH, +} from '@/app/store/components/app-integration/constants/connectModal' + +export function useConnectModal(currentStore: any, onClose: () => void) { + const [step, setStep] = useState(1) + const [option, setOption] = useState