@bacano/sdk 1.1.15 → 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),
@@ -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;