diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..a82d3532 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,18 @@ +# Output/build/cache +.next +node_modules +coverage +build +dist + +# Infra/large directories not needed for app linting +amplify +.amplify +public +template +scripts +lambda-edge-host-rewriter + +# Optional: ignore plain JS assets under renderer engine +renderer-engine/liquid/**/*.js + diff --git a/.vscode/settings.json b/.vscode/settings.json index 764f930e..ac1997f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,38 @@ "editor.codeActionsOnSave": { "source.organizeImports": "always" } - } + }, + "files.watcherExclude": { + "**/.git/**": true, + "**/node_modules/**": true, + "**/.next/**": true, + "**/amplify/**": true, + "**/.amplify/**": true, + "**/public/**": true, + "**/template/**": true, + "**/coverage/**": true, + "**/dist/**": true, + "**/build/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/.next": true, + "**/amplify": true, + "**/.amplify": true, + "**/public": true, + "**/template": true, + "**/coverage": true, + "**/dist": true, + "**/build": true + }, + "typescript.tsserver.maxTsServerMemory": 4096, + "typescript.tsserver.experimental.enableProjectDiagnostics": false, + "typescript.tsserver.watchOptions": { + "watchFile": "priorityPollingInterval", + "watchDirectory": "dynamicPriorityPolling", + "fallbackPolling": "dynamicPriorityPolling" + }, + "eslint.workingDirectories": [ + { "mode": "auto" } + ] } \ No newline at end of file diff --git a/amplify/data/resource.ts b/amplify/data/resource.ts index 353e3fca..808208d1 100644 --- a/amplify/data/resource.ts +++ b/amplify/data/resource.ts @@ -99,7 +99,6 @@ const schema = a .authorization((allow) => [allow.authenticated()]) .handler(a.handler.function(createStoreTemplate)), - // Nueva mutación para procesar la configuración de pagos processStorePaymentConfig: a .mutation() .arguments({ action: a.string(), input: PaymentConfigInput }) diff --git a/app/api/stores/[storeId]/themes/confirm/route.ts b/app/api/stores/[storeId]/themes/confirm/route.ts index d563b351..99b263f7 100644 --- a/app/api/stores/[storeId]/themes/confirm/route.ts +++ b/app/api/stores/[storeId]/themes/confirm/route.ts @@ -262,15 +262,16 @@ async function processThemeInBackground( settings: JSON.stringify(processedTheme.settings), validation: JSON.stringify(themeData.validation), analysis: JSON.stringify(themeData.analysis), - preview: themeData.theme.preview, + preview: storageResult.previewCdnUrl || themeData.theme.preview, owner: username, }; let savedThemeId: string | undefined = themeId; if (themeId) { + const { owner: _omitOwner, ...updatePayload } = themeRecord as any; const { data: updated, errors: updateErrors } = await cookiesClient.models.UserTheme.update({ id: themeId, - ...themeRecord, + ...updatePayload, } as any); if (updateErrors) { logger.error('Failed to update placeholder theme', { processId, errors: updateErrors }, 'ThemeConfirmAPI'); diff --git a/app/api/stores/[storeId]/themes/route.ts b/app/api/stores/[storeId]/themes/route.ts index 0058b8e9..8410117b 100644 --- a/app/api/stores/[storeId]/themes/route.ts +++ b/app/api/stores/[storeId]/themes/route.ts @@ -64,6 +64,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ version: theme.version, author: theme.author, description: theme.description, + previewUrl: theme.preview, fileCount: theme.fileCount, totalSize: theme.totalSize, isActive: theme.isActive, diff --git a/app/api/stores/[storeId]/themes/upload/route.ts b/app/api/stores/[storeId]/themes/upload/route.ts index 4397283d..4d9dd62f 100644 --- a/app/api/stores/[storeId]/themes/upload/route.ts +++ b/app/api/stores/[storeId]/themes/upload/route.ts @@ -91,8 +91,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ processedTheme.validation = validation; processedTheme.analysis = validation.analysis as TemplateAnalysis; - // 7. Generar preview si la validación es exitosa - if (validation.isValid) { + // 7. Generar preview solo si no existe y la validación es exitosa + if (validation.isValid && !processedTheme.preview) { processedTheme.preview = await processor.generatePreview(processedTheme); } @@ -123,6 +123,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ settings: processedTheme.settings, validation: processedTheme.validation, analysis: processedTheme.analysis, + previewUrl: processedTheme.preview, }, validation: { isValid: validation.isValid, diff --git a/app/api/stores/template/route.ts b/app/api/stores/template/route.ts index d1c6e58e..2f2976c7 100644 --- a/app/api/stores/template/route.ts +++ b/app/api/stores/template/route.ts @@ -140,6 +140,9 @@ export async function POST(request: NextRequest) { } } + // Resolver preview URL buscando un asset conocido o usando la del schema + const previewUrl = findPreviewUrlFromTemplateUrls(templateUrls) || themeInfo.previewUrl || null + // 7. Crear registro del tema en la DB con la información validada try { const s3FolderKey = `templates/${storeId}` @@ -166,7 +169,7 @@ export async function POST(request: NextRequest) { }), validation: JSON.stringify(validation), analysis: JSON.stringify(validation.analysis || {}), - preview: templateUrls['layout/theme.liquid'] || null, + preview: previewUrl, owner: user.username, } @@ -298,3 +301,35 @@ function generateTemplateUrls( function sanitizeMetadataValue(value: string): string { return value.replace(/[^\x00-\x7F]/g, '') } + +// Busca una URL de preview dentro de los archivos copiados del template +function findPreviewUrlFromTemplateUrls(urls: Record): string | undefined { + const candidates = [ + 'assets/preview.png', + 'assets/preview.jpg', + 'assets/preview.webp', + 'assets/screenshot.png', + 'assets/screenshot.jpg', + 'assets/screenshot.webp', + 'preview.png', + 'preview.jpg', + 'preview.webp', + 'screenshot.png', + 'screenshot.jpg', + 'screenshot.webp', + ] + + // Intentar coincidencia exacta por clave + for (const name of candidates) { + if (urls[name]) return urls[name] + } + + // Si no está exacto, buscar por sufijo en claves (por si vienen en subcarpetas) + const keys = Object.keys(urls) + for (const key of keys) { + if (candidates.some((c) => key.endsWith('/' + c) || key.toLowerCase().endsWith('/' + c))) { + return urls[key] + } + } + return undefined +} diff --git a/app/store/components/store-config/components/ThemePreview.tsx b/app/store/components/store-config/components/ThemePreview.tsx index ea1dae57..43a5a903 100644 --- a/app/store/components/store-config/components/ThemePreview.tsx +++ b/app/store/components/store-config/components/ThemePreview.tsx @@ -1,16 +1,36 @@ import { LogoUploader } from '@/app/store/components/store-config/components/LogoUploader'; import { ThemeUploader } from '@/app/store/components/store-config/components/ThemeUploader'; import { ThemeList } from '@/app/store/components/theme-management/components/ThemeList'; +import { useThemeList } from '@/app/store/components/theme-management/hooks/useThemeList'; import useStoreDataStore from '@/context/core/storeDataStore'; -import { Badge, BlockStack, Button, ButtonGroup, Card, Layout, MediaCard, Page, Tabs, Text } from '@shopify/polaris'; +import { + Badge, + BlockStack, + Button, + ButtonGroup, + Card, + Layout, + MediaCard, + Page, + Spinner, + Tabs, + Text, +} from '@shopify/polaris'; import { MoneyFilledIcon } from '@shopify/polaris-icons'; import Image from 'next/image'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; export function ThemePreview() { const { currentStore } = useStoreDataStore(); const customDomain = currentStore?.defaultDomain || ''; const [selectedTab, setSelectedTab] = useState(0); + const storeId = currentStore?.storeId || ''; + const { themes, loading } = useThemeList(storeId); + + // Encontrar el tema activo y su preview URL (HTTPS o data URI si aún no se propaga) + const activeTheme = useMemo(() => themes.find((t: any) => t.isActive) || themes[0], [themes]); + const activePreviewUrl = activeTheme?.previewUrl; + const isLoadingPreview = !storeId || loading || themes.length === 0; const viewStore = `https://${customDomain}`; @@ -40,19 +60,40 @@ export function ThemePreview() { Tema Actual
- Amazonas theme preview + {isLoadingPreview ? ( +
+ +
+ ) : activePreviewUrl ? ( + {`Vista + ) : ( + Theme preview placeholder + )}
- Amazonas + {activeTheme?.name || 'Tema'} diff --git a/app/store/components/theme-management/hooks/useThemeList.ts b/app/store/components/theme-management/hooks/useThemeList.ts index 26aa3cad..23bc0669 100644 --- a/app/store/components/theme-management/hooks/useThemeList.ts +++ b/app/store/components/theme-management/hooks/useThemeList.ts @@ -13,6 +13,7 @@ interface Theme { totalSize: number; createdAt: string; updatedAt: string; + previewUrl?: string; } interface UseThemeListReturn { diff --git a/docs/engine/theme-preview.md b/docs/engine/theme-preview.md new file mode 100644 index 00000000..f177c8d7 --- /dev/null +++ b/docs/engine/theme-preview.md @@ -0,0 +1,84 @@ +# Guía: Cómo definir la vista previa (preview) de tu tema + +Esta guía explica cómo los desarrolladores de temas deben proveer la imagen de vista previa para que Fasttify la detecte automáticamente, la publique en CDN y se muestre en el panel de administración. + +## Opción recomendada: archivo dentro del ZIP del tema + +Coloca una imagen de preview dentro de tu tema antes de comprimirlo. Rutas soportadas (se detectan en cualquier subcarpeta del tema): + +- assets/preview.png +- assets/preview.jpg +- assets/preview.webp +- assets/screenshot.png +- assets/screenshot.jpg +- assets/screenshot.webp +- preview.png / preview.jpg / preview.webp +- screenshot.png / screenshot.jpg / screenshot.webp + +Ejemplo de estructura: + +``` +my-theme/ + layout/theme.liquid + templates/index.json + sections/header.liquid + assets/preview.png ← recomendado + config/settings_schema.json + ... +``` + +Al subir el ZIP: + +- El sistema sube tus archivos a `templates/{storeId}/...` en S3/CloudFront. +- Genera `metadata.json` con `previewUrl` apuntando al archivo (CDN). +- El panel mostrará esa URL en la lista de temas y en la vista de “Diseño”. + +## Opción alternativa: URL en settings_schema.json + +Si no incluyes archivo, puedes declarar una URL en `config/settings_schema.json`. Se lee desde la sección `theme_info`: + +```json +[ + { + "name": "theme_info", + "theme_name": "Mi Tema", + "theme_author": "Mi Estudio", + "theme_version": "1.0.0", + "preview_url": "https://cdn.mi-cuenta.com/previews/mi-tema.png" + } +] +``` + +Notas: + +- Se admiten `preview_url` o `previewUrl`. +- Si existe un archivo de preview en el ZIP, ese tiene prioridad para la URL de CDN. + +## Recomendaciones de tamaño y formato + +- Formato: PNG o WEBP (recomendado WEBP por peso). +- Dimensiones sugeridas: 1600×900 o 1920×1080. +- Peso recomendado: < 500 KB (el validador marca warning si las imágenes son muy pesadas). +- Evita metadatos EXIF innecesarios y usa compresión. + +## Comportamiento del sistema + +- Upload de tema: se detecta el preview y se publica en CDN. +- Confirmación/almacenamiento: se escribe `metadata.json` con `previewUrl`. +- Panel (lista de temas y “Diseño”): consume `previewUrl` y muestra un loader mientras carga. +- Si inicialmente ves un data URI, espera 30–60 s mientras finaliza la escritura de metadata/propagación CDN. + +## Troubleshooting + +- No veo la preview: + - Verifica que la ruta del archivo sea una de las soportadas y que el archivo exista en el ZIP. + - Asegura extensiones .png/.jpg/.webp. + - Revisa `templates/{storeId}/metadata.json` y confirma que tenga `previewUrl`. + - Espera ~1–2 minutos por propagación de CDN si es la primera carga. +- El validador advierte por tamaño de imagen: + - Optimiza la imagen (usa WEBP, reduce dimensiones o compresión) para mejorar rendimiento. + +## Preguntas frecuentes + +- ¿Puedo usar una URL externa? Sí, con `preview_url` en `settings_schema.json`. Sin embargo, recomendamos empaquetar el preview en `assets/` para un flujo totalmente integrado con nuestro CDN. +- ¿La preview es obligatoria? No, pero es altamente recomendada para una buena UX en el panel. diff --git a/renderer-engine/liquid/filters.ts b/renderer-engine/liquid/filters.ts index 20eaa851..2427168c 100644 --- a/renderer-engine/liquid/filters.ts +++ b/renderer-engine/liquid/filters.ts @@ -1,20 +1,20 @@ // Re-exportar todos los filtros desde sus módulos específicos -export { baseFilters } from '@/renderer-engine/liquid/filters/base-filters'; -export { cartFilters } from '@/renderer-engine/liquid/filters/cart-filters'; -export { dataAccessFilters } from '@/renderer-engine/liquid/filters/data-access-filters'; -export { ecommerceFilters } from '@/renderer-engine/liquid/filters/ecommerce-filters'; -export { htmlFilters } from '@/renderer-engine/liquid/filters/html-filters'; -export { moneyFilters } from '@/renderer-engine/liquid/filters/money-filters'; +export { baseFilters } from './filters/base-filters'; +export { cartFilters } from './filters/cart-filters'; +export { dataAccessFilters } from './filters/data-access-filters'; +export { ecommerceFilters } from './filters/ecommerce-filters'; +export { htmlFilters } from './filters/html-filters'; +export { moneyFilters } from './filters/money-filters'; // Importar todos los filtros para el array principal -import { baseFilters } from '@/renderer-engine/liquid/filters/base-filters'; -import { cartFilters } from '@/renderer-engine/liquid/filters/cart-filters'; -import { dataAccessFilters } from '@/renderer-engine/liquid/filters/data-access-filters'; -import { ecommerceFilters } from '@/renderer-engine/liquid/filters/ecommerce-filters'; -import { htmlFilters } from '@/renderer-engine/liquid/filters/html-filters'; -import { moneyFilters } from '@/renderer-engine/liquid/filters/money-filters'; +import { baseFilters } from './filters/base-filters'; +import { cartFilters } from './filters/cart-filters'; +import { dataAccessFilters } from './filters/data-access-filters'; +import { ecommerceFilters } from './filters/ecommerce-filters'; +import { htmlFilters } from './filters/html-filters'; +import { moneyFilters } from './filters/money-filters'; -import type { LiquidFilter } from '@/renderer-engine/types'; +import type { LiquidFilter } from '../types'; /** * Array con todos los filtros para registrar en el motor Liquid @@ -28,5 +28,4 @@ export const allFilters: LiquidFilter[] = [ ...dataAccessFilters, ]; -// Mantener compatibilidad hacia atrás export const ecommerceFiltersLegacy = allFilters; diff --git a/renderer-engine/services/themes/core/theme-processor.ts b/renderer-engine/services/themes/core/theme-processor.ts index ebdbe9bb..edb7ed58 100644 --- a/renderer-engine/services/themes/core/theme-processor.ts +++ b/renderer-engine/services/themes/core/theme-processor.ts @@ -259,6 +259,7 @@ export class ThemeProcessor { license: themeInfo.license, settings_schema: themeInfo.settings_schema || [], settings_defaults: themeInfo.settings_defaults || {}, + previewUrl: themeInfo.previewUrl, }; } catch (error) { throw new Error(`Failed to parse theme settings: ${error instanceof Error ? error.message : 'Unknown error'}`); diff --git a/renderer-engine/services/themes/storage/s3-storage-service.ts b/renderer-engine/services/themes/storage/s3-storage-service.ts index 8a62625a..77c3e22f 100644 --- a/renderer-engine/services/themes/storage/s3-storage-service.ts +++ b/renderer-engine/services/themes/storage/s3-storage-service.ts @@ -14,6 +14,7 @@ export interface ThemeStorageResult { storeId: string; s3Key: string; cdnUrl?: string; + previewCdnUrl?: string; error?: string; } @@ -75,12 +76,21 @@ export class S3StorageService { } } - // 4. Generar metadata final del tema + // 4. Resolver preview del tema (si existe en los archivos) + const previewFile = this.findPreviewFile(theme.files || []); + let previewCdnUrl: string | undefined = undefined; + if (previewFile) { + const previewKey = this.buildS3KeyForFile(previewFile.path, baseKey); + previewCdnUrl = getCdnUrlForKey(previewKey); + } + + // 5. Generar metadata final del tema const finalMetadata = { ...this.generateThemeMetadata(theme, storeId), status: 'ready', stage: 'completed', updatedAt: new Date().toISOString(), + previewUrl: previewCdnUrl || theme.settings.previewUrl || undefined, }; const metadataResult = await this.uploadJson(finalMetadata, metadataKey); @@ -88,7 +98,7 @@ export class S3StorageService { throw new Error(`Failed to upload metadata: ${metadataResult.error}`); } - // 5. Generar URL de CDN + // 6. Generar URL de CDN const cdnUrl = getCdnUrlForKey(zipKey); const result: ThemeStorageResult = { @@ -96,6 +106,7 @@ export class S3StorageService { storeId, s3Key: baseKey, cdnUrl, + previewCdnUrl, }; this.logger.info('Theme stored successfully in S3', result, 'S3StorageService'); @@ -193,27 +204,7 @@ export class S3StorageService { private async uploadThemeFiles(files: ThemeFile[], baseKey: string): Promise<{ success: boolean; error?: string }> { try { const uploadPromises = files.map(async (file) => { - // Organizar archivos por tipo en carpetas separadas - let fileKey: string; - - if (file.path.includes('/layout/')) { - fileKey = `${baseKey}/layout/${file.path.split('/layout/')[1]}`; - } else if (file.path.includes('/templates/')) { - fileKey = `${baseKey}/templates/${file.path.split('/templates/')[1]}`; - } else if (file.path.includes('/sections/')) { - fileKey = `${baseKey}/sections/${file.path.split('/sections/')[1]}`; - } else if (file.path.includes('/snippets/')) { - fileKey = `${baseKey}/snippets/${file.path.split('/snippets/')[1]}`; - } else if (file.path.includes('/assets/')) { - fileKey = `${baseKey}/assets/${file.path.split('/assets/')[1]}`; - } else if (file.path.includes('/config/')) { - fileKey = `${baseKey}/config/${file.path.split('/config/')[1]}`; - } else if (file.path.includes('/locales/')) { - fileKey = `${baseKey}/locales/${file.path.split('/locales/')[1]}`; - } else { - // Archivos en la raíz - fileKey = `${baseKey}/root/${file.path}`; - } + const fileKey = this.buildS3KeyForFile(file.path, baseKey); const content = file.content instanceof Buffer ? file.content : Buffer.from(file.content as string); return this.uploadFile(content, fileKey); @@ -283,6 +274,59 @@ export class S3StorageService { }; } + /** + * Construye la clave S3 a partir de la ruta del archivo de tema + */ + private buildS3KeyForFile(path: string, baseKey: string): string { + if (path.includes('/layout/')) { + return `${baseKey}/layout/${path.split('/layout/')[1]}`; + } + if (path.includes('/templates/')) { + return `${baseKey}/templates/${path.split('/templates/')[1]}`; + } + if (path.includes('/sections/')) { + return `${baseKey}/sections/${path.split('/sections/')[1]}`; + } + if (path.includes('/snippets/')) { + return `${baseKey}/snippets/${path.split('/snippets/')[1]}`; + } + if (path.includes('/assets/')) { + return `${baseKey}/assets/${path.split('/assets/')[1]}`; + } + if (path.includes('/config/')) { + return `${baseKey}/config/${path.split('/config/')[1]}`; + } + if (path.includes('/locales/')) { + return `${baseKey}/locales/${path.split('/locales/')[1]}`; + } + return `${baseKey}/root/${path}`; + } + + /** + * Busca un archivo de preview dentro del tema + */ + private findPreviewFile(files: ThemeFile[]): ThemeFile | undefined { + const candidates = [ + 'assets/preview.png', + 'assets/preview.jpg', + 'assets/preview.webp', + 'assets/screenshot.png', + 'assets/screenshot.jpg', + 'assets/screenshot.webp', + 'preview.png', + 'preview.jpg', + 'preview.webp', + 'screenshot.png', + 'screenshot.jpg', + 'screenshot.webp', + ]; + + // Coincidencia por nombre o fin de ruta para soportar subcarpetas del tema + return files.find( + (f) => f.type === 'image' && candidates.some((name) => f.path === name || f.path.endsWith('/' + name)) + ); + } + /** * Obtiene un tema desde S3 */ diff --git a/renderer-engine/services/themes/types/theme-types.ts b/renderer-engine/services/themes/types/theme-types.ts index f87d0aa3..84d14ad1 100644 --- a/renderer-engine/services/themes/types/theme-types.ts +++ b/renderer-engine/services/themes/types/theme-types.ts @@ -40,6 +40,7 @@ export interface ThemeSettings { license?: string; settings_schema: any[]; settings_defaults: Record; + previewUrl?: string; } export interface ProcessedTheme { diff --git a/renderer-engine/tsconfig.json b/renderer-engine/tsconfig.json new file mode 100644 index 00000000..76939ab5 --- /dev/null +++ b/renderer-engine/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "..", + "paths": { + "@/*": ["./*"] + }, + "noEmit": true, + "isolatedModules": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["../node_modules", "../.next", "../dist", "../build"] +} diff --git a/template/assets/preview.png b/template/assets/preview.png new file mode 100644 index 00000000..d6c66a32 Binary files /dev/null and b/template/assets/preview.png differ diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..0ab147d3 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["jest", "node", "@testing-library/jest-dom"], + "jsx": "react-jsx", + "module": "commonjs", + "moduleResolution": "node", + "isolatedModules": false, + "noEmit": true + }, + "paths": { + "@/*": ["./*"] + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": [] +} diff --git a/tsconfig.json b/tsconfig.json index 9f2ac4d6..16bd1124 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, + "allowJs": false, "skipLibCheck": true, "strict": true, "noEmit": true, @@ -23,12 +23,34 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "app/**/*", + "components/**/*", + "context/**/*", + "hooks/**/*", + "lib/**/*", + "middlewares/**/*", + "renderer-engine/**/*", + "tenant-domains/**/*", + "types/**/*", + "utils/**/*", + "middleware.ts" + ], "exclude": [ "node_modules", + ".next/cache/**/*", "amplify/**/*", ".amplify/**/*", "template/**/*", - "lambda-edge-host-rewriter/**/*-host-rewriter/**/*" + "public/**/*", + "scripts/**/*", + "coverage/**/*", + "dist/**/*", + "build/**/*", + "test/**/*", + "lambda-edge-host-rewriter/**/*", + "docs/**/*" ] }