+{% endif %}
+```
+
+### 2. **Validación de Formularios**
+
+```javascript
+// Validación en tiempo real
+document.querySelectorAll('input[required]').forEach((input) => {
+ input.addEventListener('blur', function () {
+ if (!this.value.trim()) {
+ this.style.borderColor = '#dc3545';
+ } else {
+ this.style.borderColor = '#ced4da';
+ }
+ });
+});
+```
+
+### 3. **Estados de Carga**
+
+```css
+/* Mostrar indicadores de carga durante el proceso */
+.checkout-submit:disabled {
+ position: relative;
+ color: transparent;
+}
+
+.checkout-submit:disabled::after {
+ content: '';
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ top: 50%;
+ left: 50%;
+ margin-left: -10px;
+ margin-top: -10px;
+ border: 2px solid #ffffff;
+ border-radius: 50%;
+ border-top-color: transparent;
+ animation: spin 1s ease-in-out infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+```
+
+### 4. **Responsive Design**
+
+```css
+/* Optimización para móviles */
+@media (max-width: 480px) {
+ .checkout-container {
+ padding: 1rem 0.5rem;
+ }
+
+ .checkout-order-summary {
+ padding: 1rem;
+ }
+
+ .form-group input,
+ .form-group select,
+ .form-group textarea {
+ padding: 0.875rem;
+ font-size: 16px; /* Evita zoom en iOS */
+ }
+}
+```
+
+## Troubleshooting
+
+### Problemas Comunes
+
+#### 1. Token no encontrado
+
+**Causa**: Sesión expirada o token inválido
+**Solución**: Redirigir al carrito para generar nueva sesión
+
+#### 2. Error 405 en rutas de checkout
+
+**Causa**: Conflicto con middleware de dominio
+**Solución**: Verificar que las rutas `/api/stores/[storeId]/checkout/*` no sean reescritas
+
+#### 3. Redirección a dominio incorrecto
+
+**Causa**: `storeHost` no se resuelve correctamente
+**Solución**: Verificar headers `origin` y `referer` en las APIs
+
+#### 4. Formulario no se envía
+
+**Causa**: JavaScript no encuentra elementos
+**Solución**: Verificar que los selectores coincidan con el HTML
+
+### Debugging
+
+```javascript
+// Verificar que el checkout se carga correctamente
+console.log('Checkout object:', {{ checkout | json }});
+console.log('Store ID:', '{{ checkout.storeId }}');
+console.log('Token:', '{{ checkout.token }}');
+```
+
+## Próximas Mejoras
+
+- [ ] **Métodos de pago** - Integración con Stripe, PayPal
+- [ ] **Checkout de invitado** - Sin registro requerido
+- [ ] **Cálculo de envío** - Integración con APIs de envío
+- [ ] **Códigos de descuento** - Sistema de cupones
+- [ ] **Checkout express** - Un solo clic con información guardada
+
+---
+
+**Última actualización**: Sistema de checkout completamente funcional con flujo tokenizado y formularios profesionales.
+
+El sistema está listo para usar en producción y proporciona una experiencia de checkout segura y profesional para cualquier tema de Fasttify.
diff --git a/docs/cache/cache-invalidation.md b/docs/index.md
similarity index 80%
rename from docs/cache/cache-invalidation.md
rename to docs/index.md
index 0c3e4ddd..a378bc79 100644
--- a/docs/cache/cache-invalidation.md
+++ b/docs/index.md
@@ -20,6 +20,7 @@ Esta documentación cubre desde la personalización de temas y plantillas, hasta
- [Amplify Gen 2 Pagination Gotchas](./engine/amplify-gen2-pagination-gotchas.md) - Problemas conocidos de paginación
- [Cart System](./engine/cart-system.md) - **Sistema completo de carrito** - Guía para implementar carrito lateral en temas
+- [Checkout System](./engine/checkout-system.md) - **Sistema completo de checkout** - Guía para implementar flujo de pago en temas
- [Filters System](./engine/filters-system.md) - **Sistema de filtros de productos** - Guía completa para implementar filtros avanzados
- [Filters & Tags](./engine/filters-tags.md) - Filtros y tags Liquid disponibles
- [Liquid Data Access](./engine/liquid-data-access.md) - Acceso a datos en templates Liquid
@@ -92,6 +93,21 @@ Sistema de carrito lateral con funcionalidad completa para e-commerce:
- ✅ **Sistema de templates** para generación de HTML
- ✅ **Helpers reutilizables** para formateo y utilidades
+### Sistema de Checkout Completo
+
+Sistema de checkout tokenizado con pago manual para e-commerce:
+
+- ✅ **Checkout tokenizado** con URLs seguras `checkouts/cn/{token}`
+- ✅ **Sesiones temporales** con expiración automática (24 horas)
+- ✅ **Formularios profesionales** de información del cliente
+- ✅ **Integración automática** con el sistema de carrito
+- ✅ **Pago manual** con captura posterior por el dueño
+- ✅ **Redirección automática** al dominio de la tienda
+- ✅ **Estados de orden** con seguimiento completo
+- ✅ **Flujo responsive** optimizado para móviles
+- ✅ **Snapshot del carrito** preservado en la sesión
+- ✅ **Validación completa** de datos requeridos
+
### Sistema de Filtros Avanzado
Sistema de filtros de productos con funcionalidad completa:
@@ -141,10 +157,11 @@ Sistema automatizado para:
Si estás desarrollando un tema para Fasttify, comienza con:
1. [Cart System](./engine/cart-system.md) - **Sistema completo de carrito** - Implementación de carrito lateral
-2. [Filters System](./engine/filters-system.md) - **Sistema de filtros de productos** - Implementación de filtros avanzados
-3. [Search System](./engine/search-system.md) - Sistema de búsqueda automática
-4. [Theme Development Guide](./engine/theme-development-guide.md) - Guía de desarrollo
-5. [Filters & Tags](./engine/filters-tags.md) - Filtros disponibles
+2. [Checkout System](./engine/checkout-system.md) - **Sistema completo de checkout** - Implementación de flujo de pago
+3. [Filters System](./engine/filters-system.md) - **Sistema de filtros de productos** - Implementación de filtros avanzados
+4. [Search System](./engine/search-system.md) - Sistema de búsqueda automática
+5. [Theme Development Guide](./engine/theme-development-guide.md) - Guía de desarrollo
+6. [Filters & Tags](./engine/filters-tags.md) - Filtros disponibles
### Para Desarrolladores del Core
@@ -174,6 +191,41 @@ Si estás trabajando en el motor de renderizado:
```
+### Implementar Checkout en un Tema
+
+```liquid
+
+{
+ "sections": {
+ "main": { "type": "sections/checkout" }
+ },
+ "order": ["main"]
+}
+
+
+
+ {% if checkout %}
+
+ {% for item in checkout.line_items %}
+
+
+
{{ item.title }}
+
{{ item.line_price | money }}
+
+ {% endfor %}
+
+
+
+ {% else %}
+
Sesión de checkout no encontrada
+ {% endif %}
+
+```
+
### Implementar Filtros en un Tema
```liquid
@@ -254,4 +306,4 @@ Para preguntas sobre la documentación o el sistema:
---
-**Última actualización**: Sistema de filtros monolítico restaurado y optimizado, sistema de carrito modular, y documentación completa actualizada (Enero 2025)
+**Última actualización**: Sistema de checkout completado y documentado.
diff --git a/renderer-engine/config/page-config.ts b/renderer-engine/config/page-config.ts
index b6461af1..3585a314 100644
--- a/renderer-engine/config/page-config.ts
+++ b/renderer-engine/config/page-config.ts
@@ -12,6 +12,8 @@ const templatePaths: Record = {
search: 'templates/search.json',
cart: 'templates/cart.json',
'404': 'templates/404.json',
+ checkout_start: 'templates/checkout_start.json',
+ checkout: 'templates/checkout.json',
};
/**
diff --git a/renderer-engine/config/route-matchers.ts b/renderer-engine/config/route-matchers.ts
index 2b7073db..1834c210 100644
--- a/renderer-engine/config/route-matchers.ts
+++ b/renderer-engine/config/route-matchers.ts
@@ -86,6 +86,19 @@ export const routeMatchers: RouteMatcher[] = [
handler: () => ({ pageType: '404' }),
},
+ // ===== CHECKOUT =====
+ {
+ pattern: /^\/checkouts\/start$/,
+ handler: () => ({ pageType: 'checkout_start' }),
+ },
+ {
+ pattern: /^\/checkouts\/cn\/([a-zA-Z0-9_-]+)$/,
+ handler: (match) => ({
+ pageType: 'checkout',
+ checkoutToken: match[1],
+ }),
+ },
+
// ===== CASOS DE COMPATIBILIDAD =====
{
pattern: /^\/collections$/,
diff --git a/renderer-engine/liquid/engine.ts b/renderer-engine/liquid/engine.ts
index 7873d571..d7e7817e 100644
--- a/renderer-engine/liquid/engine.ts
+++ b/renderer-engine/liquid/engine.ts
@@ -41,7 +41,7 @@ class LiquidEngine {
*/
private createEngine(): Liquid {
const config: LiquidEngineConfig = {
- cache: false, // Sin cache interno para control manual
+ cache: true,
greedy: false,
trimTagLeft: false,
trimTagRight: false,
diff --git a/renderer-engine/renderers/pipeline-steps/build-context-step.ts b/renderer-engine/renderers/pipeline-steps/build-context-step.ts
index d45121d9..07bcb481 100644
--- a/renderer-engine/renderers/pipeline-steps/build-context-step.ts
+++ b/renderer-engine/renderers/pipeline-steps/build-context-step.ts
@@ -21,7 +21,8 @@ export async function buildContextStep(data: RenderingData): Promise;
}
+interface UserStoreCurrency {
+ storeCurrency?: string;
+}
export class CartFetcher {
/**
* Obtiene el carrito actual para una tienda.
@@ -19,8 +22,6 @@ export class CartFetcher {
* Si no existe, creará un nuevo carrito de invitado.
*/
public async getCart(storeId: string, sessionId: string): Promise {
- logger.info(`[CartFetcher] getCart called with sessionId: ${sessionId}`, null, 'CartFetcher');
-
try {
let rawCartData: CartRaw | undefined;
@@ -37,7 +38,13 @@ export class CartFetcher {
if (!rawCartData) {
const expiresAt = new Date();
- expiresAt.setDate(expiresAt.getDate() + 30); // 30 días de expiración
+ expiresAt.setDate(expiresAt.getDate() + 30);
+
+ let detectedCurrency: string | undefined;
+ try {
+ const { data: store } = await cookiesClient.models.UserStore.get({ storeId });
+ detectedCurrency = (store as UserStoreCurrency)?.storeCurrency || undefined;
+ } catch {}
const newCartData: any = {
storeId,
@@ -45,13 +52,13 @@ export class CartFetcher {
totalAmount: 0,
expiresAt: expiresAt.toISOString(),
sessionId: sessionId,
+ currency: detectedCurrency,
};
const { data: createdCart } = await cookiesClient.models.Cart.create(newCartData);
if (!createdCart) {
throw new Error('Failed to create new cart.');
}
- logger.info(`[CartFetcher] NEW Cart created with sessionId: ${createdCart.sessionId}`, null, 'CartFetcher');
rawCartData = createdCart;
}
@@ -113,18 +120,15 @@ export class CartFetcher {
updatedAt: product.updatedAt,
});
- // Buscar si el item ya existe en el carrito
const cartItemsResponse = await cookiesClient.models.CartItem.listCartItemByCartId(
{ cartId: currentCart.id },
{ filter: { productId: { eq: productId }, variantId: { eq: variantId || undefined } } }
);
- // Asegurarse de que data no es nulo/undefined antes de acceder a [0]
let existingCartItem =
cartItemsResponse.data && cartItemsResponse.data.length > 0 ? cartItemsResponse.data[0] : undefined;
if (existingCartItem) {
- // Actualizar cantidad del item existente
const updatedQuantity = existingCartItem.quantity + quantity;
const updatedTotalPrice = updatedQuantity * existingCartItem.unitPrice;
@@ -138,7 +142,6 @@ export class CartFetcher {
throw new Error('Failed to update cart item.');
}
} else {
- // Crear nuevo item
const newItemTotalPrice = productPrice * quantity;
const { data: createdItem } = await cookiesClient.models.CartItem.create({
cartId: currentCart.id,
@@ -156,7 +159,6 @@ export class CartFetcher {
}
}
- // Recalcular y actualizar totales del carrito
await this.recalculateCartTotals(currentCart.id);
const updatedCart = await this.getCart(storeId, sessionId || '');
@@ -186,10 +188,8 @@ export class CartFetcher {
}
if (quantity <= 0) {
- // Eliminar item si la cantidad es 0 o negativa
await cookiesClient.models.CartItem.delete({ id: itemId });
} else {
- // Actualizar item
const updatedTotalPrice = existingItem.unitPrice * quantity;
await cookiesClient.models.CartItem.update({
id: itemId,
@@ -198,7 +198,6 @@ export class CartFetcher {
});
}
- // Recalcular y actualizar totales del carrito
await this.recalculateCartTotals(currentCart.id);
const updatedCart = await this.getCart(storeId, sessionId || '');
@@ -227,7 +226,6 @@ export class CartFetcher {
await cookiesClient.models.CartItem.delete({ id: itemId });
- // Recalcular y actualizar totales del carrito
await this.recalculateCartTotals(currentCart.id);
const updatedCart = await this.getCart(storeId, sessionId || '');
@@ -248,7 +246,6 @@ export class CartFetcher {
return { success: false, error: 'Cart not found.' };
}
- // Obtener todos los ítems del carrito y eliminarlos
const { data: cartItems } = await cookiesClient.models.CartItem.listCartItemByCartId({
cartId: currentCart.id,
});
@@ -257,7 +254,6 @@ export class CartFetcher {
await cookiesClient.models.CartItem.delete({ id: item.id });
}
- // Resetear totales del carrito
await cookiesClient.models.Cart.update({
id: currentCart.id,
itemCount: 0,
@@ -283,10 +279,10 @@ export class CartFetcher {
id: cart.id,
item_count: totalItems,
total_price: totalPrice,
+ currency: cart.currency || 'COP',
items: Array.isArray(cart.items)
? cart.items.map((item) => {
let productSnapshotParsed: any = {};
- // Asegurarse de que productSnapshot sea un string antes de intentar parsear
if (typeof item.productSnapshot === 'string') {
try {
productSnapshotParsed = JSON.parse(item.productSnapshot);
diff --git a/renderer-engine/services/fetchers/checkout-data-transformer.ts b/renderer-engine/services/fetchers/checkout-data-transformer.ts
new file mode 100644
index 00000000..f76be9a2
--- /dev/null
+++ b/renderer-engine/services/fetchers/checkout-data-transformer.ts
@@ -0,0 +1,217 @@
+import { logger } from '@/renderer-engine/lib/logger';
+import type { CheckoutContext, CheckoutSession } from '@/renderer-engine/types';
+
+/**
+ * Transformador de datos específico para checkout
+ * Convierte los datos de CheckoutSession al formato esperado por Liquid templates
+ */
+export class CheckoutDataTransformer {
+ /**
+ * Transforma un item del carrito al formato esperado por checkout.liquid
+ */
+ private transformCartItem(item: any): any {
+ try {
+ // Parsear el productSnapshot que viene como JSON string
+ const productSnapshot = item.productSnapshot ? JSON.parse(item.productSnapshot) : {};
+
+ return {
+ id: item.id,
+ title: productSnapshot.title || productSnapshot.name || 'Producto',
+ variant_title: item.variantId ? 'Variante personalizada' : null,
+ quantity: item.quantity || 1,
+ price: item.unitPrice || productSnapshot.price || 0,
+ line_price: item.totalPrice || item.unitPrice * item.quantity || 0,
+ image: productSnapshot.featured_image || (productSnapshot.images && productSnapshot.images[0]) || null,
+ url: productSnapshot.url || `/products/${productSnapshot.slug || item.productId}`,
+ product_id: item.productId,
+ variant_id: item.variantId,
+ handle: productSnapshot.slug,
+ selectedAttributes: productSnapshot.selectedAttributes || {},
+ // Propiedades adicionales del producto
+ product: {
+ id: item.productId,
+ title: productSnapshot.title || productSnapshot.name,
+ images: productSnapshot.images || [],
+ price: productSnapshot.price || 0,
+ compare_at_price: productSnapshot.compare_at_price || null,
+ description: productSnapshot.description || '',
+ category: productSnapshot.category || '',
+ status: productSnapshot.status || 'active',
+ },
+ };
+ } catch (error) {
+ logger.error('Error transforming cart item:', error);
+
+ // Fallback en caso de error
+ return {
+ id: item.id,
+ title: 'Producto',
+ variant_title: null,
+ quantity: item.quantity || 1,
+ price: item.unitPrice || 0,
+ line_price: item.totalPrice || 0,
+ image: null,
+ url: `/products/${item.productId}`,
+ product_id: item.productId,
+ variant_id: item.variantId,
+ handle: item.productId,
+ selectedAttributes: {},
+ product: {
+ id: item.productId,
+ title: 'Producto',
+ images: [],
+ price: item.unitPrice || 0,
+ compare_at_price: null,
+ description: '',
+ category: '',
+ status: 'active',
+ },
+ };
+ }
+ }
+
+ /**
+ * Transforma la dirección al formato esperado por Liquid
+ */
+ private transformAddress(address: any): any {
+ if (!address) {
+ return {
+ address1: '',
+ address2: '',
+ city: '',
+ province: '',
+ zip: '',
+ country: 'CO',
+ first_name: '',
+ last_name: '',
+ phone: '',
+ };
+ }
+
+ return {
+ address1: address.address1 || '',
+ address2: address.address2 || '',
+ city: address.city || '',
+ province: address.province || address.state || '',
+ zip: address.zip || '',
+ country: address.country || 'CO',
+ first_name: address.first_name || '',
+ last_name: address.last_name || '',
+ phone: address.phone || '',
+ };
+ }
+
+ /**
+ * Transforma la información del cliente al formato esperado por Liquid
+ */
+ private transformCustomerInfo(customerInfo: any): any {
+ if (!customerInfo) {
+ return {
+ email: '',
+ firstName: '',
+ lastName: '',
+ phone: '',
+ };
+ }
+
+ return {
+ email: customerInfo.email || '',
+ firstName: customerInfo.firstName || '',
+ lastName: customerInfo.lastName || '',
+ phone: customerInfo.phone || '',
+ };
+ }
+
+ /**
+ * Transforma una sesión de checkout completa al formato Liquid
+ */
+ public transformSessionToContext(session: CheckoutSession): CheckoutContext {
+ try {
+ // Transformar items del carrito
+ const transformedItems = (session.itemsSnapshot?.items || []).map((item) => this.transformCartItem(item));
+
+ // Transformar direcciones y información del cliente
+ const customerInfo = this.transformCustomerInfo(session.customerInfo);
+ const shippingAddress = this.transformAddress(session.shippingAddress);
+ const billingAddress = this.transformAddress(session.billingAddress);
+
+ return {
+ storeId: session.storeId,
+ token: session.token,
+ line_items: transformedItems,
+ item_count: session.itemsSnapshot?.itemCount || transformedItems.length || 0,
+ total_price: session.totalAmount || 0,
+ subtotal_price: session.subtotal || 0,
+ shipping_price: session.shippingCost || 0,
+ tax_price: session.taxAmount || 0,
+ currency: session.currency || 'COP',
+ customer: customerInfo,
+ shipping_address: shippingAddress,
+ billing_address: billingAddress,
+ note: session.notes || '',
+ requires_shipping: true,
+ expires_at: session.expiresAt,
+
+ // Propiedades adicionales útiles
+ created_at: session.createdAt,
+ updated_at: session.updatedAt,
+ status: session.status,
+ session_id: session.sessionId,
+ cart_id: session.cartId,
+
+ // Información de totales adicional
+ totals: {
+ subtotal: session.subtotal || 0,
+ shipping: session.shippingCost || 0,
+ tax: session.taxAmount || 0,
+ total: session.totalAmount || 0,
+ },
+ };
+ } catch (error) {
+ logger.error('Error transforming checkout session to context:', error);
+
+ // Retornar estructura mínima válida en caso de error
+ return {
+ storeId: session.storeId || '',
+ token: session.token || '',
+ line_items: [],
+ item_count: 0,
+ total_price: 0,
+ subtotal_price: 0,
+ shipping_price: 0,
+ tax_price: 0,
+ currency: 'COP',
+ customer: this.transformCustomerInfo(null),
+ shipping_address: this.transformAddress(null),
+ billing_address: this.transformAddress(null),
+ note: '',
+ requires_shipping: true,
+ expires_at: session.expiresAt || new Date().toISOString(),
+ };
+ }
+ }
+
+ /**
+ * Valida que una sesión de checkout tenga los datos mínimos requeridos
+ */
+ public validateCheckoutSession(session: CheckoutSession): boolean {
+ if (!session || !session.token || !session.storeId) {
+ logger.warn('Invalid checkout session: missing required fields');
+ return false;
+ }
+
+ if (session.status !== 'open') {
+ logger.warn(`Checkout session ${session.token} is not open (status: ${session.status})`);
+ return false;
+ }
+
+ if (session.expiresAt && new Date(session.expiresAt) < new Date()) {
+ logger.warn(`Checkout session ${session.token} has expired`);
+ return false;
+ }
+
+ return true;
+ }
+}
+
+export const checkoutDataTransformer = new CheckoutDataTransformer();
diff --git a/renderer-engine/services/fetchers/checkout-fetcher.ts b/renderer-engine/services/fetchers/checkout-fetcher.ts
new file mode 100644
index 00000000..4cd04f03
--- /dev/null
+++ b/renderer-engine/services/fetchers/checkout-fetcher.ts
@@ -0,0 +1,299 @@
+import { logger } from '@/renderer-engine/lib/logger';
+import type {
+ Cart,
+ CartSnapshot,
+ CheckoutContext,
+ CheckoutResponse,
+ CheckoutSession,
+ CheckoutStatus,
+ StartCheckoutRequest,
+ UpdateCustomerInfoRequest,
+} from '@/renderer-engine/types';
+import { cookiesClient } from '@/utils/server/AmplifyServer';
+import crypto from 'crypto';
+import { checkoutDataTransformer } from './checkout-data-transformer';
+
+interface UserStoreCurrency {
+ storeCurrency?: string;
+}
+
+export class CheckoutFetcher {
+ /**
+ * Genera un token único para la sesión de checkout
+ * Formato: cn_ similar a Shopify
+ */
+ private generateToken(): string {
+ const raw = crypto.randomBytes(16).toString('base64url');
+ return `fs_${raw}`;
+ }
+
+ /**
+ * Obtiene el storeOwner (userId) basado en storeId
+ */
+ private async getStoreOwner(storeId: string): Promise {
+ try {
+ const { data: store } = await cookiesClient.models.UserStore.get({ storeId });
+ return (store as any)?.userId || '';
+ } catch (error) {
+ logger.error('Error getting store owner:', error);
+ throw new Error('Store not found');
+ }
+ }
+
+ /**
+ * Inicia una nueva sesión de checkout
+ */
+ public async startCheckout(request: StartCheckoutRequest, cart: Cart): Promise {
+ try {
+ const token = this.generateToken();
+ const storeOwner = await this.getStoreOwner(request.storeId);
+
+ // Configurar expiración (2 horas por defecto)
+ const expiresAt = new Date();
+ expiresAt.setHours(expiresAt.getHours() + 2);
+
+ // Calcular totales basados en el carrito
+ const subtotal = cart.totalAmount || 0;
+ const shippingCost = 0; // Por ahora, se puede calcular después
+ const taxAmount = 0; // Por ahora, se puede calcular después
+ const totalAmount = subtotal + shippingCost + taxAmount;
+
+ // Crear snapshot de los items del carrito
+ const itemsSnapshot: CartSnapshot = {
+ items: cart.items || [],
+ itemCount: cart.itemCount || 0,
+ cartTotal: cart.totalAmount || 0,
+ snapshotAt: new Date().toISOString(),
+ };
+
+ const sessionData = {
+ token,
+ storeId: request.storeId,
+ cartId: request.cartId,
+ sessionId: request.sessionId,
+ status: 'open' as const,
+ expiresAt: expiresAt.toISOString(),
+ currency: cart.currency || 'COP',
+ subtotal,
+ shippingCost,
+ taxAmount,
+ totalAmount,
+ itemsSnapshot: JSON.stringify(itemsSnapshot),
+ customerInfo: request.customerInfo ? JSON.stringify(request.customerInfo) : null,
+ shippingAddress: request.shippingAddress ? JSON.stringify(request.shippingAddress) : null,
+ billingAddress: request.billingAddress ? JSON.stringify(request.billingAddress) : null,
+ notes: request.notes,
+ storeOwner,
+ };
+
+ const response = await cookiesClient.models.CheckoutSession.create(sessionData);
+
+ if (response.data) {
+ logger.info(`Checkout session created: ${token} for store ${request.storeId}`);
+ return {
+ success: true,
+ session: this.transformToSession(response.data),
+ };
+ } else {
+ logger.error('Failed to create checkout session:', response.errors);
+ return {
+ success: false,
+ error: 'Failed to create checkout session',
+ };
+ }
+ } catch (error) {
+ logger.error('Error starting checkout:', error);
+ return {
+ success: false,
+ error: 'Internal error starting checkout',
+ };
+ }
+ }
+
+ /**
+ * Obtiene una sesión de checkout por token
+ */
+ public async getSessionByToken(token: string): Promise {
+ try {
+ const response = await cookiesClient.models.CheckoutSession.listCheckoutSessionByToken({ token }, { limit: 1 });
+
+ if (response.data && response.data.length > 0) {
+ const session = response.data[0];
+
+ // Verificar si la sesión ha expirado
+ if (session.expiresAt && new Date(session.expiresAt) < new Date()) {
+ // Marcar como expirada si no lo está ya
+ if (session.status === 'open') {
+ await this.updateSessionStatus(token, 'expired');
+ }
+ return null;
+ }
+
+ return this.transformToSession(session);
+ }
+
+ return null;
+ } catch (error) {
+ logger.error('Error getting checkout session:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Actualiza los datos del cliente en la sesión de checkout
+ */
+ public async updateCustomerInfo(request: UpdateCustomerInfoRequest): Promise {
+ try {
+ // Obtener la sesión raw directamente de la base de datos
+ const rawResponse = await cookiesClient.models.CheckoutSession.listCheckoutSessionByToken(
+ { token: request.token },
+ { limit: 1 }
+ );
+
+ if (!rawResponse.data || rawResponse.data.length === 0) {
+ return {
+ success: false,
+ error: 'Checkout session not found',
+ };
+ }
+
+ const rawSession = rawResponse.data[0];
+
+ if (rawSession.status !== 'open') {
+ return {
+ success: false,
+ error: 'Checkout session not available',
+ };
+ }
+
+ const updateData: any = {};
+ if (request.customerInfo) updateData.customerInfo = JSON.stringify(request.customerInfo);
+ if (request.shippingAddress) updateData.shippingAddress = JSON.stringify(request.shippingAddress);
+ if (request.billingAddress) updateData.billingAddress = JSON.stringify(request.billingAddress);
+ if (request.notes !== undefined) updateData.notes = request.notes;
+
+ const response = await cookiesClient.models.CheckoutSession.update({
+ id: rawSession.id,
+ ...updateData,
+ });
+
+ if (response.data) {
+ return {
+ success: true,
+ session: this.transformToSession(response.data),
+ };
+ } else {
+ return {
+ success: false,
+ error: 'Failed to update checkout session',
+ };
+ }
+ } catch (error) {
+ logger.error('Error updating checkout session:', error);
+ return {
+ success: false,
+ error: 'Internal error updating checkout session',
+ };
+ }
+ }
+
+ /**
+ * Actualiza el estado de una sesión de checkout
+ */
+ public async updateSessionStatus(token: string, status: CheckoutStatus): Promise {
+ try {
+ // Obtener la sesión raw directamente para tener el ID
+ const rawResponse = await cookiesClient.models.CheckoutSession.listCheckoutSessionByToken(
+ { token },
+ { limit: 1 }
+ );
+
+ if (!rawResponse.data || rawResponse.data.length === 0) {
+ return {
+ success: false,
+ error: 'Checkout session not found',
+ };
+ }
+
+ const rawSession = rawResponse.data[0];
+ const response = await cookiesClient.models.CheckoutSession.update({
+ id: rawSession.id,
+ status,
+ });
+
+ if (response.data) {
+ logger.info(`Checkout session ${token} updated to status: ${status}`);
+ return {
+ success: true,
+ session: this.transformToSession(response.data),
+ };
+ } else {
+ return {
+ success: false,
+ error: 'Failed to update checkout session status',
+ };
+ }
+ } catch (error) {
+ logger.error('Error updating checkout session status:', error);
+ return {
+ success: false,
+ error: 'Internal error updating checkout session',
+ };
+ }
+ }
+
+ /**
+ * Completa una sesión de checkout (la marca como completed)
+ */
+ public async completeCheckout(token: string): Promise {
+ return this.updateSessionStatus(token, 'completed');
+ }
+
+ /**
+ * Cancela una sesión de checkout
+ */
+ public async cancelCheckout(token: string): Promise {
+ return this.updateSessionStatus(token, 'cancelled');
+ }
+
+ /**
+ * Transforma los datos raw de Amplify a formato CheckoutSession
+ */
+ private transformToSession(rawData: any): CheckoutSession {
+ return {
+ token: rawData.token,
+ storeId: rawData.storeId,
+ cartId: rawData.cartId,
+ sessionId: rawData.sessionId,
+ status: rawData.status,
+ expiresAt: rawData.expiresAt,
+ currency: rawData.currency || 'COP',
+ subtotal: rawData.subtotal || 0,
+ shippingCost: rawData.shippingCost || 0,
+ taxAmount: rawData.taxAmount || 0,
+ totalAmount: rawData.totalAmount || 0,
+ itemsSnapshot: rawData.itemsSnapshot ? JSON.parse(rawData.itemsSnapshot) : null,
+ customerInfo: rawData.customerInfo ? JSON.parse(rawData.customerInfo) : null,
+ shippingAddress: rawData.shippingAddress ? JSON.parse(rawData.shippingAddress) : null,
+ billingAddress: rawData.billingAddress ? JSON.parse(rawData.billingAddress) : null,
+ notes: rawData.notes,
+ storeOwner: rawData.storeOwner,
+ };
+ }
+
+ /**
+ * Transforma sesión de checkout para uso en contexto Liquid
+ */
+ public transformSessionToContext(session: CheckoutSession): CheckoutContext {
+ return checkoutDataTransformer.transformSessionToContext(session);
+ }
+
+ /**
+ * Valida una sesión de checkout
+ */
+ public validateSession(session: CheckoutSession): boolean {
+ return checkoutDataTransformer.validateCheckoutSession(session);
+ }
+}
+
+export const checkoutFetcher = new CheckoutFetcher();
diff --git a/renderer-engine/services/page/data-loader/core/context-builder-helper.ts b/renderer-engine/services/page/data-loader/core/context-builder-helper.ts
index 3a991838..d2350feb 100644
--- a/renderer-engine/services/page/data-loader/core/context-builder-helper.ts
+++ b/renderer-engine/services/page/data-loader/core/context-builder-helper.ts
@@ -199,6 +199,30 @@ const pageContextBuilders: Record = {
baseContext.policies = loadedData.policies;
}
+ return baseContext;
+ },
+ checkout: (loadedData) => {
+ const baseContext: Record = {
+ template: 'checkout',
+ page_title: 'Checkout',
+ };
+
+ if (loadedData.checkout) {
+ baseContext.checkout = loadedData.checkout;
+ }
+
+ return baseContext;
+ },
+ checkout_start: (loadedData) => {
+ const baseContext: Record = {
+ template: 'checkout_start',
+ page_title: 'Checkout',
+ };
+
+ if (loadedData.checkout) {
+ baseContext.checkout = loadedData.checkout;
+ }
+
return baseContext;
},
};
diff --git a/renderer-engine/services/page/data-loader/handlers/data-handlers.ts b/renderer-engine/services/page/data-loader/handlers/data-handlers.ts
index 09130323..63fa75d0 100644
--- a/renderer-engine/services/page/data-loader/handlers/data-handlers.ts
+++ b/renderer-engine/services/page/data-loader/handlers/data-handlers.ts
@@ -1,4 +1,5 @@
import { logger } from '@/renderer-engine/lib/logger';
+import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout-fetcher';
import { dataFetcher } from '@/renderer-engine/services/fetchers/data-fetcher';
import type { DataLoadOptions, DataRequirement } from '@/renderer-engine/services/templates/analysis/template-analyzer';
import type { PageRenderOptions } from '@/renderer-engine/types/template';
@@ -214,6 +215,38 @@ export const dataHandlers: Record = {
// Pagination se maneja a nivel de template/request, no es un dato per se
return null;
},
+
+ checkout: async (storeId, options, pageOptions) => {
+ if (!pageOptions.checkoutToken) {
+ logger.warn('Checkout handler called without checkoutToken', undefined, 'DataHandlers');
+ return null;
+ }
+
+ try {
+ const checkoutSession = await checkoutFetcher.getSessionByToken(pageOptions.checkoutToken);
+
+ if (!checkoutSession) {
+ logger.warn(`Checkout session not found for token: ${pageOptions.checkoutToken}`, undefined, 'DataHandlers');
+ return null;
+ }
+
+ // Validar la sesión antes de transformar
+ if (!checkoutFetcher.validateSession(checkoutSession)) {
+ logger.warn(
+ `Checkout session validation failed for token: ${pageOptions.checkoutToken}`,
+ undefined,
+ 'DataHandlers'
+ );
+ return null;
+ }
+
+ // Transformar la sesión a formato compatible con Liquid
+ return checkoutFetcher.transformSessionToContext(checkoutSession);
+ } catch (error) {
+ logger.error('Error loading checkout data', error, 'DataHandlers');
+ return null;
+ }
+ },
};
/**
diff --git a/renderer-engine/services/page/data-loader/handlers/response-processors.ts b/renderer-engine/services/page/data-loader/handlers/response-processors.ts
index ed16afe7..be75dc61 100644
--- a/renderer-engine/services/page/data-loader/handlers/response-processors.ts
+++ b/renderer-engine/services/page/data-loader/handlers/response-processors.ts
@@ -116,4 +116,8 @@ export const responseProcessors: Record = {
loadedData[dataType] = data;
}
},
+
+ checkout: (data, dataType, loadedData) => {
+ loadedData[dataType] = data;
+ },
};
diff --git a/renderer-engine/services/rendering/global-context.ts b/renderer-engine/services/rendering/global-context.ts
index a7cf4e6e..3f794885 100644
--- a/renderer-engine/services/rendering/global-context.ts
+++ b/renderer-engine/services/rendering/global-context.ts
@@ -11,7 +11,8 @@ export class ContextBuilder {
products: any[],
storeTemplate?: any,
cartData?: CartContext,
- navigationMenus?: any
+ navigationMenus?: any,
+ checkoutData?: any
): Promise {
// Construir las partes del contexto
const shop = this.createShopContext(store);
@@ -40,6 +41,7 @@ export class ContextBuilder {
products,
linklists,
cart,
+ checkout: checkoutData, // Agregar datos de checkout al contexto
_currency_config: currencyConfig,
_store_template: storeTemplate, // Agregar acceso al storeTemplate
};
diff --git a/renderer-engine/services/templates/analysis/template-analyzer.ts b/renderer-engine/services/templates/analysis/template-analyzer.ts
index ab576182..2013d09e 100644
--- a/renderer-engine/services/templates/analysis/template-analyzer.ts
+++ b/renderer-engine/services/templates/analysis/template-analyzer.ts
@@ -21,7 +21,8 @@ export type DataRequirement =
| 'related_products' // Productos relacionados
| 'specific_page' // pages['handle'] o pages.handle
| 'pages' // {{ pages }} - todas las páginas
- | 'policies'; // {{ policies }} - todas las páginas de políticas
+ | 'policies' // {{ policies }} - todas las páginas de políticas
+ | 'checkout'; // {{ checkout }} - sesión de checkout
/**
* Opciones de carga para cada tipo de dato
@@ -163,6 +164,11 @@ export class TemplateAnalyzer {
if (!analysis.requiredData.has('page')) {
analysis.requiredData.set('page', {});
}
+ } else if (templatePath.includes('checkout')) {
+ // Página de checkout necesita datos de la sesión de checkout
+ if (!analysis.requiredData.has('checkout')) {
+ analysis.requiredData.set('checkout', {});
+ }
}
// Los linklists siempre son necesarios para navegación
diff --git a/renderer-engine/services/templates/parsing/liquid-syntax-detector.ts b/renderer-engine/services/templates/parsing/liquid-syntax-detector.ts
index be8f52ef..1965157e 100644
--- a/renderer-engine/services/templates/parsing/liquid-syntax-detector.ts
+++ b/renderer-engine/services/templates/parsing/liquid-syntax-detector.ts
@@ -186,6 +186,7 @@ const loadOptionsExtractors: Record DataLo
},
blog: () => ({}),
pagination: () => ({}),
+ checkout: () => ({}),
};
/**
@@ -262,6 +263,10 @@ const objectDetectors: Record = {
pattern: /\{\%\s*paginate/g,
optionsExtractor: loadOptionsExtractors.pagination,
},
+ checkout: {
+ pattern: /\{\{\s*checkout\./g,
+ optionsExtractor: loadOptionsExtractors.checkout,
+ },
};
export class LiquidSyntaxDetector {
diff --git a/renderer-engine/types/checkout.ts b/renderer-engine/types/checkout.ts
new file mode 100644
index 00000000..9b9dbdb1
--- /dev/null
+++ b/renderer-engine/types/checkout.ts
@@ -0,0 +1,99 @@
+export interface CustomerInfo {
+ email?: string;
+ firstName?: string;
+ lastName?: string;
+ phone?: string;
+}
+
+export interface Address {
+ address1?: string;
+ address2?: string;
+ city?: string;
+ province?: string;
+ zip?: string;
+ country?: string;
+}
+
+export interface StartCheckoutRequest {
+ storeId: string;
+ cartId?: string;
+ sessionId: string;
+ customerInfo?: CustomerInfo;
+ shippingAddress?: Address;
+ billingAddress?: Address;
+ notes?: string;
+}
+
+export interface CheckoutSession {
+ token: string;
+ storeId: string;
+ createdAt?: string;
+ updatedAt?: string;
+ cartId?: string;
+ sessionId: string;
+ status: 'open' | 'completed' | 'expired' | 'cancelled';
+ expiresAt: string;
+ currency: string;
+ subtotal: number;
+ shippingCost: number;
+ taxAmount: number;
+ totalAmount: number;
+ itemsSnapshot?: CartSnapshot;
+ customerInfo?: CustomerInfo;
+ shippingAddress?: Address;
+ billingAddress?: Address;
+ notes?: string;
+ storeOwner: string;
+}
+
+export interface CartSnapshot {
+ items: any[];
+ itemCount: number;
+ cartTotal: number;
+ snapshotAt: string;
+}
+
+export interface CheckoutResponse {
+ success: boolean;
+ session?: CheckoutSession;
+ error?: string;
+}
+
+export interface CheckoutContext {
+ storeId: string;
+ token: string;
+ line_items: any[];
+ item_count: number;
+ total_price: number;
+ subtotal_price: number;
+ shipping_price: number;
+ tax_price: number;
+ currency: string;
+ customer: CustomerInfo;
+ shipping_address: Address;
+ billing_address: Address;
+ note?: string;
+ requires_shipping: boolean;
+ expires_at: string;
+ created_at?: string;
+ updated_at?: string;
+ status?: string;
+ session_id?: string;
+ cart_id?: string;
+ totals?: {
+ subtotal: number;
+ shipping: number;
+ tax: number;
+ total: number;
+ };
+}
+
+export type CheckoutStatus = 'open' | 'completed' | 'expired' | 'cancelled';
+
+export interface UpdateCustomerInfoRequest {
+ token: string;
+ customerInfo?: CustomerInfo;
+ shippingAddress?: Address;
+ billingAddress?: Address;
+ notes?: string;
+}
diff --git a/renderer-engine/types/index.ts b/renderer-engine/types/index.ts
index ec281341..db150cae 100644
--- a/renderer-engine/types/index.ts
+++ b/renderer-engine/types/index.ts
@@ -68,3 +68,16 @@ export type {
CartResponse,
UpdateCartRequest,
} from '@/renderer-engine/types/cart';
+
+// Checkout types
+export type {
+ Address,
+ CartSnapshot,
+ CheckoutContext,
+ CheckoutResponse,
+ CheckoutSession,
+ CheckoutStatus,
+ CustomerInfo,
+ StartCheckoutRequest,
+ UpdateCustomerInfoRequest,
+} from '@/renderer-engine/types/checkout';
diff --git a/renderer-engine/types/template.ts b/renderer-engine/types/template.ts
index 91ad5514..f0a2f48d 100644
--- a/renderer-engine/types/template.ts
+++ b/renderer-engine/types/template.ts
@@ -31,6 +31,7 @@ export interface RenderContext {
product?: ProductContext;
collection?: CollectionContext;
cart?: any;
+ checkout?: any;
pagination?: PaginationContext;
preloaded_sections?: Record;
_assetCollector?: AssetCollector;
@@ -241,7 +242,9 @@ export type PageType =
| 'search'
| 'cart'
| '404'
- | 'policies';
+ | 'policies'
+ | 'checkout_start'
+ | 'checkout';
export interface PageRenderOptions {
pageType: PageType;
@@ -250,6 +253,7 @@ export interface PageRenderOptions {
collectionId?: string;
collectionHandle?: string;
searchTerm?: string;
+ checkoutToken?: string;
}
export interface PaginationInfo {
diff --git a/template/assets/cart/cart-templates.js b/template/assets/cart/cart-templates.js
index ad43eb0a..2fc64778 100644
--- a/template/assets/cart/cart-templates.js
+++ b/template/assets/cart/cart-templates.js
@@ -68,7 +68,7 @@ class CartTemplates {