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/[templateType]/route.ts b/app/api/stores/[storeId]/themes/templates/[templateType]/route.ts
new file mode 100644
index 00000000..d5b2a61d
--- /dev/null
+++ b/app/api/stores/[storeId]/themes/templates/[templateType]/route.ts
@@ -0,0 +1,60 @@
+/*
+ * 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 { saveTemplate } from '@/api/stores/_lib/themes/controllers/save-template-controller';
+import { NextRequest, NextResponse } from 'next/server';
+import type { TemplateType } from '@fasttify/theme-studio';
+
+interface RouteParams {
+ storeId: string;
+ templateType: string;
+}
+
+interface RequestWithParams extends NextRequest {
+ params?: RouteParams;
+}
+
+/**
+ * PUT /api/stores/[storeId]/themes/templates/[templateType]
+ * Guarda un template específico en S3
+ */
+export const PUT = withAuthHandler(
+ async (request: NextRequest, { storeId }) => {
+ // Los params ya están inyectados en el request por withAuthHandler
+ const requestWithParams = request as RequestWithParams;
+ const params = requestWithParams.params;
+
+ if (!params || !params.templateType) {
+ const corsHeaders = await getNextCorsHeaders(request);
+ return NextResponse.json({ error: 'Missing templateType parameter' }, { status: 400, headers: corsHeaders });
+ }
+
+ const templateType = params.templateType as TemplateType;
+ return saveTemplate(request, storeId, templateType);
+ },
+ {
+ 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/save-template.use-case.ts b/app/api/stores/_lib/dev-server/application/use-cases/save-template.use-case.ts
new file mode 100644
index 00000000..d5015f10
--- /dev/null
+++ b/app/api/stores/_lib/dev-server/application/use-cases/save-template.use-case.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2025 Fasttify LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ITemplateLoader } from '../../domain/ports/template-loader.port';
+import type { TemplateType, Template } from '@fasttify/theme-studio';
+
+/**
+ * Caso de uso: Guardar template
+ * Orquesta la lógica de negocio para guardar un template en el almacenamiento
+ */
+export class SaveTemplateUseCase {
+ constructor(private readonly templateLoader: ITemplateLoader) {}
+
+ async execute(storeId: string, templateType: TemplateType, template: Template): Promise {
+ // 1. Validar estructura del template
+ this.validateTemplate(template);
+
+ // 2. Guardar el nuevo template (el backup se crea automáticamente en el adapter)
+ await this.templateLoader.saveTemplate(storeId, templateType, template);
+ }
+
+ /**
+ * Valida la estructura del template antes de guardar
+ */
+ private validateTemplate(template: Template): void {
+ if (!template) {
+ throw new Error('Template is required');
+ }
+
+ if (!template.type) {
+ throw new Error('Template type is required');
+ }
+
+ if (!template.sections || typeof template.sections !== 'object') {
+ throw new Error('Template sections must be an object');
+ }
+
+ if (!Array.isArray(template.order)) {
+ throw new Error('Template order must be an array');
+ }
+
+ // Validar que todas las secciones en order existan en sections
+ for (const sectionId of template.order) {
+ if (!template.sections[sectionId]) {
+ throw new Error(`Section ${sectionId} in order does not exist in sections`);
+ }
+ }
+ }
+}
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..ed07e866
--- /dev/null
+++ b/app/api/stores/_lib/dev-server/controllers/sse-controller.ts
@@ -0,0 +1,248 @@
+/*
+ * 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,
+ });
+
+ const deadConnections: ReadableStreamDefaultController[] = [];
+
+ connections.forEach((controller) => {
+ try {
+ controller.enqueue(encoder.encode(`data: ${message}\n\n`));
+ } catch (_error) {
+ deadConnections.push(controller);
+ }
+ });
+
+ // Limpiar conexiones muertas
+ deadConnections.forEach((controller) => {
+ connections.delete(controller);
+ });
+
+ if (deadConnections.length > 0 && connections.size === 0) {
+ activeConnections.delete(connectionKey);
+ }
+}
+
+/**
+ * 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,
+ },
+ });
+
+ const deadConnections: ReadableStreamDefaultController[] = [];
+
+ connections.forEach((controller) => {
+ try {
+ controller.enqueue(encoder.encode(`data: ${message}\n\n`));
+ } catch (_err) {
+ deadConnections.push(controller);
+ }
+ });
+
+ // Limpiar conexiones muertas
+ deadConnections.forEach((controller) => {
+ connections.delete(controller);
+ });
+
+ if (deadConnections.length > 0 && connections.size === 0) {
+ activeConnections.delete(connectionKey);
+ }
+}
+
+/**
+ * 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,
+ },
+ });
+
+ const deadConnections: ReadableStreamDefaultController[] = [];
+
+ connections.forEach((controller) => {
+ try {
+ controller.enqueue(encoder.encode(`data: ${message}\n\n`));
+ } catch (_err) {
+ deadConnections.push(controller);
+ }
+ });
+
+ // Limpiar conexiones muertas
+ deadConnections.forEach((controller) => {
+ connections.delete(controller);
+ });
+
+ if (deadConnections.length > 0 && connections.size === 0) {
+ activeConnections.delete(connectionKey);
+ }
+}
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;
+
+ /**
+ * Guardar template
+ * @param storeId - ID de la tienda
+ * @param templateType - Tipo de template
+ * @param template - Template a guardar
+ */
+ saveTemplate(storeId: string, templateType: TemplateType, template: Template): Promise;
+}
diff --git a/app/api/stores/_lib/dev-server/infrastructure/adapters/section-renderer.adapter.ts b/app/api/stores/_lib/dev-server/infrastructure/adapters/section-renderer.adapter.ts
new file mode 100644
index 00000000..5063bc18
--- /dev/null
+++ b/app/api/stores/_lib/dev-server/infrastructure/adapters/section-renderer.adapter.ts
@@ -0,0 +1,202 @@
+/*
+ * 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 { sectionRenderer } from '@/liquid-forge/services/rendering/section-renderer';
+import { templateLoader } from '@/liquid-forge/services/templates/template-loader';
+import { contextBuilder } from '@/liquid-forge/services/rendering/global-context';
+import { dynamicDataLoader } from '@/liquid-forge/services/page/dynamic-data-loader';
+import { dataFetcher } from '@/liquid-forge/services/fetchers/data-fetcher';
+import { cookiesClient } from '@/utils/server/AmplifyServer';
+import type {
+ ISectionRenderer,
+ RenderSectionParams,
+ RenderSectionResult,
+} from '../../domain/ports/section-renderer.port';
+import type { PageRenderOptions } from '@/liquid-forge/types/template';
+import type { RenderContext } from '@/liquid-forge/types';
+import { logger } from '@/liquid-forge';
+
+/**
+ * Helper: Obtiene la configuración de la sección del template
+ */
+const getSectionConfig = (template: any, sectionId: string) => {
+ const sectionConfig = template.sections[sectionId];
+ if (!sectionConfig) {
+ throw new Error(`Section ${sectionId} not found in template`);
+ }
+ return sectionConfig;
+};
+
+/**
+ * Helper: Carga el contenido Liquid de la sección
+ */
+const loadSectionContent = async (storeId: string, sectionType: string): Promise => {
+ return templateLoader.loadTemplate(storeId, `${sectionType}.liquid`);
+};
+
+/**
+ * Helper: Carga los datos de la tienda desde la base de datos
+ */
+const loadStoreData = async (storeId: string) => {
+ const { data: userStore } = await cookiesClient.models.UserStore.get({ storeId });
+ if (!userStore) {
+ throw new Error(`Store ${storeId} not found`);
+ }
+ return userStore;
+};
+
+/**
+ * Helper: Construye el objeto store para el contexto
+ */
+const buildStoreObject = (userStore: any) => ({
+ storeId: userStore.storeId,
+ storeName: userStore.storeName,
+ storeDescription: userStore.storeDescription,
+ storeCurrency: userStore.storeCurrency,
+ currencyFormat: userStore.currencyFormat,
+ currencyLocale: userStore.currencyLocale,
+ currencyDecimalPlaces: userStore.currencyDecimalPlaces,
+ customDomain: userStore.defaultDomain,
+ contactEmail: userStore.contactEmail,
+ contactPhone: userStore.contactPhone,
+ storeAdress: userStore.storeAdress,
+ storeLogo: userStore.storeLogo,
+ storeStatus: true,
+});
+
+/**
+ * Helper: Construye las opciones de renderizado de página
+ */
+const buildPageRenderOptions = (pageType: string): PageRenderOptions => ({
+ pageType: pageType as PageRenderOptions['pageType'],
+});
+
+/**
+ * Helper: Construye el storeTemplate para el contexto
+ */
+const buildStoreTemplate = (pageType: string, template: any) => ({
+ templates: {
+ [pageType]: template,
+ },
+});
+
+/**
+ * Helper: Carga el settings_schema.json para los límites de búsqueda
+ */
+const loadSettingsSchema = async (storeId: string): Promise> => {
+ try {
+ const settingsSchema = await templateLoader.loadTemplate(storeId, 'config/settings_schema.json');
+ return { 'config/settings_schema.json': settingsSchema };
+ } catch (_error) {
+ return {};
+ }
+};
+
+/**
+ * Helper: Carga todos los datos dinámicos necesarios para el contexto
+ */
+const loadDynamicPageData = async (storeId: string, pageType: string) => {
+ const pageRenderOptions = buildPageRenderOptions(pageType);
+ const loadedTemplates = await loadSettingsSchema(storeId);
+ return dynamicDataLoader.loadDynamicData(storeId, pageRenderOptions, {}, loadedTemplates);
+};
+
+/**
+ * Helper: Carga los menús de navegación de la tienda
+ */
+const loadNavigationMenus = async (storeId: string) => {
+ return dataFetcher.getStoreNavigationMenus(storeId).catch(() => null);
+};
+
+/**
+ * Helper: Construye el contexto completo combinando base y datos dinámicos
+ */
+const buildCompleteContext = async (
+ store: any,
+ pageData: any,
+ navigationMenus: any,
+ storeTemplate: any
+): Promise => {
+ const baseContext = await contextBuilder.createRenderContext(
+ store,
+ pageData.products,
+ storeTemplate,
+ pageData.cartData,
+ navigationMenus || undefined,
+ pageData.contextData.checkout
+ );
+
+ Object.assign(baseContext, pageData.contextData);
+ return baseContext;
+};
+
+/**
+ * Helper: Renderiza la sección con el contexto completo
+ */
+const renderSectionWithContext = async (
+ sectionType: string,
+ sectionContent: string,
+ context: RenderContext,
+ storeTemplate: any,
+ sectionId: string,
+ pageType: string
+): Promise => {
+ return sectionRenderer.renderSectionWithSchema(
+ sectionType,
+ sectionContent,
+ context,
+ storeTemplate,
+ sectionId,
+ pageType
+ );
+};
+
+/**
+ * Adaptador: Section Renderer
+ * Implementación concreta para renderizar secciones usando Liquid
+ */
+export class SectionRendererAdapter implements ISectionRenderer {
+ async renderSection(params: RenderSectionParams): Promise {
+ try {
+ const { storeId, sectionId, template, pageType } = params;
+
+ const sectionConfig = getSectionConfig(template, sectionId);
+ const [sectionContent, userStore, pageData, navigationMenus] = await Promise.all([
+ loadSectionContent(storeId, sectionConfig.type),
+ loadStoreData(storeId),
+ loadDynamicPageData(storeId, pageType),
+ loadNavigationMenus(storeId),
+ ]);
+
+ const store = buildStoreObject(userStore);
+ const storeTemplate = buildStoreTemplate(pageType, template);
+ const context = await buildCompleteContext(store, pageData, navigationMenus, storeTemplate);
+ const html = await renderSectionWithContext(
+ sectionConfig.type,
+ sectionContent,
+ context,
+ storeTemplate,
+ sectionId,
+ pageType
+ );
+
+ return { html };
+ } catch (error) {
+ logger.error(`Error rendering section ${params.sectionId}`, error, 'SectionRendererAdapter');
+ throw error;
+ }
+ }
+}
diff --git a/app/api/stores/_lib/dev-server/infrastructure/adapters/template-loader.adapter.ts b/app/api/stores/_lib/dev-server/infrastructure/adapters/template-loader.adapter.ts
new file mode 100644
index 00000000..011d66e5
--- /dev/null
+++ b/app/api/stores/_lib/dev-server/infrastructure/adapters/template-loader.adapter.ts
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2025 Fasttify LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { templateLoader } from '@/liquid-forge/services/templates/template-loader';
+import type { ITemplateLoader } from '../../domain/ports/template-loader.port';
+import type { Template, TemplateType } from '@fasttify/theme-studio';
+import { S3Client, CopyObjectCommand } from '@aws-sdk/client-s3';
+import { Upload } from '@aws-sdk/lib-storage';
+
+const TEMPLATE_PATHS: Record = {
+ index: 'templates/index.json',
+ product: 'templates/product.json',
+ collection: 'templates/collection.json',
+ cart: 'templates/cart.json',
+ page: 'templates/page.json',
+ policies: 'templates/policies.json',
+ search: 'templates/search.json',
+ '404': 'templates/404.json',
+ checkout: 'templates/checkout.json',
+ checkout_confirmation: 'templates/checkout_confirmation.json',
+};
+
+function getTemplatePath(templateType: TemplateType): string {
+ return TEMPLATE_PATHS[templateType] || `templates/${templateType}.json`;
+}
+
+/**
+ * Adaptador: Template Loader
+ * Implementación concreta para cargar templates desde S3 usando templateLoader de liquid-forge
+ */
+export class TemplateLoaderAdapter implements ITemplateLoader {
+ private s3Client: S3Client;
+ private bucketName: string;
+
+ constructor() {
+ this.bucketName = process.env.BUCKET_NAME || '';
+ this.s3Client = new S3Client({
+ region: process.env.REGION_BUCKET,
+ });
+ }
+
+ async loadTemplate(storeId: string, templateType: TemplateType): Promise {
+ const templatePath = getTemplatePath(templateType);
+ const templateContent = await templateLoader.loadTemplate(storeId, templatePath);
+ const templateConfig = JSON.parse(templateContent.replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '$1'));
+
+ return {
+ type: templateType,
+ sections: templateConfig.sections || {},
+ order: templateConfig.order || [],
+ };
+ }
+
+ async saveTemplate(storeId: string, templateType: TemplateType, template: Template): Promise {
+ if (!this.bucketName) {
+ throw new Error('S3 bucket not configured');
+ }
+
+ const templatePath = getTemplatePath(templateType);
+ const s3Key = `templates/${storeId}/${templatePath}`;
+
+ // 1. Crear backup del template actual antes de sobrescribir
+ try {
+ const currentTemplate = await this.loadTemplate(storeId, templateType);
+ await this.createBackup(storeId, templateType, currentTemplate);
+ } catch (error) {
+ // Si no existe el template actual, no hay problema, continuar
+ // Si hay otro error, loguearlo pero continuar
+ console.warn(`Could not create backup for template ${templateType}:`, error);
+ }
+
+ // 2. Serializar template a JSON
+ const templateJson = JSON.stringify(
+ {
+ sections: template.sections,
+ order: template.order,
+ },
+ null,
+ 2
+ );
+
+ // 3. Guardar template en S3
+ const upload = new Upload({
+ client: this.s3Client,
+ params: {
+ Bucket: this.bucketName,
+ Key: s3Key,
+ Body: templateJson,
+ ContentType: 'application/json',
+ Metadata: {
+ storeId,
+ templateType,
+ savedAt: new Date().toISOString(),
+ },
+ },
+ });
+
+ await upload.done();
+
+ // 4. Invalidar caché del template
+ templateLoader.invalidateTemplateCache(storeId, templatePath);
+ }
+
+ /**
+ * Crea un backup del template actual antes de sobrescribir
+ */
+ private async createBackup(storeId: string, templateType: TemplateType, _template: Template): Promise {
+ const templatePath = getTemplatePath(templateType);
+ const sourceKey = `templates/${storeId}/${templatePath}`;
+ const timestamp = Date.now();
+ const backupKey = `backups/${storeId}/templates/${templateType}-${timestamp}.json`;
+
+ try {
+ // Copiar el template actual a la ubicación de backup
+ const copyCommand = new CopyObjectCommand({
+ Bucket: this.bucketName,
+ CopySource: `${this.bucketName}/${sourceKey}`,
+ Key: backupKey,
+ Metadata: {
+ storeId,
+ templateType,
+ backedUpAt: new Date().toISOString(),
+ },
+ MetadataDirective: 'REPLACE',
+ });
+
+ await this.s3Client.send(copyCommand);
+ } catch (error) {
+ // Si falla el backup, loguear pero no fallar el guardado
+ console.warn(`Failed to create backup for template ${templateType}:`, error);
+ }
+ }
+}
diff --git a/app/api/stores/_lib/dev-server/infrastructure/services/dev-session-manager.service.ts b/app/api/stores/_lib/dev-server/infrastructure/services/dev-session-manager.service.ts
new file mode 100644
index 00000000..2f4c9059
--- /dev/null
+++ b/app/api/stores/_lib/dev-server/infrastructure/services/dev-session-manager.service.ts
@@ -0,0 +1,103 @@
+/*
+ * 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 { DevSession } from '../../domain/entities/dev-session.entity';
+import type { Template, TemplateType } from '@fasttify/theme-studio';
+import crypto from 'crypto';
+
+/**
+ * Servicio: Dev Session Manager
+ * Gestiona las sesiones de desarrollo activas en memoria
+ */
+export class DevSessionManagerService {
+ private sessions: Map = new Map();
+ private readonly SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutos
+
+ /**
+ * Crea o recupera una sesión de desarrollo
+ */
+ createOrGetSession(storeId: string, templateType: TemplateType, template: Template): DevSession {
+ const sessionKey = `${storeId}:${templateType}`;
+ const existingSession = this.sessions.get(sessionKey);
+
+ if (existingSession) {
+ existingSession.lastActivityAt = new Date();
+ existingSession.template = template; // Actualizar template
+ return existingSession;
+ }
+
+ const newSession: DevSession = {
+ sessionId: `session-${Date.now()}-${crypto.randomBytes(16).toString('hex')}`,
+ storeId,
+ templateType,
+ template,
+ createdAt: new Date(),
+ lastActivityAt: new Date(),
+ };
+
+ this.sessions.set(sessionKey, newSession);
+ return newSession;
+ }
+
+ /**
+ * Obtiene una sesión existente
+ */
+ getSession(storeId: string, templateType: TemplateType): DevSession | null {
+ const sessionKey = `${storeId}:${templateType}`;
+ return this.sessions.get(sessionKey) || null;
+ }
+
+ /**
+ * Actualiza el template de una sesión
+ */
+ updateSessionTemplate(storeId: string, templateType: TemplateType, template: Template): void {
+ const session = this.getSession(storeId, templateType);
+ if (session) {
+ session.template = template;
+ session.lastActivityAt = new Date();
+ }
+ }
+
+ /**
+ * Elimina una sesión
+ */
+ removeSession(storeId: string, templateType: TemplateType): void {
+ const sessionKey = `${storeId}:${templateType}`;
+ this.sessions.delete(sessionKey);
+ }
+
+ /**
+ * Limpia sesiones expiradas
+ */
+ cleanupExpiredSessions(): void {
+ const now = Date.now();
+ for (const [key, session] of this.sessions.entries()) {
+ const lastActivity = session.lastActivityAt.getTime();
+ if (now - lastActivity > this.SESSION_TIMEOUT_MS) {
+ this.sessions.delete(key);
+ }
+ }
+ }
+
+ /**
+ * Obtiene todas las sesiones activas (para debugging)
+ */
+ getAllSessions(): DevSession[] {
+ return Array.from(this.sessions.values());
+ }
+}
+
+export const devSessionManager = new DevSessionManagerService();
diff --git a/app/api/stores/_lib/themes/controllers/get-layout-structure-controller.ts b/app/api/stores/_lib/themes/controllers/get-layout-structure-controller.ts
new file mode 100644
index 00000000..65881b78
--- /dev/null
+++ b/app/api/stores/_lib/themes/controllers/get-layout-structure-controller.ts
@@ -0,0 +1,205 @@
+/*
+ * 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 { templateLoader } from '@/liquid-forge/services/templates/template-loader';
+import { schemaParser } from '@/liquid-forge/services/templates/parsing/schema-parser';
+
+interface LayoutSection {
+ id: string;
+ type: string;
+ name: string;
+ isSnippet: boolean;
+ schema: {
+ name: string;
+ settings: any[];
+ blocks: any[];
+ presets: any[];
+ };
+}
+
+interface SectionReference {
+ name: string;
+ isRender: boolean;
+ position: number;
+}
+
+/**
+ * Extrae todas las referencias a secciones y snippets del layout
+ */
+function extractSectionReferences(layoutContent: string): SectionReference[] {
+ const references: SectionReference[] = [];
+ const regex = /{%\s*(section|render)\s+['"]([^'"]+)['"]\s*%}/g;
+ let match;
+
+ while ((match = regex.exec(layoutContent)) !== null) {
+ references.push({
+ name: match[2],
+ isRender: match[1] === 'render',
+ position: match.index,
+ });
+ }
+
+ return references.filter((ref) => !ref.name.endsWith('.json'));
+}
+
+/**
+ * Encuentra la posición donde comienza el contenido principal del layout
+ */
+function findMainContentStart(layoutContent: string): number {
+ const contentForLayoutIndex = layoutContent.indexOf('{{ content_for_layout }}');
+ const mainTagMatch = layoutContent.match(/]*>/i);
+ const mainTagIndex = mainTagMatch ? layoutContent.indexOf(mainTagMatch[0]) : -1;
+
+ if (contentForLayoutIndex !== -1 && mainTagIndex !== -1) {
+ return Math.min(contentForLayoutIndex, mainTagIndex);
+ }
+ return contentForLayoutIndex !== -1
+ ? contentForLayoutIndex
+ : mainTagIndex !== -1
+ ? mainTagIndex
+ : layoutContent.length;
+}
+
+/**
+ * Categoriza las referencias de secciones según su posición en el layout
+ */
+function categorizeSections(
+ references: SectionReference[],
+ layoutContent: string,
+ mainContentStart: number
+): { header: SectionReference[]; footer: SectionReference[]; other: SectionReference[] } {
+ const bodyCloseIndex = layoutContent.indexOf('