From f53d0ef82688e1b7522e4e6dc07261f0b9467e59 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Sat, 31 May 2025 20:50:45 -0500 Subject: [PATCH] feat(dependencies): update Radix UI components and enhance store management functionality This commit updates the version of the @radix-ui/react-navigation-menu package and other related dependencies in package.json and package-lock.json for improved performance. Additionally, it refactors the resource schema in the Amplify data model to include new secondary indexes and modifies various Lambda functions to streamline data retrieval processes. The PaymentSettings component is also updated to reflect changes in user identification, enhancing the overall user experience in store management. --- amplify/data/resource.ts | 4 +- .../functions/LambdaEncryptKeys/handler.ts | 10 +- amplify/functions/checkStoreDomain/handler.ts | 6 +- amplify/functions/checkStoreName/handler.ts | 6 +- .../functions/getStoreCollections/handler.ts | 15 +- amplify/functions/getStoreData/handler.ts | 13 +- amplify/functions/getStoreProducts/handler.ts | 14 +- amplify/functions/webHookPlan/handler.ts | 366 +++++------------- .../webHookPlan/services/mercadopago-api.ts | 110 ++++++ .../webHookPlan/services/payment-processor.ts | 153 ++++++++ .../webHookPlan/services/user-service.ts | 204 ++++++++++ .../webHookPlan/services/webhook-validator.ts | 70 ++++ amplify/functions/webHookPlan/types/index.ts | 76 ++++ .../components/PaymentSettings.tsx | 6 +- .../pricing/components/PricingCard.tsx | 2 +- .../first-steps/hooks/useApiKeyEncryption.ts | 2 +- .../first-steps/hooks/useUserStoreData.ts | 200 +++++----- .../components/domains/ChangeDomainDialog.tsx | 2 +- .../components/domains/DomainManagement.tsx | 4 +- .../domains/utils/storeProfileUtils.ts | 2 +- .../images-selector/image-selector-modal.tsx | 2 +- .../payments/PaymentCaptureSection.tsx | 56 +++ .../payments/PaymentGatewayCard.tsx | 71 ++++ .../payments/PaymentMethodsSection.tsx | 39 ++ .../payments/PaymentProvidersSection.tsx | 23 ++ .../components/payments/PaymentSettings.tsx | 362 +---------------- .../payments/hooks/usePaymentSettings.ts | 200 ++++++++++ .../collection-form/product-section.tsx | 8 +- .../hooks/useProductFilters.ts | 5 + .../product-table/product-card-mobile.tsx | 6 +- .../product-table/product-table-desktop.tsx | 6 +- .../product-management/utils/productUtils.ts | 17 +- app/store/components/sidebar/nav-main.tsx | 1 - app/store/components/sidebar/nav-user.tsx | 1 - .../components/store-config/LogoUploader.tsx | 8 +- .../components/store-config/ThemePreview.tsx | 1 - app/store/hooks/useProducts.ts | 45 +-- app/store/hooks/useStore.ts | 10 +- context/core/storeDataStore.ts | 83 ++-- context/core/useSubscriptionStore.ts | 35 +- middlewares/ownership/collectionOwnership.ts | 2 +- middlewares/ownership/productOwnership.ts | 2 +- package-lock.json | 115 +++++- package.json | 2 +- 44 files changed, 1463 insertions(+), 902 deletions(-) create mode 100644 amplify/functions/webHookPlan/services/mercadopago-api.ts create mode 100644 amplify/functions/webHookPlan/services/payment-processor.ts create mode 100644 amplify/functions/webHookPlan/services/user-service.ts create mode 100644 amplify/functions/webHookPlan/services/webhook-validator.ts create mode 100644 amplify/functions/webHookPlan/types/index.ts create mode 100644 app/store/components/payments/PaymentCaptureSection.tsx create mode 100644 app/store/components/payments/PaymentGatewayCard.tsx create mode 100644 app/store/components/payments/PaymentMethodsSection.tsx create mode 100644 app/store/components/payments/PaymentProvidersSection.tsx create mode 100644 app/store/components/payments/hooks/usePaymentSettings.ts diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 80a651d2..6113bb8d 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -82,6 +82,7 @@ const schema = a lastFourDigits: a.integer(), }) .identifier(['id']) + .secondaryIndexes(index => [index('userId')]) .authorization(allow => [ allow.ownerDefinedIn('userId').to(['read', 'update', 'delete']), allow.authenticated().to(['create']), @@ -114,7 +115,8 @@ const schema = a onboardingCompleted: a.boolean().required(), onboardingData: a.json(), }) - .secondaryIndexes(index => [index('userId')]) + .identifier(['storeId']) + .secondaryIndexes(index => [index('userId'), index('customDomain'), index('storeName')]) .authorization(allow => [allow.authenticated().to(['read', 'update', 'delete', 'create'])]), Product: a diff --git a/amplify/functions/LambdaEncryptKeys/handler.ts b/amplify/functions/LambdaEncryptKeys/handler.ts index 9b0c61de..75cb311c 100644 --- a/amplify/functions/LambdaEncryptKeys/handler.ts +++ b/amplify/functions/LambdaEncryptKeys/handler.ts @@ -105,11 +105,11 @@ export const handler = async (event: any) => { } // Si hay storeId, cifrar y guardar en la tienda - const { data: stores } = await client.models.UserStore.list({ - filter: { storeId: { eq: storeId } }, + const { data: stores } = await client.models.UserStore.get({ + storeId: storeId, }) - if (!stores || stores.length === 0) { + if (!stores) { return { statusCode: 404, body: JSON.stringify({ success: false, message: 'Tienda no encontrada' }), @@ -120,7 +120,7 @@ export const handler = async (event: any) => { } } - const store = stores[0] + const store = stores // Cifrar la API Key const encryptedKey = encrypt(apiKey) @@ -205,7 +205,7 @@ export const handler = async (event: any) => { // Actualizar la tienda await client.models.UserStore.update({ - id: store.id, + storeId: store.storeId, ...updateData, }) diff --git a/amplify/functions/checkStoreDomain/handler.ts b/amplify/functions/checkStoreDomain/handler.ts index 37ac39dc..28415fbc 100644 --- a/amplify/functions/checkStoreDomain/handler.ts +++ b/amplify/functions/checkStoreDomain/handler.ts @@ -24,10 +24,8 @@ export const handler = async (event: any) => { } try { - const { data: stores } = await clientSchema.models.UserStore.list({ - filter: { - customDomain: { eq: domainName }, - }, + const { data: stores } = await clientSchema.models.UserStore.listUserStoreByCustomDomain({ + customDomain: domainName, }) return { diff --git a/amplify/functions/checkStoreName/handler.ts b/amplify/functions/checkStoreName/handler.ts index 5bb13945..3ef0a3fe 100644 --- a/amplify/functions/checkStoreName/handler.ts +++ b/amplify/functions/checkStoreName/handler.ts @@ -26,10 +26,8 @@ export const handler = async (event: any) => { } try { - const { data: stores } = await clientSchema.models.UserStore.list({ - filter: { - storeName: { eq: storeName }, - }, + const { data: stores } = await clientSchema.models.UserStore.listUserStoreByStoreName({ + storeName: storeName, }) return { diff --git a/amplify/functions/getStoreCollections/handler.ts b/amplify/functions/getStoreCollections/handler.ts index d2a74e77..1de2fd83 100644 --- a/amplify/functions/getStoreCollections/handler.ts +++ b/amplify/functions/getStoreCollections/handler.ts @@ -4,7 +4,6 @@ import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtim import { env } from '$amplify/env/getStoreCollections' import { type Schema } from '../../data/resource' -// Lazy-load del cliente para evitar reconfiguración en cada invocación let clientSchema: ReturnType> | null = null const initializeClient = async () => { @@ -53,12 +52,16 @@ export const handler = async (event: any) => { } // Obtener productos asociados a esta colección - const { data: products } = await client.models.Product.list({ - filter: { - collectionId: { eq: collectionId }, - status: { eq: 'active' }, + const { data: products } = await client.models.Product.listProductByCollectionId( + { + collectionId: collectionId, }, - }) + { + filter: { + status: { eq: 'active' }, + }, + } + ) return { statusCode: 200, diff --git a/amplify/functions/getStoreData/handler.ts b/amplify/functions/getStoreData/handler.ts index dab78003..4c8a1978 100644 --- a/amplify/functions/getStoreData/handler.ts +++ b/amplify/functions/getStoreData/handler.ts @@ -4,7 +4,6 @@ import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtim import { env } from '$amplify/env/getStoreData' import { type Schema } from '../../data/resource' -// Lazy-load del cliente para evitar reconfiguración en cada invocación let clientSchema: ReturnType> | null = null const initializeClient = async () => { @@ -16,9 +15,9 @@ const initializeClient = async () => { return clientSchema } export const handler = async (event: any) => { - const storeId = event.queryStringParameters?.storeId + const storeName = event.queryStringParameters?.storeName - if (!storeId) { + if (!storeName) { return { statusCode: 400, body: JSON.stringify({ message: 'Store ID is required' }), @@ -31,12 +30,8 @@ export const handler = async (event: any) => { try { const client = await initializeClient() - const { data: store } = await client.models.UserStore.list({ - filter: { - storeId: { - eq: storeId, - }, - }, + const { data: store } = await client.models.UserStore.listUserStoreByStoreName({ + storeName: storeName, }) if (!store) { diff --git a/amplify/functions/getStoreProducts/handler.ts b/amplify/functions/getStoreProducts/handler.ts index 6f9f5670..bbf50bdb 100644 --- a/amplify/functions/getStoreProducts/handler.ts +++ b/amplify/functions/getStoreProducts/handler.ts @@ -31,12 +31,16 @@ export const handler = async (event: any) => { try { const client = await initializeClient() - const { data: products } = await client.models.Product.list({ - filter: { - storeId: { eq: storeId }, - status: { eq: 'active' }, // Opcional: filtrar solo productos activos + const { data: products } = await client.models.Product.listProductByStoreId( + { + storeId: storeId, }, - }) + { + filter: { + status: { eq: 'active' }, // Opcional: filtrar solo productos activos + }, + } + ) return { statusCode: 200, diff --git a/amplify/functions/webHookPlan/handler.ts b/amplify/functions/webHookPlan/handler.ts index 964a4f7a..05833547 100644 --- a/amplify/functions/webHookPlan/handler.ts +++ b/amplify/functions/webHookPlan/handler.ts @@ -1,294 +1,128 @@ import { APIGatewayProxyHandler } from 'aws-lambda' -import { - CognitoIdentityProviderClient, - AdminUpdateUserAttributesCommand, - AdminGetUserCommand, -} from '@aws-sdk/client-cognito-identity-provider' -import { createHmac } from 'crypto' -import axios from 'axios' -import { Amplify } from 'aws-amplify' -import { generateClient } from 'aws-amplify/data' -import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' import { env } from '$amplify/env/hookPlan' -import { type Schema } from '../../data/resource' +import { WebhookBody, LambdaHandler, WebhookResponse } from './types' +import { MercadoPagoWebhookValidator } from './services/webhook-validator' +import { MercadoPagoApiService } from './services/mercadopago-api' +import { CognitoUserService } from './services/user-service' +import { MercadoPagoPaymentProcessor } from './services/payment-processor' + +/** + * Enhanced MercadoPago Webhook Handler with TypeScript support + * Handles subscription updates and payment notifications + * + * Follows MercadoPago best practices: + * - Webhook signature validation + * - Proper error handling + * - Modular service architecture + */ +export const handler: LambdaHandler = async event => { + // Initialize services + const webhookValidator = new MercadoPagoWebhookValidator(env.MERCADO_PAGO_WEBHOOK_SECRET) + const mpApiService = new MercadoPagoApiService(env.MERCADOPAGO_ACCESS_TOKEN) + const userService = new CognitoUserService(env.USER_POOL_ID) + const paymentProcessor = new MercadoPagoPaymentProcessor(mpApiService, userService) -const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) -Amplify.configure(resourceConfig, libraryOptions) - -const client = new CognitoIdentityProviderClient() -const clientSchema = generateClient() - -const MP_AUTH_PAYMENTS_SEARCH_URL = 'https://api.mercadopago.com/v1/payments/' -const MP_AUTHORIZED_PAYMENTS_URL = 'https://api.mercadopago.com/authorized_payments/' - -export const handler: APIGatewayProxyHandler = async event => { try { - // 1. Validar la firma del webhook - const body = JSON.parse(event.body || '{}') - const signature = event.headers['x-signature'] || event.headers['X-Signature'] - - if (!signature) throw new Error('Firma no proporcionada en el webhook.') - - const match = signature.match(/ts=([^,]+),v1=([^,]+)/) - if (!match) throw new Error('Formato de firma no válido.') - const [, ts, v1] = match + console.log('🚀 Starting webhook processing') + // 1. Parse and validate request + const body: WebhookBody = JSON.parse(event.body || '{}') + const signature = event.headers['x-signature'] || event.headers['X-Signature'] const dataId = event.queryStringParameters?.['data.id'] const requestId = event.headers['x-request-id'] || event.headers['X-Request-Id'] - if (!dataId || !requestId) { - throw new Error('Faltan parámetros requeridos en la notificación.') + if (!signature || !dataId || !requestId) { + console.error('❌ Missing required webhook parameters') + return createResponse(400, { error: 'Missing required parameters' }) } - const signatureTemplate = `id:${dataId};request-id:${requestId};ts:${ts};` - const expectedSignature = createHmac('sha256', env.MERCADO_PAGO_WEBHOOK_SECRET) - .update(signatureTemplate) - .digest('hex') + // 2. Validate webhook signature + const timestamp = webhookValidator.extractTimestamp(signature) || '' + const isValidSignature = webhookValidator.validateSignature( + signature, + dataId, + requestId, + timestamp + ) - if (v1 !== expectedSignature) throw new Error('Firma del webhook no válida.') - console.log('✅ Firma validada correctamente') + if (!isValidSignature) { + console.error('❌ Invalid webhook signature') + return createResponse(401, { error: 'Invalid signature' }) + } - // 2. Determinar tipo de evento + // 3. Determine event type and validate if supported const eventType = body.type const eventAction = body.action - console.log('🔍 Tipo de evento recibido:', eventType, eventAction) - - // Manejar eventos de cancelación de suscripción - if (eventType === 'subscription_preapproval' && eventAction === 'updated') { - console.log('🛑 Procesando actualización de suscripción') - - const subscriptionId = body.data.id - - // Obtener detalles de la suscripción - const subscriptionResponse = await axios.get( - `https://api.mercadopago.com/preapproval/${subscriptionId}`, - { headers: { Authorization: `Bearer ${env.MERCADOPAGO_ACCESS_TOKEN}` } } - ) - - const subscriptionData = subscriptionResponse.data - // Verificar si es una cancelación - if (subscriptionData.status === 'cancelled') { - const userId = subscriptionData.external_reference - console.log(`⚠️ Detectada cancelación para usuario: ${userId}`) + console.log(`🔍 Processing event: ${eventType}.${eventAction}`) - // Obtener usuario de Cognito - const cognitoUser = await client.send( - new AdminGetUserCommand({ - UserPoolId: env.USER_POOL_ID, - Username: userId, - }) - ) - - // Verificar plan actual - const currentPlan = - cognitoUser.UserAttributes?.find(attr => attr.Name === 'custom:plan')?.Value || 'free' - - if (currentPlan === 'free') { - console.log('🔍 Usuario ya tiene plan free, no se realizan cambios') - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), - } - } - - // Calcular tiempo restante de suscripción - const nextPaymentDate = new Date(subscriptionData.next_payment_date) - const now = new Date() - const timeLeft = nextPaymentDate.getTime() - now.getTime() - - // Obtener suscripción existente - const existingSubscription = await clientSchema.models.UserSubscription.get({ - id: userId, - }).catch(() => ({ data: null })) - - // Preparar datos de actualización - const updateData: any = { - id: userId, - subscriptionId: subscriptionId, - pendingPlan: null, - pendingStartDate: null, - lastFourDigts: null, - planPrice: null, - nextPaymentDate: subscriptionData.next_payment_date - ? new Date(subscriptionData.next_payment_date).toISOString() - : null, - } - - if (timeLeft > 0) { - console.log(`⏳ Usuario mantiene acceso hasta ${nextPaymentDate}`) - updateData.pendingPlan = 'free' - updateData.pendingStartDate = nextPaymentDate.toISOString() - updateData.planName = currentPlan - } else { - console.log('🔒 Acceso revocado inmediatamente') - updateData.planName = 'free' - updateData.nextPaymentDate = null - ;(updateData.planPrice = null), - (updateData.pendingStartDate = null), - (updateData.lastFourDigits = null), - (updateData.pendingPlan = null), - // Actualizar Cognito inmediatamente - await client.send( - new AdminUpdateUserAttributesCommand({ - UserPoolId: env.USER_POOL_ID, - Username: userId, - UserAttributes: [{ Name: 'custom:plan', Value: 'free' }], - }) - ) - } - - // Actualizar DynamoDB - if (existingSubscription.data) { - await clientSchema.models.UserSubscription.update(updateData) - } else { - await clientSchema.models.UserSubscription.create({ - ...updateData, - userId: userId, - }) - } - - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), - } - } + if (!paymentProcessor.shouldProcessEvent(eventType, eventAction)) { + console.log('ℹ️ Event type not supported, returning OK') + return createResponse(200, { message: 'Event type not supported but acknowledged' }) } - // 3. Procesar diferentes tipos de pagos - let paymentId, paymentUrl - if (eventType === 'subscription_authorized_payment') { - paymentId = body.data?.id - paymentUrl = `${MP_AUTHORIZED_PAYMENTS_URL}${paymentId}` - console.log('🔍 Procesando pago recurrente autorizado') - } else if (eventType === 'payment') { - paymentId = body.data?.id - paymentUrl = `${MP_AUTH_PAYMENTS_SEARCH_URL}${paymentId}` - console.log('🔍 Procesando pago estándar') - } else { - console.warn('⚠️ Tipo de evento no soportado:', eventType) - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), - } - } + // 4. Route to appropriate processor + await routeEvent(eventType, eventAction, body.data.id, paymentProcessor) - // 4. Consultar detalles del pago - const paymentResponse = await axios.get(paymentUrl, { - headers: { - Authorization: `Bearer ${env.MERCADOPAGO_ACCESS_TOKEN}`, - 'Content-Type': 'application/json', - }, - }) + console.log('✅ Webhook processed successfully') + return createResponse(200, { message: 'Webhook processed successfully' }) + } catch (error) { + console.error('🔥 Webhook processing error:', error) - const paymentData = paymentResponse.data - console.log('💡 Datos del pago:', JSON.stringify(paymentData, null, 2)) + // Return 500 to trigger MercadoPago retry mechanism + return createResponse(500, { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }) + } +} - // 5. Validar estado del pago - if (!(paymentData.status === 'approved' && paymentData.status_detail === 'accredited')) { - console.warn('⚠️ Pago no completado exitosamente') - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), +/** + * Routes events to the appropriate processor based on type + */ +async function routeEvent( + eventType: string, + eventAction: string, + dataId: string, + processor: MercadoPagoPaymentProcessor +): Promise { + switch (eventType) { + case 'subscription_preapproval': + if (eventAction === 'updated') { + await processor.processSubscriptionUpdate(dataId) } - } - - // 6. Obtener información de la suscripción - const subscriptionId = paymentData.metadata?.preapproval_id || paymentData.external_reference - const subscriptionResponse = await axios.get( - `https://api.mercadopago.com/preapproval/${subscriptionId}`, - { headers: { Authorization: `Bearer ${env.MERCADOPAGO_ACCESS_TOKEN}` } } - ) - - const subscriptionData = subscriptionResponse.data - const userId = subscriptionData.external_reference - const newPlanName = subscriptionData.reason - const newAmountFromMP = subscriptionData.auto_recurring.transaction_amount - const nextPaymentDate = subscriptionData.next_payment_date - - // 7. Obtener usuario de Cognito - const cognitoUser = await client.send( - new AdminGetUserCommand({ - UserPoolId: env.USER_POOL_ID, - Username: userId, - }) - ) - - const currentPlan = - cognitoUser.UserAttributes?.find(attr => attr.Name === 'custom:plan')?.Value || 'free' - - // 8. Lógica de actualización de plan - const existingSubscription = await clientSchema.models.UserSubscription.get({ - id: userId, - }).catch(() => ({ data: null })) - - const currentPlanPrice = existingSubscription.data?.planPrice || 0 - const isUpgrade = currentPlan === 'free' || newAmountFromMP > currentPlanPrice - - // Determinar fechas clave - const nextPaymentDateISO = new Date(nextPaymentDate).toISOString() - const now = new Date() - const existingPaymentDate = existingSubscription.data?.nextPaymentDate - ? new Date(existingSubscription.data.nextPaymentDate) - : null + break - // Configurar datos para DynamoDB - const updateData = { - id: userId, - subscriptionId: subscriptionId, - planPrice: newAmountFromMP, - nextPaymentDate: nextPaymentDateISO, - lastFourDigits: paymentData.card.last_four_digits, + case 'subscription_authorized_payment': + case 'payment': + await processor.processPayment(dataId, eventType) + break - planName: isUpgrade ? newPlanName : existingSubscription.data?.planName || currentPlan, - pendingPlan: - !isUpgrade && existingPaymentDate && existingPaymentDate > now ? newPlanName : null, - pendingStartDate: - !isUpgrade && existingPaymentDate && existingPaymentDate > now - ? existingPaymentDate.toISOString() - : null, - } - - // Actualizar DynamoDB - if (existingSubscription.data) { - await clientSchema.models.UserSubscription.update(updateData) - } else { - await clientSchema.models.UserSubscription.create({ - ...updateData, - userId: userId, - }) - } - - // Actualizar Cognito SOLO si es upgrade o no hay tiempo restante - if (newPlanName !== currentPlan) { - const shouldUpdateCognito = isUpgrade || !existingPaymentDate || existingPaymentDate <= now + default: + console.warn(`⚠️ Unhandled event type: ${eventType}.${eventAction}`) + } +} - if (shouldUpdateCognito) { - await client.send( - new AdminUpdateUserAttributesCommand({ - UserPoolId: env.USER_POOL_ID, - Username: userId, - UserAttributes: [{ Name: 'custom:plan', Value: newPlanName }], - }) - ) - console.log(`✅ Plan actualizado en Cognito a ${newPlanName}`) - } else { - console.log(`⏳ Cambio a ${newPlanName} programado para ${existingPaymentDate}`) - } - } +/** + * Creates standardized API Gateway response + */ +function createResponse(statusCode: number, body: any): WebhookResponse { + return { + statusCode, + body: JSON.stringify(body), + } +} - return { - statusCode: 200, - body: JSON.stringify({ message: 'OK' }), - } - } catch (error: any) { - console.error('❌ Error en la función Lambda:', error) - return { - statusCode: 500, - body: JSON.stringify({ - error: 'Error procesando el webhook', - details: error instanceof Error ? error.message : 'Unknown error', - }), - } +/** + * Health check endpoint for webhook monitoring + */ +export const healthCheck: APIGatewayProxyHandler = async () => { + return { + statusCode: 200, + body: JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString(), + service: 'mercadopago-webhook', + }), } } diff --git a/amplify/functions/webHookPlan/services/mercadopago-api.ts b/amplify/functions/webHookPlan/services/mercadopago-api.ts new file mode 100644 index 00000000..a9967182 --- /dev/null +++ b/amplify/functions/webHookPlan/services/mercadopago-api.ts @@ -0,0 +1,110 @@ +import axios, { AxiosResponse } from 'axios' +import { SubscriptionData, PaymentData } from '../types' + +export class MercadoPagoApiService { + private readonly baseUrl = 'https://api.mercadopago.com' + private readonly paymentsSearchUrl = 'https://api.mercadopago.com/v1/payments/' + private readonly authorizedPaymentsUrl = 'https://api.mercadopago.com/authorized_payments/' + + constructor(private readonly accessToken: string) {} + + /** + * Gets subscription details from MercadoPago + */ + async getSubscription(subscriptionId: string): Promise { + try { + console.log(`🔍 Fetching subscription details for ID: ${subscriptionId}`) + + const response: AxiosResponse = await axios.get( + `${this.baseUrl}/preapproval/${subscriptionId}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + console.log(`✅ Subscription data retrieved successfully`) + return response.data + } catch (error) { + console.error(`❌ Error fetching subscription ${subscriptionId}:`, error) + throw new Error(`Failed to fetch subscription: ${subscriptionId}`) + } + } + + /** + * Gets payment details for standard payments + */ + async getStandardPayment(paymentId: string): Promise { + try { + console.log(`🔍 Fetching standard payment details for ID: ${paymentId}`) + + const response: AxiosResponse = await axios.get( + `${this.paymentsSearchUrl}${paymentId}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + console.log(`✅ Standard payment data retrieved successfully`) + return response.data + } catch (error) { + console.error(`❌ Error fetching standard payment ${paymentId}:`, error) + throw new Error(`Failed to fetch payment: ${paymentId}`) + } + } + + /** + * Gets payment details for authorized payments (recurring) + */ + async getAuthorizedPayment(paymentId: string): Promise { + try { + console.log(`🔍 Fetching authorized payment details for ID: ${paymentId}`) + + const response: AxiosResponse = await axios.get( + `${this.authorizedPaymentsUrl}${paymentId}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + + console.log(`✅ Authorized payment data retrieved successfully`) + return response.data + } catch (error) { + console.error(`❌ Error fetching authorized payment ${paymentId}:`, error) + throw new Error(`Failed to fetch authorized payment: ${paymentId}`) + } + } + + /** + * Validates if payment is approved and accredited + */ + isPaymentSuccessful(payment: PaymentData): boolean { + const isApproved = payment?.status === 'approved' + const isAccredited = payment?.status_detail === 'accredited' + + console.log(`💰 Payment status: ${payment.status}, detail: ${payment?.status_detail}`) + + return isApproved && isAccredited + } + + /** + * Gets the payment method based on event type + */ + getPaymentUrl(eventType: string, paymentId: string): string { + if (eventType === 'subscription_authorized_payment') { + return `${this.authorizedPaymentsUrl}${paymentId}` + } else if (eventType === 'payment') { + return `${this.paymentsSearchUrl}${paymentId}` + } else { + throw new Error(`Unsupported event type: ${eventType}`) + } + } +} diff --git a/amplify/functions/webHookPlan/services/payment-processor.ts b/amplify/functions/webHookPlan/services/payment-processor.ts new file mode 100644 index 00000000..a52a55b7 --- /dev/null +++ b/amplify/functions/webHookPlan/services/payment-processor.ts @@ -0,0 +1,153 @@ +import { PaymentProcessor } from '../types' +import { MercadoPagoApiService } from './mercadopago-api' +import { CognitoUserService } from './user-service' + +export class MercadoPagoPaymentProcessor implements PaymentProcessor { + constructor( + private readonly mpApiService: MercadoPagoApiService, + private readonly userService: CognitoUserService + ) {} + + /** + * Processes subscription update events (cancellations, etc.) + */ + async processSubscriptionUpdate(subscriptionId: string): Promise { + try { + console.log('🛑 Processing subscription update') + + const subscriptionData = await this.mpApiService.getSubscription(subscriptionId) + + // Handle subscription cancellation + if (subscriptionData.status === 'cancelled') { + const userId = subscriptionData.external_reference + console.log(`⚠️ Detected cancellation for user: ${userId}`) + + await this.userService.downgradeUser(userId) + } + + console.log('✅ Subscription update processed successfully') + } catch (error) { + console.error('❌ Error processing subscription update:', error) + throw error + } + } + + /** + * Processes payment events (approved payments) + */ + async processPayment(paymentId: string, paymentType: string): Promise { + try { + console.log(`💰 Processing ${paymentType} payment: ${paymentId}`) + + // Get payment data based on type + const paymentData = + paymentType === 'subscription_authorized_payment' + ? await this.mpApiService.getAuthorizedPayment(paymentId) + : await this.mpApiService.getStandardPayment(paymentId) + + console.log('💡 Payment data:', JSON.stringify(paymentData, null, 2)) + + // Validate payment status + if (!this.mpApiService.isPaymentSuccessful(paymentData)) { + console.warn('⚠️ Payment not completed successfully') + return + } + + // Get subscription information + const subscriptionId = + paymentData.point_of_interaction?.transaction_data?.subscription_id || + paymentData.metadata?.preapproval_id || + paymentData.external_reference + + if (!subscriptionId) { + console.warn('⚠️ No subscription ID found in payment data') + return + } + + const subscriptionData = await this.mpApiService.getSubscription(subscriptionId) + const userId = subscriptionData.external_reference + const newPlanName = subscriptionData.reason + const newAmountFromMP = subscriptionData.auto_recurring.transaction_amount + const nextPaymentDate = subscriptionData.next_payment_date + + console.log(`👤 User: ${userId}`) + console.log(`📦 New plan: ${newPlanName}`) + console.log(`💰 Amount: ${newAmountFromMP}`) + console.log(`📅 Next payment: ${nextPaymentDate}`) + + // Update user plan in Cognito + await this.userService.updateUserPlan(userId, newPlanName) + + // Update subscription data in DynamoDB + await this.userService.updateSubscription(userId, { + subscriptionId, + planName: newPlanName, + nextPaymentDate, + lastFourDigits: this.extractLastFourDigits(paymentData), + }) + + console.log('✅ Payment processed successfully') + } catch (error) { + console.error('❌ Error processing payment:', error) + throw error + } + } + + /** + * Extracts last four digits from payment data + * This is a simplified implementation - adjust based on actual MercadoPago response + */ + private extractLastFourDigits(paymentData: any): number | undefined { + // MercadoPago might provide card info in different ways + // Adjust this based on the actual API response structure + try { + if (paymentData.card?.last_four_digits) { + return parseInt(paymentData.card.last_four_digits, 10) + } + if (paymentData.payment_method?.last_four_digits) { + return parseInt(paymentData.payment_method.last_four_digits, 10) + } + return undefined + } catch (error) { + console.warn('⚠️ Could not extract last four digits from payment data') + return undefined + } + } + + /** + * Determines if the webhook event should be processed + */ + shouldProcessEvent(eventType: string, eventAction: string): boolean { + const supportedEvents = [ + 'subscription_preapproval', + 'subscription_authorized_payment', + 'payment', + ] + + const supportedActions = ['updated', 'created', 'payment.created'] + + const isSupported = supportedEvents.includes(eventType) + + if (!isSupported) { + console.warn(`⚠️ Unsupported event type: ${eventType}`) + } + + return isSupported + } + + /** + * Gets event processing priority (for future queue implementation) + */ + getEventPriority(eventType: string): number { + switch (eventType) { + case 'subscription_preapproval': + return 1 // High priority for cancellations + case 'subscription_authorized_payment': + return 2 // Medium priority for recurring payments + case 'payment': + return 3 // Lower priority for one-time payments + default: + return 10 // Lowest priority + } + } +} diff --git a/amplify/functions/webHookPlan/services/user-service.ts b/amplify/functions/webHookPlan/services/user-service.ts new file mode 100644 index 00000000..e5f66ca0 --- /dev/null +++ b/amplify/functions/webHookPlan/services/user-service.ts @@ -0,0 +1,204 @@ +import { + CognitoIdentityProviderClient, + AdminGetUserCommand, + AdminUpdateUserAttributesCommand, +} from '@aws-sdk/client-cognito-identity-provider' +import { generateClient } from 'aws-amplify/data' +import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime' +import { Amplify } from 'aws-amplify' +import { type Schema } from '../../../data/resource' +import { CognitoUserAttribute, UserService } from '../types' +import { env } from '$amplify/env/hookPlan' + +const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(env) +Amplify.configure(resourceConfig, libraryOptions) + +const cognitoClient = new CognitoIdentityProviderClient() +const dynamoClient = generateClient() + +export class CognitoUserService implements UserService { + constructor(private readonly userPoolId: string) {} + + /** + * Gets user from Cognito by username + */ + async getCognitoUser(userId: string) { + try { + console.log(`🔍 Fetching Cognito user: ${userId}`) + + const response = await cognitoClient.send( + new AdminGetUserCommand({ + UserPoolId: this.userPoolId, + Username: userId, + }) + ) + + console.log(`✅ Cognito user retrieved successfully`) + return response + } catch (error) { + console.error(`❌ Error fetching Cognito user ${userId}:`, error) + throw new Error(`Failed to fetch user: ${userId}`) + } + } + + /** + * Gets current user plan from Cognito attributes + */ + getCurrentPlan(userAttributes: CognitoUserAttribute[] = []): string { + const planAttribute = userAttributes.find(attr => attr.Name === 'custom:plan') + return planAttribute?.Value || 'free' + } + + /** + * Updates user plan in Cognito + */ + async updateUserPlan(userId: string, planName: string): Promise { + try { + console.log(`🔄 Updating user ${userId} to plan: ${planName}`) + + await cognitoClient.send( + new AdminUpdateUserAttributesCommand({ + UserPoolId: this.userPoolId, + Username: userId, + UserAttributes: [ + { + Name: 'custom:plan', + Value: planName, + }, + ], + }) + ) + + console.log(`✅ User plan updated successfully`) + } catch (error) { + console.error(`❌ Error updating user plan for ${userId}:`, error) + throw new Error(`Failed to update user plan: ${userId}`) + } + } + + /** + * Downgrades user to free plan after subscription cancellation + */ + async downgradeUser(userId: string): Promise { + try { + console.log(`⬇️ Downgrading user ${userId} to free plan`) + + const cognitoUser = await this.getCognitoUser(userId) + const currentPlan = this.getCurrentPlan(cognitoUser.UserAttributes) + + if (currentPlan === 'free') { + console.log('🔍 User already has free plan, no changes needed') + return + } + + // Calculate remaining subscription time + const existingSubscription = await this.getExistingSubscription(userId) + + if (existingSubscription) { + await this.handleSubscriptionCancellation(userId, existingSubscription) + } + + await this.updateUserPlan(userId, 'free') + + console.log(`✅ User successfully downgraded to free plan`) + } catch (error) { + console.error(`❌ Error downgrading user ${userId}:`, error) + throw new Error(`Failed to downgrade user: ${userId}`) + } + } + + /** + * Gets existing subscription from DynamoDB + */ + private async getExistingSubscription(userId: string) { + try { + const response = await dynamoClient.models.UserSubscription.get({ + id: userId, + }) + return response.data + } catch (error) { + console.log(`ℹ️ No existing subscription found for user: ${userId}`) + return null + } + } + + /** + * Handles subscription cancellation logic + */ + private async handleSubscriptionCancellation(userId: string, subscription: any) { + try { + // Calculate pending plan based on remaining time + const pendingPlan = this.calculatePendingPlan(subscription) + + if (pendingPlan !== 'free') { + await dynamoClient.models.UserSubscription.update({ + id: userId, + pendingPlan, + }) + + console.log(`⏰ Pending plan set to: ${pendingPlan}`) + } + } catch (error) { + console.error('❌ Error handling subscription cancellation:', error) + } + } + + /** + * Calculates pending plan based on remaining subscription time + */ + private calculatePendingPlan(subscription: any): string { + // Add your logic here to calculate pending plan + // based on subscription data and remaining time + return 'free' // Simplified for now + } + + /** + * Creates or updates subscription in DynamoDB + */ + async updateSubscription( + userId: string, + subscriptionData: { + subscriptionId?: string + planName: string + nextPaymentDate?: string + lastFourDigits?: number + } + ): Promise { + try { + console.log(`📝 Updating subscription for user: ${userId}`) + + if (!subscriptionData.subscriptionId) { + throw new Error('subscriptionId is required') + } + + // Check if subscription already exists + const existingSubscription = await this.getExistingSubscription(userId) + + const subscriptionPayload = { + id: userId, + userId: userId, + subscriptionId: subscriptionData.subscriptionId, + planName: subscriptionData.planName, + nextPaymentDate: subscriptionData.nextPaymentDate, + lastFourDigits: subscriptionData.lastFourDigits, + pendingPlan: null, // Clear pending plan on successful payment + } + + if (existingSubscription) { + // Update existing record + console.log(`🔄 Updating existing subscription record`) + await dynamoClient.models.UserSubscription.update(subscriptionPayload) + } else { + // Create new record + console.log(`✨ Creating new subscription record`) + await dynamoClient.models.UserSubscription.create(subscriptionPayload) + } + + console.log(`✅ Subscription saved successfully`) + } catch (error) { + console.error(`❌ Error saving subscription for ${userId}:`, error) + console.error('Subscription data:', JSON.stringify(subscriptionData, null, 2)) + throw new Error(`Failed to save subscription: ${userId}`) + } + } +} diff --git a/amplify/functions/webHookPlan/services/webhook-validator.ts b/amplify/functions/webHookPlan/services/webhook-validator.ts new file mode 100644 index 00000000..bff0d7fd --- /dev/null +++ b/amplify/functions/webHookPlan/services/webhook-validator.ts @@ -0,0 +1,70 @@ +import { createHmac } from 'crypto' +import { WebhookValidator } from '../types' + +export class MercadoPagoWebhookValidator implements WebhookValidator { + constructor(private readonly webhookSecret: string) {} + + /** + * Validates MercadoPago webhook signature + * According to: https://www.mercadopago.cl/developers/en/docs/security/oauth/best-practices + */ + validateSignature( + signature: string, + dataId: string, + requestId: string, + timestamp: string + ): boolean { + try { + if (!signature) { + throw new Error('Signature not provided in webhook') + } + + const match = signature.match(/ts=([^,]+),v1=([^,]+)/) + if (!match) { + throw new Error('Invalid signature format') + } + + const [, ts, v1] = match + + if (!dataId || !requestId) { + throw new Error('Missing required parameters in notification') + } + + const signatureTemplate = `id:${dataId};request-id:${requestId};ts:${ts};` + const expectedSignature = createHmac('sha256', this.webhookSecret) + .update(signatureTemplate) + .digest('hex') + + const isValid = v1 === expectedSignature + + if (isValid) { + console.log('✅ Webhook signature validated successfully') + } else { + console.error('❌ Invalid webhook signature') + } + + return isValid + } catch (error) { + console.error('🔥 Error validating webhook signature:', error) + return false + } + } + + /** + * Extracts timestamp from signature for additional validations + */ + extractTimestamp(signature: string): string | null { + const match = signature.match(/ts=([^,]+),v1=([^,]+)/) + return match ? match[1] : null + } + + /** + * Validates if the webhook is not too old (optional security measure) + */ + isTimestampValid(timestamp: string, toleranceSeconds: number = 300): boolean { + const now = Math.floor(Date.now() / 1000) + const webhookTime = parseInt(timestamp, 10) + + return Math.abs(now - webhookTime) <= toleranceSeconds + } +} diff --git a/amplify/functions/webHookPlan/types/index.ts b/amplify/functions/webHookPlan/types/index.ts new file mode 100644 index 00000000..15f0d760 --- /dev/null +++ b/amplify/functions/webHookPlan/types/index.ts @@ -0,0 +1,76 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda' + +// Webhook Event Types +export interface WebhookBody { + type: string + action: string + data: { + id: string + } +} + +// MercadoPago API Response Types +export interface SubscriptionData { + id: string + status: string + external_reference: string + next_payment_date: string + reason: string + auto_recurring: { + transaction_amount: number + } +} + +export interface PaymentData { + id: string + status: string + external_reference: string + status_detail: string + metadata?: { + preapproval_id?: string + } + point_of_interaction?: { + transaction_data?: { + subscription_id?: string + } + } + card?: { + last_four_digits?: string + } +} + +// Cognito User Attributes +export interface CognitoUserAttribute { + Name?: string + Value?: string +} + +// Response Types +export interface WebhookResponse { + statusCode: number + body: string +} + +export interface LambdaHandler { + (event: APIGatewayProxyEvent): Promise +} + +// Service Interfaces +export interface WebhookValidator { + validateSignature( + signature: string, + dataId: string, + requestId: string, + timestamp: string + ): boolean +} + +export interface PaymentProcessor { + processSubscriptionUpdate(subscriptionId: string): Promise + processPayment(paymentId: string, paymentType: string): Promise +} + +export interface UserService { + updateUserPlan(userId: string, planName: string): Promise + downgradeUser(userId: string): Promise +} diff --git a/app/(main-layout)/account-settings/components/PaymentSettings.tsx b/app/(main-layout)/account-settings/components/PaymentSettings.tsx index 00a328cc..d0cf4944 100644 --- a/app/(main-layout)/account-settings/components/PaymentSettings.tsx +++ b/app/(main-layout)/account-settings/components/PaymentSettings.tsx @@ -15,7 +15,7 @@ function SubscriptionLoader() { const [isSubmitting, setIsSubmitting] = useState(false) const { user } = useUserStore() - const cognitoUsername = user?.cognitoUsername + const cognitoUsername = user?.userId const handleCancel = async () => { if (!cognitoUsername) { @@ -142,8 +142,8 @@ export function PaymentSettings() { // Usar useEffect para inicializar el recurso de suscripción useEffect(() => { - if (user?.cognitoUsername) { - setCognitoUsername(user.cognitoUsername) + if (user?.userId) { + setCognitoUsername(user.userId) } }, [user]) diff --git a/app/(main-layout)/pricing/components/PricingCard.tsx b/app/(main-layout)/pricing/components/PricingCard.tsx index 213a1207..a1161d05 100644 --- a/app/(main-layout)/pricing/components/PricingCard.tsx +++ b/app/(main-layout)/pricing/components/PricingCard.tsx @@ -47,7 +47,7 @@ export function PricingCard({ plan }: PricingCardProps) { setIsClient(true) }, []) - const cognitoUsername = user?.cognitoUsername + const cognitoUsername = user?.userId const hasActivePlan = user && user.plan ? user.plan === plan.name : false const formatPrice = (price: string) => { diff --git a/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption.ts b/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption.ts index d65621cc..350cf746 100644 --- a/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption.ts +++ b/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption.ts @@ -59,7 +59,7 @@ export function useApiKeyEncryption() { return null } catch (error) { - console.error('Error encriptando clave API:', error) + console.error('Error encrypting API key:', error) return null } finally { setIsEncrypting(false) diff --git a/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts b/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts index dc5e4dd4..c7585a64 100644 --- a/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts +++ b/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts @@ -1,42 +1,14 @@ import { useState } from 'react' import { generateClient } from 'aws-amplify/data' import type { Schema } from '@/amplify/data/resource' +import useUserStore from '@/context/core/userStore' -// Generamos el cliente a partir del schema definido en el backend -const client = generateClient() +const client = generateClient({ + authMode: 'userPool', +}) // Definición del input para UserStore -export interface UserStoreInput { - userId: string // ID del usuario (dueño de la tienda) - storeId: string // ID único de la tienda - storeName: string // Nombre de la tienda - storeDescription?: string // Descripción opcional - storeCurrency?: string // Moneda de la tienda - storeLogo?: string // URL de la imagen del logo - storeFavicon?: string // URL de la imagen del favicon - storeTheme?: string // Tema de la tienda (opcional) - storeBanner?: string // URL de la imagen del banner - storeType?: string // Tipo de tienda - storeStatus?: string // Estado de la tienda - storePolicy?: string // Política de la tienda - storeAdress?: string // Dirección de la tienda - contactEmail?: string - contactPhone?: number - contactName?: string - contactIdentification?: string - contactIdentificationType?: string - wompiConfig?: any // Configuración de wonpi en formato JSON - mercadoPagoConfig?: any // Configuración de mercado pago en formato JSON - mastershopApiKey?: string // Clave API de Mastershop - customDomain?: string // Dominio propio a asignar (opcional) - onboardingCompleted: boolean - onboardingData?: any -} - -// Definimos el tipo de autorización a usar -export interface AuthMode { - authMode: 'userPool' -} +export type UserStore = Schema['UserStore']['type'] // Tipo para pasarelas de pago export type PaymentGatewayType = 'mercadoPago' | 'wompi' @@ -49,12 +21,10 @@ export interface PaymentGatewayConfig { createdAt: string } -// Valor por defecto de autorización -const defaultAuth: AuthMode = { authMode: 'userPool' } - export const useUserStoreData = () => { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const { user } = useUserStore() /** * Función auxiliar que ejecuta una operación y gestiona loading y error. @@ -80,78 +50,70 @@ export const useUserStoreData = () => { } /** - * Obtiene el ID del registro y la información de pasarelas de pago configuradas + * Obtiene la información de pasarelas de pago configuradas * para una tienda específica sin traer datos sensibles. * @param storeId - ID único de la tienda - * @param auth - Modo de autenticación - * @returns Objeto con el ID del registro y un array de pasarelas configuradas + * @returns Array de pasarelas configuradas */ const getStorePaymentInfo = async ( - storeId: string, - auth: AuthMode = defaultAuth + storeId: string ): Promise<{ - id: string | null configuredGateways: PaymentGatewayType[] }> => { try { setLoading(true) setError(null) - // Primero obtenemos el ID del registro - const idResult = await client.models.UserStore.list({ - filter: { storeId: { eq: storeId } }, - selectionSet: ['id'], - authMode: auth.authMode, - }) - - if (idResult.errors && idResult.errors.length > 0) { - setError(idResult.errors) - return { id: null, configuredGateways: [] } + if (!user?.userId) { + setError('User not authenticated') + return { configuredGateways: [] } } - if (idResult.data && idResult.data.length > 0) { - const storeId = idResult.data[0].id - const configuredGateways: PaymentGatewayType[] = [] + const configuredGateways: PaymentGatewayType[] = [] - // Verificamos si existe configuración de Wompi - const wompiResult = await client.models.UserStore.list({ + // Verificamos si existe configuración de Wompi + const wompiResult = await client.models.UserStore.listUserStoreByUserId( + { + userId: user.userId, + }, + { filter: { - id: { eq: storeId }, + storeId: { eq: storeId }, wompiConfig: { attributeExists: true }, }, - selectionSet: ['id'], - authMode: auth.authMode, - }) - - // Verificamos si existe configuración de MercadoPago - const mercadoPagoResult = await client.models.UserStore.list({ + selectionSet: ['storeId'], + } + ) + + // Verificamos si existe configuración de MercadoPago + const mercadoPagoResult = await client.models.UserStore.listUserStoreByUserId( + { + userId: user.userId, + }, + { filter: { - id: { eq: storeId }, + storeId: { eq: storeId }, mercadoPagoConfig: { attributeExists: true }, }, - selectionSet: ['id'], - authMode: auth.authMode, - }) - - // Agregamos las pasarelas configuradas al array - if (wompiResult.data && wompiResult.data.length > 0) { - configuredGateways.push('wompi') + selectionSet: ['storeId'], } + ) - if (mercadoPagoResult.data && mercadoPagoResult.data.length > 0) { - configuredGateways.push('mercadoPago') - } + // Agregamos las pasarelas configuradas al array + if (wompiResult.data && wompiResult.data.length > 0) { + configuredGateways.push('wompi') + } - return { - id: storeId, - configuredGateways, - } + if (mercadoPagoResult.data && mercadoPagoResult.data.length > 0) { + configuredGateways.push('mercadoPago') } - return { id: null, configuredGateways: [] } + return { + configuredGateways, + } } catch (err) { setError(err) - return { id: null, configuredGateways: [] } + return { configuredGateways: [] } } finally { setLoading(false) } @@ -161,54 +123,84 @@ export const useUserStoreData = () => { * Configura una pasarela de pago para una tienda específica. */ const configurePaymentGateway = async ( - storeRecordId: string, + storeId: string, gateway: PaymentGatewayType, config: any, - convertToJson: boolean = false, - auth: AuthMode = defaultAuth + convertToJson: boolean = false ): Promise => { - if (!storeRecordId) { - setError('ID de registro no proporcionado') + if (!storeId || !user?.userId) { + setError('Store ID or User ID not provided') return false } - // Convertir a JSON si es necesario - const configValue = convertToJson ? JSON.stringify(config) : config + try { + // Primero obtenemos el registro existente + const existingStoreResult = await client.models.UserStore.listUserStoreByUserId( + { + userId: user.userId, + }, + { + filter: { + storeId: { eq: storeId }, + }, + selectionSet: ['storeId'], + } + ) - const updatePayload = { - id: storeRecordId, - ...(gateway === 'mercadoPago' - ? { mercadoPagoConfig: configValue } - : { wompiConfig: configValue }), - } + if (!existingStoreResult.data || existingStoreResult.data.length === 0) { + setError('Store not found') + return false + } - const result = await performOperation(() => client.models.UserStore.update(updatePayload, auth)) + // Convertir a JSON si es necesario + const configValue = convertToJson ? JSON.stringify(config) : config + + // El payload de update NO debe incluir el identificador (storeId) + // Solo los campos que queremos actualizar + const updatePayload = { + ...(gateway === 'mercadoPago' + ? { mercadoPagoConfig: configValue } + : { wompiConfig: configValue }), + } - return result !== null + // Para el update, necesitamos pasar el identificador por separado + const result = await performOperation(() => + client.models.UserStore.update({ + storeId: storeId, // Este es el identificador + ...updatePayload, // Estos son los campos a actualizar + }) + ) + + return result !== null + } catch (err) { + setError(err) + return false + } } /** * Crea una tienda (UserStore) en la base de datos. */ - const createUserStore = async (storeInput: UserStoreInput, auth: AuthMode = defaultAuth) => { - return performOperation(() => client.models.UserStore.create(storeInput, auth)) + const createUserStore = async ( + storeInput: Omit + ) => { + return performOperation(() => client.models.UserStore.create(storeInput)) } /** * Actualiza los datos de una tienda. */ const updateUserStore = async ( - storeInput: Partial & { id: string }, - auth: AuthMode = defaultAuth + storeInput: Omit, 'id' | 'createdAt' | 'updatedAt'> & { storeId: string } ) => { - return performOperation(() => client.models.UserStore.update(storeInput, auth)) + return performOperation(() => client.models.UserStore.update(storeInput)) } /** - * Elimina una tienda a partir de su 'id'. + * Elimina una tienda a partir de su 'storeId'. */ - const deleteUserStore = async (id: string, auth: AuthMode = defaultAuth) => { - return performOperation(() => client.models.UserStore.delete({ id }, auth)) + const deleteUserStore = async (storeId: string) => { + return performOperation(() => client.models.UserStore.delete({ storeId })) } return { diff --git a/app/store/components/domains/ChangeDomainDialog.tsx b/app/store/components/domains/ChangeDomainDialog.tsx index e7b2fc26..4d0fd842 100644 --- a/app/store/components/domains/ChangeDomainDialog.tsx +++ b/app/store/components/domains/ChangeDomainDialog.tsx @@ -67,7 +67,7 @@ export function ChangeDomainDialog({ const fullDomain = `${domainName.trim()}.fasttify.com` const result = await updateUserStore({ - id: storeId, + storeId: storeId, customDomain: fullDomain, }) diff --git a/app/store/components/domains/DomainManagement.tsx b/app/store/components/domains/DomainManagement.tsx index daf62d62..60728966 100644 --- a/app/store/components/domains/DomainManagement.tsx +++ b/app/store/components/domains/DomainManagement.tsx @@ -198,13 +198,13 @@ export function DomainManagement() { +

