From 04d5b0583258d680ba0cd3fe565ea04ebaaae741 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 12 Aug 2025 13:48:11 -0500 Subject: [PATCH 1/5] feat(cart): Remove debug logs and adjust cache settings - Removed debug logging in the production cart API. - Changed the cache setting in the Liquid engine to true to improve performance. - Added store currency detection when creating a new cart, improving currency management. --- app/api/stores/[storeId]/cart/route.ts | 3 -- renderer-engine/liquid/engine.ts | 2 +- .../services/fetchers/cart-fetcher.ts | 28 ++++++++----------- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/app/api/stores/[storeId]/cart/route.ts b/app/api/stores/[storeId]/cart/route.ts index 3fcabc16..4c412387 100644 --- a/app/api/stores/[storeId]/cart/route.ts +++ b/app/api/stores/[storeId]/cart/route.ts @@ -25,9 +25,6 @@ export async function GET(request: NextRequest, { params }: RouteContext) { let sessionId = cookiesStore.get(SESSION_ID_COOKIE_NAME)?.value; let newSessionIdGenerated = false; - // Log para debugging en producción - logger.info(`[Cart API] GET request - storeId: ${storeId}, sessionId: ${sessionId || 'NOT_FOUND'}`, null, 'CartAPI'); - if (!sessionId) { sessionId = uuidv4(); newSessionIdGenerated = true; 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/services/fetchers/cart-fetcher.ts b/renderer-engine/services/fetchers/cart-fetcher.ts index 5dbfb5ac..0cc710ac 100644 --- a/renderer-engine/services/fetchers/cart-fetcher.ts +++ b/renderer-engine/services/fetchers/cart-fetcher.ts @@ -12,6 +12,9 @@ export interface AddToCartRequest { selectedAttributes?: Record; } +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); From 4d6049f2c891452a814e73f6805ea7a87c846bf5 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Aug 2025 15:48:18 -0500 Subject: [PATCH 2/5] feat(checkout): Implementation of checkout functionality - New models and types related to the checkout process have been added, including `CheckoutSession` and `CheckoutStatus`. - New routes and templates have been defined for the home and checkout pages. - Context loading for checkout pages has been improved, ensuring that relevant information is available. - Adjustments have been made to types and data structures to support payment session management. --- amplify/backend.ts | 6 +- amplify/data/models/checkout-session.ts | 123 ++++ amplify/data/models/user-store.ts | 1 + amplify/data/resource.ts | 2 + .../[storeId]/checkout/complete/route.ts | 89 +++ .../stores/[storeId]/checkout/start/route.ts | 77 +++ renderer-engine/config/page-config.ts | 2 + renderer-engine/config/route-matchers.ts | 13 + .../services/fetchers/checkout-fetcher.ts | 286 +++++++++ .../core/context-builder-helper.ts | 24 + .../templates/analysis/template-analyzer.ts | 3 +- renderer-engine/types/checkout.ts | 85 +++ renderer-engine/types/index.ts | 13 + renderer-engine/types/template.ts | 6 +- template/sections/checkout-start.liquid | 371 +++++++++++ template/sections/checkout.liquid | 604 ++++++++++++++++++ template/templates/checkout.json | 14 + template/templates/checkout_start.json | 13 + 18 files changed, 1727 insertions(+), 5 deletions(-) create mode 100644 amplify/data/models/checkout-session.ts create mode 100644 app/api/stores/[storeId]/checkout/complete/route.ts create mode 100644 app/api/stores/[storeId]/checkout/start/route.ts create mode 100644 renderer-engine/services/fetchers/checkout-fetcher.ts create mode 100644 renderer-engine/types/checkout.ts create mode 100644 template/sections/checkout-start.liquid create mode 100644 template/sections/checkout.liquid create mode 100644 template/templates/checkout.json create mode 100644 template/templates/checkout_start.json diff --git a/amplify/backend.ts b/amplify/backend.ts index 80fa2487..61de8d65 100644 --- a/amplify/backend.ts +++ b/amplify/backend.ts @@ -2,7 +2,7 @@ import { defineBackend } from '@aws-amplify/backend'; import { CfnOutput, Stack } from 'aws-cdk-lib'; import { AuthorizationType, Cors, LambdaIntegration, MethodLoggingLevel, RestApi } from 'aws-cdk-lib/aws-apigateway'; import { Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; -import * as kms from 'aws-cdk-lib/aws-kms'; +//import * as kms from 'aws-cdk-lib/aws-kms'; import { postConfirmation } from './auth/post-confirmation/resource'; import { auth } from './auth/resource'; import { @@ -135,7 +135,7 @@ backend.generatePriceSuggestionFunction.resources.lambda.addToRolePolicy( ); // Define la clave KMS para la encriptación de las claves de pago -const paymentKeysKmsKey = new kms.Key(backend.stack, 'PaymentKeysKmsKey', { +/*const paymentKeysKmsKey = new kms.Key(backend.stack, 'PaymentKeysKmsKey', { description: 'KMS key for encrypting payment gateway keys', enableKeyRotation: true, // Habilitar la rotación de claves para mayor seguridad alias: `alias/FasttifyPaymentKeys-${stageName}`, // Alias amigable para referenciar la clave @@ -154,7 +154,7 @@ backend.managePaymentKeys.resources.lambda.addToRolePolicy( new CfnOutput(backend.stack, 'PaymentKeysKmsKeyArn', { value: paymentKeysKmsKey.keyArn, description: 'ARN of the KMS key for encrypting payment gateway keys', -}); +});*/ backend.postConfirmation.resources.lambda.addToRolePolicy( new PolicyStatement({ diff --git a/amplify/data/models/checkout-session.ts b/amplify/data/models/checkout-session.ts new file mode 100644 index 00000000..e927e4a7 --- /dev/null +++ b/amplify/data/models/checkout-session.ts @@ -0,0 +1,123 @@ +import { a } from '@aws-amplify/backend'; + +export const checkoutSessionModel = a + .model({ + token: a + .string() + .required() + .authorization((allow) => [ + allow.publicApiKey().to(['read', 'create']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + storeId: a + .string() + .required() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + cartId: a + .string() + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + sessionId: a + .string() + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + status: a.enum(['open', 'completed', 'expired', 'cancelled']), + expiresAt: a + .datetime() + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + currency: a + .string() + .default('COP') + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + subtotal: a + .float() + .default(0) + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + shippingCost: a + .float() + .default(0) + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read', 'update']), + allow.ownerDefinedIn('storeOwner').to(['read', 'update']), + ]), + taxAmount: a + .float() + .default(0) + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read', 'update']), + allow.ownerDefinedIn('storeOwner').to(['read', 'update']), + ]), + totalAmount: a + .float() + .default(0) + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read', 'update']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + itemsSnapshot: a + .json() + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + customerInfo: a + .json() + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read', 'update']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + shippingAddress: a + .json() + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read', 'update']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + billingAddress: a + .json() + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read', 'update']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + notes: a + .string() + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read', 'update']), + allow.ownerDefinedIn('storeOwner').to(['read']), + ]), + storeOwner: a + .string() + .required() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + store: a.belongsTo('UserStore', 'storeId'), + }) + .secondaryIndexes((index) => [ + index('token'), + index('storeId'), + index('sessionId'), + index('status'), + index('storeOwner'), + index('expiresAt'), + ]) + .authorization((allow) => [ + allow.publicApiKey().to(['create', 'read', 'update']), + allow.ownerDefinedIn('storeOwner').to(['create', 'read', 'update']), + ]); diff --git a/amplify/data/models/user-store.ts b/amplify/data/models/user-store.ts index c4baae99..8a6c8f4f 100644 --- a/amplify/data/models/user-store.ts +++ b/amplify/data/models/user-store.ts @@ -60,6 +60,7 @@ export const userStoreModel = a storePaymentConfig: a.hasMany('StorePaymentConfig', 'storeId'), storeCustomDomain: a.hasOne('StoreCustomDomain', 'storeId'), userThemes: a.hasMany('UserTheme', 'storeId'), + checkoutSessions: a.hasMany('CheckoutSession', 'storeId'), }) .identifier(['storeId']) .secondaryIndexes((index) => [index('userId'), index('storeName'), index('defaultDomain')]) diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 808208d1..bf0d9c6e 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -9,6 +9,7 @@ import { webHookPlan } from '../functions/webHookPlan/resource'; // Importacion de modelos import { cartModel } from './models/cart'; import { cartItemModel } from './models/cart-item'; +import { checkoutSessionModel } from './models/checkout-session'; import { collectionModel } from './models/collection'; import { navigationMenuModel } from './models/navigation-menu'; import { orderModel } from './models/order'; @@ -116,6 +117,7 @@ const schema = a Page: pageModel, Cart: cartModel, CartItem: cartItemModel, + CheckoutSession: checkoutSessionModel, Order: orderModel, OrderItem: orderItemModel, StorePaymentConfig: storePaymentConfigModel, diff --git a/app/api/stores/[storeId]/checkout/complete/route.ts b/app/api/stores/[storeId]/checkout/complete/route.ts new file mode 100644 index 00000000..3f15514a --- /dev/null +++ b/app/api/stores/[storeId]/checkout/complete/route.ts @@ -0,0 +1,89 @@ +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout-fetcher'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ storeId: string }> }) { + const corsHeaders = await getNextCorsHeaders(request); + + // Obtener el host original de la tienda para las redirecciones + const storeHost = + request.headers.get('origin') || + request.headers.get('referer')?.split('/')[0] + '//' + request.headers.get('referer')?.split('/')[2]; + + try { + const { storeId } = await params; + const formData = await request.formData(); + + // Obtener token del query string + const url = new URL(request.url); + const token = url.searchParams.get('token'); + + if (!token) { + return NextResponse.redirect(`${storeHost}/cart?error=invalid_token`, { status: 303, headers: corsHeaders }); + } + + // Extraer datos del formulario + const customerInfo = { + email: formData.get('customer[email]') as string, + firstName: formData.get('customer[firstName]') as string, + lastName: formData.get('customer[lastName]') as string, + phone: formData.get('customer[phone]') as string, + }; + + const shippingAddress = { + address1: formData.get('shipping_address[address1]') as string, + address2: formData.get('shipping_address[address2]') as string, + city: formData.get('shipping_address[city]') as string, + province: formData.get('shipping_address[state]') as string, + zip: formData.get('shipping_address[zip]') as string, + country: formData.get('shipping_address[country]') as string, + }; + + const notes = formData.get('notes') as string; + + // Actualizar información del cliente + const updateResponse = await checkoutFetcher.updateCustomerInfo({ + token, + customerInfo, + shippingAddress, + billingAddress: shippingAddress, // Por ahora usamos la misma dirección + notes, + }); + + if (!updateResponse.success) { + return NextResponse.redirect(`${storeHost}/checkouts/cn/${token}?error=update_failed`, { + status: 303, + headers: corsHeaders, + }); + } + + // Completar el checkout + const completeResponse = await checkoutFetcher.completeCheckout(token); + + if (!completeResponse.success) { + return NextResponse.redirect(`${storeHost}/checkouts/cn/${token}?error=complete_failed`, { + status: 303, + headers: corsHeaders, + }); + } + + // Redirigir a página de confirmación en el dominio de la tienda + return NextResponse.redirect(`${storeHost}/checkouts/cn/${token}/confirmation`, { + status: 303, + headers: corsHeaders, + }); + } catch (error) { + console.error('Error completing checkout:', error); + const url = new URL(request.url); + const token = url.searchParams.get('token'); + return NextResponse.redirect(`${storeHost}/checkouts/cn/${token || 'error'}?error=checkout_error`, { + status: 303, + headers: corsHeaders, + }); + } +} diff --git a/app/api/stores/[storeId]/checkout/start/route.ts b/app/api/stores/[storeId]/checkout/start/route.ts new file mode 100644 index 00000000..e6830e17 --- /dev/null +++ b/app/api/stores/[storeId]/checkout/start/route.ts @@ -0,0 +1,77 @@ +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { cartFetcher } from '@/renderer-engine/services/fetchers/cart-fetcher'; +import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout-fetcher'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +const SESSION_COOKIE = 'fasttify_cart_session_id'; + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ storeId: string }> }) { + const corsHeaders = await getNextCorsHeaders(request); + + // Obtener el host original de la tienda para las redirecciones + const storeHost = + request.headers.get('origin') || + request.headers.get('referer')?.split('/')[0] + '//' + request.headers.get('referer')?.split('/')[2]; + + try { + const { storeId } = await params; + + // Obtener sessionId del formulario o cookies + const formData = await request.formData(); + let sessionId = formData.get('session_id') as string; + + // Fallback a cookies si no viene en el formulario + if (!sessionId) { + const cookiesStore = await cookies(); + sessionId = cookiesStore.get(SESSION_COOKIE)?.value || ''; + } + + if (!sessionId) { + const referer = request.headers.get('referer') || `${storeHost}/cart`; + return NextResponse.redirect(referer + '?error=no_session', { status: 303, headers: corsHeaders }); + } + + // Obtener el carrito actual + const cart = await cartFetcher.getCart(storeId, sessionId); + + if (!cart || !cart.items || cart.items.length === 0) { + const referer = request.headers.get('referer') || `${storeHost}/cart`; + return NextResponse.redirect(referer + '?error=empty_cart', { status: 303, headers: corsHeaders }); + } + + console.log('Cart found for checkout:', { + cartId: cart.id, + itemCount: cart.items?.length || 0, + sessionId: sessionId, + }); + + // Iniciar sesión de checkout + const checkoutResponse = await checkoutFetcher.startCheckout( + { + storeId, + sessionId, + cartId: cart.id, + }, + cart + ); + + if (!checkoutResponse.success || !checkoutResponse.session?.token) { + const referer = request.headers.get('referer') || `${storeHost}/cart`; + return NextResponse.redirect(referer + '?error=checkout_failed', { status: 303, headers: corsHeaders }); + } + + // Redirigir a la página de checkout con el token en el dominio de la tienda + const checkoutUrl = `${storeHost}/checkouts/cn/${checkoutResponse.session.token}`; + return NextResponse.redirect(checkoutUrl, { status: 303, headers: corsHeaders }); + } catch (error) { + console.error('Error starting checkout:', error); + const referer = request.headers.get('referer') || `${storeHost}/cart`; + return NextResponse.redirect(referer + '?error=checkout_error', { status: 303, headers: corsHeaders }); + } +} 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/services/fetchers/checkout-fetcher.ts b/renderer-engine/services/fetchers/checkout-fetcher.ts new file mode 100644 index 00000000..426ef4de --- /dev/null +++ b/renderer-engine/services/fetchers/checkout-fetcher.ts @@ -0,0 +1,286 @@ +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'; + +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 { + const session = await this.getSessionByToken(request.token); + if (!session || session.status !== 'open') { + return { + success: false, + error: 'Checkout session not found or 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: (session as any).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 { + const session = await this.getSessionByToken(token); + if (!session) { + return { + success: false, + error: 'Checkout session not found', + }; + } + + const response = await cookiesClient.models.CheckoutSession.update({ + id: (session as any).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 { + token: session.token, + line_items: session.itemsSnapshot?.items || [], + item_count: session.itemsSnapshot?.itemCount || 0, + total_price: session.totalAmount, + subtotal_price: session.subtotal, + shipping_price: session.shippingCost, + tax_price: session.taxAmount, + currency: session.currency, + customer: session.customerInfo || {}, + shipping_address: session.shippingAddress || {}, + billing_address: session.billingAddress || {}, + note: session.notes, + requires_shipping: true, // Por ahora siempre true + expires_at: session.expiresAt, + }; + } +} + +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/templates/analysis/template-analyzer.ts b/renderer-engine/services/templates/analysis/template-analyzer.ts index ab576182..1eddd02d 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 diff --git a/renderer-engine/types/checkout.ts b/renderer-engine/types/checkout.ts new file mode 100644 index 00000000..fc9ab767 --- /dev/null +++ b/renderer-engine/types/checkout.ts @@ -0,0 +1,85 @@ +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; + 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 { + 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; +} + +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/sections/checkout-start.liquid b/template/sections/checkout-start.liquid new file mode 100644 index 00000000..203e5bfe --- /dev/null +++ b/template/sections/checkout-start.liquid @@ -0,0 +1,371 @@ +{% comment %} + Sección para iniciar el proceso de checkout + Esta sección se usa cuando el usuario presiona "Proceder al checkout" +{% endcomment %} + +
+
+
+

Proceder al Checkout

+ +
+

Resumen del Pedido

+ + +
+
+

Cargando carrito...

+
+ + + + + + +
+
+
+
+ +{% style %} +.checkout-start-section { + padding: 2rem 0; + background: #f9f9f9; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 0 1rem; +} + +.checkout-start-content { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.checkout-start-title { + font-size: 2rem; + margin-bottom: 2rem; + text-align: center; + color: #333; +} + +.checkout-summary h2 { + font-size: 1.5rem; + margin-bottom: 1.5rem; + color: #333; + border-bottom: 2px solid #eee; + padding-bottom: 0.5rem; +} + +.checkout-items { + margin-bottom: 2rem; +} + +.checkout-item { + display: flex; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid #eee; +} + +.checkout-item:last-child { + border-bottom: none; +} + +.checkout-item-image { + width: 80px; + height: 80px; + margin-right: 1rem; + flex-shrink: 0; +} + +.checkout-item-image img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px; +} + +.checkout-item-details { + flex: 1; +} + +.checkout-item-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: #333; +} + +.checkout-item-variant { + font-size: 0.9rem; + color: #666; + margin-bottom: 0.25rem; +} + +.checkout-item-quantity { + font-size: 0.9rem; + color: #666; + margin-bottom: 0.25rem; +} + +.checkout-item-price { + font-size: 1.1rem; + font-weight: 600; + color: #333; +} + +.checkout-totals { + border-top: 2px solid #eee; + padding-top: 1rem; + margin-bottom: 2rem; +} + +.checkout-total-line { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.checkout-total-final { + font-size: 1.2rem; + border-top: 1px solid #eee; + padding-top: 0.5rem; + margin-top: 1rem; +} + +.checkout-actions { + display: flex; + gap: 1rem; + flex-direction: column; +} + +.checkout-form { + width: 100%; +} + +.btn { + display: inline-block; + padding: 0.75rem 1.5rem; + border-radius: 4px; + text-decoration: none; + text-align: center; + font-weight: 600; + border: none; + cursor: pointer; + transition: all 0.3s ease; + width: 100%; +} + +.btn-primary { + background-color: #007cba; + color: white; +} + +.btn-primary:hover { + background-color: #005a87; +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; + border: 1px solid #ddd; +} + +.btn-secondary:hover { + background-color: #eee; +} + +.btn-checkout { + font-size: 1.1rem; + padding: 1rem 1.5rem; +} + +.empty-cart-message { + text-align: center; + padding: 3rem 1rem; +} + +.empty-cart-message h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + color: #666; +} + +.empty-cart-message p { + margin-bottom: 2rem; + color: #888; +} + +.checkout-loading { + text-align: center; + padding: 3rem 1rem; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #007cba; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@media (min-width: 768px) { + .checkout-actions { + flex-direction: row; + } + + .btn { + width: auto; + } +} +{% endstyle %} + +{% javascript %} +document.addEventListener('DOMContentLoaded', function() { + loadCheckoutCart(); +}); + +async function loadCheckoutCart() { + const loadingEl = document.getElementById('checkout-loading'); + const containerEl = document.getElementById('checkout-items-container'); + const emptyEl = document.getElementById('empty-cart-message'); + + try { + // Usar la API del carrito existente + const data = await window.cartAPI.getCart(); + + if (data.success && data.cart && data.cart.item_count > 0) { + displayCartItems(data.cart); + loadingEl.style.display = 'none'; + containerEl.style.display = 'block'; + } else { + loadingEl.style.display = 'none'; + emptyEl.style.display = 'block'; + } + } catch (error) { + console.error('Error loading cart for checkout:', error); + loadingEl.style.display = 'none'; + emptyEl.style.display = 'block'; + } +} + +function displayCartItems(cart) { + const itemsContainer = document.getElementById('checkout-items'); + const totalsContainer = document.getElementById('checkout-totals'); + + // Generar HTML de items + const itemsHTML = cart.items.map(item => ` +
+
+ ${item.image ? `${item.title}` : '
'} +
+ +
+

${item.title}

+ ${item.variant_title && item.variant_title !== 'Default Title' ? `

${item.variant_title}

` : ''} +

Cantidad: ${item.quantity}

+

${window.formatMoney ? window.formatMoney(item.line_price) : item.line_price}

+
+
+ `).join(''); + + // Generar HTML de totales + const totalsHTML = ` +
+ Subtotal: + ${window.formatMoney ? window.formatMoney(cart.total_price) : cart.total_price} +
+
+ + Total: + ${window.formatMoney ? window.formatMoney(cart.total_price) : cart.total_price} + +
+ `; + + itemsContainer.innerHTML = itemsHTML; + totalsContainer.innerHTML = totalsHTML; +} + +function startCheckout() { + // Obtener sessionId de las cookies + const sessionId = getCookie('fasttify_cart_session_id'); + + if (!sessionId) { + alert('No se encontró una sesión de carrito activa. Por favor, agrega productos al carrito primero.'); + return; + } + + // Crear formulario dinámico para enviar el POST + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `/api/stores/${window.STORE_ID}/checkout/start`; + + const sessionInput = document.createElement('input'); + sessionInput.type = 'hidden'; + sessionInput.name = 'session_id'; + sessionInput.value = sessionId; + + form.appendChild(sessionInput); + document.body.appendChild(form); + form.submit(); +} + +function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; +} +{% endjavascript %} + +{% schema %} +{ + "name": "Checkout Start", + "settings": [ + { + "type": "text", + "id": "title", + "label": "Título", + "default": "Proceder al Checkout" + } + ] +} +{% endschema %} diff --git a/template/sections/checkout.liquid b/template/sections/checkout.liquid new file mode 100644 index 00000000..63f83820 --- /dev/null +++ b/template/sections/checkout.liquid @@ -0,0 +1,604 @@ +{% comment %} + Sección principal del checkout + Muestra el formulario de información del cliente y resumen del pedido +{% endcomment %} + +
+
+ {% if checkout %} +
+
+

Finalizar Compra

+

Completa tu información para procesar tu pedido

+
+ +
+ +
+
+ + + +
+

Información de Contacto

+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+

Dirección de Envío

+ +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+

Notas del Pedido

+
+
+ + +
+
+
+ + +
+ + +

+ Al completar el pedido, el dueño de la tienda recibirá los detalles y se pondrá en contacto contigo para coordinar el pago y la entrega. +

+
+
+
+ + +
+
+

Resumen del Pedido

+ +
+ {% for item in checkout.line_items %} +
+
+ {% if item.image %} + {{ item.title }} + {% endif %} +
+ +
+

{{ item.title }}

+ {% if item.variant_title and item.variant_title != 'Default Title' %} +

{{ item.variant_title }}

+ {% endif %} +

Cantidad: {{ item.quantity }}

+
+ +
+ {{ item.line_price | money }} +
+
+ {% endfor %} +
+ +
+
+ Subtotal + {{ checkout.subtotal_price | money }} +
+ + {% if checkout.shipping_price > 0 %} +
+ Envío + {{ checkout.shipping_price | money }} +
+ {% endif %} + + {% if checkout.tax_price > 0 %} +
+ Impuestos + {{ checkout.tax_price | money }} +
+ {% endif %} + +
+ + Total + {{ checkout.total_price | money }} + +
+
+ +
+

+ 🔒 Información segura y protegida +

+
+
+
+
+
+ {% else %} +
+

Sesión de Checkout No Encontrada

+

La sesión de checkout ha expirado o no es válida.

+ Volver al Carrito +
+ {% endif %} +
+
+ +{% style %} +.checkout-section { + padding: 2rem 0; + background: #f9f9f9; + min-height: 80vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.checkout-header { + text-align: center; + margin-bottom: 2rem; +} + +.checkout-title { + font-size: 2.5rem; + color: #333; + margin-bottom: 0.5rem; +} + +.checkout-subtitle { + color: #666; + font-size: 1.1rem; +} + +.checkout-layout { + display: grid; + grid-template-columns: 1fr; + gap: 2rem; +} + +@media (min-width: 1024px) { + .checkout-layout { + grid-template-columns: 2fr 1fr; + } +} + +.checkout-form-section { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.form-section { + margin-bottom: 2rem; +} + +.form-section:last-child { + margin-bottom: 0; +} + +.form-section-title { + font-size: 1.3rem; + color: #333; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid #eee; +} + +.form-row { + display: flex; + gap: 1rem; + margin-bottom: 1rem; +} + +.form-row:last-child { + margin-bottom: 0; +} + +.form-group { + flex: 1; +} + +.form-group.half { + flex: 0.5; +} + +.form-label { + display: block; + font-weight: 600; + color: #333; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.form-input, +.form-textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.3s ease; +} + +.form-input:focus, +.form-textarea:focus { + outline: none; + border-color: #007cba; + box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2); +} + +.form-textarea { + resize: vertical; + min-height: 80px; +} + +.btn { + display: inline-block; + padding: 0.75rem 1.5rem; + border-radius: 4px; + text-decoration: none; + text-align: center; + font-weight: 600; + border: none; + cursor: pointer; + transition: all 0.3s ease; +} + +.btn-primary { + background-color: #007cba; + color: white; +} + +.btn-primary:hover { + background-color: #005a87; +} + +.btn-checkout { + width: 100%; + font-size: 1.1rem; + padding: 1rem 1.5rem; + position: relative; +} + +.btn-checkout:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.checkout-disclaimer { + font-size: 0.9rem; + color: #666; + text-align: center; + margin-top: 1rem; + line-height: 1.4; +} + +/* Resumen del pedido */ +.checkout-summary-section { + position: relative; +} + +.checkout-summary { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.sticky { + position: sticky; + top: 2rem; +} + +.summary-title { + font-size: 1.3rem; + color: #333; + margin-bottom: 1.5rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid #eee; +} + +.summary-items { + margin-bottom: 1.5rem; +} + +.summary-item { + display: flex; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid #f0f0f0; +} + +.summary-item:last-child { + border-bottom: none; +} + +.item-image { + width: 60px; + height: 60px; + margin-right: 1rem; + flex-shrink: 0; +} + +.item-image img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px; +} + +.item-details { + flex: 1; +} + +.item-title { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.25rem; + color: #333; +} + +.item-variant, +.item-quantity { + font-size: 0.8rem; + color: #666; + margin-bottom: 0.25rem; +} + +.item-price { + font-weight: 600; + color: #333; +} + +.summary-totals { + border-top: 2px solid #eee; + padding-top: 1rem; +} + +.total-line { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.total-final { + font-size: 1.1rem; + border-top: 1px solid #eee; + padding-top: 0.5rem; + margin-top: 1rem; +} + +.checkout-security { + margin-top: 1.5rem; + text-align: center; +} + +.security-text { + font-size: 0.9rem; + color: #666; +} + +.checkout-error { + background: white; + border-radius: 8px; + padding: 3rem 2rem; + text-align: center; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.checkout-error h1 { + color: #333; + margin-bottom: 1rem; +} + +.checkout-error p { + color: #666; + margin-bottom: 2rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .form-row { + flex-direction: column; + gap: 0; + } + + .form-group.half { + flex: 1; + } + + .checkout-title { + font-size: 2rem; + } + + .checkout-form-section, + .checkout-summary { + padding: 1.5rem; + } +} +{% endstyle %} + +{% javascript %} +document.addEventListener('DOMContentLoaded', function() { + const form = document.getElementById('checkout-form'); + const submitBtn = document.getElementById('submit-checkout'); + const btnText = submitBtn.querySelector('.btn-text'); + const btnLoading = submitBtn.querySelector('.btn-loading'); + + if (form && submitBtn) { + form.addEventListener('submit', function() { + submitBtn.disabled = true; + btnText.style.display = 'none'; + btnLoading.style.display = 'inline'; + }); + } +}); +{% endjavascript %} + +{% schema %} +{ + "name": "Checkout", + "settings": [ + { + "type": "text", + "id": "title", + "label": "Título", + "default": "Finalizar Compra" + }, + { + "type": "text", + "id": "subtitle", + "label": "Subtítulo", + "default": "Completa tu información para procesar tu pedido" + } + ] +} +{% endschema %} diff --git a/template/templates/checkout.json b/template/templates/checkout.json new file mode 100644 index 00000000..04ca8e4a --- /dev/null +++ b/template/templates/checkout.json @@ -0,0 +1,14 @@ +{ + "sections": { + "checkout": { + "type": "sections/checkout", + "settings": { + "title": "Finalizar Compra", + "subtitle": "Completa tu información para procesar tu pedido" + } + } + }, + "order": [ + "checkout" + ] +} diff --git a/template/templates/checkout_start.json b/template/templates/checkout_start.json new file mode 100644 index 00000000..2154d54b --- /dev/null +++ b/template/templates/checkout_start.json @@ -0,0 +1,13 @@ +{ + "sections": { + "checkout_start": { + "type": "sections/checkout-start", + "settings": { + "title": "Proceder al Checkout" + } + } + }, + "order": [ + "checkout_start" + ] +} From 2b2530eb4a5dcabbce66ebef780d58bf9af5cc8d Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Aug 2025 17:19:41 -0500 Subject: [PATCH 3/5] feat(checkout): Checkout session data integration and context management improvements - Added handling of 'checkoutSessions' to the `useUserStoreData` hook. - Adjustments have been made to `buildContextStep` to include payment data in the context. - Improved the `CheckoutFetcher` class to retrieve and validate payment sessions directly from the database. - Implemented a new checkout data handler in `data-handlers`, which validates and transforms the checkout session. - Updated checkout-related types to include new fields. - Improved the cart UI to facilitate the direct checkout process. --- .../first-steps/hooks/useUserStoreData.ts | 1 + .../stores/[storeId]/checkout/direct/route.ts | 61 +++ .../pipeline-steps/build-context-step.ts | 3 +- .../fetchers/checkout-data-transformer.ts | 217 ++++++++++ .../services/fetchers/checkout-fetcher.ts | 59 +-- .../data-loader/handlers/data-handlers.ts | 33 ++ .../services/rendering/global-context.ts | 4 +- .../templates/analysis/template-analyzer.ts | 5 + renderer-engine/types/checkout.ts | 14 + template/assets/cart/cart-templates.js | 2 +- template/assets/cart/cart-ui.js | 34 ++ template/sections/checkout-start.liquid | 371 ------------------ template/sections/checkout.liquid | 15 +- template/templates/checkout_start.json | 13 - 14 files changed, 417 insertions(+), 415 deletions(-) create mode 100644 app/api/stores/[storeId]/checkout/direct/route.ts create mode 100644 renderer-engine/services/fetchers/checkout-data-transformer.ts delete mode 100644 template/sections/checkout-start.liquid delete mode 100644 template/templates/checkout_start.json diff --git a/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts b/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts index b055449f..49a0a7c6 100644 --- a/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts +++ b/app/(setup-layout)/first-steps/hooks/useUserStoreData.ts @@ -280,6 +280,7 @@ export const useUserStoreData = () => { | 'storePaymentConfig' | 'storeCustomDomain' | 'userThemes' + | 'checkoutSessions' > ) => { try { diff --git a/app/api/stores/[storeId]/checkout/direct/route.ts b/app/api/stores/[storeId]/checkout/direct/route.ts new file mode 100644 index 00000000..78f77b0f --- /dev/null +++ b/app/api/stores/[storeId]/checkout/direct/route.ts @@ -0,0 +1,61 @@ +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { cartFetcher } from '@/renderer-engine/services/fetchers/cart-fetcher'; +import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout-fetcher'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +const SESSION_COOKIE = 'fasttify_cart_session_id'; + +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, { params }: { params: Promise<{ storeId: string }> }) { + const corsHeaders = await getNextCorsHeaders(request); + + // Obtener el host original de la tienda para las redirecciones + const storeHost = + request.headers.get('origin') || + request.headers.get('referer')?.split('/')[0] + '//' + request.headers.get('referer')?.split('/')[2]; + + try { + const { storeId } = await params; + + // Obtener sessionId de cookies + const cookiesStore = await cookies(); + const sessionId = cookiesStore.get(SESSION_COOKIE)?.value; + + if (!sessionId) { + return NextResponse.redirect(`${storeHost}/cart?error=no_session`, { status: 303, headers: corsHeaders }); + } + + // Obtener el carrito actual + const cart = await cartFetcher.getCart(storeId, sessionId); + + if (!cart || !cart.items || cart.items.length === 0) { + return NextResponse.redirect(`${storeHost}/cart?error=empty_cart`, { status: 303, headers: corsHeaders }); + } + + // Iniciar sesión de checkout directamente + const checkoutResponse = await checkoutFetcher.startCheckout( + { + storeId, + sessionId, + cartId: cart.id, + }, + cart + ); + + if (!checkoutResponse.success || !checkoutResponse.session?.token) { + return NextResponse.redirect(`${storeHost}/cart?error=checkout_failed`, { status: 303, headers: corsHeaders }); + } + + // Redirigir directamente a la página de checkout con el token + const checkoutUrl = `${storeHost}/checkouts/cn/${checkoutResponse.session.token}`; + return NextResponse.redirect(checkoutUrl, { status: 303, headers: corsHeaders }); + } catch (error) { + console.error('Error creating direct checkout:', error); + return NextResponse.redirect(`${storeHost}/cart?error=checkout_error`, { status: 303, headers: corsHeaders }); + } +} 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 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 index 426ef4de..4cd04f03 100644 --- a/renderer-engine/services/fetchers/checkout-fetcher.ts +++ b/renderer-engine/services/fetchers/checkout-fetcher.ts @@ -11,6 +11,7 @@ import type { } from '@/renderer-engine/types'; import { cookiesClient } from '@/utils/server/AmplifyServer'; import crypto from 'crypto'; +import { checkoutDataTransformer } from './checkout-data-transformer'; interface UserStoreCurrency { storeCurrency?: string; @@ -143,11 +144,25 @@ export class CheckoutFetcher { */ public async updateCustomerInfo(request: UpdateCustomerInfoRequest): Promise { try { - const session = await this.getSessionByToken(request.token); - if (!session || session.status !== 'open') { + // 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 found or not available', + error: 'Checkout session not available', }; } @@ -158,7 +173,7 @@ export class CheckoutFetcher { if (request.notes !== undefined) updateData.notes = request.notes; const response = await cookiesClient.models.CheckoutSession.update({ - id: (session as any).id, + id: rawSession.id, ...updateData, }); @@ -187,16 +202,22 @@ export class CheckoutFetcher { */ public async updateSessionStatus(token: string, status: CheckoutStatus): Promise { try { - const session = await this.getSessionByToken(token); - if (!session) { + // 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: (session as any).id, + id: rawSession.id, status, }); @@ -264,22 +285,14 @@ export class CheckoutFetcher { * Transforma sesión de checkout para uso en contexto Liquid */ public transformSessionToContext(session: CheckoutSession): CheckoutContext { - return { - token: session.token, - line_items: session.itemsSnapshot?.items || [], - item_count: session.itemsSnapshot?.itemCount || 0, - total_price: session.totalAmount, - subtotal_price: session.subtotal, - shipping_price: session.shippingCost, - tax_price: session.taxAmount, - currency: session.currency, - customer: session.customerInfo || {}, - shipping_address: session.shippingAddress || {}, - billing_address: session.billingAddress || {}, - note: session.notes, - requires_shipping: true, // Por ahora siempre true - expires_at: session.expiresAt, - }; + return checkoutDataTransformer.transformSessionToContext(session); + } + + /** + * Valida una sesión de checkout + */ + public validateSession(session: CheckoutSession): boolean { + return checkoutDataTransformer.validateCheckoutSession(session); } } 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/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 1eddd02d..2013d09e 100644 --- a/renderer-engine/services/templates/analysis/template-analyzer.ts +++ b/renderer-engine/services/templates/analysis/template-analyzer.ts @@ -164,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/types/checkout.ts b/renderer-engine/types/checkout.ts index fc9ab767..9b9dbdb1 100644 --- a/renderer-engine/types/checkout.ts +++ b/renderer-engine/types/checkout.ts @@ -27,6 +27,8 @@ export interface StartCheckoutRequest { export interface CheckoutSession { token: string; storeId: string; + createdAt?: string; + updatedAt?: string; cartId?: string; sessionId: string; status: 'open' | 'completed' | 'expired' | 'cancelled'; @@ -58,6 +60,7 @@ export interface CheckoutResponse { } export interface CheckoutContext { + storeId: string; token: string; line_items: any[]; item_count: number; @@ -72,6 +75,17 @@ export interface CheckoutContext { 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'; 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 {

${subtotal}

- Finalizar Compra +
`; diff --git a/template/assets/cart/cart-ui.js b/template/assets/cart/cart-ui.js index a848f180..5cacfd07 100644 --- a/template/assets/cart/cart-ui.js +++ b/template/assets/cart/cart-ui.js @@ -72,6 +72,7 @@ class CartUI { // Setup controls after rendering this.setupQuantityControls(); this.setupRemoveButtons(); + this.setupCheckoutButtons(); } showCartFooter() { @@ -164,6 +165,39 @@ class CartUI { }); }); } + + setupCheckoutButtons() { + // Checkout directo + this.sidebar.querySelectorAll('[data-checkout-direct]').forEach(button => { + button.addEventListener('click', async (e) => { + e.preventDefault(); + + try { + // Cambiar texto del botón a estado de carga + const originalText = button.innerHTML; + button.innerHTML = 'Procesando...'; + button.disabled = true; + + // Obtener store ID + const storeId = window.STORE_ID; + if (!storeId) { + throw new Error('Store ID no disponible'); + } + + // Redireccionar a la API de checkout directo + window.location.href = `/api/stores/${storeId}/checkout/direct`; + + } catch (error) { + console.error('Error en checkout directo:', error); + CartHelpers.showError(`Error al iniciar checkout: ${error.message}`); + + // Restaurar botón + button.innerHTML = originalText; + button.disabled = false; + } + }); + }); + } } // Exportar como global diff --git a/template/sections/checkout-start.liquid b/template/sections/checkout-start.liquid deleted file mode 100644 index 203e5bfe..00000000 --- a/template/sections/checkout-start.liquid +++ /dev/null @@ -1,371 +0,0 @@ -{% comment %} - Sección para iniciar el proceso de checkout - Esta sección se usa cuando el usuario presiona "Proceder al checkout" -{% endcomment %} - -
-
-
-

Proceder al Checkout

- -
-

Resumen del Pedido

- - -
-
-

Cargando carrito...

-
- - - - - - -
-
-
-
- -{% style %} -.checkout-start-section { - padding: 2rem 0; - background: #f9f9f9; -} - -.container { - max-width: 800px; - margin: 0 auto; - padding: 0 1rem; -} - -.checkout-start-content { - background: white; - border-radius: 8px; - padding: 2rem; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); -} - -.checkout-start-title { - font-size: 2rem; - margin-bottom: 2rem; - text-align: center; - color: #333; -} - -.checkout-summary h2 { - font-size: 1.5rem; - margin-bottom: 1.5rem; - color: #333; - border-bottom: 2px solid #eee; - padding-bottom: 0.5rem; -} - -.checkout-items { - margin-bottom: 2rem; -} - -.checkout-item { - display: flex; - align-items: center; - padding: 1rem 0; - border-bottom: 1px solid #eee; -} - -.checkout-item:last-child { - border-bottom: none; -} - -.checkout-item-image { - width: 80px; - height: 80px; - margin-right: 1rem; - flex-shrink: 0; -} - -.checkout-item-image img { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 4px; -} - -.checkout-item-details { - flex: 1; -} - -.checkout-item-title { - font-size: 1.1rem; - font-weight: 600; - margin-bottom: 0.5rem; - color: #333; -} - -.checkout-item-variant { - font-size: 0.9rem; - color: #666; - margin-bottom: 0.25rem; -} - -.checkout-item-quantity { - font-size: 0.9rem; - color: #666; - margin-bottom: 0.25rem; -} - -.checkout-item-price { - font-size: 1.1rem; - font-weight: 600; - color: #333; -} - -.checkout-totals { - border-top: 2px solid #eee; - padding-top: 1rem; - margin-bottom: 2rem; -} - -.checkout-total-line { - display: flex; - justify-content: space-between; - margin-bottom: 0.5rem; - font-size: 1rem; -} - -.checkout-total-final { - font-size: 1.2rem; - border-top: 1px solid #eee; - padding-top: 0.5rem; - margin-top: 1rem; -} - -.checkout-actions { - display: flex; - gap: 1rem; - flex-direction: column; -} - -.checkout-form { - width: 100%; -} - -.btn { - display: inline-block; - padding: 0.75rem 1.5rem; - border-radius: 4px; - text-decoration: none; - text-align: center; - font-weight: 600; - border: none; - cursor: pointer; - transition: all 0.3s ease; - width: 100%; -} - -.btn-primary { - background-color: #007cba; - color: white; -} - -.btn-primary:hover { - background-color: #005a87; -} - -.btn-secondary { - background-color: #f5f5f5; - color: #333; - border: 1px solid #ddd; -} - -.btn-secondary:hover { - background-color: #eee; -} - -.btn-checkout { - font-size: 1.1rem; - padding: 1rem 1.5rem; -} - -.empty-cart-message { - text-align: center; - padding: 3rem 1rem; -} - -.empty-cart-message h2 { - font-size: 1.5rem; - margin-bottom: 1rem; - color: #666; -} - -.empty-cart-message p { - margin-bottom: 2rem; - color: #888; -} - -.checkout-loading { - text-align: center; - padding: 3rem 1rem; -} - -.loading-spinner { - width: 40px; - height: 40px; - border: 4px solid #f3f3f3; - border-top: 4px solid #007cba; - border-radius: 50%; - animation: spin 1s linear infinite; - margin: 0 auto 1rem; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -@media (min-width: 768px) { - .checkout-actions { - flex-direction: row; - } - - .btn { - width: auto; - } -} -{% endstyle %} - -{% javascript %} -document.addEventListener('DOMContentLoaded', function() { - loadCheckoutCart(); -}); - -async function loadCheckoutCart() { - const loadingEl = document.getElementById('checkout-loading'); - const containerEl = document.getElementById('checkout-items-container'); - const emptyEl = document.getElementById('empty-cart-message'); - - try { - // Usar la API del carrito existente - const data = await window.cartAPI.getCart(); - - if (data.success && data.cart && data.cart.item_count > 0) { - displayCartItems(data.cart); - loadingEl.style.display = 'none'; - containerEl.style.display = 'block'; - } else { - loadingEl.style.display = 'none'; - emptyEl.style.display = 'block'; - } - } catch (error) { - console.error('Error loading cart for checkout:', error); - loadingEl.style.display = 'none'; - emptyEl.style.display = 'block'; - } -} - -function displayCartItems(cart) { - const itemsContainer = document.getElementById('checkout-items'); - const totalsContainer = document.getElementById('checkout-totals'); - - // Generar HTML de items - const itemsHTML = cart.items.map(item => ` -
-
- ${item.image ? `${item.title}` : '
'} -
- -
-

${item.title}

- ${item.variant_title && item.variant_title !== 'Default Title' ? `

${item.variant_title}

` : ''} -

Cantidad: ${item.quantity}

-

${window.formatMoney ? window.formatMoney(item.line_price) : item.line_price}

-
-
- `).join(''); - - // Generar HTML de totales - const totalsHTML = ` -
- Subtotal: - ${window.formatMoney ? window.formatMoney(cart.total_price) : cart.total_price} -
-
- - Total: - ${window.formatMoney ? window.formatMoney(cart.total_price) : cart.total_price} - -
- `; - - itemsContainer.innerHTML = itemsHTML; - totalsContainer.innerHTML = totalsHTML; -} - -function startCheckout() { - // Obtener sessionId de las cookies - const sessionId = getCookie('fasttify_cart_session_id'); - - if (!sessionId) { - alert('No se encontró una sesión de carrito activa. Por favor, agrega productos al carrito primero.'); - return; - } - - // Crear formulario dinámico para enviar el POST - const form = document.createElement('form'); - form.method = 'POST'; - form.action = `/api/stores/${window.STORE_ID}/checkout/start`; - - const sessionInput = document.createElement('input'); - sessionInput.type = 'hidden'; - sessionInput.name = 'session_id'; - sessionInput.value = sessionId; - - form.appendChild(sessionInput); - document.body.appendChild(form); - form.submit(); -} - -function getCookie(name) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop().split(';').shift(); - return null; -} -{% endjavascript %} - -{% schema %} -{ - "name": "Checkout Start", - "settings": [ - { - "type": "text", - "id": "title", - "label": "Título", - "default": "Proceder al Checkout" - } - ] -} -{% endschema %} diff --git a/template/sections/checkout.liquid b/template/sections/checkout.liquid index 63f83820..79a69032 100644 --- a/template/sections/checkout.liquid +++ b/template/sections/checkout.liquid @@ -201,7 +201,11 @@
{% if item.image %} - {{ item.title }} + {{ item.title }} + {% else %} +
+ Sin imagen +
{% endif %}
@@ -570,14 +574,15 @@ document.addEventListener('DOMContentLoaded', function() { const form = document.getElementById('checkout-form'); const submitBtn = document.getElementById('submit-checkout'); - const btnText = submitBtn.querySelector('.btn-text'); - const btnLoading = submitBtn.querySelector('.btn-loading'); if (form && submitBtn) { + const btnText = submitBtn.querySelector('.btn-text'); + const btnLoading = submitBtn.querySelector('.btn-loading'); + form.addEventListener('submit', function() { submitBtn.disabled = true; - btnText.style.display = 'none'; - btnLoading.style.display = 'inline'; + if (btnText) btnText.style.display = 'none'; + if (btnLoading) btnLoading.style.display = 'inline'; }); } }); diff --git a/template/templates/checkout_start.json b/template/templates/checkout_start.json deleted file mode 100644 index 2154d54b..00000000 --- a/template/templates/checkout_start.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "sections": { - "checkout_start": { - "type": "sections/checkout-start", - "settings": { - "title": "Proceder al Checkout" - } - } - }, - "order": [ - "checkout_start" - ] -} From f69d74753aab11c3a83641f6d7ef226ecb22d121 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Aug 2025 17:30:55 -0500 Subject: [PATCH 4/5] feat(checkout): Add response processor for checkout data - A new response processor has been implemented to handle payment data in `responseProcessors`. - This change allows checkout data to be stored in `loadedData`, improving information management during the checkout process. --- docs/engine/checkout-system.md | 742 ++++++++++++++++++ .../{cache/cache-invalidation.md => index.md} | 62 +- .../handlers/response-processors.ts | 4 + 3 files changed, 803 insertions(+), 5 deletions(-) create mode 100644 docs/engine/checkout-system.md rename docs/{cache/cache-invalidation.md => index.md} (80%) diff --git a/docs/engine/checkout-system.md b/docs/engine/checkout-system.md new file mode 100644 index 00000000..7ff6e9a8 --- /dev/null +++ b/docs/engine/checkout-system.md @@ -0,0 +1,742 @@ +# Sistema de Checkout para Desarrolladores de Temas + +## Resumen + +El sistema de checkout de Fasttify proporciona un flujo completo de pago manual que permite a los usuarios generar órdenes desde su carrito. El sistema incluye sesiones tokenizadas para mayor seguridad, formularios de información del cliente y un proceso de confirmación profesional. + +## Características Principales + +- ✅ **Checkout tokenizado** con URLs seguras tipo `checkouts/cn/{token}` +- ✅ **Sesiones temporales** con expiración automática +- ✅ **Formularios de información** del cliente y dirección de envío +- ✅ **Integración automática** con el sistema de carrito +- ✅ **Pago manual** con captura posterior por el dueño de la tienda +- ✅ **Flujo responsive** optimizado para móviles +- ✅ **Redirección automática** al dominio de la tienda +- ✅ **Estados de orden** con seguimiento completo + +## Arquitectura del Sistema + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Carrito │ │ Checkout API │ │ Sesión Token │ +│ │───▶│ │───▶│ │ +│ (side-cart.js) │ │ (start/complete)│ │ (Base de Datos) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + │ ▼ ▼ + │ ┌─────────────────┐ ┌─────────────────┐ + │ │ Liquid Templates│ │ Order Creation │ + │ │ │ │ │ + │ │ (checkout.liquid)│ │ (Manual Payment)│ + └──────────────└─────────────────┘ └─────────────────┘ +``` + +## Flujo de Checkout + +### 1. Inicio desde el Carrito + +El usuario hace clic en "Finalizar Compra" desde el carrito lateral: + +```javascript +// En cart-ui.js +setupCheckoutButtons() { + const checkoutButton = this.sidebar.querySelector('[data-checkout-direct]'); + if (checkoutButton) { + checkoutButton.addEventListener('click', async (e) => { + e.preventDefault(); + + try { + const response = await fetch(`/api/stores/${window.STORE_ID}/checkout/direct`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sessionId: this.getSessionId(), + }), + }); + + if (response.ok) { + window.location.href = response.url; + } + } catch (error) { + console.error('Error starting checkout:', error); + } + }); + } +} +``` + +### 2. Generación de Token + +El sistema crea una sesión de checkout con un token único: + +- **Formato del token**: `fs_{base64url}` (Fast Session) +- **Duración**: 24 horas de expiración +- **Contenido**: Snapshot del carrito, información de la tienda + +### 3. Página de Checkout + +El usuario es redirigido a `/checkouts/cn/{token}` donde llena sus datos: + +```liquid + +
+ {% if checkout %} +
+ +
+

Resumen del Pedido

+ {% for item in checkout.line_items %} +
+ {{ item.title }} +
+

{{ item.title }}

+

Cantidad: {{ item.quantity }}

+

{{ item.line_price | money }}

+
+
+ {% endfor %} + +
+
+ Subtotal: + {{ checkout.subtotal_price | money }} +
+
+ Total: + {{ checkout.total_price | money }} +
+
+
+ + +
+
+
+

Información de Contacto

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

Dirección de Envío

+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

Notas del Pedido (Opcional)

+
+ + +
+
+ +
+ +
+
+
+
+ {% else %} +
+

Sesión de Checkout No Encontrada

+

La sesión de checkout ha expirado o no es válida.

+ Volver al Carrito +
+ {% endif %} +
+``` + +### 4. Procesamiento y Confirmación + +Después de completar el formulario, el sistema procesa la información y crea la orden. + +## Implementación en Temas + +### 1. Templates Requeridos + +#### `template/templates/checkout.json` + +```json +{ + "name": "Checkout", + "sections": { + "main": { + "type": "sections/checkout" + } + }, + "order": ["main"] +} +``` + +#### `template/sections/checkout.liquid` + +El template principal del checkout (ver ejemplo completo arriba). + +### 2. Integración con el Carrito + +#### Modificar `template/assets/cart/cart-templates.js` + +```javascript +// Generar botón de checkout en el footer del carrito +generateCartFooterHtml(cart) { + return ` + + `; +} +``` + +#### Actualizar `template/assets/cart/cart-ui.js` + +```javascript +// Configurar botones de checkout +setupCheckoutButtons() { + const checkoutButton = this.sidebar.querySelector('[data-checkout-direct]'); + if (checkoutButton) { + checkoutButton.addEventListener('click', async (e) => { + e.preventDefault(); + + if (checkoutButton.disabled) return; + + // Mostrar estado de carga + const btnText = checkoutButton.querySelector('.btn-text') || checkoutButton; + const btnLoading = checkoutButton.querySelector('.btn-loading'); + + if (btnText) btnText.style.display = 'none'; + if (btnLoading) btnLoading.style.display = 'inline'; + checkoutButton.disabled = true; + + try { + const sessionId = CartHelpers.getSessionId(); + const response = await fetch(`/api/stores/${window.STORE_ID}/checkout/direct`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ sessionId }), + }); + + if (response.redirected) { + window.location.href = response.url; + } else if (response.ok) { + const result = await response.json(); + if (result.redirectUrl) { + window.location.href = result.redirectUrl; + } + } else { + throw new Error('Error iniciando checkout'); + } + } catch (error) { + console.error('Error starting checkout:', error); + CartHelpers.showError('Error al iniciar el checkout. Inténtalo de nuevo.'); + + // Restaurar estado del botón + if (btnText) btnText.style.display = 'inline'; + if (btnLoading) btnLoading.style.display = 'none'; + checkoutButton.disabled = false; + } + }); + } +} +``` + +### 3. Estilos CSS + +#### `template/assets/checkout.css` + +```css +/* Contenedor principal del checkout */ +.checkout-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + display: grid; + grid-template-columns: 1fr 400px; + gap: 3rem; +} + +.checkout-content { + display: contents; +} + +/* Resumen del pedido */ +.checkout-order-summary { + background: #f8f9fa; + padding: 2rem; + border-radius: 8px; + height: fit-content; + position: sticky; + top: 2rem; +} + +.checkout-order-summary h2 { + margin-bottom: 1.5rem; + font-size: 1.25rem; + font-weight: 600; +} + +.checkout-item { + display: flex; + gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid #e9ecef; +} + +.checkout-item:last-child { + border-bottom: none; +} + +.checkout-item img { + width: 60px; + height: 60px; + object-fit: cover; + border-radius: 4px; +} + +.item-details h4 { + margin: 0 0 0.5rem 0; + font-size: 0.875rem; + font-weight: 500; +} + +.item-details p { + margin: 0.25rem 0; + font-size: 0.75rem; + color: #6c757d; +} + +.checkout-totals { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e9ecef; +} + +.total-line { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.total-final { + font-weight: 600; + font-size: 1.125rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e9ecef; +} + +/* Formulario */ +.checkout-form-section { + max-width: 600px; +} + +.form-section { + margin-bottom: 2.5rem; +} + +.form-section h3 { + margin-bottom: 1.5rem; + font-size: 1.125rem; + font-weight: 600; + color: #212529; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #495057; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 1rem; + transition: + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +/* Botones */ +.checkout-actions { + margin-top: 2rem; +} + +.checkout-submit { + width: 100%; + padding: 1rem 2rem; + background: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 1.125rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease-in-out; + position: relative; +} + +.checkout-submit:hover:not(:disabled) { + background: #0056b3; +} + +.checkout-submit:disabled { + background: #6c757d; + cursor: not-allowed; +} + +.btn-loading { + display: none; +} + +/* Estados de error */ +.checkout-error { + text-align: center; + padding: 3rem; + max-width: 500px; + margin: 0 auto; +} + +.checkout-error h2 { + color: #dc3545; + margin-bottom: 1rem; +} + +.checkout-error p { + color: #6c757d; + margin-bottom: 2rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .checkout-container { + grid-template-columns: 1fr; + gap: 2rem; + padding: 1rem; + } + + .checkout-order-summary { + position: static; + order: -1; + } + + .form-row { + grid-template-columns: 1fr; + } +} +``` + +### 4. JavaScript del Checkout + +#### `template/sections/checkout.liquid` (JavaScript integrado) + +```liquid +{% javascript %} +document.addEventListener('DOMContentLoaded', function() { + const form = document.querySelector('.checkout-form-section form'); + const submitBtn = document.querySelector('.checkout-submit'); + + if (form && submitBtn) { + const btnText = submitBtn.querySelector('.btn-text'); + const btnLoading = submitBtn.querySelector('.btn-loading'); + + form.addEventListener('submit', function(e) { + // Mostrar estado de carga + if (btnText) btnText.style.display = 'none'; + if (btnLoading) btnLoading.style.display = 'inline'; + submitBtn.disabled = true; + }); + } +}); +{% endjavascript %} +``` + +## Configuración del Routing + +### Router Configuration + +El sistema automáticamente maneja las rutas: + +- `/checkouts/cn/{token}` - Página de checkout +- `/api/stores/{storeId}/checkout/direct` - Inicio de checkout directo +- `/api/stores/{storeId}/checkout/complete` - Completar checkout + +### Middleware Configuration + +El middleware está configurado para: + +1. **No reescribir** URLs de checkout para evitar conflictos +2. **Resolver** el dominio de la tienda correctamente +3. **Manejar** redirecciones a dominios personalizados + +## Datos Disponibles en Liquid + +### Objeto `checkout` + +Cuando estás en una página de checkout (`/checkouts/cn/{token}`), tienes acceso al objeto `checkout`: + +```liquid +{{ checkout.token }} +{{ checkout.storeId }} +{{ checkout.line_items }} +{{ checkout.item_count }} +{{ checkout.total_price }} +{{ checkout.subtotal_price }} +{{ checkout.currency }} +{{ checkout.expires_at }} +{{ checkout.status }} +``` + +### Estructura de `line_items` + +```liquid +{% for item in checkout.line_items %} + {{ item.id }} + {{ item.title }} + {{ item.quantity }} + {{ item.price }} + {{ item.line_price }} + {{ item.image }} + {{ item.url }} + {{ item.product_id }} +{% endfor %} +``` + +## Estados del Checkout + +### Estados de Sesión + +- `open` - Sesión activa, puede ser editada +- `completed` - Checkout completado, orden creada +- `expired` - Sesión expirada +- `cancelled` - Sesión cancelada + +### Validaciones + +El sistema valida automáticamente: + +- ✅ **Existencia de sesión** - Token válido +- ✅ **Estado abierto** - Solo sesiones `open` pueden editarse +- ✅ **Expiración** - Sesiones tienen 24 horas de vida +- ✅ **Campos requeridos** - Email, nombre, dirección + +## Mejores Prácticas + +### 1. **Manejo de Errores** + +```liquid +{% if checkout %} + +{% else %} +
+

Sesión No Encontrada

+

Tu sesión de checkout ha expirado.

+ Volver al Carrito +
+{% 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.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/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; + }, }; From 321d27506b1fe44050db0102e8ac0a8b6a1798ce Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Aug 2025 17:38:33 -0500 Subject: [PATCH 5/5] feat(checkout): Add syntax detection for checkout - A new option extractor and object detector for checkout syntax have been added to the `liquid-syntax-detector.ts` file. - This allows checkout-related templates to be handled correctly, improving data management in the checkout process. --- .../services/templates/parsing/liquid-syntax-detector.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 {