@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.
@@ -2,9 +2,9 @@
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 y entradas** de Bacano Content. Cada capacidad requiere un permiso explícito. El acceso no
6
- autoriza productos, ventas, clientes, configuración general, GraphQL, SQL ni
7
- Storage.
5
+ de opciones, entradas e imágenes** de Bacano Content. Cada capacidad requiere
6
+ un permiso explícito. El acceso no autoriza productos, ventas, clientes,
7
+ configuración general, GraphQL, SQL ni acceso directo a Storage.
8
8
 
9
9
  Este contrato es independiente del cliente público del storefront. Debe usarse
10
10
  solo desde un proceso local o servidor confiable; nunca desde el navegador ni
@@ -30,8 +30,8 @@ Scopes disponibles:
30
30
  - `content_entries:manage`: grupos, entradas en borrador y sus ciclos de vida.
31
31
  - `content_entries:publish`: publicar y modificar contenido publicado; requiere
32
32
  también `content_entries:manage`.
33
- - `content_media:upload`: reservado para una fase posterior; no se concede
34
- mientras la carga administrativa de media no esté implementada.
33
+ - `content_media:upload`: consultar imágenes de la empresa y cargar JPEG, PNG o
34
+ WebP. Requiere también `content_entries:manage`; no permite borrar archivos.
35
35
 
36
36
  No guardes el token en Git, Figma, Content, logs, variables públicas ni archivos
37
37
  que se empaqueten con el storefront.
@@ -77,6 +77,8 @@ El cliente ofrece:
77
77
  `updateOptionList(idOrKey, input)`.
78
78
  - `inspectOptionListLifecycle(idOrKey, operation)` y
79
79
  `applyOptionListLifecycle(idOrKey, operation)`.
80
+ - `listMedia(filters)` y `uploadImage(input)` para reutilizar o cargar imágenes
81
+ cuando el acceso incluya `content_media:upload`.
80
82
 
81
83
  ## Idioma, nombres y keys
82
84
 
@@ -317,8 +319,9 @@ Reglas obligatorias:
317
319
  y presentar el cambio; no se sobrescribe silenciosamente.
318
320
  3. Enviar el documento completo que debe permanecer. `updateEntry()` reemplaza
319
321
  el contenido de la entrada después de aplicar las validaciones oficiales.
320
- 4. Reutilizar únicamente `fileId` ya existentes y pertenecientes a la misma
321
- empresa. Este acceso no sube media ni acepta URLs arbitrarias.
322
+ 4. Reutilizar únicamente `fileId` pertenecientes a la misma empresa. Con
323
+ `content_media:upload`, resolverlos mediante `listMedia()` o
324
+ `uploadImage()`; nunca relacionar URLs arbitrarias.
322
325
  5. Rich text usa el JSON controlado por Bacano, nunca HTML crudo.
323
326
  6. Publicar, retirar o modificar una entrada publicada requiere
324
327
  `content_entries:publish` y aprobación humana explícita.
@@ -328,6 +331,45 @@ La API reutiliza el mismo servicio de guardado del backoffice: campos
328
331
  obligatorios, componentes anidados, repetibles, listas, referencias, rich text,
329
332
  media y pertenencia a la empresa se validan de forma autoritativa.
330
333
 
334
+ ## Imágenes
335
+
336
+ Antes de cargar, busca por nombre o texto alternativo para reutilizar la
337
+ biblioteca existente:
338
+
339
+ ```ts
340
+ import { readFile } from "node:fs/promises";
341
+
342
+ const existing = await content.listMedia({ search: "hero juguetes" });
343
+
344
+ const hero = existing.items[0] ?? await content.uploadImage({
345
+ file: await readFile("./hero-juguetes.png"),
346
+ fileName: "Hero juguetes para bebé",
347
+ altText: "Niña jugando con juguetes educativos",
348
+ });
349
+
350
+ console.log(hero.fileId, hero.width, hero.height);
351
+ ```
352
+
353
+ `uploadImage()` envía los bytes al endpoint administrativo de Bacano. El
354
+ servidor comprueba el contenido real, corrige orientación, elimina metadatos no
355
+ necesarios, normaliza a WebP y registra nombre, MIME, tamaño y dimensiones. El
356
+ límite de entrada es 10 MB y 100 megapíxeles. No se admiten animaciones,
357
+ archivos corruptos, SVG, video ni documentos en esta primera versión.
358
+
359
+ El nombre entregado debe describir el asset; Bacano lo normaliza a un filename
360
+ SEO seguro y agrega un sufijo corto para evitar colisiones. Un SHA-256 de los
361
+ bytes WebP normalizados permite reutilizar duplicados dentro de la misma
362
+ empresa. Nunca se deduplica entre empresas.
363
+
364
+ El `altText` de la biblioteca es solo un fallback. Al asociar una imagen a una
365
+ entrada, conserva el texto alternativo contextual que corresponda a ese uso;
366
+ una misma imagen puede significar cosas distintas en dos secciones. Para una
367
+ imagen decorativa el contenido final debe usar `alt=""`.
368
+
369
+ El agente no puede reemplazar ni borrar físicamente media. Una edición genera
370
+ otro asset y cambia explícitamente el `fileId` de la entrada. Esto evita romper
371
+ productos, servicios, branding u otras entradas que todavía usen el archivo.
372
+
331
373
  ## Grupos y listas de opciones
