@bacano/sdk 1.1.17 → 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:
@@ -118,6 +118,14 @@ official `BacanoForm` renderer or consume the same typed definition through
118
118
  `useBacanoForm()` without GraphQL, HTML stored in the database or a rebuild
119
119
  after each publication.
120
120
 
121
+ Every visible region sourced from a Bacano `ContentEntry` must expose the
122
+ entry-level marker defined in
123
+ [`STOREFRONT_CONTENT_LOCATOR.md`](./STOREFRONT_CONTENT_LOCATOR.md). Use
124
+ `getContentEntryLocatorAttributes(entry)` on the outer section or card so an
125
+ authorized internal browser tool can show the entry name and open its Bacano
126
+ editor. This contract identifies entries only; it does not expose field-level
127
+ editing or grant backoffice access.
128
+
121
129
  Static and hybrid stores must also follow
122
130
  [`STOREFRONT_PERFORMANCE.md`](./STOREFRONT_PERFORMANCE.md). It defines the
123
131
  official build bootstrap, live-state refresh, responsive media and request
@@ -213,7 +221,7 @@ The public storefront remains static. Preview V1 never exposes drafts and
213
221
  automatically disables the SDK marketing runtime inside its tree. See
214
222
  [`STOREFRONT_PREVIEW.md`](./STOREFRONT_PREVIEW.md), the canonical contract for
215
223
  provider hierarchy, responsibilities, security, agent checklist, and the
216
- validated Baby Rex reference implementation.
224
+ validated reference implementation.
217
225
 
218
226
  ---
219
227
 
@@ -0,0 +1,111 @@
1
+ # Localizador de entradas de Content
2
+
3
+ Este documento define el contrato público V1 que permite reconocer desde el
4
+ navegador qué entrada de Bacano Content produce una región visible de una web.
5
+ El objetivo es únicamente **identificar la entrada y abrir su editor**. No es
6
+ edición inline y no identifica campos individuales.
7
+
8
+ ## Contrato obligatorio
9
+
10
+ Toda región visual cuyo contenido editorial provenga de un `ContentEntry` debe
11
+ marcar su elemento DOM exterior significativo con:
12
+
13
+ ```html
14
+ <section
15
+ data-bacano-content-entry="v1"
16
+ data-bacano-entry-key="seccion-de-servicios"
17
+ data-bacano-entry-name="Sección de servicios"
18
+ data-bacano-entry-locale="es-CO"
19
+ >
20
+ <!-- contenido visible -->
21
+ </section>
22
+ ```
23
+
24
+ Usar el helper oficial evita diferencias en nombres, versión y normalización:
25
+
26
+ ```tsx
27
+ import {
28
+ getContentEntryLocatorAttributes,
29
+ type ContentEntry,
30
+ } from "@bacano/sdk";
31
+
32
+ function ServicesSection({ entry }: { entry: ContentEntry }) {
33
+ return (
34
+ <section {...getContentEntryLocatorAttributes(entry)}>
35
+ {/* La sección puede consumir un view model derivado de entry. */}
36
+ </section>
37
+ );
38
+ }
39
+ ```
40
+
41
+ El helper falla si la entrada no tiene una `key` estable o un `locale`. Una
42
+ región que no puede abrir una entrada inequívoca no debe aparentar que sí puede.
43
+
44
+ ## Granularidad
45
+
46
+ - Marcar una vez el bloque visual administrable: `section`, `article`, banner,
47
+ tarjeta o contenedor equivalente.
48
+ - No marcar títulos, párrafos, botones ni campos individuales dentro del mismo
49
+ bloque.
50
+ - Si una tarjeta visible usa otra entrada referenciada, la tarjeta recibe su
51
+ propio marcador anidado. El espacio restante conserva el marcador de la
52
+ entrada contenedora.
53
+ - Si una entrada produce varias regiones independientes, cada región puede
54
+ repetir el mismo marcador.
55
+ - No marcar texto perteneciente al código/i18n, productos del catálogo,
56
+ configuración pública ni documentos legales como si fueran Content.
57
+
58
+ Los mappers pueden transformar `contentJson`, pero deben conservar el origen
59
+ editorial necesario para llamar el helper. Por ejemplo:
60
+
61
+ ```ts
62
+ const section = {
63
+ locator: getContentEntryLocatorAttributes(entry),
64
+ title: readTitle(entry.contentJson),
65
+ };
66
+ ```
67
+
68
+ ```tsx
69
+ <section {...section.locator}>{section.title}</section>
70
+ ```
71
+
72
+ ## Responsabilidades del consumidor
73
+
74
+ Una extensión o herramienta de inspección:
75
+
76
+ 1. busca `[data-bacano-content-entry="v1"]`;
77
+ 2. muestra `data-bacano-entry-name` al usuario;
78
+ 3. usa `data-bacano-entry-key` y `data-bacano-entry-locale` para construir la
79
+ navegación hacia el backoffice;
80
+ 4. deja autenticación, empresa y permisos bajo control de Bacano.
81
+
82
+ El SDK no publica una URL de administración ni abre ventanas. Ese destino
83
+ pertenece a la herramienta interna y puede cambiar entre ambientes.
84
+
85
+ ## Seguridad y salida estática
86
+
87
+ Los cuatro atributos contienen exclusivamente identidad editorial ya pública:
88
+ versión, key, nombre y locale de una entrada publicada. Nunca incluir tokens,
89
+ URLs administrativas firmadas, secretos, IDs de usuarios ni datos privados.
90
+
91
+ Los marcadores no conceden permisos. El backoffice debe autenticar y autorizar
92
+ cualquier lectura o modificación. En exports estáticos los atributos quedan
93
+ materializados junto con el contenido publicado y se actualizan en el siguiente
94
+ build.
95
+
96
+ ## Verificación
97
+
98
+ Antes de publicar una web que usa Bacano Content:
99
+
100
+ - inventariar las entradas que producen regiones visibles;
101
+ - inspeccionar el HTML final, no solo JSX o componentes;
102
+ - comprobar que cada región tiene exactamente la key, nombre y locale de su
103
+ entrada fuente;
104
+ - probar marcadores anidados cuando una entrada contenedora renderiza entradas
105
+ referenciadas;
106
+ - comprobar que activar una herramienta de localización no altera navegación,
107
+ layout ni accesibilidad cuando vuelve a desactivarse.
108
+
109
+ Una región editorial sin marcador o con una key distinta de su fuente es un
110
+ fallo de integración, porque impide localizar de manera confiable el contenido
111
+ que la web declara administrable.
@@ -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:
@@ -81,6 +87,47 @@ El renderer personalizado puede cambiar componentes y estilos, pero debe usar
81
87
  los campos, keys, opciones y validaciones entregados por el SDK. No debe crear
