@bacano/sdk 1.1.18 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,8 @@
2
2
 
3
3
  `@bacano/sdk/content-management` permite que una herramienta administrativa o
4
4
  un agente autorizado consulte y mantenga **tipos, componentes, grupos, listas
5
- de opciones, entradas e imágenes** de Bacano Content. Cada capacidad requiere
5
+ de opciones, entradas e imágenes** de Bacano Content, y **formularios web**.
6
+ Cada capacidad requiere
6
7
  un permiso explícito. El acceso no autoriza productos, ventas, clientes,
7
8
  configuración general, GraphQL, SQL ni acceso directo a Storage.
8
9
 
@@ -12,7 +13,7 @@ dentro del bundle del sitio web.
12
13
 
13
14
  ## Activar un acceso temporal
14
15
 
15
- 1. En Bacano, abre **Sitios web → Contenido → Tipos** o **Componentes**.
16
+ 1. En Bacano, abre **Sitios web → Contenido → Tipos**, **Componentes** o **Formularios**.
16
17
  2. Selecciona **Conectar agente**.
17
18
  3. Elige el alcance, 15, 30 o 60 minutos y genera el acceso.
18
19
  4. Copia el token al entorno local del agente.
@@ -32,6 +33,15 @@ Scopes disponibles:
32
33
  también `content_entries:manage`.
33
34
  - `content_media:upload`: consultar imágenes de la empresa y cargar JPEG, PNG o
34
35
  WebP. Requiere también `content_entries:manage`; no permite borrar archivos.
36
+ - `forms:manage`: consultar sitios y listas disponibles para formularios,
37
+ inventariar formularios y crear o reemplazar sus borradores.
38
+ - `forms:publish`: publicar, pausar, reactivar o archivar formularios. Requiere
39
+ `forms:manage`. También se exige para cambiar notificaciones de formularios
40
+ que ya fueron publicados, porque afectan envíos en curso.
41
+
42
+ Los permisos de Content no conceden acceso a formularios ni viceversa. Los
43
+ tokens existentes no se amplían; solicita uno nuevo con el alcance necesario.
44
+ Estos permisos no permiten leer respuestas, datos personales, leads ni métricas.
35
45
 
36
46
  No guardes el token en Git, Figma, Content, logs, variables públicas ni archivos
37
47
  que se empaqueten con el storefront.
@@ -432,6 +442,74 @@ omitas opciones existentes por accidente en una actualización.
432
442
  - Los tokens v2 no son compatibles con tokens anteriores y tienen como máximo
433
443
  60 minutos de vigencia.
434
444
 