332
374
 
333
375
  Los grupos organizan entradas y usan el scope `content_entries:manage`. Las
@@ -385,12 +427,16 @@ omitas opciones existentes por accidente en una actualización.
385
427
  - Emisión, revocación y mutaciones quedan en una auditoría inmutable sin guardar
386
428
  el bearer token ni el documento completo.
387
429
  - Las respuestas del endpoint usan `no-store`, límites de tamaño y rate limit.
430
+ - Las cargas tienen un límite adicional por concesión; no aceptan importación
431
+ por URL ni exponen credenciales o endpoints internos de Storage.
388
432
  - Los tokens v2 no son compatibles con tokens anteriores y tienen como máximo
389
433
  60 minutos de vigencia.
390
434
 
391
435
  ## Límites de esta versión
392
436
 
393
- - No carga archivos ni crea media; puede relacionar media existente validada.
437
+ - La carga administrativa solo admite imágenes raster JPEG, PNG y WebP. SVG,
438
+ animaciones, video y documentos continúan en el backoffice.
439
+ - No elimina ni reemplaza físicamente archivos de la biblioteca.
394
440
  - No funciona desde el cliente público del storefront.
395
441
  - No acepta GraphQL directo ni credenciales administrativas permanentes.
396
442
  - No previsualiza borradores desde el storefront público.
