From 2f9db0095259b9724769f8aea5373974c51f3d13 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Fri, 6 Jun 2025 14:32:29 -0500 Subject: [PATCH 1/2] feat(first-steps): integrate template upload functionality after store creation --- .../first-steps/hooks/useFirstStepsSetup.ts | 57 +++++ .../first-steps/hooks/useTemplateUpload.ts | 75 ++++++ app/api/stores/template/route.ts | 239 ++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 app/(setup-layout)/first-steps/hooks/useTemplateUpload.ts create mode 100644 app/api/stores/template/route.ts diff --git a/app/(setup-layout)/first-steps/hooks/useFirstStepsSetup.ts b/app/(setup-layout)/first-steps/hooks/useFirstStepsSetup.ts index df46b914..2a71900d 100644 --- a/app/(setup-layout)/first-steps/hooks/useFirstStepsSetup.ts +++ b/app/(setup-layout)/first-steps/hooks/useFirstStepsSetup.ts @@ -9,6 +9,7 @@ import { storeInfoSchema, additionalSettingsSchema, } from '@/lib/zod-schemas/first-step' +import { useTemplateUpload } from '@/app/(setup-layout)/first-steps/hooks/useTemplateUpload' import sellingOptionsData from '@/app/(setup-layout)/first-steps/data/selling-options.json' export const useFirstStepsSetup = () => { @@ -37,9 +38,11 @@ export const useFirstStepsSetup = () => { const [validationErrors, setValidationErrors] = useState>({}) const [saving, setSaving] = useState(false) + const [uploadingTemplate, setUploadingTemplate] = useState(false) const { userData } = useAuthUser() const { loading, createUserStore, createStoreWithTemplate } = useUserStoreData() const { encryptApiKey } = useApiKeyEncryption() + const { uploadTemplate } = useTemplateUpload() const cognitoUsername = userData && userData['cognito:username'] ? userData['cognito:username'] : null @@ -131,6 +134,34 @@ export const useFirstStepsSetup = () => { const result = await createStoreWithTemplate(storeInput) if (result) { + // Subir plantillas a S3 después de crear la tienda + try { + setUploadingTemplate(true) + const templateResult = await uploadTemplate({ + storeId: result.store.storeId, + storeName: formData.storeName, + domain: storeInput.customDomain, + storeData: { + theme: 'modern', + currency: 'COP', + description: formData.description, + contactEmail: formData.email, + contactPhone: formData.phone, + storeAddress: formData.location, + }, + }) + + if (templateResult) { + console.log('templateResult:', templateResult) + } else { + console.warn('Error uploading template') + } + } catch (templateError) { + console.error('Error uploading template:', templateError) + } finally { + setUploadingTemplate(false) + } + setTimeout(() => { window.location.href = routes.store.dashboard.main(result.store.storeId) }, 3000) @@ -165,6 +196,31 @@ export const useFirstStepsSetup = () => { const result = await createStoreWithTemplate(quickStoreInput) if (result) { + // Subir plantillas por defecto para quick setup + try { + setUploadingTemplate(true) + const templateResult = await uploadTemplate({ + storeId: result.store.storeId, + storeName: storeName, + domain: quickStoreInput.customDomain, + storeData: { + theme: 'modern', + currency: 'COP', + description: 'Tienda creada con configuración rápida', + }, + }) + + if (templateResult) { + console.log('templateResult:', templateResult) + } else { + console.warn('Error uploading template') + } + } catch (templateError) { + console.error('Error uploading template:', templateError) + } finally { + setUploadingTemplate(false) + } + setTimeout(() => { window.location.href = routes.store.dashboard.main(result.store.storeId) }, 3000) @@ -194,6 +250,7 @@ export const useFirstStepsSetup = () => { setValidationErrors, saving, setSaving, + uploadingTemplate, userData, loading, createUserStore, diff --git a/app/(setup-layout)/first-steps/hooks/useTemplateUpload.ts b/app/(setup-layout)/first-steps/hooks/useTemplateUpload.ts new file mode 100644 index 00000000..2b26a43b --- /dev/null +++ b/app/(setup-layout)/first-steps/hooks/useTemplateUpload.ts @@ -0,0 +1,75 @@ +import { useState } from 'react' + +interface TemplateUploadData { + storeId: string + storeName: string + domain: string + storeData?: { + theme?: string + currency?: string + description?: string + logo?: string + banner?: string + contactEmail?: string + contactPhone?: string + storeAddress?: string + } +} + +interface TemplateUploadResponse { + success: boolean + message: string + templateUrls: Record + uploadedFiles: number + files: Array<{ key: string; path: string; size: number }> +} + +interface TemplateUploadError { + error: string + message?: string +} + +export function useTemplateUpload() { + const [isUploading, setIsUploading] = useState(false) + const [error, setError] = useState(null) + + const uploadTemplate = async ( + data: TemplateUploadData + ): Promise => { + setIsUploading(true) + setError(null) + + try { + const response = await fetch('/api/stores/template', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + + const result = await response.json() + + if (!response.ok) { + const errorData = result as TemplateUploadError + throw new Error(errorData.message || errorData.error || 'Failed to upload template') + } + + return result as TemplateUploadResponse + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' + setError(errorMessage) + console.error('Template upload error:', err) + return null + } finally { + setIsUploading(false) + } + } + + return { + uploadTemplate, + isUploading, + error, + clearError: () => setError(null), + } +} diff --git a/app/api/stores/template/route.ts b/app/api/stores/template/route.ts new file mode 100644 index 00000000..87e440a6 --- /dev/null +++ b/app/api/stores/template/route.ts @@ -0,0 +1,239 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookiesClient, AuthGetCurrentUserServer } from '@/utils/AmplifyUtils' +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' +import { readFile, readdir } from 'fs/promises' +import { join } from 'path' + +interface TemplateRequest { + storeId: string + storeName: string + domain: string + storeData?: { + theme?: string + currency?: string + description?: string + logo?: string + banner?: string + } +} + +interface TemplateFile { + path: string + content: string + contentType: string +} + +// Configuración de S3 +const s3Client = new S3Client() + +const BUCKET_NAME = process.env.BUCKET_NAME || '' +const CLOUDFRONT_DOMAIN = process.env.CLOUDFRONT_DOMAIN_NAME || '' +const APP_ENV = process.env.APP_ENV || 'development' + +export async function POST(request: NextRequest) { + try { + // 1. Validar autenticación + const user = await AuthGetCurrentUserServer() + + if (!user) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + // 2. Parsear request body + const body: TemplateRequest = await request.json() + const { storeId, storeName, domain, storeData } = body + + if (!storeId || !storeName || !domain) { + return NextResponse.json( + { error: 'Missing required fields: storeId, storeName, domain' }, + { status: 400 } + ) + } + + // Nota: No verificamos la existencia de la tienda aquí porque puede haber delay + // en la propagación de datos después de la creación. La autenticación del usuario + // es suficiente para este endpoint que viene del flujo controlado de setup. + + // 3. Leer plantillas base + const templateFiles = await readTemplateFiles() + + // 4. Personalizar plantillas con datos de la tienda + const processedTemplates = await processTemplateFiles(templateFiles, { + storeName, + domain, + storeData: storeData || {}, + }) + + // 5. Subir plantillas a S3 + const uploadResults = await uploadTemplatesToS3(storeId, processedTemplates) + + // 6. Generar URLs de las plantillas + const templateUrls = generateTemplateUrls(storeId, uploadResults) + + return NextResponse.json({ + success: true, + message: 'Template files uploaded to S3 successfully', + templateUrls, + uploadedFiles: uploadResults.length, + files: uploadResults, + }) + } catch (error) { + console.error('Error uploading template to S3:', error) + return NextResponse.json( + { + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +} + +// Función para leer archivos de plantilla +async function readTemplateFiles(): Promise { + const templateDir = join(process.cwd(), 'template') + const files: TemplateFile[] = [] + + async function readDirectory(dir: string, basePath: string = ''): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + const relativePath = join(basePath, entry.name) + + if (entry.isDirectory()) { + await readDirectory(fullPath, relativePath) + } else if (entry.isFile()) { + const content = await readFile(fullPath, 'utf-8') + const contentType = getContentType(entry.name) + + files.push({ + path: relativePath.replace(/\\/g, '/'), // Normalizar path para web + content, + contentType, + }) + } + } + } + + await readDirectory(templateDir) + return files +} + +// Función para procesar y personalizar plantillas Liquid +async function processTemplateFiles( + files: TemplateFile[], + storeConfig: { + storeName: string + domain: string + storeData: any + } +): Promise { + return files.map(file => { + let processedContent = file.content + + // Variables de configuración de la tienda + const storeVariables = { + 'shop.name': storeConfig.storeName, + 'shop.domain': storeConfig.domain, + 'shop.description': storeConfig.storeData.description || '', + 'shop.currency': storeConfig.storeData.currency || 'USD', + 'shop.money_format': storeConfig.storeData.currency === 'EUR' ? '€{{amount}}' : '${{amount}}', + 'shop.logo': storeConfig.storeData.logo || '', + 'shop.banner': storeConfig.storeData.banner || '', + 'shop.theme': storeConfig.storeData.theme || 'modern', + 'shop.email': storeConfig.storeData.contactEmail || '', + 'shop.phone': storeConfig.storeData.contactPhone || '', + 'shop.address': storeConfig.storeData.storeAddress || '', + } + + // Reemplazar variables de Liquid + Object.entries(storeVariables).forEach(([key, value]) => { + // Reemplazar tanto {{ shop.name }} como {{shop.name}} (con y sin espacios) + const regexWithSpaces = new RegExp(`\\{\\{\\s*${key.replace('.', '\\.')}\\s*\\}\\}`, 'g') + processedContent = processedContent.replace(regexWithSpaces, value) + }) + + // Variables adicionales para compatibilidad + processedContent = processedContent + .replace(/\{\{storeName\}\}/g, storeConfig.storeName) + .replace(/\{\{domain\}\}/g, storeConfig.domain) + .replace(/\{\{storeDescription\}\}/g, storeConfig.storeData.description || '') + + return { + ...file, + content: processedContent, + } + }) +} + +// Función para subir plantillas a S3 +async function uploadTemplatesToS3( + storeId: string, + files: TemplateFile[] +): Promise> { + const uploadPromises = files.map(async file => { + const key = `templates/${storeId}/${file.path}` + + const command = new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: file.content, + ContentType: file.contentType, + Metadata: { + 'store-id': storeId, + 'template-type': 'store-template', + 'upload-time': new Date().toISOString(), + }, + }) + + await s3Client.send(command) + + return { + key, + path: file.path, + size: Buffer.byteLength(file.content, 'utf-8'), + } + }) + + return Promise.all(uploadPromises) +} + +// Función para generar URLs de plantillas +function generateTemplateUrls( + storeId: string, + uploadResults: Array<{ key: string; path: string; size: number }> +): Record { + const urls: Record = {} + + uploadResults.forEach(({ key, path }) => { + const baseUrl = + CLOUDFRONT_DOMAIN && APP_ENV === 'production' + ? `https://${CLOUDFRONT_DOMAIN}` + : `https://${BUCKET_NAME}.s3.${process.env.AWS_REGION_BUCKET || 'us-east-2'}.amazonaws.com` + + urls[path] = `${baseUrl}/${key}` + }) + + return urls +} + +// Función para determinar content type +function getContentType(filename: string): string { + const ext = filename.toLowerCase().split('.').pop() + + const contentTypes: Record = { + html: 'text/html', + css: 'text/css', + js: 'application/javascript', + json: 'application/json', + liquid: 'application/liquid', + txt: 'text/plain', + md: 'text/markdown', + scss: 'text/scss', + sass: 'text/sass', + xml: 'application/xml', + } + + return contentTypes[ext || ''] || 'text/plain' +} From daa2820c4582d8b482968e794f10dd4820cc1ac7 Mon Sep 17 00:00:00 2001 From: Stivenjs Date: Fri, 6 Jun 2025 21:54:52 -0500 Subject: [PATCH 2/2] feat(store): enhance store page with SSR, SEO metadata generation, and improved authorization rules --- amplify/data/resource.ts | 10 +- app/[store]/page.tsx | 126 +++- app/[store]/products/[product]/page.tsx | 128 ++++ app/api/stores/render/route.ts | 176 ++++++ app/api/stores/template/route.ts | 2 +- lib/store-renderer/README.md | 282 +++++++++ lib/store-renderer/index.ts | 142 +++++ lib/store-renderer/liquid/engine.ts | 280 +++++++++ lib/store-renderer/liquid/filters.ts | 244 ++++++++ lib/store-renderer/liquid/tags/schema-tag.ts | 137 +++++ lib/store-renderer/renderers/homepage.ts | 376 ++++++++++++ lib/store-renderer/renderers/product.ts | 250 ++++++++ lib/store-renderer/services/data-fetcher.ts | 576 ++++++++++++++++++ .../services/domain-resolver.ts | 214 +++++++ .../services/template-loader.ts | 360 +++++++++++ lib/store-renderer/types/index.ts | 47 ++ lib/store-renderer/types/liquid.ts | 69 +++ lib/store-renderer/types/product.ts | 105 ++++ lib/store-renderer/types/store.ts | 62 ++ lib/store-renderer/types/template.ts | 144 +++++ template/sections/featured-products.liquid | 6 +- utils/AmplifyServer.ts | 28 + 22 files changed, 3747 insertions(+), 17 deletions(-) create mode 100644 app/[store]/products/[product]/page.tsx create mode 100644 app/api/stores/render/route.ts create mode 100644 lib/store-renderer/README.md create mode 100644 lib/store-renderer/index.ts create mode 100644 lib/store-renderer/liquid/engine.ts create mode 100644 lib/store-renderer/liquid/filters.ts create mode 100644 lib/store-renderer/liquid/tags/schema-tag.ts create mode 100644 lib/store-renderer/renderers/homepage.ts create mode 100644 lib/store-renderer/renderers/product.ts create mode 100644 lib/store-renderer/services/data-fetcher.ts create mode 100644 lib/store-renderer/services/domain-resolver.ts create mode 100644 lib/store-renderer/services/template-loader.ts create mode 100644 lib/store-renderer/types/index.ts create mode 100644 lib/store-renderer/types/liquid.ts create mode 100644 lib/store-renderer/types/product.ts create mode 100644 lib/store-renderer/types/store.ts create mode 100644 lib/store-renderer/types/template.ts create mode 100644 utils/AmplifyServer.ts diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 995acd02..f0037990 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -128,7 +128,12 @@ const schema = a }) .identifier(['storeId']) .secondaryIndexes(index => [index('userId'), index('customDomain'), index('storeName')]) - .authorization(allow => [allow.authenticated().to(['read', 'update', 'delete', 'create'])]), + .authorization(allow => [ + allow.authenticated().to(['read', 'update', 'delete', 'create']), + allow.publicApiKey().to(['read']), + allow.guest().to(['read']), + allow.ownerDefinedIn('userId').to(['read', 'update', 'delete', 'create']), + ]), Product: a .model({ @@ -159,6 +164,7 @@ const schema = a .authorization(allow => [ allow.ownerDefinedIn('owner').to(['update', 'delete', 'read', 'create']), allow.guest().to(['read']), + allow.publicApiKey().to(['read']), ]), Collection: a @@ -177,6 +183,7 @@ const schema = a .authorization(allow => [ allow.ownerDefinedIn('owner').to(['update', 'delete', 'read', 'create']), allow.guest().to(['read']), + allow.publicApiKey().to(['read']), ]), StoreTemplate: a @@ -194,6 +201,7 @@ const schema = a .authorization(allow => [ allow.ownerDefinedIn('owner').to(['update', 'delete', 'read', 'create']), allow.guest().to(['read']), + allow.publicApiKey().to(['read']), ]), }) .authorization(allow => [ diff --git a/app/[store]/page.tsx b/app/[store]/page.tsx index ff34a94e..594ca921 100644 --- a/app/[store]/page.tsx +++ b/app/[store]/page.tsx @@ -1,18 +1,120 @@ -'use client' +import { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { storeRenderer } from '@/lib/store-renderer' -import { useParams } from 'next/navigation' +interface StorePageProps { + params: Promise<{ + store: string + }> + searchParams: Promise<{ + path?: string + }> +} + +/** + * Página principal de tienda con SSR + * Maneja todas las rutas de tienda: /, /products/slug, /collections/slug + */ +export default async function StorePage({ params, searchParams }: StorePageProps) { + const resolvedParams = await params + const resolvedSearchParams = await searchParams + const { store } = resolvedParams + const path = resolvedSearchParams.path || '/' + + try { + // Resolver dominio completo (el middleware ya reescribió la URL) + const domain = `${store}.fasttify.com` + + // Renderizar página usando el sistema + const result = await storeRenderer.renderPage(domain, path) + + // Retornar HTML renderizado como componente dangerouslySetInnerHTML + // Esto permite SSR completo con SEO optimizado + return
+ } catch (error: any) { + console.error(`Error rendering store page ${store}${path}:`, error) + + // Mostrar 404 para tiendas no encontradas + if (error.type === 'STORE_NOT_FOUND' || error.statusCode === 404) { + notFound() + } + + // Para otros errores, mostrar página de error + throw error + } +} + +/** + * Genera metadata SEO para la página + */ +export async function generateMetadata({ + params, + searchParams, +}: StorePageProps): Promise { + const resolvedParams = await params + const resolvedSearchParams = await searchParams + const { store } = resolvedParams + const path = resolvedSearchParams.path || '/' + + try { + const domain = `${store}.fasttify.com` + const result = await storeRenderer.renderPage(domain, path) -export default function StorePage() { - const params = useParams() - const store = (params.store as string) || undefined + const { metadata } = result - if (!store) { - return
No se encontró la tienda
+ return { + title: metadata.title, + description: metadata.description, + alternates: { + canonical: metadata.canonical, + }, + openGraph: metadata.openGraph + ? { + title: metadata.openGraph.title, + description: metadata.openGraph.description, + url: metadata.openGraph.url, + type: metadata.openGraph.type as any, + images: metadata.openGraph.image ? [metadata.openGraph.image] : undefined, + siteName: metadata.openGraph.site_name, + } + : undefined, + twitter: metadata.openGraph + ? { + card: 'summary_large_image', + title: metadata.openGraph.title, + description: metadata.openGraph.description, + images: metadata.openGraph.image ? [metadata.openGraph.image] : undefined, + } + : undefined, + other: metadata.schema + ? { + 'application-ld+json': JSON.stringify(metadata.schema), + } + : undefined, + } + } catch (error) { + console.error(`Error generating metadata for ${store}${path}:`, error) + + // Metadata por defecto para errores + return { + title: `${store} - Tienda Online`, + description: `Descubre productos únicos en ${store}. ¡Compra online!`, + } } +} + +/** + * Configurar revalidación de páginas para ISR + * Esto permite que las páginas se regeneren automáticamente + */ +export const revalidate = 1800 // 30 minutos - return ( -
-

Tienda: {store}

-
- ) +/** + * Configurar generación estática para tiendas populares + * (esto se ejecutaría en build time) + */ +export async function generateStaticParams() { + // TODO: Obtener lista de tiendas activas desde la base de datos + // Por ahora retornamos array vacío para generar páginas bajo demanda + return [] } diff --git a/app/[store]/products/[product]/page.tsx b/app/[store]/products/[product]/page.tsx new file mode 100644 index 00000000..e50526aa --- /dev/null +++ b/app/[store]/products/[product]/page.tsx @@ -0,0 +1,128 @@ +import { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { storeRenderer } from '@/lib/store-renderer' + +interface ProductPageProps { + params: Promise<{ + store: string + product: string + }> + searchParams: Promise<{ + [key: string]: string | string[] | undefined + }> +} + +/** + * Página de producto individual con SSR + * Ruta: /[store]/products/[product] + */ +export default async function ProductPage({ params }: ProductPageProps) { + const resolvedParams = await params + const { store, product } = resolvedParams + + try { + // Construir dominio y path + const domain = `${store}.fasttify.com` + const path = `/products/${product}` + + // Renderizar página de producto + const result = await storeRenderer.renderPage(domain, path) + + return
+ } catch (error: any) { + console.error(`Error rendering product page ${store}/products/${product}:`, error) + + // 404 para productos no encontrados + if (error.type === 'DATA_ERROR' || error.statusCode === 404) { + notFound() + } + + // Re-lanzar otros errores para error boundary + throw error + } +} + +/** + * Genera metadata SEO específica para productos + */ +export async function generateMetadata({ params }: ProductPageProps): Promise { + const resolvedParams = await params + const { store, product } = resolvedParams + + try { + const domain = `${store}.fasttify.com` + const path = `/products/${product}` + const result = await storeRenderer.renderPage(domain, path) + + const { metadata } = result + + // Metadata optimizada para productos (e-commerce) + return { + title: metadata.title, + description: metadata.description, + alternates: { + canonical: metadata.canonical, + }, + openGraph: metadata.openGraph + ? { + title: metadata.openGraph.title, + description: metadata.openGraph.description, + url: metadata.openGraph.url, + type: 'website', // Para productos específicos + images: metadata.openGraph.image + ? [ + { + url: metadata.openGraph.image, + alt: metadata.openGraph.title, + }, + ] + : undefined, + siteName: metadata.openGraph.site_name, + } + : undefined, + twitter: metadata.openGraph + ? { + card: 'summary_large_image', + title: metadata.openGraph.title, + description: metadata.openGraph.description, + images: metadata.openGraph.image ? [metadata.openGraph.image] : undefined, + } + : undefined, + // Schema.org para productos + other: metadata.schema + ? { + 'application-ld+json': JSON.stringify(metadata.schema), + } + : undefined, + // Keywords específicos para productos + keywords: [product.replace(/-/g, ' '), store, 'comprar online', 'tienda', 'producto'].join( + ', ' + ), + } + } catch (error) { + console.error(`Error generating product metadata for ${store}/products/${product}:`, error) + + // Metadata de fallback + const productName = product.replace(/-/g, ' ') + return { + title: `${productName} | ${store}`, + description: `Compra ${productName} en ${store}. ¡Envío rápido y seguro!`, + keywords: `${productName}, ${store}, comprar online`, + } + } +} + +/** + * Configurar ISR para productos + * Los productos cambian con más frecuencia que las páginas principales + */ +export const revalidate = 900 // 15 minutos + +/** + * Generar parámetros estáticos para productos populares + */ +export async function generateStaticParams() { + // TODO: Implementar generación de productos populares + // Por ahora generar bajo demanda + return [] +} diff --git a/app/api/stores/render/route.ts b/app/api/stores/render/route.ts new file mode 100644 index 00000000..420ab1c8 --- /dev/null +++ b/app/api/stores/render/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from 'next/server' +import { storeRenderer } from '@/lib/store-renderer' + +/** + * API endpoint para renderizar páginas de tiendas + * + * GET /api/stores/render?domain=example.com&path=/products/mi-producto + * + * Query params: + * - domain: Dominio de la tienda (requerido) + * - path: Path de la página a renderizar (opcional, default: '/') + */ +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const domain = searchParams.get('domain') + const path = searchParams.get('path') || '/' + + // Validar parámetros requeridos + if (!domain) { + return NextResponse.json({ error: 'Domain parameter is required' }, { status: 400 }) + } + + // Validar formato del dominio + if (!isValidDomain(domain)) { + return NextResponse.json({ error: 'Invalid domain format' }, { status: 400 }) + } + + // Renderizar página usando el sistema de renderizado + const result = await storeRenderer.renderPage(domain, path) + + // Configurar headers de respuesta + const headers = new Headers({ + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': `public, max-age=${Math.floor(result.cacheTTL / 1000)}`, + 'X-Store-Cache-Key': result.cacheKey, + }) + + // Añadir headers SEO si existen + if (result.metadata.canonical) { + headers.set('Link', `<${result.metadata.canonical}>; rel="canonical"`) + } + + // Crear respuesta HTML con metadata incluida + const fullHtml = generateFullHTML(result.html, result.metadata) + + return new NextResponse(fullHtml, { + status: 200, + headers, + }) + } catch (error: any) { + console.error('Error rendering store page:', error) + + // Manejar errores tipados del sistema de renderizado + if (error.type && error.statusCode) { + return NextResponse.json( + { + error: error.message, + type: error.type, + details: process.env.APP_ENV === 'development' ? error.details : undefined, + }, + { status: error.statusCode } + ) + } + + // Error genérico + return NextResponse.json( + { + error: 'Internal server error', + message: process.env.APP_ENV === 'development' ? error.message : 'Something went wrong', + }, + { status: 500 } + ) + } +} + +/** + * Valida si un dominio tiene formato correcto + */ +function isValidDomain(domain: string): boolean { + const domainRegex = + /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]\.([a-zA-Z]{2,}|[a-zA-Z]{2,}\.[a-zA-Z]{2,})$/ + return domainRegex.test(domain) || domain.includes('.fasttify.com') +} + +/** + * Genera HTML completo con metadata SEO + */ +function generateFullHTML(body: string, metadata: any): string { + const { title, description, canonical, openGraph, schema } = metadata + + return ` + + + + + + + ${escapeHtml(title)} + + ${canonical ? `` : ''} + + + ${openGraph ? generateOpenGraphTags(openGraph) : ''} + + + ${schema ? `` : ''} + + + + + + + + + ${body} + + + +` +} + +/** + * Genera tags de Open Graph + */ +function generateOpenGraphTags(og: any): string { + return ` + + + + + ${og.image ? `` : ''} + ${og.site_name ? `` : ''} + + + + + + ${og.image ? `` : ''} + `.trim() +} + +/** + * Escapa HTML para prevenir XSS + */ +function escapeHtml(text: string): string { + if (!text) return '' + + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/app/api/stores/template/route.ts b/app/api/stores/template/route.ts index 87e440a6..b8b1d300 100644 --- a/app/api/stores/template/route.ts +++ b/app/api/stores/template/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { cookiesClient, AuthGetCurrentUserServer } from '@/utils/AmplifyUtils' +import { AuthGetCurrentUserServer } from '@/utils/AmplifyUtils' import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' import { readFile, readdir } from 'fs/promises' import { join } from 'path' diff --git a/lib/store-renderer/README.md b/lib/store-renderer/README.md new file mode 100644 index 00000000..f71baa1a --- /dev/null +++ b/lib/store-renderer/README.md @@ -0,0 +1,282 @@ +# Sistema de Renderizado de Tiendas + +Sistema completo para renderizar tiendas de e-commerce con dominios personalizados, plantillas Liquid, y SEO optimizado. + +## 🏗️ Arquitectura + +``` +lib/store-renderer/ +├── types/ # Definiciones TypeScript +├── services/ # Servicios principales +│ ├── domain-resolver.ts # Resolución dominio → tienda +│ ├── template-loader.ts # Carga plantillas desde S3 +│ └── data-fetcher.ts # Obtiene datos de Amplify +├── liquid/ # Motor de plantillas LiquidJS +│ ├── engine.ts # Configuración LiquidJS +│ └── filters.ts # Filtros personalizados +├── renderers/ # Renderizadores específicos +│ ├── homepage.ts # Renderizado homepage +│ └── product.ts # Renderizado productos +└── index.ts # Factory principal +``` + +## 🚀 Uso Básico + +```typescript +import { storeRenderer } from '@/lib/store-renderer' + +// Renderizar homepage +const result = await storeRenderer.renderPage('mitienda.fasttify.com', '/') + +// Renderizar producto +const productResult = await storeRenderer.renderPage( + 'mitienda.fasttify.com', + '/products/mi-producto' +) +``` + +## 🔧 Componentes Principales + +### 1. Domain Resolver + +Resuelve dominios personalizados a tiendas: + +```typescript +import { domainResolver } from '@/lib/store-renderer' + +const store = await domainResolver.resolveStoreByDomain('mitienda.fasttify.com') +``` + +### 2. Template Loader + +Carga plantillas Liquid desde S3: + +```typescript +import { templateLoader } from '@/lib/store-renderer' + +const layout = await templateLoader.loadMainLayout(storeId) +const section = await templateLoader.loadSection(storeId, 'header') +``` + +### 3. Data Fetcher + +Obtiene datos de productos/colecciones desde Amplify: + +```typescript +import { dataFetcher } from '@/lib/store-renderer' + +const products = await dataFetcher.getStoreProducts(storeId) +const product = await dataFetcher.getProduct(storeId, productId) +``` + +### 4. Liquid Engine + +Renderiza plantillas con datos: + +```typescript +import { liquidEngine } from '@/lib/store-renderer' + +const html = await liquidEngine.render(template, context, cacheKey) +``` + +## 🎨 Plantillas Liquid + +### Estructura de Archivos + +``` +templates/{storeId}/ +├── layout/ +│ └── theme.liquid # Layout principal +└── sections/ + ├── header.liquid # Encabezado + ├── footer.liquid # Pie de página + ├── hero-banner.liquid # Banner principal + ├── featured-products.liquid # Productos destacados + └── collection-list.liquid # Lista de colecciones +``` + +### Variables Disponibles + +#### Context Global + +```liquid +{{ shop.name }} +{{ shop.domain }} +{{ shop.currency }} +{{ shop.logo }} +{{ shop.description }} + +{{ page.title }} +{{ page.url }} +{{ page.template }} +``` + +#### Productos + +```liquid +{% for product in products %} + {{ product.title }} + {{ product.price }} + {{ product.description }} + {{ product.url }} + {{ product.handle }} + {{ product.available }} + + {% for image in product.images %} + {{ image.url }} + {{ image.alt }} + {% endfor %} +{% endfor %} +``` + +### Filtros Disponibles + +```liquid +{{ 15000 | money }} +{{ product.title | handleize }} +{{ product | product_url }} +{{ collection | collection_url }} +{{ "2024-01-15" | date: "%d/%m/%Y" }} +{{ text | truncate: 100 }} +``` + +## 🌐 API Routes + +### GET /api/stores/render + +Renderiza páginas de tienda: + +```typescript +// Renderizar homepage +GET /api/stores/render?domain=mitienda.fasttify.com + +// Renderizar producto +GET /api/stores/render?domain=mitienda.fasttify.com&path=/products/mi-producto +``` + +## 📄 Páginas Next.js + +### Rutas Dinámicas + +- `app/[store]/page.tsx` - Homepage de tienda +- `app/[store]/products/[product]/page.tsx` - Página de producto + +### SSR + ISR + +- **SSR**: Renderizado completo en servidor +- **ISR**: Regeneración incremental cada 15-30 minutos +- **Metadata**: SEO optimizado con Open Graph y Schema.org + +## 🗄️ Sistema de Caché + +### Niveles de Caché + +1. **Plantillas**: 1 hora (cambian poco) +2. **Productos**: 15 minutos (cambian frecuentemente) +3. **Colecciones**: 30 minutos (cambian moderadamente) +4. **Resolución dominios**: 30 minutos (estable) + +### Gestión de Caché + +```typescript +// Limpiar caché de tienda +templateLoader.invalidateStoreCache(storeId) +dataFetcher.invalidateStoreCache(storeId) + +// Limpiar caché específico +dataFetcher.invalidateProductCache(storeId, productId) + +// Estadísticas de caché +const stats = templateLoader.getCacheStats() +``` + +## 🎯 SEO Optimizado + +### Metadata Generada + +- **Title**: Optimizado por tipo de página +- **Description**: Descriptiva y con keywords +- **Canonical**: URLs canónicas correctas +- **Open Graph**: Redes sociales optimizadas +- **Schema.org**: Structured data para buscadores + +### Ejemplo de Metadata + +```html +Mi Producto Genial | Mi Tienda + + + +``` + +## 🔒 Manejo de Errores + +### Errores Tipados + +```typescript +interface TemplateError { + type: 'STORE_NOT_FOUND' | 'TEMPLATE_NOT_FOUND' | 'DATA_ERROR' | 'RENDER_ERROR' + message: string + statusCode: number + details?: any +} +``` + +### Códigos de Estado + +- **404**: Tienda/producto no encontrado +- **500**: Error de renderizado/datos +- **400**: Parámetros inválidos + +## 🚀 Deployment + +### Variables de Entorno + +```env +BUCKET_NAME=mi-bucket-plantillas +AWS_REGION_BUCKET=us-east-1 +CLOUDFRONT_DOMAIN_NAME=cdn.midominio.com +APP_ENV=production +``` + +### Configuración S3 + +- Bucket con permisos de lectura para Lambda +- Estructura: `templates/{storeId}/layout/` y `templates/{storeId}/sections/` +- CloudFront para distribución global + +## 📊 Monitoreo + +### Logs Estructurados + +- Errores de renderizado +- Estadísticas de caché +- Performance de consultas +- Resolución de dominios + +### Métricas Importantes + +- Tiempo de renderizado por página +- Hit rate del caché +- Errores por tipo de página +- Uso de memoria de caché + +## 🔄 Próximas Funcionalidades + +- [ ] Renderizador de colecciones +- [ ] Páginas estáticas personalizadas +- [ ] Sistema de temas +- [ ] A/B testing de plantillas +- [ ] Analytics integrado +- [ ] CDN edge caching diff --git a/lib/store-renderer/index.ts b/lib/store-renderer/index.ts new file mode 100644 index 00000000..8c29c85a --- /dev/null +++ b/lib/store-renderer/index.ts @@ -0,0 +1,142 @@ +import { HomepageRenderer } from './renderers/homepage' +import { ProductRenderer } from './renderers/product' +import type { RenderResult } from './types' + +/** + * Factory principal del sistema de renderizado de tiendas + * Combina todos los renderizadores específicos y expone una API unificada + */ +export class StoreRendererFactory { + private homepageRenderer: HomepageRenderer + private productRenderer: ProductRenderer + + constructor() { + this.homepageRenderer = new HomepageRenderer() + this.productRenderer = new ProductRenderer() + } + + /** + * Renderiza cualquier página de una tienda basada en el path + * @param domain - Dominio completo de la tienda + * @param path - Path de la página (ej: '/', '/products/mi-producto') + * @returns Resultado del renderizado con metadata SEO + */ + public async renderPage(domain: string, path: string = '/'): Promise { + // Limpiar path + const cleanPath = path.startsWith('/') ? path : `/${path}` + + try { + // Determinar tipo de página basado en el path + const pageType = this.determinePageType(cleanPath) + + switch (pageType.type) { + case 'homepage': + return await this.homepageRenderer.render(domain) + + case 'product': + if (!pageType.handle) { + throw new Error('Product handle is required for product pages') + } + return await this.productRenderer.render(domain, pageType.handle) + + case 'collection': + // TODO: Implementar CollectionRenderer + throw new Error('Collection pages not yet implemented') + + case 'page': + // TODO: Implementar PageRenderer (páginas estáticas) + throw new Error('Static pages not yet implemented') + + default: + throw new Error(`Unknown page type: ${pageType.type}`) + } + } catch (error) { + console.error(`Error rendering page ${cleanPath} for domain ${domain}:`, error) + + // Re-lanzar errores de plantilla con su metadata + if (error instanceof Error && 'type' in error) { + throw error + } + + // Crear error genérico para otros casos + throw { + type: 'RENDER_ERROR', + message: `Failed to render page: ${error}`, + statusCode: 500, + } + } + } + + /** + * Determina el tipo de página basado en el path + */ + private determinePageType(path: string): { + type: 'homepage' | 'product' | 'collection' | 'page' + handle?: string + } { + // Homepage + if (path === '/') { + return { type: 'homepage' } + } + + // Producto: /products/mi-producto + const productMatch = path.match(/^\/products\/([^\/]+)$/) + if (productMatch) { + return { + type: 'product', + handle: productMatch[1], + } + } + + // Colección: /collections/mi-coleccion + const collectionMatch = path.match(/^\/collections\/([^\/]+)$/) + if (collectionMatch) { + return { + type: 'collection', + handle: collectionMatch[1], + } + } + + // Página estática: /pages/mi-pagina + const pageMatch = path.match(/^\/pages\/([^\/]+)$/) + if (pageMatch) { + return { + type: 'page', + handle: pageMatch[1], + } + } + + // Fallback a homepage para paths no reconocidos + return { type: 'homepage' } + } + + /** + * Verifica si una tienda tiene configuración completa para renderizado + * @param domain - Dominio de la tienda + * @returns True si la tienda puede ser renderizada + */ + public async canRenderStore(domain: string): Promise { + try { + // Intentar renderizar homepage para verificar configuración + await this.homepageRenderer.render(domain) + return true + } catch (error) { + console.warn(`Store ${domain} cannot be rendered:`, error) + return false + } + } +} + +// Exportar instancia singleton +export const storeRenderer = new StoreRendererFactory() + +// Exportar tipos para uso externo +export type { RenderResult } from './types' +export { HomepageRenderer } from './renderers/homepage' +export { ProductRenderer } from './renderers/product' + +// Exportar servicios para uso avanzado +export { domainResolver } from './services/domain-resolver' +export { templateLoader } from './services/template-loader' +export { dataFetcher } from './services/data-fetcher' +export { liquidEngine } from './liquid/engine' diff --git a/lib/store-renderer/liquid/engine.ts b/lib/store-renderer/liquid/engine.ts new file mode 100644 index 00000000..d5a23359 --- /dev/null +++ b/lib/store-renderer/liquid/engine.ts @@ -0,0 +1,280 @@ +import { Liquid } from 'liquidjs' +import type { + LiquidEngineConfig, + CompiledTemplate, + TemplateCache, + LiquidContext, + TemplateError, +} from '../types' +import { ecommerceFilters } from './filters' +import { SchemaTag } from './tags/schema-tag' + +interface EngineCache { + [templatePath: string]: TemplateCache +} + +class LiquidEngine { + private static instance: LiquidEngine + private liquid: Liquid + private cache: EngineCache = {} + private readonly TEMPLATE_CACHE_TTL = 60 * 60 * 1000 // 1 hora en ms + + private constructor() { + this.liquid = this.createEngine() + this.registerFilters() + this.registerCustomTags() + } + + public static getInstance(): LiquidEngine { + if (!LiquidEngine.instance) { + LiquidEngine.instance = new LiquidEngine() + } + return LiquidEngine.instance + } + + /** + * Crea y configura la instancia de LiquidJS + */ + private createEngine(): Liquid { + const config: LiquidEngineConfig = { + cache: true, // Caché interno de LiquidJS + greedy: false, // Permite variables undefined sin error + trimTagLeft: false, + trimTagRight: false, + trimOutputLeft: false, + trimOutputRight: false, + strictFilters: false, // Permite filtros undefined + strictVariables: false, // Permite variables undefined + globals: { + // Variables globales disponibles en todas las plantillas + settings: { + currency: 'COP', + currency_symbol: '$', + money_format: '${{amount}}', + timezone: 'America/Bogota', + }, + }, + } + + return new Liquid(config) + } + + /** + * Registra todos los filtros personalizados + */ + private registerFilters(): void { + ecommerceFilters.forEach(({ name, filter }) => { + this.liquid.registerFilter(name, filter) + }) + } + + /** + * Registra tags personalizados para compatibilidad con Shopify + */ + private registerCustomTags(): void { + // Registrar el tag schema + this.liquid.registerTag('schema', SchemaTag) + } + + /** + * Compila y renderiza una plantilla con contexto + * @param templateContent - Contenido de la plantilla Liquid + * @param context - Variables para el renderizado + * @param templatePath - Path para caché (opcional) + * @returns HTML renderizado + */ + public async render( + templateContent: string, + context: LiquidContext, + templatePath?: string + ): Promise { + try { + // Renderizar directamente usando parseAndRender + // LiquidJS maneja internamente el parsing y rendering + const result = await this.liquid.parseAndRender(templateContent, context) + + return result + } catch (error) { + console.error('Liquid render error:', error) + + const templateError: TemplateError = { + type: 'RENDER_ERROR', + message: `Template rendering failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + details: error, + statusCode: 500, + } + + throw templateError + } + } + + /** + * Precompila una plantilla para optimización + * @param templateContent - Contenido de la plantilla + * @param templatePath - Path para identificar la plantilla + * @returns Template compilado + */ + public async compileTemplate( + templateContent: string, + templatePath: string + ): Promise { + try { + const compiledTemplate = this.liquid.parse(templateContent) + + // Guardar en caché + this.setCachedTemplate(templatePath, templateContent, compiledTemplate) + + return { + liquid: this.liquid, + template: compiledTemplate, + cacheKey: templatePath, + compiledAt: new Date(), + } + } catch (error) { + console.error('Template compilation error:', error) + + const templateError: TemplateError = { + type: 'RENDER_ERROR', + message: `Template compilation failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + details: error, + statusCode: 500, + } + + throw templateError + } + } + + /** + * Renderiza una plantilla precompilada + * @param compiled - Plantilla compilada + * @param context - Variables para el renderizado + * @returns HTML renderizado + */ + public async renderCompiled(compiled: CompiledTemplate, context: LiquidContext): Promise { + try { + return await compiled.liquid.render(compiled.template, context) + } catch (error) { + console.error('Compiled template render error:', error) + + const templateError: TemplateError = { + type: 'RENDER_ERROR', + message: `Compiled template rendering failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + details: error, + statusCode: 500, + } + + throw templateError + } + } + + /** + * Obtiene una plantilla del caché si existe y es válida + */ + private getCachedTemplate(templatePath: string, content: string): any | null { + const cached = this.cache[templatePath] + if (!cached) { + return null + } + + const now = Date.now() + if (now > cached.lastUpdated.getTime() + cached.ttl) { + // Caché expirado + delete this.cache[templatePath] + return null + } + + // Verificar que el contenido no haya cambiado + if (cached.content !== content) { + delete this.cache[templatePath] + return null + } + + return cached.compiledTemplate + } + + /** + * Guarda una plantilla compilada en caché + */ + private setCachedTemplate(templatePath: string, content: string, compiled: any): void { + this.cache[templatePath] = { + content, + compiledTemplate: compiled, + lastUpdated: new Date(), + ttl: this.TEMPLATE_CACHE_TTL, + } + } + + /** + * Invalida el caché para una plantilla específica + * @param templatePath - Path de la plantilla a invalidar + */ + public invalidateCache(templatePath: string): void { + delete this.cache[templatePath] + } + + /** + * Limpia todo el caché de plantillas + */ + public clearCache(): void { + this.cache = {} + // Recrear la instancia de Liquid para limpiar su caché interno + this.liquid = this.createEngine() + this.registerFilters() + } + + /** + * Limpia plantillas expiradas del caché + */ + public cleanExpiredCache(): void { + const now = Date.now() + Object.keys(this.cache).forEach(templatePath => { + const cached = this.cache[templatePath] + if (now > cached.lastUpdated.getTime() + cached.ttl) { + delete this.cache[templatePath] + } + }) + } + + /** + * Obtiene estadísticas del caché para debugging + */ + public getCacheStats(): { total: number; expired: number; active: number } { + const now = Date.now() + let total = 0 + let expired = 0 + let active = 0 + + Object.values(this.cache).forEach(cached => { + total++ + if (now > cached.lastUpdated.getTime() + cached.ttl) { + expired++ + } else { + active++ + } + }) + + return { total, expired, active } + } + + /** + * Registra un filtro personalizado adicional + * @param name - Nombre del filtro + * @param filterFunction - Función del filtro + */ + public registerCustomFilter(name: string, filterFunction: (...args: any[]) => any): void { + this.liquid.registerFilter(name, filterFunction) + } + + /** + * Obtiene la instancia de Liquid para uso avanzado + */ + public getLiquidInstance(): Liquid { + return this.liquid + } +} + +// Export singleton instance +export const liquidEngine = LiquidEngine.getInstance() + +// Export class for testing +export { LiquidEngine } diff --git a/lib/store-renderer/liquid/filters.ts b/lib/store-renderer/liquid/filters.ts new file mode 100644 index 00000000..aca760a7 --- /dev/null +++ b/lib/store-renderer/liquid/filters.ts @@ -0,0 +1,244 @@ +import type { LiquidFilter } from '../types' + +/** + * Filtro para formatear precios con moneda + */ +export const moneyFilter: LiquidFilter = { + name: 'money', + filter: (amount: number | string, format?: string): string => { + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount + + if (isNaN(numAmount)) { + return '$0.00' + } + + // Formato por defecto o personalizado + const defaultFormat = '${{amount}}' + const actualFormat = format || defaultFormat + + // Formatear el número con separadores de miles + const formattedAmount = new Intl.NumberFormat('es-CO', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numAmount) + + return actualFormat.replace('{{amount}}', formattedAmount) + }, +} + +/** + * Filtro para formatear precios sin decimales + */ +export const moneyWithoutCurrencyFilter: LiquidFilter = { + name: 'money_without_currency', + filter: (amount: number | string): string => { + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount + + if (isNaN(numAmount)) { + return '0.00' + } + + return new Intl.NumberFormat('es-CO', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(numAmount) + }, +} + +/** + * Filtro para generar URLs de productos + */ +export const productUrlFilter: LiquidFilter = { + name: 'product_url', + filter: (product: any): string => { + if (!product || !product.handle) { + return '#' + } + return `/products/${product.handle}` + }, +} + +/** + * Filtro para generar URLs de colecciones + */ +export const collectionUrlFilter: LiquidFilter = { + name: 'collection_url', + filter: (collection: any): string => { + if (!collection || !collection.handle) { + return '#' + } + return `/collections/${collection.handle}` + }, +} + +/** + * Filtro para optimizar imágenes (básico, expandible) + */ +export const imgUrlFilter: LiquidFilter = { + name: 'img_url', + filter: (url: string, size?: string): string => { + if (!url) { + return '' + } + + // Por ahora devolvemos la URL original + // TODO: Implementar optimización de imágenes + return url + }, +} + +/** + * Filtro para crear URLs absolutas + */ +export const urlFilter: LiquidFilter = { + name: 'url', + filter: (path: string, domain?: string): string => { + if (!path) { + return '' + } + + // Si ya es una URL absoluta, la devolvemos tal como está + if (path.startsWith('http://') || path.startsWith('https://')) { + return path + } + + // Si no hay dominio, devolvemos la ruta relativa + if (!domain) { + return path.startsWith('/') ? path : `/${path}` + } + + // Construir URL absoluta + const cleanDomain = domain.replace(/\/+$/, '') // Quitar barras al final + const cleanPath = path.startsWith('/') ? path : `/${path}` + + return `https://${cleanDomain}${cleanPath}` + }, +} + +/** + * Filtro para formatear fechas + */ +export const dateFilter: LiquidFilter = { + name: 'date', + filter: (date: string | Date, format?: string): string => { + let dateObj: Date + + if (typeof date === 'string') { + dateObj = new Date(date) + } else if (date instanceof Date) { + dateObj = date + } else { + return '' + } + + if (isNaN(dateObj.getTime())) { + return '' + } + + // Formatos básicos + switch (format) { + case '%B %d, %Y': + return dateObj.toLocaleDateString('es-ES', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + case '%Y-%m-%d': + return dateObj.toISOString().split('T')[0] + case '%d/%m/%Y': + return dateObj.toLocaleDateString('es-ES') + default: + return dateObj.toLocaleDateString('es-ES') + } + }, +} + +/** + * Filtro para crear handles/slugs SEO-friendly + */ +export const handleizeFilter: LiquidFilter = { + name: 'handleize', + filter: (text: string): string => { + if (!text) { + return '' + } + + return text + .toLowerCase() + .trim() + .replace(/[áàäâã]/g, 'a') + .replace(/[éèëê]/g, 'e') + .replace(/[íìïî]/g, 'i') + .replace(/[óòöôõ]/g, 'o') + .replace(/[úùüû]/g, 'u') + .replace(/[ñ]/g, 'n') + .replace(/[ç]/g, 'c') + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + }, +} + +/** + * Filtro para pluralizar texto + */ +export const pluralizeFilter: LiquidFilter = { + name: 'pluralize', + filter: (count: number, singular: string, plural?: string): string => { + if (count === 1) { + return singular + } + + return plural || `${singular}s` + }, +} + +/** + * Filtro para truncar texto + */ +export const truncateFilter: LiquidFilter = { + name: 'truncate', + filter: (text: string, length: number = 50, truncateString: string = '...'): string => { + if (!text || text.length <= length) { + return text || '' + } + + return text.substring(0, length - truncateString.length) + truncateString + }, +} + +/** + * Filtro para escapar HTML + */ +export const escapeFilter: LiquidFilter = { + name: 'escape', + filter: (text: string): string => { + if (!text) { + return '' + } + + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + }, +} + +/** + * Array con todos los filtros para registrar + */ +export const ecommerceFilters: LiquidFilter[] = [ + moneyFilter, + moneyWithoutCurrencyFilter, + productUrlFilter, + collectionUrlFilter, + imgUrlFilter, + urlFilter, + dateFilter, + handleizeFilter, + pluralizeFilter, + truncateFilter, + escapeFilter, +] diff --git a/lib/store-renderer/liquid/tags/schema-tag.ts b/lib/store-renderer/liquid/tags/schema-tag.ts new file mode 100644 index 00000000..4f3cc8f9 --- /dev/null +++ b/lib/store-renderer/liquid/tags/schema-tag.ts @@ -0,0 +1,137 @@ +import { Tag, TagToken, Context, TopLevelToken, Liquid, TokenKind } from 'liquidjs' + +/** + * Custom Schema Tag para manejar {% schema %} de Shopify en LiquidJS + * Este tag extrae las configuraciones JSON y las inyecta en el contexto + */ +export class SchemaTag extends Tag { + private schemaContent: string = '' + private parsedSchema: any = null + + constructor(tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) { + super(tagToken, remainTokens, liquid) + + // Parsear el contenido entre {% schema %} y {% endschema %} + this.parseSchemaContent(remainTokens) + } + + private parseSchemaContent(remainTokens: TopLevelToken[]): void { + const contentTokens: string[] = [] + let closed = false + + while (remainTokens.length) { + const token = remainTokens.shift() + + if (!token) break + + // Verificar si encontramos el tag de cierre + if (token.kind === TokenKind.Tag && (token as any).name === 'endschema') { + closed = true + break + } + + // Acumular contenido del schema + if (token.kind === TokenKind.HTML) { + contentTokens.push((token as any).value) + } else if (token.kind === TokenKind.Output) { + contentTokens.push(`{{ ${(token as any).content} }}`) + } + } + + if (!closed) { + throw new Error('tag {% schema %} not closed') + } + + // Unir todo el contenido y parsear JSON + this.schemaContent = contentTokens.join('').trim() + this.parseJSON() + } + + private parseJSON(): void { + try { + if (this.schemaContent) { + this.parsedSchema = JSON.parse(this.schemaContent) + } + } catch (error) { + console.warn('Error parsing schema JSON:', error) + console.warn('Schema content:', this.schemaContent) + this.parsedSchema = {} + } + } + + /** + * Extrae los settings del schema con sus valores por defecto + */ + public getSettings(): Record { + if (!this.parsedSchema || !this.parsedSchema.settings) { + return {} + } + + const settings: Record = {} + + for (const setting of this.parsedSchema.settings) { + if (setting.id) { + // Usar valor por defecto si está definido + settings[setting.id] = setting.default || this.getDefaultValueForType(setting.type) + } + } + + return settings + } + + /** + * Obtiene valores por defecto basados en el tipo de setting + */ + private getDefaultValueForType(type: string): any { + switch (type) { + case 'text': + case 'textarea': + case 'richtext': + case 'html': + case 'url': + return '' + case 'number': + case 'range': + return 0 + case 'checkbox': + return false + case 'color': + return '#000000' + case 'select': + case 'radio': + return '' + case 'image_picker': + case 'video': + case 'file': + return null + default: + return '' + } + } + + /** + * Obtiene los blocks del schema + */ + public getBlocks(): any[] { + if (!this.parsedSchema || !this.parsedSchema.blocks) { + return [] + } + return this.parsedSchema.blocks + } + + /** + * Obtiene el nombre de la sección + */ + public getSectionName(): string { + return this.parsedSchema?.name || 'Untitled Section' + } + + /** + * El render del tag schema no produce output HTML + * Solo extrae y procesa los metadatos + */ + *render(ctx: Context): Generator { + // Schema tag no renderiza contenido, solo procesa metadatos + return '' + } +} diff --git a/lib/store-renderer/renderers/homepage.ts b/lib/store-renderer/renderers/homepage.ts new file mode 100644 index 00000000..7bfbd563 --- /dev/null +++ b/lib/store-renderer/renderers/homepage.ts @@ -0,0 +1,376 @@ +import { domainResolver } from '../services/domain-resolver' +import { templateLoader } from '../services/template-loader' +import { dataFetcher } from '../services/data-fetcher' +import { liquidEngine } from '../liquid/engine' +import type { + RenderResult, + RenderContext, + ShopContext, + PageContext, + OpenGraphData, + SchemaData, + TemplateError, +} from '../types' + +export class HomepageRenderer { + /** + * Renderiza la homepage de una tienda + * @param domain - Dominio completo de la tienda + * @returns Resultado completo del renderizado con metadata SEO + */ + public async render(domain: string): Promise { + try { + // 1. Resolver dominio a tienda + const store = await domainResolver.resolveStoreByDomain(domain) + + // 2. Verificar que la tienda tenga plantillas + const hasTemplates = await templateLoader.hasTemplates(store.storeId) + if (!hasTemplates) { + throw this.createTemplateError( + 'TEMPLATE_NOT_FOUND', + `No templates found for store: ${store.storeId}` + ) + } + + // 3. Cargar layout principal y secciones necesarias + const [layout, featuredProducts, collections] = await Promise.all([ + templateLoader.loadMainLayout(store.storeId), + dataFetcher.getFeaturedProducts(store.storeId, 8), + dataFetcher.getStoreCollections(store.storeId, { limit: 6 }), + ]) + + // 4. Crear contexto para las plantillas Liquid + const context = this.createRenderContext(store, featuredProducts, collections.collections) + + // 5. Generar contenido de homepage usando nuestras secciones reales + const homepageContent = await this.generateHomepageContent(store.storeId, context) + + // 6. Insertar contenido en el layout + context.content_for_layout = homepageContent + context.content_for_header = this.generateHeadContent(store) + + // 7. Renderizar el layout completo + const html = await liquidEngine.render(layout, context, `homepage_${store.storeId}`) + + // 8. Generar metadata SEO + const metadata = this.generateMetadata(store, domain) + + // 9. Crear clave de caché + const cacheKey = `homepage_${store.storeId}_${Date.now()}` + + return { + html, + metadata, + cacheKey, + cacheTTL: 30 * 60 * 1000, // 30 minutos + } + } catch (error) { + console.error(`Error rendering homepage for domain ${domain}:`, error) + console.error('Error stack:', error instanceof Error ? error.stack : 'No stack') + + if (error instanceof Error && 'type' in error) { + throw error // Re-lanzar errores tipados + } + + const errorMessage = error instanceof Error ? error.message : String(error) + throw this.createTemplateError('RENDER_ERROR', `Failed to render homepage: ${errorMessage}`) + } + } + + /** + * Crea el contexto completo para el renderizado de Liquid + */ + private createRenderContext( + store: any, + featuredProducts: any[], + collections: any[] + ): RenderContext { + // Crear contexto de la tienda (como 'shop' para compatibilidad) + const shop: ShopContext = { + name: store.storeName, + description: store.storeDescription || `Tienda online de ${store.storeName}`, + domain: store.customDomain, + url: `https://${store.customDomain}`, + currency: store.storeCurrency || 'COP', + money_format: store.storeCurrency === 'USD' ? '${{amount}}' : '${{amount}}', + email: store.contactEmail, + phone: store.contactPhone?.toString(), + address: store.storeAdress, + logo: store.storeLogo, + banner: store.storeBanner, + theme: store.storeTheme || 'modern', + favicon: store.storeFavicon, + } + + // Crear contexto de la página + const page: PageContext = { + title: store.storeName, + url: '/', + template: 'index', + handle: 'homepage', + } + + // Crear contexto que incluye tanto 'shop' como 'store' para compatibilidad + // y variables de página al nivel raíz como espera el template + return { + shop, + store: shop, // Alias para compatibilidad con templates que usan {{ store.name }} + page, + page_title: store.storeName, // Variable al nivel raíz para {{ page_title }} + page_description: store.storeDescription || `Tienda online de ${store.storeName}`, + products: featuredProducts, + collections, + } + } + + /** + * Genera metadata SEO para la homepage + */ + private generateMetadata(store: any, domain: string): RenderResult['metadata'] { + const title = `${store.storeName} - Tienda Online` + const description = + store.storeDescription || + `Descubre los mejores productos en ${store.storeName}. Compra online con envío seguro.` + const url = `https://${domain}` + + const openGraph: OpenGraphData = { + title, + description, + url, + type: 'website', + image: store.storeLogo || store.storeBanner, + site_name: store.storeName, + } + + const schema: SchemaData = { + '@context': 'https://schema.org', + '@type': 'Store', + name: store.storeName, + description, + url, + logo: store.storeLogo, + image: store.storeBanner, + email: store.contactEmail, + telephone: store.contactPhone, + address: store.storeAdress + ? { + '@type': 'PostalAddress', + addressLocality: store.storeAdress, + } + : undefined, + currenciesAccepted: store.storeCurrency || 'COP', + paymentAccepted: ['Credit Card', 'Debit Card'], + } + + return { + title, + description, + canonical: url, + openGraph, + schema, + } + } + + /** + * Genera el contenido de la homepage renderizando las secciones reales + */ + private async generateHomepageContent(storeId: string, context: RenderContext): Promise { + try { + // Cargar secciones reales desde nuestros templates + const [header, heroBanner, featuredProducts, collectionList, footer] = await Promise.all([ + templateLoader.loadTemplate(storeId, 'sections/header.liquid'), + templateLoader.loadTemplate(storeId, 'sections/hero-banner.liquid'), + templateLoader.loadTemplate(storeId, 'sections/featured-products.liquid'), + templateLoader.loadTemplate(storeId, 'sections/collection-list.liquid'), + templateLoader.loadTemplate(storeId, 'sections/footer.liquid'), + ]) + + // Renderizar cada sección con su contexto específico y extraer settings del schema + const renderedSections = await Promise.all([ + this.renderSectionWithSchema('header', header, context), + this.renderSectionWithSchema('hero-banner', heroBanner, context), + this.renderSectionWithSchema('featured-products', featuredProducts, context), + this.renderSectionWithSchema('collection-list', collectionList, context), + this.renderSectionWithSchema('footer', footer, context), + ]) + + // Combinar todas las secciones + return renderedSections.join('\n') + } catch (error) { + console.error('Error generating homepage content for store', storeId, ':', error) + return '' + } + } + + /** + * Renderiza una sección extrayendo primero los settings del schema + */ + private async renderSectionWithSchema( + sectionName: string, + templateContent: string, + baseContext: RenderContext + ): Promise { + try { + // Crear contexto específico para esta sección + const sectionContext = { + ...baseContext, + section: { + id: sectionName, + settings: this.extractSchemaSettings(templateContent), + blocks: this.extractSchemaBlocks(templateContent), + }, + } + + // Renderizar la sección con el contexto enriquecido + return await liquidEngine.render(templateContent, sectionContext, `section_${sectionName}`) + } catch (error) { + console.error(`Error rendering section ${sectionName}:`, error) + return `` + } + } + + /** + * Extrae los settings del schema de un template usando expresiones regulares + */ + private extractSchemaSettings(templateContent: string): Record { + try { + // Buscar el bloque {% schema %}...{% endschema %} + const schemaRegex = /{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/i + const match = templateContent.match(schemaRegex) + + if (!match || !match[1]) { + return {} + } + + // Parsear el JSON del schema + const schemaJSON = JSON.parse(match[1].trim()) + + if (!schemaJSON.settings) { + return {} + } + + // Convertir settings a valores por defecto + const settings: Record = {} + + for (const setting of schemaJSON.settings) { + if (setting.id) { + settings[setting.id] = setting.default || this.getDefaultValueForType(setting.type) + } + } + + return settings + } catch (error) { + console.warn('Error extracting schema settings:', error) + return {} + } + } + + /** + * Extrae los blocks del schema + */ + private extractSchemaBlocks(templateContent: string): any[] { + try { + const schemaRegex = /{%\s*schema\s*%}([\s\S]*?){%\s*endschema\s*%}/i + const match = templateContent.match(schemaRegex) + + if (!match || !match[1]) { + return [] + } + + const schemaJSON = JSON.parse(match[1].trim()) + return schemaJSON.blocks || [] + } catch (error) { + console.warn('Error extracting schema blocks:', error) + return [] + } + } + + /** + * Obtiene valores por defecto basados en el tipo de setting + */ + private getDefaultValueForType(type: string): any { + switch (type) { + case 'text': + case 'textarea': + case 'richtext': + case 'html': + case 'url': + return '' + case 'number': + case 'range': + return 0 + case 'checkbox': + return false + case 'color': + return '#000000' + case 'select': + case 'radio': + return '' + case 'image_picker': + case 'video': + case 'file': + return null + default: + return '' + } + } + + /** + * Crea un error de plantilla tipado + */ + private createTemplateError(type: TemplateError['type'], message: string): TemplateError { + return { + type, + message, + statusCode: type === 'TEMPLATE_NOT_FOUND' ? 404 : 500, + } + } + + /** + * Genera el contenido para el incluyendo favicon y meta tags + */ + private generateHeadContent(store: any): string { + const headContent = [] + + // Favicon con soporte para diferentes tipos + if (store.storeFavicon) { + const faviconUrl = store.storeFavicon + let mimeType = 'image/x-icon' + + // Detectar tipo de archivo por extensión + if (faviconUrl.includes('.png')) { + mimeType = 'image/png' + } else if (faviconUrl.includes('.svg')) { + mimeType = 'image/svg+xml' + } else if (faviconUrl.includes('.jpg') || faviconUrl.includes('.jpeg')) { + mimeType = 'image/jpeg' + } + + headContent.push(``) + headContent.push(``) + + // Para PNG, agregar también tamaños comunes + if (mimeType === 'image/png') { + headContent.push(``) + headContent.push(``) + headContent.push(``) + } + } + + // Open Graph meta tags adicionales + if (store.storeBanner) { + headContent.push(``) + } + + // Meta tags adicionales para SEO + headContent.push(``) + headContent.push(``) + + // Canonical URL + if (store.customDomain) { + headContent.push(``) + } + + return headContent.join('\n ') + } +} diff --git a/lib/store-renderer/renderers/product.ts b/lib/store-renderer/renderers/product.ts new file mode 100644 index 00000000..8b2b8428 --- /dev/null +++ b/lib/store-renderer/renderers/product.ts @@ -0,0 +1,250 @@ +import { domainResolver } from '../services/domain-resolver' +import { templateLoader } from '../services/template-loader' +import { dataFetcher } from '../services/data-fetcher' +import { liquidEngine } from '../liquid/engine' +import type { + RenderResult, + RenderContext, + ShopContext, + PageContext, + ProductContext, + OpenGraphData, + SchemaData, + TemplateError, +} from '../types' + +export class ProductRenderer { + /** + * Renderiza una página de producto + * @param domain - Dominio completo de la tienda + * @param productHandle - Handle SEO del producto (slug) + * @returns Resultado completo del renderizado con metadata SEO + */ + public async render(domain: string, productHandle: string): Promise { + try { + // 1. Resolver dominio a tienda + const store = await domainResolver.resolveStoreByDomain(domain) + + // 2. Buscar producto por handle o ID + const product = await this.findProductByHandle(store.storeId, productHandle) + if (!product) { + throw this.createTemplateError('DATA_ERROR', `Product not found: ${productHandle}`, 404) + } + + // 3. Verificar que la tienda tenga plantillas + const hasTemplates = await templateLoader.hasTemplates(store.storeId) + if (!hasTemplates) { + throw this.createTemplateError( + 'TEMPLATE_NOT_FOUND', + `No templates found for store: ${store.storeId}` + ) + } + + // 4. Cargar layout principal y productos relacionados + const [layout, relatedProducts] = await Promise.all([ + templateLoader.loadMainLayout(store.storeId), + dataFetcher.getFeaturedProducts(store.storeId, 4), // Productos relacionados + ]) + + // 5. Crear contexto para las plantillas Liquid + const context = this.createRenderContext(store, product, relatedProducts) + + // 6. Renderizar con LiquidJS + const html = await liquidEngine.render(layout, context, `product_${store.storeId}`) + + // 7. Generar metadata SEO + const metadata = this.generateMetadata(store, product, domain) + + // 8. Crear clave de caché + const cacheKey = `product_${store.storeId}_${product.id}_${Date.now()}` + + return { + html, + metadata, + cacheKey, + cacheTTL: 15 * 60 * 1000, // 15 minutos (productos cambian más frecuentemente) + } + } catch (error) { + console.error(`Error rendering product ${productHandle} for domain ${domain}:`, error) + + if (error instanceof Error && 'type' in error) { + throw error // Re-lanzar errores tipados + } + + throw this.createTemplateError('RENDER_ERROR', `Failed to render product: ${error}`) + } + } + + /** + * Busca un producto por handle (slug SEO-friendly) + * Como no tenemos índice por handle, buscamos todos los productos y filtramos + * TODO: Optimizar con índice por handle en el futuro + */ + private async findProductByHandle( + storeId: string, + handle: string + ): Promise { + try { + // Si el handle parece ser un ID, intentar búsqueda directa + if (handle.match(/^[a-zA-Z0-9-]{8,}$/)) { + const product = await dataFetcher.getProduct(storeId, handle) + if (product) return product + } + + // Buscar en todos los productos (con paginación) + const { products } = await dataFetcher.getStoreProducts(storeId, { limit: 50 }) + + // Buscar por handle exacto + const product = products.find(p => p.handle === handle) + if (product) return product + + // Buscar por título similar (fallback) + const titleMatch = products.find(p => this.createHandle(p.title) === handle) + + return titleMatch || null + } catch (error) { + console.error(`Error finding product by handle ${handle}:`, error) + return null + } + } + + /** + * Crea el contexto completo para el renderizado de Liquid + */ + private createRenderContext( + store: any, + product: ProductContext, + relatedProducts: ProductContext[] + ): RenderContext { + // Crear contexto de la tienda + const shop: ShopContext = { + name: store.storeName, + description: store.storeDescription || `Tienda online de ${store.storeName}`, + domain: store.customDomain, + url: `https://${store.customDomain}`, + currency: store.storeCurrency || 'COP', + money_format: store.storeCurrency === 'USD' ? '${{amount}}' : '${{amount}}', + email: store.contactEmail, + phone: store.contactPhone?.toString(), + address: store.storeAdress, + logo: store.storeLogo, + banner: store.storeBanner, + theme: store.storeTheme || 'modern', + favicon: store.storeFavicon, + } + + // Crear contexto de la página + const page: PageContext = { + title: `${product.title} | ${store.storeName}`, + url: product.url, + template: 'product', + handle: product.handle, + } + + return { + shop, + store: shop, // Alias para compatibilidad + page, + page_title: `${product.title} | ${store.storeName}`, + page_description: + product.description || `${product.title} - Disponible en ${store.storeName}`, + product, + products: relatedProducts, // Productos relacionados + collections: [], // No hay colecciones en la página de producto + } + } + + /** + * Genera metadata SEO para la página de producto + */ + private generateMetadata( + store: any, + product: ProductContext, + domain: string + ): RenderResult['metadata'] { + const title = `${product.title} | ${store.storeName}` + const description = + product.description || + `${product.title} - ${product.price} COP. Disponible en ${store.storeName}. ¡Compra ahora!` + const url = `https://${domain}${product.url}` + const image = product.images[0]?.url || store.storeLogo + + const openGraph: OpenGraphData = { + title, + description, + url, + type: 'product', + image, + site_name: store.storeName, + } + + const schema: SchemaData = { + '@context': 'https://schema.org', + '@type': 'Product', + name: product.title, + description: product.description, + url, + image: product.images.map(img => img.url), + brand: { + '@type': 'Brand', + name: store.storeName, + }, + offers: { + '@type': 'Offer', + price: product.price.replace(/[.,\s]/g, ''), // Limpiar formato para schema + priceCurrency: store.storeCurrency || 'COP', + availability: product.available + ? 'https://schema.org/InStock' + : 'https://schema.org/OutOfStock', + seller: { + '@type': 'Organization', + name: store.storeName, + }, + }, + sku: product.variants[0]?.sku || product.id, + productID: product.id, + } + + return { + title, + description, + canonical: url, + openGraph, + schema, + } + } + + /** + * Crea un handle SEO-friendly a partir de un texto + */ + private createHandle(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[áàäâã]/g, 'a') + .replace(/[éèëê]/g, 'e') + .replace(/[íìïî]/g, 'i') + .replace(/[óòöôõ]/g, 'o') + .replace(/[úùüû]/g, 'u') + .replace(/[ñ]/g, 'n') + .replace(/[ç]/g, 'c') + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + } + + /** + * Crea un error de plantilla tipado + */ + private createTemplateError( + type: TemplateError['type'], + message: string, + statusCode?: number + ): TemplateError { + return { + type, + message, + statusCode: statusCode || (type === 'TEMPLATE_NOT_FOUND' ? 404 : 500), + } + } +} diff --git a/lib/store-renderer/services/data-fetcher.ts b/lib/store-renderer/services/data-fetcher.ts new file mode 100644 index 00000000..41c39039 --- /dev/null +++ b/lib/store-renderer/services/data-fetcher.ts @@ -0,0 +1,576 @@ +import { cookiesClient } from '@/utils/AmplifyServer' +import type { ProductContext, CollectionContext, TemplateError } from '../types' + +interface DataCache { + [key: string]: { + data: any + timestamp: number + ttl: number + } +} + +interface PaginationOptions { + limit?: number + offset?: number + nextToken?: string +} + +interface ProductsResponse { + products: ProductContext[] + nextToken?: string + totalCount?: number +} + +interface CollectionsResponse { + collections: CollectionContext[] + nextToken?: string + totalCount?: number +} + +class DataFetcher { + private static instance: DataFetcher + private cache: DataCache = {} + private readonly PRODUCT_CACHE_TTL = 15 * 60 * 1000 // 15 minutos + private readonly COLLECTION_CACHE_TTL = 30 * 60 * 1000 // 30 minutos + private readonly STORE_CACHE_TTL = 30 * 60 * 1000 // 30 minutos + + private constructor() {} + + public static getInstance(): DataFetcher { + if (!DataFetcher.instance) { + DataFetcher.instance = new DataFetcher() + } + return DataFetcher.instance + } + + /** + * Obtiene productos de una tienda con paginación + * @param storeId - ID de la tienda + * @param options - Opciones de paginación + * @returns Lista de productos transformados para Liquid + */ + public async getStoreProducts( + storeId: string, + options: PaginationOptions = {} + ): Promise { + try { + const { limit = 20, nextToken } = options + const cacheKey = `products_${storeId}_${limit}_${nextToken || 'first'}` + + // Verificar caché + const cached = this.getCached(cacheKey) + if (cached) { + return cached as ProductsResponse + } + + // Obtener productos desde Amplify + const response = await cookiesClient.models.Product.listProductByStoreId( + { storeId }, + { + limit, + nextToken, + } + ) + + if (!response.data) { + throw new Error(`No products found for store: ${storeId}`) + } + + // Transformar productos al formato Liquid + const products: ProductContext[] = response.data.map(product => + this.transformProduct(product) + ) + + const result: ProductsResponse = { + products, + nextToken: response.nextToken || undefined, + totalCount: products.length, + } + + // Guardar en caché + this.setCached(cacheKey, result, this.PRODUCT_CACHE_TTL) + + return result + } catch (error) { + console.error(`Error fetching products for store ${storeId}:`, error) + + 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 + * @param storeId - ID de la tienda + * @param productId - ID del producto + * @returns Producto transformado para Liquid + */ + public async getProduct(storeId: string, productId: string): Promise { + try { + const cacheKey = `product_${storeId}_${productId}` + + // Verificar caché + const cached = this.getCached(cacheKey) + if (cached) { + return cached as ProductContext + } + + // Obtener producto desde Amplify + const { data: product } = await cookiesClient.models.Product.get({ + id: productId, + }) + + if (!product || product.storeId !== storeId) { + return null + } + + // Transformar producto + const transformedProduct = this.transformProduct(product) + + // Guardar en caché + this.setCached(cacheKey, transformedProduct, this.PRODUCT_CACHE_TTL) + + return transformedProduct + } catch (error) { + console.error(`Error fetching product ${productId} for store ${storeId}:`, error) + return null + } + } + + /** + * Obtiene colecciones de una tienda + * @param storeId - ID de la tienda + * @param options - Opciones de paginación + * @returns Lista de colecciones transformadas para Liquid + */ + public async getStoreCollections( + storeId: string, + options: PaginationOptions = {} + ): Promise { + try { + const { limit = 10, nextToken } = options + const cacheKey = `collections_${storeId}_${limit}_${nextToken || 'first'}` + + // Verificar caché + const cached = this.getCached(cacheKey) + if (cached) { + return cached as CollectionsResponse + } + + // Obtener colecciones desde Amplify + const response = await cookiesClient.models.Collection.listCollectionByStoreId( + { storeId }, + { + limit, + nextToken, + } + ) + + if (!response.data || response.data.length === 0) { + // Retornar resultado vacío en lugar de error + const result: CollectionsResponse = { + collections: [], + nextToken: undefined, + totalCount: 0, + } + this.setCached(cacheKey, result, this.COLLECTION_CACHE_TTL) + return result + } + + // Transformar colecciones al formato Liquid + const collections: CollectionContext[] = [] + + for (const collection of response.data) { + const transformedCollection = await this.transformCollection(collection, storeId) + collections.push(transformedCollection) + } + + const result: CollectionsResponse = { + collections, + nextToken: response.nextToken || undefined, + totalCount: collections.length, + } + + // Guardar en caché + this.setCached(cacheKey, result, this.COLLECTION_CACHE_TTL) + + return result + } catch (error) { + console.error(`Error fetching collections for store ${storeId}:`, error) + + 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 + * @param storeId - ID de la tienda + * @param collectionId - ID de la colección + * @returns Colección transformada para Liquid con productos + */ + public async getCollection( + storeId: string, + collectionId: string + ): Promise { + try { + const cacheKey = `collection_${storeId}_${collectionId}` + + // Verificar caché + const cached = this.getCached(cacheKey) + if (cached) { + return cached as CollectionContext + } + + // Obtener colección desde Amplify + const { data: collection } = await cookiesClient.models.Collection.get({ + id: collectionId, + }) + + if (!collection || collection.storeId !== storeId) { + return null + } + + // Transformar colección con productos + const transformedCollection = await this.transformCollection(collection, storeId) + + // Guardar en caché + this.setCached(cacheKey, transformedCollection, this.COLLECTION_CACHE_TTL) + + return transformedCollection + } catch (error) { + console.error(`Error fetching collection ${collectionId} for store ${storeId}:`, error) + return null + } + } + + /** + * Obtiene productos destacados de una tienda + * @param storeId - ID de la tienda + * @param limit - Número máximo de productos + * @returns Lista de productos destacados + */ + public async getFeaturedProducts(storeId: string, limit: number = 8): Promise { + try { + const cacheKey = `featured_products_${storeId}_${limit}` + + // Verificar caché + const cached = this.getCached(cacheKey) + if (cached) { + return cached as ProductContext[] + } + + // Obtener productos destacados desde Amplify + // TODO: Implementar sistema de productos destacados real + // Por ahora, obtener los productos más recientes + const response = await cookiesClient.models.Product.listProductByStoreId( + { storeId }, + { + limit, + } + ) + + if (!response.data) { + return [] + } + + const products = response.data.map(product => this.transformProduct(product)) + + // Guardar en caché + this.setCached(cacheKey, products, this.PRODUCT_CACHE_TTL) + + return products + } catch (error) { + console.error(`Error fetching featured products for store ${storeId}:`, error) + return [] + } + } + + /** + * Transforma un producto de Amplify al formato Liquid + */ + private transformProduct(product: any): ProductContext { + // Crear handle SEO-friendly + const handle = this.createHandle(product.name || product.title || `product-${product.id}`) + + // Formatear precio + const price = this.formatPrice(product.price || 0) + const compareAtPrice = product.compareAtPrice + ? this.formatPrice(product.compareAtPrice) + : undefined + + // Transformar imágenes - pueden venir como string JSON o array + let imagesArray = [] + if (product.images) { + if (typeof product.images === 'string') { + try { + imagesArray = JSON.parse(product.images) + } catch (error) { + console.warn('Error parsing product images JSON:', error) + imagesArray = [] + } + } else if (Array.isArray(product.images)) { + imagesArray = product.images + } + } + + const images = Array.isArray(imagesArray) + ? imagesArray.map((img: any, index: number) => ({ + id: img.id || `img-${index}`, + url: img.url || img.src || '', + alt: img.altText || img.alt || product.name || '', + width: img.width, + height: img.height, + })) + : [] + + // Transformar variantes - pueden venir como string JSON o array + let variantsArray = [] + if (product.variants) { + if (typeof product.variants === 'string') { + try { + variantsArray = JSON.parse(product.variants) + } catch (error) { + console.warn('Error parsing product variants JSON:', error) + variantsArray = [] + } + } else if (Array.isArray(product.variants)) { + variantsArray = product.variants + } + } + + const variants = Array.isArray(variantsArray) + ? variantsArray.map((variant: any) => ({ + id: variant.id, + title: variant.title || variant.name || 'Default', + price: this.formatPrice(variant.price || product.price || 0), + available: (variant.quantity || variant.stock || 0) > 0, + sku: variant.sku, + })) + : [] + + return { + id: product.id, + title: product.name || product.title || '', + description: product.description || '', + handle, + price, + compare_at_price: compareAtPrice, + url: `/products/${handle}`, + images, + variants: + variants.length > 0 + ? variants + : [ + { + id: `${product.id}-default`, + title: 'Default', + price, + available: (product.quantity || product.stock || 0) > 0, + sku: product.sku || '', + }, + ], + tags: (() => { + if (product.tags) { + if (typeof product.tags === 'string') { + try { + const parsed = JSON.parse(product.tags) + return Array.isArray(parsed) ? parsed : [] + } catch (error) { + console.warn('Error parsing product tags JSON:', error) + return [] + } + } else if (Array.isArray(product.tags)) { + return product.tags + } + } + return [] + })(), + available: (product.quantity || product.stock || 0) > 0 && product.isActive !== false, + vendor: product.vendor || product.brand || '', + type: product.category || product.type || 'Product', + } + } + + /** + * Transforma una colección de Amplify al formato Liquid + */ + private async transformCollection(collection: any, storeId: string): Promise { + const handle = this.createHandle( + collection.name || collection.title || `collection-${collection.id}` + ) + + // Obtener productos de la colección si existe relación + const products: ProductContext[] = [] + // TODO: Implementar obtención de productos de colección si existe la relación + + // Transformar imagen de colección + const image = collection.image + ? { + id: collection.image.id || 'collection-img', + url: collection.image.url || collection.image.src || collection.image, + alt: collection.image.alt || collection.name || '', + } + : undefined + + return { + id: collection.id, + title: collection.name || collection.title || '', + description: collection.description || '', + handle, + url: `/collections/${handle}`, + image, + products, + products_count: products.length, + } + } + + /** + * Crea un handle SEO-friendly a partir de un texto + */ + private createHandle(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[áàäâã]/g, 'a') + .replace(/[éèëê]/g, 'e') + .replace(/[íìïî]/g, 'i') + .replace(/[óòöôõ]/g, 'o') + .replace(/[úùüû]/g, 'u') + .replace(/[ñ]/g, 'n') + .replace(/[ç]/g, 'c') + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + } + + /** + * Formatea un precio para mostrar en pesos colombianos + */ + private formatPrice(amount: number): string { + return new Intl.NumberFormat('es-CO', { + style: 'currency', + currency: 'COP', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount) + } + + /** + * Obtiene una entrada del caché si existe y no ha expirado + */ + private getCached(key: string): any | null { + const entry = this.cache[key] + if (!entry) { + return null + } + + const now = Date.now() + if (now > entry.timestamp + entry.ttl) { + delete this.cache[key] + return null + } + + return entry.data + } + + /** + * Guarda una entrada en el caché + */ + private setCached(key: string, data: any, ttl: number): void { + this.cache[key] = { + data, + timestamp: Date.now(), + ttl, + } + } + + /** + * Invalida el caché para una tienda específica + */ + public invalidateStoreCache(storeId: string): void { + Object.keys(this.cache).forEach(key => { + if (key.includes(`_${storeId}_`)) { + delete this.cache[key] + } + }) + } + + /** + * Invalida el caché para un producto específico + */ + public invalidateProductCache(storeId: string, productId: string): void { + const keys = [ + `product_${storeId}_${productId}`, + `products_${storeId}`, + `featured_products_${storeId}`, + ] + + keys.forEach(key => { + Object.keys(this.cache).forEach(cacheKey => { + if (cacheKey.startsWith(key)) { + delete this.cache[cacheKey] + } + }) + }) + } + + /** + * Limpia todo el caché + */ + public clearCache(): void { + this.cache = {} + } + + /** + * Limpia entradas expiradas del caché + */ + public cleanExpiredCache(): void { + const now = Date.now() + Object.keys(this.cache).forEach(key => { + const entry = this.cache[key] + if (now > entry.timestamp + entry.ttl) { + delete this.cache[key] + } + }) + } + + /** + * Obtiene estadísticas del caché para debugging + */ + public getCacheStats(): { total: number; expired: number; active: number } { + const now = Date.now() + let total = 0 + let expired = 0 + let active = 0 + + Object.values(this.cache).forEach(entry => { + total++ + if (now > entry.timestamp + entry.ttl) { + expired++ + } else { + active++ + } + }) + + return { total, expired, active } + } +} + +// Export singleton instance +export const dataFetcher = DataFetcher.getInstance() + +// Export class for testing +export { DataFetcher } diff --git a/lib/store-renderer/services/domain-resolver.ts b/lib/store-renderer/services/domain-resolver.ts new file mode 100644 index 00000000..f433faa4 --- /dev/null +++ b/lib/store-renderer/services/domain-resolver.ts @@ -0,0 +1,214 @@ +import { cookiesClient } from '@/utils/AmplifyServer' +import type { DomainResolution, Store, TemplateError } from '../types' + +interface DomainCache { + [domain: string]: { + data: DomainResolution | null + timestamp: number + ttl: number + } +} + +class DomainResolver { + private static instance: DomainResolver + private cache: DomainCache = {} + private readonly CACHE_TTL = 30 * 60 * 1000 // 30 minutos en ms + + private constructor() {} + + public static getInstance(): DomainResolver { + if (!DomainResolver.instance) { + DomainResolver.instance = new DomainResolver() + } + return DomainResolver.instance + } + + /** + * Resuelve un dominio a información de tienda + * @param domain - El dominio completo (ej: "usuario.fasttify.com") + * @returns DomainResolution o null si no se encuentra + */ + public async resolveDomain(domain: string): Promise { + try { + // Verificar caché primero + const cached = this.getCached(domain) + if (cached !== undefined) { + return cached + } + + // Buscar en Amplify por customDomain + const { data: stores } = await cookiesClient.models.UserStore.listUserStoreByCustomDomain({ + customDomain: domain, + }) + + if (!stores || stores.length === 0) { + // Cachear resultado negativo por menos tiempo (5 minutos) + this.setCached(domain, null, 5 * 60 * 1000) + return null + } + + const store = stores[0] // Debería ser único por dominio + const resolution: DomainResolution = { + storeId: store.storeId, + storeName: store.storeName, + customDomain: store.customDomain || '', + isActive: store.onboardingCompleted && store.storeStatus !== 'inactive', + } + + // Cachear resultado positivo + this.setCached(domain, resolution, this.CACHE_TTL) + return resolution + } catch (error) { + console.error(`Error resolving domain ${domain}:`, error) + + return null + } + } + + /** + * Obtiene la información completa de la tienda por storeId + * @param storeId - ID de la tienda + * @returns Store o null si no se encuentra + */ + public async getStoreById(storeId: string): Promise { + try { + const { data: store } = await cookiesClient.models.UserStore.get({ + storeId: storeId, + }) + + if (!store) { + return null + } + + return store as Store + } catch (error) { + console.error(`Error fetching store ${storeId}:`, error) + + return null + } + } + + /** + * Resuelve un dominio completo: busca y retorna información completa de la tienda + * @param domain - El dominio completo + * @returns Store completa o lanza error + */ + public async resolveStoreByDomain(domain: string): Promise { + const resolution = await this.resolveDomain(domain) + + if (!resolution) { + const error: TemplateError = { + type: 'STORE_NOT_FOUND', + message: `No store found for domain: ${domain}`, + statusCode: 404, + } + throw error + } + + if (!resolution.isActive) { + const error: TemplateError = { + type: 'STORE_NOT_FOUND', + message: `Store is not active for domain: ${domain}`, + statusCode: 503, + } + throw error + } + + const store = await this.getStoreById(resolution.storeId) + + if (!store) { + const error: TemplateError = { + type: 'DATA_ERROR', + message: `Store data not found for ID: ${resolution.storeId}`, + statusCode: 500, + } + throw error + } + + return store + } + + /** + * Invalida el caché para un dominio específico + * @param domain - Dominio a invalidar + */ + public invalidateCache(domain: string): void { + delete this.cache[domain] + } + + /** + * Limpia todo el caché + */ + public clearCache(): void { + this.cache = {} + } + + /** + * Limpia entradas expiradas del caché + */ + public cleanExpiredCache(): void { + const now = Date.now() + Object.keys(this.cache).forEach(domain => { + const entry = this.cache[domain] + if (now > entry.timestamp + entry.ttl) { + delete this.cache[domain] + } + }) + } + + /** + * Obtiene una entrada del caché si existe y no ha expirado + */ + private getCached(domain: string): DomainResolution | null | undefined { + const entry = this.cache[domain] + if (!entry) { + return undefined + } + + const now = Date.now() + if (now > entry.timestamp + entry.ttl) { + delete this.cache[domain] + return undefined + } + + return entry.data + } + + /** + * Guarda una entrada en el caché + */ + private setCached(domain: string, data: DomainResolution | null, ttl: number): void { + this.cache[domain] = { + data, + timestamp: Date.now(), + ttl, + } + } + + /** + * Obtiene estadísticas del caché para debugging + */ + public getCacheStats(): { total: number; expired: number; active: number } { + const now = Date.now() + let total = 0 + let expired = 0 + let active = 0 + + Object.values(this.cache).forEach(entry => { + total++ + if (now > entry.timestamp + entry.ttl) { + expired++ + } else { + active++ + } + }) + + return { total, expired, active } + } +} + +// Export singleton instance +export const domainResolver = DomainResolver.getInstance() + +// Export class for testing +export { DomainResolver } diff --git a/lib/store-renderer/services/template-loader.ts b/lib/store-renderer/services/template-loader.ts new file mode 100644 index 00000000..40897dbd --- /dev/null +++ b/lib/store-renderer/services/template-loader.ts @@ -0,0 +1,360 @@ +import { S3Client, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3' +import type { TemplateFile, TemplateCache, TemplateError } from '../types' +import { cookiesClient } from '@/utils/AmplifyServer' + +interface S3TemplateCache { + [storeId: string]: { + [templatePath: string]: TemplateCache + } +} + +class TemplateLoader { + private static instance: TemplateLoader + private s3Client?: S3Client + private cache: S3TemplateCache = {} + private readonly TEMPLATE_CACHE_TTL = 60 * 60 * 1000 // 1 hora en ms + private readonly bucketName: string + private readonly cloudFrontDomain: string + private readonly appEnv: string + + private constructor() { + this.bucketName = process.env.BUCKET_NAME || '' + this.cloudFrontDomain = process.env.CLOUDFRONT_DOMAIN_NAME || '' + this.appEnv = process.env.APP_ENV || 'development' + + // Solo inicializar S3 si tenemos bucket configurado + if (this.bucketName) { + this.s3Client = new S3Client() + } + } + + public static getInstance(): TemplateLoader { + if (!TemplateLoader.instance) { + TemplateLoader.instance = new TemplateLoader() + } + return TemplateLoader.instance + } + + /** + * Carga una plantilla específica desde S3 + * @param storeId - ID de la tienda + * @param templatePath - Ruta de la plantilla (ej: "layout/theme.liquid") + * @returns Contenido de la plantilla + */ + public async loadTemplate(storeId: string, templatePath: string): Promise { + try { + // Verificar caché primero + const cached = this.getCachedTemplate(storeId, templatePath) + if (cached) { + return cached.content + } + + if (!this.s3Client || !this.bucketName) { + throw new Error('S3 client or bucket not configured') + } + + // Construir la key de S3 + const s3Key = `templates/${storeId}/${templatePath}` + + // Cargar desde S3 + const command = new GetObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }) + + const response = await this.s3Client.send(command) + + if (!response.Body) { + throw new Error(`Template not found: ${templatePath}`) + } + + // Convertir stream a string usando AWS SDK v3 + const content = await response.Body!.transformToString() + + // Guardar en caché + this.setCachedTemplate(storeId, templatePath, content) + + return content + } catch (error) { + console.error(`Error loading template ${templatePath} for store ${storeId}:`, error) + + const templateError: TemplateError = { + type: 'TEMPLATE_NOT_FOUND', + message: `Template not found: ${templatePath}`, + details: error, + statusCode: 404, + } + + throw templateError + } + } + + /** + * Carga todas las plantillas de una tienda + * @param storeId - ID de la tienda + * @returns Array de archivos de plantilla + */ + public async loadAllTemplates(storeId: string): Promise { + try { + // Primero verificar si existe el registro en StoreTemplate + const { data: storeTemplate } = await cookiesClient.models.StoreTemplate.get({ + storeId: storeId, + }) + + if (!storeTemplate || !storeTemplate.isActive) { + throw new Error(`No active templates found for store: ${storeId}`) + } + + // Listar todos los archivos de plantilla en S3 + const prefix = `templates/${storeId}/` + const command = new ListObjectsV2Command({ + Bucket: this.bucketName, + Prefix: prefix, + }) + + const response = await this.s3Client!.send(command) + + if (!response.Contents || response.Contents.length === 0) { + throw new Error(`No template files found for store: ${storeId}`) + } + + // Cargar contenido de cada archivo + const templateFiles: TemplateFile[] = [] + + for (const object of response.Contents) { + if (!object.Key) continue + + // Extraer el path relativo + const relativePath = object.Key.replace(prefix, '') + + try { + const content = await this.loadTemplate(storeId, relativePath) + + templateFiles.push({ + path: relativePath, + content, + contentType: this.getContentType(relativePath), + lastModified: object.LastModified, + }) + } catch (error) { + console.warn(`Failed to load template file: ${relativePath}`, error) + // Continuar con otros archivos + } + } + + if (templateFiles.length === 0) { + throw new Error(`No valid template files found for store: ${storeId}`) + } + + return templateFiles + } catch (error) { + console.error(`Error loading templates for store ${storeId}:`, error) + + const templateError: TemplateError = { + type: 'TEMPLATE_NOT_FOUND', + message: `Templates not found for store: ${storeId}`, + details: error, + statusCode: 404, + } + + throw templateError + } + } + + /** + * Carga una plantilla específica por tipo (layout, section) + * @param storeId - ID de la tienda + * @param templateType - Tipo de plantilla ('layout' | 'sections') + * @param templateName - Nombre de la plantilla + * @returns Contenido de la plantilla + */ + public async loadTemplateByType( + storeId: string, + templateType: 'layout' | 'sections', + templateName: string + ): Promise { + const templatePath = `${templateType}/${templateName}` + return await this.loadTemplate(storeId, templatePath) + } + + /** + * Carga el layout principal de la tienda + * @param storeId - ID de la tienda + * @returns Contenido del layout principal + */ + public async loadMainLayout(storeId: string): Promise { + return await this.loadTemplateByType(storeId, 'layout', 'theme.liquid') + } + + /** + * Carga una sección específica + * @param storeId - ID de la tienda + * @param sectionName - Nombre de la sección (sin extensión) + * @returns Contenido de la sección + */ + public async loadSection(storeId: string, sectionName: string): Promise { + const fileName = sectionName.endsWith('.liquid') ? sectionName : `${sectionName}.liquid` + return await this.loadTemplateByType(storeId, 'sections', fileName) + } + + /** + * Verifica si una tienda tiene plantillas disponibles + * @param storeId - ID de la tienda + * @returns true si tiene plantillas activas + */ + public async hasTemplates(storeId: string): Promise { + try { + const { data: storeTemplate } = await cookiesClient.models.StoreTemplate.get({ + storeId: storeId, + }) + + return !!(storeTemplate && storeTemplate.isActive) + } catch (error) { + console.error(`Error checking templates for store ${storeId}:`, error) + return false + } + } + + /** + * Invalida el caché para una tienda específica + * @param storeId - ID de la tienda + */ + public invalidateStoreCache(storeId: string): void { + delete this.cache[storeId] + } + + /** + * Invalida el caché para una plantilla específica + * @param storeId - ID de la tienda + * @param templatePath - Ruta de la plantilla + */ + public invalidateTemplateCache(storeId: string, templatePath: string): void { + if (this.cache[storeId]) { + delete this.cache[storeId][templatePath] + } + } + + /** + * Limpia todo el caché + */ + public clearCache(): void { + this.cache = {} + } + + /** + * Limpia plantillas expiradas del caché + */ + public cleanExpiredCache(): void { + const now = Date.now() + + Object.keys(this.cache).forEach(storeId => { + const storeCache = this.cache[storeId] + + Object.keys(storeCache).forEach(templatePath => { + const cached = storeCache[templatePath] + if (now > cached.lastUpdated.getTime() + cached.ttl) { + delete storeCache[templatePath] + } + }) + + // Si no quedan plantillas en caché para esta tienda, eliminar la entrada + if (Object.keys(storeCache).length === 0) { + delete this.cache[storeId] + } + }) + } + + /** + * Obtiene una plantilla del caché si existe y es válida + */ + private getCachedTemplate(storeId: string, templatePath: string): TemplateCache | null { + const storeCache = this.cache[storeId] + if (!storeCache) { + return null + } + + const cached = storeCache[templatePath] + if (!cached) { + return null + } + + const now = Date.now() + if (now > cached.lastUpdated.getTime() + cached.ttl) { + delete storeCache[templatePath] + return null + } + + return cached + } + + /** + * Guarda una plantilla en caché + */ + private setCachedTemplate(storeId: string, templatePath: string, content: string): void { + if (!this.cache[storeId]) { + this.cache[storeId] = {} + } + + this.cache[storeId][templatePath] = { + content, + lastUpdated: new Date(), + ttl: this.TEMPLATE_CACHE_TTL, + } + } + + /** + * Determina el content type basado en la extensión del archivo + */ + private getContentType(filename: string): string { + const ext = filename.toLowerCase().split('.').pop() + + const contentTypes: Record = { + html: 'text/html', + css: 'text/css', + js: 'application/javascript', + json: 'application/json', + liquid: 'application/liquid', + txt: 'text/plain', + md: 'text/markdown', + } + + return contentTypes[ext || ''] || 'text/plain' + } + + /** + * Obtiene estadísticas del caché para debugging + */ + public getCacheStats(): { + stores: number + totalTemplates: number + expiredTemplates: number + activeTemplates: number + } { + const now = Date.now() + let stores = 0 + let totalTemplates = 0 + let expiredTemplates = 0 + let activeTemplates = 0 + + Object.values(this.cache).forEach(storeCache => { + stores++ + + Object.values(storeCache).forEach(cached => { + totalTemplates++ + if (now > cached.lastUpdated.getTime() + cached.ttl) { + expiredTemplates++ + } else { + activeTemplates++ + } + }) + }) + + return { stores, totalTemplates, expiredTemplates, activeTemplates } + } +} + +// Export singleton instance +export const templateLoader = TemplateLoader.getInstance() + +// Export class for testing +export { TemplateLoader } diff --git a/lib/store-renderer/types/index.ts b/lib/store-renderer/types/index.ts new file mode 100644 index 00000000..2434a44a --- /dev/null +++ b/lib/store-renderer/types/index.ts @@ -0,0 +1,47 @@ +// Store types +export type { Store, StoreTemplate, StoreConfig, TemplateFiles, DomainResolution } from './store' + +// Product types +export type { + Product, + ProductImage, + ProductVariant, + Collection, + CollectionProduct, + LiquidProduct, + LiquidProductImage, + LiquidProductVariant, + LiquidCollection, +} from './product' + +// Template types +export type { + TemplateFile, + TemplateCache, + RenderContext, + ShopContext, + PageContext, + ProductContext, + CollectionContext, + PaginationContext, + RenderResult, + OpenGraphData, + SchemaData, + TemplateError, +} from './template' + +// Liquid types +export type { + LiquidEngineConfig, + LiquidFilter, + LiquidTag, + LiquidContext, + CompiledTemplate, + MoneyFilter, + ImageFilter, + UrlFilter, + DateFilter, + ProductFormTag, + PaginateTag, + CommentTag, +} from './liquid' diff --git a/lib/store-renderer/types/liquid.ts b/lib/store-renderer/types/liquid.ts new file mode 100644 index 00000000..82074a43 --- /dev/null +++ b/lib/store-renderer/types/liquid.ts @@ -0,0 +1,69 @@ +import { Liquid } from 'liquidjs' + +export interface LiquidEngineConfig { + cache: boolean + greedy: boolean + trimTagLeft: boolean + trimTagRight: boolean + trimOutputLeft: boolean + trimOutputRight: boolean + strictFilters: boolean + strictVariables: boolean + globals: Record +} + +export interface LiquidFilter { + name: string + filter: (...args: any[]) => any +} + +export interface LiquidTag { + name: string + parse: (token: any, remainTokens: any) => void + render: (ctx: any, emitter: any) => any +} + +export interface LiquidContext { + [key: string]: any +} + +export interface CompiledTemplate { + liquid: Liquid + template: any // Plantilla compilada interna de LiquidJS + cacheKey: string + compiledAt: Date +} + +// Filtros específicos para e-commerce +export interface MoneyFilter { + (amount: number | string, format?: string): string +} + +export interface ImageFilter { + (url: string, size?: string): string +} + +export interface UrlFilter { + (path: string, params?: Record): string +} + +export interface DateFilter { + (date: string | Date, format?: string): string +} + +// Tags personalizados para e-commerce +export interface ProductFormTag { + productId: string + action?: string + class?: string +} + +export interface PaginateTag { + collection: any[] + pageSize: number + baseUrl: string +} + +export interface CommentTag { + content: string +} diff --git a/lib/store-renderer/types/product.ts b/lib/store-renderer/types/product.ts new file mode 100644 index 00000000..a39584f8 --- /dev/null +++ b/lib/store-renderer/types/product.ts @@ -0,0 +1,105 @@ +export interface Product { + id: string + storeId: string + name: string + description?: string + price: number + compareAtPrice?: number + sku?: string + barcode?: string + quantity: number + weight?: number + category?: string + tags?: string[] + images?: ProductImage[] + variants?: ProductVariant[] + isActive: boolean + isDigital: boolean + requiresShipping: boolean + createdAt: string + updatedAt: string +} + +export interface ProductImage { + id: string + url: string + altText?: string + position: number +} + +export interface ProductVariant { + id: string + productId: string + title: string + price: number + compareAtPrice?: number + sku?: string + barcode?: string + quantity: number + weight?: number + options: Record // { "Color": "Red", "Size": "L" } +} + +export interface Collection { + id: string + storeId: string + name: string + description?: string + slug: string + image?: string + isActive: boolean + sortOrder?: number + createdAt: string + updatedAt: string +} + +export interface CollectionProduct { + collectionId: string + productId: string + position: number +} + +// Tipos para el contexto de Liquid +export interface LiquidProduct { + id: string + title: string + description: string + price: string // Formateado con moneda + compare_at_price?: string + url: string // URL del producto + images: LiquidProductImage[] + variants: LiquidProductVariant[] + tags: string[] + vendor?: string + type?: string + available: boolean +} + +export interface LiquidProductImage { + id: string + url: string + alt?: string + width?: number + height?: number +} + +export interface LiquidProductVariant { + id: string + title: string + price: string + available: boolean + sku?: string + weight?: number + option1?: string + option2?: string + option3?: string +} + +export interface LiquidCollection { + id: string + title: string + description: string + url: string + image?: LiquidProductImage + products: LiquidProduct[] +} diff --git a/lib/store-renderer/types/store.ts b/lib/store-renderer/types/store.ts new file mode 100644 index 00000000..1d784a4f --- /dev/null +++ b/lib/store-renderer/types/store.ts @@ -0,0 +1,62 @@ +export interface Store { + storeId: string + userId: string + storeName: string + storeDescription?: string + storeLogo?: string + storeFavicon?: string + storeBanner?: string + storeTheme?: string + storeCurrency: string + storeType: string + storeStatus: string + storePolicy?: string + storeAdress?: string + contactEmail?: string + contactPhone?: number + contactName?: string + customDomain: string + onboardingCompleted: boolean + wompiConfig?: string // JSON string + mercadoPagoConfig?: string // JSON string +} + +export interface StoreTemplate { + storeId: string + domain: string + templateKey: string + templateData: string // JSON string con las URLs de archivos + isActive: boolean + lastUpdated?: string + owner: string +} + +export interface StoreConfig { + name: string + description: string + domain: string + currency: string + theme: string + logo?: string + banner?: string + email?: string + phone?: string + address?: string +} + +export interface TemplateFiles { + files: Array<{ key: string; path: string; size: number }> + metadata: { + storeName: string + domain: string + createdAt: string + customizations: Record + } +} + +export interface DomainResolution { + storeId: string + storeName: string + customDomain: string + isActive: boolean +} diff --git a/lib/store-renderer/types/template.ts b/lib/store-renderer/types/template.ts new file mode 100644 index 00000000..36c1d024 --- /dev/null +++ b/lib/store-renderer/types/template.ts @@ -0,0 +1,144 @@ +export interface TemplateFile { + path: string + content: string + contentType: string + lastModified?: Date +} + +export interface TemplateCache { + content: string + compiledTemplate?: any // Plantilla compilada de LiquidJS + lastUpdated: Date + ttl: number +} + +export interface RenderContext { + shop: ShopContext + store: ShopContext // Alias para compatibilidad + page: PageContext + page_title: string + page_description: string + products: any[] + collections: any[] + content_for_layout?: string // Contenido principal de la página + content_for_header?: string // Contenido adicional para el + product?: any // Para páginas de producto + collection?: any // Para páginas de colección + pagination?: any // Para páginas con paginación +} + +export interface ShopContext { + name: string + description: string + domain: string + url: string + currency: string + money_format: string + email?: string + phone?: string + address?: string + logo?: string + favicon?: string + banner?: string + theme: string +} + +export interface PageContext { + title: string + url: string + template: string // 'index', 'product', 'collection' + handle?: string // Slug/handle for SEO friendly URLs +} + +export interface ProductContext { + id: string + title: string + description: string + handle: string // SEO friendly URL slug + price: string + compare_at_price?: string + url: string + images: Array<{ + id: string + url: string + alt?: string + width?: number + height?: number + }> + variants: Array<{ + id: string + title: string + price: string + available: boolean + sku?: string + }> + tags: string[] + available: boolean + vendor?: string + type?: string +} + +export interface CollectionContext { + id: string + title: string + description: string + handle: string + url: string + image?: { + id: string + url: string + alt?: string + } + products: ProductContext[] + products_count: number +} + +export interface PaginationContext { + current_page: number + current_offset: number + total_pages: number + total_items: number + items_per_page: number + previous_page?: number + next_page?: number + parts: Array<{ + title: string + url: string + is_link: boolean + }> +} + +export interface RenderResult { + html: string + metadata: { + title: string + description: string + canonical?: string + openGraph: OpenGraphData + schema: SchemaData + } + cacheKey: string + cacheTTL: number +} + +export interface OpenGraphData { + title: string + description: string + url: string + type: 'website' | 'product' | 'article' + image?: string + site_name: string +} + +export interface SchemaData { + '@context': string + '@type': string + [key: string]: any +} + +export interface TemplateError { + type: 'STORE_NOT_FOUND' | 'TEMPLATE_NOT_FOUND' | 'RENDER_ERROR' | 'DATA_ERROR' + message: string + details?: any + statusCode: number +} diff --git a/template/sections/featured-products.liquid b/template/sections/featured-products.liquid index 427deea8..99b41cea 100644 --- a/template/sections/featured-products.liquid +++ b/template/sections/featured-products.liquid @@ -57,12 +57,12 @@
{% if product.compareAtPrice and product.compareAtPrice > product.price %} - ${{ product.price }} + {{ product.price }} ${{ product.compareAtPrice -}} + >{{ product.compareAtPrice -}} {% else %} - ${{ product.price }} + {{ product.price }} {% endif %}
diff --git a/utils/AmplifyServer.ts b/utils/AmplifyServer.ts new file mode 100644 index 00000000..4675fa88 --- /dev/null +++ b/utils/AmplifyServer.ts @@ -0,0 +1,28 @@ +import { cookies } from 'next/headers' +import { createServerRunner } from '@aws-amplify/adapter-nextjs' +import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api' +import { getCurrentUser } from 'aws-amplify/auth/server' +import { type Schema } from '@/amplify/data/resource' +import outputs from '@/amplify_outputs.json' + +export const { runWithAmplifyServerContext } = createServerRunner({ + config: outputs, +}) + +export const cookiesClient = generateServerClientUsingCookies({ + config: outputs, + cookies, + authMode: 'apiKey', +}) + +export async function AuthGetCurrentUserServer() { + try { + const currentUser = await runWithAmplifyServerContext({ + nextServerContext: { cookies }, + operation: contextSpec => getCurrentUser(contextSpec), + }) + return currentUser + } catch (error) { + console.error(error) + } +}