@bacano/sdk 1.1.13 → 1.1.17

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.
package/react.js CHANGED
@@ -12,10 +12,10 @@ var WebsiteError = class extends Error {
12
12
  }
13
13
  };
14
14
  var RateLimitError = class extends WebsiteError {
15
- constructor(message, retryAfterSeconds) {
15
+ constructor(message, retryAfterSeconds2) {
16
16
  super(message, "RATE_LIMITED", {
17
17
  status: 429,
18
- retryAfterSeconds
18
+ retryAfterSeconds: retryAfterSeconds2
19
19
  });
20
20
  this.name = "RateLimitError";
21
21
  }
@@ -6445,6 +6445,171 @@ function createContentModule(graphqlUrl, getHeaders, getContext, storageUrl) {
6445
6445
  };
6446
6446
  }
6447
6447
 
6448
+ // libs/sdk/src/modules/forms.ts
6449
+ function retryAfterSeconds(response) {
6450
+ const value = Number(response.headers.get("Retry-After") ?? "1");
6451
+ return Number.isFinite(value) && value >= 0 ? Math.ceil(value) : 1;
6452
+ }
6453
+ function isFormKey(value) {
6454
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
6455
+ }
6456
+ function createFormsModule(apiUrl, websiteSlug, getContext) {
6457
+ const cache = /* @__PURE__ */ new Map();
6458
+ function getServerOrigin() {
6459
+ if (typeof window !== "undefined") return null;
6460
+ const domain = getContext().domain?.trim();
6461
+ if (!domain) return null;
6462
+ try {
6463
+ return new URL(domain.includes("://") ? domain : `https://${domain}`).origin;
6464
+ } catch {
6465
+ return null;
6466
+ }
6467
+ }
6468
+ return {
6469
+ async getDefinition(formKey, options) {
6470
+ const normalizedKey = formKey.trim();
6471
+ if (!isFormKey(normalizedKey)) {
6472
+ throw new WebsiteError(
6473
+ "formKey has an invalid format",
6474
+ "INVALID_FORM_KEY"
6475
+ );
6476
+ }
6477
+ const cached = cache.get(normalizedKey);
6478
+ if (!options?.force && cached && cached.expiresAt > Date.now()) {
6479
+ return cached.value;
6480
+ }
6481
+ const url = new URL(
6482
+ `${apiUrl.replace(/\/+$/, "")}/api/v1/website/forms/definition`
6483
+ );
6484
+ url.searchParams.set("websiteSlug", websiteSlug);
6485
+ url.searchParams.set("formKey", normalizedKey);
6486
+ let response;
6487
+ try {
6488
+ const serverOrigin = getServerOrigin();
6489
+ const headers = new Headers();
6490
+ if (serverOrigin) headers.set("Origin", serverOrigin);
6491
+ if (cached?.etag) headers.set("If-None-Match", cached.etag);
6492
+ response = await fetch(url, {
6493
+ ...serverOrigin || cached?.etag ? { headers } : {},
6494
+ ...options?.force ? { cache: "no-cache" } : {}
6495
+ });
6496
+ } catch (error) {
6497
+ throw new WebsiteError(
6498
+ `Could not load the website form: ${error instanceof Error ? error.message : String(error)}`,
6499
+ "NETWORK_ERROR"
6500
+ );
6501
+ }
6502
+ if (response.status === 304 && cached) {
6503
+ cache.set(normalizedKey, {
6504
+ ...cached,
6505
+ expiresAt: Date.now() + 3e4
6506
+ });
6507
+ return cached.value;
6508
+ }
6509
+ if (response.status === 404) {
6510
+ cache.set(normalizedKey, {
6511
+ etag: null,
6512
+ expiresAt: Date.now() + 15e3,
6513
+ value: null
6514
+ });
6515
+ return null;
6516
+ }
6517
+ if (response.status === 429) {
6518
+ throw new RateLimitError(
6519
+ "Rate limit exceeded. Please try again later.",
6520
+ retryAfterSeconds(response)
6521
+ );
6522
+ }
6523
+ if (!response.ok) {
6524
+ throw new WebsiteError(
6525
+ `Could not load the website form (status ${response.status})`,
6526
+ response.status === 403 ? "ORIGIN_NOT_ALLOWED" : "FORM_DEFINITION_ERROR",
6527
+ { status: response.status }
6528
+ );
6529
+ }
6530
+ const value = await response.json();
6531
+ if (value.schemaVersion !== 1 || value.key !== normalizedKey || !Array.isArray(value.definition?.fields) || !Array.isArray(value.definition?.sections) || !Array.isArray(value.definition?.steps)) {
6532
+ throw new WebsiteError(
6533
+ "Website form returned an invalid definition",
6534
+ "INVALID_FORM_DEFINITION"
6535
+ );
6536
+ }
6537
+ cache.set(normalizedKey, {
6538
+ etag: response.headers.get("ETag"),
6539
+ expiresAt: Date.now() + 3e4,
6540
+ value
6541
+ });
6542
+ return value;
6543
+ },
6544
+ async submitResponse(input) {
6545
+ const normalizedKey = input.formKey.trim();
6546
+ if (!isFormKey(normalizedKey)) {
6547
+ throw new WebsiteError(
6548
+ "formKey has an invalid format",
6549
+ "INVALID_FORM_KEY"
6550
+ );
6551
+ }
6552
+ const context = getContext();
6553
+ const attribution = getWebsiteMarketingAttribution(context.websiteId);
6554
+ const pageUrl = typeof window !== "undefined" ? window.location.href : void 0;
6555
+ const referrerUrl = typeof document !== "undefined" && document.referrer ? document.referrer : void 0;
6556
+ let response;
6557
+ try {
6558
+ response = await fetch(
6559
+ `${apiUrl.replace(/\/+$/, "")}/api/v1/website/forms/responses`,
6560
+ {
6561
+ method: "POST",
6562
+ headers: { "Content-Type": "application/json" },
6563
+ body: JSON.stringify({
6564
+ websiteSlug,
6565
+ formKey: normalizedKey,
6566
+ formVersion: input.formVersion,
6567
+ definitionHash: input.definitionHash,
6568
+ idempotencyKey: input.idempotencyKey,
6569
+ source: input.source ?? "WEBSITE",
6570
+ values: input.values,
6571
+ pageUrl,
6572
+ referrerUrl,
6573
+ attribution: attribution ? serializeWebsiteMarketingAttribution(attribution) : null,
6574
+ honeypot: input.honeypot ?? ""
6575
+ })
6576
+ }
6577
+ );
6578
+ } catch (error) {
6579
+ throw new WebsiteError(
6580
+ `Could not submit the website form: ${error instanceof Error ? error.message : String(error)}`,
6581
+ "NETWORK_ERROR"
6582
+ );
6583
+ }
6584
+ if (response.status === 429) {
6585
+ throw new RateLimitError(
6586
+ "Rate limit exceeded. Please try again later.",
6587
+ retryAfterSeconds(response)
6588
+ );
6589
+ }
6590
+ const result = await response.json().catch(() => null);
6591
+ if (!response.ok) {
6592
+ throw new WebsiteError(
6593
+ result?.message ?? `Could not submit the website form (status ${response.status})`,
6594
+ result?.code ?? (response.status === 403 ? "ORIGIN_NOT_ALLOWED" : "FORM_RESPONSE_ERROR"),
6595
+ { status: response.status }
6596
+ );
6597
+ }
6598
+ if (typeof result?.accepted !== "boolean" || typeof result.message !== "string") {
6599
+ throw new WebsiteError(
6600
+ "Website form returned an invalid response",
6601
+ "INVALID_RESPONSE"
6602
+ );
6603
+ }
6604
+ return {
6605
+ accepted: result.accepted,
6606
+ responseId: typeof result.responseId === "string" ? result.responseId : null,
6607
+ message: result.message
6608
+ };
6609
+ }
6610
+ };
6611
+ }
6612
+
6448
6613
  // libs/sdk/src/modules/marketing.ts
6449
6614
  function readRetryAfter(response) {
6450
6615
  const value = Number(response.headers.get("Retry-After") ?? "1");
@@ -6971,6 +7136,7 @@ function createBacanoClient(config) {
6971
7136
  config.websiteSlug,
6972
7137
  getContext
6973
7138
  );
7139
+ const formsModule = createFormsModule(apiUrl, config.websiteSlug, getContext);
6974
7140
  return {
6975
7141
  async init() {
6976
7142
  if (context) return;
@@ -7023,6 +7189,10 @@ function createBacanoClient(config) {
7023
7189
  getByTag: contentModule.getByTag.bind(contentModule),
7024
7190
  getForProduct: contentModule.getForProduct.bind(contentModule)
7025
7191
  },
7192
+ forms: {
7193
+ getDefinition: formsModule.getDefinition.bind(formsModule),
7194
+ submitResponse: formsModule.submitResponse.bind(formsModule)
7195
+ },
7026
7196
  cart: {
7027
7197
  getOrCreate: cartModule.getOrCreate.bind(cartModule),
7028
7198
  startCheckout: cartModule.startCheckout.bind(cartModule),
@@ -77,7 +77,8 @@ pnpm exec bacano-figma-web validate-snapshot .bacano/designs/<proyecto>/manifest
77
77
  ## 4. Mapear a Bacano antes de programar
78
78
 
79
79
  Leer [references/bacano-mapping.md](references/bacano-mapping.md),
80
- [references/content-workflow.md](references/content-workflow.md) y
80
+ [references/content-workflow.md](references/content-workflow.md),
81
+ [references/content-media.md](references/content-media.md) y
81
82
  [references/implementation-quality.md](references/implementation-quality.md).
82
83
  Preparar una matriz ruta → frame → componente → fuente de datos → Content →
83
84
  responsive → preview → estado, y determinar si el trabajo comienza desde
@@ -103,7 +104,10 @@ blueprint, leer la sección de automatización de
103
104
  componentes, grupos, listas y entradas únicamente mediante el acceso temporal
104
105
  y los scopes oficiales. Inventariar esos recursos antes de crear otros y
105
106
  preparar entradas en borrador por defecto; la media permanece en el
106
- backoffice y publicar requiere permiso y aprobación explícitos.
107
+ backoffice salvo que el usuario conceda `content_media:upload`. Con ese scope,
108
+ buscar primero imágenes reutilizables y cargar bytes únicamente mediante
109
+ `listMedia()` y `uploadImage()`; publicar requiere permiso y aprobación
110
+ explícitos.
107
111
 
108
112
  Al proponer keys nuevas, resolver primero el idioma según
109
113
  `CONTENT_TYPE_MANAGEMENT.md`: instrucción explícita, convención existente y
@@ -129,7 +133,9 @@ lo pendiente cuando la revisión visual quede a cargo del usuario.
129
133
 
130
134
  No añadir paquetes, fuentes, iconos o assets si el proyecto ya ofrece un
131
135
  equivalente. Descargar assets mediante la fuente Figma autorizada; no incrustar
132
- URLs temporales ni construir transformaciones de Storage manualmente.
136
+ URLs temporales ni construir transformaciones de Storage manualmente. La carga
137
+ administrativa admite JPEG, PNG o WebP raster; video, documentos, SVG y
138
+ animaciones continúan bajo revisión editorial en el backoffice.
133
139
 
134
140
  ## 6. Validar diseño y contrato
135
141
 
@@ -0,0 +1,57 @@
1
+ # Imágenes de Content desde un agente
2
+
3
+ Usar este flujo solo cuando `getContext().scopes` incluya
4
+ `content_media:upload`. El token temporal continúa siendo administrativo: no se
5
+ incluye en el storefront, Figma, archivos del proyecto, logs ni respuestas al
6
+ usuario.
7
+
8
+ ## Orden obligatorio
9
+
10
+ 1. Clasificar el asset: hero desktop/mobile, card, galería, logo, decorativo u
11
+ otro uso editorial.
12
+ 2. Buscar en `listMedia()` con términos semánticos. Reutilizar únicamente cuando
13
+ sea el mismo asset, no solo una imagen parecida.
14
+ 3. Exportar desde la fuente Figma autorizada los bytes originales necesarios.
15
+ 4. Para imágenes informativas, preparar un nombre descriptivo y `altText`
16
+ contextual. No usar nombres de capas como `Frame 32` o `image_1`.
17
+ 5. Cargar con `uploadImage()`. Nunca usar GraphQL, URLs de Figma, base64 dentro
18
+ de una entrada ni endpoints directos de Nhost Storage.
19
+ 6. Guardar en Content el `fileId` devuelto y volver a consultar la entrada.
20
+ 7. Mantener en borrador hasta validar imagen, encuadre, responsive y texto
21
+ alternativo en contexto.
22
+
23
+ ```ts
24
+ import { readFile } from "node:fs/promises";
25
+ import { createContentManagementClient } from "@bacano/sdk/content-management";
26
+
27
+ const content = createContentManagementClient({
28
+ apiUrl: process.env.BACANO_API_URL!,
29
+ accessToken: process.env.BACANO_CONTENT_ACCESS_TOKEN!,
30
+ });
31
+
32
+ const matches = await content.listMedia({ search: "hero juguetes" });
33
+ const media = matches.items[0] ?? await content.uploadImage({
34
+ file: await readFile("./exports/hero-juguetes-desktop.png"),
35
+ fileName: "Hero juguetes para bebé desktop",
36
+ altText: "Niña jugando con bloques de colores",
37
+ });
38
+
39
+ console.log(media.fileId);
40
+ ```
41
+
42
+ ## Contrato de la primera versión
43
+
44
+ - Admite JPEG, PNG y WebP raster no animado.
45
+ - Máximo 10 MB de entrada y 100 megapíxeles.
46
+ - Bacano valida bytes reales, corrige orientación y normaliza a WebP.
47
+ - El nombre se convierte a un filename SEO seguro.
48
+ - Los duplicados binarios normalizados se reutilizan dentro de la misma
49
+ empresa; nunca entre empresas.
50
+ - SVG, GIF animado, video y documentos se cargan desde el backoffice.
51
+ - El agente no reemplaza ni elimina físicamente media.
52
+ - `altText` de biblioteca es fallback. El texto alternativo final pertenece al
53
+ uso dentro de la entrada; una imagen decorativa usa `alt=""` en ese contexto.
54
+
55
+ Si el asset no cabe en este contrato, reportar `BLOQUEADO` y pedir que el equipo
56
+ editorial lo prepare en la biblioteca. No convertirlo de forma improvisada ni
57
+ introducir paquetes o servicios paralelos.
@@ -155,7 +155,9 @@ usuario autoriza que el agente materialice el blueprint, usar exclusivamente
155
155
  agente**. Confirmar la empresa y los scopes, inventariar primero, crear
156
156
  componentes antes de sus consumidores y trabajar inicialmente en borrador. La
157
157
  skill puede preparar entradas cuando el usuario conceda
158
- `content_entries:manage`; no carga media ni publica sin el scope específico.
158
+ `content_entries:manage`. Si además se concede `content_media:upload`, debe
159
+ buscar y cargar imágenes por el contrato descrito en
160
+ [content-media.md](content-media.md). No publica sin el scope específico.
159
161
 
160
162
  ## 6. Cambio a Bacano real
161
163
 
@@ -189,7 +191,8 @@ escribir:
189
191
  locale; crear las faltantes en borrador y actualizar únicamente con
190
192
  `expectedUpdatedAt`.
191
193
  7. No fusionar por nombres, no usar HTML crudo y no relacionar URLs remotas. La
192
- media debe existir en Bacano y pertenecer a la empresa autorizada.
194
+ media debe existir en Bacano y pertenecer a la empresa autorizada; resolver
195
+ su `fileId` mediante `listMedia()` o `uploadImage()`.
193
196
  8. Para archivar o eliminar tipos, entradas, grupos o listas, inspeccionar
194
197
  dependencias y confirmar nuevamente. Una actualización de lista debe enviar
195
198
  todos los elementos que deben permanecer, incluidos sus IDs existentes.
@@ -198,5 +201,5 @@ escribir:
198
201
 
199
202
  Nunca exponer el token en el navegador, commit, blueprint, salida de consola o
200
203
  chat. Nunca convertir el blueprint en una importación implícita ni usar GraphQL
201
- directo. La carga de media permanece bajo gestión editorial en Bacano hasta que
202
- exista el scope y endpoint administrativo oficial.
204
+ directo. Sin `content_media:upload`, toda media permanece bajo gestión
205
+ editorial en el backoffice.
@@ -51,6 +51,9 @@ de proponer cambios. Como mínimo revisar `STOREFRONT_LEGAL.md`,
51
51
  `AUTHENTICATION.md`, `STOREFRONT_LEADS.md`, `STOREFRONT_CLARITY.md` y
52
52
  otros contratos únicamente cuando esas capacidades existan. Leer
53
53
  `STOREFRONT_CONSTRUCTION.md` cuando el website permita ocultarse temporalmente.
54
+ Si existen formularios administrados desde Bacano, leer obligatoriamente
55
+ `STOREFRONT_FORMS.md` y
56
+ [references/forms.md](references/forms.md) antes de auditarlos o modificarlos.
54
57
 
55
58
  ## 3. Auditar sin alterar el sistema
56
59
 
@@ -0,0 +1,50 @@
1
+ # Formularios públicos de Bacano
2
+
3
+ Aplicar este contrato cuando el sitio muestre al menos un formulario administrado
4
+ desde Bacano. Leer también `STOREFRONT_FORMS.md` completo desde el paquete SDK.
5
+
6
+ ## Implementación oficial
7
+
8
+ - Clasificar formularios como `ACTIVA`, `INACTIVA` o `BLOQUEADA` en la matriz de
9
+ capacidades.
10
+ - Usar `BacanoForm` desde `@bacano/sdk/react/forms` o un renderer propio basado
11
+ exclusivamente en `useBacanoForm()`.
12
+ - Reutilizar el único `BacanoProvider` y cliente global del sitio. No crear otro
13
+ cliente, consultar GraphQL ni llamar endpoints internos.
14
+ - No duplicar keys, validaciones, opciones o estructura en un esquema local.
15
+ - En Static First, obtener la definición durante build/servidor y entregarla
16
+ como `initialForm`. La revalidación runtime no debe borrar el HTML inicial.
17
+ - Renderizar campos directos, secciones visuales y pasos según su semántica. Una
18
+ sección no es un paso y no debe inventar navegación.
19
+ - Las listas de Content llegan resueltas por Bacano; el storefront no reconstruye
20
+ opciones ni consulta Content por separado.
21
+
22
+ ## Envío y seguridad
23
+
24
+ - Enviar mediante `client.forms.submitResponse()` o el envío interno de
25
+ `BacanoForm`.
26
+ - Conservar una `idempotencyKey` durante reintentos del mismo intento lógico.
27
+ - Enviar la versión y `definitionHash` exactos de la definición renderizada.
28
+ - Respetar estados `ACTIVE` y `PAUSED`; no renderizar borradores o archivados.
29
+ - 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.
32
+ - Una brecha del SDK se reporta; nunca se resuelve con GraphQL directo ni una API
33
+ paralela del storefront.
34
+
35
+ ## Verificaciones obligatorias
36
+
37
+ - Formulario activo: estructura, labels, required, ayudas, opciones y envío.
38
+ - Formulario pausado e inexistente: mensaje seguro y ningún envío.
39
+ - Versión desactualizada: refresh oficial y conservación controlada de datos.
40
+ - Doble clic y reintento: una sola respuesta por intento idempotente.
41
+ - Error de red: estado recuperable sin promesa falsa de recepción.
42
+ - Persistencia: la respuesta aparece una sola vez en Bacano con versión, origen y
43
+ snapshot correctos.
44
+ - Accesibilidad: label asociado, foco, teclado, mensajes de error y progreso de
45
+ pasos comprensibles.
46
+ - Static First: el HTML exportado conserva el formulario cuando corresponde.
47
+
48
+ Reportar evidencia con la key pública, ruta, archivo del renderer, requests
49
+ observados y prueba de persistencia. Nunca incluir valores personales reales en
50
+ el informe.
@@ -12,6 +12,9 @@ Aplicar además de los controles comunes.
12
12
 
13
13
  ## Formularios y contacto
14
14
 
15
+ - Los formularios administrados desde Bacano cumplen
16
+ [forms.md](forms.md), usan el renderer o hook oficial y conservan su definición
17
+ estática cuando corresponde.
15
18
  - Distinguir formularios informativos/PQRS de capturas comerciales.
16
19
  - Newsletter, cupón o interés comercial usan
17
20
  `client.marketing.captureLead()` y el texto vigente de autorización.
package/types.d.ts CHANGED
@@ -149,6 +149,89 @@ export interface WebsiteCampaignCaptureResult {
149
149
  accepted: boolean;
150
150
  message: string;
151
151
  }
152
+ export type WebsiteFormAction = "STORE_RESPONSE" | "CAPTURE_LEAD";
153
+ export type WebsiteFormStatus = "ACTIVE" | "PAUSED";
154
+ export type WebsiteFormFieldType = "text" | "textarea" | "email" | "phone" | "number" | "date" | "select" | "radio" | "multi_select" | "checkbox";
155
+ export interface WebsiteFormOption {
156
+ value: string;
157
+ label: string;
158
+ }
159
+ export type WebsiteFormOptionSource = {
160
+ kind: "INLINE";
161
+ options: WebsiteFormOption[];
162
+ } | {
163
+ kind: "CONTENT_LIST";
164
+ listKey: string;
165
+ };
166
+ export interface WebsiteFormField {
167
+ key: string;
168
+ label: string;
169
+ type: WebsiteFormFieldType;
170
+ required: boolean;
171
+ placeholder?: string | null;
172
+ helpText?: string | null;
173
+ optionSource?: WebsiteFormOptionSource | null;
174
+ /** Resolved public options. Content List internals never need rebuilding by a storefront. */
175
+ options?: WebsiteFormOption[];
176
+ validation: {
177
+ min?: number | null;
178
+ max?: number | null;
179
+ minLength?: number | null;
180
+ maxLength?: number | null;
181
+ };
182
+ }
183
+ export interface WebsiteFormSection {
184
+ key: string;
185
+ title: string;
186
+ description?: string | null;
187
+ fields: WebsiteFormField[];
188
+ }
189
+ export interface WebsiteFormStep {
190
+ key: string;
191
+ title: string;
192
+ description?: string | null;
193
+ fields: WebsiteFormField[];
194
+ sections: WebsiteFormSection[];
195
+ }
196
+ export interface WebsiteFormDefinition {
197
+ schemaVersion: 1;
198
+ submitLabel: string;
199
+ successMessage: string;
200
+ unavailableMessage: string;
201
+ fields: WebsiteFormField[];
202
+ sections: WebsiteFormSection[];
203
+ steps: WebsiteFormStep[];
204
+ }
205
+ export interface WebsitePublishedForm {
206
+ schemaVersion: 1;
207
+ id: string;
208
+ key: string;
209
+ name: string;
210
+ description: string;
211
+ action: WebsiteFormAction;
212
+ status: WebsiteFormStatus;
213
+ version: number;
214
+ publishedAt: string;
215
+ definitionHash: string;
216
+ definition: WebsiteFormDefinition;
217
+ }
218
+ export type WebsiteFormResponseSource = "WEBSITE" | "QR";
219
+ export type WebsiteFormResponseValue = string | string[] | boolean;
220
+ export interface WebsiteFormResponseInput {
221
+ formKey: string;
222
+ formVersion: number;
223
+ definitionHash: string;
224
+ idempotencyKey: string;
225
+ values: Record<string, WebsiteFormResponseValue>;
226
+ source?: WebsiteFormResponseSource;
227
+ /** Bot-trap field. Storefronts should render it visually hidden and leave it empty. */
228
+ honeypot?: string;
229
+ }
230
+ export interface WebsiteFormResponseResult {
231
+ accepted: boolean;
232
+ responseId: string | null;
233
+ message: string;
234
+ }
152
235
  export type ResolvedWebsitePublicConfig = WebsitePublicConfig & {
153
236
  branding: WebsiteBrandingPublicConfig;
154
237
  legal: WebsiteLegalPublicConfig;