Método de Captura de Pago

+

+ Decide cómo quieres procesar los pagos cuando un cliente realice una compra:{' '} + + Más información + + . +

+ + +
+ +
+ +

+ El pago se procesa de inmediato al realizar el pedido. +

+
+
+ +
+ +
+ +

+ Se autoriza el pago al finalizar la compra y se captura al completar el pedido. +

+
+
+ +
+ +
+ +

+ Se autoriza el pago al finalizar la compra y debe capturarse manualmente. +

+
+
+
+ + ) +} diff --git a/app/store/components/payments/PaymentGatewayCard.tsx b/app/store/components/payments/PaymentGatewayCard.tsx new file mode 100644 index 00000000..70c4dea6 --- /dev/null +++ b/app/store/components/payments/PaymentGatewayCard.tsx @@ -0,0 +1,71 @@ +import Image from 'next/image' +import { Button } from '@/components/ui/button' +import { Check } from 'lucide-react' +import { PaymentGatewayType } from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' +import { + WompiPaymentIcons, + MercadoPagoIcons, +} from '@/app/store/components/payments/PaymentMethodIcons' + +interface PaymentGatewayCardProps { + gateway: PaymentGatewayType + isConfigured: boolean + onActivate: (gateway: PaymentGatewayType) => void +} + +export function PaymentGatewayCard({ gateway, isConfigured, onActivate }: PaymentGatewayCardProps) { + const gatewayConfig = { + wompi: { + name: 'Wompi', + logo: '/icons/wompi.webp', + PaymentIcons: WompiPaymentIcons, + }, + mercadoPago: { + name: 'Mercado Pago', + logo: '/icons/mercadopago-logo.webp', + PaymentIcons: MercadoPagoIcons, + }, + } + + const config = gatewayConfig[gateway] + + return ( +
+
+
+
+ {config.name} + {isConfigured && ( + + + Activo + + )} +
+ Sin cargos adicionales en Fasttify +
+ {`${config.name} +
+
+ +
+ +
+ Métodos de pago + +
+
+ ) +} diff --git a/app/store/components/payments/PaymentMethodsSection.tsx b/app/store/components/payments/PaymentMethodsSection.tsx new file mode 100644 index 00000000..02a7b722 --- /dev/null +++ b/app/store/components/payments/PaymentMethodsSection.tsx @@ -0,0 +1,39 @@ +import { PaymentGatewayType } from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' +import { PaymentGatewayCard } from '@/app/store/components/payments/PaymentGatewayCard' + +interface PaymentMethodsSectionProps { + configuredGateways: PaymentGatewayType[] + onOpenModal: (gateway: PaymentGatewayType) => void +} + +export function PaymentMethodsSection({ + configuredGateways, + onOpenModal, +}: PaymentMethodsSectionProps) { + const isGatewayConfigured = (gateway: PaymentGatewayType) => { + return configuredGateways.includes(gateway) + } + + return ( +
+

Métodos de Pago Admitidos

+

+ Métodos de pago disponibles en Fasttify a través de nuestras pasarelas integradas. +

+ +
+ + + +
+
+ ) +} diff --git a/app/store/components/payments/PaymentProvidersSection.tsx b/app/store/components/payments/PaymentProvidersSection.tsx new file mode 100644 index 00000000..5487a03a --- /dev/null +++ b/app/store/components/payments/PaymentProvidersSection.tsx @@ -0,0 +1,23 @@ +import Link from 'next/link' +import { WompiGuide } from '@/app/store/components/payments/WompiGuide' +import { MercadoPagoGuide } from '@/app/store/components/payments/MercadoPagoGuide' + +export function PaymentProvidersSection() { + return ( +
+

Pasarelas de Pago

+

+ Configura las pasarelas de pago para aceptar transacciones en tu tienda Fasttify. Se pueden + aplicar tarifas según el proveedor seleccionado.{' '} + + Selecciona un plan + + . +

+
+ + +
+
+ ) +} diff --git a/app/store/components/payments/PaymentSettings.tsx b/app/store/components/payments/PaymentSettings.tsx index 103822ad..b811b2d9 100644 --- a/app/store/components/payments/PaymentSettings.tsx +++ b/app/store/components/payments/PaymentSettings.tsx @@ -1,28 +1,11 @@ 'use client' -import { useState } from 'react' -import Link from 'next/link' -import Image from 'next/image' -import { Button } from '@/components/ui/button' -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' -import { Label } from '@/components/ui/label' -import { Check } from 'lucide-react' import { PaymentSettingsSkeleton } from '@/app/store/components/payments/PaymentSettingsSkeleton' import { ApiKeyModal } from '@/app/store/components/payments/ApiKeyModal' -import { useParams } from 'next/navigation' -import { toast } from 'sonner' -import { - useUserStoreData, - PaymentGatewayType, -} from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' -import { WompiGuide } from '@/app/store/components/payments/WompiGuide' -import { MercadoPagoGuide } from '@/app/store/components/payments/MercadoPagoGuide' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { - WompiPaymentIcons, - MercadoPagoIcons, -} from '@/app/store/components/payments/PaymentMethodIcons' -import { useApiKeyEncryption } from '@/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption' +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 { usePaymentSettings } from '@/app/store/components/payments/hooks/usePaymentSettings' import { Amplify } from 'aws-amplify' import outputs from '@/amplify_outputs.json' @@ -37,333 +20,28 @@ Amplify.configure({ }) export function PaymentSettings() { - const params = useParams() - const storeId = params.slug as string - const [modalOpen, setModalOpen] = useState(false) - const [selectedGateway, setSelectedGateway] = useState('mercadoPago') - const { getStorePaymentInfo, configurePaymentGateway } = useUserStoreData() - const { encryptApiKey, isEncrypting } = useApiKeyEncryption() - const queryClient = useQueryClient() - - const { data, isLoading, isRefetching } = useQuery({ - queryKey: ['storePaymentInfo', storeId], - queryFn: () => getStorePaymentInfo(storeId), - enabled: !!storeId, - staleTime: 5 * 60 * 1000, // Los datos se consideran frescos por 5 minutos - gcTime: 5 * 60 * 1000, // Los datos se eliminan de caché después de 5 minutos sin usarse - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - }) - - const storeRecordId = data?.id || null - const configuredGateways = data?.configuredGateways || [] - - const configureGatewayMutation = useMutation({ - mutationFn: async (data: { storeId: string; gateway: PaymentGatewayType; configData: any }) => { - return await configurePaymentGateway(data.storeId, data.gateway, data.configData, true) - }, - onSuccess: (_, variables) => { - const gatewayName = variables.gateway === 'wompi' ? 'Wompi' : 'Mercado Pago' - toast.success(`¡Configuración exitosa!`, { - description: `La pasarela ${gatewayName} ha sido configurada correctamente.`, - }) - queryClient.invalidateQueries({ queryKey: ['storePaymentInfo', storeId] }) - }, - onError: (error, variables) => { - // Mostrar toast de error - const gatewayName = variables.gateway === 'wompi' ? 'Wompi' : 'Mercado Pago' - toast.error(`Error de configuración`, { - description: `No se pudo configurar ${gatewayName}. Por favor, intenta nuevamente.`, - }) - }, - }) - - const handleOpenModal = (gateway: PaymentGatewayType) => { - setSelectedGateway(gateway) - setModalOpen(true) - } - - const handleSubmit = async (data: { - gateway: PaymentGatewayType - publicKey: string - privateKey: string - }): Promise => { - try { - if (!storeRecordId) { - toast.error('Error de configuración', { - description: 'Uyps! Hubo un error al configurar la pasarela de pago.', - }) - console.error('Store record ID not found') - return false - } - - // Encriptar las claves API antes de guardarlas - let configData: any = { isActive: true } - - if (data.gateway === 'wompi') { - // Encriptar la clave pública de Wompi - if (data.publicKey) { - const encryptedPublicKey = await encryptApiKey( - data.publicKey, - 'wompi', - 'publicKey', - storeId - ) - if (encryptedPublicKey) { - configData.publicKey = encryptedPublicKey - } else { - console.error('Error encrypting Wompi public key') - toast.error('Error de configuración', { - description: - 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - - // Encriptar la firma (clave privada) de Wompi - if (data.privateKey) { - const encryptedSignature = await encryptApiKey( - data.privateKey, - 'wompi', - 'signature', - storeId - ) - if (encryptedSignature) { - configData.signature = encryptedSignature - } else { - console.error('Error encrypting Wompi signature') - toast.error('Error de configuración', { - description: - 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - } else if (data.gateway === 'mercadoPago') { - // Encriptar la clave pública de Mercado Pago - if (data.publicKey) { - const encryptedPublicKey = await encryptApiKey( - data.publicKey, - 'mercadopago', - 'publicKey', - storeId - ) - if (encryptedPublicKey) { - configData.publicKey = encryptedPublicKey - } else { - console.error('Error encrypting the Mercado Pago public key') - toast.error('Error de configuración', { - description: - 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - - // Encriptar la clave privada de Mercado Pago - if (data.privateKey) { - const encryptedPrivateKey = await encryptApiKey( - data.privateKey, - 'mercadopago', - 'privateKey', - storeId - ) - if (encryptedPrivateKey) { - configData.privateKey = encryptedPrivateKey - } else { - console.error('Error encrypting the Mercado Pago private key') - toast.error('Error de configuración', { - description: - 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - } - - if (isEncrypting) { - toast.loading('Configurando pasarela de pago...') - return false - } - - const success = await configureGatewayMutation.mutateAsync({ - storeId: storeRecordId, - gateway: data.gateway, - configData, - }) - - return success - } catch (err) { - console.error('Error configuring the payment gateway:', err) - toast.error('Error de configuración', { - description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', - }) - return false - } - } - - const isGatewayConfigured = (gateway: PaymentGatewayType) => { - return configuredGateways.includes(gateway) - } + const { + modalOpen, + setModalOpen, + selectedGateway, + configuredGateways, + isLoading, + handleOpenModal, + handleSubmit, + } = usePaymentSettings() return (
- {isLoading || isRefetching ? ( + {isLoading ? ( ) : ( <> - {/* Sección de Proveedores de Pago */} -
-

Pasarelas de Pago

-

- Configura las pasarelas de pago para aceptar transacciones en tu tienda Fasttify. Se - pueden aplicar tarifas según el proveedor seleccionado.{' '} - - Selecciona un plan - - . -

-
- - -
-
- - {/* Sección de Métodos de Pago */} -
-

Métodos de Pago Admitidos

-

- Métodos de pago disponibles en Fasttify a través de nuestras pasarelas integradas. -

- -
-
-
-
- Wompi - {isGatewayConfigured('wompi') && ( - - - Activo - - )} -
- Sin cargos adicionales en Fasttify -
- Wompi logo -
-
- -
- -
- Métodos de pago - -
-
- -
-
-
-
- Mercado Pago - {isGatewayConfigured('mercadoPago') && ( - - - Activo - - )} -
- Sin cargos adicionales en Fasttify -
- Mercado Pago logo -
-
- -
- -
- Métodos de pago - -
-
-
- - {/* Sección de Método de Captura de Pago */} -
-

Método de Captura de Pago

-

- Decide cómo quieres procesar los pagos cuando un cliente realice una compra:{' '} - - Más información - - . -

- - -
- -
- -

- El pago se procesa de inmediato al realizar el pedido. -

-
-
- -
- -
- -

- Se autoriza el pago al finalizar la compra y se captura al completar el pedido. -

-
-
- -
- -
- -

- Se autoriza el pago al finalizar la compra y debe capturarse manualmente. -

-
-
-
-
+ + + )} diff --git a/app/store/components/payments/hooks/usePaymentSettings.ts b/app/store/components/payments/hooks/usePaymentSettings.ts new file mode 100644 index 00000000..4bcc2312 --- /dev/null +++ b/app/store/components/payments/hooks/usePaymentSettings.ts @@ -0,0 +1,200 @@ +import { useState } from 'react' +import { useParams } from 'next/navigation' +import { toast } from 'sonner' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + useUserStoreData, + PaymentGatewayType, +} from '@/app/(setup-layout)/first-steps/hooks/useUserStoreData' +import { useApiKeyEncryption } from '@/app/(setup-layout)/first-steps/hooks/useApiKeyEncryption' +import useUserStore from '@/context/core/userStore' + +export function usePaymentSettings() { + const params = useParams() + const storeId = params.slug as string + const [modalOpen, setModalOpen] = useState(false) + const [selectedGateway, setSelectedGateway] = useState('mercadoPago') + const { getStorePaymentInfo, configurePaymentGateway } = useUserStoreData() + const { encryptApiKey, isEncrypting } = useApiKeyEncryption() + const { user, loading: userLoading } = useUserStore() + const queryClient = useQueryClient() + const userId = user?.userId + + const { data, isLoading, isRefetching } = useQuery({ + queryKey: ['storePaymentInfo', storeId], + queryFn: () => getStorePaymentInfo(storeId), + enabled: !!storeId && !!userId && !userLoading, + staleTime: 5 * 60 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }) + + const configuredGateways = data?.configuredGateways || [] + + const configureGatewayMutation = useMutation({ + mutationFn: async (data: { storeId: string; gateway: PaymentGatewayType; configData: any }) => { + return await configurePaymentGateway(data.storeId, data.gateway, data.configData, true) + }, + onSuccess: (_, variables) => { + const gatewayName = variables.gateway === 'wompi' ? 'Wompi' : 'Mercado Pago' + toast.success(`¡Configuración exitosa!`, { + description: `La pasarela ${gatewayName} ha sido configurada correctamente.`, + }) + queryClient.invalidateQueries({ queryKey: ['storePaymentInfo', storeId] }) + }, + onError: (error, variables) => { + const gatewayName = variables.gateway === 'wompi' ? 'Wompi' : 'Mercado Pago' + toast.error(`Error de configuración`, { + description: `No se pudo configurar ${gatewayName}. Por favor, intenta nuevamente.`, + }) + }, + }) + + const handleOpenModal = (gateway: PaymentGatewayType) => { + setSelectedGateway(gateway) + setModalOpen(true) + } + + const handleSubmit = async (data: { + gateway: PaymentGatewayType + publicKey: string + privateKey: string + }): Promise => { + try { + if (!storeId) { + toast.error('Error de configuración', { + description: 'Uyps! Hubo un error al configurar la pasarela de pago.', + }) + console.error('Store ID not found') + return false + } + + // Encriptar las claves API antes de guardarlas + let configData: any = { isActive: true } + + if (data.gateway === 'wompi') { + // Encriptar la clave pública de Wompi + if (data.publicKey) { + const encryptedPublicKey = await encryptApiKey( + data.publicKey, + 'wompi', + 'publicKey', + storeId + ) + if (encryptedPublicKey) { + configData.publicKey = encryptedPublicKey + } else { + console.error('Error encrypting Wompi public key') + toast.error('Error de configuración', { + description: + 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + + // Encriptar la firma (clave privada) de Wompi + if (data.privateKey) { + const encryptedSignature = await encryptApiKey( + data.privateKey, + 'wompi', + 'signature', + storeId + ) + if (encryptedSignature) { + configData.signature = encryptedSignature + } else { + console.error('Error encrypting Wompi signature') + toast.error('Error de configuración', { + description: + 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + } else if (data.gateway === 'mercadoPago') { + // Encriptar la clave pública de Mercado Pago + if (data.publicKey) { + const encryptedPublicKey = await encryptApiKey( + data.publicKey, + 'mercadopago', + 'publicKey', + storeId + ) + if (encryptedPublicKey) { + configData.publicKey = encryptedPublicKey + } else { + console.error('Error encrypting the Mercado Pago public key') + toast.error('Error de configuración', { + description: + 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + + // Encriptar la clave privada de Mercado Pago + if (data.privateKey) { + const encryptedPrivateKey = await encryptApiKey( + data.privateKey, + 'mercadopago', + 'privateKey', + storeId + ) + if (encryptedPrivateKey) { + configData.privateKey = encryptedPrivateKey + } else { + console.error('Error encrypting the Mercado Pago private key') + toast.error('Error de configuración', { + description: + 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + } + + if (isEncrypting) { + toast.loading('Configurando pasarela de pago...') + return false + } + + const success = await configureGatewayMutation.mutateAsync({ + storeId: storeId, + gateway: data.gateway, + configData, + }) + + return success + } catch (err) { + console.error('Error configuring the payment gateway:', err) + toast.error('Error de configuración', { + description: 'No se pudo configurar la pasarela de pago. Por favor, intenta nuevamente.', + }) + return false + } + } + + const isGatewayConfigured = (gateway: PaymentGatewayType) => { + return configuredGateways.includes(gateway) + } + + return { + // State + modalOpen, + setModalOpen, + selectedGateway, + storeId, + + // Data + configuredGateways, + isLoading: isLoading || isRefetching || userLoading, + + // Functions + handleOpenModal, + handleSubmit, + isGatewayConfigured, + } +} diff --git a/app/store/components/product-management/collection-form/product-section.tsx b/app/store/components/product-management/collection-form/product-section.tsx index f4fe2659..6dfa81af 100644 --- a/app/store/components/product-management/collection-form/product-section.tsx +++ b/app/store/components/product-management/collection-form/product-section.tsx @@ -173,7 +173,9 @@ export function ProductSection({ src={ typeof product.images === 'string' ? JSON.parse(product.images)[0]?.url - : product.images[0]?.url + : Array.isArray(product.images) + ? product.images[0]?.url + : undefined } alt={product.name} className="w-8 h-8 object-cover rounded" @@ -254,7 +256,9 @@ export function ProductSection({ src={ typeof product.images === 'string' ? JSON.parse(product.images)[0]?.url - : product.images[0]?.url + : Array.isArray(product.images) + ? product.images[0]?.url + : undefined } alt={product.name} className="w-8 h-8 object-cover rounded" diff --git a/app/store/components/product-management/hooks/useProductFilters.ts b/app/store/components/product-management/hooks/useProductFilters.ts index 0091ea7f..60955beb 100644 --- a/app/store/components/product-management/hooks/useProductFilters.ts +++ b/app/store/components/product-management/hooks/useProductFilters.ts @@ -61,6 +61,11 @@ export function useProductFilters(products: IProduct[]) { return 0 } + // Handle null/undefined values + if (valueA == null && valueB == null) return 0 + if (valueA == null) return sortDirection === 'asc' ? -1 : 1 + if (valueB == null) return sortDirection === 'asc' ? 1 : -1 + if (valueA < valueB) return sortDirection === 'asc' ? -1 : 1 if (valueA > valueB) return sortDirection === 'asc' ? 1 : -1 return 0 diff --git a/app/store/components/product-management/product-table/product-card-mobile.tsx b/app/store/components/product-management/product-table/product-card-mobile.tsx index 9c458eff..ce81ae41 100644 --- a/app/store/components/product-management/product-table/product-card-mobile.tsx +++ b/app/store/components/product-management/product-table/product-card-mobile.tsx @@ -39,7 +39,9 @@ export function ProductCardMobile({ src={ typeof product.images === 'string' ? JSON.parse(product.images)[0]?.url - : product.images[0]?.url + : Array.isArray(product.images) + ? product.images[0]?.url + : undefined } alt={product.name} className="w-10 h-10 object-cover rounded" @@ -98,7 +100,7 @@ export function ProductCardMobile({ {visibleColumns.inventory && (

Inventario

-

{formatInventory(product.quantity)}

+

{formatInventory(product.quantity ?? 0)}

)}
diff --git a/app/store/components/product-management/product-table/product-table-desktop.tsx b/app/store/components/product-management/product-table/product-table-desktop.tsx index dc9a24ce..fc37ffdd 100644 --- a/app/store/components/product-management/product-table/product-table-desktop.tsx +++ b/app/store/components/product-management/product-table/product-table-desktop.tsx @@ -133,7 +133,9 @@ export function ProductTableDesktop({ src={ typeof product.images === 'string' ? JSON.parse(product.images)[0]?.url - : product.images[0]?.url + : Array.isArray(product.images) + ? product.images[0]?.url + : undefined } alt={product.name} className="w-8 h-8 object-cover rounded" @@ -167,7 +169,7 @@ export function ProductTableDesktop({ )} {visibleColumns.inventory && ( - {formatInventory(product.quantity)} + {formatInventory(product.quantity ?? 0)} )} {visibleColumns.price && ( diff --git a/app/store/components/product-management/utils/productUtils.ts b/app/store/components/product-management/utils/productUtils.ts index 1cd93169..f6ac0b06 100644 --- a/app/store/components/product-management/utils/productUtils.ts +++ b/app/store/components/product-management/utils/productUtils.ts @@ -10,21 +10,26 @@ import { ProductFormValues } from '@/lib/zod-schemas/product-schema' export function mapProductToFormValues(product: IProduct): Partial { return { name: product.name, - description: product.description, + description: product.description ?? undefined, price: product.price, compareAtPrice: product.compareAtPrice, costPerItem: product.costPerItem, - sku: product.sku, - barcode: product.barcode, - quantity: product.quantity, - category: product.category, + sku: product.sku ?? undefined, + barcode: product.barcode ?? undefined, + quantity: product.quantity ?? undefined, + category: product.category ?? undefined, images: typeof product.images === 'string' ? JSON.parse(product.images) : product.images, attributes: typeof product.attributes === 'string' ? JSON.parse(product.attributes) : product.attributes, variants: typeof product.variants === 'string' ? JSON.parse(product.variants) : product.variants, tags: typeof product.tags === 'string' ? JSON.parse(product.tags) : product.tags, - status: product.status, + status: (product.status ?? undefined) as + | 'active' + | 'draft' + | 'inactive' + | 'pending' + | undefined, } } diff --git a/app/store/components/sidebar/nav-main.tsx b/app/store/components/sidebar/nav-main.tsx index 8d4f1795..9ee91a3a 100644 --- a/app/store/components/sidebar/nav-main.tsx +++ b/app/store/components/sidebar/nav-main.tsx @@ -61,7 +61,6 @@ export function NavMain({ items }: NavMainProps) { {items.map(item => { - // Check if this item is active based on the current path const isItemActive = item.url && pathname.startsWith(item.url) const hasActiveChild = item.items?.some(subItem => pathname === subItem.url) diff --git a/app/store/components/sidebar/nav-user.tsx b/app/store/components/sidebar/nav-user.tsx index 0ab1ed7a..af592498 100644 --- a/app/store/components/sidebar/nav-user.tsx +++ b/app/store/components/sidebar/nav-user.tsx @@ -57,7 +57,6 @@ export function NavUser({ user, loading }: NavUserProps) { return (firstInitial + secondInitial).toUpperCase() || 'U' } - // Set isClient to true after component mounts useEffect(() => { setIsClient(true) }, []) diff --git a/app/store/components/store-config/LogoUploader.tsx b/app/store/components/store-config/LogoUploader.tsx index 84e60b28..1a3c4235 100644 --- a/app/store/components/store-config/LogoUploader.tsx +++ b/app/store/components/store-config/LogoUploader.tsx @@ -72,7 +72,7 @@ export function LogoUploader() { return } - if (!currentStore || !currentStore.id) { + if (!currentStore || !currentStore.storeId) { toast.error('No se pudo identificar la tienda', { description: 'Intenta recargar la página', }) @@ -84,7 +84,7 @@ export function LogoUploader() { if (result) { // Actualizar la URL del logo en la base de datos const updateResult = await updateUserStore({ - id: currentStore.id, + storeId: currentStore.storeId, storeLogo: result.url, }) @@ -119,7 +119,7 @@ export function LogoUploader() { return } - if (!currentStore || !currentStore.id) { + if (!currentStore || !currentStore.storeId) { toast.error('No se pudo identificar la tienda', { description: 'Intenta recargar la página', }) @@ -131,7 +131,7 @@ export function LogoUploader() { if (result) { // Actualizar la URL del favicon en la base de datos const updateResult = await updateUserStore({ - id: currentStore.id, + storeId: currentStore.storeId, storeFavicon: result.url, }) diff --git a/app/store/components/store-config/ThemePreview.tsx b/app/store/components/store-config/ThemePreview.tsx index 3b7628d0..4e2886a5 100644 --- a/app/store/components/store-config/ThemePreview.tsx +++ b/app/store/components/store-config/ThemePreview.tsx @@ -5,7 +5,6 @@ import { LogoUploader } from '@/app/store/components/store-config/LogoUploader' import useStoreDataStore from '@/context/core/storeDataStore' import Link from 'next/link' import Image from 'next/image' -import { useState } from 'react' export function ThemePreview() { return ( diff --git a/app/store/hooks/useProducts.ts b/app/store/hooks/useProducts.ts index 9ed88b57..674f6d68 100644 --- a/app/store/hooks/useProducts.ts +++ b/app/store/hooks/useProducts.ts @@ -10,50 +10,7 @@ const client = generateClient({ /** * Interfaz para representar un producto */ -export interface IProduct { - id: string - storeId: string - name: string - description?: string - price: number - compareAtPrice?: number - costPerItem?: number - sku?: string - barcode?: string - quantity: number - category?: string - images?: Array<{ - url: string - alt?: string - position?: number - }> - attributes?: Array<{ - name: string - values: string[] - }> - status: 'active' | 'inactive' | 'pending' | 'draft' - slug?: string - featured?: boolean - tags?: string[] - variants?: Array<{ - id: string - name: string - price?: number - sku?: string - quantity?: number - attributes?: Array<{ - name: string - value: string - }> - images?: Array<{ - url: string - alt?: string - }> - }> - owner: string - createdAt?: string - updatedAt?: string -} +export type IProduct = Schema['Product']['type'] /** * Tipo para los datos necesarios al crear un producto diff --git a/app/store/hooks/useStore.ts b/app/store/hooks/useStore.ts index 59b32465..7dc5e281 100644 --- a/app/store/hooks/useStore.ts +++ b/app/store/hooks/useStore.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import useStoreDataStore from '@/context/core/storeDataStore' +import useUserStore from '@/context/core/userStore' interface UseStoreReturn { store: any @@ -10,8 +11,9 @@ interface UseStoreReturn { /** * Hook para obtener y gestionar los datos de una tienda */ -export function useStore(storeId: string | null): UseStoreReturn { +export function useStore(storeId: string): UseStoreReturn { const { currentStore, isLoading, error, fetchStoreData } = useStoreDataStore() + const { user } = useUserStore() const [isClient, setIsClient] = useState(false) // Efecto para manejar la hidratación del cliente @@ -21,10 +23,10 @@ export function useStore(storeId: string | null): UseStoreReturn { // Efecto para cargar los datos de la tienda useEffect(() => { - if (storeId && isClient) { - fetchStoreData(storeId) + if (storeId && user?.userId && isClient) { + fetchStoreData(storeId, user.userId) } - }, [storeId, isClient, fetchStoreData]) + }, [storeId, user?.userId, isClient, fetchStoreData]) return { store: currentStore, diff --git a/context/core/storeDataStore.ts b/context/core/storeDataStore.ts index 6404526b..cd8c8ce1 100644 --- a/context/core/storeDataStore.ts +++ b/context/core/storeDataStore.ts @@ -4,7 +4,9 @@ import type { Schema } from '@/amplify/data/resource' import { CONNECTION_STATE_CHANGE, ConnectionState } from 'aws-amplify/data' import { Hub } from 'aws-amplify/utils' -const client = generateClient() +const client = generateClient({ + authMode: 'userPool', +}) type StoreType = Schema['UserStore']['type'] @@ -16,7 +18,7 @@ interface StoreDataState { connectionState: ConnectionState | null hasMasterShopApiKey: boolean setStoreId: (id: string | null) => void - fetchStoreData: (id: string) => Promise + fetchStoreData: (storeId: string, userId: string) => Promise clearStore: () => void setupSubscription: (id: string) => () => void setConnectionState: (state: ConnectionState) => void @@ -35,6 +37,7 @@ Hub.listen('api', (data: any) => { const useStoreDataStore = create((set, get) => ({ currentStore: null, storeId: null, + userId: null, isLoading: true, error: null, connectionState: null, @@ -44,9 +47,9 @@ const useStoreDataStore = create((set, get) => ({ setConnectionState: (state: ConnectionState) => set({ connectionState: state }), - fetchStoreData: async id => { + fetchStoreData: async (storeId, userId) => { // No hacer fetch si ya tenemos los datos de esta tienda - if (get().currentStore && get().storeId === id) { + if (get().currentStore && get().storeId === storeId) { set({ isLoading: false }) return } @@ -54,45 +57,45 @@ const useStoreDataStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { - const { data: stores } = await client.models.UserStore.list({ - authMode: 'userPool', - filter: { storeId: { eq: id } }, - selectionSet: [ - 'id', - 'storeId', - 'storeName', - 'storeLogo', - 'customDomain', - 'contactPhone', - 'contactEmail', - 'storeFavicon', - 'storeTheme', - 'onboardingData', - ], - }) + const { data: store } = await client.models.UserStore.get( + { storeId: storeId }, + { + selectionSet: [ + 'storeId', + 'storeName', + 'storeLogo', + 'customDomain', + 'contactPhone', + 'contactEmail', + 'storeFavicon', + 'storeTheme', + 'onboardingData', + ], + } + ) - if (stores && stores.length > 0) { + if (store) { set({ - currentStore: stores[0] as StoreType, - storeId: id, + currentStore: store as StoreType, + storeId: storeId, isLoading: false, }) // Verificar si existe la API key de Master Shop - const hasMasterShopApiKey = await get().checkMasterShopApiKey(stores[0].id) + const hasMasterShopApiKey = await get().checkMasterShopApiKey(store.storeId) set({ hasMasterShopApiKey }) // Configurar suscripción automáticamente después de obtener los datos - get().setupSubscription(id) + get().setupSubscription(storeId) } else { set({ - error: new Error('Tienda no encontrada'), + error: new Error('Store not found'), isLoading: false, }) } } catch (err) { set({ - error: err instanceof Error ? err : new Error('Error al obtener la tienda'), + error: err instanceof Error ? err : new Error('Error fetching store data'), isLoading: false, }) } @@ -101,14 +104,16 @@ const useStoreDataStore = create((set, get) => ({ // Verificar si existe la API key de Master Shop sin traer su valor checkMasterShopApiKey: async (id: string) => { try { - const { data } = await client.models.UserStore.list({ - filter: { - id: { eq: id }, - mastershopApiKey: { attributeExists: true }, + const { data } = await client.models.UserStore.listUserStoreByUserId( + { + userId: id, }, - selectionSet: ['id'], - authMode: 'userPool', - }) + { + filter: { + mastershopApiKey: { attributeExists: true }, + }, + } + ) const hasApiKey = data && data.length > 0 set({ hasMasterShopApiKey: hasApiKey }) @@ -133,9 +138,7 @@ const useStoreDataStore = create((set, get) => ({ // Usar observeQuery para mantener los datos actualizados automáticamente const subscription = client.models.UserStore.observeQuery({ filter: { storeId: { eq: id } }, - authMode: 'userPool', selectionSet: [ - 'id', 'storeId', 'storeName', 'storeLogo', @@ -157,22 +160,22 @@ const useStoreDataStore = create((set, get) => ({ // Verificar si existe la API key cuando hay cambios if (isSynced) { - get().checkMasterShopApiKey(items[0].id) + get().checkMasterShopApiKey(items[0].storeId) } } else if (isSynced) { // Si no hay elementos después de sincronizar, la tienda podría haber sido eliminada set({ currentStore: null, - error: new Error('Tienda no encontrada o eliminada'), + error: new Error('Store not found or deleted'), isLoading: false, hasMasterShopApiKey: false, }) } }, error: error => { - console.error('Error en la suscripción:', error) + console.error('Error in data subscription:', error) set({ - error: new Error('Error en la suscripción de datos'), + error: new Error('Error in data subscription'), isLoading: false, }) }, diff --git a/context/core/useSubscriptionStore.ts b/context/core/useSubscriptionStore.ts index a49aebd1..de88c974 100644 --- a/context/core/useSubscriptionStore.ts +++ b/context/core/useSubscriptionStore.ts @@ -2,17 +2,12 @@ import { create } from 'zustand' import { generateClient } from 'aws-amplify/data' import { type Schema } from '@/amplify/data/resource' -const client = generateClient() +const client = generateClient({ + authMode: 'userPool', +}) // tipo con solo los campos necesarios -interface MinimalSubscription { - subscriptionId: Schema['UserSubscription']['type']['subscriptionId'] - planName: Schema['UserSubscription']['type']['planName'] - nextPaymentDate: Schema['UserSubscription']['type']['nextPaymentDate'] - lastFourDigits: Schema['UserSubscription']['type']['lastFourDigits'] - pendingPlan: Schema['UserSubscription']['type']['pendingPlan'] - createdAt: string -} +export type MinimalSubscription = Schema['UserSubscription']['type'] interface SubscriptionState { cognitoUsername: string | null @@ -70,17 +65,8 @@ function createResource() { // Función auxiliar para obtener los datos de suscripción async function fetchSubscriptionData(username: string): Promise { try { - const { data, errors } = await client.models.UserSubscription.list({ - filter: { userId: { eq: username } }, - selectionSet: [ - 'subscriptionId', - 'planName', - 'pendingPlan', - 'nextPaymentDate', - 'lastFourDigits', - 'createdAt', - ], - authMode: 'userPool', + const { data, errors } = await client.models.UserSubscription.listUserSubscriptionByUserId({ + userId: username, }) if (errors && errors.length > 0) { @@ -95,14 +81,7 @@ async function fetchSubscriptionData(username: string): Promise