82
88
  un segundo cliente, resolver el website nuevamente ni usar GraphQL directo.
83
89
 
90
+ ## Honeypot y confirmación de persistencia
91
+
92
+ `honeypot` es un campo trampa contra bots, no un token de seguridad. No es una
93
+ marca de tiempo, un UUID, un número aleatorio ni un identificador del visitante.
94
+ Un usuario real debe enviarlo siempre vacío.
95
+
96
+ - `BacanoForm` administra el honeypot internamente. El storefront no debe crear
97
+ otro ni asignarle un valor.
98
+ - Un renderer personalizado debe omitir `honeypot` en
99
+ `client.forms.submitResponse(...)` salvo que implemente deliberadamente un
100
+ input trampa oculto. El SDK lo enviará como cadena vacía por defecto.
101
+ - Si se implementa el input trampa, debe quedar fuera del flujo visual y del
102
+ teclado, con autocompletado desactivado. Nunca debe reutilizar un campo real ni
103
+ recibir sugerencias del navegador o de un gestor de contraseñas.
104
+ - Bacano responde de manera deliberadamente genérica ante un honeypot lleno:
105
+ `accepted: true` y `responseId: null`. La respuesta no se persiste y el bot no
106
+ descubre que fue detectado.
107
+ - Para una prueba funcional o aprobación de producción, el envío debe devolver
108
+ `accepted: true` y un UUID en `responseId`, y debe aparecer una sola vez en
109
+ Respuestas dentro de Bacano.
110
+
111
+ No uses `responseId: null` para mostrar información distinta al visitante. Es
112
+ una señal técnica para pruebas y observabilidad segura, no un mensaje que deba
113
+ revelar la protección antispam.
114
+
115
+ ## Tipos semánticos de contacto
116
+
117
+ La etiqueta y la key no sustituyen el tipo del campo. Si un dato representa un
118
+ correo o un teléfono, el formulario debe definirlo como `email` o `phone`:
119
+
120
+ - Bacano valida el formato según el tipo declarado.
121
+ - Las respuestas conservan todos los valores, pero solo los campos `email` y
122
+ `phone` alimentan los datos de contacto normalizados de la respuesta.
123
+ - Un campo `text` llamado `correo`, `email`, `telefono` o similar continúa
124
+ siendo texto libre. No debe dependerse de su nombre para CRM, leads o futuras
125
+ automatizaciones.
126
+
127
+ Antes de publicar, revisa que los campos destinados a identificar o contactar a
128
+ la persona tengan el tipo correcto. Cambiar únicamente su etiqueta no corrige el
129
+ contrato de datos.
130
+
84
131
  ## Next.js y Static First
85
132
 
86
133
  Obtén la definición durante el build o desde un Server Component usando el
@@ -127,15 +174,32 @@ consultarlo.
127
174
  validación del navegador mejora la experiencia, pero no es autoritativa.
128
175
  - URL, referidor y atribución se sanean. Las respuestas y datos personales no
129
176
  se escriben en logs.
