@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/README.md +6 -0
- package/STOREFRONT_FORMS.md +147 -0
- package/client.d.ts +8 -1
- package/index.cjs +172 -2
- package/index.d.ts +1 -1
- package/index.js +172 -2
- package/modules/forms.d.ts +9 -0
- package/package.json +6 -1
- package/react/form-field-control.d.ts +10 -0
- package/react/forms.cjs +450 -0
- package/react/forms.d.ts +49 -0
- package/react/forms.js +435 -0
- package/react.cjs +172 -2
- package/react.js +172 -2
- package/skills/bacano-web/SKILL.md +3 -0
- package/skills/bacano-web/references/forms.md +50 -0
- package/skills/bacano-web/references/informational.md +3 -0
- package/types.d.ts +83 -0
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,
|
|
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;
|