diff --git a/amplify/data/models/user-subscription.ts b/amplify/data/models/user-subscription.ts index e6fd2f7c..3b2054f2 100644 --- a/amplify/data/models/user-subscription.ts +++ b/amplify/data/models/user-subscription.ts @@ -5,28 +5,66 @@ export const userSubscriptionModel = a id: a .id() .required() - .authorization((allow) => [allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete'])]), + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), + ]), userId: a .string() .required() - .authorization((allow) => [allow.ownerDefinedIn('userId').to(['create', 'read', 'delete'])]), + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'delete']), + allow.publicApiKey().to(['create', 'read', 'delete']), + ]), subscriptionId: a .string() .required() - .authorization((allow) => [allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete'])]), + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), + ]), planName: a .string() .required() - .authorization((allow) => [allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete'])]), - nextPaymentDate: a.datetime(), - pendingPlan: a.string(), - pendingStartDate: a.datetime(), - planPrice: a.float(), - lastFourDigits: a.integer(), + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), + ]), + nextPaymentDate: a + .datetime() + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), + ]), + pendingPlan: a + .string() + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), + ]), + pendingStartDate: a + .datetime() + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), + ]), + planPrice: a + .float() + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), + ]), + lastFourDigits: a + .integer() + .authorization((allow) => [ + allow.ownerDefinedIn('userId').to(['create', 'read', 'update', 'delete']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), + ]), }) .identifier(['id']) .secondaryIndexes((index) => [index('userId'), index('subscriptionId'), index('pendingPlan')]) .authorization((allow) => [ allow.ownerDefinedIn('userId').to(['read', 'update', 'delete', 'create']), allow.authenticated().to(['create']), + allow.publicApiKey().to(['read', 'create', 'update', 'delete']), ]); diff --git a/amplify/functions/createSubscription/handler.ts b/amplify/functions/createSubscription/handler.ts index 5cba86ea..942a53ab 100644 --- a/amplify/functions/createSubscription/handler.ts +++ b/amplify/functions/createSubscription/handler.ts @@ -21,8 +21,38 @@ export const handler: APIGatewayProxyHandler = async (event) => { // Inicializar el cliente de Polar const polar = new Polar({ accessToken: env.POLAR_ACCESS_TOKEN, + server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox', }); + // Validación temprana del plan y producto + if (!plan || !plan.polarId || typeof plan.polarId !== 'string') { + return { + statusCode: 400, + headers: getCorsHeaders(origin), + body: JSON.stringify({ + error: 'Invalid request', + details: 'plan.polarId is required', + }), + }; + } + + // Verificar que el producto exista en el entorno actual (sandbox/production) + try { + await polar.products.get({ id: plan.polarId }); + } catch (productErr) { + return { + statusCode: 400, + headers: getCorsHeaders(origin), + body: JSON.stringify({ + error: 'Invalid product', + details: + `The product ${plan.polarId} does not exist in the environment ` + + (process.env.NODE_ENV === 'production' ? 'production' : 'sandbox') + + '. Verify that the ID corresponds to the correct environment.', + }), + }; + } + // Buscar o crear el cliente en Polar let customer; let checkoutUrl = ''; @@ -65,27 +95,61 @@ export const handler: APIGatewayProxyHandler = async (event) => { } } } catch (error) { - // Si no existe, crear un nuevo cliente - customer = await polar.customers.create({ - email: email, - name: name, - externalId: userId, - billingAddress: { - country: 'CO', - }, - }); + // Si no existe, intentar crear un nuevo cliente + try { + customer = await polar.customers.create({ + email: email, + name: name, + externalId: userId, + billingAddress: { + country: 'CO', + }, + }); - // Crear checkout para la suscripción - const checkout = await polar.checkouts.create({ - customerBillingAddress: { - country: 'CO', - }, - customerId: customer.id, - successUrl: 'https://fasttify.com/first-steps', - products: [plan.polarId], - }); + // Crear checkout para la suscripción + const checkout = await polar.checkouts.create({ + customerBillingAddress: { + country: 'CO', + }, + customerId: customer.id, + successUrl: 'https://fasttify.com/first-steps', + products: [plan.polarId], + }); + + checkoutUrl = checkout.url; + } catch (createErr) { + const message = createErr instanceof Error ? createErr.message : String(createErr); + const isDuplicate = + message.includes('already exists') || + message.includes('value_error') || + message.includes('PolarRequestValidationError'); + + if (!isDuplicate) { + throw createErr; + } + + // Si ya existe (email/externalId duplicados), recuperar el cliente existente + customer = await polar.customers.getExternal({ + externalId: userId, + }); + + // Igual que en el caso de cliente existente: verificar suscripción activa + const customerState = await polar.customers.getState({ id: customer.id }); + const hasActiveSubscription = customerState.activeSubscriptions && customerState.activeSubscriptions.length > 0; - checkoutUrl = checkout.url; + if (!hasActiveSubscription) { + const customerCheckout = await polar.checkouts.create({ + customerBillingAddress: { country: 'CO' }, + customerId: customer.id, + products: [plan.polarId], + successUrl: 'https://fasttify.com/first-steps', + }); + checkoutUrl = customerCheckout.url; + } else { + const result = await polar.customerSessions.create({ customerId: customer.id }); + checkoutUrl = result.customerPortalUrl; + } + } } // Siempre devolver código 200 con la URL correspondiente diff --git a/amplify/functions/webHookPlan/services/polar-api.ts b/amplify/functions/webHookPlan/services/polar-api.ts index 98d8c379..a25667a5 100644 --- a/amplify/functions/webHookPlan/services/polar-api.ts +++ b/amplify/functions/webHookPlan/services/polar-api.ts @@ -7,6 +7,7 @@ export class PolarApiService { constructor(accessToken: string) { this.polar = new Polar({ accessToken, + server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox', }); } diff --git a/amplify/functions/webHookPlan/services/polar-payment-processor.ts b/amplify/functions/webHookPlan/services/polar-payment-processor.ts index f1fa6307..7977d3c4 100644 --- a/amplify/functions/webHookPlan/services/polar-payment-processor.ts +++ b/amplify/functions/webHookPlan/services/polar-payment-processor.ts @@ -53,13 +53,26 @@ export class PolarPaymentProcessor implements PaymentProcessor { * Determina el nombre del plan basado en el ID del producto */ private getPlanFromProductId(productId: string): string { - // Mapeo de product_id a nombres de planes - // Esto debe configurarse según tus productos en Polar + // IDs por entorno + const isProd = process.env && process.env.NODE_ENV === 'production'; + + const DEV_ROYAL_ID = 'd889915d-bb1a-4c54-badd-de697857e624'; + const DEV_MAJESTIC_ID = '442aacda-1fa3-47cd-8fba-6ad028285218'; + const DEV_IMPERIAL_ID = '21e675ee-db9d-4cd7-9902-0fead14a85f5'; + + const PROD_ROYAL_ID = 'e02f173f-1ca5-4f7b-a900-2e5c9413d8a6'; + const PROD_MAJESTIC_ID = '149c6595-1611-477d-b0b4-61700d33c069'; + const PROD_IMPERIAL_ID = '3a85e94a-7deb-4f94-8aa4-99a972406f0f'; + + const ROYAL_ID = isProd ? PROD_ROYAL_ID : DEV_ROYAL_ID; + const MAJESTIC_ID = isProd ? PROD_MAJESTIC_ID : DEV_MAJESTIC_ID; + const IMPERIAL_ID = isProd ? PROD_IMPERIAL_ID : DEV_IMPERIAL_ID; + + // Mapeo de product_id a nombres de planes según entorno const productMap: Record = { - 'e02f173f-1ca5-4f7b-a900-2e5c9413d8a6': 'Royal', - '149c6595-1611-477d-b0b4-61700d33c069': 'Majestic', - '3a85e94a-7deb-4f94-8aa4-99a972406f0f': 'Imperial', - // Añadir más mapeos según sea necesario + [ROYAL_ID]: 'Royal', + [MAJESTIC_ID]: 'Majestic', + [IMPERIAL_ID]: 'Imperial', }; return productMap[productId]; diff --git a/amplify/functions/webHookPlan/services/user-service.ts b/amplify/functions/webHookPlan/services/user-service.ts index 2d03e526..6eaa6e3c 100644 --- a/amplify/functions/webHookPlan/services/user-service.ts +++ b/amplify/functions/webHookPlan/services/user-service.ts @@ -169,8 +169,6 @@ export class CognitoUserService implements UserService { * 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 } @@ -201,7 +199,7 @@ export class CognitoUserService implements UserService { planName: subscriptionData.planName, nextPaymentDate: subscriptionData.nextPaymentDate, lastFourDigits: subscriptionData.lastFourDigits, - pendingPlan: null, // Clear pending plan on successful payment + pendingPlan: null, }; if (existingSubscription) { diff --git a/app/(www)/pricing/components/plans.ts b/app/(www)/pricing/components/plans.ts index 7591e133..fbf5c3a2 100644 --- a/app/(www)/pricing/components/plans.ts +++ b/app/(www)/pricing/components/plans.ts @@ -1,6 +1,21 @@ +const isProd = typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production'; + +// IDs por entorno +const DEV_ROYAL_ID = 'd889915d-bb1a-4c54-badd-de697857e624'; +const DEV_MAJESTIC_ID = '442aacda-1fa3-47cd-8fba-6ad028285218'; +const DEV_IMPERIAL_ID = '21e675ee-db9d-4cd7-9902-0fead14a85f5'; + +const PROD_ROYAL_ID = 'e02f173f-1ca5-4f7b-a900-2e5c9413d8a6'; +const PROD_MAJESTIC_ID = '149c6595-1611-477d-b0b4-61700d33c069'; +const PROD_IMPERIAL_ID = '3a85e94a-7deb-4f94-8aa4-99a972406f0f'; + +const ROYAL_ID = isProd ? PROD_ROYAL_ID : DEV_ROYAL_ID; +const MAJESTIC_ID = isProd ? PROD_MAJESTIC_ID : DEV_MAJESTIC_ID; +const IMPERIAL_ID = isProd ? PROD_IMPERIAL_ID : DEV_IMPERIAL_ID; + export const plans = [ { - polarId: 'e02f173f-1ca5-4f7b-a900-2e5c9413d8a6', + polarId: ROYAL_ID, name: 'Royal', title: '$55.000 COP/mes', price: '55000', @@ -18,7 +33,7 @@ export const plans = [ className: 'bg-white', }, { - polarId: '149c6595-1611-477d-b0b4-61700d33c069', + polarId: MAJESTIC_ID, name: 'Majestic', title: '$75.000 COP/mes', price: '75000', @@ -37,7 +52,7 @@ export const plans = [ popular: true, }, { - polarId: '3a85e94a-7deb-4f94-8aa4-99a972406f0f', + polarId: IMPERIAL_ID, name: 'Imperial', title: '$100.000 COP/mes', price: '100000', @@ -56,3 +71,53 @@ export const plans = [ className: 'bg-white', }, ]; + +export const planFaqs = [ + { + question: '¿Qué es Fasttify y cómo funciona?', + paragraphs: [ + 'Fasttify es una plataforma integral de comercio para que emprendedores y empresas inicien, administren y hagan crecer su negocio en línea, en tienda física y en cualquier canal digital.', + 'Estas son algunas de las cosas que puedes hacer con Fasttify:', + ], + bullets: [ + 'Crear y personalizar una tienda online.', + 'Vender en web, móvil, redes sociales y tienda física.', + 'Gestionar productos, inventario, pagos y envíos.', + 'Crear, ejecutar y analizar campañas de marketing.', + ], + }, + { + question: '¿Cuánto cuesta Fasttify?', + paragraphs: [ + 'Ofrecemos planes desde $55.000 COP/mes. Puedes elegir el plan que mejor se adapte a tu negocio y cambiar cuando lo necesites.', + ], + }, + { + question: '¿Cuál es la duración de los contratos?', + paragraphs: ['La suscripción es mensual y flexible; no hay contratos a largo plazo.'], + }, + { + question: '¿Puedo cancelar mi cuenta en cualquier momento?', + paragraphs: ['Sí. Puedes cancelar o cambiar de plan cuando quieras desde la configuración de tu tienda.'], + }, + { + question: '¿Puedo cambiar mi plan más adelante?', + paragraphs: ['Sí. Puedes escalar o reducir tu plan en cualquier momento según tus necesidades.'], + }, + { + question: '¿Ofrecen descuentos?', + paragraphs: ['Periódicamente ofrecemos promociones. Si necesitas facturación anual o por volumen, contáctanos.'], + }, + { + question: '¿En qué países puedo usar Fasttify?', + paragraphs: [ + 'Fasttify funciona en la mayoría de países. La disponibilidad de métodos de pago puede variar por región.', + ], + }, + { + question: '¿Fasttify es compatible con PCI o está certificado PCI?', + paragraphs: [ + 'Sí. Cumplimos con estándares de seguridad y buenas prácticas para el manejo de pagos y datos sensibles.', + ], + }, +]; diff --git a/app/api/_lib/polar/repositories/subscription.repository.ts b/app/api/_lib/polar/repositories/subscription.repository.ts new file mode 100644 index 00000000..b5eebb29 --- /dev/null +++ b/app/api/_lib/polar/repositories/subscription.repository.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import { UserSubscriptionData, PlanType } from '@/app/api/_lib/polar/types'; + +/** + * Repositorio para operaciones de suscripciones en DynamoDB + * Implementa Clean Architecture separando infraestructura de lógica de negocio + */ +export class SubscriptionRepository { + /** + * Busca una suscripción por ID de usuario + */ + async findByUserId(userId: string): Promise { + try { + const response = await cookiesClient.models.UserSubscription.get({ + id: userId, + }); + + return response.data as unknown as UserSubscriptionData | null; + } catch (error) { + console.error(`Error finding subscription for user ${userId}:`, error); + return null; + } + } + + /** + * Crea una nueva suscripción + */ + async create(data: UserSubscriptionData): Promise { + try { + const subscriptionPayload = { + id: data.userId, + userId: data.userId, + subscriptionId: data.subscriptionId, + planName: data.planName, + nextPaymentDate: data.nextPaymentDate, + lastFourDigits: data.lastFourDigits, + pendingPlan: data.pendingPlan, + }; + + const response = await cookiesClient.models.UserSubscription.create(subscriptionPayload); + + if (!response.data) { + throw new Error('Failed to create subscription'); + } + + return response.data as unknown as UserSubscriptionData; + } catch (error) { + console.error(`Error creating subscription for user ${data.userId}:`, error); + throw new Error(`Failed to create subscription: ${data.userId}`); + } + } + + /** + * Actualiza una suscripción existente + */ + async update(userId: string, data: Partial): Promise { + try { + const updatePayload = { + id: userId, + ...data, + }; + + const response = await cookiesClient.models.UserSubscription.update(updatePayload); + + if (!response.data) { + throw new Error('Failed to update subscription'); + } + + return response.data as unknown as UserSubscriptionData; + } catch (error) { + console.error(`Error updating subscription for user ${userId}:`, error); + throw new Error(`Failed to update subscription: ${userId}`); + } + } + + /** + * Crea o actualiza una suscripción (upsert) + */ + async upsert(data: UserSubscriptionData): Promise { + try { + const existingSubscription = await this.findByUserId(data.userId); + + if (existingSubscription) { + return await this.update(data.userId, data); + } else { + return await this.create(data); + } + } catch (error) { + console.error(`Error upserting subscription for user ${data.userId}:`, error); + throw new Error(`Failed to upsert subscription: ${data.userId}`); + } + } + + /** + * Elimina una suscripción + */ + async delete(userId: string): Promise { + try { + await cookiesClient.models.UserSubscription.delete({ + id: userId, + }); + } catch (error) { + console.error(`Error deleting subscription for user ${userId}:`, error); + throw new Error(`Failed to delete subscription: ${userId}`); + } + } + + /** + * Actualiza el plan pendiente de un usuario + */ + async updatePendingPlan(userId: string, pendingPlan: PlanType | null): Promise { + try { + await this.update(userId, { pendingPlan }); + } catch (error) { + console.error(`Error updating pending plan for user ${userId}:`, error); + throw new Error(`Failed to update pending plan: ${userId}`); + } + } + + /** + * Verifica si un usuario tiene una suscripción activa + */ + async hasActiveSubscription(userId: string): Promise { + try { + const subscription = await this.findByUserId(userId); + return subscription !== null && subscription.planName !== PlanType.FREE; + } catch (error) { + console.error(`Error checking active subscription for user ${userId}:`, error); + return false; + } + } + + /** + * Obtiene todas las suscripciones de un usuario (si hubiera múltiples) + */ + async findAllByUserId(userId: string): Promise { + try { + const response = await cookiesClient.models.UserSubscription.listUserSubscriptionByUserId({ + userId: userId, + }); + + return response.data as unknown as UserSubscriptionData[]; + } catch (error) { + console.error(`Error finding all subscriptions for user ${userId}:`, error); + return []; + } + } +} diff --git a/app/api/_lib/polar/repositories/user.repository.ts b/app/api/_lib/polar/repositories/user.repository.ts new file mode 100644 index 00000000..91c9fd19 --- /dev/null +++ b/app/api/_lib/polar/repositories/user.repository.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CognitoIdentityProviderClient, + AdminGetUserCommand, + AdminUpdateUserAttributesCommand, + AttributeType, +} from '@aws-sdk/client-cognito-identity-provider'; +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import { PlanType } from '@/app/api/_lib/polar/types'; + +/** + * Repositorio para operaciones de usuario en Cognito y DynamoDB + * Implementa Clean Architecture separando infraestructura de lógica de negocio + */ +export class UserRepository { + private readonly cognitoClient: CognitoIdentityProviderClient; + private readonly userPoolId: string; + + constructor(userPoolId: string) { + this.cognitoClient = new CognitoIdentityProviderClient(); + this.userPoolId = userPoolId; + } + + /** + * Obtiene un usuario de Cognito por su ID + */ + async getUserById(userId: string): Promise { + try { + const command = new AdminGetUserCommand({ + UserPoolId: this.userPoolId, + Username: userId, + }); + + const response = await this.cognitoClient.send(command); + return response.UserAttributes || null; + } catch (error) { + console.error(`Error fetching user ${userId}:`, error); + return null; + } + } + + /** + * Actualiza el plan del usuario en Cognito + */ + async updateUserPlan(userId: string, plan: PlanType): Promise { + try { + const command = new AdminUpdateUserAttributesCommand({ + UserPoolId: this.userPoolId, + Username: userId, + UserAttributes: [ + { + Name: 'custom:plan', + Value: plan, + }, + ], + }); + + await this.cognitoClient.send(command); + } catch (error) { + console.error(`Error updating user plan for ${userId}:`, error); + throw new Error(`Failed to update user plan: ${userId}`); + } + } + + /** + * Actualiza el estado de todas las tiendas del usuario + */ + async updateStoresStatus(userId: string, isActive: boolean): Promise { + try { + // Obtener todas las tiendas del usuario + const userStoresResponse = await cookiesClient.models.UserStore.listUserStoreByUserId({ + userId: userId, + }); + + const userStores = userStoresResponse.data || []; + + // Actualizar el estado de cada tienda + const updatePromises = userStores.map((store) => + cookiesClient.models.UserStore.update({ + storeId: store.storeId, + storeStatus: isActive, + }).catch((storeUpdateError) => { + console.error(`Error updating store ${store.storeId} status:`, storeUpdateError); + return null; // No fallar toda la operación por un store + }) + ); + + await Promise.all(updatePromises); + } catch (error) { + console.error(`Error updating stores status for user ${userId}:`, error); + throw new Error(`Failed to update stores status: ${userId}`); + } + } + + /** + * Obtiene el plan actual del usuario desde sus atributos + */ + getCurrentPlanFromAttributes(userAttributes: AttributeType[] = []): PlanType { + const planAttribute = userAttributes.find((attr) => attr.Name === 'custom:plan'); + const planValue = planAttribute?.Value || 'Free'; + + // Validar que el plan sea uno de los tipos válidos + const validPlans = Object.values(PlanType); + return validPlans.includes(planValue as PlanType) ? (planValue as PlanType) : PlanType.FREE; + } + + /** + * Verifica si el usuario tiene un plan pagado + */ + isPaidPlan(plan: PlanType): boolean { + return plan !== PlanType.FREE; + } +} diff --git a/app/api/_lib/polar/services/polar.service.ts b/app/api/_lib/polar/services/polar.service.ts new file mode 100644 index 00000000..d443c13a --- /dev/null +++ b/app/api/_lib/polar/services/polar.service.ts @@ -0,0 +1,182 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Polar } from '@polar-sh/sdk'; +import { PolarSubscription, PolarCustomer, SubscriptionStatus } from '@/app/api/_lib/polar/types'; + +/** + * Servicio para interactuar con la API de Polar + * Implementa Clean Architecture como capa de infraestructura + */ +export class PolarService { + private readonly polar: Polar; + + constructor(accessToken: string) { + this.polar = new Polar({ + accessToken, + server: this.getServerEnvironment(), + }); + } + + /** + * Obtiene el entorno del servidor (sandbox/production) + */ + private getServerEnvironment(): 'sandbox' | 'production' { + return process.env.NODE_ENV === 'production' ? 'production' : 'sandbox'; + } + + /** + * Obtiene una suscripción por ID + */ + async getSubscription(subscriptionId: string): Promise { + try { + const response = await this.polar.subscriptions.get({ + id: subscriptionId, + }); + + if (!response) { + console.warn(`Subscription not found: ${subscriptionId}`); + return null; + } + + return this.mapToPolarSubscription(response); + } catch (error) { + console.error(`Error fetching subscription ${subscriptionId}:`, error); + return null; + } + } + + /** + * Obtiene un cliente por ID + */ + async getCustomer(customerId: string): Promise { + try { + const response = await this.polar.customers.get({ + id: customerId, + }); + + if (!response) { + console.warn(`Customer not found: ${customerId}`); + return null; + } + + return this.mapToPolarCustomer(response); + } catch (error) { + console.error(`Error fetching customer ${customerId}:`, error); + return null; + } + } + + /** + * Obtiene un cliente por external ID + */ + async getCustomerByExternalId(externalId: string): Promise { + try { + const response = await this.polar.customers.getExternal({ + externalId: externalId, + }); + + if (!response) { + console.warn(`Customer not found with external ID: ${externalId}`); + return null; + } + + return this.mapToPolarCustomer(response); + } catch (error) { + console.error(`Error fetching customer by external ID ${externalId}:`, error); + return null; + } + } + + /** + * Obtiene el estado completo de un cliente (incluye suscripciones activas) + */ + async getCustomerState(customerId: string): Promise { + try { + const response = await this.polar.customers.getState({ + id: customerId, + }); + + return response; + } catch (error) { + console.error(`Error fetching customer state ${customerId}:`, error); + return null; + } + } + + /** + * Verifica si un cliente tiene suscripciones activas + */ + async hasActiveSubscriptions(customerId: string): Promise { + try { + const customerState = await this.getCustomerState(customerId); + + if (!customerState) { + return false; + } + + const activeSubscriptions = customerState.activeSubscriptions || []; + return activeSubscriptions.length > 0; + } catch (error) { + console.error(`Error checking active subscriptions for customer ${customerId}:`, error); + return false; + } + } + + /** + * Mapea la respuesta de Polar a nuestro tipo PolarSubscription + */ + private mapToPolarSubscription(polarResponse: any): PolarSubscription { + return { + id: polarResponse.id, + status: polarResponse.status as SubscriptionStatus, + customerId: polarResponse.customer?.id || '', + customerExternalId: polarResponse.customer?.externalId || '', + productId: polarResponse.productId || '', + amount: polarResponse.amount || 0, + currentPeriodEnd: polarResponse.currentPeriodEnd ? new Date(polarResponse.currentPeriodEnd) : new Date(), + cancelAtPeriodEnd: polarResponse.cancelAtPeriodEnd || false, + }; + } + + /** + * Mapea la respuesta de Polar a nuestro tipo PolarCustomer + */ + private mapToPolarCustomer(polarResponse: any): PolarCustomer { + return { + id: polarResponse.id, + email: polarResponse.email || '', + name: polarResponse.name || '', + externalId: polarResponse.externalId || '', + }; + } + + /** + * Verifica si una suscripción está activa + */ + isSubscriptionActive(subscription: PolarSubscription): boolean { + return subscription.status === SubscriptionStatus.ACTIVE; + } + + /** + * Verifica si una suscripción está cancelada + */ + isSubscriptionCanceled(subscription: PolarSubscription): boolean { + return [SubscriptionStatus.CANCELED, SubscriptionStatus.INCOMPLETE_EXPIRED, SubscriptionStatus.UNPAID].includes( + subscription.status + ); + } +} diff --git a/app/api/_lib/polar/services/subscription.service.ts b/app/api/_lib/polar/services/subscription.service.ts new file mode 100644 index 00000000..546de4a8 --- /dev/null +++ b/app/api/_lib/polar/services/subscription.service.ts @@ -0,0 +1,239 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UserRepository } from '@/app/api/_lib/polar/repositories/user.repository'; +import { SubscriptionRepository } from '@/app/api/_lib/polar/repositories/subscription.repository'; +import { PolarService } from '@/app/api/_lib/polar/services/polar.service'; +import { PlanType, SubscriptionProcessResult, ProductConfig, ProductPlanMapping } from '@/app/api/_lib/polar/types'; + +/** + * Servicio de aplicación para lógica de negocio de suscripciones + * Implementa Clean Architecture como capa de aplicación + * Coordina repositorios y servicios de infraestructura + */ +export class SubscriptionService { + private readonly productConfig: ProductConfig; + + constructor( + private readonly userRepository: UserRepository, + private readonly subscriptionRepository: SubscriptionRepository, + private readonly polarService: PolarService + ) { + this.productConfig = this.initializeProductConfig(); + } + + /** + * Procesa una actualización de suscripción desde webhook de Polar + */ + async processSubscriptionUpdate(subscriptionId: string): Promise { + try { + console.log(`Processing subscription update for: ${subscriptionId}`); + + const polarSubscription = await this.polarService.getSubscription(subscriptionId); + + if (!polarSubscription) { + return { + success: false, + userId: '', + plan: PlanType.FREE, + message: `Subscription not found: ${subscriptionId}`, + }; + } + + const userId = polarSubscription.customerExternalId; + + if (!userId) { + return { + success: false, + userId: '', + plan: PlanType.FREE, + message: 'No user ID found in subscription', + }; + } + + // Determinar acción basada en el estado de la suscripción + if (this.polarService.isSubscriptionActive(polarSubscription)) { + return await this.activateSubscription(userId, polarSubscription.productId); + } else if (this.polarService.isSubscriptionCanceled(polarSubscription)) { + return await this.cancelSubscription(userId); + } + + return { + success: true, + userId, + plan: PlanType.FREE, + message: 'Subscription processed successfully', + }; + } catch (error) { + console.error(`Error processing subscription update ${subscriptionId}:`, error); + return { + success: false, + userId: '', + plan: PlanType.FREE, + message: `Error processing subscription: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Activa una suscripción para un usuario + */ + async activateSubscription(userId: string, productId: string): Promise { + try { + const plan = this.mapProductIdToPlan(productId); + + if (plan === PlanType.FREE) { + return { + success: false, + userId, + plan: PlanType.FREE, + message: `Invalid product ID: ${productId}`, + }; + } + + // Actualizar plan en Cognito + await this.userRepository.updateUserPlan(userId, plan); + + // Activar tiendas del usuario + await this.userRepository.updateStoresStatus(userId, true); + + // Crear o actualizar registro de suscripción + await this.subscriptionRepository.upsert({ + id: userId, + userId, + subscriptionId: '', // Se actualizará cuando tengamos el ID real + planName: plan, + pendingPlan: null, + }); + + console.log(`Successfully activated subscription for user ${userId} with plan ${plan}`); + + return { + success: true, + userId, + plan, + message: `Subscription activated with plan ${plan}`, + }; + } catch (error) { + console.error(`Error activating subscription for user ${userId}:`, error); + return { + success: false, + userId, + plan: PlanType.FREE, + message: `Error activating subscription: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Cancela/downgrade una suscripción de un usuario + */ + async cancelSubscription(userId: string): Promise { + try { + // Downgrade a plan gratuito + await this.userRepository.updateUserPlan(userId, PlanType.FREE); + + // Desactivar tiendas del usuario + await this.userRepository.updateStoresStatus(userId, false); + + // Actualizar registro de suscripción + const existingSubscription = await this.subscriptionRepository.findByUserId(userId); + if (existingSubscription) { + await this.subscriptionRepository.update(userId, { + planName: PlanType.FREE, + pendingPlan: null, + }); + } + + console.log(`Successfully canceled subscription for user ${userId}`); + + return { + success: true, + userId, + plan: PlanType.FREE, + message: 'Subscription canceled successfully', + }; + } catch (error) { + console.error(`Error canceling subscription for user ${userId}:`, error); + return { + success: false, + userId, + plan: PlanType.FREE, + message: `Error canceling subscription: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Mapea un ID de producto de Polar a un tipo de plan + */ + mapProductIdToPlan(productId: string): PlanType { + const isProduction = process.env.NODE_ENV === 'production'; + const environment = isProduction ? 'production' : 'development'; + const config = this.productConfig[environment]; + + const productPlanMapping: ProductPlanMapping = { + [config.royal]: PlanType.ROYAL, + [config.majestic]: PlanType.MAJESTIC, + [config.imperial]: PlanType.IMPERIAL, + }; + + return productPlanMapping[productId] || PlanType.FREE; + } + + /** + * Obtiene el plan actual de un usuario + */ + async getUserPlan(userId: string): Promise { + try { + const userAttributes = await this.userRepository.getUserById(userId); + if (!userAttributes) { + return PlanType.FREE; + } + + return this.userRepository.getCurrentPlanFromAttributes(userAttributes); + } catch (error) { + console.error(`Error getting user plan for ${userId}:`, error); + return PlanType.FREE; + } + } + + /** + * Verifica si un usuario tiene un plan pagado + */ + async hasPaidPlan(userId: string): Promise { + const plan = await this.getUserPlan(userId); + return this.userRepository.isPaidPlan(plan); + } + + /** + * Inicializa la configuración de productos por entorno + */ + private initializeProductConfig(): ProductConfig { + return { + development: { + royal: 'd889915d-bb1a-4c54-badd-de697857e624', + majestic: '442aacda-1fa3-47cd-8fba-6ad028285218', + imperial: '21e675ee-db9d-4cd7-9902-0fead14a85f5', + }, + production: { + royal: 'e02f173f-1ca5-4f7b-a900-2e5c9413d8a6', + majestic: '149c6595-1611-477d-b0b4-61700d33c069', + imperial: '3a85e94a-7deb-4f94-8aa4-99a972406f0f', + }, + }; + } +} diff --git a/app/api/_lib/polar/types/index.ts b/app/api/_lib/polar/types/index.ts new file mode 100644 index 00000000..967f10a7 --- /dev/null +++ b/app/api/_lib/polar/types/index.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tipos de planes disponibles + */ +export enum PlanType { + ROYAL = 'Royal', + MAJESTIC = 'Majestic', + IMPERIAL = 'Imperial', + FREE = 'Free', +} + +/** + * Estado de una suscripción en Polar + */ +export enum SubscriptionStatus { + ACTIVE = 'active', + CANCELED = 'canceled', + INCOMPLETE = 'incomplete', + INCOMPLETE_EXPIRED = 'incomplete_expired', + PAST_DUE = 'past_due', + UNPAID = 'unpaid', + TRIALING = 'trialing', +} + +/** + * Datos de suscripción de Polar + */ +export interface PolarSubscription { + id: string; + status: SubscriptionStatus; + customerId: string; + customerExternalId: string; + productId: string; + amount: number; + currentPeriodEnd: Date; + cancelAtPeriodEnd: boolean; +} + +/** + * Datos de cliente de Polar + */ +export interface PolarCustomer { + id: string; + email: string; + name: string; + externalId: string; +} + +/** + * Datos de suscripción del usuario en nuestro sistema + */ +export interface UserSubscriptionData { + id: string; + userId: string; + subscriptionId: string; + planName: PlanType; + nextPaymentDate?: string; + lastFourDigits?: number; + pendingPlan?: PlanType | null; +} + +/** + * Parámetros para crear checkout + */ +export interface CheckoutParams { + productId: string; + userId: string; + email: string; + name: string; +} + +/** + * Resultado de procesamiento de suscripción + */ +export interface SubscriptionProcessResult { + success: boolean; + userId: string; + plan: PlanType; + message?: string; +} + +/** + * Configuración de productos por entorno + */ +export interface ProductConfig { + development: { + royal: string; + majestic: string; + imperial: string; + }; + production: { + royal: string; + majestic: string; + imperial: string; + }; +} + +/** + * Mapeo de ID de producto a tipo de plan + */ +export interface ProductPlanMapping { + [productId: string]: PlanType; +} diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 00000000..8ef5d96f --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,40 @@ +import { Checkout } from '@polar-sh/nextjs'; +import { NextRequest, NextResponse } from 'next/server'; +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { AuthGetCurrentUserServer } from '@/utils/client/AmplifyUtils'; + +/** + * API Route para checkout usando adaptador de Polar + * Requiere autenticación para acceder al checkout + */ + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} + +export async function GET(request: NextRequest) { + try { + // Verificar autenticación directamente + const session = await AuthGetCurrentUserServer(); + if (!session) { + const corsHeaders = await getNextCorsHeaders(request); + return NextResponse.json({ error: 'Authentication required' }, { status: 401, headers: corsHeaders }); + } + + // Crear el adaptador de checkout con autenticación verificada + const checkoutHandler = Checkout({ + accessToken: process.env.POLAR_ACCESS_TOKEN || '', + successUrl: process.env.SUCCESS_URL || 'https://fasttify.com/first-steps', + server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox', + theme: 'dark', + }); + + // Ejecutar el handler del adaptador + return await checkoutHandler(request); + } catch (error) { + console.error('Error in checkout route:', error); + const corsHeaders = await getNextCorsHeaders(request); + return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: corsHeaders }); + } +} diff --git a/app/api/portal/route.ts b/app/api/portal/route.ts new file mode 100644 index 00000000..12339590 --- /dev/null +++ b/app/api/portal/route.ts @@ -0,0 +1,54 @@ +import { CustomerPortal } from '@polar-sh/nextjs'; +import { NextRequest } from 'next/server'; +import { PolarService } from '@/app/api/_lib/polar/services/polar.service'; +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { AuthGetCurrentUserServer } from '@/utils/client/AmplifyUtils'; + +/** + * API Route para customer portal usando adaptador de Polar + * Requiere autenticación para acceder al portal del cliente + */ + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} + +export const GET = CustomerPortal({ + accessToken: process.env.POLAR_ACCESS_TOKEN || '', + getCustomerId: async (_req: NextRequest) => { + try { + // Verificar autenticación directamente + const session = await AuthGetCurrentUserServer(); + if (!session) { + throw new Error('Authentication required'); + } + + const externalId = session.username; + + if (!externalId) { + throw new Error('User ID not found in session'); + } + + // Obtener el customer ID real de Polar usando external ID + const accessToken = process.env.POLAR_ACCESS_TOKEN; + if (!accessToken) { + throw new Error('POLAR_ACCESS_TOKEN not configured'); + } + + const polarService = new PolarService(accessToken); + const customer = await polarService.getCustomerByExternalId(externalId); + + if (!customer) { + throw new Error(`Customer not found in Polar for external ID: ${externalId}`); + } + + // Retornar el customer ID real (UUID) de Polar + return customer.id; + } catch (error) { + console.error('Error getting customer ID:', error); + throw new Error('Error getting customer ID'); + } + }, + server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox', +}); diff --git a/app/api/webhooks/polar/route.ts b/app/api/webhooks/polar/route.ts new file mode 100644 index 00000000..674f8a7b --- /dev/null +++ b/app/api/webhooks/polar/route.ts @@ -0,0 +1,87 @@ +import { Webhooks } from '@polar-sh/nextjs'; +import { NextRequest } from 'next/server'; +import { UserRepository } from '@/app/api/_lib/polar/repositories/user.repository'; +import { SubscriptionRepository } from '@/app/api/_lib/polar/repositories/subscription.repository'; +import { PolarService } from '@/app/api/_lib/polar/services/polar.service'; +import { SubscriptionService } from '@/app/api/_lib/polar/services/subscription.service'; +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; + +/** + * API Route para webhooks de Polar + * Procesa eventos de suscripciones y órdenes automáticamente + */ +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} + +export const POST = Webhooks({ + webhookSecret: process.env.POLAR_WEBHOOK_SECRET || '', + + onSubscriptionCreated: async (payload) => { + console.log('Subscription created:', payload.id); + await processSubscriptionEvent(payload.id); + }, + + onSubscriptionUpdated: async (payload) => { + console.log('Subscription updated:', payload.id); + await processSubscriptionEvent(payload.id); + }, + + onSubscriptionActive: async (payload) => { + console.log('Subscription active:', payload.id); + await processSubscriptionEvent(payload.id); + }, + + onSubscriptionCanceled: async (payload) => { + console.log('Subscription canceled:', payload.id); + await processSubscriptionEvent(payload.id); + }, + + onSubscriptionRevoked: async (payload) => { + console.log('Subscription revoked:', payload.id); + await processSubscriptionEvent(payload.id); + }, + + onOrderCreated: async (payload) => { + console.log('Order created:', payload.id); + + // Si el pago está asociado a una suscripción, procesar la suscripción + if (payload.subscription_id) { + await processSubscriptionEvent(payload.subscription_id); + } + }, +}); + +/** + * Procesa un evento de suscripción usando los servicios de la aplicación + */ +async function processSubscriptionEvent(subscriptionId: string): Promise { + try { + // Validar variables de entorno + const userPoolId = process.env.USER_POOL_ID; + const accessToken = process.env.POLAR_ACCESS_TOKEN; + + if (!userPoolId || !accessToken) { + console.error('Missing required environment variables: USER_POOL_ID or POLAR_ACCESS_TOKEN'); + return; + } + + // Inicializar servicios + const userRepository = new UserRepository(userPoolId); + const subscriptionRepository = new SubscriptionRepository(); + const polarService = new PolarService(accessToken); + const subscriptionService = new SubscriptionService(userRepository, subscriptionRepository, polarService); + + // Procesar actualización de suscripción + const result = await subscriptionService.processSubscriptionUpdate(subscriptionId); + + if (result.success) { + console.log(`Successfully processed subscription ${subscriptionId}:`, result.message); + } else { + console.error(`Failed to process subscription ${subscriptionId}:`, result.message); + } + } catch (error) { + console.error(`Error processing subscription event ${subscriptionId}:`, error); + } +} diff --git a/app/store/[slug]/suscribe/select-plan/SelectPlanClient.tsx b/app/store/[slug]/suscribe/select-plan/SelectPlanClient.tsx new file mode 100644 index 00000000..451e46c2 --- /dev/null +++ b/app/store/[slug]/suscribe/select-plan/SelectPlanClient.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { BlockStack, Box, Button, Card, InlineStack, Layout, Page, Text, Collapsible } from '@shopify/polaris'; +import { Icon } from '@shopify/polaris'; +import { CheckIcon } from '@shopify/polaris-icons'; +import { plans, planFaqs } from '@/app/(www)/pricing/components/plans'; +import type { Plan } from '@/app/store/components/select-plan/domain/plan'; +import { usePlanCheckout } from '@/app/store/components/select-plan/application/usePlanCheckout'; +import PlanCard from './components/PlanCard'; +import { motion, useReducedMotion } from 'framer-motion'; +import { routes } from '@/utils/client/routes'; +import { getStoreId } from '@/utils/client/store-utils'; +import { useParams, usePathname, useRouter } from 'next/navigation'; + +export default function SelectPlanClient() { + const { isSubmitting, subscribe } = usePlanCheckout(); + const router = useRouter(); + const params = useParams(); + const pathname = usePathname(); + const storeId = getStoreId(params, pathname); + + const typedPlans: Plan[] = useMemo(() => plans as unknown as Plan[], []); + const [openFaqs, setOpenFaqs] = useState>({}); + const [contentMinHeight, setContentMinHeight] = useState(0); + const prefersReducedMotion = useReducedMotion(); + + const handleMeasure = (height: number) => { + setContentMinHeight((prev) => (height > prev ? height : prev)); + }; + + const toggleFaq = (index: number) => { + setOpenFaqs((prev) => ({ ...prev, [index]: !prev[index] })); + }; + + return ( + + + + + + + Elige el plan para tu tienda + + + + +
+ + + + + Todo lo que necesitas para gestionar tu negocio + + + + {[ + 'Venta multicanal: web, móvil y redes sociales', + 'Soporte 24/7 por chat', + 'Integraciones y API para conectar servicios', + ].map((item) => ( + + + + {item} + + + ))} + + + +
+ + + {typedPlans.map((plan) => ( + + + + ))} + + + + + + + Preguntas frecuentes + + +
+ {planFaqs.map((faq, index) => ( +
+ + +
+ {faq.paragraphs?.map((p) => ( + + {p} + + ))} + {faq.bullets && faq.bullets.length > 0 && ( +
    + {faq.bullets.map((b) => ( +
  • + + {b} + +
  • + ))} +
+ )} +
+
+
+ ))} +
+
+
+
+ + + + +
+
+
+ ); +} diff --git a/app/store/[slug]/suscribe/select-plan/components/PlanCard.tsx b/app/store/[slug]/suscribe/select-plan/components/PlanCard.tsx new file mode 100644 index 00000000..64824b6f --- /dev/null +++ b/app/store/[slug]/suscribe/select-plan/components/PlanCard.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { BlockStack, Box, Button, Card, Divider, List, Text } from '@shopify/polaris'; +import { useLayoutEffect, useRef } from 'react'; +import type { Plan } from '@/app/store/components/select-plan/domain/plan'; + +interface Props { + plan: Plan; + loading?: boolean; + onSubscribe: (plan: Plan) => void; + contentMinHeight?: number; + onMeasure?: (height: number) => void; +} + +export default function PlanCard({ plan, onSubscribe, loading, contentMinHeight = 0, onMeasure }: Props) { + const contentRef = useRef(null); + + useLayoutEffect(() => { + if (!contentRef.current) return; + const height = contentRef.current.offsetHeight; + if (onMeasure) onMeasure(height); + }, [plan, onMeasure]); + + return ( + + +
+
+ + + + {plan.name} + + + + + {plan.title} + + + {plan.description} + + + + + + {plan.features.map((feature) => ( + {feature} + ))} + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/app/store/[slug]/suscribe/select-plan/page.tsx b/app/store/[slug]/suscribe/select-plan/page.tsx new file mode 100644 index 00000000..16190682 --- /dev/null +++ b/app/store/[slug]/suscribe/select-plan/page.tsx @@ -0,0 +1,12 @@ +import SelectPlanClient from './SelectPlanClient'; + +export async function generateMetadata({ params: _params }: { params: Promise<{ slug: string }> }) { + return { + title: 'Selección de plan - Admin Panel', + description: 'Selecciona el plan que deseas suscribirte', + }; +} + +export default async function Page({ params: _params }: { params: Promise<{ slug: string }> }) { + return ; +} diff --git a/app/store/components/ai-chat/components/ChatTrigger.tsx b/app/store/components/ai-chat/components/ChatTrigger.tsx index 80e3e37e..240bc550 100644 --- a/app/store/components/ai-chat/components/ChatTrigger.tsx +++ b/app/store/components/ai-chat/components/ChatTrigger.tsx @@ -4,19 +4,23 @@ import { useCallback } from 'react'; import { TopBar, Icon } from '@shopify/polaris'; import { MagicIcon } from '@shopify/polaris-icons'; import { useChatContext } from '@/app/store/components/ai-chat/context/ChatContext'; +import { useChatMobileDetection } from '@/app/store/components/ai-chat/hooks/useMobileDetection'; export function ChatTrigger() { const { isOpen, setIsOpen } = useChatContext(); + const { renderOnDesktopOnly, preventMobileAction } = useChatMobileDetection(setIsOpen); const handleMenuToggle = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen, setIsOpen]); + preventMobileAction(() => { + setIsOpen(!isOpen); + }); + }, [isOpen, setIsOpen, preventMobileAction]); const handleMenuClose = useCallback(() => { setIsOpen(false); }, [setIsOpen]); - return ( + return renderOnDesktopOnly( } open={isOpen} diff --git a/app/store/components/ai-chat/components/RefinedAiAssistant.tsx b/app/store/components/ai-chat/components/RefinedAiAssistant.tsx index 2632b753..a142f72f 100644 --- a/app/store/components/ai-chat/components/RefinedAiAssistant.tsx +++ b/app/store/components/ai-chat/components/RefinedAiAssistant.tsx @@ -8,10 +8,18 @@ import { useSimpleChat } from '@/app/store/components/ai-chat/hooks/useSimpleCha import { RefinedAIAssistantSheetProps } from '@/app/store/components/ai-chat/types/chat-types'; import { Box, Scrollable, Spinner } from '@shopify/polaris'; import { useCallback, useEffect, useRef } from 'react'; +import { useMobileDetection } from '@/app/store/components/ai-chat/hooks/useMobileDetection'; export function RefinedAIAssistantSheet({ open, onOpenChange }: RefinedAIAssistantSheetProps) { const messagesEndRef = useRef(null); + // Usar el hook de detección móvil + const { renderOnDesktopOnly } = useMobileDetection(() => { + if (open) { + onOpenChange(false); + } + }, true); + // Usar el hook de conversación AI const { messages, @@ -99,7 +107,7 @@ export function RefinedAIAssistantSheet({ open, onOpenChange }: RefinedAIAssista if (!open) return null; - return ( + return renderOnDesktopOnly(
(undefined); export function ChatProvider({ children }: { children: ReactNode }) { - // Inicializar con el valor de sessionStorage si existe - const [isOpen, setIsOpen] = useState(() => { + const [internalIsOpen, setInternalIsOpen] = useState(() => { if (typeof window !== 'undefined') { const saved = sessionStorage.getItem('chat-is-open'); return saved === 'true'; @@ -19,14 +19,30 @@ export function ChatProvider({ children }: { children: ReactNode }) { return false; }); + // Usar el hook de detección móvil + const { preventMobileAction } = useMobileDetection(() => { + if (internalIsOpen) { + setInternalIsOpen(false); + } + }, true); + // Persistir el estado en sessionStorage cuando cambie useEffect(() => { if (typeof window !== 'undefined') { - sessionStorage.setItem('chat-is-open', isOpen.toString()); + sessionStorage.setItem('chat-is-open', internalIsOpen.toString()); } - }, [isOpen]); + }, [internalIsOpen]); + + // Función personalizada para setIsOpen que previene abrir en móvil + const setIsOpenSafe = (open: boolean) => { + preventMobileAction(() => { + setInternalIsOpen(open); + }); + }; - return {children}; + return ( + {children} + ); } export function useChatContext() { diff --git a/app/store/components/ai-chat/hooks/index.ts b/app/store/components/ai-chat/hooks/index.ts index 3dc4255a..a24e4efb 100644 --- a/app/store/components/ai-chat/hooks/index.ts +++ b/app/store/components/ai-chat/hooks/index.ts @@ -10,6 +10,9 @@ export { useSimpleChat } from './useSimpleChat'; // Hooks de historial de conversaciones export { useCurrentConversation } from './useCurrentConversation'; +// Hooks de detección móvil +export { useMobileDetection, useChatMobileDetection } from './useMobileDetection'; + // Hook existente (mantener compatibilidad) - REMOVIDO: useChat deprecated // Tipos diff --git a/app/store/components/ai-chat/hooks/useMobileDetection.ts b/app/store/components/ai-chat/hooks/useMobileDetection.ts new file mode 100644 index 00000000..5fe6ad87 --- /dev/null +++ b/app/store/components/ai-chat/hooks/useMobileDetection.ts @@ -0,0 +1,93 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +/** + * Hook para detectar dispositivos móviles y manejar el cierre automático del chat + * @param onMobileDetected - Callback que se ejecuta cuando se detecta móvil + * @param autoClose - Si debe cerrar automáticamente en móvil (default: true) + */ +export function useMobileDetection(onMobileDetected?: () => void, autoClose: boolean = true) { + const [isMobile, setIsMobile] = useState(false); + + const checkIsMobile = useCallback(() => { + if (typeof window === 'undefined') return false; + return window.innerWidth < 768; + }, []); + + useEffect(() => { + // Verificar al cargar + const mobile = checkIsMobile(); + setIsMobile(mobile); + + // Si es móvil y hay callback, ejecutarlo + if (mobile && autoClose && onMobileDetected) { + onMobileDetected(); + } + + const handleResize = () => { + const mobile = checkIsMobile(); + const wasDesktop = !isMobile; + setIsMobile(mobile); + + // Si cambió de desktop a móvil, ejecutar callback + if (mobile && wasDesktop && autoClose && onMobileDetected) { + onMobileDetected(); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [checkIsMobile, isMobile, autoClose, onMobileDetected]); + + // Función para prevenir acciones en móvil + const preventMobileAction = useCallback( + (action: () => void) => { + if (!isMobile) { + action(); + } + }, + [isMobile] + ); + + // Función para renderizar condicionalmente + const renderOnDesktopOnly = useCallback( + (component: React.ReactNode) => { + return isMobile ? null : component; + }, + [isMobile] + ); + + return { + isMobile, + isDesktop: !isMobile, + preventMobileAction, + renderOnDesktopOnly, + checkIsMobile, + }; +} + +/** + * Hook específico para el chat AI que maneja el estado de apertura + */ +export function useChatMobileDetection(setIsOpen: (open: boolean) => void) { + const handleMobileDetected = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + const mobileDetection = useMobileDetection(handleMobileDetected, true); + + const setIsOpenSafe = useCallback( + (open: boolean) => { + mobileDetection.preventMobileAction(() => { + setIsOpen(open); + }); + }, + [setIsOpen, mobileDetection] + ); + + return { + ...mobileDetection, + setIsOpenSafe, + }; +} diff --git a/app/store/components/checkout-modal/CheckoutModal.tsx b/app/store/components/checkout-modal/CheckoutModal.tsx index ce3f4654..25a9b21e 100644 --- a/app/store/components/checkout-modal/CheckoutModal.tsx +++ b/app/store/components/checkout-modal/CheckoutModal.tsx @@ -20,6 +20,8 @@ import { Button, Card, Text, BlockStack } from '@shopify/polaris'; import { ExitIcon } from '@shopify/polaris-icons'; import { AnimatedBackground } from '@/app/(setup)/my-store/components/AnimatedBackground'; import { useCheckoutPayment } from './hooks/useCheckoutPayment'; +import { useParams, usePathname, useRouter } from 'next/navigation'; +import { getStoreId } from '@/utils/client/store-utils'; interface CheckoutModalProps { open: boolean; @@ -32,7 +34,10 @@ interface CheckoutModalProps { */ export function CheckoutModal({ open, onClose }: CheckoutModalProps) { const { isSubmitting, handlePayment } = useCheckoutPayment(); - + const router = useRouter(); + const params = useParams(); + const pathname = usePathname(); + const storeId = getStoreId(params, pathname); if (!open) { return null; } @@ -124,7 +129,7 @@ export function CheckoutModal({ open, onClose }: CheckoutModalProps) {
-
-
- - - ); -} diff --git a/app/store/components/store-setup/components/ecommerce-setup-parts/SetupAdBanner.tsx b/app/store/components/store-setup/components/ecommerce-setup-parts/SetupAdBanner.tsx index 45a26a20..b2d0897a 100644 --- a/app/store/components/store-setup/components/ecommerce-setup-parts/SetupAdBanner.tsx +++ b/app/store/components/store-setup/components/ecommerce-setup-parts/SetupAdBanner.tsx @@ -1,8 +1,12 @@ +'use client'; + import { Banner } from '@shopify/polaris'; import { useState } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { extractStoreIdFromPath } from '@/utils/client/store-utils'; interface SetupAdBannerProps { - onActionClick: () => void; + onActionClick?: () => void; } const BANNER_STORAGE_KEY = 'fasttify-setup-banner-dismissed'; @@ -15,6 +19,8 @@ const getInitialVisibility = (): boolean => { export function SetupAdBanner({ onActionClick }: SetupAdBannerProps) { const [visible, setVisible] = useState(getInitialVisibility); + const router = useRouter(); + const pathname = usePathname(); // Función para manejar el cierre del banner const handleDismiss = () => { @@ -22,6 +28,17 @@ export function SetupAdBanner({ onActionClick }: SetupAdBannerProps) { localStorage.setItem(BANNER_STORAGE_KEY, 'true'); }; + // Navegación por defecto a la página de planes + const handleAction = () => { + if (onActionClick) { + onActionClick(); + return; + } + + const slug = extractStoreIdFromPath(pathname); + router.push(`/store/${slug}/suscribe/select-plan`); + }; + if (!visible) { return null; } @@ -31,7 +48,7 @@ export function SetupAdBanner({ onActionClick }: SetupAdBannerProps) { title="Suscríbete a un plan y obtén 2 meses a solo $35.000 mes en Fasttify" tone="info" onDismiss={handleDismiss} - action={{ content: 'Ver planes', onAction: onActionClick }} + action={{ content: 'Ver planes', onAction: handleAction }} /> ); } diff --git a/app/store/hooks/api/useCheckout.ts b/app/store/hooks/api/useCheckout.ts new file mode 100644 index 00000000..c94f0360 --- /dev/null +++ b/app/store/hooks/api/useCheckout.ts @@ -0,0 +1,26 @@ +import { useMutation } from '@tanstack/react-query'; +import { CheckoutParams } from '@/app/api/_lib/polar/types'; + +/** + * Hook para manejar checkout usando el adaptador de Polar + * Construye URL con query params y redirige directamente + */ +export const useCheckout = () => { + const mutation = useMutation({ + mutationFn: async ({ productId, userId, email, name }: CheckoutParams) => { + // Construir URL con query params para el adaptador de Polar + const url = new URL('/api/checkout', window.location.origin); + url.searchParams.set('products', productId); + url.searchParams.set('customerExternalId', userId); + url.searchParams.set('customerEmail', email); + url.searchParams.set('customerName', name); + + // Redirigir directamente (el adaptador maneja la redirección) + window.location.href = url.toString(); + + return { success: true }; + }, + }); + + return mutation; +}; diff --git a/app/store/layout/StoreLayoutClient.tsx b/app/store/layout/StoreLayoutClient.tsx index f1f9eab1..721ef3f4 100644 --- a/app/store/layout/StoreLayoutClient.tsx +++ b/app/store/layout/StoreLayoutClient.tsx @@ -35,8 +35,10 @@ export const StoreLayoutClient = ({ children }: { children: React.ReactNode }) = useStore(storeId); const [prefersReducedMotion, _setPrefersReducedMotion] = useState(false); - const hideSidebar = pathname.includes('/editor') || pathname.includes('/profile'); + const hideSidebar = + pathname.includes('/editor') || pathname.includes('/profile') || pathname.includes('/suscribe/select-plan'); const isCheckoutPage = pathname.includes('/access_account/checkout'); + const isSelectPlanPage = pathname.includes('/suscribe/select-plan'); return ( @@ -44,7 +46,11 @@ export const StoreLayoutClient = ({ children }: { children: React.ReactNode }) = {hideSidebar ? ( -
{children}
+ isSelectPlanPage ? ( +
{children}
+ ) : ( +
{children}
+ ) ) : isCheckoutPage ? (
{/* Layout completo con blur - Sidebar, TopBar y contenido */} diff --git a/middlewares/domain-handling/domainHandler.ts b/middlewares/domain-handling/domainHandler.ts index 5c175304..2200a3a7 100644 --- a/middlewares/domain-handling/domainHandler.ts +++ b/middlewares/domain-handling/domainHandler.ts @@ -25,8 +25,18 @@ const MAIN_DOMAINS = { /** Subdominios del sistema que no representan tiendas */ const SYSTEM_SUBDOMAINS = ['orders', 'admin', 'orders-domain'] as const; +/** Patrones de ngrok para desarrollo local */ +const NGROK_PATTERNS = /^[a-f0-9]{8,12}(\.ngrok(?:-free)?)?\.app$/i; + /** Rutas que no deben ser reescritas por el middleware */ -const EXCLUDED_PATHS = ['/assets/', '/sitemap.xml', '/robots.txt'] as const; +const EXCLUDED_PATHS = [ + '/assets/', + '/sitemap.xml', + '/robots.txt', + '/api/checkout', + '/api/portal', + '/api/webhooks/polar', +] as const; /** Número mínimo de segmentos para considerar un subdominio válido */ const MIN_SUBDOMAIN_SEGMENTS = 2; @@ -327,6 +337,15 @@ function isSystemSubdomain(subdomain: string): boolean { return SYSTEM_SUBDOMAINS.includes(subdomain as any); } +/** + * Verifica si un hostname es de ngrok (para desarrollo local) + * @param hostname - Hostname a verificar + * @returns True si es un dominio de ngrok + */ +function isNgrokDomain(hostname: string): boolean { + return NGROK_PATTERNS.test(hostname); +} + /** * Función principal que maneja todos los tipos de dominios de forma recursiva * @param request - Petición de Next.js @@ -336,6 +355,16 @@ function isSystemSubdomain(subdomain: string): boolean { export function handleDomainRouting(request: NextRequest, analysis?: DomainAnalysis): NextResponse | null { const domainAnalysis = analysis || analyzeDomain(request); + // Verificar si la ruta debe ser excluida del procesamiento de dominio + if (shouldExcludePath(request.nextUrl.pathname)) { + return null; + } + + // Caso específico: dominios de ngrok (desarrollo local) - no procesar como tienda + if (isNgrokDomain(domainAnalysis.hostname)) { + return null; + } + // Caso base: dominio principal if (domainAnalysis.isMainDomain) { return null; diff --git a/middlewares/store-access/store.ts b/middlewares/store-access/store.ts index 4b82388d..13444501 100644 --- a/middlewares/store-access/store.ts +++ b/middlewares/store-access/store.ts @@ -50,7 +50,7 @@ export async function handleStoreMiddleware(request: NextRequest, response: Next const path = request.nextUrl.pathname; // Excluir rutas de checkout completamente - no validar nada - if (path.includes('/access_account/checkout') || path.includes('/checkout')) { + if (path.includes('/access_account/checkout') || path.includes('/suscribe/select-plan')) { return response; } diff --git a/middlewares/store-access/storeAccess.ts b/middlewares/store-access/storeAccess.ts index daeeb851..761013fa 100644 --- a/middlewares/store-access/storeAccess.ts +++ b/middlewares/store-access/storeAccess.ts @@ -27,6 +27,11 @@ export async function handleStoreAccessMiddleware(request: NextRequest) { // Extraer el ID de la tienda de la URL const path = request.nextUrl.pathname; + // Permitir acceso a la pantalla de selección de planes sin bloquear por plan + if (path.includes('/suscribe/select-plan')) { + return NextResponse.next(); + } + // Verificar autenticación usando el middleware centralizado const authResponse = await handleAuthenticationMiddleware(request, NextResponse.next()); if (authResponse) { diff --git a/next.config.ts b/next.config.ts index 48764e60..06d19bbd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,7 +14,6 @@ const nextConfig: NextConfig = { '@aws-sdk/client-s3', '@aws-sdk/client-ses', '@aws-sdk/s3-request-presigner', - '@polar-sh/sdk', 'dotenv', 'axios', 'node-fetch', diff --git a/package.json b/package.json index 8cdb8d95..8e16ac24 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@fasttify/theme-editor": "workspace:*", "@hookform/resolvers": "3.3.4", "@next/bundle-analyzer": "^15.5.2", + "@polar-sh/nextjs": "^0.4.9", "@polar-sh/sdk": "^0.34.3", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-checkbox": "^1.3.2", @@ -123,7 +124,7 @@ "uuid": "^11.1.0", "uuidv4": "^6.2.13", "vaul": "^1.1.2", - "zod": "^3.25.64", + "zod": "^3.25.76", "zustand": "^5.0.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 832635f9..cb8b1892 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@next/bundle-analyzer': specifier: ^15.5.2 version: 15.5.4 + '@polar-sh/nextjs': + specifier: ^0.4.9 + version: 0.4.9(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@polar-sh/sdk': specifier: ^0.34.3 version: 0.34.17 @@ -216,7 +219,7 @@ importers: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.25))(@types/react@18.3.25)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zod: - specifier: ^3.25.64 + specifier: ^3.25.76 version: 3.25.76 zustand: specifier: ^5.0.6 @@ -2927,6 +2930,15 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@polar-sh/adapter-utils@0.2.8': + resolution: {integrity: sha512-VRCYectPSc8H63hYsGMNJK9rGy4WjpwL0G7LvnVHHYCpxBadkn90eWssS0AyaOsYaUiqCwwGcJd609UoDk3IQA==} + + '@polar-sh/nextjs@0.4.9': + resolution: {integrity: sha512-3oePCaOFyBpmauFsekjsiGsclxzTVfib0vL91cQbuBlse0dTPgnA8BtuQoZWW5TY+ivaA3kiyuRKnTJr92SxYw==} + engines: {node: '>=16'} + peerDependencies: + next: ^15.0.0 || ^15.2.0-canary.* + '@polar-sh/sdk@0.34.17': resolution: {integrity: sha512-+eJAAyyP4CAtMy9Hd6gaNXErjaH3KuTXJFv72kqlCCvv7SweBlM4U2+zpeYAZvd/YMRZq/c447f0a0DD2e7UEA==} hasBin: true @@ -2936,6 +2948,15 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@polar-sh/sdk@0.35.4': + resolution: {integrity: sha512-vv4Ptl5jNsHIZoLvzKr0wR+dGXJOpz8VWOOTEGqaiYx6YJvzIvrayg52qp8ZtBjsRBkySLZS2EVTV3wDHTACwA==} + hasBin: true + peerDependencies: + '@modelcontextprotocol/sdk': '>=1.5.0 <1.10.0' + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -14168,11 +14189,30 @@ snapshots: '@pkgr/core@0.2.9': {} + '@polar-sh/adapter-utils@0.2.8': + dependencies: + '@polar-sh/sdk': 0.35.4 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + + '@polar-sh/nextjs@0.4.9(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': + dependencies: + '@polar-sh/adapter-utils': 0.2.8 + '@polar-sh/sdk': 0.35.4 + next: 15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + '@polar-sh/sdk@0.34.17': dependencies: standardwebhooks: 1.0.0 zod: 3.25.76 + '@polar-sh/sdk@0.35.4': + dependencies: + standardwebhooks: 1.0.0 + zod: 3.25.76 + '@polka/url@1.0.0-next.29': {} '@radix-ui/number@1.1.1': {} diff --git a/utils/client/ConfigureAmplify.tsx b/utils/client/ConfigureAmplify.tsx index 6640ce0d..3ed40d35 100644 --- a/utils/client/ConfigureAmplify.tsx +++ b/utils/client/ConfigureAmplify.tsx @@ -21,6 +21,14 @@ import { Amplify } from 'aws-amplify'; import outputs from '@/amplify_outputs.json'; Amplify.configure(outputs, { ssr: true }); +const existingConfig = Amplify.getConfig(); +Amplify.configure({ + ...existingConfig, + API: { + ...existingConfig.API, + REST: outputs.custom.APIs, + }, +}); export default function ConfigureAmplifyClientSide() { return null;