diff --git a/amplify/data/models/order.ts b/amplify/data/models/order.ts index 4a8061e1..6b76d26a 100644 --- a/amplify/data/models/order.ts +++ b/amplify/data/models/order.ts @@ -2,30 +2,112 @@ import { a } from '@aws-amplify/backend'; export const orderModel = a .model({ - orderNumber: a.string().required(), + orderNumber: a + .string() + .required() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), storeId: a .string() .required() .authorization((allow) => [ allow.ownerDefinedIn('storeOwner').to(['create', 'read']), - allow.publicApiKey().to(['read']), + allow.publicApiKey().to(['create', 'read']), ]), - customerId: a.string(), // Puede ser userId o sessionId + customerId: a + .string() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), // Puede ser userId o sessionId customerType: a.enum(['registered', 'guest']), - items: a.hasMany('OrderItem', 'orderId'), - subtotal: a.float().required(), - shippingCost: a.float().default(0), - taxAmount: a.float().default(0), - totalAmount: a.float().required(), - currency: a.string().default('USD'), + customerEmail: a + .string() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + items: a + .hasMany('OrderItem', 'orderId') + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + subtotal: a + .float() + .required() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + shippingCost: a + .float() + .default(0) + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + taxAmount: a + .float() + .default(0) + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + totalAmount: a + .float() + .required() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + currency: a + .string() + .default('COP') + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), status: a.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled']), paymentStatus: a.enum(['pending', 'paid', 'failed', 'refunded']), - paymentMethod: a.string(), - paymentId: a.string(), // ID de la transacción del gateway de pago - shippingAddress: a.json(), - billingAddress: a.json(), - customerInfo: a.json(), // Email, nombre, teléfono - notes: a.string(), + paymentMethod: a + .string() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + paymentId: a + .string() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), // ID de la transacción del gateway de pago + shippingAddress: a + .json() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + billingAddress: a + .json() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), + customerInfo: a + .json() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), // Email, nombre, teléfono + notes: a + .string() + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), storeOwner: a .string() .required() @@ -33,7 +115,12 @@ export const orderModel = a allow.ownerDefinedIn('storeOwner').to(['create', 'read']), allow.publicApiKey().to(['create', 'read']), ]), - store: a.belongsTo('UserStore', 'storeId'), + store: a + .belongsTo('UserStore', 'storeId') + .authorization((allow) => [ + allow.ownerDefinedIn('storeOwner').to(['create', 'read']), + allow.publicApiKey().to(['create', 'read']), + ]), }) .secondaryIndexes((index) => [ index('storeId'), @@ -41,6 +128,7 @@ export const orderModel = a index('orderNumber'), index('status'), index('storeOwner'), + index('customerEmail'), ]) .authorization((allow) => [ allow.ownerDefinedIn('storeOwner').to(['create', 'read', 'update']), diff --git a/app/api/stores/[storeId]/cart/route.ts b/app/api/stores/[storeId]/cart/route.ts index 5d045dfa..bebda0e2 100644 --- a/app/api/stores/[storeId]/cart/route.ts +++ b/app/api/stores/[storeId]/cart/route.ts @@ -17,7 +17,7 @@ import { getCartCookieOptions } from '@/lib/cookies/cookiesOption'; import { getNextCorsHeaders } from '@/lib/utils/next-cors'; import { logger } from '@/renderer-engine/lib/logger'; -import { cartFetcher } from '@/renderer-engine/services/fetchers/cart-fetcher'; +import { cartFetcher } from '@/renderer-engine/services/fetchers/cart'; import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { v4 as uuidv4 } from 'uuid'; diff --git a/app/api/stores/[storeId]/checkout/complete/route.ts b/app/api/stores/[storeId]/checkout/complete/route.ts index 4814a097..05e17801 100644 --- a/app/api/stores/[storeId]/checkout/complete/route.ts +++ b/app/api/stores/[storeId]/checkout/complete/route.ts @@ -15,7 +15,7 @@ */ import { getNextCorsHeaders } from '@/lib/utils/next-cors'; -import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout-fetcher'; +import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout'; import { NextRequest, NextResponse } from 'next/server'; export async function OPTIONS(request: NextRequest) { diff --git a/app/api/stores/[storeId]/checkout/direct/route.ts b/app/api/stores/[storeId]/checkout/direct/route.ts index 5e65a30a..76829b1b 100644 --- a/app/api/stores/[storeId]/checkout/direct/route.ts +++ b/app/api/stores/[storeId]/checkout/direct/route.ts @@ -15,8 +15,8 @@ */ 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 { cartFetcher } from '@/renderer-engine/services/fetchers/cart'; +import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout'; import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; diff --git a/app/api/stores/[storeId]/checkout/start/route.ts b/app/api/stores/[storeId]/checkout/start/route.ts index c4f7bbb1..ef36d7e6 100644 --- a/app/api/stores/[storeId]/checkout/start/route.ts +++ b/app/api/stores/[storeId]/checkout/start/route.ts @@ -15,8 +15,8 @@ */ 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 { cartFetcher } from '@/renderer-engine/services/fetchers/cart'; +import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout'; import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; diff --git a/app/store/hooks/data/useProducts/index.ts b/app/store/hooks/data/useProducts/index.ts new file mode 100644 index 00000000..2eecd745 --- /dev/null +++ b/app/store/hooks/data/useProducts/index.ts @@ -0,0 +1,14 @@ +// Exportar el hook principal +export { useProducts } from './useProducts'; + +// Exportar tipos +export type * from './types'; + +// Exportar utilidades +export * from './utils'; + +// Exportar mutaciones +export * from './mutations'; + +// Exportar queries +export * from './queries'; diff --git a/app/store/hooks/data/useProducts/mutations/index.ts b/app/store/hooks/data/useProducts/mutations/index.ts new file mode 100644 index 00000000..10778bda --- /dev/null +++ b/app/store/hooks/data/useProducts/mutations/index.ts @@ -0,0 +1 @@ +export { useProductMutations } from './useProductMutations'; diff --git a/app/store/hooks/data/useProducts/mutations/useProductMutations.ts b/app/store/hooks/data/useProducts/mutations/useProductMutations.ts new file mode 100644 index 00000000..78ad6e13 --- /dev/null +++ b/app/store/hooks/data/useProducts/mutations/useProductMutations.ts @@ -0,0 +1,166 @@ +import type { Schema } from '@/amplify/data/resource'; +import { normalizeAttributesField, normalizeTagsField, withLowercaseName } from '@/app/store/hooks/utils/productUtils'; +import { useCacheInvalidation } from '@/hooks/cache/useCacheInvalidation'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { generateClient } from 'aws-amplify/api'; +import { getCurrentUser } from 'aws-amplify/auth'; +import type { IProduct, ProductCreateInput, ProductUpdateInput } from '../types'; + +const client = generateClient({ + authMode: 'userPool', +}); + +/** + * Hook para manejar todas las mutaciones de productos + */ +export const useProductMutations = (storeId: string | undefined) => { + const queryClient = useQueryClient(); + const { invalidateProductCache } = useCacheInvalidation(); + + /** + * Mutación para crear un producto + */ + const createProductMutation = useMutation({ + mutationFn: async (productData: ProductCreateInput) => { + const { username } = await getCurrentUser(); + const dataToSend = withLowercaseName({ + ...productData, + attributes: normalizeAttributesField( + productData.attributes as string | { name?: string; values?: string[] }[] | undefined + ), + tags: normalizeTagsField(productData.tags as string[] | string | undefined), + storeId: storeId || '', + owner: username, + status: productData.status || 'DRAFT', + quantity: productData.quantity || 0, + }); + + const { data } = await client.models.Product.create(dataToSend); + return data as IProduct; + }, + onSuccess: async () => { + // Invalidar React Query cache + queryClient.invalidateQueries({ queryKey: ['products', storeId] }); + + // Invalidar caché del motor de renderizado + if (storeId) { + await invalidateProductCache(storeId); + } + }, + }); + + /** + * Mutación para actualizar un producto + */ + const updateProductMutation = useMutation({ + mutationFn: async (productData: ProductUpdateInput) => { + const dataToSend = withLowercaseName({ + ...productData, + attributes: normalizeAttributesField( + productData.attributes as string | { name?: string; values?: string[] }[] | undefined + ), + tags: normalizeTagsField(productData.tags as string[] | string | undefined), + }); + + const { data } = await client.models.Product.update(dataToSend); + return data as IProduct; + }, + onSuccess: async (updatedProduct) => { + // Invalidar caché del motor de renderizado + if (storeId) { + await invalidateProductCache(storeId, updatedProduct.id); + } + }, + }); + + /** + * Mutación para eliminar un producto + */ + const deleteProductMutation = useMutation({ + mutationFn: async (id: string) => { + await client.models.Product.delete({ id }); + return id; + }, + onSuccess: async (deletedId) => { + // Invalidar caché del motor de renderizado + if (storeId) { + await invalidateProductCache(storeId, deletedId); + } + }, + }); + + /** + * Mutación para eliminar múltiples productos + */ + const deleteMultipleProductsMutation = useMutation({ + mutationFn: async (ids: string[]) => { + await Promise.all(ids.map((id) => client.models.Product.delete({ id }))); + return ids; + }, + onSuccess: async (deletedIds) => { + // Invalidar caché del motor de renderizado para cada producto eliminado + if (storeId) { + await Promise.all(deletedIds.map((id) => invalidateProductCache(storeId, id))); + } + }, + }); + + /** + * Mutación para duplicar un producto + */ + const duplicateProductMutation = useMutation({ + mutationFn: async (id: string) => { + const { data: originalProduct } = await client.models.Product.get({ id }); + if (!originalProduct) { + throw new Error(`Product with ID ${id} not found`); + } + + const { username } = await getCurrentUser(); + + // Crear una copia del producto sin campos que se generan automáticamente + const duplicatedProduct = withLowercaseName({ + storeId: originalProduct.storeId, + name: `${originalProduct.name} (Copia)`, + nameLowercase: `${originalProduct.name} (copia)`.toLowerCase(), + description: originalProduct.description, + price: originalProduct.price, + compareAtPrice: originalProduct.compareAtPrice, + costPerItem: originalProduct.costPerItem, + sku: originalProduct.sku ? `${originalProduct.sku}-copy` : undefined, + barcode: originalProduct.barcode, + quantity: 0, + category: originalProduct.category, + images: originalProduct.images, + attributes: originalProduct.attributes, + status: 'active', + slug: originalProduct.slug ? `${originalProduct.slug}-copy` : undefined, + featured: false, + tags: originalProduct.tags, + variants: originalProduct.variants, + collectionId: originalProduct.collectionId, + supplier: originalProduct.supplier, + owner: username, + }); + + const { data } = await client.models.Product.create(duplicatedProduct); + return data as IProduct; + }, + onSuccess: async () => { + // Invalidar React Query cache + queryClient.invalidateQueries({ queryKey: ['products', storeId] }); + + // Invalidar caché del motor de renderizado + if (storeId) { + await invalidateProductCache(storeId); + } + }, + }); + + return { + createProductMutation, + updateProductMutation, + deleteProductMutation, + deleteMultipleProductsMutation, + duplicateProductMutation, + }; +}; diff --git a/app/store/hooks/data/useProducts/queries/index.ts b/app/store/hooks/data/useProducts/queries/index.ts new file mode 100644 index 00000000..b34ca446 --- /dev/null +++ b/app/store/hooks/data/useProducts/queries/index.ts @@ -0,0 +1 @@ +export { useProductQueries } from './useProductQueries'; diff --git a/app/store/hooks/data/useProducts/queries/useProductQueries.ts b/app/store/hooks/data/useProducts/queries/useProductQueries.ts new file mode 100644 index 00000000..8b962534 --- /dev/null +++ b/app/store/hooks/data/useProducts/queries/useProductQueries.ts @@ -0,0 +1,122 @@ +import type { Schema } from '@/amplify/data/resource'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { generateClient } from 'aws-amplify/api'; +import type { IProduct, PaginationOptions, ProductsQueryResult } from '../types'; + +const client = generateClient({ + authMode: 'userPool', +}); + +/** + * Hook para manejar las queries de productos + */ +export const useProductQueries = ( + storeId: string | undefined, + options: PaginationOptions, + currentPage: number, + pageTokens: (string | null)[] +) => { + const queryClient = useQueryClient(); + const { limit, sortDirection, sortField } = options; + + /** + * Función para obtener productos de una página específica + */ + const fetchProductsPage = async (): Promise => { + if (!storeId) throw new Error('Store ID is required'); + + const token = pageTokens[currentPage - 1]; + + const { data, nextToken } = await client.models.Product.listProductByStoreId( + { + storeId: storeId, + }, + { + limit, + nextToken: token, + } + ); + + const sortedData = [...(data || [])].sort((a, b) => { + const fieldA = a[sortField as keyof typeof a]; + const fieldB = b[sortField as keyof typeof b]; + + if (fieldA === undefined || fieldA === null) return sortDirection === 'ASC' ? -1 : 1; + if (fieldB === undefined || fieldB === null) return sortDirection === 'ASC' ? 1 : -1; + + if (fieldA < fieldB) return sortDirection === 'ASC' ? -1 : 1; + if (fieldA > fieldB) return sortDirection === 'ASC' ? 1 : -1; + return 0; + }); + + return { + products: sortedData as IProduct[], + nextToken: nextToken as string | null, + }; + }; + + /** + * Query principal para obtener productos + */ + const { + data, + isFetching, + error: queryError, + refetch, + } = useQuery({ + queryKey: ['products', storeId, limit, sortDirection, sortField, currentPage], + queryFn: fetchProductsPage, + enabled: !!storeId, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + gcTime: 10 * 60 * 1000, + retry: 2, + }); + + /** + * Función para buscar un producto específico por ID + */ + const fetchProductById = async (id: string): Promise => { + if (!storeId) { + console.error('Cannot get product: storeId not defined'); + return null; + } + + const queryCache = queryClient.getQueryCache(); + const productQueries = queryCache.findAll({ queryKey: ['products', storeId] }); + + for (const query of productQueries) { + const pageData = query.state.data as { products: IProduct[] } | undefined; + if (pageData?.products) { + const existingProduct = pageData.products.find((p) => p.id === id); + if (existingProduct) { + return existingProduct; + } + } + } + + try { + const { data: product } = await client.models.Product.get({ id }); + + if (product) { + queryClient.setQueryData(['product', id], product); + return product as IProduct; + } + + return null; + } catch (error) { + console.error(`Error fetching product ${id}:`, error); + return null; + } + }; + + return { + data, + isFetching, + error: queryError, + refetch, + fetchProductById, + }; +}; diff --git a/app/store/hooks/data/useProducts/types/index.ts b/app/store/hooks/data/useProducts/types/index.ts new file mode 100644 index 00000000..e20f980d --- /dev/null +++ b/app/store/hooks/data/useProducts/types/index.ts @@ -0,0 +1 @@ +export type * from './useProducts-types'; diff --git a/app/store/hooks/data/useProducts/types/useProducts-types.ts b/app/store/hooks/data/useProducts/types/useProducts-types.ts new file mode 100644 index 00000000..aa3fd1c6 --- /dev/null +++ b/app/store/hooks/data/useProducts/types/useProducts-types.ts @@ -0,0 +1,75 @@ +import type { Schema } from '@/amplify/data/resource'; + +/** + * Interfaz para representar un producto + */ +export type IProduct = Schema['Product']['type']; + +/** + * Tipo para los datos necesarios al crear un producto + */ +export type ProductCreateInput = Omit; + +/** + * Tipo para los datos al actualizar un producto + */ +export type ProductUpdateInput = Partial> & { + id: string; +}; + +/** + * Opciones para filtrar productos + */ +export interface ProductFilterOptions { + category?: string; + featured?: boolean; + status?: 'active' | 'inactive' | 'pending' | 'draft'; + priceRange?: { min?: number; max?: number }; +} + +/** + * Opciones de paginación + */ +export interface PaginationOptions { + limit?: number; + sortDirection?: 'ASC' | 'DESC'; + sortField?: keyof IProduct; +} + +/** + * Resultado del hook useProducts + */ +export interface UseProductsResult { + products: IProduct[]; + loading: boolean; + error: Error | null; + currentPage: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + nextPage: () => void; + previousPage: () => void; + resetPagination: () => void; + createProduct: (productData: ProductCreateInput) => Promise; + updateProduct: (productData: ProductUpdateInput) => Promise; + deleteProduct: (id: string) => Promise; + deleteMultipleProducts: (ids: string[]) => Promise; + duplicateProduct: (id: string) => Promise; + refreshProducts: () => void; + fetchProduct: (id: string) => Promise; +} + +/** + * Opciones de paginación y configuración + */ +export interface UseProductsOptions extends PaginationOptions { + skipInitialFetch?: boolean; + enabled?: boolean; +} + +/** + * Resultado de la consulta de productos + */ +export interface ProductsQueryResult { + products: IProduct[]; + nextToken: string | null; +} diff --git a/app/store/hooks/data/useProducts/useProducts.ts b/app/store/hooks/data/useProducts/useProducts.ts new file mode 100644 index 00000000..9e0e81ea --- /dev/null +++ b/app/store/hooks/data/useProducts/useProducts.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect } from 'react'; +import { useProductMutations } from './mutations'; +import { useProductQueries } from './queries'; +import type { IProduct, ProductCreateInput, ProductUpdateInput, UseProductsOptions, UseProductsResult } from './types'; +import { useProductPagination } from './utils'; + +/** + * Hook para gestionar productos con paginación y caché usando React Query + * @param storeId - ID de la tienda para la que se gestionan los productos + * @param options - Opciones de paginación y configuración (opcional) + * @returns Objeto con productos, estado de carga, error, funciones CRUD y funciones de paginación + */ +export function useProducts(storeId: string | undefined, options?: UseProductsOptions): UseProductsResult { + // Configuración de paginación + const pagination = useProductPagination(options || {}); + const { + currentPage, + pageTokens, + limit, + sortDirection, + sortField, + resetPagination, + nextPage, + previousPage, + updatePageTokens, + } = pagination; + + // Mutaciones + const mutations = useProductMutations(storeId); + const { + createProductMutation, + updateProductMutation, + deleteProductMutation, + deleteMultipleProductsMutation, + duplicateProductMutation, + } = mutations; + + // Queries + const queries = useProductQueries( + storeId, + { limit, sortDirection, sortField: sortField as keyof IProduct }, + currentPage, + pageTokens + ); + const { data, isFetching, error: queryError, refetch, fetchProductById } = queries; + + // Efectos para manejar la paginación + useEffect(() => { + resetPagination(); + }, [storeId, limit, sortDirection, sortField, resetPagination]); + + useEffect(() => { + if (data?.nextToken) { + updatePageTokens(data.nextToken); + } + }, [data?.nextToken, updatePageTokens]); + + // Funciones wrapper para las mutaciones + const createProduct = useCallback( + async (productData: ProductCreateInput) => { + try { + return await createProductMutation.mutateAsync(productData); + } catch (err) { + console.error('Error creating product:', err); + return null; + } + }, + [createProductMutation] + ); + + const updateProduct = useCallback( + async (productData: ProductUpdateInput) => { + try { + return await updateProductMutation.mutateAsync(productData); + } catch (err) { + console.error('Error updating product:', err); + return null; + } + }, + [updateProductMutation] + ); + + const deleteProduct = useCallback( + async (id: string) => { + try { + if (!id) { + throw new Error('Product ID is required for deletion'); + } + await deleteProductMutation.mutateAsync(id); + return true; + } catch (err) { + console.error('Error deleting product:', err); + return false; + } + }, + [deleteProductMutation] + ); + + const deleteMultipleProducts = useCallback( + async (ids: string[]) => { + try { + await deleteMultipleProductsMutation.mutateAsync(ids); + return true; + } catch (err) { + console.error('Error deleting multiple products:', err); + return false; + } + }, + [deleteMultipleProductsMutation] + ); + + const duplicateProduct = useCallback( + async (id: string) => { + try { + if (!id) { + throw new Error('Product ID is required for duplication'); + } + return await duplicateProductMutation.mutateAsync(id); + } catch (err) { + console.error('Error duplicating product:', err); + return null; + } + }, + [duplicateProductMutation] + ); + + // Datos derivados + const products = data?.products || []; + const hasNextPage = !!data?.nextToken; + const hasPreviousPage = currentPage > 1; + + // Función para resetear paginación y refrescar + const resetPaginationAndRefetch = useCallback(() => { + resetPagination(); + refetch(); + }, [resetPagination, refetch]); + + return { + products, + loading: isFetching, + error: queryError ? new Error(queryError.message) : null, + + currentPage, + hasNextPage, + hasPreviousPage, + + nextPage, + previousPage, + resetPagination: resetPaginationAndRefetch, + + createProduct, + updateProduct, + deleteProduct, + deleteMultipleProducts, + duplicateProduct, + fetchProduct: fetchProductById, + refreshProducts: refetch, + }; +} diff --git a/app/store/hooks/data/useProducts/utils/index.ts b/app/store/hooks/data/useProducts/utils/index.ts new file mode 100644 index 00000000..b1efb196 --- /dev/null +++ b/app/store/hooks/data/useProducts/utils/index.ts @@ -0,0 +1,2 @@ +export { useProductCacheUtils } from './productCacheUtils'; +export { useProductPagination } from './productPaginationUtils'; diff --git a/app/store/hooks/data/useProducts/utils/productCacheUtils.ts b/app/store/hooks/data/useProducts/utils/productCacheUtils.ts new file mode 100644 index 00000000..816233ff --- /dev/null +++ b/app/store/hooks/data/useProducts/utils/productCacheUtils.ts @@ -0,0 +1,86 @@ +import { useQueryClient } from '@tanstack/react-query'; +import type { IProduct } from '../types'; + +/** + * Utilidades para manejar el caché de productos en React Query + */ +export const useProductCacheUtils = (storeId: string | undefined) => { + const queryClient = useQueryClient(); + + /** + * Actualiza el caché de productos optimísticamente después de una actualización + */ + const updateProductInCache = (updatedProduct: IProduct) => { + if (!storeId) return; + + queryClient + .getQueryCache() + .findAll({ queryKey: ['products', storeId] }) + .forEach((query) => { + const oldData = query.state.data as { products: IProduct[]; nextToken: string | null } | undefined; + if (oldData?.products.some((p) => p.id === updatedProduct.id)) { + queryClient.setQueryData(query.queryKey, { + ...oldData, + products: oldData.products.map((p) => (p.id === updatedProduct.id ? updatedProduct : p)), + }); + } + }); + }; + + /** + * Remueve productos del caché optimísticamente después de una eliminación + */ + const removeProductsFromCache = (deletedIds: string[]) => { + if (!storeId) return; + + queryClient + .getQueryCache() + .findAll({ queryKey: ['products', storeId] }) + .forEach((query) => { + const oldData = query.state.data as { products: IProduct[]; nextToken: string | null } | undefined; + if (oldData?.products.some((p) => deletedIds.includes(p.id))) { + queryClient.setQueryData(query.queryKey, { + ...oldData, + products: oldData.products.filter((p) => !deletedIds.includes(p.id)), + }); + } + }); + }; + + /** + * Busca un producto en el caché existente + */ + const findProductInCache = (id: string): IProduct | null => { + if (!storeId) return null; + + const queryCache = queryClient.getQueryCache(); + const productQueries = queryCache.findAll({ queryKey: ['products', storeId] }); + + for (const query of productQueries) { + const pageData = query.state.data as { products: IProduct[] } | undefined; + if (pageData?.products) { + const existingProduct = pageData.products.find((p) => p.id === id); + if (existingProduct) { + return existingProduct; + } + } + } + + return null; + }; + + /** + * Invalida todas las queries de productos para una tienda + */ + const invalidateProductsCache = () => { + if (!storeId) return; + queryClient.invalidateQueries({ queryKey: ['products', storeId] }); + }; + + return { + updateProductInCache, + removeProductsFromCache, + findProductInCache, + invalidateProductsCache, + }; +}; diff --git a/app/store/hooks/data/useProducts/utils/productPaginationUtils.ts b/app/store/hooks/data/useProducts/utils/productPaginationUtils.ts new file mode 100644 index 00000000..4058a5bc --- /dev/null +++ b/app/store/hooks/data/useProducts/utils/productPaginationUtils.ts @@ -0,0 +1,84 @@ +import { useCallback, useState } from 'react'; +import type { PaginationOptions } from '../types'; + +/** + * Hook personalizado para manejar la paginación de productos + */ +export const useProductPagination = (options: PaginationOptions) => { + const [currentPage, setCurrentPage] = useState(1); + const [pageTokens, setPageTokens] = useState<(string | null)[]>([null]); + + const limit = options.limit || 50; + const sortDirection = options.sortDirection || 'DESC'; + const sortField = options.sortField || 'creationDate'; + + /** + * Resetea la paginación a la primera página + */ + const resetPagination = useCallback(() => { + setCurrentPage(1); + setPageTokens([null]); + }, []); + + /** + * Avanza a la siguiente página + */ + const nextPage = useCallback(() => { + setCurrentPage((prev) => prev + 1); + }, []); + + /** + * Retrocede a la página anterior + */ + const previousPage = useCallback(() => { + setCurrentPage((prev) => prev - 1); + }, []); + + /** + * Actualiza los tokens de página cuando se recibe un nuevo nextToken + */ + const updatePageTokens = useCallback( + (nextToken: string | null) => { + if (nextToken && pageTokens.length === currentPage) { + setPageTokens((tokens) => [...tokens, nextToken]); + } + }, + [currentPage, pageTokens.length] + ); + + /** + * Obtiene el token de la página actual + */ + const getCurrentPageToken = useCallback(() => { + return pageTokens[currentPage - 1]; + }, [currentPage, pageTokens]); + + /** + * Verifica si hay una página siguiente + */ + const hasNextPage = useCallback((nextToken: string | null) => { + return !!nextToken; + }, []); + + /** + * Verifica si hay una página anterior + */ + const hasPreviousPage = useCallback(() => { + return currentPage > 1; + }, [currentPage]); + + return { + currentPage, + pageTokens, + limit, + sortDirection, + sortField, + resetPagination, + nextPage, + previousPage, + updatePageTokens, + getCurrentPageToken, + hasNextPage, + hasPreviousPage, + }; +}; diff --git a/renderer-engine/exports.ts b/renderer-engine/exports.ts index 49690f25..4f20643a 100644 --- a/renderer-engine/exports.ts +++ b/renderer-engine/exports.ts @@ -33,7 +33,7 @@ export { errorRenderer } from './services/errors/error-renderer'; // ===== SERVICIOS DE DATOS ===== export { dataFetcher } from './services/fetchers/data-fetcher'; -export { navigationFetcher } from './services/fetchers/navigation-fetcher'; +export { navigationFetcher } from './services/fetchers/navigation'; // ===== SERVICIOS DE PLANTILLAS ===== export { templateAnalyzer } from './services/templates/analysis/template-analyzer'; diff --git a/renderer-engine/services/fetchers/cart/cart-context-transformer.ts b/renderer-engine/services/fetchers/cart/cart-context-transformer.ts new file mode 100644 index 00000000..f65f8f4b --- /dev/null +++ b/renderer-engine/services/fetchers/cart/cart-context-transformer.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import type { Cart, CartContext } from '@/renderer-engine/types'; + +export class CartContextTransformer { + /** + * Transforma el carrito al formato para Liquid Context + */ + public transformCartToContext(cart: Cart): CartContext { + const totalItems = Array.isArray(cart.items) ? cart.items.reduce((total, item) => total + item.quantity, 0) : 0; + + const totalPrice = Array.isArray(cart.items) ? cart.items.reduce((total, item) => total + item.totalPrice, 0) : 0; + + return { + id: cart.id, + item_count: totalItems, + total_price: totalPrice, + currency: cart.currency || 'COP', + items: Array.isArray(cart.items) ? cart.items.map((item) => this.transformCartItemToContext(item)) : [], + created_at: cart.createdAt, + updated_at: cart.updatedAt, + }; + } + + /** + * Transforma un item del carrito al formato de contexto + */ + private transformCartItemToContext(item: any): any { + let productSnapshotParsed: any = {}; + if (typeof item.productSnapshot === 'string') { + try { + productSnapshotParsed = JSON.parse(item.productSnapshot); + } catch (e) { + logger.error('Failed to parse productSnapshot for cart item', e, 'CartContextTransformer'); + } + } + + return { + id: item.id, + product_id: item.productId, + variant_id: item.variantId || '', + title: productSnapshotParsed.name || 'N/A', + price: item.unitPrice, + quantity: item.quantity, + line_price: item.totalPrice, + image: productSnapshotParsed.images?.[0] || '', + url: `/products/${productSnapshotParsed.slug || productSnapshotParsed.id}`, + attributes: productSnapshotParsed.attributes || [], + selectedAttributes: productSnapshotParsed.selectedAttributes || {}, + variant_title: productSnapshotParsed.variant_title || '', + }; + } +} + +export const cartContextTransformer = new CartContextTransformer(); diff --git a/renderer-engine/services/fetchers/cart-fetcher.ts b/renderer-engine/services/fetchers/cart/cart-fetcher.ts similarity index 54% rename from renderer-engine/services/fetchers/cart-fetcher.ts rename to renderer-engine/services/fetchers/cart/cart-fetcher.ts index 02372517..f2652556 100644 --- a/renderer-engine/services/fetchers/cart-fetcher.ts +++ b/renderer-engine/services/fetchers/cart/cart-fetcher.ts @@ -16,21 +16,13 @@ import { logger } from '@/renderer-engine/lib/logger'; import { dataFetcher } from '@/renderer-engine/services/fetchers/data-fetcher'; -import type { Cart, CartContext, CartItem, CartRaw, CartResponse, UpdateCartRequest } from '@/renderer-engine/types'; +import type { Cart, CartRaw } from '@/renderer-engine/types'; import { cookiesClient } from '@/utils/server/AmplifyServer'; +import { cartContextTransformer } from './cart-context-transformer'; +import { cartItemTransformer } from './cart-item-transformer'; +import { cartTotalsCalculator } from './cart-totals-calculator'; +import type { AddToCartRequest, CartResponse, UpdateCartRequest, UserStoreCurrency } from './types/cart-types'; -export interface AddToCartRequest { - storeId: string; - productId: string; - variantId?: string | null; - quantity: number; - sessionId?: string; - selectedAttributes?: Record; -} - -interface UserStoreCurrency { - storeCurrency?: string; -} export class CartFetcher { /** * Obtiene el carrito actual para una tienda. @@ -48,34 +40,10 @@ export class CartFetcher { if (guestCartResponse.data && guestCartResponse.data.length > 0) { rawCartData = guestCartResponse.data[0]; - } else { - rawCartData = undefined; } if (!rawCartData) { - const expiresAt = new Date(); - 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, - itemCount: 0, - 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.'); - } - rawCartData = createdCart; + rawCartData = await this.createNewCart(storeId, sessionId); } if (!rawCartData) { @@ -84,10 +52,13 @@ export class CartFetcher { const { data: items } = await cookiesClient.models.CartItem.listCartItemByCartId({ cartId: rawCartData.id }); + // Transformar los items para incluir los atributos del productSnapshot + const transformedItems = cartItemTransformer.transformCartItems(items); + const cart: Cart = { ...rawCartData, - items: items as CartItem[], - }; + items: transformedItems, + } as unknown as Cart; return cart; } catch (error) { @@ -96,6 +67,32 @@ export class CartFetcher { } } + /** + * Crea un nuevo carrito + */ + private async createNewCart(storeId: string, sessionId: string): Promise { + const expiresAt = new Date(); + 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, + itemCount: 0, + totalAmount: 0, + expiresAt: expiresAt.toISOString(), + sessionId: sessionId, + currency: detectedCurrency, + }; + + const { data: createdCart } = await cookiesClient.models.Cart.create(newCartData); + return createdCart || undefined; + } + /** * Agrega un producto al carrito o actualiza su cantidad si ya existe. */ @@ -113,28 +110,7 @@ export class CartFetcher { return { success: false, error: 'Product not found.' }; } - const productPrice = product.price || 0; - const productSnapshot = JSON.stringify({ - id: product.id, - storeId: product.storeId, - name: product.name, - title: product.name, - slug: product.slug, - attributes: product.attributes || [], - selectedAttributes: selectedAttributes || {}, - featured_image: product.featured_image, - quantity: product.quantity, - description: product.description, - price: product.price, - compare_at_price: product.compare_at_price, - url: product.url, - images: product.images || [], - variants: product.variants || [], - status: product.status, - category: product.category, - createdAt: product.createdAt, - updatedAt: product.updatedAt, - }); + const productSnapshot = cartItemTransformer.createProductSnapshot(product, selectedAttributes); const cartItemsResponse = await cookiesClient.models.CartItem.listCartItemByCartId( { cartId: currentCart.id }, @@ -145,37 +121,12 @@ export class CartFetcher { cartItemsResponse.data && cartItemsResponse.data.length > 0 ? cartItemsResponse.data[0] : undefined; if (existingCartItem) { - const updatedQuantity = existingCartItem.quantity + quantity; - const updatedTotalPrice = updatedQuantity * existingCartItem.unitPrice; - - const { data: updatedItem } = await cookiesClient.models.CartItem.update({ - id: existingCartItem.id, - quantity: updatedQuantity, - totalPrice: updatedTotalPrice, - owner: currentCart.sessionId || 'public', - }); - if (!updatedItem) { - throw new Error('Failed to update cart item.'); - } + await this.updateExistingCartItem(existingCartItem, quantity); } else { - const newItemTotalPrice = productPrice * quantity; - const { data: createdItem } = await cookiesClient.models.CartItem.create({ - cartId: currentCart.id, - storeId: currentCart.storeId, - productId: productId, - variantId: variantId, - quantity: quantity, - unitPrice: productPrice, - totalPrice: newItemTotalPrice, - productSnapshot: productSnapshot, - owner: currentCart.sessionId || 'public', - }); - if (!createdItem) { - throw new Error('Failed to create cart item.'); - } + await this.createNewCartItem(currentCart, productId, variantId, quantity, product.price || 0, productSnapshot); } - await this.recalculateCartTotals(currentCart.id); + await cartTotalsCalculator.recalculateCartTotals(currentCart.id); const updatedCart = await this.getCart(storeId, sessionId || ''); return { success: true, cart: updatedCart }; @@ -185,6 +136,54 @@ export class CartFetcher { } } + /** + * Actualiza un item existente del carrito + */ + private async updateExistingCartItem(existingCartItem: any, quantity: number): Promise { + const updatedQuantity = existingCartItem.quantity + quantity; + const updatedTotalPrice = updatedQuantity * existingCartItem.unitPrice; + + const { data: updatedItem } = await cookiesClient.models.CartItem.update({ + id: existingCartItem.id, + quantity: updatedQuantity, + totalPrice: updatedTotalPrice, + owner: existingCartItem.owner, + }); + + if (!updatedItem) { + throw new Error('Failed to update cart item.'); + } + } + + /** + * Crea un nuevo item del carrito + */ + private async createNewCartItem( + cart: Cart, + productId: string, + variantId: string | null | undefined, + quantity: number, + productPrice: number, + productSnapshot: string + ): Promise { + const newItemTotalPrice = productPrice * quantity; + const { data: createdItem } = await cookiesClient.models.CartItem.create({ + cartId: cart.id, + storeId: cart.storeId, + productId: productId, + variantId: variantId, + quantity: quantity, + unitPrice: productPrice, + totalPrice: newItemTotalPrice, + productSnapshot: productSnapshot, + owner: cart.sessionId || 'public', + }); + + if (!createdItem) { + throw new Error('Failed to create cart item.'); + } + } + /** * Actualiza la cantidad de un item en el carrito o lo elimina si la cantidad es <= 0. */ @@ -214,7 +213,7 @@ export class CartFetcher { }); } - await this.recalculateCartTotals(currentCart.id); + await cartTotalsCalculator.recalculateCartTotals(currentCart.id); const updatedCart = await this.getCart(storeId, sessionId || ''); return { success: true, cart: updatedCart }; @@ -242,7 +241,7 @@ export class CartFetcher { await cookiesClient.models.CartItem.delete({ id: itemId }); - await this.recalculateCartTotals(currentCart.id); + await cartTotalsCalculator.recalculateCartTotals(currentCart.id); const updatedCart = await this.getCart(storeId, sessionId || ''); return { success: true, cart: updatedCart }; @@ -287,61 +286,8 @@ export class CartFetcher { /** * Transforma el carrito al formato para Liquid Context. */ - public transformCartToContext(cart: Cart): CartContext { - const totalItems = Array.isArray(cart.items) ? cart.items.reduce((total, item) => total + item.quantity, 0) : 0; - const totalPrice = Array.isArray(cart.items) ? cart.items.reduce((total, item) => total + item.totalPrice, 0) : 0; - - return { - 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 = {}; - if (typeof item.productSnapshot === 'string') { - try { - productSnapshotParsed = JSON.parse(item.productSnapshot); - } catch (e) { - logger.error('Failed to parse productSnapshot for cart item', e, 'CartFetcher'); - } - } - - return { - id: item.id, - product_id: item.productId, - variant_id: item.variantId || '', - title: productSnapshotParsed.name || 'N/A', - price: item.unitPrice, - quantity: item.quantity, - line_price: item.totalPrice, - image: productSnapshotParsed.images?.[0] || '', - url: `/products/${productSnapshotParsed.slug || productSnapshotParsed.id}`, - attributes: productSnapshotParsed.attributes || [], - selectedAttributes: productSnapshotParsed.selectedAttributes || {}, - variant_title: productSnapshotParsed.variant_title || '', - }; - }) - : [], - created_at: cart.createdAt, - updated_at: cart.updatedAt, - }; - } - - /** - * Recalcula los totales del carrito (cantidad de ítems y precio total). - */ - private async recalculateCartTotals(cartId: string): Promise { - const { data: cartItems } = await cookiesClient.models.CartItem.listCartItemByCartId({ cartId }); - - const totalItems = cartItems.reduce((total, item) => total + item.quantity, 0); - const totalAmount = cartItems.reduce((total, item) => total + item.totalPrice, 0); - - await cookiesClient.models.Cart.update({ - id: cartId, - itemCount: totalItems, - totalAmount: totalAmount, - }); + public transformCartToContext(cart: Cart) { + return cartContextTransformer.transformCartToContext(cart); } } diff --git a/renderer-engine/services/fetchers/cart/cart-item-transformer.ts b/renderer-engine/services/fetchers/cart/cart-item-transformer.ts new file mode 100644 index 00000000..3a9c6b54 --- /dev/null +++ b/renderer-engine/services/fetchers/cart/cart-item-transformer.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { TransformedCartItem } from './types/cart-types'; + +export class CartItemTransformer { + /** + * Transforma un item del carrito para incluir los atributos del productSnapshot + */ + public transformCartItem(item: any): TransformedCartItem { + let productSnapshot = null; + try { + if (item.productSnapshot && typeof item.productSnapshot === 'string') { + productSnapshot = JSON.parse(item.productSnapshot); + } + } catch (error) {} + + return { + ...item, + attributes: productSnapshot?.attributes || [], + selectedAttributes: productSnapshot?.selectedAttributes || {}, + title: productSnapshot?.title || productSnapshot?.name || 'Unknown Product', + price: item.unitPrice, + image: productSnapshot?.featured_image || productSnapshot?.image, + url: productSnapshot?.url, + variant_title: productSnapshot?.variantTitle, + }; + } + + /** + * Transforma un array de items del carrito + */ + public transformCartItems(items: any[]): TransformedCartItem[] { + return (items || []).map((item) => this.transformCartItem(item)); + } + + /** + * Crea un productSnapshot para un producto + */ + public createProductSnapshot(product: any, selectedAttributes?: Record): string { + return JSON.stringify({ + id: product.id, + storeId: product.storeId, + name: product.name, + title: product.name, + slug: product.slug, + attributes: product.attributes || [], + selectedAttributes: selectedAttributes || {}, + featured_image: product.featured_image, + quantity: product.quantity, + description: product.description, + price: product.price, + compare_at_price: product.compare_at_price, + url: product.url, + images: product.images || [], + variants: product.variants || [], + status: product.status, + category: product.category, + createdAt: product.createdAt, + updatedAt: product.updatedAt, + }); + } +} + +export const cartItemTransformer = new CartItemTransformer(); diff --git a/renderer-engine/services/fetchers/cart/cart-totals-calculator.ts b/renderer-engine/services/fetchers/cart/cart-totals-calculator.ts new file mode 100644 index 00000000..f027f702 --- /dev/null +++ b/renderer-engine/services/fetchers/cart/cart-totals-calculator.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cookiesClient } from '@/utils/server/AmplifyServer'; + +export class CartTotalsCalculator { + /** + * Recalcula los totales del carrito (cantidad de ítems y precio total) + */ + public async recalculateCartTotals(cartId: string): Promise { + const { data: cartItems } = await cookiesClient.models.CartItem.listCartItemByCartId({ cartId }); + + const totalItems = cartItems.reduce((total, item) => total + item.quantity, 0); + const totalAmount = cartItems.reduce((total, item) => total + item.totalPrice, 0); + + await cookiesClient.models.Cart.update({ + id: cartId, + itemCount: totalItems, + totalAmount: totalAmount, + }); + } + + /** + * Calcula los totales del carrito sin guardar en la base de datos + */ + public calculateCartTotals(items: any[]): { totalItems: number; totalAmount: number } { + const totalItems = items.reduce((total, item) => total + item.quantity, 0); + const totalAmount = items.reduce((total, item) => total + item.totalPrice, 0); + + return { totalItems, totalAmount }; + } +} + +export const cartTotalsCalculator = new CartTotalsCalculator(); diff --git a/renderer-engine/services/fetchers/cart/index.ts b/renderer-engine/services/fetchers/cart/index.ts new file mode 100644 index 00000000..70d593c9 --- /dev/null +++ b/renderer-engine/services/fetchers/cart/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { cartContextTransformer } from './cart-context-transformer'; +export { CartFetcher, cartFetcher } from './cart-fetcher'; +export { cartItemTransformer } from './cart-item-transformer'; +export { cartTotalsCalculator } from './cart-totals-calculator'; +export * from './types/cart-types'; diff --git a/renderer-engine/services/fetchers/cart/types/cart-types.ts b/renderer-engine/services/fetchers/cart/types/cart-types.ts new file mode 100644 index 00000000..4273bec5 --- /dev/null +++ b/renderer-engine/services/fetchers/cart/types/cart-types.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface AddToCartRequest { + storeId: string; + productId: string; + variantId?: string | null; + quantity: number; + sessionId?: string; + selectedAttributes?: Record; +} + +export interface UpdateCartRequest { + storeId: string; + itemId: string; + quantity: number; + sessionId?: string; +} + +export interface CartResponse { + success: boolean; + cart?: any; + error?: string; +} + +export interface UserStoreCurrency { + storeCurrency?: string; +} + +export interface TransformedCartItem { + id: string; + cartId: string; + storeId: string; + productId: string; + variantId?: string | null; + quantity: number; + unitPrice: number; + totalPrice: number; + productSnapshot: string; + owner: string; + createdAt?: string; + updatedAt?: string; + // Campos transformados del productSnapshot + attributes: any[]; + selectedAttributes: Record; + title: string; + price: number; + image?: string; + url?: string; + variant_title?: string; +} diff --git a/renderer-engine/services/fetchers/checkout-data-transformer.ts b/renderer-engine/services/fetchers/checkout/checkout-data-transformer.ts similarity index 73% rename from renderer-engine/services/fetchers/checkout-data-transformer.ts rename to renderer-engine/services/fetchers/checkout/checkout-data-transformer.ts index ccd73fb9..98a17587 100644 --- a/renderer-engine/services/fetchers/checkout-data-transformer.ts +++ b/renderer-engine/services/fetchers/checkout/checkout-data-transformer.ts @@ -25,7 +25,7 @@ export class CheckoutDataTransformer { /** * Transforma un item del carrito al formato esperado por checkout.liquid */ - private transformCartItem(item: any): any { + public transformCartItem(item: any): any { try { // Parsear el productSnapshot que viene como JSON string const productSnapshot = item.productSnapshot ? JSON.parse(item.productSnapshot) : {}; @@ -59,49 +59,46 @@ export class CheckoutDataTransformer { logger.error('Error transforming cart item:', error); // Fallback en caso de error - return { - id: item.id, + return this.createFallbackCartItem(item); + } + } + + /** + * Crea un item de carrito de fallback en caso de error + */ + private createFallbackCartItem(item: any): any { + return { + id: item.id, + title: 'Producto', + variant_title: null, + quantity: item.quantity || 1, + price: item.unitPrice || 0, + line_price: item.totalPrice || 0, + image: null, + url: `/products/${item.productId}`, + product_id: item.productId, + variant_id: item.variantId, + handle: item.productId, + selectedAttributes: {}, + product: { + id: item.productId, title: 'Producto', - variant_title: null, - quantity: item.quantity || 1, + images: [], price: item.unitPrice || 0, - line_price: item.totalPrice || 0, - image: null, - url: `/products/${item.productId}`, - product_id: item.productId, - variant_id: item.variantId, - handle: item.productId, - selectedAttributes: {}, - product: { - id: item.productId, - title: 'Producto', - images: [], - price: item.unitPrice || 0, - compare_at_price: null, - description: '', - category: '', - status: 'active', - }, - }; - } + compare_at_price: null, + description: '', + category: '', + status: 'active', + }, + }; } /** * Transforma la dirección al formato esperado por Liquid */ - private transformAddress(address: any): any { + public transformAddress(address: any): any { if (!address) { - return { - address1: '', - address2: '', - city: '', - province: '', - zip: '', - country: 'CO', - first_name: '', - last_name: '', - phone: '', - }; + return this.createDefaultAddress(); } return { @@ -117,17 +114,29 @@ export class CheckoutDataTransformer { }; } + /** + * Crea una dirección por defecto + */ + private createDefaultAddress(): any { + return { + address1: '', + address2: '', + city: '', + province: '', + zip: '', + country: 'CO', + first_name: '', + last_name: '', + phone: '', + }; + } + /** * Transforma la información del cliente al formato esperado por Liquid */ - private transformCustomerInfo(customerInfo: any): any { + public transformCustomerInfo(customerInfo: any): any { if (!customerInfo) { - return { - email: '', - firstName: '', - lastName: '', - phone: '', - }; + return this.createDefaultCustomerInfo(); } return { @@ -138,6 +147,18 @@ export class CheckoutDataTransformer { }; } + /** + * Crea información de cliente por defecto + */ + private createDefaultCustomerInfo(): any { + return { + email: '', + firstName: '', + lastName: '', + phone: '', + }; + } + /** * Transforma una sesión de checkout completa al formato Liquid */ @@ -187,26 +208,33 @@ export class CheckoutDataTransformer { 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(), - }; + return this.createFallbackCheckoutContext(session); } } + /** + * Crea un contexto de checkout de fallback en caso de error + */ + private createFallbackCheckoutContext(session: CheckoutSession): CheckoutContext { + 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.createDefaultCustomerInfo(), + shipping_address: this.createDefaultAddress(), + billing_address: this.createDefaultAddress(), + note: '', + requires_shipping: true, + expires_at: session.expiresAt || new Date().toISOString(), + }; + } + /** * Valida que una sesión de checkout tenga los datos mínimos requeridos */ diff --git a/renderer-engine/services/fetchers/checkout-fetcher.ts b/renderer-engine/services/fetchers/checkout/checkout-fetcher.ts similarity index 58% rename from renderer-engine/services/fetchers/checkout-fetcher.ts rename to renderer-engine/services/fetchers/checkout/checkout-fetcher.ts index 3eec89e5..6d9fccb3 100644 --- a/renderer-engine/services/fetchers/checkout-fetcher.ts +++ b/renderer-engine/services/fetchers/checkout/checkout-fetcher.ts @@ -25,62 +25,29 @@ import type { StartCheckoutRequest, UpdateCustomerInfoRequest, } from '@/renderer-engine/types'; -import { cookiesClient } from '@/utils/server/AmplifyServer'; -import crypto from 'crypto'; import { checkoutDataTransformer } from './checkout-data-transformer'; - -interface UserStoreCurrency { - storeCurrency?: string; -} +import { checkoutOrderCreator } from './checkout-order-creator'; +import { checkoutSessionManager } from './checkout-session-manager'; +import type { CheckoutTotals } from './types/checkout-types'; 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); + const token = checkoutSessionManager.generateToken(); + const storeOwner = await checkoutSessionManager.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; + const totals = this.calculateCheckoutTotals(cart); // 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 itemsSnapshot = this.createItemsSnapshot(cart); const sessionData = { token, @@ -90,10 +57,7 @@ export class CheckoutFetcher { status: 'open' as const, expiresAt: expiresAt.toISOString(), currency: cart.currency || 'COP', - subtotal, - shippingCost, - taxAmount, - totalAmount, + ...totals, itemsSnapshot: JSON.stringify(itemsSnapshot), customerInfo: request.customerInfo ? JSON.stringify(request.customerInfo) : null, shippingAddress: request.shippingAddress ? JSON.stringify(request.shippingAddress) : null, @@ -102,21 +66,12 @@ export class CheckoutFetcher { storeOwner, }; - const response = await cookiesClient.models.CheckoutSession.create(sessionData); + const response = await checkoutSessionManager.createSession(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', - }; - } + return { + success: true, + session: this.transformToSession(response), + }; } catch (error) { logger.error('Error starting checkout:', error); return { @@ -131,24 +86,19 @@ export class CheckoutFetcher { */ 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]; + const session = await checkoutSessionManager.getSessionByToken(token); - // 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; - } + if (!session) { + return null; + } - return this.transformToSession(session); + // Verificar si la sesión ha expirado + if (checkoutSessionManager.isSessionExpired(session.expiresAt)) { + await checkoutSessionManager.markSessionAsExpiredIfNeeded(session); + return null; } - return null; + return this.transformToSession(session); } catch (error) { logger.error('Error getting checkout session:', error); return null; @@ -161,21 +111,16 @@ export class CheckoutFetcher { public async updateCustomerInfo(request: UpdateCustomerInfoRequest): Promise { try { // Obtener la sesión raw directamente de la base de datos - const rawResponse = await cookiesClient.models.CheckoutSession.listCheckoutSessionByToken( - { token: request.token }, - { limit: 1 } - ); + const session = await checkoutSessionManager.getSessionByToken(request.token); - if (!rawResponse.data || rawResponse.data.length === 0) { + if (!session) { return { success: false, error: 'Checkout session not found', }; } - const rawSession = rawResponse.data[0]; - - if (rawSession.status !== 'open') { + if (session.status !== 'open') { return { success: false, error: 'Checkout session not available', @@ -188,22 +133,12 @@ export class CheckoutFetcher { if (request.billingAddress) updateData.billingAddress = JSON.stringify(request.billingAddress); if (request.notes !== undefined) updateData.notes = request.notes; - const response = await cookiesClient.models.CheckoutSession.update({ - id: rawSession.id, - ...updateData, - }); + const updatedSession = await checkoutSessionManager.updateSessionCustomerInfo(session.id, updateData); - if (response.data) { - return { - success: true, - session: this.transformToSession(response.data), - }; - } else { - return { - success: false, - error: 'Failed to update checkout session', - }; - } + return { + success: true, + session: this.transformToSession(updatedSession), + }; } catch (error) { logger.error('Error updating checkout session:', error); return { @@ -219,36 +154,21 @@ export class CheckoutFetcher { public async updateSessionStatus(token: string, status: CheckoutStatus): Promise { try { // Obtener la sesión raw directamente para tener el ID - const rawResponse = await cookiesClient.models.CheckoutSession.listCheckoutSessionByToken( - { token }, - { limit: 1 } - ); + const session = await checkoutSessionManager.getSessionByToken(token); - if (!rawResponse.data || rawResponse.data.length === 0) { + if (!session) { return { success: false, error: 'Checkout session not found', }; } - const rawSession = rawResponse.data[0]; - const response = await cookiesClient.models.CheckoutSession.update({ - id: rawSession.id, - status, - }); + const updatedSession = await checkoutSessionManager.updateSessionStatus(session.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', - }; - } + return { + success: true, + session: this.transformToSession(updatedSession), + }; } catch (error) { logger.error('Error updating checkout session status:', error); return { @@ -259,10 +179,48 @@ export class CheckoutFetcher { } /** - * Completa una sesión de checkout (la marca como completed) + * Completa una sesión de checkout (la marca como completed) y crea la orden */ - public async completeCheckout(token: string): Promise { - return this.updateSessionStatus(token, 'completed'); + public async completeCheckout( + token: string, + paymentMethod?: string, + paymentId?: string, + customerEmail?: string + ): Promise { + try { + // Primero actualizar el estado a completed + const updateResponse = await this.updateSessionStatus(token, 'completed'); + + if (!updateResponse.success || !updateResponse.session) { + return updateResponse; + } + + // Crear la orden automáticamente + return await checkoutOrderCreator.completeCheckout( + updateResponse.session, + paymentMethod, + paymentId, + customerEmail + ); + } catch (error) { + logger.error('Error completing checkout:', error); + return { + success: false, + error: 'Internal error completing checkout', + }; + } + } + + /** + * Completa una sesión de checkout con información de pago y crea la orden + */ + public async completeCheckoutWithPayment( + token: string, + paymentMethod: string, + paymentId: string, + customerEmail?: string + ): Promise { + return this.completeCheckout(token, paymentMethod, paymentId, customerEmail); } /** @@ -310,6 +268,48 @@ export class CheckoutFetcher { public validateSession(session: CheckoutSession): boolean { return checkoutDataTransformer.validateCheckoutSession(session); } + + /** + * Calcula los totales del checkout basados en el carrito + */ + private calculateCheckoutTotals(cart: Cart): CheckoutTotals { + 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; + + return { + subtotal, + shippingCost, + taxAmount, + totalAmount, + }; + } + + /** + * Crea el snapshot de los items del carrito + */ + private createItemsSnapshot(cart: Cart): CartSnapshot { + return { + items: (cart.items || []).map((item: any) => ({ + ...item, + attributes: item.attributes || [], + selectedAttributes: item.selectedAttributes || {}, + product_id: item.product_id || item.productId, + variant_id: item.variant_id || item.variantId, + title: item.title, + price: item.price, + unitPrice: item.unitPrice, + quantity: item.quantity, + image: item.image, + url: item.url, + variant_title: item.variant_title || item.variantTitle, + })), + itemCount: cart.itemCount || 0, + cartTotal: cart.totalAmount || 0, + snapshotAt: new Date().toISOString(), + }; + } } export const checkoutFetcher = new CheckoutFetcher(); diff --git a/renderer-engine/services/fetchers/checkout/checkout-order-creator.ts b/renderer-engine/services/fetchers/checkout/checkout-order-creator.ts new file mode 100644 index 00000000..f1273856 --- /dev/null +++ b/renderer-engine/services/fetchers/checkout/checkout-order-creator.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import type { CheckoutResponse, CheckoutSession } from '@/renderer-engine/types'; +import { orderFetcher, type CreateOrderRequest } from '../order'; + +export class CheckoutOrderCreator { + /** + * Completa una sesión de checkout (la marca como completed) y crea la orden + */ + public async completeCheckout( + session: CheckoutSession, + paymentMethod?: string, + paymentId?: string, + customerEmail?: string + ): Promise { + try { + // Crear la orden automáticamente + const createOrderRequest: CreateOrderRequest = { + checkoutSession: session, + paymentMethod, + paymentId, + customerEmail, + }; + + const orderResponse = await orderFetcher.createOrderFromCheckout(createOrderRequest); + + if (orderResponse.success) { + logger.info(`Order created successfully for checkout session ${session.token}`); + return { + success: true, + session: session, + order: orderResponse.order, + }; + } else { + logger.error(`Failed to create order for checkout session ${session.token}:`, orderResponse.error); + // Aunque falle la creación de la orden, el checkout se completó + // Podemos retornar éxito pero con un warning + return { + success: true, + session: session, + warning: 'Checkout completed but order creation failed', + }; + } + } catch (error) { + logger.error('Error completing checkout:', error); + return { + success: false, + error: 'Internal error completing checkout', + }; + } + } + + /** + * Completa una sesión de checkout con información de pago y crea la orden + */ + public async completeCheckoutWithPayment( + session: CheckoutSession, + paymentMethod: string, + paymentId: string, + customerEmail?: string + ): Promise { + return this.completeCheckout(session, paymentMethod, paymentId, customerEmail); + } +} + +export const checkoutOrderCreator = new CheckoutOrderCreator(); diff --git a/renderer-engine/services/fetchers/checkout/checkout-session-manager.ts b/renderer-engine/services/fetchers/checkout/checkout-session-manager.ts new file mode 100644 index 00000000..315e01cf --- /dev/null +++ b/renderer-engine/services/fetchers/checkout/checkout-session-manager.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import crypto from 'crypto'; +import type { CheckoutSessionData, UserStoreCurrency } from './types/checkout-types'; + +export class CheckoutSessionManager { + /** + * Genera un token único para la sesión de checkout + * Formato: fs_ similar a Shopify + */ + public generateToken(): string { + const raw = crypto.randomBytes(16).toString('base64url'); + return `fs_${raw}`; + } + + /** + * Obtiene el storeOwner (userId) basado en storeId + */ + public async getStoreOwner(storeId: string): Promise { + try { + const { data: store } = await cookiesClient.models.UserStore.get({ storeId }); + return (store as UserStoreCurrency)?.userId || ''; + } catch (error) { + logger.error('Error getting store owner:', error); + throw new Error('Store not found'); + } + } + + /** + * Crea una nueva sesión de checkout + */ + public async createSession(sessionData: CheckoutSessionData): Promise { + try { + const response = await cookiesClient.models.CheckoutSession.create(sessionData); + + if (response.data) { + logger.info(`Checkout session created: ${sessionData.token} for store ${sessionData.storeId}`); + return response.data; + } else { + logger.error('Failed to create checkout session:', response.errors); + throw new Error('Failed to create checkout session'); + } + } catch (error) { + logger.error('Error creating checkout session:', error); + throw error; + } + } + + /** + * 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) { + return response.data[0]; + } + + return null; + } catch (error) { + logger.error('Error getting checkout session:', error); + return null; + } + } + + /** + * Actualiza el estado de una sesión de checkout + */ + public async updateSessionStatus( + sessionId: string, + status: 'open' | 'completed' | 'expired' | 'cancelled' + ): Promise { + try { + const response = await cookiesClient.models.CheckoutSession.update({ + id: sessionId, + status, + }); + + if (response.data) { + logger.info(`Checkout session ${sessionId} updated to status: ${status}`); + return response.data; + } else { + throw new Error('Failed to update checkout session status'); + } + } catch (error) { + logger.error('Error updating checkout session status:', error); + throw error; + } + } + + /** + * Actualiza los datos del cliente en la sesión de checkout + */ + public async updateSessionCustomerInfo(sessionId: string, updateData: any): Promise { + try { + const response = await cookiesClient.models.CheckoutSession.update({ + id: sessionId, + ...updateData, + }); + + if (response.data) { + return response.data; + } else { + throw new Error('Failed to update checkout session'); + } + } catch (error) { + logger.error('Error updating checkout session:', error); + throw error; + } + } + + /** + * Verifica si una sesión ha expirado + */ + public isSessionExpired(expiresAt: string): boolean { + return new Date(expiresAt) < new Date(); + } + + /** + * Marca una sesión como expirada si es necesario + */ + public async markSessionAsExpiredIfNeeded(session: any): Promise { + if (this.isSessionExpired(session.expiresAt) && session.status === 'open') { + await this.updateSessionStatus(session.id, 'expired'); + } + } +} + +export const checkoutSessionManager = new CheckoutSessionManager(); diff --git a/renderer-engine/services/fetchers/checkout/index.ts b/renderer-engine/services/fetchers/checkout/index.ts new file mode 100644 index 00000000..e91f5e57 --- /dev/null +++ b/renderer-engine/services/fetchers/checkout/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { checkoutDataTransformer } from './checkout-data-transformer'; +export { CheckoutFetcher, checkoutFetcher } from './checkout-fetcher'; +export { checkoutOrderCreator } from './checkout-order-creator'; +export { checkoutSessionManager } from './checkout-session-manager'; +export * from './types/checkout-types'; diff --git a/renderer-engine/services/fetchers/checkout/types/checkout-types.ts b/renderer-engine/services/fetchers/checkout/types/checkout-types.ts new file mode 100644 index 00000000..41f4bc37 --- /dev/null +++ b/renderer-engine/services/fetchers/checkout/types/checkout-types.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface UserStoreCurrency { + storeCurrency?: string; + userId?: string; +} + +export interface CheckoutSessionData { + 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: string; + customerInfo?: string | null; + shippingAddress?: string | null; + billingAddress?: string | null; + notes?: string; + storeOwner: string; +} + +export interface CheckoutTotals { + subtotal: number; + shippingCost: number; + taxAmount: number; + totalAmount: number; +} + +export interface CheckoutItemSnapshot { + id: string; + product_id: string; + variant_id?: string; + title: string; + price: number; + unitPrice: number; + quantity: number; + image?: string; + url?: string; + variant_title?: string; + attributes: any[]; + selectedAttributes: Record; +} diff --git a/renderer-engine/services/fetchers/collection-fetcher.ts b/renderer-engine/services/fetchers/collection-fetcher.ts deleted file mode 100644 index e08bc217..00000000 --- a/renderer-engine/services/fetchers/collection-fetcher.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2025 Fasttify LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { logger } from '@/renderer-engine/lib/logger'; -import { cacheManager, getCollectionCacheKey, getCollectionsCacheKey } from '@/renderer-engine/services/core/cache'; -import { dataTransformer } from '@/renderer-engine/services/core/data-transformer'; -import { productFetcher } from '@/renderer-engine/services/fetchers/product-fetcher'; -import type { CollectionContext, ProductContext, TemplateError } from '@/renderer-engine/types'; -import { cookiesClient } from '@/utils/server/AmplifyServer'; - -interface PaginationOptions { - limit?: number; - offset?: number; - nextToken?: string; -} - -interface CollectionsResponse { - collections: CollectionContext[]; - nextToken?: string | null; -} - -export class CollectionFetcher { - /** - * Obtiene colecciones de una tienda - */ - public async getStoreCollections(storeId: string, options: PaginationOptions = {}): Promise { - try { - const { limit = 10, nextToken } = options; - const cacheKey = getCollectionsCacheKey(storeId, limit, nextToken); - - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as CollectionsResponse; - } - - // Amplify Query - const response = await cookiesClient.models.Collection.listCollectionByStoreId( - { storeId }, - { - limit, - nextToken, - filter: { - isActive: { eq: true }, - }, - } - ); - - if (!response.data) { - return { collections: [] }; - } - - const collections: CollectionContext[] = []; - for (const collection of response.data) { - const transformedCollection = this.transformCollection(collection, [], null, 0); - collections.push(transformedCollection); - } - - const result: CollectionsResponse = { - collections, - nextToken: response.nextToken, - }; - - cacheManager.setCached(cacheKey, result, cacheManager.getDataTTL('collection')); - return result; - } catch (error) { - logger.error(`Error fetching collections for store ${storeId}`, error, 'CollectionFetcher'); - - const templateError: TemplateError = { - type: 'DATA_ERROR', - message: `Failed to fetch collections for store: ${storeId}`, - details: error, - statusCode: 500, - }; - - throw templateError; - } - } - - /** - * Obtiene una colección específica con sus productos paginados. - */ - public async getCollection( - storeId: string, - collectionId: string, - options: PaginationOptions = {} - ): Promise { - try { - const cacheKey = getCollectionCacheKey(storeId, collectionId, options.limit, options.nextToken); - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as CollectionContext; - } - - const { data: collection } = await cookiesClient.models.Collection.get({ - id: collectionId, - }); - if (!collection || collection.storeId !== storeId) { - return null; - } - - const handle = dataTransformer.createHandle(collection.title || `collection-${collection.id}`); - - const { products, nextToken, totalCount } = await productFetcher.getProductsByCollection( - storeId, - collectionId, - handle, - options - ); - - const transformedCollection = this.transformCollection(collection, products, nextToken, totalCount || 0); - - cacheManager.setCached(cacheKey, transformedCollection, cacheManager.getDataTTL('collection')); - return transformedCollection; - } catch (error) { - logger.error(`Error fetching collection ${collectionId} for store ${storeId}`, error, 'CollectionFetcher'); - return null; - } - } - - /** - * Transforma una colección de Amplify al formato Liquid, inyectando productos paginados. - */ - private transformCollection( - collection: any, - products: ProductContext[], - nextToken: string | null | undefined, - totalCount?: number | undefined - ): CollectionContext { - const handle = dataTransformer.createHandle(collection.name || collection.title || `collection-${collection.id}`); - - return { - id: collection.id, - storeId: collection.storeId, - title: collection.title, - description: collection.description, - slug: handle, - url: `/collections/${handle}`, - image: collection.image || 'collection-img', - products, - nextToken, - owner: collection.owner, - sortOrder: collection.sortOrder, - isActive: collection.isActive, - createdAt: collection.createdAt, - updatedAt: collection.updatedAt, - products_count: totalCount, - }; - } -} - -export const collectionFetcher = new CollectionFetcher(); diff --git a/renderer-engine/services/fetchers/collection/collection-cache-manager.ts b/renderer-engine/services/fetchers/collection/collection-cache-manager.ts new file mode 100644 index 00000000..f56ef402 --- /dev/null +++ b/renderer-engine/services/fetchers/collection/collection-cache-manager.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cacheManager, getCollectionCacheKey, getCollectionsCacheKey } from '@/renderer-engine/services/core/cache'; +import type { CollectionContext, CollectionsResponse } from './types/collection-types'; + +export class CollectionCacheManager { + /** + * Obtiene datos del caché para colecciones de una tienda + */ + public getCachedCollections(storeId: string, limit: number, nextToken?: string): CollectionsResponse | null { + const cacheKey = getCollectionsCacheKey(storeId, limit, nextToken); + return cacheManager.getCached(cacheKey); + } + + /** + * Obtiene datos del caché para una colección específica + */ + public getCachedCollection( + storeId: string, + collectionId: string, + limit?: number, + nextToken?: string + ): CollectionContext | null { + const cacheKey = getCollectionCacheKey(storeId, collectionId, limit, nextToken); + return cacheManager.getCached(cacheKey); + } + + /** + * Guarda en caché la respuesta de colecciones + */ + public setCachedCollections( + storeId: string, + limit: number, + nextToken: string | undefined, + data: CollectionsResponse + ): void { + const cacheKey = getCollectionsCacheKey(storeId, limit, nextToken); + cacheManager.setCached(cacheKey, data, cacheManager.getDataTTL('collection')); + } + + /** + * Guarda en caché una colección específica + */ + public setCachedCollection( + storeId: string, + collectionId: string, + limit: number | undefined, + nextToken: string | undefined, + data: CollectionContext + ): void { + const cacheKey = getCollectionCacheKey(storeId, collectionId, limit, nextToken); + cacheManager.setCached(cacheKey, data, cacheManager.getDataTTL('collection')); + } + + /** + * Invalida el caché de colecciones para una tienda + */ + public invalidateStoreCache(storeId: string): void { + const collectionsCacheKey = getCollectionsCacheKey(storeId, 10); + cacheManager.invalidateTemplateCache(collectionsCacheKey); + } + + /** + * Invalida el caché de una colección específica + */ + public invalidateCollectionCache(storeId: string, collectionId: string): void { + const cacheKey = getCollectionCacheKey(storeId, collectionId); + cacheManager.invalidateTemplateCache(cacheKey); + } +} + +export const collectionCacheManager = new CollectionCacheManager(); diff --git a/renderer-engine/services/fetchers/collection/collection-fetcher.ts b/renderer-engine/services/fetchers/collection/collection-fetcher.ts new file mode 100644 index 00000000..69b8caf4 --- /dev/null +++ b/renderer-engine/services/fetchers/collection/collection-fetcher.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import type { CollectionContext, TemplateError } from '@/renderer-engine/types'; +import { productFetcher } from '../product'; +import { collectionCacheManager } from './collection-cache-manager'; +import { collectionQueryManager } from './collection-query-manager'; +import { collectionTransformer } from './collection-transformer'; +import type { CollectionsResponse, PaginationOptions } from './types/collection-types'; + +export class CollectionFetcher { + /** + * Obtiene colecciones de una tienda + */ + public async getStoreCollections(storeId: string, options: PaginationOptions = {}): Promise { + try { + const { limit = 10, nextToken } = options; + + // Verificar caché + const cached = collectionCacheManager.getCachedCollections(storeId, limit, nextToken); + if (cached) { + return cached; + } + + // Consultar base de datos + const response = await collectionQueryManager.queryStoreCollections(storeId, options); + + // Transformar colecciones + const transformedCollections = collectionTransformer.transformCollections(response.collections); + + const result: CollectionsResponse = { + collections: transformedCollections, + nextToken: response.nextToken, + }; + + // Guardar en caché + collectionCacheManager.setCachedCollections(storeId, limit, nextToken, result); + return result; + } catch (error) { + logger.error(`Error fetching collections for store ${storeId}`, error, 'CollectionFetcher'); + + const templateError: TemplateError = { + type: 'DATA_ERROR', + message: `Failed to fetch collections for store: ${storeId}`, + details: error, + statusCode: 500, + }; + + throw templateError; + } + } + + /** + * Obtiene una colección específica con sus productos paginados + */ + public async getCollection( + storeId: string, + collectionId: string, + options: PaginationOptions = {} + ): Promise { + try { + // Verificar caché + const cached = collectionCacheManager.getCachedCollection( + storeId, + collectionId, + options.limit, + options.nextToken + ); + if (cached) { + return cached; + } + + // Consultar base de datos + const collection = await collectionQueryManager.queryCollectionById(collectionId); + if (!collection || collection.storeId !== storeId) { + return null; + } + + const handle = collection.title || `collection-${collection.id}`; + + // Obtener productos de la colección + const { products, nextToken, totalCount } = await productFetcher.getProductsByCollection( + storeId, + collectionId, + handle, + options + ); + + // Transformar colección + const transformedCollection = collectionTransformer.transformCollection( + collection, + products, + nextToken, + totalCount || 0 + ); + + // Guardar en caché + collectionCacheManager.setCachedCollection( + storeId, + collectionId, + options.limit, + options.nextToken, + transformedCollection + ); + return transformedCollection; + } catch (error) { + logger.error(`Error fetching collection ${collectionId} for store ${storeId}`, error, 'CollectionFetcher'); + return null; + } + } +} + +export const collectionFetcher = new CollectionFetcher(); diff --git a/renderer-engine/services/fetchers/collection/collection-query-manager.ts b/renderer-engine/services/fetchers/collection/collection-query-manager.ts new file mode 100644 index 00000000..11c05763 --- /dev/null +++ b/renderer-engine/services/fetchers/collection/collection-query-manager.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import { collectionTransformer } from './collection-transformer'; +import type { CollectionData, CollectionQueryOptions, CollectionsResponse } from './types/collection-types'; + +export class CollectionQueryManager { + /** + * Obtiene colecciones de una tienda con filtros y paginación + */ + public async queryStoreCollections( + storeId: string, + options: CollectionQueryOptions = {} + ): Promise { + const { limit = 10, nextToken, filter } = options; + + const response = await cookiesClient.models.Collection.listCollectionByStoreId( + { storeId }, + { + limit, + nextToken, + filter: { + isActive: { eq: true }, + ...filter, + }, + } + ); + + if (!response.data) { + return { collections: [] }; + } + + const collections = response.data as CollectionData[]; + + return { + collections: collectionTransformer.transformCollections(collections), + nextToken: response.nextToken, + }; + } + + /** + * Obtiene una colección específica por ID + */ + public async queryCollectionById(collectionId: string): Promise { + const { data: collection } = await cookiesClient.models.Collection.get({ + id: collectionId, + }); + + return (collection as CollectionData) || null; + } +} + +export const collectionQueryManager = new CollectionQueryManager(); diff --git a/renderer-engine/services/fetchers/collection/collection-transformer.ts b/renderer-engine/services/fetchers/collection/collection-transformer.ts new file mode 100644 index 00000000..6950500e --- /dev/null +++ b/renderer-engine/services/fetchers/collection/collection-transformer.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { dataTransformer } from '@/renderer-engine/services/core/data-transformer'; +import type { CollectionContext, ProductContext } from '@/renderer-engine/types'; +import type { CollectionData } from './types/collection-types'; + +export class CollectionTransformer { + /** + * Transforma una colección de Amplify al formato Liquid + */ + public transformCollection( + collection: CollectionData, + products: ProductContext[], + nextToken: string | null | undefined, + totalCount?: number | undefined + ): CollectionContext { + const handle = dataTransformer.createHandle(collection.name || collection.title || `collection-${collection.id}`); + + return { + id: collection.id, + storeId: collection.storeId, + title: collection.title, + description: collection.description, + slug: handle, + url: `/collections/${handle}`, + image: collection.image || 'collection-img', + products, + nextToken, + owner: collection.owner, + sortOrder: collection.sortOrder, + isActive: collection.isActive, + createdAt: collection.createdAt, + updatedAt: collection.updatedAt, + products_count: totalCount, + }; + } + + /** + * Transforma múltiples colecciones + */ + public transformCollections(collections: CollectionData[]): CollectionContext[] { + return collections.map((collection) => this.transformCollection(collection, [], null, 0)); + } +} + +export const collectionTransformer = new CollectionTransformer(); diff --git a/renderer-engine/services/fetchers/collection/index.ts b/renderer-engine/services/fetchers/collection/index.ts new file mode 100644 index 00000000..086d806f --- /dev/null +++ b/renderer-engine/services/fetchers/collection/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Exportar el fetcher principal +export { collectionFetcher } from './collection-fetcher'; + +// Exportar managers y utilities +export { collectionCacheManager } from './collection-cache-manager'; +export { collectionQueryManager } from './collection-query-manager'; +export { collectionTransformer } from './collection-transformer'; + +// Exportar tipos +export type * from './types/collection-types'; diff --git a/renderer-engine/services/fetchers/collection/types/collection-types.ts b/renderer-engine/services/fetchers/collection/types/collection-types.ts new file mode 100644 index 00000000..30309443 --- /dev/null +++ b/renderer-engine/services/fetchers/collection/types/collection-types.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CollectionContext } from '@/renderer-engine/types'; + +export type { CollectionContext }; + +export interface PaginationOptions { + limit?: number; + offset?: number; + nextToken?: string; +} + +export interface CollectionsResponse { + collections: CollectionContext[]; + nextToken?: string | null; +} + +export interface CollectionData { + id: string; + storeId: string; + title: string; + name?: string; + description?: string; + image?: string; + owner: string; + sortOrder?: number; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CollectionQueryOptions { + limit?: number; + nextToken?: string; + filter?: { + isActive?: { eq: boolean }; + }; +} diff --git a/renderer-engine/services/fetchers/data-fetcher.ts b/renderer-engine/services/fetchers/data-fetcher.ts index bd27ef07..133c407d 100644 --- a/renderer-engine/services/fetchers/data-fetcher.ts +++ b/renderer-engine/services/fetchers/data-fetcher.ts @@ -15,11 +15,11 @@ */ import { cacheManager } from '@/renderer-engine/services/core/cache'; -import { cartFetcher } from '@/renderer-engine/services/fetchers/cart-fetcher'; -import { collectionFetcher } from '@/renderer-engine/services/fetchers/collection-fetcher'; -import { navigationFetcher } from '@/renderer-engine/services/fetchers/navigation-fetcher'; -import { pageFetcher } from '@/renderer-engine/services/fetchers/page-fetcher'; -import { productFetcher } from '@/renderer-engine/services/fetchers/product-fetcher'; +import { cartFetcher } from '@/renderer-engine/services/fetchers/cart'; +import { collectionFetcher } from '@/renderer-engine/services/fetchers/collection'; +import { navigationFetcher } from '@/renderer-engine/services/fetchers/navigation'; +import { pageFetcher } from '@/renderer-engine/services/fetchers/page'; +import { productFetcher } from '@/renderer-engine/services/fetchers/product'; import type { AddToCartRequest, Cart, diff --git a/renderer-engine/services/fetchers/navigation-fetcher.ts b/renderer-engine/services/fetchers/navigation-fetcher.ts deleted file mode 100644 index 582955c2..00000000 --- a/renderer-engine/services/fetchers/navigation-fetcher.ts +++ /dev/null @@ -1,327 +0,0 @@ -/* - * Copyright 2025 Fasttify LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { logger } from '@/renderer-engine/lib/logger'; -import { cacheManager, getNavigationCacheKey, getNavigationMenuCacheKey } from '@/renderer-engine/services/core/cache'; -import type { - NavigationMenuItem, - ProcessedNavigationMenu, - ProcessedNavigationMenuItem, -} from '@/renderer-engine/types/store'; -import { cookiesClient } from '@/utils/server/AmplifyServer'; - -interface NavigationMenusResponse { - menus: ProcessedNavigationMenu[]; - mainMenu?: ProcessedNavigationMenu; - footerMenu?: ProcessedNavigationMenu; -} - -export class NavigationFetcher { - private static instance: NavigationFetcher; - - private constructor() {} - - public static getInstance(): NavigationFetcher { - if (!NavigationFetcher.instance) { - NavigationFetcher.instance = new NavigationFetcher(); - } - return NavigationFetcher.instance; - } - - /** - * Obtiene todos los menús de navegación activos de una tienda - * @param storeId - ID de la tienda - * @returns Menús de navegación procesados - */ - public async getStoreNavigationMenus(storeId: string): Promise { - try { - const cacheKey = getNavigationCacheKey(storeId); - - // Verificar caché - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached; - } - - // Obtener menús activos de la tienda - const { data: rawMenus } = await cookiesClient.models.NavigationMenu.listNavigationMenuByStoreId( - { - storeId: storeId, - }, - { - filter: { - isActive: { - eq: true, - }, - }, - } - ); - - if (!rawMenus || rawMenus.length === 0) { - logger.warn(`No navigation menus found for store: ${storeId}`, undefined, 'NavigationFetcher'); - const emptyResponse = { menus: [] }; - // Cachear respuesta vacía usando el sistema híbrido - cacheManager.setCached(cacheKey, emptyResponse, cacheManager.getDataTTL('navigation')); - return emptyResponse; - } - - // Filtrar solo menús activos y procesar - const activeMenus = rawMenus.filter((menu) => menu.isActive); - const processedMenus = await Promise.all(activeMenus.map((menu) => this.processNavigationMenu(menu, storeId))); - - // Encontrar el menú principal - const mainMenu = processedMenus.find((menu) => menu.isMain || menu.handle === 'main-menu'); - const footerMenu = processedMenus.find((menu) => menu.handle === 'footer-menu'); - - const response: NavigationMenusResponse = { - menus: processedMenus, - mainMenu, - footerMenu, - }; - - // Guardar en caché usando el sistema híbrido - cacheManager.setCached(cacheKey, response, cacheManager.getDataTTL('navigation')); - - return response; - } catch (error) { - logger.error(`Error fetching navigation menus for store ${storeId}`, error, 'NavigationFetcher'); - return { menus: [] }; - } - } - - /** - * Obtiene un menú específico por su handle - * @param storeId - ID de la tienda - * @param handle - Handle del menú (ej: 'main-menu', 'footer-menu') - * @returns Menú de navegación procesado o null si no se encuentra - */ - public async getNavigationMenuByHandle(storeId: string, handle: string): Promise { - try { - const cacheKey = getNavigationMenuCacheKey(storeId, handle); - - // Verificar caché - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached; - } - - // Obtener menú específico por handle y storeId - const { data: rawMenus } = await cookiesClient.models.NavigationMenu.listNavigationMenuByStoreId({ - storeId: storeId, - }); - - if (!rawMenus || rawMenus.length === 0) { - return null; - } - - // Buscar el menú por handle y que esté activo - const targetMenu = rawMenus.find((menu) => menu.handle === handle && menu.isActive); - - if (!targetMenu) { - return null; - } - - const processedMenu = await this.processNavigationMenu(targetMenu, storeId); - - // Guardar en caché usando el sistema híbrido - cacheManager.setCached(cacheKey, processedMenu, cacheManager.getDataTTL('navigation')); - - return processedMenu; - } catch (error) { - logger.error(`Error fetching navigation menu ${handle} for store ${storeId}`, error, 'NavigationFetcher'); - return null; - } - } - - /** - * Procesa un menú crudo de la base de datos - * @param rawMenu - Menú crudo de la base de datos - * @param storeId - ID de la tienda para resolver URLs - * @returns Menú procesado - */ - private async processNavigationMenu(rawMenu: any, storeId: string): Promise { - try { - // Parsear menuData si es string - let menuItems: NavigationMenuItem[] = []; - if (rawMenu.menuData) { - if (typeof rawMenu.menuData === 'string') { - menuItems = JSON.parse(rawMenu.menuData); - } else if (Array.isArray(rawMenu.menuData)) { - menuItems = rawMenu.menuData; - } - } - - // Procesar items del menú - const processedItems = await Promise.all( - menuItems - .filter((item) => item.isVisible) - .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) - .map((item) => this.processMenuItem(item, storeId)) - ); - - return { - id: rawMenu.id, - storeId: rawMenu.storeId, - domain: rawMenu.domain, - name: rawMenu.name, - handle: rawMenu.handle, - isMain: rawMenu.isMain, - isActive: rawMenu.isActive, - items: processedItems, - owner: rawMenu.owner, - }; - } catch (error) { - logger.error(`Error processing navigation menu ${rawMenu.handle}`, error, 'NavigationFetcher'); - return { - id: rawMenu.id, - storeId: rawMenu.storeId, - domain: rawMenu.domain, - name: rawMenu.name, - handle: rawMenu.handle, - isMain: rawMenu.isMain, - isActive: rawMenu.isActive, - items: [], - owner: rawMenu.owner, - }; - } - } - - /** - * Procesa un item individual del menú y genera su URL - * @param item - Item del menú - * @param storeId - ID de la tienda - * @returns Item procesado con URL generada - */ - private async processMenuItem(item: NavigationMenuItem, storeId: string): Promise { - let url = item.url || ''; - - // Generar URL basada en el tipo de item - switch (item.type) { - case 'internal': - url = item.url || '/'; - break; - - case 'external': - url = item.url || '#'; - break; - - case 'page': - if (item.pageHandle) { - url = `/pages/${item.pageHandle}`; - } else { - url = await this.resolvePageUrl(storeId, item.pageHandle); - } - break; - - case 'collection': - if (item.collectionHandle) { - url = `/collections/${item.collectionHandle}`; - } else { - url = await this.resolveCollectionUrl(storeId, item.collectionHandle); - } - break; - - case 'product': - if (item.productHandle) { - url = `/products/${item.productHandle}`; - } else { - url = '/products'; - } - break; - - default: - url = item.url || '#'; - } - - return { - title: item.label, - url: url, - active: item.isVisible, - type: item.type, - target: item.target, - }; - } - - /** - * Resuelve la URL de una página por su handle - */ - private async resolvePageUrl(storeId: string, pageHandle?: string): Promise { - if (!pageHandle) return '/'; - - try { - const { data: pages } = await cookiesClient.models.Page.listPageByStoreId({ - storeId: storeId, - }); - - if (pages && pages.length > 0) { - const targetPage = pages.find((page) => page.slug === pageHandle && page.isVisible); - if (targetPage) { - return `/${targetPage.slug}`; - } - } - } catch (error) { - logger.warn(`Error resolving page URL for handle ${pageHandle}`, error, 'NavigationFetcher'); - } - - return `/${pageHandle}`; - } - - /** - * Resuelve la URL de una colección por su handle - */ - private async resolveCollectionUrl(storeId: string, collectionHandle?: string): Promise { - if (!collectionHandle) return '/collections'; - - try { - const { data: collections } = await cookiesClient.models.Collection.listCollectionByStoreId({ - storeId: storeId, - }); - - if (collections && collections.length > 0) { - const targetCollection = collections.find( - (collection) => collection.slug === collectionHandle && collection.isActive - ); - if (targetCollection) { - return `/collections/${targetCollection.slug}`; - } - } - } catch (error) { - logger.warn(`Error resolving collection URL for handle ${collectionHandle}`, error, 'NavigationFetcher'); - } - - return `/collections/${collectionHandle}`; - } - - /** - * Invalida el caché de menús de navegación para una tienda - * @param storeId - ID de la tienda - */ - public invalidateStoreCache(storeId: string): void { - cacheManager.invalidateStoreCache(storeId); - } - - /** - * Invalida el caché de un menú específico - * @param storeId - ID de la tienda - * @param handle - Handle del menú - */ - public invalidateMenuCache(storeId: string, handle: string): void { - const cacheKey = getNavigationMenuCacheKey(storeId, handle); - cacheManager.invalidateTemplateCache(cacheKey); - } -} - -export const navigationFetcher = NavigationFetcher.getInstance(); diff --git a/renderer-engine/services/fetchers/navigation/index.ts b/renderer-engine/services/fetchers/navigation/index.ts new file mode 100644 index 00000000..3be32710 --- /dev/null +++ b/renderer-engine/services/fetchers/navigation/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { navigationCacheManager } from './navigation-cache-manager'; +export { NavigationFetcher, navigationFetcher } from './navigation-fetcher'; +export { navigationMenuProcessor } from './navigation-menu-processor'; +export { navigationUrlResolver } from './navigation-url-resolver'; +export * from './types/navigation-types'; diff --git a/renderer-engine/services/fetchers/navigation/navigation-cache-manager.ts b/renderer-engine/services/fetchers/navigation/navigation-cache-manager.ts new file mode 100644 index 00000000..0b76aa46 --- /dev/null +++ b/renderer-engine/services/fetchers/navigation/navigation-cache-manager.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cacheManager, getNavigationCacheKey, getNavigationMenuCacheKey } from '@/renderer-engine/services/core/cache'; +import type { NavigationMenusResponse, ProcessedNavigationMenu } from './types/navigation-types'; + +export class NavigationCacheManager { + /** + * Obtiene datos del caché para menús de navegación + */ + public getCachedMenus(storeId: string): NavigationMenusResponse | null { + const cacheKey = getNavigationCacheKey(storeId); + return cacheManager.getCached(cacheKey); + } + + /** + * Obtiene datos del caché para un menú específico + */ + public getCachedMenu(storeId: string, handle: string): ProcessedNavigationMenu | null { + const cacheKey = getNavigationMenuCacheKey(storeId, handle); + return cacheManager.getCached(cacheKey); + } + + /** + * Guarda en caché la respuesta de menús de navegación + */ + public setCachedMenus(storeId: string, data: NavigationMenusResponse): void { + const cacheKey = getNavigationCacheKey(storeId); + cacheManager.setCached(cacheKey, data, cacheManager.getDataTTL('navigation')); + } + + /** + * Guarda en caché un menú específico + */ + public setCachedMenu(storeId: string, handle: string, data: ProcessedNavigationMenu): void { + const cacheKey = getNavigationMenuCacheKey(storeId, handle); + cacheManager.setCached(cacheKey, data, cacheManager.getDataTTL('navigation')); + } + + /** + * Invalida el caché de menús de navegación para una tienda + */ + public invalidateStoreCache(storeId: string): void { + cacheManager.invalidateStoreCache(storeId); + } + + /** + * Invalida el caché de un menú específico + */ + public invalidateMenuCache(storeId: string, handle: string): void { + const cacheKey = getNavigationMenuCacheKey(storeId, handle); + cacheManager.invalidateTemplateCache(cacheKey); + } +} + +export const navigationCacheManager = new NavigationCacheManager(); diff --git a/renderer-engine/services/fetchers/navigation/navigation-fetcher.ts b/renderer-engine/services/fetchers/navigation/navigation-fetcher.ts new file mode 100644 index 00000000..acf99619 --- /dev/null +++ b/renderer-engine/services/fetchers/navigation/navigation-fetcher.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import type { ProcessedNavigationMenu } from '@/renderer-engine/types/store'; +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import { navigationCacheManager } from './navigation-cache-manager'; +import { navigationMenuProcessor } from './navigation-menu-processor'; +import type { NavigationMenusResponse } from './types/navigation-types'; + +export class NavigationFetcher { + private static instance: NavigationFetcher; + + private constructor() {} + + public static getInstance(): NavigationFetcher { + if (!NavigationFetcher.instance) { + NavigationFetcher.instance = new NavigationFetcher(); + } + return NavigationFetcher.instance; + } + + /** + * Obtiene todos los menús de navegación activos de una tienda + */ + public async getStoreNavigationMenus(storeId: string): Promise { + try { + // Verificar caché + const cached = navigationCacheManager.getCachedMenus(storeId); + if (cached) { + return cached; + } + + // Obtener menús activos de la tienda + const { data: rawMenus } = await cookiesClient.models.NavigationMenu.listNavigationMenuByStoreId( + { + storeId: storeId, + }, + { + filter: { + isActive: { + eq: true, + }, + }, + } + ); + + if (!rawMenus || rawMenus.length === 0) { + logger.warn(`No navigation menus found for store: ${storeId}`, undefined, 'NavigationFetcher'); + const emptyResponse = { menus: [] }; + navigationCacheManager.setCachedMenus(storeId, emptyResponse); + return emptyResponse; + } + + // Filtrar solo menús activos y procesar + const activeMenus = rawMenus.filter((menu) => menu.isActive); + const processedMenus = await Promise.all( + activeMenus.map((menu) => navigationMenuProcessor.processNavigationMenu(menu)) + ); + + // Encontrar el menú principal + const mainMenu = processedMenus.find((menu) => menu.isMain || menu.handle === 'main-menu'); + const footerMenu = processedMenus.find((menu) => menu.handle === 'footer-menu'); + + const response: NavigationMenusResponse = { + menus: processedMenus, + mainMenu, + footerMenu, + }; + + // Guardar en caché + navigationCacheManager.setCachedMenus(storeId, response); + + return response; + } catch (error) { + logger.error(`Error fetching navigation menus for store ${storeId}`, error, 'NavigationFetcher'); + return { menus: [] }; + } + } + + /** + * Obtiene un menú específico por su handle + */ + public async getNavigationMenuByHandle(storeId: string, handle: string): Promise { + try { + // Verificar caché + const cached = navigationCacheManager.getCachedMenu(storeId, handle); + if (cached) { + return cached; + } + + // Obtener menú específico por handle y storeId + const { data: rawMenus } = await cookiesClient.models.NavigationMenu.listNavigationMenuByStoreId({ + storeId: storeId, + }); + + if (!rawMenus || rawMenus.length === 0) { + return null; + } + + // Buscar el menú por handle y que esté activo + const targetMenu = rawMenus.find((menu) => menu.handle === handle && menu.isActive); + + if (!targetMenu) { + return null; + } + + const processedMenu = await navigationMenuProcessor.processNavigationMenu(targetMenu); + + // Guardar en caché + navigationCacheManager.setCachedMenu(storeId, handle, processedMenu); + + return processedMenu; + } catch (error) { + logger.error(`Error fetching navigation menu ${handle} for store ${storeId}`, error, 'NavigationFetcher'); + return null; + } + } + + /** + * Invalida el caché de menús de navegación para una tienda + */ + public invalidateStoreCache(storeId: string): void { + navigationCacheManager.invalidateStoreCache(storeId); + } + + /** + * Invalida el caché de un menú específico + */ + public invalidateMenuCache(storeId: string, handle: string): void { + navigationCacheManager.invalidateMenuCache(storeId, handle); + } +} + +export const navigationFetcher = NavigationFetcher.getInstance(); diff --git a/renderer-engine/services/fetchers/navigation/navigation-menu-processor.ts b/renderer-engine/services/fetchers/navigation/navigation-menu-processor.ts new file mode 100644 index 00000000..6dfa7f6d --- /dev/null +++ b/renderer-engine/services/fetchers/navigation/navigation-menu-processor.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import type { NavigationMenuItem, ProcessedNavigationMenu } from '@/renderer-engine/types/store'; +import type { NavigationMenuData, ProcessedMenuItemData } from './types/navigation-types'; + +export class NavigationMenuProcessor { + /** + * Procesa un menú crudo de la base de datos + */ + public async processNavigationMenu(rawMenu: NavigationMenuData): Promise { + try { + // Parsear menuData si es string + let menuItems: NavigationMenuItem[] = []; + if (rawMenu.menuData) { + if (typeof rawMenu.menuData === 'string') { + menuItems = JSON.parse(rawMenu.menuData); + } else if (Array.isArray(rawMenu.menuData)) { + menuItems = rawMenu.menuData; + } + } + + // Procesar items del menú + const processedItems = await Promise.all( + menuItems + .filter((item) => item.isVisible) + .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) + .map((item) => this.processMenuItem(item)) + ); + + return { + id: rawMenu.id, + storeId: rawMenu.storeId, + domain: rawMenu.domain, + name: rawMenu.name, + handle: rawMenu.handle, + isMain: rawMenu.isMain, + isActive: rawMenu.isActive, + items: processedItems, + owner: rawMenu.owner, + }; + } catch (error) { + logger.error(`Error processing navigation menu ${rawMenu.handle}`, error, 'NavigationMenuProcessor'); + return { + id: rawMenu.id, + storeId: rawMenu.storeId, + domain: rawMenu.domain, + name: rawMenu.name, + handle: rawMenu.handle, + isMain: rawMenu.isMain, + isActive: rawMenu.isActive, + items: [], + owner: rawMenu.owner, + }; + } + } + + /** + * Procesa un item individual del menú y genera su URL + */ + public async processMenuItem(item: NavigationMenuItem): Promise { + let url = item.url || ''; + + // Generar URL basada en el tipo de item + switch (item.type) { + case 'internal': + url = item.url || '/'; + break; + + case 'external': + url = item.url || '#'; + break; + + case 'page': + if (item.pageHandle) { + url = `/pages/${item.pageHandle}`; + } + break; + + case 'collection': + if (item.collectionHandle) { + url = `/collections/${item.collectionHandle}`; + } else { + url = '/collections'; + } + break; + + case 'product': + if (item.productHandle) { + url = `/products/${item.productHandle}`; + } else { + url = '/products'; + } + break; + + default: + url = item.url || '#'; + } + + return { + title: item.label, + url: url, + active: item.isVisible, + type: item.type, + target: item.target, + }; + } +} + +export const navigationMenuProcessor = new NavigationMenuProcessor(); diff --git a/renderer-engine/services/fetchers/navigation/navigation-url-resolver.ts b/renderer-engine/services/fetchers/navigation/navigation-url-resolver.ts new file mode 100644 index 00000000..57e9fc9b --- /dev/null +++ b/renderer-engine/services/fetchers/navigation/navigation-url-resolver.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import { cookiesClient } from '@/utils/server/AmplifyServer'; + +export class NavigationUrlResolver { + /** + * Resuelve la URL de una página por su handle + */ + public async resolvePageUrl(storeId: string, pageHandle?: string): Promise { + if (!pageHandle) return '/'; + + try { + const { data: pages } = await cookiesClient.models.Page.listPageByStoreId({ + storeId: storeId, + }); + + if (pages && pages.length > 0) { + const targetPage = pages.find((page) => page.slug === pageHandle && page.isVisible); + if (targetPage) { + return `/${targetPage.slug}`; + } + } + } catch (error) { + logger.warn(`Error resolving page URL for handle ${pageHandle}`, error, 'NavigationUrlResolver'); + } + + return `/${pageHandle}`; + } + + /** + * Resuelve la URL de una colección por su handle + */ + public async resolveCollectionUrl(storeId: string, collectionHandle?: string): Promise { + if (!collectionHandle) return '/collections'; + + try { + const { data: collections } = await cookiesClient.models.Collection.listCollectionByStoreId({ + storeId: storeId, + }); + + if (collections && collections.length > 0) { + const targetCollection = collections.find( + (collection) => collection.slug === collectionHandle && collection.isActive + ); + if (targetCollection) { + return `/collections/${targetCollection.slug}`; + } + } + } catch (error) { + logger.warn(`Error resolving collection URL for handle ${collectionHandle}`, error, 'NavigationUrlResolver'); + } + + return `/collections/${collectionHandle}`; + } +} + +export const navigationUrlResolver = new NavigationUrlResolver(); diff --git a/renderer-engine/services/fetchers/navigation/types/navigation-types.ts b/renderer-engine/services/fetchers/navigation/types/navigation-types.ts new file mode 100644 index 00000000..3546a8ce --- /dev/null +++ b/renderer-engine/services/fetchers/navigation/types/navigation-types.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface ProcessedNavigationMenu { + id: string; + storeId: string; + domain: string; + name: string; + handle: string; + isMain: boolean; + isActive: boolean; + items: ProcessedMenuItemData[]; + owner: string; +} + +export interface NavigationMenusResponse { + menus: ProcessedNavigationMenu[]; + mainMenu?: ProcessedNavigationMenu; + footerMenu?: ProcessedNavigationMenu; +} + +export interface NavigationMenuData { + id: string; + storeId: string; + domain: string; + name: string; + handle: string; + isMain: boolean; + isActive: boolean; + menuData: any; + owner: string; +} + +export interface ProcessedMenuItemData { + title: string; + url: string; + active: boolean; + type: string; + target?: string; +} diff --git a/renderer-engine/services/fetchers/order/customer-info-manager.ts b/renderer-engine/services/fetchers/order/customer-info-manager.ts new file mode 100644 index 00000000..c125b55d --- /dev/null +++ b/renderer-engine/services/fetchers/order/customer-info-manager.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class CustomerInfoManager { + /** + * Determina el tipo de cliente basado en la información disponible + */ + public determineCustomerType(customerInfo: any, customerEmail?: string): 'registered' | 'guest' { + // Si hay customerEmail específico o customerInfo tiene userId, es registrado + if (customerEmail || (customerInfo && customerInfo.userId)) { + return 'registered'; + } + return 'guest'; + } + + /** + * Extrae el customerId del customerInfo o genera uno basado en sessionId + */ + public extractCustomerId(customerInfo: any, sessionId?: string): string { + if (customerInfo && customerInfo.userId) { + return customerInfo.userId; + } + // Si no hay userId, usar sessionId como identificador de cliente invitado + return sessionId || 'guest'; + } + + /** + * Extrae email del customerInfo o usa el proporcionado + */ + public extractCustomerEmail(customerInfo: any, providedEmail?: string): string | null { + if (providedEmail) { + return providedEmail; + } + + if (customerInfo?.email) { + return customerInfo.email; + } + + if (customerInfo && typeof customerInfo === 'object' && 'email' in customerInfo) { + return (customerInfo as any).email; + } + + return null; + } +} + +export const customerInfoManager = new CustomerInfoManager(); diff --git a/renderer-engine/services/fetchers/order/index.ts b/renderer-engine/services/fetchers/order/index.ts new file mode 100644 index 00000000..8db8902c --- /dev/null +++ b/renderer-engine/services/fetchers/order/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { customerInfoManager } from './customer-info-manager'; +export { OrderFetcher, orderFetcher } from './order-fetcher'; +export { orderItemCreator } from './order-item-creator'; +export { orderNumberGenerator } from './order-number-generator'; +export { orderValidator } from './order-validator'; +export * from './types/order-types'; diff --git a/renderer-engine/services/fetchers/order/order-fetcher.ts b/renderer-engine/services/fetchers/order/order-fetcher.ts new file mode 100644 index 00000000..339f752b --- /dev/null +++ b/renderer-engine/services/fetchers/order/order-fetcher.ts @@ -0,0 +1,214 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Order } from '@/renderer-engine/types'; +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import { customerInfoManager } from './customer-info-manager'; +import { orderItemCreator } from './order-item-creator'; +import { orderNumberGenerator } from './order-number-generator'; +import { orderValidator } from './order-validator'; +import type { CreateOrderRequest, CreateOrderResponse } from './types/order-types'; + +export class OrderFetcher { + /** + * Crea una nueva orden basada en la sesión de checkout completada + */ + public async createOrderFromCheckout(request: CreateOrderRequest): Promise { + try { + const { checkoutSession, paymentMethod, paymentId, customerEmail } = request; + + if (checkoutSession.status !== 'completed') { + return { + success: false, + error: 'Checkout session must be completed to create order', + }; + } + + // Generar número de orden único + const orderNumber = orderNumberGenerator.generateOrderNumber(); + + // Determinar información del cliente + const customerType = customerInfoManager.determineCustomerType(checkoutSession.customerInfo, customerEmail); + + const customerId = customerInfoManager.extractCustomerId(checkoutSession.customerInfo, checkoutSession.sessionId); + + // Extraer email del customerInfo o usar el proporcionado + const finalCustomerEmail = customerInfoManager.extractCustomerEmail(checkoutSession.customerInfo, customerEmail); + + // Crear datos de la orden + const orderData = { + orderNumber, + storeId: checkoutSession.storeId, + customerId, + customerType, + customerEmail: finalCustomerEmail, + subtotal: checkoutSession.subtotal, + shippingCost: checkoutSession.shippingCost, + taxAmount: checkoutSession.taxAmount, + totalAmount: checkoutSession.totalAmount, + currency: checkoutSession.currency, + status: 'pending' as const, + paymentStatus: 'pending' as const, + paymentMethod: paymentMethod || 'unknown', + paymentId: paymentId || null, + shippingAddress: checkoutSession.shippingAddress ? JSON.stringify(checkoutSession.shippingAddress) : null, + billingAddress: checkoutSession.billingAddress ? JSON.stringify(checkoutSession.billingAddress) : null, + customerInfo: checkoutSession.customerInfo ? JSON.stringify(checkoutSession.customerInfo) : null, + notes: checkoutSession.notes, + storeOwner: checkoutSession.storeOwner, + }; + + // Validar campos requeridos + const requiredValidation = orderValidator.validateRequiredFields(orderData); + if (!requiredValidation.isValid) { + return { + success: false, + error: `Missing required fields: ${requiredValidation.errors.join(', ')}`, + }; + } + + // Validar campos JSON + const jsonValidation = orderValidator.validateJsonFields(orderData); + if (!jsonValidation.isValid) { + return { + success: false, + error: `JSON field validation failed: ${jsonValidation.errors.join(', ')}`, + }; + } + + // Validar datos completos de la orden + const validation = orderValidator.validateOrderData(orderData); + if (!validation.isValid) { + return { + success: false, + error: `Order data validation failed: ${validation.errors.join(', ')}`, + }; + } + + // Crear la orden + const orderResponse = await cookiesClient.models.Order.create(orderData); + + if (!orderResponse.data) { + return { + success: false, + error: 'Failed to create order', + }; + } + + const order = orderResponse.data as Order; + + // Crear los items de la orden + if (checkoutSession.itemsSnapshot) { + await orderItemCreator.createOrderItems( + order.id, + checkoutSession.itemsSnapshot, + checkoutSession.storeId, + checkoutSession.storeOwner + ); + } + + return { + success: true, + order, + }; + } catch (error) { + return { + success: false, + error: 'Internal error creating order', + }; + } + } + + /** + * Obtiene una orden por ID + */ + public async getOrderById(orderId: string): Promise { + try { + const response = await cookiesClient.models.Order.get({ id: orderId }); + return (response.data as Order) || null; + } catch (error) { + return null; + } + } + + /** + * Obtiene una orden por número de orden + */ + public async getOrderByNumber(orderNumber: string): Promise { + try { + const response = await cookiesClient.models.Order.listOrderByOrderNumber({ orderNumber }, { limit: 1 }); + + if (response.data && response.data.length > 0) { + return response.data[0] as Order; + } + + return null; + } catch (error) { + return null; + } + } + + /** + * Obtiene órdenes por storeId + */ + public async getOrdersByStore(storeId: string, limit: number = 50): Promise { + try { + const response = await cookiesClient.models.Order.listOrderByStoreId({ storeId }, { limit }); + + return (response.data as Order[]) || []; + } catch (error) { + return []; + } + } + + /** + * Actualiza el estado de una orden + */ + public async updateOrderStatus(orderId: string, status: Order['status']): Promise { + try { + const response = await cookiesClient.models.Order.update({ + id: orderId, + status, + }); + + if (response.data) { + return true; + } + + return false; + } catch (error) { + return false; + } + } + + /** + * Actualiza el estado de pago de una orden + */ + public async updatePaymentStatus(orderId: string, paymentStatus: Order['paymentStatus']): Promise { + try { + const response = await cookiesClient.models.Order.update({ + id: orderId, + paymentStatus, + }); + + if (response.data) { + return true; + } + + return false; + } catch (error) { + return false; + } + } +} + +export const orderFetcher = new OrderFetcher(); diff --git a/renderer-engine/services/fetchers/order/order-item-creator.ts b/renderer-engine/services/fetchers/order/order-item-creator.ts new file mode 100644 index 00000000..81a37b04 --- /dev/null +++ b/renderer-engine/services/fetchers/order/order-item-creator.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CartSnapshot, OrderItem } from '@/renderer-engine/types'; +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import type { OrderItemData, ProductSnapshotData } from './types/order-types'; + +export class OrderItemCreator { + /** + * Crea los items de la orden basados en el snapshot del carrito + */ + public async createOrderItems( + orderId: string, + itemsSnapshot: CartSnapshot, + storeId: string, + storeOwner: string + ): Promise { + const orderItems: OrderItem[] = []; + + if (!itemsSnapshot.items || itemsSnapshot.items.length === 0) { + return orderItems; + } + + for (const item of itemsSnapshot.items) { + try { + // Validar que el precio esté disponible (usar unitPrice del carrito) + const itemPrice = item.unitPrice || item.price; + if (itemPrice === undefined || itemPrice === null) { + continue; // Saltar este item si no tiene precio + } + + // Crear el snapshot del producto + const productSnapshotData = this.createProductSnapshotData(item, itemPrice); + + const orderItemData: OrderItemData = { + orderId, + storeId, + productId: item.product_id || item.productId, + variantId: item.variant_id || item.variantId || null, + quantity: item.quantity, + unitPrice: itemPrice, + totalPrice: itemPrice * item.quantity, + productSnapshot: JSON.stringify(productSnapshotData), + storeOwner, + }; + + const response = await cookiesClient.models.OrderItem.create(orderItemData); + + if (response.data) { + orderItems.push(response.data as OrderItem); + } + } catch (error) { + // Silenciar error, continuar con el siguiente item + } + } + + return orderItems; + } + + /** + * Crea los datos del snapshot del producto para el item de la orden + */ + private createProductSnapshotData(item: any, itemPrice: number): ProductSnapshotData { + // Parsear el productSnapshot para extraer información del producto + let productInfo = null; + try { + if (item.productSnapshot && typeof item.productSnapshot === 'string') { + productInfo = JSON.parse(item.productSnapshot); + } + } catch (error) { + // Silenciar error de parsing, continuar con valores por defecto + } + + return { + id: item.product_id || item.productId, + title: productInfo?.title || item.title || 'Unknown Product', + variantTitle: item.variant_title || item.variantTitle || null, + price: itemPrice, + image: productInfo?.featured_image || productInfo?.image || item.image || null, + handle: productInfo?.url || item.url || null, + variantHandle: null, + attributes: item.attributes || [], + selectedAttributes: item.selectedAttributes || {}, + snapshotAt: new Date().toISOString(), + }; + } +} + +export const orderItemCreator = new OrderItemCreator(); diff --git a/renderer-engine/services/fetchers/order/order-number-generator.ts b/renderer-engine/services/fetchers/order/order-number-generator.ts new file mode 100644 index 00000000..05e2320b --- /dev/null +++ b/renderer-engine/services/fetchers/order/order-number-generator.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import crypto from 'crypto'; + +export class OrderNumberGenerator { + /** + * Genera un número de orden único + * Formato: ORD-- + */ + public generateOrderNumber(): string { + const timestamp = Date.now(); + const random = crypto.randomBytes(4).toString('hex').toUpperCase(); + return `ORD-${timestamp}-${random}`; + } +} + +export const orderNumberGenerator = new OrderNumberGenerator(); diff --git a/renderer-engine/services/fetchers/order/order-validator.ts b/renderer-engine/services/fetchers/order/order-validator.ts new file mode 100644 index 00000000..6943d8fa --- /dev/null +++ b/renderer-engine/services/fetchers/order/order-validator.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { OrderValidationResult } from './types/order-types'; + +export class OrderValidator { + /** + * Valida los datos de la orden antes de crearla + */ + public validateOrderData(orderData: any): OrderValidationResult { + const errors: string[] = []; + + // Validar campos requeridos + if (!orderData.orderNumber) errors.push('orderNumber is required'); + if (!orderData.storeId) errors.push('storeId is required'); + if (!orderData.customerId) errors.push('customerId is required'); + if (!orderData.customerType) errors.push('customerType is required'); + if (!orderData.storeOwner) errors.push('storeOwner is required'); + + // Validar campos numéricos + if (typeof orderData.subtotal !== 'number') errors.push('subtotal must be a number'); + if (typeof orderData.totalAmount !== 'number') errors.push('totalAmount must be a number'); + if (typeof orderData.shippingCost !== 'number') errors.push('shippingCost must be a number'); + if (typeof orderData.taxAmount !== 'number') errors.push('taxAmount must be a number'); + + // Validar enums + const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled']; + if (!validStatuses.includes(orderData.status)) { + errors.push(`status must be one of: ${validStatuses.join(', ')}`); + } + + const validPaymentStatuses = ['pending', 'paid', 'failed', 'refunded']; + if (!validPaymentStatuses.includes(orderData.paymentStatus)) { + errors.push(`paymentStatus must be one of: ${validPaymentStatuses.join(', ')}`); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Valida que los campos JSON sean strings válidos después de la conversión + */ + public validateJsonFields(orderData: any): OrderValidationResult { + const errors: string[] = []; + + if (orderData.shippingAddress && typeof orderData.shippingAddress !== 'string') { + errors.push('Invalid shippingAddress format after JSON.stringify'); + } + + if (orderData.billingAddress && typeof orderData.billingAddress !== 'string') { + errors.push('Invalid billingAddress format after JSON.stringify'); + } + + if (orderData.customerInfo && typeof orderData.customerInfo !== 'string') { + errors.push('Invalid customerInfo format after JSON.stringify'); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Valida que los campos requeridos estén presentes + */ + public validateRequiredFields(orderData: any): OrderValidationResult { + const errors: string[] = []; + + if (!orderData.orderNumber) errors.push('orderNumber is required'); + if (!orderData.storeId) errors.push('storeId is required'); + if (!orderData.customerId) errors.push('customerId is required'); + if (!orderData.customerType) errors.push('customerType is required'); + if (!orderData.storeOwner) errors.push('storeOwner is required'); + + return { + isValid: errors.length === 0, + errors, + }; + } +} + +export const orderValidator = new OrderValidator(); diff --git a/renderer-engine/services/fetchers/order/types/order-types.ts b/renderer-engine/services/fetchers/order/types/order-types.ts new file mode 100644 index 00000000..d7d514ae --- /dev/null +++ b/renderer-engine/services/fetchers/order/types/order-types.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface CreateOrderRequest { + checkoutSession: any; // CheckoutSession type + paymentMethod?: string; + paymentId?: string; + customerEmail?: string; +} + +export interface CreateOrderResponse { + success: boolean; + order?: any; // Order type + error?: string; +} + +export interface OrderItemData { + orderId: string; + storeId: string; + productId: string; + variantId?: string | null; + quantity: number; + unitPrice: number; + totalPrice: number; + productSnapshot: string; + storeOwner: string; +} + +export interface ProductSnapshotData { + id: string; + title: string; + variantTitle?: string | null; + price: number; + image?: string | null; + handle?: string | null; + variantHandle?: string | null; + attributes: any[]; + selectedAttributes: Record; + snapshotAt: string; +} + +export interface OrderValidationResult { + isValid: boolean; + errors: string[]; +} diff --git a/renderer-engine/services/fetchers/page-fetcher.ts b/renderer-engine/services/fetchers/page-fetcher.ts deleted file mode 100644 index 7c77d481..00000000 --- a/renderer-engine/services/fetchers/page-fetcher.ts +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright 2025 Fasttify LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { logger } from '@/renderer-engine/lib/logger'; -import { cacheManager, getPagesCacheKey, getPageCacheKey } from '@/renderer-engine/services/core/cache'; -import { dataTransformer } from '@/renderer-engine/services/core/data-transformer'; -import type { PageContext, TemplateError } from '@/renderer-engine/types'; -import { cookiesClient } from '@/utils/server/AmplifyServer'; - -interface PaginationOptions { - limit?: number; - nextToken?: string; -} - -interface PagesResponse { - pages: PageContext[]; - nextToken?: string | null; -} - -export class PageFetcher { - /** - * Obtiene páginas de una tienda con paginación - */ - public async getStorePages(storeId: string, options: PaginationOptions = {}): Promise { - try { - const { limit = 10, nextToken } = options; - const cacheKey = getPagesCacheKey(storeId); - - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as PagesResponse; - } - - // Amplify Query - const response = await cookiesClient.models.Page.listPageByStoreId( - { storeId }, - { - limit, - nextToken, - filter: { - isVisible: { eq: true }, - status: { eq: 'published' }, - pageType: { eq: 'standard' }, - }, - } - ); - - if (!response.data) { - return { pages: [] }; - } - - const pages: PageContext[] = []; - for (const page of response.data) { - const transformedPage = this.transformPage(page); - pages.push(transformedPage); - } - - const result: PagesResponse = { - pages, - nextToken: response.nextToken, - }; - - cacheManager.setCached(cacheKey, result, cacheManager.getPageTTL()); - return result; - } catch (error) { - logger.error(`Error fetching pages for store ${storeId}`, error, 'PageFetcher'); - - const templateError: TemplateError = { - type: 'DATA_ERROR', - message: `Failed to fetch pages for store: ${storeId}`, - details: error, - statusCode: 500, - }; - - throw templateError; - } - } - - /** - * Obtiene una página específica por ID - */ - public async getPage(storeId: string, pageId: string): Promise { - try { - const cacheKey = getPageCacheKey(storeId, pageId); - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as PageContext; - } - - const { data: page } = await cookiesClient.models.Page.get({ - id: pageId, - }); - - if (!page || page.storeId !== storeId) { - return null; - } - - const transformedPage = this.transformPage(page); - - cacheManager.setCached(cacheKey, transformedPage, cacheManager.getPageTTL()); - return transformedPage; - } catch (error) { - logger.error(`Error fetching page ${pageId} for store ${storeId}`, error, 'PageFetcher'); - return null; - } - } - - /** - * Obtiene una página específica por slug - */ - public async getPageBySlug(storeId: string, slug: string): Promise { - try { - const cacheKey = getPageCacheKey(storeId, slug); - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as PageContext; - } - - // Buscar por slug en el store específico - const response = await cookiesClient.models.Page.listPageByStoreId( - { storeId }, - { - filter: { - slug: { eq: slug }, - isVisible: { eq: true }, - status: { eq: 'published' }, - pageType: { eq: 'standard' }, - }, - } - ); - - if (!response.data || response.data.length === 0) { - return null; - } - - const page = response.data[0]; - const transformedPage = this.transformPage(page); - - cacheManager.setCached(cacheKey, transformedPage, cacheManager.getPageTTL()); - return transformedPage; - } catch (error) { - logger.error(`Error fetching page by slug ${slug} for store ${storeId}`, error, 'PageFetcher'); - return null; - } - } - - /** - * Obtiene todas las páginas de políticas de una tienda. - */ - public async getPoliciesPages(storeId: string): Promise { - try { - const cacheKey = getPagesCacheKey(storeId); - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as PageContext[]; - } - - const response = await cookiesClient.models.Page.listPageByStoreId( - { storeId }, - { - filter: { - isVisible: { eq: true }, - status: { eq: 'published' }, - pageType: { eq: 'policies' }, - }, - } - ); - - if (!response.data) { - return []; - } - - const pages = response.data.map((page) => this.transformPage(page)); - - cacheManager.setCached(cacheKey, pages, cacheManager.getPageTTL()); - return pages; - } catch (error) { - logger.error(`Error fetching policies pages for store ${storeId}`, error, 'PageFetcher'); - return []; - } - } - - /** - * Obtiene páginas visibles de una tienda - */ - public async getVisibleStorePages(storeId: string, options: PaginationOptions = {}): Promise { - try { - const { limit = 10, nextToken } = options; - const cacheKey = getPagesCacheKey(storeId); - - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as PagesResponse; - } - - // Amplify Query con filtro para páginas visibles - const response = await cookiesClient.models.Page.listPageByStoreId( - { storeId }, - { - limit, - nextToken, - filter: { - isVisible: { eq: true }, - status: { eq: 'published' }, - pageType: { eq: 'standard' }, - }, - } - ); - - if (!response.data) { - return { pages: [] }; - } - - const pages: PageContext[] = []; - for (const page of response.data) { - const transformedPage = this.transformPage(page); - pages.push(transformedPage); - } - - const result: PagesResponse = { - pages, - nextToken: response.nextToken, - }; - - cacheManager.setCached(cacheKey, result, cacheManager.getPageTTL()); - return result; - } catch (error) { - logger.error(`Error fetching visible pages for store ${storeId}`, error, 'PageFetcher'); - - const templateError: TemplateError = { - type: 'DATA_ERROR', - message: `Failed to fetch visible pages for store: ${storeId}`, - details: error, - statusCode: 500, - }; - - throw templateError; - } - } - - /** - * Transforma una página de Amplify al formato Liquid - */ - private transformPage(page: any): PageContext { - const handle = dataTransformer.createHandle(page.slug || page.title || `page-${page.id}`); - return { - id: page.id, - title: page.title, - content: page.content, - createdAt: page.createdAt, - url: `/pages/${handle}`, - updatedAt: page.updatedAt, - }; - } -} - -export const pageFetcher = new PageFetcher(); diff --git a/renderer-engine/services/fetchers/page/index.ts b/renderer-engine/services/fetchers/page/index.ts new file mode 100644 index 00000000..3503f378 --- /dev/null +++ b/renderer-engine/services/fetchers/page/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { pageCacheManager } from './page-cache-manager'; +export { PageFetcher, pageFetcher } from './page-fetcher'; +export { pageQueryManager } from './page-query-manager'; +export { pageTransformer } from './page-transformer'; +export * from './types/page-types'; diff --git a/renderer-engine/services/fetchers/page/page-cache-manager.ts b/renderer-engine/services/fetchers/page/page-cache-manager.ts new file mode 100644 index 00000000..be6f8695 --- /dev/null +++ b/renderer-engine/services/fetchers/page/page-cache-manager.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cacheManager, getPageCacheKey, getPagesCacheKey } from '@/renderer-engine/services/core/cache'; +import type { PageContext, PagesResponse } from './types/page-types'; + +export class PageCacheManager { + /** + * Obtiene datos del caché para páginas de una tienda + */ + public getCachedPages(storeId: string): PagesResponse | null { + const cacheKey = getPagesCacheKey(storeId); + return cacheManager.getCached(cacheKey); + } + + /** + * Obtiene datos del caché para una página específica + */ + public getCachedPage(storeId: string, pageId: string): PageContext | null { + const cacheKey = getPageCacheKey(storeId, pageId); + return cacheManager.getCached(cacheKey); + } + + /** + * Guarda en caché la respuesta de páginas + */ + public setCachedPages(storeId: string, data: PagesResponse): void { + const cacheKey = getPagesCacheKey(storeId); + cacheManager.setCached(cacheKey, data, cacheManager.getPageTTL()); + } + + /** + * Guarda en caché una página específica + */ + public setCachedPage(storeId: string, pageId: string, data: PageContext): void { + const cacheKey = getPageCacheKey(storeId, pageId); + cacheManager.setCached(cacheKey, data, cacheManager.getPageTTL()); + } + + /** + * Invalida el caché de páginas para una tienda + */ + public invalidateStoreCache(storeId: string): void { + const cacheKey = getPagesCacheKey(storeId); + cacheManager.invalidateTemplateCache(cacheKey); + } + + /** + * Invalida el caché de una página específica + */ + public invalidatePageCache(storeId: string, pageId: string): void { + const cacheKey = getPageCacheKey(storeId, pageId); + cacheManager.invalidateTemplateCache(cacheKey); + } +} + +export const pageCacheManager = new PageCacheManager(); diff --git a/renderer-engine/services/fetchers/page/page-fetcher.ts b/renderer-engine/services/fetchers/page/page-fetcher.ts new file mode 100644 index 00000000..cabc94da --- /dev/null +++ b/renderer-engine/services/fetchers/page/page-fetcher.ts @@ -0,0 +1,201 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import type { PageContext, TemplateError } from '@/renderer-engine/types'; +import { pageCacheManager } from './page-cache-manager'; +import { pageQueryManager } from './page-query-manager'; +import { pageTransformer } from './page-transformer'; +import type { PageData, PagesResponse, PaginationOptions } from './types/page-types'; + +export class PageFetcher { + /** + * Obtiene páginas de una tienda con paginación + */ + public async getStorePages(storeId: string, options: PaginationOptions = {}): Promise { + try { + // Verificar caché + const cached = pageCacheManager.getCachedPages(storeId); + if (cached) { + return cached; + } + + // Consultar base de datos + const response = await pageQueryManager.queryStorePages(storeId, { + ...options, + filter: { + isVisible: { eq: true }, + status: { eq: 'published' }, + pageType: { eq: 'standard' }, + }, + }); + + const result: PagesResponse = { + pages: response.pages, + nextToken: response.nextToken, + }; + + // Guardar en caché + pageCacheManager.setCachedPages(storeId, result); + return result; + } catch (error) { + logger.error(`Error fetching pages for store ${storeId}`, error, 'PageFetcher'); + + const templateError: TemplateError = { + type: 'DATA_ERROR', + message: `Failed to fetch pages for store: ${storeId}`, + details: error, + statusCode: 500, + }; + + throw templateError; + } + } + + /** + * Obtiene una página específica por ID + */ + public async getPage(storeId: string, pageId: string): Promise { + try { + // Verificar caché + const cached = pageCacheManager.getCachedPage(storeId, pageId); + if (cached) { + return cached; + } + + // Consultar base de datos + const page = await pageQueryManager.queryPageById(pageId); + + if (!page || page.storeId !== storeId) { + return null; + } + + // Transformar página + const transformedPage = pageTransformer.transformPage(page); + + // Guardar en caché + pageCacheManager.setCachedPage(storeId, pageId, transformedPage); + return transformedPage; + } catch (error) { + logger.error(`Error fetching page ${pageId} for store ${storeId}`, error, 'PageFetcher'); + return null; + } + } + + /** + * Obtiene una página específica por slug + */ + public async getPageBySlug(storeId: string, slug: string): Promise { + try { + const cacheKey = `${storeId}-${slug}`; + const cached = pageCacheManager.getCachedPage(storeId, cacheKey); + if (cached) { + return cached; + } + + // Consultar base de datos + const response = await pageQueryManager.queryStorePages(storeId, { + filter: { + slug: { eq: slug }, + isVisible: { eq: true }, + status: { eq: 'published' }, + pageType: { eq: 'standard' }, + }, + }); + + if (!response.pages || response.pages.length === 0) { + return null; + } + + const page = response.pages[0]; + const transformedPage = pageTransformer.transformPage(page as PageData); + + // Guardar en caché + pageCacheManager.setCachedPage(storeId, cacheKey, transformedPage); + return transformedPage; + } catch (error) { + logger.error(`Error fetching page by slug ${slug} for store ${storeId}`, error, 'PageFetcher'); + return null; + } + } + + /** + * Obtiene todas las páginas de políticas de una tienda + */ + public async getPoliciesPages(storeId: string): Promise { + try { + // Verificar caché + const cached = pageCacheManager.getCachedPages(storeId); + if (cached) { + return cached.pages; + } + + // Consultar base de datos + const pages = await pageQueryManager.queryPoliciesPages(storeId); + + // Transformar páginas + const transformedPages = pageTransformer.transformPages(pages); + + // Guardar en caché + pageCacheManager.setCachedPages(storeId, { pages: transformedPages }); + return transformedPages; + } catch (error) { + logger.error(`Error fetching policies pages for store ${storeId}`, error, 'PageFetcher'); + return []; + } + } + + /** + * Obtiene páginas visibles de una tienda + */ + public async getVisibleStorePages(storeId: string, options: PaginationOptions = {}): Promise { + try { + // Verificar caché + const cached = pageCacheManager.getCachedPages(storeId); + if (cached) { + return cached; + } + + // Consultar base de datos + const response = await pageQueryManager.queryStorePages(storeId, { + ...options, + filter: { + isVisible: { eq: true }, + status: { eq: 'published' }, + pageType: { eq: 'standard' }, + }, + }); + + const result: PagesResponse = { + pages: response.pages, + nextToken: response.nextToken, + }; + + // Guardar en caché + pageCacheManager.setCachedPages(storeId, result); + return result; + } catch (error) { + logger.error(`Error fetching visible pages for store ${storeId}`, error, 'PageFetcher'); + + const templateError: TemplateError = { + type: 'DATA_ERROR', + message: `Failed to fetch visible pages for store: ${storeId}`, + details: error, + statusCode: 500, + }; + + throw templateError; + } + } +} + +export const pageFetcher = new PageFetcher(); diff --git a/renderer-engine/services/fetchers/page/page-query-manager.ts b/renderer-engine/services/fetchers/page/page-query-manager.ts new file mode 100644 index 00000000..d4efcf99 --- /dev/null +++ b/renderer-engine/services/fetchers/page/page-query-manager.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import type { PageData, PageQueryOptions, PagesResponse } from './types/page-types'; + +export class PageQueryManager { + /** + * Obtiene páginas de una tienda con filtros y paginación + */ + public async queryStorePages(storeId: string, options: PageQueryOptions = {}): Promise { + const { limit = 10, nextToken, filter } = options; + + const response = await cookiesClient.models.Page.listPageByStoreId( + { storeId }, + { + limit, + nextToken, + filter, + } + ); + + if (!response.data) { + return { pages: [] }; + } + + return { + pages: response.data as any[], + nextToken: response.nextToken, + }; + } + + /** + * Obtiene una página específica por ID + */ + public async queryPageById(pageId: string): Promise { + const { data: page } = await cookiesClient.models.Page.get({ + id: pageId, + }); + + return (page as PageData) || null; + } + + /** + * Obtiene páginas de políticas de una tienda + */ + public async queryPoliciesPages(storeId: string): Promise { + const response = await cookiesClient.models.Page.listPageByStoreId( + { storeId }, + { + filter: { + isVisible: { eq: true }, + status: { eq: 'published' }, + pageType: { eq: 'policies' }, + }, + } + ); + + return (response.data as PageData[]) || []; + } +} + +export const pageQueryManager = new PageQueryManager(); diff --git a/renderer-engine/services/fetchers/page/page-transformer.ts b/renderer-engine/services/fetchers/page/page-transformer.ts new file mode 100644 index 00000000..ab2bcbda --- /dev/null +++ b/renderer-engine/services/fetchers/page/page-transformer.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { dataTransformer } from '@/renderer-engine/services/core/data-transformer'; +import type { PageContext, PageData } from './types/page-types'; + +export class PageTransformer { + /** + * Transforma una página de Amplify al formato Liquid + */ + public transformPage(page: PageData): PageContext { + const handle = dataTransformer.createHandle(page.slug || page.title || `page-${page.id}`); + + return { + id: page.id, + title: page.title, + content: page.content, + createdAt: page.createdAt, + url: `/pages/${handle}`, + updatedAt: page.updatedAt, + }; + } + + /** + * Transforma múltiples páginas + */ + public transformPages(pages: PageData[]): PageContext[] { + return pages.map((page) => this.transformPage(page)); + } +} + +export const pageTransformer = new PageTransformer(); diff --git a/renderer-engine/services/fetchers/page/types/page-types.ts b/renderer-engine/services/fetchers/page/types/page-types.ts new file mode 100644 index 00000000..15894baa --- /dev/null +++ b/renderer-engine/services/fetchers/page/types/page-types.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PageContext } from '@/renderer-engine/types'; + +export type { PageContext }; + +export interface PaginationOptions { + limit?: number; + nextToken?: string; +} + +export interface PagesResponse { + pages: PageContext[]; + nextToken?: string | null; +} + +export interface PageData { + id: string; + title: string; + content: string; + slug?: string; + storeId: string; + isVisible: boolean; + status: string; + pageType: string; + createdAt: string; + updatedAt: string; +} + +export interface PageQueryOptions { + limit?: number; + nextToken?: string; + filter?: { + isVisible?: { eq: boolean }; + status?: { eq: string }; + pageType?: { eq: string }; + slug?: { eq: string }; + }; +} diff --git a/renderer-engine/services/fetchers/product-fetcher.ts b/renderer-engine/services/fetchers/product-fetcher.ts deleted file mode 100644 index 116ace05..00000000 --- a/renderer-engine/services/fetchers/product-fetcher.ts +++ /dev/null @@ -1,292 +0,0 @@ -/* - * Copyright 2025 Fasttify LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { logger } from '@/renderer-engine/lib/logger'; -import { - cacheManager, - getFeaturedProductsCacheKey, - getProductCacheKey, - getProductHandleMapCacheKey, - getProductsCacheKey, -} from '@/renderer-engine/services/core/cache'; -import { dataTransformer } from '@/renderer-engine/services/core/data-transformer'; -import type { ProductAttribute, ProductContext, TemplateError } from '@/renderer-engine/types'; -import { cookiesClient } from '@/utils/server/AmplifyServer'; - -interface PaginationOptions { - limit?: number; - nextToken?: string; -} - -interface ProductsResponse { - products: ProductContext[]; - nextToken?: string | null; - totalCount?: number; -} - -export class ProductFetcher { - /** - * Obtiene productos de una tienda con paginación - */ - public async getStoreProducts(storeId: string, options: PaginationOptions = {}): Promise { - try { - const { limit = 20, nextToken } = options; - const cacheKey = getProductsCacheKey(storeId, limit, nextToken); - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as ProductsResponse; - } - - const response = await cookiesClient.models.Product.listProductByStoreId( - { storeId }, - { - limit, - nextToken, - filter: { - status: { eq: 'active' }, - }, - } - ); - - if (!response.data) { - throw new Error(`No products found for store: ${storeId}`); - } - - const products: ProductContext[] = response.data.map((product) => this.transformProduct(product)); - - // Calcular totalCount: si hay nextToken, significa que hay más productos - // Si no hay nextToken y tenemos menos productos que el límite, es el total - let totalCount = products.length; - if (response.nextToken) { - // Hay más productos, pero no sabemos el total exacto - // Para evitar consultas adicionales costosas, usamos una estimación - // basada en el patrón de paginación - totalCount = (limit || 20) * 2; // Estimación conservadora - } - - const result: ProductsResponse = { - products, - nextToken: response.nextToken, - totalCount, - }; - - cacheManager.setCached(cacheKey, result, cacheManager.getDataTTL('product')); - - return result; - } catch (error) { - logger.error(`Error fetching products for store ${storeId}`, error, 'ProductFetcher'); - - const templateError: TemplateError = { - type: 'DATA_ERROR', - message: `Failed to fetch products for store: ${storeId}`, - details: error, - statusCode: 500, - }; - - throw templateError; - } - } - - /** - * Obtiene un producto específico por ID o por su handle (slug). - */ - public async getProduct(storeId: string, productIdOrHandle: string): Promise { - try { - const productCacheKey = getProductCacheKey(storeId, productIdOrHandle); - const cachedProduct = cacheManager.getCached(productCacheKey); - if (cachedProduct) { - return cachedProduct as ProductContext; - } - - try { - const { data: productById } = await cookiesClient.models.Product.get({ - id: productIdOrHandle, - }); - if (productById && productById.storeId === storeId) { - const transformed = this.transformProduct(productById); - cacheManager.setCached(productCacheKey, transformed, cacheManager.getDataTTL('product')); - return transformed; - } - } catch (e) {} - - const handleMapCacheKey = getProductHandleMapCacheKey(storeId); - const handleMap = cacheManager.getCached(handleMapCacheKey); - - if (handleMap && handleMap[productIdOrHandle]) { - const productId = handleMap[productIdOrHandle]; - return this.getProduct(storeId, productId); - } - - const { data: allProducts } = await cookiesClient.models.Product.listProductByStoreId( - { storeId }, - { - filter: { - status: { eq: 'active' }, - }, - } - ); - - if (!allProducts || allProducts.length === 0) { - return null; - } - - const newHandleMap: { [handle: string]: string } = {}; - let targetProduct: any = null; - - for (const p of allProducts) { - const handle = dataTransformer.createHandle(p.name); - newHandleMap[handle] = p.id; - if (handle === productIdOrHandle) { - targetProduct = p; - } - } - - cacheManager.setCached(handleMapCacheKey, newHandleMap, cacheManager.getDataTTL()); - - if (!targetProduct) { - return null; - } - - const transformedProduct = this.transformProduct(targetProduct); - cacheManager.setCached(productCacheKey, transformedProduct, cacheManager.getDataTTL('product')); - return transformedProduct; - } catch (error) { - logger.error(`Error fetching product "${productIdOrHandle}" for store ${storeId}`, error, 'ProductFetcher'); - return null; - } - } - - /** - * Obtiene productos destacados de una tienda - */ - public async getFeaturedProducts(storeId: string, limit: number = 8): Promise { - try { - const cacheKey = getFeaturedProductsCacheKey(storeId, limit); - const cached = cacheManager.getCached(cacheKey); - if (cached) { - return cached as ProductContext[]; - } - - const response = await cookiesClient.models.Product.listProductByStoreId( - { storeId }, - { - limit, - filter: { - status: { eq: 'active' }, - }, - } - ); - - if (!response.data) { - return []; - } - - const products = response.data.map((product) => this.transformProduct(product)); - - cacheManager.setCached(cacheKey, products, cacheManager.getDataTTL('product')); - - return products; - } catch (error) { - logger.error(`Error fetching featured products for store ${storeId}`, error, 'ProductFetcher'); - return []; - } - } - - /** - * Obtiene productos de una colección específica con paginación usando el índice secundario. - */ - public async getProductsByCollection( - storeId: string, - collectionId: string, - collectionHandle?: string, - options: PaginationOptions = {} - ): Promise { - try { - const { limit = 20, nextToken } = options; - - const response = await cookiesClient.models.Product.listProductByCollectionId( - { - collectionId: collectionId, - }, - { - limit, - nextToken: nextToken, - filter: { - status: { eq: 'active' }, - }, - } - ); - - const products = response.data.map((p) => this.transformProduct(p, collectionHandle)); - - // Calcular totalCount: si hay nextToken, significa que hay más productos - let totalCount = products.length; - if (response.nextToken) { - totalCount = (limit || 20) * 2; // Estimación conservadora - } - - return { - products, - nextToken: response.nextToken, - totalCount, - }; - } catch (error) { - logger.error(`Error fetching products for collection ${collectionId}`, error, 'ProductFetcher'); - return { products: [], nextToken: null, totalCount: 0 }; - } - } - - /** - * Transforma un producto al formato Liquid - */ - public transformProduct(product: any, collectionHandle?: string): ProductContext { - const handle = dataTransformer.createHandle(product.name); - - const price = product.price || 0; - const compareAtPrice = product.compareAtPrice || undefined; - const transformedImages = dataTransformer.transformImages(product.images, product.name); - const variants = dataTransformer.transformVariants(product.variants, product.price); - const attributes: ProductAttribute[] = dataTransformer.transformAttributes(product.attributes); - const tags: string[] = dataTransformer.transformTags(product.tags); - const featured_image = transformedImages.length > 0 ? transformedImages[0].url : undefined; - const images = transformedImages.map((img) => img.url || img); - const url = collectionHandle ? `/collections/${collectionHandle}/products/${handle}` : `/products/${handle}`; - - return { - id: product.id, - storeId: product.storeId, - name: product.name, - title: product.name, - slug: handle, - attributes: attributes, - featured_image: featured_image, - quantity: product.quantity, - description: product.description, - price: price, - compare_at_price: compareAtPrice, - url: url, - images: images, - variants: variants, - status: product.status, - category: product.category, - createdAt: product.createdAt, - updatedAt: product.updatedAt, - tags, - }; - } -} - -export const productFetcher = new ProductFetcher(); diff --git a/renderer-engine/services/fetchers/product/index.ts b/renderer-engine/services/fetchers/product/index.ts new file mode 100644 index 00000000..2bdf75aa --- /dev/null +++ b/renderer-engine/services/fetchers/product/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { productCacheManager } from './product-cache-manager'; +export { ProductFetcher, productFetcher } from './product-fetcher'; +export { productHandleManager } from './product-handle-manager'; +export { productQueryManager } from './product-query-manager'; +export { productTransformer } from './product-transformer'; +export * from './types/product-types'; diff --git a/renderer-engine/services/fetchers/product/product-cache-manager.ts b/renderer-engine/services/fetchers/product/product-cache-manager.ts new file mode 100644 index 00000000..0c25ab35 --- /dev/null +++ b/renderer-engine/services/fetchers/product/product-cache-manager.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + cacheManager, + getFeaturedProductsCacheKey, + getProductCacheKey, + getProductHandleMapCacheKey, + getProductsCacheKey, +} from '@/renderer-engine/services/core/cache'; +import type { ProductContext, ProductsResponse } from './types/product-types'; + +export class ProductCacheManager { + /** + * Obtiene datos del caché para productos de una tienda + */ + public getCachedProducts(storeId: string, limit: number, nextToken?: string): ProductsResponse | null { + const cacheKey = getProductsCacheKey(storeId, limit, nextToken); + return cacheManager.getCached(cacheKey); + } + + /** + * Obtiene datos del caché para un producto específico + */ + public getCachedProduct(storeId: string, productIdOrHandle: string): ProductContext | null { + const cacheKey = getProductCacheKey(storeId, productIdOrHandle); + return cacheManager.getCached(cacheKey); + } + + /** + * Obtiene datos del caché para productos destacados + */ + public getCachedFeaturedProducts(storeId: string, limit: number): ProductContext[] | null { + const cacheKey = getFeaturedProductsCacheKey(storeId, limit); + return cacheManager.getCached(cacheKey); + } + + /** + * Obtiene el mapa de handles del caché + */ + public getCachedHandleMap(storeId: string): { [handle: string]: string } | null { + const cacheKey = getProductHandleMapCacheKey(storeId); + return cacheManager.getCached(cacheKey); + } + + /** + * Guarda en caché la respuesta de productos + */ + public setCachedProducts( + storeId: string, + limit: number, + nextToken: string | undefined, + data: ProductsResponse + ): void { + const cacheKey = getProductsCacheKey(storeId, limit, nextToken); + cacheManager.setCached(cacheKey, data, cacheManager.getDataTTL('product')); + } + + /** + * Guarda en caché un producto específico + */ + public setCachedProduct(storeId: string, productIdOrHandle: string, data: ProductContext): void { + const cacheKey = getProductCacheKey(storeId, productIdOrHandle); + cacheManager.setCached(cacheKey, data, cacheManager.getDataTTL('product')); + } + + /** + * Guarda en caché productos destacados + */ + public setCachedFeaturedProducts(storeId: string, limit: number, data: ProductContext[]): void { + const cacheKey = getFeaturedProductsCacheKey(storeId, limit); + cacheManager.setCached(cacheKey, data, cacheManager.getDataTTL('product')); + } + + /** + * Guarda en caché el mapa de handles + */ + public setCachedHandleMap(storeId: string, data: { [handle: string]: string }): void { + const cacheKey = getProductHandleMapCacheKey(storeId); + cacheManager.setCached(cacheKey, data, cacheManager.getDataTTL()); + } + + /** + * Invalida el caché de productos para una tienda + */ + public invalidateStoreCache(storeId: string): void { + // Invalida todos los tipos de caché relacionados con productos + const productsCacheKey = getProductsCacheKey(storeId, 20); + const featuredCacheKey = getFeaturedProductsCacheKey(storeId, 8); + const handleMapCacheKey = getProductHandleMapCacheKey(storeId); + + cacheManager.invalidateTemplateCache(productsCacheKey); + cacheManager.invalidateTemplateCache(featuredCacheKey); + cacheManager.invalidateTemplateCache(handleMapCacheKey); + } + + /** + * Invalida el caché de un producto específico + */ + public invalidateProductCache(storeId: string, productId: string): void { + const cacheKey = getProductCacheKey(storeId, productId); + cacheManager.invalidateTemplateCache(cacheKey); + } +} + +export const productCacheManager = new ProductCacheManager(); diff --git a/renderer-engine/services/fetchers/product/product-fetcher.ts b/renderer-engine/services/fetchers/product/product-fetcher.ts new file mode 100644 index 00000000..3aa02dbf --- /dev/null +++ b/renderer-engine/services/fetchers/product/product-fetcher.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@/renderer-engine/lib/logger'; +import type { ProductContext, TemplateError } from '@/renderer-engine/types'; +import { productCacheManager } from './product-cache-manager'; +import { productHandleManager } from './product-handle-manager'; +import { productQueryManager } from './product-query-manager'; +import { productTransformer } from './product-transformer'; +import type { PaginationOptions, ProductsResponse } from './types/product-types'; + +export class ProductFetcher { + /** + * Obtiene productos de una tienda con paginación + */ + public async getStoreProducts(storeId: string, options: PaginationOptions = {}): Promise { + try { + const { limit = 20, nextToken } = options; + + // Verificar caché + const cached = productCacheManager.getCachedProducts(storeId, limit, nextToken); + if (cached) { + return cached; + } + + // Consultar base de datos + const response = await productQueryManager.queryStoreProducts(storeId, options); + + // Transformar productos + const transformedProducts = productTransformer.transformProducts(response.products); + + const result: ProductsResponse = { + products: transformedProducts, + nextToken: response.nextToken, + totalCount: response.totalCount, + }; + + // Guardar en caché + productCacheManager.setCachedProducts(storeId, limit, nextToken, result); + return result; + } catch (error) { + logger.error(`Error fetching products for store ${storeId}`, error, 'ProductFetcher'); + + const templateError: TemplateError = { + type: 'DATA_ERROR', + message: `Failed to fetch products for store: ${storeId}`, + details: error, + statusCode: 500, + }; + + throw templateError; + } + } + + /** + * Obtiene un producto específico por ID o por su handle (slug) + */ + public async getProduct(storeId: string, productIdOrHandle: string): Promise { + try { + // Verificar caché + const cachedProduct = productCacheManager.getCachedProduct(storeId, productIdOrHandle); + if (cachedProduct) { + return cachedProduct; + } + + // Intentar obtener por ID primero + try { + const product = await productQueryManager.queryProductById(productIdOrHandle); + if (product && product.storeId === storeId) { + const transformed = productTransformer.transformProduct(product); + productCacheManager.setCachedProduct(storeId, productIdOrHandle, transformed); + return transformed; + } + } catch (e) { + // Si falla, continuar con la búsqueda por handle + } + + // Si no se encontró por ID, buscar por handle + const handleResult = await productHandleManager.getProductByHandle(storeId, productIdOrHandle); + + if (!handleResult) { + return null; + } + + const transformedProduct = productTransformer.transformProduct(handleResult.product); + productCacheManager.setCachedProduct(storeId, productIdOrHandle, transformedProduct); + return transformedProduct; + } catch (error) { + logger.error(`Error fetching product "${productIdOrHandle}" for store ${storeId}`, error, 'ProductFetcher'); + return null; + } + } + + /** + * Obtiene productos destacados de una tienda + */ + public async getFeaturedProducts(storeId: string, limit: number = 8): Promise { + try { + // Verificar caché + const cached = productCacheManager.getCachedFeaturedProducts(storeId, limit); + if (cached) { + return cached; + } + + // Consultar base de datos + const products = await productQueryManager.queryFeaturedProducts(storeId, limit); + + // Transformar productos + const transformedProducts = productTransformer.transformProducts(products); + + // Guardar en caché + productCacheManager.setCachedFeaturedProducts(storeId, limit, transformedProducts); + return transformedProducts; + } catch (error) { + logger.error(`Error fetching featured products for store ${storeId}`, error, 'ProductFetcher'); + return []; + } + } + + /** + * Obtiene productos de una colección específica con paginación + */ + public async getProductsByCollection( + storeId: string, + collectionId: string, + collectionHandle?: string, + options: PaginationOptions = {} + ): Promise { + try { + // Consultar base de datos + const response = await productQueryManager.queryProductsByCollection(collectionId, options); + + // Transformar productos + const transformedProducts = productTransformer.transformProducts(response.products, collectionHandle); + + return { + products: transformedProducts, + nextToken: response.nextToken, + totalCount: response.totalCount, + }; + } catch (error) { + logger.error(`Error fetching products for collection ${collectionId}`, error, 'ProductFetcher'); + return { products: [], nextToken: null, totalCount: 0 }; + } + } +} + +export const productFetcher = new ProductFetcher(); diff --git a/renderer-engine/services/fetchers/product/product-handle-manager.ts b/renderer-engine/services/fetchers/product/product-handle-manager.ts new file mode 100644 index 00000000..fdd253ed --- /dev/null +++ b/renderer-engine/services/fetchers/product/product-handle-manager.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { dataTransformer } from '@/renderer-engine/services/core/data-transformer'; +import { productCacheManager } from './product-cache-manager'; +import { productQueryManager } from './product-query-manager'; +import type { ProductData, ProductHandleMap } from './types/product-types'; + +export class ProductHandleManager { + /** + * Obtiene un producto por handle usando el mapa de caché + */ + public async getProductByHandle( + storeId: string, + handle: string + ): Promise<{ product: ProductData; handleMap: ProductHandleMap } | null> { + // Verificar caché del mapa de handles + const handleMapCacheKey = `${storeId}-handle-map`; + let handleMap = productCacheManager.getCachedHandleMap(storeId); + + if (handleMap && handleMap[handle]) { + const productId = handleMap[handle]; + const product = await productQueryManager.queryProductById(productId); + + if (product && product.storeId === storeId) { + return { product, handleMap }; + } + } + + // Si no está en caché o no se encontró, crear nuevo mapa + const allProducts = await productQueryManager.queryAllStoreProducts(storeId); + + if (!allProducts || allProducts.length === 0) { + return null; + } + + const newHandleMap: ProductHandleMap = {}; + let targetProduct: ProductData | null = null; + + for (const product of allProducts) { + const productHandle = dataTransformer.createHandle(product.name); + newHandleMap[productHandle] = product.id; + + if (productHandle === handle) { + targetProduct = product; + } + } + + // Guardar en caché + productCacheManager.setCachedHandleMap(storeId, newHandleMap); + + if (!targetProduct) { + return null; + } + + return { product: targetProduct, handleMap: newHandleMap }; + } + + /** + * Crea un mapa de handles para todos los productos de una tienda + */ + public async createHandleMap(storeId: string): Promise { + const allProducts = await productQueryManager.queryAllStoreProducts(storeId); + + if (!allProducts || allProducts.length === 0) { + return {}; + } + + const handleMap: ProductHandleMap = {}; + + for (const product of allProducts) { + const handle = dataTransformer.createHandle(product.name); + handleMap[handle] = product.id; + } + + // Guardar en caché + productCacheManager.setCachedHandleMap(storeId, handleMap); + + return handleMap; + } + + /** + * Invalida el mapa de handles para una tienda + */ + public invalidateHandleMap(storeId: string): void { + productCacheManager.invalidateStoreCache(storeId); + } +} + +export const productHandleManager = new ProductHandleManager(); diff --git a/renderer-engine/services/fetchers/product/product-query-manager.ts b/renderer-engine/services/fetchers/product/product-query-manager.ts new file mode 100644 index 00000000..56a8359c --- /dev/null +++ b/renderer-engine/services/fetchers/product/product-query-manager.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cookiesClient } from '@/utils/server/AmplifyServer'; +import type { ProductData, ProductQueryOptions, ProductsQueryResponse } from './types/product-types'; + +export class ProductQueryManager { + /** + * Obtiene productos de una tienda con filtros y paginación + */ + public async queryStoreProducts(storeId: string, options: ProductQueryOptions = {}): Promise { + const { limit = 20, nextToken, filter } = options; + + const response = await cookiesClient.models.Product.listProductByStoreId( + { storeId }, + { + limit, + nextToken, + filter: { + status: { eq: 'active' }, + ...filter, + }, + } + ); + + if (!response.data) { + throw new Error(`No products found for store: ${storeId}`); + } + + const products = response.data as ProductData[]; + + // Calcular totalCount: si hay nextToken, significa que hay más productos + let totalCount = products.length; + if (response.nextToken) { + // Hay más productos, pero no sabemos el total exacto + // Para evitar consultas adicionales costosas, usamos una estimación + // basada en el patrón de paginación + totalCount = (limit || 20) * 2; // Estimación conservadora + } + + return { + products, + nextToken: response.nextToken, + totalCount, + }; + } + + /** + * Obtiene un producto específico por ID + */ + public async queryProductById(productId: string): Promise { + const { data: product } = await cookiesClient.models.Product.get({ + id: productId, + }); + + return (product as ProductData) || null; + } + + /** + * Obtiene productos destacados de una tienda + */ + public async queryFeaturedProducts(storeId: string, limit: number = 8): Promise { + const response = await cookiesClient.models.Product.listProductByStoreId( + { storeId }, + { + limit, + filter: { + status: { eq: 'active' }, + }, + } + ); + + return (response.data as ProductData[]) || []; + } + + /** + * Obtiene productos de una colección específica con paginación + */ + public async queryProductsByCollection( + collectionId: string, + options: ProductQueryOptions = {} + ): Promise { + const { limit = 20, nextToken } = options; + + const response = await cookiesClient.models.Product.listProductByCollectionId( + { + collectionId: collectionId, + }, + { + limit, + nextToken: nextToken, + filter: { + status: { eq: 'active' }, + }, + } + ); + + const products = response.data as ProductData[]; + + // Calcular totalCount: si hay nextToken, significa que hay más productos + let totalCount = products.length; + if (response.nextToken) { + totalCount = (limit || 20) * 2; // Estimación conservadora + } + + return { + products, + nextToken: response.nextToken, + totalCount, + }; + } + + /** + * Obtiene todos los productos de una tienda para crear el mapa de handles + */ + public async queryAllStoreProducts(storeId: string): Promise { + const response = await cookiesClient.models.Product.listProductByStoreId( + { storeId }, + { + filter: { + status: { eq: 'active' }, + }, + } + ); + + return (response.data as ProductData[]) || []; + } +} + +export const productQueryManager = new ProductQueryManager(); diff --git a/renderer-engine/services/fetchers/product/product-transformer.ts b/renderer-engine/services/fetchers/product/product-transformer.ts new file mode 100644 index 00000000..776012eb --- /dev/null +++ b/renderer-engine/services/fetchers/product/product-transformer.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { dataTransformer } from '@/renderer-engine/services/core/data-transformer'; +import type { ProductAttribute, ProductContext } from '@/renderer-engine/types'; +import type { ProductData } from './types/product-types'; + +export class ProductTransformer { + /** + * Transforma un producto al formato Liquid + */ + public transformProduct(product: ProductData, collectionHandle?: string): ProductContext { + const handle = dataTransformer.createHandle(product.name); + const price = product.price || 0; + const compareAtPrice = product.compareAtPrice || undefined; + const transformedImages = dataTransformer.transformImages(product.images, product.name); + const variants = dataTransformer.transformVariants(product.variants, product.price); + const attributes: ProductAttribute[] = dataTransformer.transformAttributes(product.attributes); + const tags: string[] = dataTransformer.transformTags(product.tags); + const featured_image = transformedImages.length > 0 ? transformedImages[0].url : undefined; + const images = transformedImages.map((img) => img.url || img); + const cleanCollectionHandle = collectionHandle ? dataTransformer.createHandle(collectionHandle) : undefined; + const url = cleanCollectionHandle + ? `/collections/${cleanCollectionHandle}/products/${handle}` + : `/products/${handle}`; + + return { + id: product.id, + storeId: product.storeId, + name: product.name, + title: product.name, + slug: handle, + attributes: attributes, + featured_image: featured_image, + quantity: product.quantity, + description: product.description, + price: price, + compare_at_price: compareAtPrice, + url: url, + images: images, + variants: variants, + status: product.status, + category: product.category, + createdAt: product.createdAt, + updatedAt: product.updatedAt, + tags, + }; + } + + /** + * Transforma múltiples productos + */ + public transformProducts(products: ProductData[], collectionHandle?: string): ProductContext[] { + return products.map((product) => this.transformProduct(product, collectionHandle)); + } +} + +export const productTransformer = new ProductTransformer(); diff --git a/renderer-engine/services/fetchers/product/types/product-types.ts b/renderer-engine/services/fetchers/product/types/product-types.ts new file mode 100644 index 00000000..3ee34b7d --- /dev/null +++ b/renderer-engine/services/fetchers/product/types/product-types.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ProductContext } from '@/renderer-engine/types'; + +export type { ProductContext }; + +export interface PaginationOptions { + limit?: number; + nextToken?: string; +} + +export interface ProductsResponse { + products: ProductContext[]; + nextToken?: string | null; + totalCount?: number; +} + +export interface ProductsQueryResponse { + products: ProductData[]; + nextToken?: string | null; + totalCount?: number; +} + +export interface ProductData { + id: string; + storeId: string; + name: string; + description?: string; + price: number; + compareAtPrice?: number; + quantity: number; + status: string; + category?: string; + images?: string; + variants?: string; + attributes?: string; + tags?: string; + createdAt: string; + updatedAt: string; +} + +export interface ProductQueryOptions { + limit?: number; + nextToken?: string; + filter?: { + status?: { eq: string }; + collectionId?: { eq: string }; + }; +} + +export interface ProductHandleMap { + [handle: string]: string; +} 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 2891b74c..fec5e620 100644 --- a/renderer-engine/services/page/data-loader/handlers/data-handlers.ts +++ b/renderer-engine/services/page/data-loader/handlers/data-handlers.ts @@ -15,7 +15,7 @@ */ import { logger } from '@/renderer-engine/lib/logger'; -import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout-fetcher'; +import { checkoutFetcher } from '@/renderer-engine/services/fetchers/checkout'; 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'; diff --git a/renderer-engine/services/page/data-loader/search/search-data-loader.ts b/renderer-engine/services/page/data-loader/search/search-data-loader.ts index cc93d5a5..cf80ac80 100644 --- a/renderer-engine/services/page/data-loader/search/search-data-loader.ts +++ b/renderer-engine/services/page/data-loader/search/search-data-loader.ts @@ -16,7 +16,7 @@ import { logger } from '@/renderer-engine/lib/logger'; import { dataFetcher } from '@/renderer-engine/services/fetchers/data-fetcher'; -import { productFetcher } from '@/renderer-engine/services/fetchers/product-fetcher'; +import { productTransformer } from '@/renderer-engine/services/fetchers/product'; import { extractSearchLimitsFromSettings } from '@/renderer-engine/services/page/data-loader/search/search-limits-extractor'; import type { ProductContext } from '@/renderer-engine/types'; import { cookiesClient } from '@/utils/server/AmplifyServer'; @@ -56,7 +56,7 @@ export async function searchProductsByTerm( }, } ); - return (data || []).map((product) => productFetcher.transformProduct(product)); + return (data || []).map((product) => productTransformer.transformProduct(product as any)); } catch (error) { logger.error('Failed to search products by term:', error); return []; @@ -115,14 +115,6 @@ export class SearchDataLoader { } } - logger.info(`Search data loaded successfully`, { - productsCount: searchProducts.length, - collectionsCount: searchCollections.length, - productsLimit: searchProductsLimit, - collectionsLimit: searchCollectionsLimit, - searchTerm: searchTerm || 'N/A', - }); - return { searchProducts, searchProductsLimit, diff --git a/renderer-engine/types/checkout.ts b/renderer-engine/types/checkout.ts index d81b560d..751d2543 100644 --- a/renderer-engine/types/checkout.ts +++ b/renderer-engine/types/checkout.ts @@ -72,6 +72,8 @@ export interface CartSnapshot { export interface CheckoutResponse { success: boolean; session?: CheckoutSession; + order?: any; + warning?: string; error?: string; } diff --git a/renderer-engine/types/index.ts b/renderer-engine/types/index.ts index d2bfdf02..c015aa7f 100644 --- a/renderer-engine/types/index.ts +++ b/renderer-engine/types/index.ts @@ -89,3 +89,6 @@ export type { StartCheckoutRequest, UpdateCustomerInfoRequest, } from './checkout'; + +// Order types +export type { CreateOrderItemRequest, CreateOrderRequest, Order, OrderItem } from './order'; diff --git a/renderer-engine/types/order.ts b/renderer-engine/types/order.ts new file mode 100644 index 00000000..5175d1af --- /dev/null +++ b/renderer-engine/types/order.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Order { + id: string; + orderNumber: string; + storeId: string; + customerId: string; + customerType: 'registered' | 'guest'; + customerEmail?: string; + subtotal: number; + shippingCost: number; + taxAmount: number; + totalAmount: number; + currency: string; + status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; + paymentStatus: 'pending' | 'paid' | 'failed' | 'refunded'; + paymentMethod?: string; + paymentId?: string; + shippingAddress?: any; + billingAddress?: any; + customerInfo?: any; + notes?: string; + storeOwner: string; + createdAt?: string; + updatedAt?: string; +} + +export interface OrderItem { + id: string; + orderId: string; + storeId: string; + productId: string; + variantId?: string; + quantity: number; + unitPrice: number; + totalPrice: number; + productSnapshot: string; // JSON stringified product data + storeOwner: string; + createdAt?: string; + updatedAt?: string; +} + +export interface CreateOrderRequest { + orderNumber: string; + storeId: string; + customerId: string; + customerType: 'registered' | 'guest'; + customerEmail?: string; + subtotal: number; + shippingCost: number; + taxAmount: number; + totalAmount: number; + currency: string; + status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; + paymentStatus: 'pending' | 'paid' | 'failed' | 'refunded'; + paymentMethod?: string; + paymentId?: string; + shippingAddress?: any; + billingAddress?: any; + customerInfo?: any; + notes?: string; + storeOwner: string; +} + +export interface CreateOrderItemRequest { + orderId: string; + storeId: string; + productId: string; + variantId?: string; + quantity: number; + unitPrice: number; + totalPrice: number; + productSnapshot: string; + storeOwner: string; +} diff --git a/template/assets/product-details.js b/template/assets/product-details.js index a64b6f96..acf4c011 100644 --- a/template/assets/product-details.js +++ b/template/assets/product-details.js @@ -77,7 +77,6 @@ class ProductDetails { // Attribute selection selectOption(element) { - console.log('selectOption called for:', element.textContent || element.title); // Remove selected class from siblings (with null check) if (element.parentNode) { @@ -92,8 +91,6 @@ class ProductDetails { // Add selected class to clicked element element.classList.add('selected'); - console.log('Selected attribute:', element.textContent || element.title); - console.log('Data attributes:', element.dataset.optionName, element.dataset.optionValue); } // Quantity controls diff --git a/template/sections/page.liquid b/template/sections/page.liquid index f55fad53..033d5e99 100644 --- a/template/sections/page.liquid +++ b/template/sections/page.liquid @@ -1,4 +1,4 @@ -{% assign page = pages['pagina-test'] %} +{% assign page = pages['test'] %} {% if page %} {{ page.title }}