445
+ ## Formularios: misma definición en el agente, el ERP y el storefront
446
+
447
+ No modeles un formulario como un tipo de Content ni mantengas una copia de sus
448
+ campos en el frontend. Usa el mismo cliente administrativo con `forms:manage`:
449
+
450
+ 1. `getContext()` confirma empresa, scopes y vencimiento.
451
+ 2. `getFormReferences()` devuelve los sitios de esa empresa y las Listas de
452
+ Content activas, con sus pares `value` / `label`.
453
+ 3. `listForms({ websiteId, key, limit, offset })` inventaría antes de crear.
454
+ Las keys son únicas por sitio, no por empresa. Pagina hasta alcanzar `total`.
455
+ 4. Presenta al usuario sitio, key, campos, acción y mensajes antes de crear.
456
+ 5. `createForm(input)` crea exclusivamente un borrador.
457
+ 6. `getForm(formId)` consulta la definición completa y su `updatedAt`.
458
+ 7. `updateForm(formId, { ...input, expectedUpdatedAt })` reemplaza el borrador
459
+ completo. Conserva los campos que deben permanecer; no cambia key ni sitio.
460
+ 8. Solo con aprobación humana y `forms:publish`, usa `publishForm(formId,
461
+ { expectedUpdatedAt })`. Crea una versión inmutable que consume el SDK público.
462
+
463
+ ```ts
464
+ const { websites, optionLists } = await content.getFormReferences();
465
+ // Elegir y confirmar con el usuario un websiteId devuelto por websites.
466
+ const form = await content.createForm({
467
+ websiteId,
468
+ key: "solicitar-informacion",
469
+ name: "Solicitar información",
470
+ action: "CAPTURE_LEAD",
471
+ definition: {
472
+ schemaVersion: 1,
473
+ submitLabel: "Enviar solicitud",
474
+ successMessage: "Recibimos tu solicitud.",
475
+ unavailableMessage: "Este formulario no está disponible.",
476
+ fields: [
477
+ { key: "correo", label: "Correo electrónico", type: "email", required: true },
478
+ {
479
+ key: "intereses", label: "¿Qué te interesa?", type: "multi_select", required: true,
480
+ optionSource: { kind: "INLINE", options: [
481
+ { value: "sitio_web", label: "Sitio web" },
482
+ { value: "tienda_virtual", label: "Tienda virtual" },
483
+ ] },
484
+ },
485
+ ],
486
+ sections: [],
487
+ steps: [],
488
+ },
489
+ });
490
+ // Tras revisar y aprobar; requiere forms:publish:
491
+ await content.publishForm(form.id, { expectedUpdatedAt: form.updatedAt });
492
+ ```
493
+
494
+ Las opciones `select`, `radio` y `multi_select` usan pares `value` / `label`
495
+ o `optionSource: { kind: "CONTENT_LIST", listKey }`. Reutiliza únicamente una
496
+ lista activa de `getFormReferences()`; administrarla exige el permiso separado
497
+ de Content. Los tipos `email` y `phone` conservan la semántica de contacto.
498
+ `CAPTURE_LEAD` utiliza la resolución de identidad ya existente: no llames además
499
+ a la captura de campañas ni crees otro lead desde el agente.
500
+
501
+ `changeFormStatus(formId, { status, expectedUpdatedAt })` acepta `ACTIVE`,
502
+ `PAUSED` o `ARCHIVED`, con aprobación humana. Activar o pausar requiere una
503
+ versión publicada; archivar es terminal en el flujo actual. No hay borrado ni
504
+ restauración por este acceso.
505
+
506
+ Ante `WEBSITE_FORM_REVISION_CONFLICT` (HTTP 409), vuelve a consultar y presenta
507
+ las diferencias; nunca reintentes automáticamente reemplazando la revisión.
508
+ Editar un borrador no publica su estructura: la versión pública anterior sigue
509
+ vigente hasta publicar. Las respuestas y sus snapshots no se reescriben.
510
+ Usa `BacanoForm` o `client.forms` con la key publicada y el mismo website,
511
+ siguiendo `STOREFRONT_FORMS.md`. El token temporal jamás entra en ese bundle.
512
+
435
513
  ## Límites de esta versión
436
514
 
437
515
  - La carga administrativa solo admite imágenes raster JPEG, PNG y WebP. SVG,
package/README.md CHANGED
@@ -21,10 +21,10 @@ React hooks are available from `@bacano/sdk/react` and require `react >= 18`
21
21
  as a peer dependency. The core client has no runtime dependencies and works
22
22
  without React.
23
23
 
24
- ### Gestión de Bacano Content para agentes
24
+ ### Gestión de Content y formularios para agentes
25
25
 
26
26
  Un agente autorizado puede consultar y mantener tipos, componentes, grupos,
27
- listas de opciones y entradas con el entrypoint administrativo
27
+ listas de opciones, entradas y formularios con el entrypoint administrativo
28
28
  `@bacano/sdk/content-management`. Cada capacidad exige un scope explícito. El
29
29
  acceso se genera temporalmente desde Bacano, está limitado a una empresa y
30
30
  nunca debe incluirse en el storefront:
@@ -221,7 +221,7 @@ The public storefront remains static. Preview V1 never exposes drafts and
221
221
  automatically disables the SDK marketing runtime inside its tree. See
222
222
  [`STOREFRONT_PREVIEW.md`](./STOREFRONT_PREVIEW.md), the canonical contract for
223
223
  provider hierarchy, responsibilities, security, agent checklist, and the
224
- validated Baby Rex reference implementation.
224
+ validated reference implementation.
225
225
 
226
226
  ---
227
227
 
@@ -4,6 +4,12 @@ Los formularios públicos de Bacano son definiciones tipadas y versionadas. El
4
4
  storefront decide dónde se muestran y cómo lucen; Bacano controla su estructura,
5
5
  estado y validaciones. La definición nunca contiene HTML ni JavaScript arbitrario.
6
6
 
7
+ Un agente puede crear y editar estas mismas definiciones desde
8
+ `@bacano/sdk/content-management`, con el acceso temporal de **Conectar agente**.
9
+ Lee la sección de formularios en `CONTENT_TYPE_MANAGEMENT.md`: `forms:manage`
10
+ prepara borradores y `forms:publish` permite publicar con aprobación. Nunca
11
+ incluyas ese cliente administrativo ni su token en el storefront público.
12
+
7
13
  ## Renderer oficial para React