package/README.md CHANGED
@@ -34,10 +34,11 @@ import { createContentManagementClient } from "@bacano/sdk/content-management";
34
34
  ```
35
35
 
36
36
  Lee [`CONTENT_TYPE_MANAGEMENT.md`](./CONTENT_TYPE_MANAGEMENT.md) antes de usarlo.
37
- La guía contiene el flujo de autorización, contratos de campos y entradas,
38
- estados, referencias, cambios destructivos y límites de seguridad. La carga de
39
- media continúa en el backoffice hasta que exista un contrato administrativo
40
- oficial para ella.
37
+ La guía contiene el flujo de autorización, contratos de campos, entradas y
38
+ media, estados, referencias, cambios destructivos y límites de seguridad. Con
39
+ el scope `content_media:upload`, un agente puede buscar imágenes existentes y
40
+ cargar JPEG, PNG o WebP mediante el contrato oficial; Bacano valida los bytes,
41
+ normaliza a WebP y registra el resultado en la biblioteca de la empresa.
41
42
 
42
43
  ### Auditoría del storefront
43
44
 
@@ -111,6 +112,12 @@ Commercial forms and newsletter subscriptions must follow
111
112
  Bacano owns consent evidence, identity resolution, attribution and idempotent
112
113
  lead capture through the SDK.
113
114
 
115
+ Website forms defined and published from Bacano use the additive contract in
116
+ [`STOREFRONT_FORMS.md`](./STOREFRONT_FORMS.md). Storefronts can mount the
117
+ official `BacanoForm` renderer or consume the same typed definition through
118
+ `useBacanoForm()` without GraphQL, HTML stored in the database or a rebuild
119
+ after each publication.
120
+
114
121
  Static and hybrid stores must also follow
115
122
  [`STOREFRONT_PERFORMANCE.md`](./STOREFRONT_PERFORMANCE.md). It defines the
116
123
  official build bootstrap, live-state refresh, responsive media and request
@@ -0,0 +1,147 @@
1
+ # Formularios de websites
2
+
3
+ Los formularios públicos de Bacano son definiciones tipadas y versionadas. El
4
+ storefront decide dónde se muestran y cómo lucen; Bacano controla su estructura,
5
+ estado y validaciones. La definición nunca contiene HTML ni JavaScript arbitrario.
6
+
7
+ ## Renderer oficial para React
8
+
9
+ Monta un único `BacanoProvider` global y usa la key pública configurada en el ERP:
10
+
11
+ ```tsx
12
+ import { BacanoForm } from "@bacano/sdk/react/forms";
13
+
14
+ export function ContactForm() {
15
+ return (
16
+ <BacanoForm
17
+ formKey="contacto"
18
+ classNames={{ root: "contact-form", field: "contact-field" }}
19
+ />
20
+ );
21
+ }
22
+ ```
23
+
24
+ `BacanoForm` soporta campos directos, secciones visuales y pasos reales,
25
+ validación nativa, opciones manuales, Listas de Content y el envío oficial de
26
+ respuestas. Si se
27
+ omite `onSubmit`, utiliza el mismo `BacanoClient` global, genera una clave
28
+ idempotente estable y llama `client.forms.submitResponse(...)`. Un formulario
29
+ pausado muestra su mensaje de indisponibilidad y deja de operar sin reconstruir
30
+ el storefront.
31
+
32
+ ## Estructura del formulario
33
+
34
+ La definición tiene tres niveles explícitos:
35
+
36
+ - `fields`: campos directos de un formulario continuo.
37
+ - `sections`: agrupaciones visuales opcionales dentro del formulario continuo.
38
+ - `steps`: etapas reales de un formulario multipaso. Cada paso puede contener
39
+ campos directos y secciones.
40
+
41
+ Un formulario continuo puede tener campos, secciones o ambos. Cuando existen
42
+ pasos, `fields` y `sections` de la raíz deben estar vacíos: todo el contenido
43
+ pertenece a un paso. Las secciones no crean navegación ni cambian el envío; los
44
+ pasos sí muestran avance y controles para continuar o regresar. Un formulario
45
+ también puede existir sin secciones ni pasos, usando únicamente campos directos.
46
+
47
+ `onSubmit` permanece disponible para experiencias personalizadas. No debe
48
+ usarse para llamar GraphQL, endpoints internos ni crear una persistencia
49
+ paralela.
50
+
51
+ ## Renderer personalizado
52
+
53
+ ```tsx
54
+ import { useBacanoState } from "@bacano/sdk/react";
55
+ import { useBacanoForm } from "@bacano/sdk/react/forms";
56
+
57
+ export function BrandedContactForm() {
58
+ const { client } = useBacanoState();
59
+ const { form, loading, error, refresh } = useBacanoForm("contacto");
60
+
61
+ if (loading || !client) return null;
62
+ if (error || !form) return <p>Formulario no disponible.</p>;
63
+ if (form.status === "PAUSED") {
64
+ return <p>{form.definition.unavailableMessage}</p>;
65
+ }
66
+
67
+ const submit = async (values: Record<string, string | string[] | boolean>) =>
68
+ client.forms.submitResponse({
69
+ formKey: form.key,
70
+ formVersion: form.version,
71
+ definitionHash: form.definitionHash,
72
+ idempotencyKey: crypto.randomUUID(),
73
+ values,
74
+ });
75
+
76
+ return <CustomRenderer definition={form.definition} onSubmit={submit} />;
77
+ }
78
+ ```
79
+
80
+ El renderer personalizado puede cambiar componentes y estilos, pero debe usar
81
+ los campos, keys, opciones y validaciones entregados por el SDK. No debe crear
82
+ un segundo cliente, resolver el website nuevamente ni usar GraphQL directo.
83
+
84
+ ## Next.js y Static First
85
+
86
+ Obtén la definición durante el build o desde un Server Component usando el
87
+ mismo cliente configurado para el website. Después entrégala al renderer como
88
+ `initialForm`:
89
+
90
+ ```tsx
91
+ const initialForm = await bacano.forms.getDefinition("contacto");
92
+
93
+ return (
94
+ <BacanoForm
95
+ formKey="contacto"
96
+ initialForm={initialForm}
97
+ />
98
+ );
99
+ ```
100
+
101
+ Así, el HTML inicial contiene el formulario aunque JavaScript todavía no haya
102
+ hidratado. El provider global reutiliza esa definición y la revalida en runtime
103
+ para reflejar una publicación o pausa sin rebuild. No ocultes el formulario
104
+ estático detrás de un `loadingFallback` global ni crees otro `BacanoClient` para
105
+ consultarlo.
106
+
107
+ ## Contrato operativo
108
+
109
+ - `DRAFT`: no es público.
110
+ - `ACTIVE`: entrega la última versión publicada.
111
+ - `PAUSED`: conserva definición y mensaje, pero no acepta nuevos envíos.
112
+ - `ARCHIVED`: deja de ser público y conserva auditoría.
113
+ - Publicar crea una versión inmutable; editar modifica únicamente el borrador.
114
+ - Los cambios publicados se revalidan en runtime y no requieren rebuild.
115
+ - La revalidación usa caché corta y ETag; una falla temporal no elimina una
116
+ definición válida que ya fue generada estáticamente.
117
+ - Una Lista de Content se resuelve en Bacano. El storefront recibe opciones
118
+ públicas listas para renderizar.
119
+ - Cada envío incluye la versión y huella exactas de la definición publicada.
120
+ Una página desactualizada recibe `FORM_VERSION_MISMATCH` y debe refrescar.
121
+ - `idempotencyKey` identifica un intento lógico. Repetir la misma clave y el
122
+ mismo contenido devuelve la respuesta existente; reutilizarla con otro
123
+ contenido se rechaza.
124
+ - Bacano deriva empresa y website desde `websiteSlug`; el storefront no envía
125
+ identificadores internos ni controla el tenant.
126
+ - La API vuelve a validar tipos, requeridos, rangos y opciones de listas. La
127
+ validación del navegador mejora la experiencia, pero no es autoritativa.
128
+ - URL, referidor y atribución se sanean. Las respuestas y datos personales no
129
+ 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.
133
+
134
+ ## Checklist mínimo del storefront
135
+
136
+ - Usar un solo `BacanoProvider` y el cliente global ya inicializado.
137
+ - Renderizar con `BacanoForm` o con `useBacanoForm`; nunca copiar la definición
138
+ a un esquema local ni consultar GraphQL directamente.
139
+ - Entregar `initialForm` cuando la página se genera de forma estática.
140
+ - Respetar `ACTIVE`, `PAUSED`, versión y `definitionHash` sin inferir estados.
141
+ - Mantener una `idempotencyKey` estable durante los reintentos del mismo envío.
142
+ - Probar formulario activo, pausado, inexistente, versión desactualizada,
143
+ reintento idempotente y error de red.
144
+ - Confirmar en Bacano que una respuesta real quedó persistida antes de aprobar
145
+ 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.
package/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BacanoClientConfig, BacanoClientContext, Brand, Cart, CartItem, CatalogAttributeList, CatalogCategoryList, CatalogPageResult, CheckoutResult, CheckoutSubmission, ContentEntry, CustomerAddress, CustomerProfile, CustomerProfileUpdate, GetAttributeListOptions, GetCatalogPageOptions, GetCategoryListOptions, GetContentOptions, GetOrdersOptions, GetProductFiltersOptions, GetProductRecommendationsOptions, GetProductSeoEntriesOptions, GetProductsOptions, GetSearchSuggestionsOptions, GetStaticCatalogBootstrapOptions, GetStaticCatalogSnapshotOptions, Order, Product, ProductAvailability, ProductCategory, ProductContentBlock, ProductFilters, ProductRecommendationsResult, ProductSeoEntriesResult, ProductVariantLiveState, PublicOrder, PublicOrderLookupInput, SearchSuggestion, StartGuestCheckoutInput, StaticCatalogBootstrap, StaticCatalogSnapshot, WaitForPublicOrderPaymentInput, WebsiteCampaignCaptureInput, WebsiteCampaignCaptureResult, WebsiteCheckoutAddress, WebsiteCheckoutItem, WebsiteCouponActionResult, WebsiteDeliveryOption, WebsiteFreeShippingProgress, WebsiteFulfillmentFamily, WebsiteGuestContact, WebsiteLocationOptions, WebsitePaymentMethod, WebsitePaymentOption, WebsitePaymentSessionResult, WebsiteShippingPolicy, WebsiteShippingQuote } from "./types";
1
+ import type { BacanoClientConfig, BacanoClientContext, Brand, Cart, CartItem, CatalogAttributeList, CatalogCategoryList, CatalogPageResult, CheckoutResult, CheckoutSubmission, ContentEntry, CustomerAddress, CustomerProfile, CustomerProfileUpdate, GetAttributeListOptions, GetCatalogPageOptions, GetCategoryListOptions, GetContentOptions, GetOrdersOptions, GetProductFiltersOptions, GetProductRecommendationsOptions, GetProductSeoEntriesOptions, GetProductsOptions, GetSearchSuggestionsOptions, GetStaticCatalogBootstrapOptions, GetStaticCatalogSnapshotOptions, Order, Product, ProductAvailability, ProductCategory, ProductContentBlock, ProductFilters, ProductRecommendationsResult, ProductSeoEntriesResult, ProductVariantLiveState, PublicOrder, PublicOrderLookupInput, SearchSuggestion, StartGuestCheckoutInput, StaticCatalogBootstrap, StaticCatalogSnapshot, WaitForPublicOrderPaymentInput, WebsiteCampaignCaptureInput, WebsiteCampaignCaptureResult, WebsiteCheckoutAddress, WebsiteCheckoutItem, WebsiteCouponActionResult, WebsiteDeliveryOption, WebsiteFormResponseInput, WebsiteFormResponseResult, WebsiteFreeShippingProgress, WebsiteFulfillmentFamily, WebsiteGuestContact, WebsiteLocationOptions, WebsitePaymentMethod, WebsitePaymentOption, WebsitePaymentSessionResult, WebsitePublishedForm, WebsiteShippingPolicy, WebsiteShippingQuote } from "./types";
2
2
  export interface BacanoClient {
3
3
  /** Initialize the client and resolve its website context. */
4
4
  init(): Promise<void>;
@@ -34,6 +34,13 @@ export interface BacanoClient {
34
34
  getByTag(tag: string, opts?: GetContentOptions): Promise<ContentEntry[]>;
35
35
  getForProduct(productId: string, opts?: GetContentOptions): Promise<ProductContentBlock[]>;
36
36
  };
37
+ forms: {
38
+ /** Returns the current published definition or null when it is unavailable. */
39
+ getDefinition(formKey: string, options?: {
40
+ force?: boolean;
41
+ }): Promise<WebsitePublishedForm | null>;
42
+ submitResponse(input: WebsiteFormResponseInput): Promise<WebsiteFormResponseResult>;
43
+ };
37
44
  cart: {
38
45
  getOrCreate(contact?: WebsiteGuestContact, fulfillmentFamily?: WebsiteFulfillmentFamily): Promise<Cart>;
39
46
  startCheckout(input: StartGuestCheckoutInput): Promise<Cart>;
@@ -113,6 +113,35 @@ export interface ContentManagementEntryList {
113
113
  entries: ContentManagementEntry[];
114
114
  total: number;
115
115
  }
116
+ export interface ContentManagementMedia {
117
+ id: string;
118
+ fileId: string;
119
+ name: string;
120
+ mediaKind: "image";
121
+ mimeType: string;
122
+ sizeBytes: number;
123
+ width: number | null;
124
+ height: number | null;
125
+ altText: string;
126
+ createdAt: string;
127
+ }
128
+ export interface ContentManagementMediaListInput {
129
+ search?: string;
130
+ limit?: number;
131
+ offset?: number;
132
+ }
133
+ export interface ContentManagementMediaList {
134
+ items: ContentManagementMedia[];
135
+ total: number;
136
+ }
137
+ export interface ContentManagementImageUploadInput {
138
+ file: Blob | ArrayBuffer | Uint8Array;
139
+ fileName: string;
140
+ altText?: string;
141
+ }
142
+ export interface ContentManagementImageUploadResult extends ContentManagementMedia {
143
+ reused: boolean;
144
+ }
116
145
  export interface ContentManagementReferences {
117
146
  categories: Array<{
118
147
  code: string;
@@ -245,6 +274,8 @@ export interface ContentManagementClient {
245
274
  updateEntry(identifier: string, input: ContentManagementEntryInput): Promise<ContentManagementEntry>;
246
275
  inspectEntryLifecycle(identifier: string, operation: ContentManagementLifecycleOperation): Promise<ContentManagementLifecycleResult>;
247
276
  applyEntryLifecycle(identifier: string, operation: ContentManagementLifecycleOperation): Promise<ContentManagementLifecycleResult>;
277
+ listMedia(input?: ContentManagementMediaListInput): Promise<ContentManagementMediaList>;
278
+ uploadImage(input: ContentManagementImageUploadInput): Promise<ContentManagementImageUploadResult>;
248
279
  listGroups(): Promise<ContentManagementGroup[]>;
249
280
  getGroup(identifier: string): Promise<ContentManagementGroup>;
250
281
  createGroup(input: ContentManagementGroupInput): Promise<ContentManagementGroup>;
@@ -68,6 +68,24 @@ function entryListPath(input = {}) {
68
68
  const query = search.toString();
69
69
  return query ? `/entries?${query}` : "/entries";
70
70
  }
71
+ function mediaListPath(input = {}) {
72
+ const search = new URLSearchParams();
73
+ if (input.search) search.set("search", input.search);
74
+ if (input.limit !== void 0) search.set("limit", String(input.limit));
75
+ if (input.offset !== void 0) search.set("offset", String(input.offset));
76
+ const query = search.toString();
77
+ return query ? `/media?${query}` : "/media";
78
+ }
79
+ function uploadBody(file) {
80
+ if ("arrayBuffer" in file) return file;
81
+ if (file instanceof ArrayBuffer) return file;
82
+ const bytes = new Uint8Array(file.byteLength);
83
+ bytes.set(file);
84
+ return bytes.buffer;
85
+ }
86
+ function encodedHeader(value) {
87
+ return encodeURIComponent(value.trim());
88
+ }
71
89
  function createContentManagementClient(config) {
72
90
  const apiUrl = normalizeApiUrl(config.apiUrl);
73
91
  const fetchImplementation = config.fetch ?? globalThis.fetch;
@@ -98,7 +116,7 @@ function createContentManagementClient(config) {
98
116
  headers: {
99
117
  Accept: "application/json",
100
118
  Authorization: `Bearer ${await getAccessToken()}`,
101
- ...init?.body ? { "Content-Type": "application/json" } : {},
119
+ ...typeof init?.body === "string" ? { "Content-Type": "application/json" } : {},
102
120
  ...init?.headers
103
121
  }
104
122
  }
@@ -161,6 +179,26 @@ function createContentManagementClient(config) {
161
179
  ),
162
180
  inspectEntryLifecycle: (identifier, operation) => lifecycle("entries", identifier, operation, true),
163
181
  applyEntryLifecycle: (identifier, operation) => lifecycle("entries", identifier, operation, false),
182
+ listMedia: (input) => request(mediaListPath(input)),
183
+ uploadImage: (input) => {
184
+ const fileName = input.fileName.trim();
185
+ if (!fileName) {
186
+ throw new ContentManagementError({
187
+ message: "A descriptive image file name is required.",
188
+ code: "CONTENT_MEDIA_FILE_NAME_REQUIRED",
189
+ status: 0
190
+ });
191
+ }
192
+ return request("/media/images", {
193
+ method: "POST",
194
+ headers: {
195
+ "Content-Type": "application/octet-stream",
196
+ "X-Bacano-File-Name": encodedHeader(fileName),
197
+ ...input.altText ? { "X-Bacano-Alt-Text": encodedHeader(input.altText) } : {}
198
+ },
199
+ body: uploadBody(input.file)
200
+ });
201
+ },
164
202
  listGroups: () => request("/groups"),
165
203
  getGroup: (identifier) => request(
166
204
  `/groups/${encodeIdentifier(identifier)}`
@@ -43,6 +43,24 @@ function entryListPath(input = {}) {
43
43
  const query = search.toString();
44
44
  return query ? `/entries?${query}` : "/entries";
45
45
  }
46
+ function mediaListPath(input = {}) {
47
+ const search = new URLSearchParams();
48
+ if (input.search) search.set("search", input.search);
49
+ if (input.limit !== void 0) search.set("limit", String(input.limit));
50
+ if (input.offset !== void 0) search.set("offset", String(input.offset));
51
+ const query = search.toString();
52
+ return query ? `/media?${query}` : "/media";
53
+ }
54
+ function uploadBody(file) {
55
+ if ("arrayBuffer" in file) return file;
56
+ if (file instanceof ArrayBuffer) return file;
57
+ const bytes = new Uint8Array(file.byteLength);
58
+ bytes.set(file);
59
+ return bytes.buffer;
60
+ }
61
+ function encodedHeader(value) {
62
+ return encodeURIComponent(value.trim());
63
+ }
46
64
  function createContentManagementClient(config) {
47
65
  const apiUrl = normalizeApiUrl(config.apiUrl);
48
66
  const fetchImplementation = config.fetch ?? globalThis.fetch;
@@ -73,7 +91,7 @@ function createContentManagementClient(config) {
73
91
  headers: {
74
92
  Accept: "application/json",
75
93
  Authorization: `Bearer ${await getAccessToken()}`,
76
- ...init?.body ? { "Content-Type": "application/json" } : {},
94
+ ...typeof init?.body === "string" ? { "Content-Type": "application/json" } : {},
77
95
  ...init?.headers
78
96
  }
79
97
  }
@@ -136,6 +154,26 @@ function createContentManagementClient(config) {
136
154
  ),
137
155
  inspectEntryLifecycle: (identifier, operation) => lifecycle("entries", identifier, operation, true),
138
156
  applyEntryLifecycle: (identifier, operation) => lifecycle("entries", identifier, operation, false),
157
+ listMedia: (input) => request(mediaListPath(input)),
158
+ uploadImage: (input) => {
159
+ const fileName = input.fileName.trim();
160
+ if (!fileName) {
161
+ throw new ContentManagementError({
162
+ message: "A descriptive image file name is required.",
163
+ code: "CONTENT_MEDIA_FILE_NAME_REQUIRED",
164
+ status: 0
165
+ });
166
+ }
167
+ return request("/media/images", {
168
+ method: "POST",
169
+ headers: {
170
+ "Content-Type": "application/octet-stream",
171
+ "X-Bacano-File-Name": encodedHeader(fileName),
172
+ ...input.altText ? { "X-Bacano-Alt-Text": encodedHeader(input.altText) } : {}
173
+ },
174
+ body: uploadBody(input.file)
175
+ });
176
+ },
139
177
  listGroups: () => request("/groups"),
140
178
  getGroup: (identifier) => request(
141
179
  `/groups/${encodeIdentifier(identifier)}`
package/index.cjs CHANGED
@@ -66,10 +66,10 @@ var WebsiteError = class extends Error {
66
66
  }
67
67
  };
68
68
  var RateLimitError = class extends WebsiteError {
69
- constructor(message, retryAfterSeconds) {
69
+ constructor(message, retryAfterSeconds2) {
70
70
  super(message, "RATE_LIMITED", {
71
71
  status: 429,
72
- retryAfterSeconds
72
+ retryAfterSeconds: retryAfterSeconds2
73
73
  });
74
74
  this.name = "RateLimitError";
75
75
  }
@@ -6115,6 +6115,171 @@ function createWebsiteMarketingRuntime(options) {
6115
6115
  };
6116
6116
  }
6117
6117
 
6118
+ // libs/sdk/src/modules/forms.ts
6119
+ function retryAfterSeconds(response) {
6120
+ const value = Number(response.headers.get("Retry-After") ?? "1");
6121
+ return Number.isFinite(value) && value >= 0 ? Math.ceil(value) : 1;
6122
+ }
6123
+ function isFormKey(value) {
6124
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
6125
+ }
6126
+ function createFormsModule(apiUrl, websiteSlug, getContext) {
6127
+ const cache = /* @__PURE__ */ new Map();
6128
+ function getServerOrigin() {
6129
+ if (typeof window !== "undefined") return null;
6130
+ const domain = getContext().domain?.trim();
6131
+ if (!domain) return null;
6132
+ try {
6133
+ return new URL(domain.includes("://") ? domain : `https://${domain}`).origin;
6134
+ } catch {
6135
+ return null;
6136
+ }
6137
+ }
6138
+ return {
6139
+ async getDefinition(formKey, options) {
6140
+ const normalizedKey = formKey.trim();
6141
+ if (!isFormKey(normalizedKey)) {
6142
+ throw new WebsiteError(
6143
+ "formKey has an invalid format",
6144
+ "INVALID_FORM_KEY"
6145
+ );
6146
+ }
6147
+ const cached = cache.get(normalizedKey);
6148
+ if (!options?.force && cached && cached.expiresAt > Date.now()) {
6149
+ return cached.value;
6150
+ }
6151
+ const url = new URL(
6152
+ `${apiUrl.replace(/\/+$/, "")}/api/v1/website/forms/definition`
6153
+ );
6154
+ url.searchParams.set("websiteSlug", websiteSlug);
6155
+ url.searchParams.set("formKey", normalizedKey);
6156
+ let response;
6157
+ try {
6158
+ const serverOrigin = getServerOrigin();
6159
+ const headers = new Headers();
6160
+ if (serverOrigin) headers.set("Origin", serverOrigin);
6161
+ if (cached?.etag) headers.set("If-None-Match", cached.etag);
6162
+ response = await fetch(url, {
6163
+ ...serverOrigin || cached?.etag ? { headers } : {},
6164
+ ...options?.force ? { cache: "no-cache" } : {}
6165
+ });
6166
+ } catch (error) {
6167
+ throw new WebsiteError(
6168
+ `Could not load the website form: ${error instanceof Error ? error.message : String(error)}`,
6169
+ "NETWORK_ERROR"
6170
+ );
6171
+ }
6172
+ if (response.status === 304 && cached) {
6173
+ cache.set(normalizedKey, {
6174
+ ...cached,
6175
+ expiresAt: Date.now() + 3e4
6176
+ });
6177
+ return cached.value;
6178
+ }
6179
+ if (response.status === 404) {
6180
+ cache.set(normalizedKey, {
6181
+ etag: null,
6182
+ expiresAt: Date.now() + 15e3,
6183
+ value: null
6184
+ });
6185
+ return null;
6186
+ }
6187
+ if (response.status === 429) {
6188
+ throw new RateLimitError(
6189
+ "Rate limit exceeded. Please try again later.",
6190
+ retryAfterSeconds(response)
6191
+ );
6192
+ }
6193
+ if (!response.ok) {
6194
+ throw new WebsiteError(
6195
+ `Could not load the website form (status ${response.status})`,
6196
+ response.status === 403 ? "ORIGIN_NOT_ALLOWED" : "FORM_DEFINITION_ERROR",
6197
+ { status: response.status }
6198
+ );
6199
+ }
6200
+ const value = await response.json();
6201
+ if (value.schemaVersion !== 1 || value.key !== normalizedKey || !Array.isArray(value.definition?.fields) || !Array.isArray(value.definition?.sections) || !Array.isArray(value.definition?.steps)) {
6202
+ throw new WebsiteError(
6203
+ "Website form returned an invalid definition",
6204
+ "INVALID_FORM_DEFINITION"
6205
+ );
6206
+ }
6207
+ cache.set(normalizedKey, {
6208
+ etag: response.headers.get("ETag"),
6209
+ expiresAt: Date.now() + 3e4,
6210
+ value
6211
+ });
6212
+ return value;
6213
+ },
6214
+ async submitResponse(input) {
6215
+ const normalizedKey = input.formKey.trim();
6216
+ if (!isFormKey(normalizedKey)) {
6217
+ throw new WebsiteError(
6218
+ "formKey has an invalid format",
6219
+ "INVALID_FORM_KEY"
6220
+ );
6221
+ }
6222
+ const context = getContext();
6223
+ const attribution = getWebsiteMarketingAttribution(context.websiteId);
6224
+ const pageUrl = typeof window !== "undefined" ? window.location.href : void 0;
6225
+ const referrerUrl = typeof document !== "undefined" && document.referrer ? document.referrer : void 0;
6226
+ let response;
6227
+ try {
6228
+ response = await fetch(
6229
+ `${apiUrl.replace(/\/+$/, "")}/api/v1/website/forms/responses`,
6230
+ {
6231
+ method: "POST",
6232
+ headers: { "Content-Type": "application/json" },
6233
+ body: JSON.stringify({
6234
+ websiteSlug,
6235
+ formKey: normalizedKey,
6236
+ formVersion: input.formVersion,
6237
+ definitionHash: input.definitionHash,
6238
+ idempotencyKey: input.idempotencyKey,
6239
+ source: input.source ?? "WEBSITE",
6240
+ values: input.values,
6241
+ pageUrl,
6242
+ referrerUrl,
6243
+ attribution: attribution ? serializeWebsiteMarketingAttribution(attribution) : null,
6244
+ honeypot: input.honeypot ?? ""
6245
+ })
6246
+ }
6247
+ );
6248
+ } catch (error) {
6249
+ throw new WebsiteError(
6250
+ `Could not submit the website form: ${error instanceof Error ? error.message : String(error)}`,
6251
+ "NETWORK_ERROR"
6252
+ );
6253
+ }
6254
+ if (response.status === 429) {
6255
+ throw new RateLimitError(
6256
+ "Rate limit exceeded. Please try again later.",
6257
+ retryAfterSeconds(response)
6258
+ );
6259
+ }
6260
+ const result = await response.json().catch(() => null);
6261
+ if (!response.ok) {
6262
+ throw new WebsiteError(
6263
+ result?.message ?? `Could not submit the website form (status ${response.status})`,
6264
+ result?.code ?? (response.status === 403 ? "ORIGIN_NOT_ALLOWED" : "FORM_RESPONSE_ERROR"),
6265
+ { status: response.status }
6266
+ );
6267
+ }
6268
+ if (typeof result?.accepted !== "boolean" || typeof result.message !== "string") {
6269
+ throw new WebsiteError(
6270
+ "Website form returned an invalid response",
6271
+ "INVALID_RESPONSE"
6272
+ );
6273
+ }
6274
+ return {
6275
+ accepted: result.accepted,
6276
+ responseId: typeof result.responseId === "string" ? result.responseId : null,
6277
+ message: result.message
6278
+ };
6279
+ }
6280
+ };
6281
+ }
6282
+
6118
6283
  // libs/sdk/src/modules/marketing.ts
6119
6284
  function readRetryAfter(response) {
6120
6285
  const value = Number(response.headers.get("Retry-After") ?? "1");
@@ -6641,6 +6806,7 @@ function createBacanoClient(config) {
6641
6806
  config.websiteSlug,
6642
6807
  getContext
6643
6808
  );
6809
+ const formsModule = createFormsModule(apiUrl, config.websiteSlug, getContext);
6644
6810
  return {
6645
6811
  async init() {
6646
6812
  if (context) return;
@@ -6693,6 +6859,10 @@ function createBacanoClient(config) {
6693
6859
  getByTag: contentModule.getByTag.bind(contentModule),
6694
6860
  getForProduct: contentModule.getForProduct.bind(contentModule)
6695
6861
  },
6862
+ forms: {
6863
+ getDefinition: formsModule.getDefinition.bind(formsModule),
6864
+ submitResponse: formsModule.submitResponse.bind(formsModule)
6865
+ },
6696
6866
  cart: {
6697
6867
  getOrCreate: cartModule.getOrCreate.bind(cartModule),
6698
6868
  startCheckout: cartModule.startCheckout.bind(cartModule),