130
- - Una falla futura de correo, CRM o automatización no revierte la respuesta.
131
- - `CAPTURE_LEAD` se procesa en una fase posterior. Toda respuesta se conserva
132
- primero y de forma independiente.
177
+ - `pageUrl`, `referrerUrl` y la URL de atribución aceptan únicamente HTTP o
178
+ HTTPS. No envíes protocolos locales, ejecutables o de datos.
179
+ - Los teléfonos admiten un `+` inicial y separadores habituales; Bacano los
180
+ normaliza antes de resolver identidad y rechaza letras o signos mal ubicados.
181
+ - Los números usan notación decimal o científica explícita. Valores ambiguos
182
+ como `0x10` o `3 unidades` se rechazan aunque JavaScript pudiera convertirlos.
183
+ - Un checkbox opcional omitido se conserva como `null`; uno requerido debe
184
+ enviarse expresamente como `true`.
185
+ - Una falla de correo, CRM o automatización no revierte la respuesta.
186
+ - `STORE_RESPONSE` conserva la respuesta sin crear un lead.
187
+ - `CAPTURE_LEAD` conserva primero la respuesta y después resuelve internamente
188
+ la identidad canónica. Reutiliza un lead o cliente existente, crea un lead
189
+ cuando corresponde y manda a revisión las coincidencias contradictorias de
190
+ correo y teléfono sin fusionarlas automáticamente.
191
+ - El resultado público continúa siendo deliberadamente genérico. El storefront
192
+ no recibe ni debe inferir si Bacano creó un lead, encontró un cliente o marcó
193
+ una identidad para revisión.
133
194
 
134
195
  ## Checklist mínimo del storefront
135
196
 
136
197
  - Usar un solo `BacanoProvider` y el cliente global ya inicializado.
137
198
  - Renderizar con `BacanoForm` o con `useBacanoForm`; nunca copiar la definición
138
199
  a un esquema local ni consultar GraphQL directamente.
200
+ - Pueden coexistir varios `BacanoForm` en una página. El renderer oficial aísla
201
+ los identificadores de sus controles; no construyas IDs globales solo con la
202
+ key del campo.
139
203
  - Entregar `initialForm` cuando la página se genera de forma estática.
140
204
  - Respetar `ACTIVE`, `PAUSED`, versión y `definitionHash` sin inferir estados.
141
205
  - Mantener una `idempotencyKey` estable durante los reintentos del mismo envío.
@@ -143,5 +207,11 @@ consultarlo.
143
207
  reintento idempotente y error de red.
144
208
  - Confirmar en Bacano que una respuesta real quedó persistida antes de aprobar
145
209
  el flujo; no basta con mostrar el mensaje de éxito del navegador.
146
- - No prometer correo, lead, solicitud o automatización mientras la capacidad no
147
- esté implementada y activa en Bacano.
210
+ - Probar autocompletado del navegador y gestores de contraseñas: ningún campo
211
+ real puede llenar el honeypot. Un envío real debe devolver un UUID como
212
+ `responseId`; `null` no demuestra persistencia.
213
+ - Verificar que correos y teléfonos utilicen los tipos `email` y `phone`, no
214
+ campos de texto con una etiqueta parecida.
215
+ - No prometer correo, cupón, solicitud ni automatización mientras la capacidad
216
+ no esté implementada y activa en Bacano. Aunque `CAPTURE_LEAD` esté activo,
217
+ el mensaje público no debe afirmar qué identidad interna se creó o reutilizó.
@@ -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.
@@ -0,0 +1,16 @@
1
+ import type { ContentEntry } from "./types";
2
+ export declare const CONTENT_ENTRY_LOCATOR_VERSION: "v1";
3
+ export declare const CONTENT_ENTRY_LOCATOR_SELECTOR: "[data-bacano-content-entry=\"v1\"]";
4
+ export type ContentEntryLocatorSource = Pick<ContentEntry, "key" | "locale" | "name">;
5
+ export interface ContentEntryLocatorAttributes {
6
+ "data-bacano-content-entry": typeof CONTENT_ENTRY_LOCATOR_VERSION;
7
+ "data-bacano-entry-key": string;
8
+ "data-bacano-entry-locale": string;
9
+ "data-bacano-entry-name": string;
10
+ }
11
+ /**
12
+ * Adds the public, non-authoritative marker consumed by Bacano's content
13
+ * locator. Spread the result only on the outermost meaningful DOM element
14
+ * whose visible editorial content is owned by the entry.
15
+ */
16
+ export declare function getContentEntryLocatorAttributes(entry: ContentEntryLocatorSource): ContentEntryLocatorAttributes;
@@ -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/index.cjs CHANGED
@@ -21,6 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AuthError: () => AuthError,
24
+ CONTENT_ENTRY_LOCATOR_SELECTOR: () => CONTENT_ENTRY_LOCATOR_SELECTOR,
25
+ CONTENT_ENTRY_LOCATOR_VERSION: () => CONTENT_ENTRY_LOCATOR_VERSION,
24
26
  CheckoutError: () => CheckoutError,
25
27
  GraphQLRequestError: () => GraphQLRequestError,
26
28
  MARKETING_SCHEMA_VERSION: () => MARKETING_SCHEMA_VERSION,
@@ -38,6 +40,7 @@ __export(index_exports, {
38
40
  createWebsiteMarketingRuntime: () => createWebsiteMarketingRuntime,
39
41
  formatMediaSrcSet: () => formatMediaSrcSet,
40
42
  getAllowedMarketingParameters: () => getAllowedMarketingParameters,
43
+ getContentEntryLocatorAttributes: () => getContentEntryLocatorAttributes,
41
44
  getPrimaryProductImage: () => getPrimaryProductImage,
42
45
  getProductDetailVariants: () => getProductDetailVariants,
43
46
  getProductImages: () => getProductImages,
@@ -6904,3 +6907,23 @@ function createBacanoClient(config) {
6904
6907
  }
6905
6908
  };
6906
6909
  }
