From f011bb9dc0ef5f0d3f5b41f07230fd96f1153ef9 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 16 Oct 2025 00:33:43 -0500 Subject: [PATCH 1/5] Refactor subscription handling and environment configuration for Polar integration This commit updates the subscription handling logic across multiple components to improve user experience and streamline interactions with the Polar API. It introduces environment-specific product IDs for better management of plans in development and production. Additionally, it enhances the authorization model in the user subscription schema and modifies the checkout process to ensure proper validation and error handling. The AWS Amplify configuration is also refined to ensure consistent API access. --- amplify/data/models/user-subscription.ts | 56 +++- .../functions/createSubscription/handler.ts | 102 ++++++-- .../webHookPlan/services/polar-api.ts | 1 + .../services/polar-payment-processor.ts | 25 +- .../webHookPlan/services/user-service.ts | 4 +- app/(www)/pricing/components/plans.ts | 21 +- .../repositories/subscription.repository.ts | 164 ++++++++++++ .../polar/repositories/user.repository.ts | 128 ++++++++++ app/api/_lib/polar/services/polar.service.ts | 182 +++++++++++++ .../polar/services/subscription.service.ts | 239 ++++++++++++++++++ app/api/_lib/polar/types/index.ts | 118 +++++++++ app/api/checkout/route.ts | 40 +++ app/api/portal/route.ts | 55 ++++ app/api/webhooks/polar/route.ts | 87 +++++++ .../hooks/useCheckoutPayment.ts | 48 ++-- .../components/SubscriptionSection.tsx | 63 ++--- app/store/hooks/api/useCheckout.ts | 26 ++ middlewares/domain-handling/domainHandler.ts | 31 ++- next.config.ts | 1 - package.json | 3 +- pnpm-lock.yaml | 42 ++- utils/client/ConfigureAmplify.tsx | 8 + 22 files changed, 1331 insertions(+), 113 deletions(-) create mode 100644 app/api/_lib/polar/repositories/subscription.repository.ts create mode 100644 app/api/_lib/polar/repositories/user.repository.ts create mode 100644 app/api/_lib/polar/services/polar.service.ts create mode 100644 app/api/_lib/polar/services/subscription.service.ts create mode 100644 app/api/_lib/polar/types/index.ts create mode 100644 app/api/checkout/route.ts create mode 100644 app/api/portal/route.ts create mode 100644 app/api/webhooks/polar/route.ts create mode 100644 app/store/hooks/api/useCheckout.ts 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..c5126bf3 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', 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..ba69dcd3 --- /dev/null +++ b/app/api/portal/route.ts @@ -0,0 +1,55 @@ +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) { + const corsHeaders = await getNextCorsHeaders(request); + return NextResponse.json({ error: 'Authentication required' }, { status: 401, headers: corsHeaders }); + } + + 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/components/checkout-modal/hooks/useCheckoutPayment.ts b/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts index 4c613592..4df7abcc 100644 --- a/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts +++ b/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts @@ -2,18 +2,12 @@ import { useState } from 'react'; import { useAuth } from '@/context/hooks/useAuth'; -import { post } from 'aws-amplify/api'; import { useToast } from '@/app/store/context/ToastContext'; - -interface SubscriptionResponse { - checkoutUrl?: string; - error?: string; - details?: string; -} +import { plans } from '@/app/(www)/pricing/components/plans'; /** * Hook personalizado para manejar el proceso de pago en el modal de checkout - * Integra con Polar.sh para crear la sesión de pago + * Integra con Polar.sh usando el adaptador de Next.js */ export function useCheckoutPayment() { const [isSubmitting, setIsSubmitting] = useState(false); @@ -37,33 +31,23 @@ export function useCheckoutPayment() { setIsSubmitting(true); try { - // ID del plan Royal (el plan por defecto para el checkout) - const royalPlanId = 'e02f173f-1ca5-4f7b-a900-2e5c9413d8a6'; + // Seleccionar el plan Royal desde plans.ts (respeta entorno) + const royalPlan = plans.find((p) => p.name === 'Royal'); + const royalPlanId = royalPlan?.polarId; - const restOperation = post({ - apiName: 'SubscriptionApi', - path: 'subscribe', - options: { - body: { - userId: user.userId, - email: user.email, - name: user.nickName, - plan: { - polarId: royalPlanId, - }, - }, - }, - }); + if (!royalPlanId) { + throw new Error('No se encontró el plan Royal'); + } - const { body } = await restOperation.response; - const response = (await body.json()) as SubscriptionResponse; + // Construir URL con query params para el adaptador de Polar + const url = new URL('/api/checkout', window.location.origin); + url.searchParams.set('products', royalPlanId); + url.searchParams.set('customerExternalId', user.userId); + url.searchParams.set('customerEmail', user.email); + url.searchParams.set('customerName', user.nickName); - if (response && response.checkoutUrl) { - // Redirigir a Polar.sh para completar el pago - window.location.href = response.checkoutUrl; - } else { - throw new Error('No se recibió URL de checkout'); - } + // Redirigir directamente (el adaptador maneja la redirección) + window.location.href = url.toString(); } catch (error) { console.error('Error procesando suscripción:', error); showToast('Hubo un error al procesar tu suscripción. Por favor, inténtalo de nuevo.', true); diff --git a/app/store/components/profile/components/SubscriptionSection.tsx b/app/store/components/profile/components/SubscriptionSection.tsx index c0807fb4..c1c70b15 100644 --- a/app/store/components/profile/components/SubscriptionSection.tsx +++ b/app/store/components/profile/components/SubscriptionSection.tsx @@ -1,22 +1,9 @@ import { Card, Text, Button, Banner, SkeletonBodyText, Icon } from '@shopify/polaris'; import { ExternalIcon, CheckCircleIcon } from '@shopify/polaris-icons'; import { useState } from 'react'; -import { post } from 'aws-amplify/api'; -import { Amplify } from 'aws-amplify'; -import outputs from '@/amplify_outputs.json'; +import { plans } from '@/app/(www)/pricing/components/plans'; import type { UserProps } from '@/app/store/components/profile/types'; -// Configurar Amplify para la API REST -Amplify.configure(outputs); -const existingConfig = Amplify.getConfig(); -Amplify.configure({ - ...existingConfig, - API: { - ...existingConfig.API, - REST: outputs.custom.APIs, - }, -}); - interface SubscriptionSectionProps extends UserProps { storeId: string; } @@ -43,30 +30,32 @@ export function SubscriptionSection({ user, loading }: SubscriptionSectionProps) setIsSubmitting(true); try { - const defaultPlanId = '149c6595-1611-477d-b0b4-61700d33c069'; - - const response = await post({ - apiName: 'SubscriptionApi', - path: 'subscribe', - options: { - body: { - userId: user.userId, - email: user.email, - name: user.nickName, - plan: { - polarId: defaultPlanId, - }, - }, - }, - }); - - const { body } = await response.response; - const responseUrl = (await body.json()) as { checkoutUrl?: string }; - - if (responseUrl && responseUrl.checkoutUrl) { - window.location.href = responseUrl.checkoutUrl; + // Si el usuario tiene un plan pagado, ir al portal de gestión + // Si no, ir al checkout para suscribirse + const isPaidPlan = user.plan && user.plan !== 'Gratuito'; + + if (isPaidPlan) { + // Redirigir al customer portal + const portalUrl = new URL('/api/portal', window.location.origin); + portalUrl.searchParams.set('customerExternalId', user.userId); + window.location.href = portalUrl.toString(); } else { - console.warn('No checkout URL received.'); + // Redirigir al checkout con el plan popular + const selectedPlan = plans.find((p) => p.popular) || plans[0]; + const defaultPlanId = selectedPlan?.polarId; + + if (!defaultPlanId) { + console.error('No default plan available to subscribe.'); + return; + } + + const checkoutUrl = new URL('/api/checkout', window.location.origin); + checkoutUrl.searchParams.set('products', defaultPlanId); + checkoutUrl.searchParams.set('customerExternalId', user.userId); + checkoutUrl.searchParams.set('customerEmail', user.email); + checkoutUrl.searchParams.set('customerName', user.nickName); + + window.location.href = checkoutUrl.toString(); } } catch (error) { console.error('Error redirecting to Polarsh:', error); 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/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/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; From c1e98be419c626d73518466117c8077ac09f0bde Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 16 Oct 2025 00:45:31 -0500 Subject: [PATCH 2/5] Refactor authentication error handling in portal route This commit updates the error handling in the portal route by replacing the JSON response for unauthenticated users with a thrown error. This change simplifies the authentication check and improves the clarity of error management in the API response. --- app/api/portal/route.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/api/portal/route.ts b/app/api/portal/route.ts index ba69dcd3..12339590 100644 --- a/app/api/portal/route.ts +++ b/app/api/portal/route.ts @@ -21,8 +21,7 @@ export const GET = CustomerPortal({ // 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 }); + throw new Error('Authentication required'); } const externalId = session.username; From 1165b34eacce296b86aac35b5d4301995bea6715 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 16 Oct 2025 01:01:26 -0500 Subject: [PATCH 3/5] Refactor Chat components to integrate mobile detection functionality This commit enhances the ChatTrigger and RefinedAIAssistantSheet components by incorporating mobile detection hooks. It modifies the state management for chat visibility to prevent actions on mobile devices and ensures that the chat UI is rendered only on desktop. Additionally, the ChatContext is updated to use a safe setter for the chat state, improving user experience across different devices. --- .../ai-chat/components/ChatTrigger.tsx | 10 +- .../ai-chat/components/RefinedAiAssistant.tsx | 10 +- .../ai-chat/context/ChatContext.tsx | 26 +++++- app/store/components/ai-chat/hooks/index.ts | 3 + .../ai-chat/hooks/useMobileDetection.ts | 93 +++++++++++++++++++ 5 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 app/store/components/ai-chat/hooks/useMobileDetection.ts 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, + }; +} From e9d6d88eef4933c5ae0836d00449ae69329efd9b Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 16 Oct 2025 13:14:42 -0500 Subject: [PATCH 4/5] Implement plan FAQs and enhance checkout modal navigation This commit introduces a new `planFaqs` export in the plans component, providing detailed FAQs about the Fasttify platform. Additionally, it updates the `CheckoutModal` to navigate to the plan selection page when the "Ver todos los planes" button is clicked, improving user experience. The `TopBarPolaris` component is also enhanced with new icons for better visual representation of actions related to user profile and subscription plans. Furthermore, several profile components are refactored to handle loading states more gracefully by checking for user existence, ensuring a smoother user experience. --- app/(www)/pricing/components/plans.ts | 50 ++++ .../suscribe/select-plan/SelectPlanClient.tsx | 169 ++++++++++++ .../select-plan/components/PlanCard.tsx | 62 +++++ .../[slug]/suscribe/select-plan/page.tsx | 12 + .../checkout-modal/CheckoutModal.tsx | 9 +- .../profile/components/DangerZone.tsx | 14 +- .../profile/components/EmailSection.tsx | 14 +- .../components/PersonalInformation.tsx | 14 +- .../profile/components/ProfileHeader.tsx | 14 +- .../profile/components/SecuritySection.tsx | 14 +- .../components/SubscriptionSection.tsx | 14 +- .../application/usePlanCheckout.ts | 45 ++++ .../components/select-plan/domain/plan.ts | 11 + .../sidebar/components/TopBarPolaris.tsx | 20 +- .../store-setup/components/EcommerceSetup.tsx | 6 +- .../store-setup/components/PricingDrawer.tsx | 255 ------------------ .../ecommerce-setup-parts/SetupAdBanner.tsx | 21 +- app/store/layout/StoreLayoutClient.tsx | 10 +- middlewares/store-access/store.ts | 2 +- middlewares/store-access/storeAccess.ts | 5 + 20 files changed, 413 insertions(+), 348 deletions(-) create mode 100644 app/store/[slug]/suscribe/select-plan/SelectPlanClient.tsx create mode 100644 app/store/[slug]/suscribe/select-plan/components/PlanCard.tsx create mode 100644 app/store/[slug]/suscribe/select-plan/page.tsx create mode 100644 app/store/components/select-plan/application/usePlanCheckout.ts create mode 100644 app/store/components/select-plan/domain/plan.ts delete mode 100644 app/store/components/store-setup/components/PricingDrawer.tsx diff --git a/app/(www)/pricing/components/plans.ts b/app/(www)/pricing/components/plans.ts index c5126bf3..fbf5c3a2 100644 --- a/app/(www)/pricing/components/plans.ts +++ b/app/(www)/pricing/components/plans.ts @@ -71,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/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/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/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/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) { From bb2bbbac7258086047aa59b95306172af487c8a3 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 16 Oct 2025 13:27:43 -0500 Subject: [PATCH 5/5] Update PolarisLayout and TopBarPolaris components for improved user experience This commit modifies the `PolarisLayout` component to conditionally set the dashboard URL based on the presence of a store ID, enhancing navigation. Additionally, it updates the `TopBarPolaris` component to ensure the user picture is handled more gracefully by using undefined as a fallback, improving the robustness of the user profile display. These changes aim to streamline user interactions and enhance overall usability. --- app/store/components/sidebar/components/PolarisLayout.tsx | 3 ++- app/store/components/sidebar/components/TopBarPolaris.tsx | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/store/components/sidebar/components/PolarisLayout.tsx b/app/store/components/sidebar/components/PolarisLayout.tsx index 73580d9a..03445886 100644 --- a/app/store/components/sidebar/components/PolarisLayout.tsx +++ b/app/store/components/sidebar/components/PolarisLayout.tsx @@ -73,8 +73,9 @@ export const PolarisLayout = memo(({ children, storeId, prefersReducedMotion = f const logo = useMemo( () => ({ topBarSource: 'https://cdn.fasttify.com/assets/b/fasttify-white.webp', + contextualSaveBarSource: 'https://cdn.fasttify.com/assets/b/fasttify-white.webp', width: 40, - url: routes.store.dashboard.main(storeId), + url: storeId ? routes.store.dashboard.main(storeId) : '/my-store', accessibilityLabel: 'Fasttify', }), [storeId] diff --git a/app/store/components/sidebar/components/TopBarPolaris.tsx b/app/store/components/sidebar/components/TopBarPolaris.tsx index e802fca4..a35aa173 100644 --- a/app/store/components/sidebar/components/TopBarPolaris.tsx +++ b/app/store/components/sidebar/components/TopBarPolaris.tsx @@ -33,12 +33,12 @@ export function TopBarPolaris({ storeId, onNavigationToggle }: TopBarPolarisProp const [searchValue, setSearchValue] = useState(''); const { clearStore, currentStore } = useStoreDataStore(); const storeName = currentStore?.storeName; - const userPicture = user?.picture; + const userPicture = user?.picture || undefined; const { url: secureUserPicture, isLoading: isPictureLoading } = useSecureUrl({ - baseUrl: userPicture || '', + baseUrl: userPicture ?? '', type: 'profile-image', - enabled: !!userPicture, + enabled: Boolean(userPicture), }); const handleChangeStore = async () => {