diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cde906f4..1ae58cf2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 10.18.0 + version: 10.20.0 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index b79f0f76..023e9bf6 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -24,7 +24,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 10.18.0 + version: 10.20.0 - name: Configure Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index b42d4f07..0c2fd749 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -22,7 +22,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 10.18.0 + version: 10.20.0 - name: setup node uses: actions/setup-node@v4 diff --git a/LICENSE b/LICENSE index f565a68c..6f8cd542 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,7 @@ + Portions of this software are derived from Shopify Polaris (https://polaris.shopify.com) + Copyright (c) Shopify Inc. + Licensed under the MIT License. + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -177,15 +181,6 @@ APPENDIX: How to apply the Apache License to your work. - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - Copyright 2025 Fasttify LLC Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 00000000..31fa1726 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,24 @@ +# Notice + +This project uses components and/or design resources from **Shopify Polaris**, +an open-source design system licensed under the **MIT License**. + +--- + +## Copyright and License + +**Shopify Polaris** +Copyright (c) Shopify Inc. +Released under the MIT License. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +This project is **not affiliated with, endorsed by, or sponsored by Shopify Inc.** +All trademarks and brand names are the property of their respective owners. diff --git a/amplify.yml b/amplify.yml index 1d31aacf..bab6ed75 100644 --- a/amplify.yml +++ b/amplify.yml @@ -5,7 +5,7 @@ backend: commands: - export NODE_OPTIONS='--max-old-space-size=7000' - node -e "console.log('Total available heap size (MB):', v8.getHeapStatistics().heap_size_limit / 1024 / 1024)" - - npm install -g pnpm@10.18.0 + - npm install -g pnpm@10.20.0 - pnpm install --frozen-lockfile - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID @@ -15,7 +15,7 @@ frontend: commands: - export NODE_OPTIONS='--max-old-space-size=7000' - node -e "console.log('Total available heap size (MB):', v8.getHeapStatistics().heap_size_limit / 1024 / 1024)" - - npm install -g pnpm@10.18.0 + - npm install -g pnpm@10.20.0 - env | grep -e BUCKET_NAME >> .env.production - env | grep -e CLOUDFRONT_DOMAIN_NAME >> .env.production - env | grep -e APP_URL >> .env.production diff --git a/app/[store]/page.tsx b/app/[store]/page.tsx index 15290f32..764a802e 100644 --- a/app/[store]/page.tsx +++ b/app/[store]/page.tsx @@ -1,6 +1,7 @@ import DevAutoReloadScript from '@/app/[store]/src/components/DevAutoReloadScript'; import { StorePageController } from '@/app/[store]/src/_lib/controllers/store-page-controller'; import { StoreMetadataController } from '@/app/[store]/src/_lib/controllers/store-metadata-controller'; +import { PreviewPageController } from '@/app/[store]/src/_lib/controllers/preview-page-controller'; import { isAssetPath } from '@/app/[store]/src/lib/store-page-utils'; import { Metadata } from 'next'; @@ -11,6 +12,7 @@ interface StorePageProps { store: string; }>; searchParams: Promise<{ + domain?: string; path?: string; [key: string]: string | string[] | undefined; }>; @@ -19,12 +21,40 @@ interface StorePageProps { /** * Página principal de tienda con SSR * Maneja todas las rutas de tienda: /, /products/slug, /collections/slug, etc + * También maneja /preview?domain=... para previews del Theme Studio * * Esta página solo orquesta la llamada al controller, delegando toda la lógica */ export default async function StorePage(props: StorePageProps) { const controller = new StorePageController(); + const awaitedParams = await props.params; + const awaitedSearchParams = await props.searchParams; + // Si la ruta es /preview, usar el controlador de preview + if (awaitedParams.store === 'preview' && awaitedSearchParams.domain) { + const previewController = new PreviewPageController(); + const domainParam = Array.isArray(awaitedSearchParams.domain) + ? awaitedSearchParams.domain[0] + : awaitedSearchParams.domain; + const path = Array.isArray(awaitedSearchParams.path) + ? awaitedSearchParams.path[0] || '/' + : awaitedSearchParams.path || '/'; + + const result = await previewController.handle({ + domain: domainParam, + path, + searchParams: awaitedSearchParams, + }); + + return ( + <> + {result.html &&
} + {process.env.NODE_ENV === 'development' && } + + ); + } + + // Comportamiento normal para rutas de tienda let html: string | null = null; let errorHtml: string | null = null; @@ -52,7 +82,17 @@ export default async function StorePage(props: StorePageProps) { * Genera metadata SEO para la página */ export async function generateMetadata(props: StorePageProps): Promise { - const { path } = await props.searchParams; + const awaitedParams = await props.params; + const awaitedSearchParams = await props.searchParams; + const { path } = awaitedSearchParams; + + // Si es preview, no generar metadata (devolver valores por defecto) + if (awaitedParams.store === 'preview') { + return { + title: 'Preview', + description: 'Theme Studio Preview', + }; + } if (path && isAssetPath(path)) { return { diff --git a/app/[store]/src/_lib/controllers/preview-page-controller.ts b/app/[store]/src/_lib/controllers/preview-page-controller.ts new file mode 100644 index 00000000..7fe7c4d0 --- /dev/null +++ b/app/[store]/src/_lib/controllers/preview-page-controller.ts @@ -0,0 +1,38 @@ +import { getCachedRenderResult } from '@/app/[store]/src/lib/store-page-utils'; + +interface PreviewPageProps { + domain: string; + path: string; + searchParams: Record; +} + +/** + * Controlador para manejar las páginas de preview del Theme Studio + * Renderiza directamente usando el dominio sin pasar por resolveDomainFromParam + */ +export class PreviewPageController { + async handle(props: PreviewPageProps) { + const { domain, path, searchParams } = props; + + // Validar que el dominio no sea "preview" + if (!domain || domain === 'preview' || domain.startsWith('preview.')) { + throw new Error('Invalid domain for preview. Domain must be provided.'); + } + + // Decodificar el dominio si viene codificado + const decodedDomain = decodeURIComponent(domain); + + // Validar que el dominio decodificado tenga el formato correcto + if (!decodedDomain.includes('.')) { + throw new Error(`Invalid domain format: ${decodedDomain}`); + } + + // Renderizar directamente usando el dominio + const result = await getCachedRenderResult(decodedDomain, path, searchParams); + + return { + html: result.html, + metadata: result.metadata, + }; + } +} diff --git a/app/[store]/src/_lib/services/domain.service.ts b/app/[store]/src/_lib/services/domain.service.ts index 72a3135e..4329e750 100644 --- a/app/[store]/src/_lib/services/domain.service.ts +++ b/app/[store]/src/_lib/services/domain.service.ts @@ -6,10 +6,16 @@ import { DOMAIN_CONFIG, COMMON_ASSETS } from '@/app/[store]/src/_lib/constants/s export class DomainService { /** * Resuelve el dominio de la tienda a partir del parámetro store + * Maneja tanto parámetros codificados como sin codificar */ resolveDomainFromParam(store: string): string { const { BASE_DOMAIN } = DOMAIN_CONFIG; - return store.includes('.') ? store : `${store}.${BASE_DOMAIN}`; + + // Decodificar el parámetro si viene codificado (ej: tienda-695a7d7%2Efasttify%2Ecom) + const decodedStore = decodeURIComponent(store); + + // Si el store decodificado tiene puntos, ya es un dominio completo + return decodedStore.includes('.') ? decodedStore : `${decodedStore}.${BASE_DOMAIN}`; } /** diff --git a/app/api/stores/[storeId]/dev/update/route.ts b/app/api/stores/[storeId]/dev/update/route.ts new file mode 100644 index 00000000..a07bb32c --- /dev/null +++ b/app/api/stores/[storeId]/dev/update/route.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { withAuthHandler } from '@/api/_lib/auth-middleware'; +import { TemplateLoaderAdapter } from '@/app/api/stores/_lib/dev-server/infrastructure/adapters/template-loader.adapter'; +import { SectionRendererAdapter } from '@/app/api/stores/_lib/dev-server/infrastructure/adapters/section-renderer.adapter'; +import { UpdateSectionSettingUseCase } from '@/app/api/stores/_lib/dev-server/application/use-cases/update-section-setting.use-case'; +import { UpdateBlockSettingUseCase } from '@/app/api/stores/_lib/dev-server/application/use-cases/update-block-setting.use-case'; +import { UpdateSubBlockSettingUseCase } from '@/app/api/stores/_lib/dev-server/application/use-cases/update-sub-block-setting.use-case'; +import { devSessionManager } from '@/app/api/stores/_lib/dev-server/infrastructure/services/dev-session-manager.service'; +import { + broadcastChangeApplied, + broadcastRenderError, +} from '@/app/api/stores/_lib/dev-server/controllers/sse-controller'; + +const templateLoader = new TemplateLoaderAdapter(); +const sectionRenderer = new SectionRendererAdapter(); +const updateSectionSettingUseCase = new UpdateSectionSettingUseCase(templateLoader, sectionRenderer); +const updateBlockSettingUseCase = new UpdateBlockSettingUseCase(templateLoader, sectionRenderer); +const updateSubBlockSettingUseCase = new UpdateSubBlockSettingUseCase(templateLoader, sectionRenderer); + +/** + * Endpoint POST para recibir actualizaciones de settings desde el cliente + * Procesa el cambio y envía la actualización por SSE + */ +export const POST = withAuthHandler( + async (request: NextRequest, { storeId, corsHeaders }) => { + try { + const body = await request.json(); + const { type, payload, templateType } = body; + + if (!templateType) { + return NextResponse.json({ error: 'Missing templateType' }, { status: 400, headers: corsHeaders }); + } + + // Obtener o crear sesión + let session = devSessionManager.getSession(storeId, templateType); + if (!session) { + // Cargar template inicial y crear sesión + const template = await templateLoader.loadTemplate(storeId, templateType); + session = devSessionManager.createOrGetSession(storeId, templateType, template); + } + + let result; + + switch (type) { + case 'UPDATE_SECTION_SETTING': { + result = await updateSectionSettingUseCase.execute(payload, templateType, session.template); + if (result.updatedTemplate) { + devSessionManager.updateSessionTemplate(storeId, templateType, result.updatedTemplate); + } + break; + } + + case 'UPDATE_BLOCK_SETTING': { + result = await updateBlockSettingUseCase.execute(payload, templateType, session.template); + if (result.updatedTemplate) { + devSessionManager.updateSessionTemplate(storeId, templateType, result.updatedTemplate); + } + break; + } + + case 'UPDATE_SUB_BLOCK_SETTING': { + result = await updateSubBlockSettingUseCase.execute(payload, templateType, session.template); + if (result.updatedTemplate) { + devSessionManager.updateSessionTemplate(storeId, templateType, result.updatedTemplate); + } + break; + } + + default: + return NextResponse.json({ error: 'Invalid message type' }, { status: 400, headers: corsHeaders }); + } + + // Enviar resultado a través de SSE + if (result.success) { + broadcastChangeApplied(storeId, templateType, result); + } else { + broadcastRenderError(storeId, templateType, result.error || 'Unknown error', result.sectionId); + } + + return NextResponse.json({ success: true }, { headers: corsHeaders }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500, headers: corsHeaders } + ); + } + }, + { + requireStoreOwnership: true, + storeIdSource: 'params', + storeIdParamName: 'storeId', + } +); + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} diff --git a/app/api/stores/[storeId]/dev/ws/route.ts b/app/api/stores/[storeId]/dev/ws/route.ts new file mode 100644 index 00000000..d1ea4210 --- /dev/null +++ b/app/api/stores/[storeId]/dev/ws/route.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { withAuthHandler } from '@/api/_lib/auth-middleware'; +import { handleSSEConnection } from '@/app/api/stores/_lib/dev-server/controllers/sse-controller'; + +/** + * Endpoint SSE (Server-Sent Events) para hot-reload del editor + * Similar a template-dev pero específico para el Theme Studio + */ +export const GET = withAuthHandler( + async (request: NextRequest, { storeId }) => { + const url = new URL(request.url); + if (!url.searchParams.has('storeId')) { + url.searchParams.set('storeId', storeId); + } + + const modifiedRequest = new NextRequest(url, { + method: request.method, + headers: request.headers, + body: request.body, + signal: request.signal, + }); + + return handleSSEConnection(modifiedRequest); + }, + { + requireStoreOwnership: true, + storeIdSource: 'params', + storeIdParamName: 'storeId', + } +); diff --git a/app/api/stores/[storeId]/themes/layout/structure/route.ts b/app/api/stores/[storeId]/themes/layout/structure/route.ts new file mode 100644 index 00000000..2f5f261e --- /dev/null +++ b/app/api/stores/[storeId]/themes/layout/structure/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { withAuthHandler } from '@/api/_lib/auth-middleware'; +import { getLayoutStructure } from '@/api/stores/_lib/themes/controllers/get-layout-structure-controller'; +import { NextRequest } from 'next/server'; + +/** + * GET /api/stores/[storeId]/themes/layout/structure + * Obtiene la estructura completa del layout procesada (Header, Footer, Other) + */ +export const GET = withAuthHandler( + async (request: NextRequest, { storeId }) => { + return getLayoutStructure(request, storeId); + }, + { + requireStoreOwnership: true, + storeIdSource: 'params', + storeIdParamName: 'storeId', + } +); + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} diff --git a/app/api/stores/[storeId]/themes/sections/available/route.ts b/app/api/stores/[storeId]/themes/sections/available/route.ts new file mode 100644 index 00000000..53b3fd1f --- /dev/null +++ b/app/api/stores/[storeId]/themes/sections/available/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { withAuthHandler } from '@/api/_lib/auth-middleware'; +import { listAvailableSections } from '@/api/stores/_lib/themes/controllers/list-available-sections-controller'; +import { NextRequest } from 'next/server'; + +/** + * GET /api/stores/[storeId]/themes/sections/available + * Lista todas las secciones disponibles para agregar al template + */ +export const GET = withAuthHandler( + async (request: NextRequest, { storeId }) => { + return listAvailableSections(request, storeId); + }, + { + requireStoreOwnership: true, + storeIdSource: 'params', + storeIdParamName: 'storeId', + } +); + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} diff --git a/app/api/stores/[storeId]/themes/structure/[pageType]/route.ts b/app/api/stores/[storeId]/themes/structure/[pageType]/route.ts new file mode 100644 index 00000000..ac3ed170 --- /dev/null +++ b/app/api/stores/[storeId]/themes/structure/[pageType]/route.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { withAuthHandler } from '@/api/_lib/auth-middleware'; +import { getTemplateStructure } from '@/api/stores/_lib/themes/controllers/get-template-structure-controller'; +import { NextRequest, NextResponse } from 'next/server'; + +/** + * GET /api/stores/[storeId]/themes/structure/[pageType] + * Obtiene la estructura completa del template procesada (con schemas parseados) + */ +export const GET = withAuthHandler( + async (request: NextRequest, { storeId }) => { + const params = (request as any).params || {}; + const pageType = params.pageType; + + if (!pageType || typeof pageType !== 'string') { + const corsHeaders = await getNextCorsHeaders(request); + return NextResponse.json({ error: 'Page type is required' }, { status: 400, headers: corsHeaders }); + } + + return getTemplateStructure(request, storeId, pageType); + }, + { + requireStoreOwnership: true, + storeIdSource: 'params', + storeIdParamName: 'storeId', + } +); + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} diff --git a/app/api/stores/[storeId]/themes/templates/route.ts b/app/api/stores/[storeId]/themes/templates/route.ts new file mode 100644 index 00000000..62d1ba94 --- /dev/null +++ b/app/api/stores/[storeId]/themes/templates/route.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { withAuthHandler } from '@/api/_lib/auth-middleware'; +import { listTemplates } from '@/api/stores/_lib/themes/controllers/list-templates-controller'; +import { NextRequest } from 'next/server'; + +/** + * GET /api/stores/[storeId]/themes/templates + * Lista todos los templates disponibles del tema desde S3 + */ +export const GET = withAuthHandler( + async (request: NextRequest, { storeId }) => { + return listTemplates(request, storeId); + }, + { + requireStoreOwnership: true, + storeIdSource: 'params', + storeIdParamName: 'storeId', + } +); + +export async function OPTIONS(request: NextRequest) { + const corsHeaders = await getNextCorsHeaders(request); + return new Response(null, { status: 204, headers: corsHeaders }); +} diff --git a/app/api/stores/_lib/dev-server/application/use-cases/update-block-setting.use-case.ts b/app/api/stores/_lib/dev-server/application/use-cases/update-block-setting.use-case.ts new file mode 100644 index 00000000..69cffd27 --- /dev/null +++ b/app/api/stores/_lib/dev-server/application/use-cases/update-block-setting.use-case.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ITemplateLoader } from '../../domain/ports/template-loader.port'; +import type { ISectionRenderer } from '../../domain/ports/section-renderer.port'; +import type { UpdateBlockSettingPayload, AppliedChangePayload } from '../../domain/entities/dev-session.entity'; +import type { TemplateType, Template } from '@fasttify/theme-studio'; + +/** + * Caso de uso: Actualizar setting de bloque y re-renderizar + */ +export class UpdateBlockSettingUseCase { + constructor( + private readonly templateLoader: ITemplateLoader, + private readonly sectionRenderer: ISectionRenderer + ) {} + + async execute( + payload: UpdateBlockSettingPayload, + templateType: TemplateType, + currentTemplate: Template + ): Promise { + const changeId = `change-${Date.now()}-${Math.random()}`; + + try { + // 1. Actualizar el template en memoria + const updatedTemplate = JSON.parse(JSON.stringify(currentTemplate)); // Deep copy + const section = updatedTemplate.sections[payload.sectionId]; + if (!section?.blocks) { + throw new Error(`Section ${payload.sectionId} or blocks not found`); + } + + updatedTemplate.sections[payload.sectionId] = { + ...section, + blocks: section.blocks.map((block: any) => + block.id === payload.blockId + ? { + ...block, + settings: { + ...block.settings, + [payload.settingId]: payload.value, + }, + } + : block + ), + }; + + // 2. Re-renderizar la sección + const renderResult = await this.sectionRenderer.renderSection({ + storeId: payload.storeId, + sectionId: payload.sectionId, + template: updatedTemplate, + pageType: templateType, + }); + + return { + changeId, + sectionId: payload.sectionId, + html: renderResult.html, + css: renderResult.css, + success: true, + timestamp: Date.now(), + updatedTemplate, // Retornar template actualizado + }; + } catch (error) { + return { + changeId, + sectionId: payload.sectionId, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + timestamp: Date.now(), + }; + } + } +} diff --git a/app/api/stores/_lib/dev-server/application/use-cases/update-section-setting.use-case.ts b/app/api/stores/_lib/dev-server/application/use-cases/update-section-setting.use-case.ts new file mode 100644 index 00000000..be681347 --- /dev/null +++ b/app/api/stores/_lib/dev-server/application/use-cases/update-section-setting.use-case.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ITemplateLoader } from '../../domain/ports/template-loader.port'; +import type { ISectionRenderer } from '../../domain/ports/section-renderer.port'; +import type { UpdateSectionSettingPayload, AppliedChangePayload } from '../../domain/entities/dev-session.entity'; +import type { TemplateType, Template } from '@fasttify/theme-studio'; + +/** + * Caso de uso: Actualizar setting de sección y re-renderizar + * Orquesta la lógica de negocio para actualizar un setting y renderizar la sección + */ +export class UpdateSectionSettingUseCase { + constructor( + private readonly templateLoader: ITemplateLoader, + private readonly sectionRenderer: ISectionRenderer + ) {} + + async execute( + payload: UpdateSectionSettingPayload, + templateType: TemplateType, + currentTemplate: Template + ): Promise { + const changeId = `change-${Date.now()}-${Math.random()}`; + + try { + // 1. Actualizar el template en memoria + const updatedTemplate = JSON.parse(JSON.stringify(currentTemplate)); // Deep copy + const section = updatedTemplate.sections[payload.sectionId]; + if (!section) { + throw new Error(`Section ${payload.sectionId} not found`); + } + + updatedTemplate.sections[payload.sectionId] = { + ...section, + settings: { + ...section.settings, + [payload.settingId]: payload.value, + }, + }; + + // 2. Re-renderizar la sección + const renderResult = await this.sectionRenderer.renderSection({ + storeId: payload.storeId, + sectionId: payload.sectionId, + template: updatedTemplate, + pageType: templateType, + }); + + return { + changeId, + sectionId: payload.sectionId, + html: renderResult.html, + css: renderResult.css, + success: true, + timestamp: Date.now(), + updatedTemplate, // Retornar template actualizado + }; + } catch (error) { + return { + changeId, + sectionId: payload.sectionId, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + timestamp: Date.now(), + }; + } + } +} diff --git a/app/api/stores/_lib/dev-server/application/use-cases/update-sub-block-setting.use-case.ts b/app/api/stores/_lib/dev-server/application/use-cases/update-sub-block-setting.use-case.ts new file mode 100644 index 00000000..a67b0101 --- /dev/null +++ b/app/api/stores/_lib/dev-server/application/use-cases/update-sub-block-setting.use-case.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ITemplateLoader } from '../../domain/ports/template-loader.port'; +import type { ISectionRenderer } from '../../domain/ports/section-renderer.port'; +import type { UpdateSubBlockSettingPayload, AppliedChangePayload } from '../../domain/entities/dev-session.entity'; +import type { TemplateType, Template } from '@fasttify/theme-studio'; + +/** + * Caso de uso: Actualizar setting de sub-bloque y re-renderizar + */ +export class UpdateSubBlockSettingUseCase { + constructor( + private readonly templateLoader: ITemplateLoader, + private readonly sectionRenderer: ISectionRenderer + ) {} + + async execute( + payload: UpdateSubBlockSettingPayload, + templateType: TemplateType, + currentTemplate: Template + ): Promise { + const changeId = `change-${Date.now()}-${Math.random()}`; + + try { + // 1. Actualizar el template en memoria + const updatedTemplate = JSON.parse(JSON.stringify(currentTemplate)); // Deep copy + const section = updatedTemplate.sections[payload.sectionId]; + if (!section?.blocks) { + throw new Error(`Section ${payload.sectionId} or blocks not found`); + } + + updatedTemplate.sections[payload.sectionId] = { + ...section, + blocks: section.blocks.map((block: any) => { + if (block.id === payload.blockId && block.blocks) { + return { + ...block, + blocks: block.blocks.map((subBlock: any) => + subBlock.id === payload.subBlockId + ? { + ...subBlock, + settings: { + ...subBlock.settings, + [payload.settingId]: payload.value, + }, + } + : subBlock + ), + }; + } + return block; + }), + }; + + // 2. Re-renderizar la sección + const renderResult = await this.sectionRenderer.renderSection({ + storeId: payload.storeId, + sectionId: payload.sectionId, + template: updatedTemplate, + pageType: templateType, + }); + + return { + changeId, + sectionId: payload.sectionId, + html: renderResult.html, + css: renderResult.css, + success: true, + timestamp: Date.now(), + updatedTemplate, // Retornar template actualizado + }; + } catch (error) { + return { + changeId, + sectionId: payload.sectionId, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + timestamp: Date.now(), + }; + } + } +} diff --git a/app/api/stores/_lib/dev-server/controllers/sse-controller.ts b/app/api/stores/_lib/dev-server/controllers/sse-controller.ts new file mode 100644 index 00000000..de8ba34f --- /dev/null +++ b/app/api/stores/_lib/dev-server/controllers/sse-controller.ts @@ -0,0 +1,218 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getNextCorsHeaders } from '@/lib/utils/next-cors'; +import { logger } from '@/liquid-forge'; +import type { TemplateType } from '@fasttify/theme-studio'; +import type { AppliedChangePayload } from '../domain/entities/dev-session.entity'; +import { TemplateLoaderAdapter } from '../infrastructure/adapters/template-loader.adapter'; +import { devSessionManager } from '../infrastructure/services/dev-session-manager.service'; + +const templateLoader = new TemplateLoaderAdapter(); + +/** + * Conexiones SSE activas por storeId:templateType + */ +const activeConnections = new Map>(); + +function getConnectionKey(storeId: string, templateType: TemplateType): string { + return `${storeId}:${templateType}`; +} + +/** + * Maneja una conexión SSE para hot-reload del editor + */ +export async function handleSSEConnection(request: NextRequest): Promise { + const corsHeaders = await getNextCorsHeaders(request); + const encoder = new TextEncoder(); + + // Obtener parámetros de la URL + const url = new URL(request.url); + const storeId = url.searchParams.get('storeId'); + const templateType = url.searchParams.get('templateType') as TemplateType | null; + + if (!storeId || !templateType) { + return NextResponse.json( + { error: 'Missing storeId or templateType' }, + { + status: 400, + headers: corsHeaders, + } + ); + } + + const connectionKey = getConnectionKey(storeId, templateType); + + const stream = new ReadableStream({ + start(controller) { + // Agregar conexión al mapa + if (!activeConnections.has(connectionKey)) { + activeConnections.set(connectionKey, new Set()); + } + const connections = activeConnections.get(connectionKey); + if (connections) { + connections.add(controller); + } + + // Enviar mensaje de conexión + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'CONNECTED' })}\n\n`)); + + // Cargar y enviar template inicial + templateLoader + .loadTemplate(storeId, templateType) + .then((template) => { + const session = devSessionManager.createOrGetSession(storeId, templateType, template); + const message = JSON.stringify({ + type: 'TEMPLATE_LOADED', + payload: { + template: session.template, + }, + }); + controller.enqueue(encoder.encode(`data: ${message}\n\n`)); + }) + .catch((error) => { + logger.error(`Error loading template for SSE connection`, error, 'SSEController'); + }); + + // Ping cada 30 segundos para mantener la conexión viva + const pingInterval = setInterval(() => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping' })}\n\n`)); + } catch (_error) { + clearInterval(pingInterval); + } + }, 30000); + + // Limpiar al cerrar la conexión + request.signal.addEventListener('abort', () => { + const connections = activeConnections.get(connectionKey); + if (connections) { + connections.delete(controller); + if (connections.size === 0) { + activeConnections.delete(connectionKey); + } + } + clearInterval(pingInterval); + }); + }, + }); + + return new NextResponse(stream, { + headers: { + ...corsHeaders, + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} + +/** + * Envía un cambio aplicado a todas las conexiones SSE de un storeId:templateType + */ +export function broadcastChangeApplied( + storeId: string, + templateType: TemplateType, + change: AppliedChangePayload +): void { + const connectionKey = getConnectionKey(storeId, templateType); + const connections = activeConnections.get(connectionKey); + + if (!connections || connections.size === 0) { + return; + } + + const encoder = new TextEncoder(); + const message = JSON.stringify({ + type: 'CHANGE_APPLIED', + payload: change, + }); + + connections.forEach((controller) => { + try { + controller.enqueue(encoder.encode(`data: ${message}\n\n`)); + } catch (error) { + logger.error('Error sending SSE change message', error, 'SSEController'); + connections.delete(controller); + } + }); +} + +/** + * Envía un error de renderizado a todas las conexiones SSE + */ +export function broadcastRenderError( + storeId: string, + templateType: TemplateType, + error: string, + sectionId: string +): void { + const connectionKey = getConnectionKey(storeId, templateType); + const connections = activeConnections.get(connectionKey); + + if (!connections || connections.size === 0) { + return; + } + + const encoder = new TextEncoder(); + const message = JSON.stringify({ + type: 'RENDER_ERROR', + payload: { + error, + sectionId, + }, + }); + + connections.forEach((controller) => { + try { + controller.enqueue(encoder.encode(`data: ${message}\n\n`)); + } catch (err) { + logger.error('Error sending SSE error message', err, 'SSEController'); + connections.delete(controller); + } + }); +} + +/** + * Envía un template cargado a todas las conexiones SSE + */ +export function broadcastTemplateLoaded(storeId: string, templateType: TemplateType, template: any): void { + const connectionKey = getConnectionKey(storeId, templateType); + const connections = activeConnections.get(connectionKey); + + if (!connections || connections.size === 0) { + return; + } + + const encoder = new TextEncoder(); + const message = JSON.stringify({ + type: 'TEMPLATE_LOADED', + payload: { + template, + }, + }); + + connections.forEach((controller) => { + try { + controller.enqueue(encoder.encode(`data: ${message}\n\n`)); + } catch (err) { + logger.error('Error sending SSE template loaded message', err, 'SSEController'); + connections.delete(controller); + } + }); +} diff --git a/app/api/stores/_lib/dev-server/domain/entities/dev-session.entity.ts b/app/api/stores/_lib/dev-server/domain/entities/dev-session.entity.ts new file mode 100644 index 00000000..93d21168 --- /dev/null +++ b/app/api/stores/_lib/dev-server/domain/entities/dev-session.entity.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Template, TemplateType } from '@fasttify/theme-studio'; + +/** + * Sesión de desarrollo activa + * Mantiene el estado del template en memoria durante la sesión de edición + */ +export interface DevSession { + sessionId: string; + storeId: string; + templateType: TemplateType; + template: Template; + createdAt: Date; + lastActivityAt: Date; +} + +/** + * Mensaje del cliente al servidor + */ +export type ClientMessage = + | { type: 'UPDATE_SECTION_SETTING'; payload: UpdateSectionSettingPayload } + | { type: 'UPDATE_BLOCK_SETTING'; payload: UpdateBlockSettingPayload } + | { type: 'UPDATE_SUB_BLOCK_SETTING'; payload: UpdateSubBlockSettingPayload } + | { type: 'LOAD_TEMPLATE'; payload: { storeId: string; templateType: TemplateType } }; + +/** + * Mensaje del servidor al cliente + */ +export type ServerMessage = + | { type: 'CHANGE_APPLIED'; payload: AppliedChangePayload } + | { type: 'RENDER_ERROR'; payload: { error: string; sectionId: string } } + | { type: 'TEMPLATE_LOADED'; payload: { template: Template } } + | { type: 'CONNECTED'; payload: { sessionId: string } }; + +export interface UpdateSectionSettingPayload { + storeId: string; + sectionId: string; + settingId: string; + value: unknown; +} + +export interface UpdateBlockSettingPayload { + storeId: string; + sectionId: string; + blockId: string; + settingId: string; + value: unknown; +} + +export interface UpdateSubBlockSettingPayload { + storeId: string; + sectionId: string; + blockId: string; + subBlockId: string; + settingId: string; + value: unknown; +} + +export interface AppliedChangePayload { + changeId: string; + sectionId: string; + html?: string; + css?: string; + success: boolean; + error?: string; + timestamp: number; +} diff --git a/app/api/stores/_lib/dev-server/domain/ports/section-renderer.port.ts b/app/api/stores/_lib/dev-server/domain/ports/section-renderer.port.ts new file mode 100644 index 00000000..a72020e5 --- /dev/null +++ b/app/api/stores/_lib/dev-server/domain/ports/section-renderer.port.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Template, TemplateType } from '@fasttify/theme-studio'; + +/** + * Parámetros para renderizar una sección + */ +export interface RenderSectionParams { + storeId: string; + sectionId: string; + template: Template; // Template completo con todas las secciones + pageType: TemplateType; +} + +/** + * Resultado del renderizado de una sección + */ +export interface RenderSectionResult { + html: string; + css?: string; +} + +/** + * Puerto (interfaz) para renderizar secciones + * Define el contrato para renderizar secciones usando Liquid + */ +export interface ISectionRenderer { + /** + * Renderizar una sección específica + * @param params - Parámetros del renderizado + */ + renderSection(params: RenderSectionParams): Promise; +} diff --git a/app/api/stores/_lib/dev-server/domain/ports/template-loader.port.ts b/app/api/stores/_lib/dev-server/domain/ports/template-loader.port.ts new file mode 100644 index 00000000..cbf516d1 --- /dev/null +++ b/app/api/stores/_lib/dev-server/domain/ports/template-loader.port.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Fasttify LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Template, TemplateType } from '@fasttify/theme-studio'; + +/** + * Puerto (interfaz) para cargar templates + * Define el contrato para obtener templates desde el almacenamiento + */ +export interface ITemplateLoader { + /** + * Cargar template por tipo + * @param storeId - ID de la tienda + * @param templateType - Tipo de template + */ + loadTemplate(storeId: string, templateType: TemplateType): Promise