6910
+
6911
+ // libs/sdk/src/content-entry-locator.ts
6912
+ var CONTENT_ENTRY_LOCATOR_VERSION = "v1";
6913
+ var CONTENT_ENTRY_LOCATOR_SELECTOR = `[data-bacano-content-entry="${CONTENT_ENTRY_LOCATOR_VERSION}"]`;
6914
+ function getContentEntryLocatorAttributes(entry) {
6915
+ const key = entry.key?.trim();
6916
+ if (!key) {
6917
+ throw new Error("A Bacano Content locator requires a stable entry key.");
6918
+ }
6919
+ const locale = entry.locale.trim();
6920
+ if (!locale) {
6921
+ throw new Error("A Bacano Content locator requires an entry locale.");
6922
+ }
6923
+ return {
6924
+ "data-bacano-content-entry": CONTENT_ENTRY_LOCATOR_VERSION,
6925
+ "data-bacano-entry-key": key,
6926
+ "data-bacano-entry-locale": locale,
6927
+ "data-bacano-entry-name": entry.name.trim() || key
6928
+ };
6929
+ }
package/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type { BacanoClient } from "./client";
2
2
  export { createBacanoClient } from "./client";
3
+ export type { ContentEntryLocatorAttributes, ContentEntryLocatorSource, } from "./content-entry-locator";
4
+ export { CONTENT_ENTRY_LOCATOR_SELECTOR, CONTENT_ENTRY_LOCATOR_VERSION, getContentEntryLocatorAttributes, } from "./content-entry-locator";
3
5
  export { AuthError, CheckoutError, GraphQLRequestError, NotInitializedError, RateLimitError, WebsiteError, } from "./errors";
4
6
  export type { WebsiteAdvertisingConsent, WebsiteMarketingAttribution, WebsiteMarketingBrowserEvent, WebsiteMarketingBrowserEventName, WebsiteMarketingContactPlacement, WebsiteMarketingDiagnostic, WebsiteMarketingItem, WebsiteMarketingRuntime, WebsiteMarketingRuntimeOptions, WebsiteMarketingScriptLoadStrategy, WebsiteMarketingTouchpoint, } from "./marketing";
5
7
  export { captureWebsiteMarketingAttribution, createWebsiteMarketingRuntime, getAllowedMarketingParameters, getWebsiteMarketingAttribution, MARKETING_SCHEMA_VERSION, sanitizeMarketingUrl, WEBSITE_MARKETING_CONTACT_PLACEMENTS, } from "./marketing";
package/index.js CHANGED
@@ -6850,8 +6850,30 @@ function createBacanoClient(config) {
6850
6850
  }
6851
6851
  };
6852
6852
  }
