@bacano/sdk 1.1.15 → 1.1.18
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 +14 -0
- package/STOREFRONT_CONTENT_LOCATOR.md +111 -0
- package/STOREFRONT_FORMS.md +211 -0
- package/client.d.ts +8 -1
- package/content-entry-locator.d.ts +16 -0
- package/index.cjs +195 -2
- package/index.d.ts +3 -1
- package/index.js +195 -2
- package/modules/forms.d.ts +9 -0
- package/package.json +6 -1
- package/react/form-field-control.d.ts +11 -0
- package/react/forms.cjs +460 -0
- package/react/forms.d.ts +49 -0
- package/react/forms.js +446 -0
- package/react.cjs +172 -2
- package/react.js +172 -2
- package/skills/bacano-figma-web/SKILL.md +8 -0
- package/skills/bacano-figma-web/references/bacano-mapping.md +5 -3
- package/skills/bacano-figma-web/references/content-workflow.md +11 -1
- package/skills/bacano-figma-web/references/implementation-quality.md +12 -0
- package/skills/bacano-web/SKILL.md +7 -1
- package/skills/bacano-web/references/common.md +7 -0
- package/skills/bacano-web/references/forms.md +67 -0
- package/skills/bacano-web/references/informational.md +3 -0
- package/types.d.ts +87 -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),
|
|
@@ -91,6 +91,11 @@ 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
|
+
- 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`.
|
|
94
99
|
- No alterar Baby Rex u otra 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
|
|
@@ -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.
|
|
@@ -35,6 +35,10 @@ reduzca duplicación real.
|
|
|
35
35
|
consumidores.
|
|
36
36
|
- No hacer depender la UI del shape crudo de Content: fixture y mapper real
|
|
37
37
|
deben converger en el mismo view model.
|
|
38
|
+
- Conservar en ese view model los atributos producidos por
|
|
39
|
+
`getContentEntryLocatorAttributes(entry)`. Toda región visual administrada
|
|
40
|
+
por una entrada debe exponerlos en su contenedor DOM exterior; no se marcan
|
|
41
|
+
campos individuales.
|
|
38
42
|
|
|
39
43
|
## 2. Elegir el punto de partida
|
|
40
44
|
|
|
@@ -138,6 +142,10 @@ Bacano mapper ─┘
|
|
|
138
142
|
El cambio de fuente debe ocurrir en el loader o repositorio de contenido, nunca
|
|
139
143
|
mediante condicionales repartidos por los componentes.
|
|
140
144
|
|
|
145
|
+
Los fixtures pueden simular el view model, pero no deben inventar marcadores de
|
|
146
|
+
entrada en producción. El marcador definitivo se deriva del `ContentEntry`
|
|
147
|
+
real para que nombre, key y locale no diverjan.
|
|
148
|
+
|
|
141
149
|
## 5. Entrega al equipo de Content
|
|
142
150
|
|
|
143
151
|
Entregar una tabla legible con:
|
|
@@ -167,7 +175,9 @@ Antes de retirar el fixture:
|
|
|
167
175
|
2. Validar campos requeridos, media y contenido largo.
|
|
168
176
|
3. Comparar la página con Figma usando Content real.
|
|
169
177
|
4. Verificar preview si la capacidad está activa.
|
|
170
|
-
5.
|
|
178
|
+
5. Inspeccionar el HTML y confirmar los marcadores definidos en
|
|
179
|
+
`STOREFRONT_CONTENT_LOCATOR.md` para cada región editorial.
|
|
180
|
+
6. Eliminar el fixture del camino de ejecución, no necesariamente de los tests.
|
|
171
181
|
|
|
172
182
|
En producción no se permite fallback silencioso a fixtures. Si una página
|
|
173
183
|
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,10 +47,14 @@ 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`;
|
|
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.
|
|
55
|
+
Si existen formularios administrados desde Bacano, leer obligatoriamente
|
|
56
|
+
`STOREFRONT_FORMS.md` y
|
|
57
|
+
[references/forms.md](references/forms.md) antes de auditarlos o modificarlos.
|
|
54
58
|
|
|
55
59
|
## 3. Auditar sin alterar el sistema
|
|
56
60
|
|
|
@@ -67,6 +71,8 @@ ejecutar compras/pagos sin autorización explícita.
|
|
|
67
71
|
5. Comparar router, salida del build y sitemap; ninguna ruta pública debe
|
|
68
72
|
omitirse silenciosamente.
|
|
69
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.
|
|
70
76
|
7. Si existe un dominio o una aplicación ejecutable, auditar sus rutas reales,
|
|
71
77
|
la consola y un trace de red con nombres y conteos de operaciones GraphQL.
|
|
72
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
|
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
- `honeypot` es un campo trampa, no un nonce, timestamp, UUID ni dato del
|
|
27
|
+
visitante. `BacanoForm` lo administra. En un renderer manual, omitirlo salvo
|
|
28
|
+
que exista un input trampa realmente oculto; para una persona siempre debe
|
|
29
|
+
permanecer vacío y el autocompletado no debe llenarlo.
|
|
30
|
+
- `accepted: true` con `responseId: null` es una aceptación silenciosa antispam:
|
|
31
|
+
no demuestra que la respuesta fue guardada. La verificación funcional exige
|
|
32
|
+
un UUID y la respuesta visible una sola vez en Bacano.
|
|
33
|
+
- Los datos de contacto deben conservar su semántica: usar campos `email` y
|
|
34
|
+
`phone`. Un campo `text` llamado correo o teléfono se guarda como texto libre
|
|
35
|
+
y no alimenta los datos de contacto normalizados para CRM o automatizaciones.
|
|
36
|
+
- Conservar una `idempotencyKey` durante reintentos del mismo intento lógico.
|
|
37
|
+
- Enviar la versión y `definitionHash` exactos de la definición renderizada.
|
|
38
|
+
- Respetar estados `ACTIVE` y `PAUSED`; no renderizar borradores o archivados.
|
|
39
|
+
- No escribir respuestas o PII en consola, analítica, URLs o logs del storefront.
|
|
40
|
+
- En `CAPTURE_LEAD`, Bacano conserva primero la respuesta y resuelve la identidad
|
|
41
|
+
internamente. No afirmar al visitante que se creó un lead o que ya era cliente:
|
|
42
|
+
la respuesta pública es genérica y no expone ese resultado.
|
|
43
|
+
- No afirmar que se envió un correo, se entregó un cupón o se abrió una solicitud
|
|
44
|
+
si esa acción aún no está implementada y verificada en Bacano.
|
|
45
|
+
- Una brecha del SDK se reporta; nunca se resuelve con GraphQL directo ni una API
|
|
46
|
+
paralela del storefront.
|
|
47
|
+
|
|
48
|
+
## Verificaciones obligatorias
|
|
49
|
+
|
|
50
|
+
- Formulario activo: estructura, labels, required, ayudas, opciones y envío.
|
|
51
|
+
- Formulario pausado e inexistente: mensaje seguro y ningún envío.
|
|
52
|
+
- Versión desactualizada: refresh oficial y conservación controlada de datos.
|
|
53
|
+
- Doble clic y reintento: una sola respuesta por intento idempotente.
|
|
54
|
+
- Error de red: estado recuperable sin promesa falsa de recepción.
|
|
55
|
+
- Persistencia: la respuesta aparece una sola vez en Bacano con versión, origen y
|
|
56
|
+
snapshot correctos.
|
|
57
|
+
- Autocompletado: completar datos con el navegador o un gestor de contraseñas no
|
|
58
|
+
llena el honeypot ni produce `responseId: null`.
|
|
59
|
+
- Semántica: correo y teléfono usan `email` y `phone`; no se infieren por label o
|
|
60
|
+
key.
|
|
61
|
+
- Accesibilidad: label asociado, foco, teclado, mensajes de error y progreso de
|
|
62
|
+
pasos comprensibles.
|
|
63
|
+
- Static First: el HTML exportado conserva el formulario cuando corresponde.
|
|
64
|
+
|
|
65
|
+
Reportar evidencia con la key pública, ruta, archivo del renderer, requests
|
|
66
|
+
observados y prueba de persistencia. Nunca incluir valores personales reales en
|
|
67
|
+
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,93 @@ 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
|
+
/**
|
|
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
|
+
*/
|
|
231
|
+
honeypot?: string;
|
|
232
|
+
}
|
|
233
|
+
export interface WebsiteFormResponseResult {
|
|
234
|
+
accepted: boolean;
|
|
235
|
+
/** A null value is not proof of persistence (for example, an anti-bot response). */
|
|
236
|
+
responseId: string | null;
|
|
237
|
+
message: string;
|
|
238
|
+
}
|
|
152
239
|
export type ResolvedWebsitePublicConfig = WebsitePublicConfig & {
|
|
153
240
|
branding: WebsiteBrandingPublicConfig;
|
|
154
241
|
legal: WebsiteLegalPublicConfig;
|