8
14
 
9
15
  Monta un único `BacanoProvider` global y usa la key pública configurada en el ERP:
@@ -50,7 +50,7 @@ if (!consent) return null;
50
50
  // Conserva esta clave en el estado del formulario mientras reintentas el envío.
51
51
  const idempotencyKey = crypto.randomUUID();
52
52
  const result = await client.marketing.captureLead({
53
- campaignKey: "newsletter-baby-rex",
53
+ campaignKey: "newsletter-primavera",
54
54
  idempotencyKey,
55
55
  commercialConsent: true,
56
56
  contact: {
@@ -227,6 +227,7 @@ export default function PreviewPage() {
227
227
  "use client";
228
228
 
229
229
  import { useSearchParams } from "next/navigation";
230
+ import { useState } from "react";
230
231
  import {
231
232
  BacanoPreviewShell,
232
233
  interceptPreviewLink,
@@ -265,7 +266,11 @@ const loadRoute: PreviewRouteLoader<PreviewDocument> = async ({
265
266
 
266
267
  export function PreviewClient() {
267
268
  const searchParams = useSearchParams();
268
- const route = normalizePreviewRoute(searchParams.get("route"));
269
+ // Initial value only. Held in state afterwards so `navigate` can change it
270
+ // without a reload; listen for `popstate` to keep the back button working.
271
+ const [route, setRoute] = useState(() =>
272
+ normalizePreviewRoute(searchParams.get("route")),
273
+ );
269
274
 
270
275
  return (
271
276
  <BacanoPreviewShell
@@ -274,9 +279,17 @@ export function PreviewClient() {
274
279
  loadRoute={loadRoute}
275
280
  isPreviewableRoute={(nextRoute) => previewable.has(nextRoute)}
276
281
  navigate={(nextRoute) => {
277
- window.location.assign(
282
+ // pushState, not location.assign. A full navigation unmounts the
283
+ // provider, and its route cache lives in a ref on it — so assigning
284
+ // location throws away every already-loaded route and refetches the
285
+ // one you are navigating to. Keep the route in state and let the
286
+ // provider reload it in place.
287
+ window.history.pushState(
288
+ null,
289
+ "",
278
290
  `/vista-previa/?route=${encodeURIComponent(nextRoute)}`,
279
291
  );
292
+ setRoute(nextRoute);
280
293
  }}
281
294
  toolbar={{
282
295
  websiteName: "My store",
@@ -414,10 +427,10 @@ When the storefront follows this contract:
414
427
  The build still depends on whatever Bacano data the public static pages request
415
428
  at build time. Preview does not change or replace that build contract.
416
429
 
417
- ## Validated reference implementation: Baby Rex
430
+ ## Validated reference implementation
418
431
 
419
- Baby Rex validated the official integration with `@bacano/sdk` `1.0.5` on
420
- 2026-08-06. It uses one global `BacanoProvider`; `BacanoPreviewShell` consumes
432
+ A customer storefront validated the official integration with `@bacano/sdk`
433
+ `1.0.5` on 2026-08-06. It uses one global `BacanoProvider`; `BacanoPreviewShell` consumes
421
434
  that initialized client and the loader receives `{ route, force, client }`.
422
435
 
423
436
  The validated static production build generated all `417/417` pages. TypeScript,
@@ -435,7 +448,7 @@ The implementation removed these generic storefront responsibilities:
435
448
  - Pathname-based marketing suppression inside preview renderers once the SDK
436
449
  boundary covers them.
437
450
 
438
- Baby Rex retained these storefront-specific responsibilities:
451
+ That storefront retained these storefront-specific responsibilities:
439
452
 
440
453
  - `/vista-previa/page.tsx` and its `noindex` metadata.
441
454
  - Sitemap exclusion.
@@ -445,9 +458,9 @@ Baby Rex retained these storefront-specific responsibilities:
445
458
  content-driven catalog sections.
446
459
  - `PreviewDocumentView` and all production renderers.
447
460
  - Storefront-specific skeleton, missing-content, and unsupported-route views.
448
- - The Next.js adapter that writes `?ruta={encodedRoute}`.
461
+ - The Next.js adapter that writes `?route={encodedRoute}`.
449
462
 
450
- Baby Rex is the implementation reference for integration shape, not a source to
463
+ It is the implementation reference for integration shape, not a source to
451
464
  copy blindly. Other storefronts keep their own route maps, Content keys,
452
465
  mappers, and visual components.
453
466
 
@@ -124,7 +124,7 @@ confirmado.
124
124
 
125
125
  ## Prohibiciones
126
126
 
127
- - No calcular umbrales en Baby Rex ni en otro storefront.
127
+ - No calcular umbrales en el storefront.
128
128
  - No seleccionar `WHOLESALE` directamente desde el navegador.
129
129
  - No enviar precios en operaciones de carrito o checkout.
130
130
  - No consultar Hasura ni tablas de precios mediante GraphQL directo.
@@ -1,7 +1,8 @@
1
+ import type { FormManagementClient } from "./form-management-types";
1
2
  export type ContentManagementTypeKind = "entry" | "component";
2
3
  export type ContentManagementTypeStatus = "draft" | "active" | "archived";
3
4
  export type ContentManagementTypeUsage = "website_content" | "product_additional" | "product_specification";
4
- export type ContentManagementScope = "content_types:manage" | "content_entries:manage" | "content_media:upload" | "content_entries:publish";
5
+ export type ContentManagementScope = "content_types:manage" | "content_entries:manage" | "content_media:upload" | "content_entries:publish" | "forms:manage" | "forms:publish";
5
6
  export type ContentManagementFieldType = "boolean" | "component" | "date" | "link" | "measure" | "media" | "number" | "reference" | "repeater" | "rich_text" | "rich_text_full" | "select" | "slug" | "text" | "textarea";
6
7
  export interface ContentManagementChildFieldInput {
7
8
  id?: string | null;
@@ -65,7 +66,7 @@ export interface ContentManagementType extends Omit<ContentManagementTypeInput,
65
66
  export interface ContentManagementContext {
66
67
  companyId: string;
67
68
  /** @deprecated Use scopes to inspect the complete grant. */
68
- scope: "content_types:manage";
69
+ scope: ContentManagementScope;
69
70
  scopes: ContentManagementScope[];
70
71
  expiresAt: string;
71
72
  }
@@ -259,7 +260,7 @@ export interface ContentManagementClientConfig {
259
260
  accessToken: string | ContentManagementAccessTokenProvider;
260
261
  fetch?: typeof fetch;
261
262
  }
262
- export interface ContentManagementClient {
263
+ export interface ContentManagementClient extends FormManagementClient {
263
264
  getContext(): Promise<ContentManagementContext>;
264
265
  getReferences(): Promise<ContentManagementReferences>;
265
266
  listTypes(): Promise<ContentManagementType[]>;
@@ -24,6 +24,42 @@ __export(content_management_exports, {
24
24
  createContentManagementClient: () => createContentManagementClient
25
25
  });
26
26
  module.exports = __toCommonJS(content_management_exports);
27
+
28
+ // libs/sdk/src/form-management.ts
29
+ function createFormManagementMethods(request) {
30
+ const path = (id) => `/forms/${encodeURIComponent(id.trim())}`;
31
+ return {
32
+ getFormReferences: () => request("/forms/references"),
33
+ listForms: (input = {}) => {
34
+ const search = new URLSearchParams();
35
+ for (const key of ["websiteId", "key", "limit", "offset"]) {
36
+ if (input[key] !== void 0) search.set(key, String(input[key]));
37
+ }
38
+ return request(
39
+ `/forms${search.size ? `?${search}` : ""}`
40
+ );
41
+ },
42
+ getForm: (id) => request(path(id)),
43
+ createForm: (input) => request("/forms", {
44
+ method: "POST",
45
+ body: JSON.stringify(input)
46
+ }),
47
+ updateForm: (id, input) => request(path(id), {
48
+ method: "PUT",
49
+ body: JSON.stringify(input)
50
+ }),
51
+ publishForm: (id, input) => request(`${path(id)}/publish`, {
52
+ method: "POST",
53
+ body: JSON.stringify(input)
54
+ }),
55
+ changeFormStatus: (id, input) => request(`${path(id)}/status`, {
56
+ method: "POST",
57
+ body: JSON.stringify(input)
58
+ })
59
+ };
60
+ }
61
+
62
+ // libs/sdk/src/content-management.ts
27
63
  var ContentManagementError = class extends Error {
28
64
  code;
29
65
  status;
@@ -145,6 +181,7 @@ function createContentManagementClient(config) {
145
181
  }
146
182
  );
147
183
  return {
184
+ ...createFormManagementMethods(request),
148
185
  getContext: () => request("/context"),
149
186
  getReferences: () => request("/references"),
150
187
  listTypes: () => request("/types"),
@@ -1,5 +1,6 @@
1
1
  import type { ContentManagementClient, ContentManagementClientConfig } from "./content-management-types";
2
2
  export * from "./content-management-types";
3
+ export * from "./form-management-types";
3
4
  export declare class ContentManagementError extends Error {
4
5
  readonly code: string;
5
6
  readonly status: number;
@@ -1,3 +1,37 @@
1
+ // libs/sdk/src/form-management.ts
2
+ function createFormManagementMethods(request) {
3
+ const path = (id) => `/forms/${encodeURIComponent(id.trim())}`;
4
+ return {
5
+ getFormReferences: () => request("/forms/references"),
6
+ listForms: (input = {}) => {
7
+ const search = new URLSearchParams();
8
+ for (const key of ["websiteId", "key", "limit", "offset"]) {
9
+ if (input[key] !== void 0) search.set(key, String(input[key]));
10
+ }
11
+ return request(
12
+ `/forms${search.size ? `?${search}` : ""}`
13
+ );
14
+ },
15
+ getForm: (id) => request(path(id)),
16
+ createForm: (input) => request("/forms", {
17
+ method: "POST",
18
+ body: JSON.stringify(input)
19
+ }),
20
+ updateForm: (id, input) => request(path(id), {
21
+ method: "PUT",
22
+ body: JSON.stringify(input)
23
+ }),
24
+ publishForm: (id, input) => request(`${path(id)}/publish`, {
25
+ method: "POST",
26
+ body: JSON.stringify(input)
27
+ }),
28
+ changeFormStatus: (id, input) => request(`${path(id)}/status`, {
29
+ method: "POST",
30
+ body: JSON.stringify(input)
31
+ })
32
+ };
33
+ }
34
+
1
35
  // libs/sdk/src/content-management.ts
2
36
  var ContentManagementError = class extends Error {
3
37
  code;
@@ -120,6 +154,7 @@ function createContentManagementClient(config) {
120
154
  }
121
155
  );
122
156
  return {
157
+ ...createFormManagementMethods(request),
123
158
  getContext: () => request("/context"),
124
159
  getReferences: () => request("/references"),
125
160
  listTypes: () => request("/types"),
@@ -0,0 +1,62 @@
1
+ import type { WebsiteFormAction, WebsiteFormDefinition } from "./types";
2
+ export interface ManagedFormInput {
3
+ websiteId: string;
4
+ key: string;
5
+ name: string;
6
+ description?: string;
7
+ action?: WebsiteFormAction;
8
+ notifyByEmail?: boolean;
9
+ notificationRecipients?: string[];
10
+ definition: WebsiteFormDefinition;
11
+ }
12
+ export interface ManagedForm extends Required<ManagedFormInput> {
13
+ id: string;
14
+ companyId: string;
15
+ status: "DRAFT" | "ACTIVE" | "PAUSED" | "ARCHIVED";
16
+ publishedVersion: number | null;
17
+ createdAt: string;
18
+ updatedAt: string;
19
+ }
20
+ export interface ManagedFormListInput {
21
+ websiteId?: string;
22
+ key?: string;
23
+ limit?: number;
24
+ offset?: number;
25
+ }
26
+ export interface ManagedFormList {
27
+ forms: ManagedForm[];
28
+ total: number;
29
+ }
30
+ export interface ManagedFormReferences {
31
+ websites: Array<{
32
+ id: string;
33
+ name: string;
34
+ slug: string;
35
+ isActive: boolean;
36
+ }>;
37
+ optionLists: Array<{
38
+ key: string;
39
+ name: string;
40
+ options: Array<{
41
+ value: string;
42
+ label: string;
43
+ }>;
44
+ }>;
45
+ }
46
+ export interface FormManagementClient {
47
+ getFormReferences(): Promise<ManagedFormReferences>;
48
+ listForms(input?: ManagedFormListInput): Promise<ManagedFormList>;
49
+ getForm(formId: string): Promise<ManagedForm>;
50
+ createForm(input: ManagedFormInput): Promise<ManagedForm>;
51
+ /** Replaces the full draft. Preserve existing fields and use the last read revision. */
52
+ updateForm(formId: string, input: ManagedFormInput & {
53
+ expectedUpdatedAt: string;
54
+ }): Promise<ManagedForm>;
55
+ publishForm(formId: string, input: {
56
+ expectedUpdatedAt: string;
57
+ }): Promise<ManagedForm>;
58
+ changeFormStatus(formId: string, input: {
59
+ status: "ACTIVE" | "PAUSED" | "ARCHIVED";
60
+ expectedUpdatedAt: string;
61
+ }): Promise<ManagedForm>;
62
+ }
@@ -0,0 +1,2 @@
1
+ import type { FormManagementClient } from "./form-management-types";
2
+ export declare function createFormManagementMethods(request: <T>(path: string, init?: RequestInit) => Promise<T>): FormManagementClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacano/sdk",
3
- "version": "1.1.18",
3
+ "version": "1.1.19",
4
4
  "description": "TypeScript SDK for building websites on the Bacano ERP",
5
5
  "main": "./index.cjs",
6
6
  "module": "./index.js",
@@ -96,7 +96,7 @@ Figma, desde Bacano Content o desde ambas fuentes.
96
96
  entrada completa, nunca campos individuales. Conservar esta identidad al
97
97
  transformar Content en view models y seguir
98
98
  `STOREFRONT_CONTENT_LOCATOR.md`.
99
- - No alterar Baby Rex u otra referencia salvo que ese sea el proyecto indicado.
99
+ - No alterar el storefront de referencia salvo que ese sea el proyecto indicado.
100
100
 
101
101
  Si Content todavía no existe, generar un blueprint de entrega y fixtures
102
102
  temporales fieles al contenido visible de Figma. Los fixtures deben usar el
@@ -11,6 +11,7 @@ Antes de crear campos o fixtures, asignar cada valor a una fuente:
11
11
  | Información | Fuente habitual |
12
12
  | --- | --- |
13
13
  | Títulos, textos, banners y bloques editoriales | Bacano Content |
14
+ | Campos, opciones, pasos y validaciones de formularios | Formularios de Bacano; nunca tipos de Content |
14
15
  | Productos, variantes, precios y disponibilidad | Catálogo y live state del SDK |
15
16
  | Legal, branding y configuración del sitio | `publicConfig` |
16
17
  | Carrito, checkout, cliente y pedidos | Módulo correspondiente del SDK |
@@ -19,6 +20,12 @@ Antes de crear campos o fixtures, asignar cada valor a una fuente:
19
20
  No duplicar en Content información que ya tiene una fuente funcional. Un valor
20
21
  visible en Figma no implica que sea editorial.
21
22
 
23
+ Para materializar un formulario del diseño, seguir `STOREFRONT_FORMS.md` y la
24
+ sección de formularios de `CONTENT_TYPE_MANAGEMENT.md`. El cliente temporal
25
+ admite `forms:manage` para crear/editar borradores y `forms:publish` para publicar
26
+ con confirmación. Reutilizar la definición del ERP y sus pares value/label;
27
+ no inventar un esquema paralelo. Los conflictos de revisión requieren releer.
28
+
22
29
  ## Modelar sin extremos
23
30
 
24
31
  No convertir una página extensa en un único tipo gigante ni crear un componente
@@ -3,6 +3,19 @@
3
3
  Aplicar este contrato cuando el sitio muestre al menos un formulario administrado
4
4
  desde Bacano. Leer también `STOREFRONT_FORMS.md` completo desde el paquete SDK.
5
5
 
6
+ ## Creación asistida y sincronización
7
+
8
+ Si falta el formulario, proponer su definición y pedir el acceso temporal de
9
+ **Conectar agente** en Formularios o Content. Leer `CONTENT_TYPE_MANAGEMENT.md`.
10
+ Usar `createContentManagementClient` con `forms:manage`; inventariar sitios,
11
+ listas y formularios antes de crear. Editar mediante `updateForm()` enviando
12
+ el documento completo y `expectedUpdatedAt`. Los conflictos exigen releer,
13
+ no sobreescribir. Publicar o retirar requiere `forms:publish` y confirmación.
14
+ No duplicar las definiciones en Content, GraphQL, SQL ni en fixtures permanentes.
15
+ El storefront consume la key y versión publicada usando el SDK público.
16
+ El acceso administrativo no permite leer respuestas ni PII. `CAPTURE_LEAD`
17
+ ya resuelve la identidad; no ejecutar una segunda captura por campaña.
18
+
6
19
  ## Implementación oficial
7
20
 
8
21
  - Clasificar formularios como `ACTIVA`, `INACTIVA` o `BLOQUEADA` en la matriz de