6853
+
6854
+ // libs/sdk/src/content-entry-locator.ts
6855
+ var CONTENT_ENTRY_LOCATOR_VERSION = "v1";
6856
+ var CONTENT_ENTRY_LOCATOR_SELECTOR = `[data-bacano-content-entry="${CONTENT_ENTRY_LOCATOR_VERSION}"]`;
6857
+ function getContentEntryLocatorAttributes(entry) {
6858
+ const key = entry.key?.trim();
6859
+ if (!key) {
6860
+ throw new Error("A Bacano Content locator requires a stable entry key.");
6861
+ }
6862
+ const locale = entry.locale.trim();
6863
+ if (!locale) {
6864
+ throw new Error("A Bacano Content locator requires an entry locale.");
6865
+ }
6866
+ return {
6867
+ "data-bacano-content-entry": CONTENT_ENTRY_LOCATOR_VERSION,
6868
+ "data-bacano-entry-key": key,
6869
+ "data-bacano-entry-locale": locale,
6870
+ "data-bacano-entry-name": entry.name.trim() || key
6871
+ };
6872
+ }
6853
6873
  export {
6854
6874
  AuthError,
6875
+ CONTENT_ENTRY_LOCATOR_SELECTOR,
6876
+ CONTENT_ENTRY_LOCATOR_VERSION,
6855
6877
  CheckoutError,
6856
6878
  GraphQLRequestError,
6857
6879
  MARKETING_SCHEMA_VERSION,
@@ -6869,6 +6891,7 @@ export {
6869
6891
  createWebsiteMarketingRuntime,
6870
6892
  formatMediaSrcSet,
6871
6893
  getAllowedMarketingParameters,
6894
+ getContentEntryLocatorAttributes,
6872
6895
  getPrimaryProductImage,
6873
6896
  getProductDetailVariants,
6874
6897
  getProductImages,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacano/sdk",
3
- "version": "1.1.17",
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",
@@ -3,6 +3,7 @@ import type { BacanoFormValues } from "./forms";
3
3
  type BacanoFormValue = BacanoFormValues[string];
4
4
  export declare function BacanoFormFieldControl(props: {
5
5
  field: WebsiteFormField;
6
+ id?: string;
6
7
  className?: string;
7
8
  value: BacanoFormValue;
8
9
  onChange(value: BacanoFormValue): void;
package/react/forms.cjs CHANGED
@@ -30,8 +30,9 @@ var import_react3 = require("react");
30
30
  var import_jsx_runtime = require("react/jsx-runtime");
31
31
  function BacanoFormFieldControl(props) {
32
32
  const { field, className, value, onChange } = props;
33
+ const controlId = props.id ?? field.key;
33
34
  const common = {
34
- id: field.key,
35
+ id: controlId,
35
36
  name: field.key,
36
37
  required: field.required,
37
38
  className
@@ -71,7 +72,7 @@ function BacanoFormFieldControl(props) {
71
72
  );
72
73
  }
73
74
  if (field.type === "radio") {
74
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { role: "radiogroup", "aria-labelledby": `${field.key}-label`, children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
75
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { role: "radiogroup", "aria-labelledby": `${controlId}-label`, children: (field.options ?? []).map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
75
76
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
76
77
  "input",
77
78
  {
@@ -210,13 +211,14 @@ function useBacanoForm(formKey, options) {
210
211
  }
211
212
  function FormFields(props) {
212
213
  return props.fields.map((field) => {
214
+ const controlId = `${props.idPrefix}-${field.key}`;
213
215
  const value = props.values[field.key] ?? (field.type === "checkbox" ? false : field.type === "multi_select" ? [] : "");
214
216
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: props.classNames?.field, children: [
215
217
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
216
218
  "label",
217
219
  {
218
- id: `${field.key}-label`,
219
- htmlFor: field.type === "radio" ? void 0 : field.key,
220
+ id: `${controlId}-label`,
221
+ htmlFor: field.type === "radio" ? void 0 : controlId,
220
222
  className: props.classNames?.label,
221
223
  children: field.label
222
224
  }
@@ -225,6 +227,7 @@ function FormFields(props) {
225
227
  BacanoFormFieldControl,
226
228
  {
227
229
  field,
230
+ id: controlId,
228
231
  value,
229
232
  className: props.classNames?.input,
230
233
  onChange: (nextValue) => props.onChange(field.key, nextValue)
@@ -242,6 +245,7 @@ function FormSections(props) {
242
245
  FormFields,
243
246
  {
244
247
  fields: section.fields,
248
+ idPrefix: props.idPrefix,
245
249
  values: props.values,
246
250
  classNames: props.classNames,
247
251
  onChange: props.onChange
@@ -250,6 +254,8 @@ function FormSections(props) {
250
254
  ] }, section.key));
251
255
  }
252
256
  function BacanoForm(props) {
257
+ const reactId = (0, import_react3.useId)();
258
+ const idPrefix = `${props.formKey}-${reactId.replace(/:/g, "")}`;
253
259
  const { client } = useBacanoState();
254
260
  const { form, loading, error } = useBacanoForm(props.formKey, {
255
261
  initialForm: props.initialForm
@@ -382,6 +388,7 @@ function BacanoForm(props) {
382
388
  FormFields,
383
389
  {
384
390
  fields: activeStep.fields,
391
+ idPrefix,
385
392
  values,
386
393
  classNames: props.classNames,
387
394
  onChange: setFieldValue
@@ -391,6 +398,7 @@ function BacanoForm(props) {
391
398
  FormSections,
392
399
  {
393
400
  sections: activeStep.sections,
401
+ idPrefix,
394
402
  values,
395
403
  classNames: props.classNames,
396
404
  onChange: setFieldValue
@@ -401,6 +409,7 @@ function BacanoForm(props) {
401
409
  FormFields,
402
410
  {
403
411
  fields,
412
+ idPrefix,
404
413
  values,
405
414
  classNames: props.classNames,
406
415
  onChange: setFieldValue
@@ -410,6 +419,7 @@ function BacanoForm(props) {
410
419
  FormSections,
411
420
  {
412
421
  sections,
422
+ idPrefix,
413
423
  values,
414
424
  classNames: props.classNames,
415
425
  onChange: setFieldValue
package/react/forms.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  useCallback as useCallback2,
4
4
  useEffect as useEffect2,
5
+ useId,
5
6
  useMemo,
6
7
  useRef as useRef2,
7
8
  useState as useState2
@@ -11,8 +12,9 @@ import {
11
12
  import { jsx, jsxs } from "react/jsx-runtime";
12
13
  function BacanoFormFieldControl(props) {
13
14
  const { field, className, value, onChange } = props;
15
+ const controlId = props.id ?? field.key;
14
16
  const common = {
15
- id: field.key,
17
+ id: controlId,
16
18
  name: field.key,
17
19
  required: field.required,
18
20
  className
@@ -52,7 +54,7 @@ function BacanoFormFieldControl(props) {
52
54
  );
53
55
  }
54
56
  if (field.type === "radio") {
55
- return /* @__PURE__ */ jsx("div", { role: "radiogroup", "aria-labelledby": `${field.key}-label`, children: (field.options ?? []).map((option) => /* @__PURE__ */ jsxs("label", { children: [
57
+ return /* @__PURE__ */ jsx("div", { role: "radiogroup", "aria-labelledby": `${controlId}-label`, children: (field.options ?? []).map((option) => /* @__PURE__ */ jsxs("label", { children: [
56
58
  /* @__PURE__ */ jsx(
57
59
  "input",
58
60
  {
@@ -191,13 +193,14 @@ function useBacanoForm(formKey, options) {
191
193
  }
192
194
  function FormFields(props) {
193
195
  return props.fields.map((field) => {
196
+ const controlId = `${props.idPrefix}-${field.key}`;
194
197
  const value = props.values[field.key] ?? (field.type === "checkbox" ? false : field.type === "multi_select" ? [] : "");
195
198
  return /* @__PURE__ */ jsxs2("div", { className: props.classNames?.field, children: [
196
199
  /* @__PURE__ */ jsx2(
197
200
  "label",
198
201
  {
199
- id: `${field.key}-label`,
200
- htmlFor: field.type === "radio" ? void 0 : field.key,
202
+ id: `${controlId}-label`,
203
+ htmlFor: field.type === "radio" ? void 0 : controlId,
201
204
  className: props.classNames?.label,
202
205
  children: field.label
203
206
  }
@@ -206,6 +209,7 @@ function FormFields(props) {
206
209
  BacanoFormFieldControl,
207
210
  {
208
211
  field,
212
+ id: controlId,
209
213
  value,
210
214
  className: props.classNames?.input,
211
215
  onChange: (nextValue) => props.onChange(field.key, nextValue)
@@ -223,6 +227,7 @@ function FormSections(props) {
223
227
  FormFields,
224
228
  {
225
229
  fields: section.fields,
230
+ idPrefix: props.idPrefix,
226
231
  values: props.values,
227
232
  classNames: props.classNames,
228
233
  onChange: props.onChange
@@ -231,6 +236,8 @@ function FormSections(props) {
231
236
  ] }, section.key));
232
237
  }
233
238
  function BacanoForm(props) {
239
+ const reactId = useId();
240
+ const idPrefix = `${props.formKey}-${reactId.replace(/:/g, "")}`;
234
241
  const { client } = useBacanoState();
235
242
  const { form, loading, error } = useBacanoForm(props.formKey, {
236
243
  initialForm: props.initialForm
@@ -363,6 +370,7 @@ function BacanoForm(props) {
363
370
  FormFields,
364
371
  {
365
372
  fields: activeStep.fields,
373
+ idPrefix,
366
374
  values,
367
375
  classNames: props.classNames,
368
376
  onChange: setFieldValue
@@ -372,6 +380,7 @@ function BacanoForm(props) {
372
380
  FormSections,
373
381
  {
374
382
  sections: activeStep.sections,
383
+ idPrefix,
375
384
  values,
376
385
  classNames: props.classNames,
377
386
  onChange: setFieldValue
@@ -382,6 +391,7 @@ function BacanoForm(props) {
382
391
  FormFields,
383
392
  {
384
393
  fields,
394
+ idPrefix,
385
395
  values,
386
396
  classNames: props.classNames,
387
397
  onChange: setFieldValue
@@ -391,6 +401,7 @@ function BacanoForm(props) {
391
401
  FormSections,
392
402
  {
393
403
  sections,
404
+ idPrefix,
394
405
  values,
395
406
  classNames: props.classNames,
396
407
  onChange: setFieldValue
@@ -91,7 +91,12 @@ Figma, desde Bacano Content o desde ambas fuentes.
91
91
  los contratos específicos del SDK.
92
92
  - No usar GraphQL directo, endpoints internos ni lógica comercial duplicada.
93
93
  - No codificar textos editables si pertenecen a Bacano Content.
94
- - No alterar Baby Rex u otra referencia salvo que ese sea el proyecto indicado.
94
+ - Marcar obligatoriamente cada región visual proveniente de un `ContentEntry`
95
+ con `getContentEntryLocatorAttributes(entry)`. El marcador identifica la
96
+ entrada completa, nunca campos individuales. Conservar esta identidad al
97
+ transformar Content en view models y seguir
98
+ `STOREFRONT_CONTENT_LOCATOR.md`.
99
+ - No alterar el storefront de referencia salvo que ese sea el proyecto indicado.
95
100
 
96
101
  Si Content todavía no existe, generar un blueprint de entrega y fixtures
97
102
  temporales fieles al contenido visible de Figma. Los fixtures deben usar el
@@ -147,6 +152,9 @@ proyecto.
147
152
  Antes de aprobar producción, comprobar que la fuente editorial efectiva sea
148
153
  Bacano Content. Un fixture, placeholder o fallback temporal accesible en el
149
154
  build de producción es un bloqueo, aunque visualmente coincida con Figma.
155
+ Inspeccionar además el HTML final: toda región alimentada por una entrada debe
156
+ conservar su marcador V1 con la key, nombre y locale correctos. Una región
157
+ editorial sin marcador bloquea la aprobación.
150
158
 
151
159
  Al finalizar, invocar `$bacano-web` si está disponible para la auditoría de
152
160
  producción completa. Esta skill valida fidelidad y correcta integración desde
@@ -7,9 +7,9 @@ negocio.
7
7
 
8
8
  Antes de programar, registrar por ruta:
9
9
 
10
- | Ruta | Frame | Componente | Fuente | Content | Responsive | Preview | Estado |
11
- | --- | --- | --- | --- | --- | --- | --- | --- |
12
- | `/` | Home desktop/mobile | `Hero` | Content | `hero_inicio` | 2 frames | activa | pendiente |
10
+ | Ruta | Frame | Componente | Fuente | Content | Localizador | Responsive | Preview | Estado |
11
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- |
12
+ | `/` | Home desktop/mobile | `Hero` | Content | `hero_inicio` | entrada V1 | 2 frames | activa | pendiente |
13
13
 
14
14
  Fuentes posibles: Content, catálogo estático, live state, public config,
15
15
  checkout, auth u otra operación documentada del SDK.
@@ -18,6 +18,8 @@ checkout, auth u otra operación documentada del SDK.
18
18
 
19
19
  - Usar componentes y tokens existentes antes de crear otros.
20
20
  - Mapear textos/imágenes editoriales a Content cuando deban ser administrables.
21
+ - Para cada fuente `ContentEntry`, conservar y renderizar el marcador de entrada
22
+ definido en `STOREFRONT_CONTENT_LOCATOR.md`; no bajar a nivel de campo.
21
23
  - Si el equipo de Content y desarrollo trabajan en tiempos distintos, seguir
22
24
  [content-workflow.md](content-workflow.md) y mantener un único contrato de
23
25
  datos entre fixtures temporales y Bacano real.
@@ -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
@@ -35,6 +42,10 @@ reduzca duplicación real.
35
42
  consumidores.
36
43
  - No hacer depender la UI del shape crudo de Content: fixture y mapper real
37
44
  deben converger en el mismo view model.
45
+ - Conservar en ese view model los atributos producidos por
46
+ `getContentEntryLocatorAttributes(entry)`. Toda región visual administrada
47
+ por una entrada debe exponerlos en su contenedor DOM exterior; no se marcan
48
+ campos individuales.
38
49
 
39
50
  ## 2. Elegir el punto de partida
40
51
 
@@ -138,6 +149,10 @@ Bacano mapper ─┘
138
149
  El cambio de fuente debe ocurrir en el loader o repositorio de contenido, nunca
139
150
  mediante condicionales repartidos por los componentes.
140
151
 
152
+ Los fixtures pueden simular el view model, pero no deben inventar marcadores de
153
+ entrada en producción. El marcador definitivo se deriva del `ContentEntry`
154
+ real para que nombre, key y locale no diverjan.
155
+
141
156
  ## 5. Entrega al equipo de Content
142
157
 
143
158
  Entregar una tabla legible con:
@@ -167,7 +182,9 @@ Antes de retirar el fixture:
167
182
  2. Validar campos requeridos, media y contenido largo.
168
183
  3. Comparar la página con Figma usando Content real.
169
184
  4. Verificar preview si la capacidad está activa.
170
- 5. Eliminar el fixture del camino de ejecución, no necesariamente de los tests.
185
+ 5. Inspeccionar el HTML y confirmar los marcadores definidos en
186
+ `STOREFRONT_CONTENT_LOCATOR.md` para cada región editorial.
187
+ 6. Eliminar el fixture del camino de ejecución, no necesariamente de los tests.
171
188
 
172
189
  En producción no se permite fallback silencioso a fixtures. Si una página
173
190
  depende de contenido temporal, reportar `❌ NO LISTO` con las rutas y secciones
@@ -58,6 +58,18 @@ no aceptar una ruta arbitraria solo porque coincide con una cadena. Preview
58
58
  reutiliza componentes y mappers de producción, permanece fuera del sitemap y
59
59
  no habilita funciones transaccionales o marketing.
60
60
 
61
+ ## Trazabilidad editorial
62
+
63
+ Al mapear un `ContentEntry`, conservar los atributos retornados por
64
+ `getContentEntryLocatorAttributes(entry)` hasta el componente visual. Aplicar
65
+ el spread al `section`, `article`, banner o tarjeta exterior que representa la
66
+ entrada. No repetirlo en cada título o párrafo. Si una tarjeta proviene de otra
67
+ entrada referenciada, esa tarjeta lleva su propio marcador anidado.
68
+
69
+ La salida final debe cumplir `STOREFRONT_CONTENT_LOCATOR.md`. Verificar el HTML
70
+ generado; encontrar la llamada al helper en TypeScript no demuestra que el
71
+ marcador haya llegado al DOM.
72
+
61
73
  ## Diagnóstico antes de cambiar código
62
74
 
63
75
  Un `ORIGIN_NOT_ALLOWED` o un `403` al resolver la configuración durante el
@@ -47,7 +47,8 @@ Después leer solo:
47
47
  Si `@bacano/sdk` está disponible, leer sus contratos canónicos aplicables antes
48
48
  de proponer cambios. Como mínimo revisar `STOREFRONT_LEGAL.md`,
49
49
  `STOREFRONT_MARKETING.md`, `STOREFRONT_PERFORMANCE.md` y
50
- `STOREFRONT_PREVIEW.md`; añadir
50
+ `STOREFRONT_PREVIEW.md`; si el sitio renderiza Bacano Content, leer también y
51
+ aplicar obligatoriamente `STOREFRONT_CONTENT_LOCATOR.md`; añadir
51
52
  `AUTHENTICATION.md`, `STOREFRONT_LEADS.md`, `STOREFRONT_CLARITY.md` y
52
53
  otros contratos únicamente cuando esas capacidades existan. Leer
53
54
  `STOREFRONT_CONSTRUCTION.md` cuando el website permita ocultarse temporalmente.
@@ -70,6 +71,8 @@ ejecutar compras/pagos sin autorización explícita.
70
71
  5. Comparar router, salida del build y sitemap; ninguna ruta pública debe
71
72
  omitirse silenciosamente.
72
73
  6. Inspeccionar HTML generado, metadata, JSON-LD, media y enlaces internos.
74
+ Si existe Content, comparar las entradas renderizadas con los marcadores V1
75
+ presentes en ese HTML; revisar únicamente el JSX no es evidencia suficiente.
73
76
  7. Si existe un dominio o una aplicación ejecutable, auditar sus rutas reales,
74
77
  la consola y un trace de red con nombres y conteos de operaciones GraphQL.
75
78
  8. Probar vistas representativas desktop y mobile.
@@ -29,6 +29,13 @@ función apagada en un bloqueo artificial.
29
29
  - Comprobar que preview, callbacks, cuenta, checkout y respuestas de pago no se
30
30
  indexen cuando corresponda.
31
31
  - Validar estados vacío, loading, error y ausencia de Content opcional.
32
+ - `P0`: toda región visual cuya fuente sea un `ContentEntry` expone en el HTML
33
+ final el marcador de `STOREFRONT_CONTENT_LOCATOR.md`, con la key, nombre y
34
+ locale de la entrada fuente. El control es por entrada completa, no por campo.
35
+ - Comparar inventario de entradas y regiones. Permitir marcadores repetidos si
36
+ una entrada aparece en varios lugares y marcadores anidados cuando una tarjeta
37
+ proviene de otra entrada referenciada. No exigirlos a código/i18n, catálogo,
38
+ `publicConfig` ni documentos legales.
32
39
 
33
40
  ## SEO y compartición
34
41
 
@@ -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
@@ -23,12 +36,25 @@ desde Bacano. Leer también `STOREFRONT_FORMS.md` completo desde el paquete SDK.
23
36
 
24
37
  - Enviar mediante `client.forms.submitResponse()` o el envío interno de
25
38
  `BacanoForm`.
39
+ - `honeypot` es un campo trampa, no un nonce, timestamp, UUID ni dato del
40
+ visitante. `BacanoForm` lo administra. En un renderer manual, omitirlo salvo
41
+ que exista un input trampa realmente oculto; para una persona siempre debe
42
+ permanecer vacío y el autocompletado no debe llenarlo.
43
+ - `accepted: true` con `responseId: null` es una aceptación silenciosa antispam:
44
+ no demuestra que la respuesta fue guardada. La verificación funcional exige
45
+ un UUID y la respuesta visible una sola vez en Bacano.
46
+ - Los datos de contacto deben conservar su semántica: usar campos `email` y
47
+ `phone`. Un campo `text` llamado correo o teléfono se guarda como texto libre
48
+ y no alimenta los datos de contacto normalizados para CRM o automatizaciones.
26
49
  - Conservar una `idempotencyKey` durante reintentos del mismo intento lógico.
27
50
  - Enviar la versión y `definitionHash` exactos de la definición renderizada.
28
51
  - Respetar estados `ACTIVE` y `PAUSED`; no renderizar borradores o archivados.
29
52
  - No escribir respuestas o PII en consola, analítica, URLs o logs del storefront.
30
- - No afirmar que se creó un lead, se envió un correo o se abrió una solicitud si
31
- esa acción aún no está implementada y verificada en Bacano.
53
+ - En `CAPTURE_LEAD`, Bacano conserva primero la respuesta y resuelve la identidad
54
+ internamente. No afirmar al visitante que se creó un lead o que ya era cliente:
55
+ la respuesta pública es genérica y no expone ese resultado.
56
+ - No afirmar que se envió un correo, se entregó un cupón o se abrió una solicitud
57
+ si esa acción aún no está implementada y verificada en Bacano.
32
58
  - Una brecha del SDK se reporta; nunca se resuelve con GraphQL directo ni una API
33
59
  paralela del storefront.
34
60
 
@@ -41,6 +67,10 @@ desde Bacano. Leer también `STOREFRONT_FORMS.md` completo desde el paquete SDK.
41
67
  - Error de red: estado recuperable sin promesa falsa de recepción.
42
68
  - Persistencia: la respuesta aparece una sola vez en Bacano con versión, origen y
43
69
  snapshot correctos.
70
+ - Autocompletado: completar datos con el navegador o un gestor de contraseñas no
71
+ llena el honeypot ni produce `responseId: null`.
72
+ - Semántica: correo y teléfono usan `email` y `phone`; no se infieren por label o
73
+ key.
44
74
  - Accesibilidad: label asociado, foco, teclado, mensajes de error y progreso de
45
75
  pasos comprensibles.
46
76
  - Static First: el HTML exportado conserva el formulario cuando corresponde.
package/types.d.ts CHANGED
@@ -224,11 +224,15 @@ export interface WebsiteFormResponseInput {
224
224
  idempotencyKey: string;
225
225
  values: Record<string, WebsiteFormResponseValue>;
226
226
  source?: WebsiteFormResponseSource;
227
- /** Bot-trap field. Storefronts should render it visually hidden and leave it empty. */
227
+ /**
228
+ * Bot-trap field, not a token or nonce. Omit it unless a real hidden trap is
229
+ * rendered; legitimate visitors must always submit an empty value.
230
+ */
228
231
  honeypot?: string;
229
232
  }
230
233
  export interface WebsiteFormResponseResult {
231
234
  accepted: boolean;
235
+ /** A null value is not proof of persistence (for example, an anti-bot response). */
232
236
  responseId: string | null;
233
237
  message: string;
234
238
  }