-
Funciones destacadas
-
- {plan.features.map((feature) => (
-
-
- {feature}
-
- ))}
-
+
+
+
{plan.name}
+
{plan.description}
+
+
{formatPrice(plan.price)}
+
COP al mes
+
facturación mensual
+
-
- {hasActivePlan ? 'Plan activo' : isSubmitting ? 'Procesando...' : plan.buttonText}
-
+
+
Funciones destacadas
+
+ {plan.features.map((feature) => (
+
+
+ {feature}
+
+ ))}
+
-
-
- >
+
+ {plan.buttonText}
+
+
+
);
}
diff --git a/app/(www)/pricing/page.tsx b/app/(www)/pricing/page.tsx
index a62a1e9c..1516876e 100644
--- a/app/(www)/pricing/page.tsx
+++ b/app/(www)/pricing/page.tsx
@@ -1,25 +1,13 @@
'use client';
-import outputs from '@/amplify_outputs.json';
import { Footer } from '@/app/(www)/landing/components/Footer';
import { faqItems } from '@/app/(www)/pricing/components/FAQItem';
import { FAQSection } from '@/app/(www)/pricing/components/FAQSection';
import { FeatureComparison } from '@/app/(www)/pricing/components/FeatureComparison';
import { plans } from '@/app/(www)/pricing/components/plans';
import { PricingCard } from '@/app/(www)/pricing/components/PricingCard';
-import { Amplify } from 'aws-amplify';
import { motion } from 'framer-motion';
-Amplify.configure(outputs);
-const existingConfig = Amplify.getConfig();
-Amplify.configure({
- ...existingConfig,
- API: {
- ...existingConfig.API,
- REST: outputs.custom.APIs,
- },
-});
-
export default function PricingPage() {
return (
<>
diff --git a/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx b/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx
new file mode 100644
index 00000000..93abad7f
--- /dev/null
+++ b/app/store/[slug]/access_account/checkout/CheckoutModalClient.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+/*
+ * 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 { CheckoutModal } from '@/app/store/components/checkout-modal/CheckoutModal';
+import { CheckoutModalMobile } from '@/app/store/components/checkout-modal/CheckoutModalMobile';
+import { useEffect } from 'react';
+import useAuthStore from '@/context/core/userStore';
+
+/**
+ * Componente cliente que maneja el modal de checkout bloqueante
+ * Previene la navegación y aplica efectos visuales al fondo
+ */
+export function CheckoutModalClient({ storeId: _storeId }: { storeId: string }) {
+ const { logout } = useAuthStore();
+ /**
+ * Previene la navegación del usuario mientras está en el modal de checkout
+ */
+ useEffect(() => {
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+ event.returnValue = '';
+ };
+
+ const handlePopState = () => {
+ // Prevenir navegación hacia atrás
+ window.history.pushState(null, '', window.location.href);
+ };
+
+ // Agregar el estado actual al historial para prevenir navegación
+ window.history.pushState(null, '', window.location.href);
+
+ // Agregar listeners para prevenir navegación
+ window.addEventListener('beforeunload', handleBeforeUnload);
+ window.addEventListener('popstate', handlePopState);
+
+ // Bloquear el scroll del body
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload);
+ window.removeEventListener('popstate', handlePopState);
+ document.body.style.overflow = 'unset';
+ };
+ }, []);
+
+ /**
+ * Maneja el cierre de sesión y redirige al login
+ */
+ const handleLogout = () => {
+ logout();
+ };
+
+ return (
+ <>
+ {/* Modal para desktop */}
+
+
+
+
+ {/* Modal para móvil */}
+
+
+
+ >
+ );
+}
diff --git a/app/store/[slug]/access_account/checkout/page.tsx b/app/store/[slug]/access_account/checkout/page.tsx
new file mode 100644
index 00000000..9a1cf87b
--- /dev/null
+++ b/app/store/[slug]/access_account/checkout/page.tsx
@@ -0,0 +1,31 @@
+/*
+ * 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 { CheckoutModalClient } from '@/app/store/[slug]/access_account/checkout/CheckoutModalClient';
+
+interface CheckoutPageProps {
+ params: Promise<{ slug: string }>;
+}
+
+/**
+ * Página de checkout para usuarios con suscripción expirada que necesitan reactivar
+ */
+export default async function CheckoutPage({ params }: CheckoutPageProps) {
+ const { slug: storeId } = await params;
+
+ // El middleware ya validó todo, solo renderizar el modal
+ return
;
+}
diff --git a/app/store/components/checkout-modal/CheckoutModal.tsx b/app/store/components/checkout-modal/CheckoutModal.tsx
new file mode 100644
index 00000000..ce3f4654
--- /dev/null
+++ b/app/store/components/checkout-modal/CheckoutModal.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+/*
+ * 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 { 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';
+
+interface CheckoutModalProps {
+ open: boolean;
+ onClose?: () => void;
+}
+
+/**
+ * Modal bloqueante de checkout basado en el diseño de Polar.sh
+ * Este modal no se puede cerrar y bloquea toda navegación hasta completar el pago
+ */
+export function CheckoutModal({ open, onClose }: CheckoutModalProps) {
+ const { isSubmitting, handlePayment } = useCheckoutPayment();
+
+ if (!open) {
+ return null;
+ }
+
+ const benefits = [
+ 'Asesoría por chat, email y WhatsApp',
+ 'Hosting y SSL gratuitos',
+ 'Panel de estadísticas avanzadas',
+ 'Soporte prioritario 24/7',
+ 'Integraciones con redes sociales',
+ ];
+
+ return (
+
+ {/* Backdrop con blur */}
+
+
+ {/* Modal */}
+
+ {/* Header con botón de logout */}
+
+
+ Cerrar sesión
+
+
+
+ {/* Contenido principal */}
+
+ {/* Panel izquierdo - Información del plan */}
+
+ {/* Animated Background */}
+
+ {/* Contenido */}
+
+
+
+ Vuelve al negocio por $55.000 COP/mes
+
+
+
+
+
+ Plan gratuito expirado
+
+
+ ¡Buenas noticias - guardamos tu progreso!
+
+
+
+
+
+ Hoy - $55.000 COP/mes
+
+
+ Eso es acceso completo a todas las funciones
+
+
+
+
+
+ Siempre - Sin compromiso, cancela cuando quieras
+
+
+
+
+ {/* Beneficios del plan */}
+
+
+ Con tu plan obtienes:
+
+
+ {benefits.map((benefit, index) => (
+
+
+ {benefit}
+
+
+ ))}
+
+
+
+
+
+ console.log('Ver todos los planes')}>
+ Ver todos los planes
+
+ console.log('Ver detalles de la tienda')}>
+ Ver detalles de la tienda
+
+
+
+
+
+ {/* Panel derecho - Formulario de pago */}
+
+
+
+
+
+ Resumen del plan
+
+
+
+
+
+
+
+
+ Plan Royal
+
+
+ Acceso completo a todas las funciones
+
+
+
+
+
+ $55.000 COP/mes
+
+
+ Facturación mensual
+
+
+
+
+
+
+
+
+ {/* Botón de suscripción */}
+
+ {isSubmitting ? 'Procesando...' : 'Continuar al pago'}
+
+
+
+ Más impuestos aplicables. Se renueva mensualmente en plan Royal $55.000/mes
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/store/components/checkout-modal/CheckoutModalMobile.tsx b/app/store/components/checkout-modal/CheckoutModalMobile.tsx
new file mode 100644
index 00000000..03cca648
--- /dev/null
+++ b/app/store/components/checkout-modal/CheckoutModalMobile.tsx
@@ -0,0 +1,185 @@
+'use client';
+
+/*
+ * 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 { Button, Card, Text, BlockStack } from '@shopify/polaris';
+import { ExitIcon } from '@shopify/polaris-icons';
+import { useCheckoutPayment } from './hooks/useCheckoutPayment';
+
+interface CheckoutModalMobileProps {
+ open: boolean;
+ onClose?: () => void;
+}
+
+/**
+ * Modal móvil de checkout optimizado para pantallas pequeñas
+ */
+export function CheckoutModalMobile({ open, onClose }: CheckoutModalMobileProps) {
+ const { isSubmitting, handlePayment } = useCheckoutPayment();
+
+ if (!open) {
+ return null;
+ }
+
+ const benefits = [
+ 'Asesoría por chat, email y WhatsApp',
+ 'Hosting y SSL gratuitos',
+ 'Panel de estadísticas avanzadas',
+ 'Soporte prioritario 24/7',
+ 'Integraciones con redes sociales',
+ ];
+
+ return (
+
+ {/* Backdrop con blur */}
+
+
+ {/* Modal móvil */}
+
+ {/* Panel superior - Información del plan */}
+
+ {/* Fondo personalizado para móvil */}
+
+
+
+
+ Vuelve al negocio por $55.000 COP/mes
+
+
+
+
+
+ Plan gratuito expirado
+
+
+ ¡Buenas noticias - guardamos tu progreso!
+
+
+
+
+
+ Hoy - $55.000 COP/mes
+
+
+ Eso es acceso completo a todas las funciones
+
+
+
+
+
+ Siempre - Sin compromiso, cancela cuando quieras
+
+
+
+
+ {/* Beneficios del plan */}
+
+
+ Con tu plan obtienes:
+
+
+ {benefits.map((benefit, index) => (
+
+
+ {benefit}
+
+
+ ))}
+
+
+
+
+
+ Ver todos los planes
+
+
+ Ver detalles de la tienda
+
+
+
+
+
+ {/* Panel inferior - Formulario de pago */}
+
+ {/* Botón de logout en la parte blanca */}
+
+
+ Cerrar sesión
+
+
+
+
+
+
+
+ Resumen del plan
+
+
+
+
+
+
+
+
+ Plan Royal
+
+
+ Acceso completo a todas las funciones
+
+
+
+
+
+ $55.000 COP/mes
+
+
+ Facturación mensual
+
+
+
+
+
+
+
+
+ {/* Botón de suscripción */}
+
+ {isSubmitting ? 'Procesando...' : 'Continuar al pago'}
+
+
+
+ Más impuestos aplicables. Se renueva mensualmente en plan Royal $55.000/mes
+
+
+
+
+
+
+ );
+}
diff --git a/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts b/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts
new file mode 100644
index 00000000..4c613592
--- /dev/null
+++ b/app/store/components/checkout-modal/hooks/useCheckoutPayment.ts
@@ -0,0 +1,78 @@
+'use client';
+
+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;
+}
+
+/**
+ * Hook personalizado para manejar el proceso de pago en el modal de checkout
+ * Integra con Polar.sh para crear la sesión de pago
+ */
+export function useCheckoutPayment() {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const { user } = useAuth();
+ const { showToast } = useToast();
+
+ /**
+ * Maneja el proceso de suscripción y redirige a Polar.sh
+ */
+ const handlePayment = async () => {
+ if (!user) {
+ showToast('Debes iniciar sesión para continuar', true);
+ return;
+ }
+
+ if (!user.userId || !user.email || !user.nickName) {
+ showToast('Información de usuario incompleta', true);
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ // ID del plan Royal (el plan por defecto para el checkout)
+ const royalPlanId = 'e02f173f-1ca5-4f7b-a900-2e5c9413d8a6';
+
+ const restOperation = post({
+ apiName: 'SubscriptionApi',
+ path: 'subscribe',
+ options: {
+ body: {
+ userId: user.userId,
+ email: user.email,
+ name: user.nickName,
+ plan: {
+ polarId: royalPlanId,
+ },
+ },
+ },
+ });
+
+ const { body } = await restOperation.response;
+ const response = (await body.json()) as SubscriptionResponse;
+
+ 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');
+ }
+ } 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);
+ setIsSubmitting(false);
+ }
+ };
+
+ return {
+ isSubmitting,
+ handlePayment,
+ };
+}
diff --git a/app/store/layout/StoreLayoutClient.tsx b/app/store/layout/StoreLayoutClient.tsx
index e7b3b63d..f1f9eab1 100644
--- a/app/store/layout/StoreLayoutClient.tsx
+++ b/app/store/layout/StoreLayoutClient.tsx
@@ -36,6 +36,7 @@ export const StoreLayoutClient = ({ children }: { children: React.ReactNode }) =
const [prefersReducedMotion, _setPrefersReducedMotion] = useState(false);
const hideSidebar = pathname.includes('/editor') || pathname.includes('/profile');
+ const isCheckoutPage = pathname.includes('/access_account/checkout');
return (
@@ -44,6 +45,22 @@ export const StoreLayoutClient = ({ children }: { children: React.ReactNode }) =
{hideSidebar ? (
{children}
+ ) : isCheckoutPage ? (
+
+ {/* Layout completo con blur - Sidebar, TopBar y contenido */}
+
+
+
+
Panel de Administración
+
+ Este contenido está bloqueado hasta que renueves tu suscripción
+
+
+
+
+ {/* Modal de checkout superpuesto */}
+ {children}
+
) : (
{children}
diff --git a/context/core/userStore.ts b/context/core/userStore.ts
index c9083f98..d7da036f 100644
--- a/context/core/userStore.ts
+++ b/context/core/userStore.ts
@@ -177,7 +177,12 @@ const useAuthStore = create((set, get) => ({
logout: async () => {
try {
- await signOut();
+ await signOut({
+ global: true,
+ oauth: {
+ redirectUrl: '/login',
+ },
+ });
} catch (error) {
console.error('Error during logout:', error);
} finally {
diff --git a/lib/utils/subscription-utils.ts b/lib/utils/subscription-utils.ts
new file mode 100644
index 00000000..5c478602
--- /dev/null
+++ b/lib/utils/subscription-utils.ts
@@ -0,0 +1,73 @@
+/*
+ * 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.
+ */
+
+/**
+ * Verifica si el plan del usuario está expirado o inactivo
+ * @param plan - Nombre del plan del usuario
+ * @param nextPaymentDate - Fecha del próximo pago en formato ISO string
+ * @returns true si el plan requiere renovación, false si está activo
+ */
+export function isPlanExpiredOrInactive(plan?: string, nextPaymentDate?: string): boolean {
+ // Planes activos que no requieren renovación
+ const activePlans = ['Royal', 'Majestic', 'Imperial'];
+
+ // Si no hay plan o es plan gratuito, requiere renovación
+ if (!plan || plan === 'free' || !activePlans.includes(plan)) {
+ return true;
+ }
+
+ // Si no hay fecha de próximo pago, el plan está expirado
+ if (!nextPaymentDate) {
+ return true;
+ }
+
+ // Verificar si la fecha de próximo pago ya pasó
+ const paymentDate = new Date(nextPaymentDate);
+ const currentDate = new Date();
+
+ // Si la fecha de pago ya pasó, el plan está expirado
+ return paymentDate <= currentDate;
+}
+
+/**
+ * Verifica si el usuario tiene un plan activo válido
+ * @param plan - Nombre del plan del usuario
+ * @param nextPaymentDate - Fecha del próximo pago en formato ISO string
+ * @returns true si el plan está activo, false si requiere renovación
+ */
+export function hasActivePlan(plan?: string, nextPaymentDate?: string): boolean {
+ return !isPlanExpiredOrInactive(plan, nextPaymentDate);
+}
+
+/**
+ * Obtiene el estado de suscripción del usuario basado en los datos de Cognito
+ * @param userPlan - Plan del usuario desde Cognito
+ * @param subscriptionData - Datos de suscripción desde DynamoDB
+ * @returns objeto con el estado de la suscripción
+ */
+export function getSubscriptionStatus(userPlan?: string, subscriptionData?: { nextPaymentDate?: string }) {
+ const nextPaymentDate = subscriptionData?.nextPaymentDate;
+ const isExpired = isPlanExpiredOrInactive(userPlan, nextPaymentDate);
+ const isActive = hasActivePlan(userPlan, nextPaymentDate);
+
+ return {
+ isExpired,
+ isActive,
+ plan: userPlan,
+ nextPaymentDate,
+ requiresCheckout: isExpired,
+ };
+}
diff --git a/middleware.ts b/middleware.ts
index 0ef18d28..e09eaae1 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -198,7 +198,7 @@ async function handleOAuthProtection(
): Promise {
if (request.nextUrl.pathname === PROTECTED_ROUTES.OAUTH_CALLBACK) {
if (!isValidOAuthCallback(request.nextUrl)) {
- return NextResponse.redirect(new URL(PROTECTED_ROUTES.LOGIN, request.url));
+ return NextResponse.redirect(new URL(PROTECTED_ROUTES.LOGIN, request.url), { status: 302 });
}
}
@@ -218,7 +218,7 @@ async function handleOrdersSubdomainProtection(
const domainAnalysis = analyzeDomain(request);
if (shouldBlockOrdersAccess(request.nextUrl.pathname, domainAnalysis.subdomain)) {
- return NextResponse.redirect(new URL(PROTECTED_ROUTES.HOME, request.url));
+ return NextResponse.redirect(new URL(PROTECTED_ROUTES.HOME, request.url), { status: 302 });
}
return await next();
diff --git a/middlewares/auth/auth.ts b/middlewares/auth/auth.ts
index 42cccd1a..d074a075 100644
--- a/middlewares/auth/auth.ts
+++ b/middlewares/auth/auth.ts
@@ -26,6 +26,8 @@ export interface AuthSession {
payload?: {
'cognito:username'?: string;
'custom:plan'?: string;
+ email?: string;
+ nickname?: string;
};
};
};
@@ -37,6 +39,15 @@ const sessionCache = new NodeCache({
useClones: false,
});
+/**
+ * Limpia el caché de sesiones para un usuario específico
+ * Útil cuando se detectan problemas de autenticación
+ */
+export function clearUserSessionCache(request: NextRequest): void {
+ const cacheKey = getCacheKey(request);
+ sessionCache.del(cacheKey);
+}
+
function getCacheKey(request: NextRequest): string {
// Usar un hash simple de las cookies principales de autenticación
const cookies = request.headers?.get('cookie') || '';
@@ -69,14 +80,20 @@ export async function getSession(request: NextRequest, response: NextResponse, f
const session = await fetchAuthSession(contextSpec, { forceRefresh });
const result = session.tokens !== undefined ? session : null;
- // Guardar en cache solo si la sesión es válida
- if (result && result.tokens) {
- sessionCache.set(cacheKey, result);
+ // Limpiar caché si la sesión no es válida
+ if (!result || !result.tokens) {
+ sessionCache.del(cacheKey);
+ return null;
}
+ // Guardar en cache solo si la sesión es válida
+ sessionCache.set(cacheKey, result);
+
return result;
} catch (error) {
console.error('Error fetching user session:', error);
+ // Limpiar caché en caso de error
+ sessionCache.del(cacheKey);
return null;
}
},
@@ -87,24 +104,31 @@ export async function handleAuthenticationMiddleware(request: NextRequest, respo
const session = await getSession(request, response);
if (!session) {
- return NextResponse.redirect(new URL('/login', request.url));
+ // Limpiar caché cuando no hay sesión válida
+ clearUserSessionCache(request);
+ return NextResponse.redirect(new URL('/login', request.url), { status: 302 });
}
return null; // Permitir que el middleware continúe
}
export async function handleAuthenticatedRedirectMiddleware(request: NextRequest, response: NextResponse) {
- const session = await getSession(request, response, false);
+ // Siempre forzar refresh para verificar la sesión actual, especialmente importante
+ // cuando el usuario navega manualmente a /login
+ const session = await getSession(request, response, true);
- if (session) {
+ if (session && typeof session === 'object' && 'tokens' in session && session.tokens) {
+ // Verificar que la sesión tiene tokens válidos antes de redirigir
const lastStoreId = getLastVisitedStore(request);
if (lastStoreId) {
- return NextResponse.redirect(new URL(`/store/${lastStoreId}/home`, request.url));
+ return NextResponse.redirect(new URL(`/store/${lastStoreId}/home`, request.url), { status: 302 });
} else {
- return NextResponse.redirect(new URL('/my-store', request.url));
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
}
}
+ // Si no hay sesión válida, limpiar caché y permitir continuar (mostrar login)
+ clearUserSessionCache(request);
return response;
}
diff --git a/middlewares/ownership/collectionOwnership.ts b/middlewares/ownership/collectionOwnership.ts
index d78df074..30888370 100644
--- a/middlewares/ownership/collectionOwnership.ts
+++ b/middlewares/ownership/collectionOwnership.ts
@@ -50,7 +50,7 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest)
const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username'];
if (!userId || typeof userId !== 'string') {
- return NextResponse.redirect(new URL('/login', request.url));
+ return NextResponse.redirect(new URL('/login', request.url), { status: 302 });
}
// Extraer información de la URL
@@ -61,7 +61,7 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest)
if (!currentStoreId) {
const redirectUrl = new URL('/my-store', request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -87,7 +87,7 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest)
if (!userStoreResult.data || userStoreResult.data.length === 0) {
const redirectUrl = new URL('/my-store', request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -114,14 +114,14 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest)
if (!collection) {
const redirectUrl = new URL(`/store/${currentStoreId}/products/collections`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
if (collection.storeId !== currentStoreId) {
const redirectUrl = new URL(`/store/${currentStoreId}/products/collections`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -129,7 +129,7 @@ export async function handleCollectionOwnershipMiddleware(request: NextRequest)
return NextResponse.next();
} catch (_error) {
const redirectUrl = new URL(`/my-store`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
diff --git a/middlewares/ownership/pagesOwnership.ts b/middlewares/ownership/pagesOwnership.ts
index 3e8bff34..4fb5acdc 100644
--- a/middlewares/ownership/pagesOwnership.ts
+++ b/middlewares/ownership/pagesOwnership.ts
@@ -50,7 +50,7 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) {
const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username'];
if (!userId || typeof userId !== 'string') {
- return NextResponse.redirect(new URL('/login', request.url));
+ return NextResponse.redirect(new URL('/login', request.url), { status: 302 });
}
// Extraer información de la URL
@@ -61,7 +61,7 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) {
if (!currentStoreId) {
const redirectUrl = new URL('/my-store', request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -87,7 +87,7 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) {
if (!userStoreResult.data || userStoreResult.data.length === 0) {
const redirectUrl = new URL('/my-store', request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -114,14 +114,14 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) {
if (!page) {
const redirectUrl = new URL(`/store/${currentStoreId}/setup/pages`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
if (page.storeId !== currentStoreId) {
const redirectUrl = new URL(`/store/${currentStoreId}/setup/pages/${pageId}`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -129,7 +129,7 @@ export async function handlePagesOwnershipMiddleware(request: NextRequest) {
return NextResponse.next();
} catch (_error) {
const redirectUrl = new URL(`/my-store`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
diff --git a/middlewares/ownership/productOwnership.ts b/middlewares/ownership/productOwnership.ts
index 72288ffa..48843429 100644
--- a/middlewares/ownership/productOwnership.ts
+++ b/middlewares/ownership/productOwnership.ts
@@ -50,7 +50,7 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) {
const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username'];
if (!userId || typeof userId !== 'string') {
- return NextResponse.redirect(new URL('/login', request.url));
+ return NextResponse.redirect(new URL('/login', request.url), { status: 302 });
}
// Extraer información de la URL
@@ -61,7 +61,7 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) {
if (!currentStoreId) {
const redirectUrl = new URL('/my-store', request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -87,7 +87,7 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) {
if (!userStoreResult.data || userStoreResult.data.length === 0) {
const redirectUrl = new URL('/my-store', request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -117,14 +117,14 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) {
if (!product) {
const redirectUrl = new URL(`/store/${currentStoreId}/products`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
if (product.storeId !== currentStoreId) {
const redirectUrl = new URL(`/store/${currentStoreId}/products`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
@@ -132,7 +132,7 @@ export async function handleProductOwnershipMiddleware(request: NextRequest) {
return NextResponse.next();
} catch (_error) {
const redirectUrl = new URL(`/my-store`, request.url);
- const response = NextResponse.redirect(redirectUrl);
+ const response = NextResponse.redirect(redirectUrl, { status: 302 });
response.headers.set('x-redirect-check', 'true');
return response;
}
diff --git a/middlewares/store-access/store.ts b/middlewares/store-access/store.ts
index 83724d65..4b82388d 100644
--- a/middlewares/store-access/store.ts
+++ b/middlewares/store-access/store.ts
@@ -16,6 +16,7 @@
import { getSession, handleAuthenticationMiddleware, type AuthSession } from '@/middlewares/auth/auth';
import { cookiesClient } from '@/utils/client/AmplifyUtils';
+import { getLastVisitedStore } from '@/lib/cookies/last-store';
import { NextRequest, NextResponse } from 'next/server';
const STORE_LIMITS = {
@@ -24,12 +25,6 @@ const STORE_LIMITS = {
Royal: 1,
};
-async function hasValidPlan(session: AuthSession) {
- const userPlan = session.tokens?.idToken?.payload?.['custom:plan'] as string | undefined;
- const allowedPlans = ['Royal', 'Majestic', 'Imperial'];
- return userPlan && allowedPlans.includes(userPlan);
-}
-
async function checkStoreLimit(userId: string, plan: string) {
try {
const { data: stores } = await cookiesClient.models.UserStore.listUserStoreByUserId({
@@ -52,6 +47,13 @@ async function checkStoreLimit(userId: string, plan: string) {
}
export async function handleStoreMiddleware(request: NextRequest, response: NextResponse) {
+ const path = request.nextUrl.pathname;
+
+ // Excluir rutas de checkout completamente - no validar nada
+ if (path.includes('/access_account/checkout') || path.includes('/checkout')) {
+ return response;
+ }
+
// Verificar autenticación usando el middleware centralizado
const authResponse = await handleAuthenticationMiddleware(request, response);
if (authResponse) {
@@ -63,24 +65,67 @@ export async function handleStoreMiddleware(request: NextRequest, response: Next
const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username'];
const userPlan = (session as AuthSession).tokens?.idToken?.payload?.['custom:plan'];
- const hasValidSubscription = await hasValidPlan(session as AuthSession);
- if (!hasValidSubscription) {
- return NextResponse.redirect(new URL('/pricing', request.url));
+ if (!userId) {
+ return NextResponse.redirect(new URL('/login', request.url), { status: 302 });
+ }
+
+ // Verificar plan de suscripción - misma lógica que storeAccess.ts
+ const validPlans = ['Royal', 'Majestic', 'Imperial'];
+
+ if (!userPlan || !validPlans.includes(userPlan)) {
+ // Usuario con plan 'free' o sin plan - verificar si tiene suscripción en DB
+ try {
+ const { data: subscriptions } = await cookiesClient.models.UserSubscription.listUserSubscriptionByUserId({
+ userId,
+ });
+
+ if (subscriptions && subscriptions.length > 0) {
+ // Tiene suscripción en DB pero plan 'free' - necesita reactivar
+ const lastStoreId = getLastVisitedStore(request);
+ if (lastStoreId) {
+ return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), {
+ status: 302,
+ });
+ } else {
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
+ }
+ } else {
+ // No tiene suscripción - usuario nuevo
+ const lastStoreId = getLastVisitedStore(request);
+ if (lastStoreId) {
+ return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), {
+ status: 302,
+ });
+ } else {
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
+ }
+ }
+ } catch (error) {
+ console.error('Error checking subscription:', error);
+ // En caso de error, redirigir a última tienda
+ const lastStoreId = getLastVisitedStore(request);
+ if (lastStoreId) {
+ return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), {
+ status: 302,
+ });
+ } else {
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
+ }
+ }
}
- const path = request.nextUrl.pathname;
const { hasStores, canCreateMore } = await checkStoreLimit(userId as string, userPlan as string);
if (path === '/first-steps') {
if (hasStores && !canCreateMore) {
- return NextResponse.redirect(new URL('/my-store', request.url));
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
}
}
if (path === '/my-store') {
if (!hasStores) {
- return NextResponse.redirect(new URL('/first-steps', request.url));
+ return NextResponse.redirect(new URL('/first-steps', request.url), { status: 302 });
}
}
diff --git a/middlewares/store-access/storeAccess.ts b/middlewares/store-access/storeAccess.ts
index 1586ec7b..daeeb851 100644
--- a/middlewares/store-access/storeAccess.ts
+++ b/middlewares/store-access/storeAccess.ts
@@ -16,6 +16,7 @@
import { getSession, handleAuthenticationMiddleware, type AuthSession } from '@/middlewares/auth/auth';
import { cookiesClient } from '@/utils/client/AmplifyUtils';
+import { getLastVisitedStore } from '@/lib/cookies/last-store';
import { NextRequest, NextResponse } from 'next/server';
/**
@@ -23,6 +24,9 @@ import { NextRequest, NextResponse } from 'next/server';
* Verifica que el usuario tenga acceso a la tienda solicitada y un plan de suscripción válido
*/
export async function handleStoreAccessMiddleware(request: NextRequest) {
+ // Extraer el ID de la tienda de la URL
+ const path = request.nextUrl.pathname;
+
// Verificar autenticación usando el middleware centralizado
const authResponse = await handleAuthenticationMiddleware(request, NextResponse.next());
if (authResponse) {
@@ -32,33 +36,108 @@ export async function handleStoreAccessMiddleware(request: NextRequest) {
// Obtener la sesión del usuario (ya validada)
const session = await getSession(request, NextResponse.next(), false);
- // Verificar plan de suscripción válido ANTES de verificar acceso a tienda
- const userPlan: string | undefined = (session as AuthSession).tokens?.idToken?.payload?.['custom:plan'] as
- | string
- | undefined;
- const allowedPlans = ['Royal', 'Majestic', 'Imperial'];
+ // Obtener el ID del usuario y plan desde la sesión
+ const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username'];
+ const userPlan = (session as AuthSession).tokens?.idToken?.payload?.['custom:plan'] as string | undefined;
- if (!userPlan || !allowedPlans.includes(userPlan)) {
- return NextResponse.redirect(new URL('/pricing', request.url));
+ if (!userId) {
+ return NextResponse.redirect(new URL('/login', request.url), { status: 302 });
}
- // Obtener el ID del usuario desde la sesión
- const userId = (session as AuthSession).tokens?.idToken?.payload?.['cognito:username'];
+ // Si es ruta de checkout, verificar que el usuario tenga el perfil correcto Y sea dueño de la tienda
+ if (path.includes('/access_account/checkout')) {
+ // Extraer el storeId de la URL
+ const storeIdMatch = path.match(/\/store\/([^\/]+)/);
+ if (!storeIdMatch || !storeIdMatch[1]) {
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
+ }
- if (!userId) {
- return NextResponse.redirect(new URL('/login', request.url));
+ const requestedStoreId = storeIdMatch[1];
+
+ // Si tiene plan válido, no debe estar aquí
+ const validPlans = ['Royal', 'Majestic', 'Imperial'];
+ if (userPlan && validPlans.includes(userPlan)) {
+ return NextResponse.redirect(new URL(`/store/${requestedStoreId}/home`, request.url), { status: 302 });
+ }
+
+ // Verificar que la tienda pertenece al usuario (CRÍTICO para seguridad)
+ try {
+ const { data: stores } = await cookiesClient.models.UserStore.listUserStoreByUserId(
+ {
+ userId: userId as string,
+ },
+ {
+ filter: {
+ storeId: { eq: requestedStoreId },
+ },
+ selectionSet: ['storeId'],
+ }
+ );
+
+ if (!stores || stores.length === 0) {
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
+ }
+ } catch (error) {
+ console.error('Error verifying store ownership for checkout:', error);
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
+ }
+
+ return NextResponse.next();
+ }
+
+ // Excluir otras rutas de checkout
+ if (path.includes('/checkout')) {
+ return NextResponse.next();
}
- // Extraer el ID de la tienda de la URL
- const path = request.nextUrl.pathname;
const storeIdMatch = path.match(/\/store\/([^\/]+)/);
if (!storeIdMatch || !storeIdMatch[1]) {
- return NextResponse.redirect(new URL('/my-store', request.url));
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
}
const requestedStoreId = storeIdMatch[1];
+ // Verificar plan de suscripción
+ const validPlans = ['Royal', 'Majestic', 'Imperial'];
+
+ if (!userPlan || !validPlans.includes(userPlan)) {
+ // Usuario con plan 'free' o sin plan - verificar si tiene suscripción en DB
+ try {
+ const { data: subscriptions } = await cookiesClient.models.UserSubscription.listUserSubscriptionByUserId({
+ userId,
+ });
+
+ if (subscriptions && subscriptions.length > 0) {
+ // Tiene suscripción en DB pero plan 'free' - necesita reactivar
+ return NextResponse.redirect(new URL(`/store/${requestedStoreId}/access_account/checkout`, request.url), {
+ status: 302,
+ });
+ } else {
+ // No tiene suscripción - usuario nuevo - redirigir a última tienda
+ const lastStoreId = getLastVisitedStore(request);
+ if (lastStoreId) {
+ return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), {
+ status: 302,
+ });
+ } else {
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
+ }
+ }
+ } catch (error) {
+ console.error('Error checking subscription:', error);
+ // En caso de error, redirigir a última tienda
+ const lastStoreId = getLastVisitedStore(request);
+ if (lastStoreId) {
+ return NextResponse.redirect(new URL(`/store/${lastStoreId}/access_account/checkout`, request.url), {
+ status: 302,
+ });
+ } else {
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
+ }
+ }
+ }
+
try {
// Verificar si la tienda pertenece al usuario
const { data: stores } = await cookiesClient.models.UserStore.listUserStoreByUserId(
@@ -75,13 +154,13 @@ export async function handleStoreAccessMiddleware(request: NextRequest) {
// Si la tienda no pertenece al usuario, redirigir a my-store
if (!stores || stores.length === 0) {
- return NextResponse.redirect(new URL('/my-store', request.url));
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
}
// Si todo está bien (plan válido y tienda pertenece al usuario), permitir el acceso
return NextResponse.next();
} catch (error) {
console.error('Error verifying store access:', error);
- return NextResponse.redirect(new URL('/my-store', request.url));
+ return NextResponse.redirect(new URL('/my-store', request.url), { status: 302 });
}
}
diff --git a/middlewares/subscription/subscription.ts b/middlewares/subscription/subscription.ts
index f29fcd4e..acc89e5d 100644
--- a/middlewares/subscription/subscription.ts
+++ b/middlewares/subscription/subscription.ts
@@ -33,7 +33,7 @@ export async function handleSubscriptionMiddleware(request: NextRequest, respons
const allowedPlans = ['Royal', 'Majestic', 'Imperial'];
if (!userPlan || !allowedPlans.includes(userPlan)) {
- return NextResponse.redirect(new URL('/pricing', request.url));
+ return NextResponse.redirect(new URL('/checkout', request.url), { status: 302 });
}
return response;
diff --git a/scripts/upload-base-template.js b/scripts/upload-base-template.js
index 82cb6eec..74b59d2f 100644
--- a/scripts/upload-base-template.js
+++ b/scripts/upload-base-template.js
@@ -22,7 +22,7 @@ const BUCKET_NAME = process.env.BUCKET_NAME;
const REGION = process.env.REGION_BUCKET || 'us-east-2';
const CLOUDFRONT_DISTRIBUTION_ID = process.env.CLOUDFRONT_DISTRIBUTION_ID;
const BASE_TEMPLATE_PREFIX = 'base-templates/default/';
-const TEMPLATE_DIR = join(process.cwd(), 'template');
+const TEMPLATE_DIR = join(process.cwd(), 'packages/example-themes/fasttify/theme');
const FILTER_MODULES_DIR = join(process.cwd(), 'packages/liquid-forge/liquid/tags/filters/js');
const FILTER_MODULES_PREFIX = 'assets/';
diff --git a/test/unit/integration/auth-flow.test.ts b/test/unit/integration/auth-flow.test.ts
index 6620be2f..7b68ae0f 100644
--- a/test/unit/integration/auth-flow.test.ts
+++ b/test/unit/integration/auth-flow.test.ts
@@ -23,6 +23,7 @@ jest.mock('node-cache', () => {
return jest.fn().mockImplementation(() => ({
get: jest.fn(),
set: jest.fn(),
+ del: jest.fn(),
flushAll: jest.fn(),
}));
});
diff --git a/test/unit/middlewares/auth-cache.test.ts b/test/unit/middlewares/auth-cache.test.ts
index af384714..820ff44c 100644
--- a/test/unit/middlewares/auth-cache.test.ts
+++ b/test/unit/middlewares/auth-cache.test.ts
@@ -2,6 +2,7 @@ jest.mock('node-cache', () => {
return jest.fn().mockImplementation(() => ({
get: jest.fn(),
set: jest.fn(),
+ del: jest.fn(),
flushAll: jest.fn(),
}));
});
diff --git a/test/unit/middlewares/auth.test.ts b/test/unit/middlewares/auth.test.ts
index ce7ccba3..6dbe2cdb 100644
--- a/test/unit/middlewares/auth.test.ts
+++ b/test/unit/middlewares/auth.test.ts
@@ -154,7 +154,7 @@ describe('Auth Middleware', () => {
const result = await handleAuthenticationMiddleware(mockRequest, mockResponse);
- expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', mockRequest.url));
+ expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', mockRequest.url), { status: 302 });
expect(result).toBe(mockRedirectResponse);
});
@@ -175,7 +175,7 @@ describe('Auth Middleware', () => {
const result = await handleAuthenticationMiddleware(mockRequest, mockResponse);
- expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', mockRequest.url));
+ expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', mockRequest.url), { status: 302 });
expect(result).toBe(mockRedirectResponse);
consoleErrorSpy.mockRestore();
@@ -211,7 +211,9 @@ describe('Auth Middleware', () => {
const result = await handleAuthenticatedRedirectMiddleware(mockRequestWithCookies, mockResponse);
- expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/my-store', mockRequestWithCookies.url));
+ expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/my-store', mockRequestWithCookies.url), {
+ status: 302,
+ });
expect(result).toBe(mockRedirectResponse);
});
@@ -243,7 +245,7 @@ describe('Auth Middleware', () => {
await handleAuthenticationMiddleware(customRequest, mockResponse);
- expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', customRequest.url));
+ expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/login', customRequest.url), { status: 302 });
});
it('should handle authenticated redirect with custom URL', async () => {
@@ -269,7 +271,7 @@ describe('Auth Middleware', () => {
await handleAuthenticatedRedirectMiddleware(customRequest, mockResponse);
- expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/my-store', customRequest.url));
+ expect(NextResponse.redirect).toHaveBeenCalledWith(new URL('/my-store', customRequest.url), { status: 302 });
});
});
});
diff --git a/utils/client/routes.ts b/utils/client/routes.ts
index 29ae4341..a5deecfc 100644
--- a/utils/client/routes.ts
+++ b/utils/client/routes.ts
@@ -60,6 +60,10 @@ export const routes = {
main: (storeId: string) => `/store/${storeId}/themes`,
edit: (storeId: string, themeId: string) => `/store/${storeId}/themes/${themeId}`,
},
+
+ accessAccount: {
+ checkout: (storeId: string) => `/store/${storeId}/access_account/checkout?no_redirect=true`,
+ },
},
auth: {