@bacano/sdk 1.1.13 → 1.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTENT_TYPE_MANAGEMENT.md +54 -8
- package/README.md +11 -4
- package/STOREFRONT_FORMS.md +147 -0
- package/client.d.ts +8 -1
- package/content-management-types.d.ts +31 -0
- package/content-management.cjs +39 -1
- package/content-management.js +39 -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-figma-web/SKILL.md +9 -3
- package/skills/bacano-figma-web/references/content-media.md +57 -0
- package/skills/bacano-figma-web/references/content-workflow.md +7 -4
- 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),
|
|
@@ -77,7 +77,8 @@ pnpm exec bacano-figma-web validate-snapshot .bacano/designs/<proyecto>/manifest
|
|
|
77
77
|
## 4. Mapear a Bacano antes de programar
|
|
78
78
|
|
|
79
79
|
Leer [references/bacano-mapping.md](references/bacano-mapping.md),
|
|
80
|
-
[references/content-workflow.md](references/content-workflow.md)
|
|
80
|
+
[references/content-workflow.md](references/content-workflow.md),
|
|
81
|
+
[references/content-media.md](references/content-media.md) y
|
|
81
82
|
[references/implementation-quality.md](references/implementation-quality.md).
|
|
82
83
|
Preparar una matriz ruta → frame → componente → fuente de datos → Content →
|
|
83
84
|
responsive → preview → estado, y determinar si el trabajo comienza desde
|
|
@@ -103,7 +104,10 @@ blueprint, leer la sección de automatización de
|
|
|
103
104
|
componentes, grupos, listas y entradas únicamente mediante el acceso temporal
|
|
104
105
|
y los scopes oficiales. Inventariar esos recursos antes de crear otros y
|
|
105
106
|
preparar entradas en borrador por defecto; la media permanece en el
|
|
106
|
-
backoffice
|
|
107
|
+
backoffice salvo que el usuario conceda `content_media:upload`. Con ese scope,
|
|
108
|
+
buscar primero imágenes reutilizables y cargar bytes únicamente mediante
|
|
109
|
+
`listMedia()` y `uploadImage()`; publicar requiere permiso y aprobación
|
|
110
|
+
explícitos.
|
|
107
111
|
|
|
108
112
|
Al proponer keys nuevas, resolver primero el idioma según
|
|
109
113
|
`CONTENT_TYPE_MANAGEMENT.md`: instrucción explícita, convención existente y
|
|
@@ -129,7 +133,9 @@ lo pendiente cuando la revisión visual quede a cargo del usuario.
|
|
|
129
133
|
|
|
130
134
|
No añadir paquetes, fuentes, iconos o assets si el proyecto ya ofrece un
|
|
131
135
|
equivalente. Descargar assets mediante la fuente Figma autorizada; no incrustar
|
|
132
|
-
URLs temporales ni construir transformaciones de Storage manualmente.
|
|
136
|
+
URLs temporales ni construir transformaciones de Storage manualmente. La carga
|
|
137
|
+
administrativa admite JPEG, PNG o WebP raster; video, documentos, SVG y
|
|
138
|
+
animaciones continúan bajo revisión editorial en el backoffice.
|
|
133
139
|
|
|
134
140
|
## 6. Validar diseño y contrato
|
|
135
141
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Imágenes de Content desde un agente
|
|
2
|
+
|
|
3
|
+
Usar este flujo solo cuando `getContext().scopes` incluya
|
|
4
|
+
`content_media:upload`. El token temporal continúa siendo administrativo: no se
|
|
5
|
+
incluye en el storefront, Figma, archivos del proyecto, logs ni respuestas al
|
|
6
|
+
usuario.
|
|
7
|
+
|
|
8
|
+
## Orden obligatorio
|
|
9
|
+
|
|
10
|
+
1. Clasificar el asset: hero desktop/mobile, card, galería, logo, decorativo u
|
|
11
|
+
otro uso editorial.
|
|
12
|
+
2. Buscar en `listMedia()` con términos semánticos. Reutilizar únicamente cuando
|
|
13
|
+
sea el mismo asset, no solo una imagen parecida.
|
|
14
|
+
3. Exportar desde la fuente Figma autorizada los bytes originales necesarios.
|
|
15
|
+
4. Para imágenes informativas, preparar un nombre descriptivo y `altText`
|
|
16
|
+
contextual. No usar nombres de capas como `Frame 32` o `image_1`.
|
|
17
|
+
5. Cargar con `uploadImage()`. Nunca usar GraphQL, URLs de Figma, base64 dentro
|
|
18
|
+
de una entrada ni endpoints directos de Nhost Storage.
|
|
19
|
+
6. Guardar en Content el `fileId` devuelto y volver a consultar la entrada.
|
|
20
|
+
7. Mantener en borrador hasta validar imagen, encuadre, responsive y texto
|
|
21
|
+
alternativo en contexto.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { readFile } from "node:fs/promises";
|
|
25
|
+
import { createContentManagementClient } from "@bacano/sdk/content-management";
|
|
26
|
+
|
|
27
|
+
const content = createContentManagementClient({
|
|
28
|
+
apiUrl: process.env.BACANO_API_URL!,
|
|
29
|
+
accessToken: process.env.BACANO_CONTENT_ACCESS_TOKEN!,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const matches = await content.listMedia({ search: "hero juguetes" });
|
|
33
|
+
const media = matches.items[0] ?? await content.uploadImage({
|
|
34
|
+
file: await readFile("./exports/hero-juguetes-desktop.png"),
|
|
35
|
+
fileName: "Hero juguetes para bebé desktop",
|
|
36
|
+
altText: "Niña jugando con bloques de colores",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
console.log(media.fileId);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Contrato de la primera versión
|
|
43
|
+
|
|
44
|
+
- Admite JPEG, PNG y WebP raster no animado.
|
|
45
|
+
- Máximo 10 MB de entrada y 100 megapíxeles.
|
|
46
|
+
- Bacano valida bytes reales, corrige orientación y normaliza a WebP.
|
|
47
|
+
- El nombre se convierte a un filename SEO seguro.
|
|
48
|
+
- Los duplicados binarios normalizados se reutilizan dentro de la misma
|
|
49
|
+
empresa; nunca entre empresas.
|
|
50
|
+
- SVG, GIF animado, video y documentos se cargan desde el backoffice.
|
|
51
|
+
- El agente no reemplaza ni elimina físicamente media.
|
|
52
|
+
- `altText` de biblioteca es fallback. El texto alternativo final pertenece al
|
|
53
|
+
uso dentro de la entrada; una imagen decorativa usa `alt=""` en ese contexto.
|
|
54
|
+
|
|
55
|
+
Si el asset no cabe en este contrato, reportar `BLOQUEADO` y pedir que el equipo
|
|
56
|
+
editorial lo prepare en la biblioteca. No convertirlo de forma improvisada ni
|
|
57
|
+
introducir paquetes o servicios paralelos.
|
|
@@ -155,7 +155,9 @@ usuario autoriza que el agente materialice el blueprint, usar exclusivamente
|
|
|
155
155
|
agente**. Confirmar la empresa y los scopes, inventariar primero, crear
|
|
156
156
|
componentes antes de sus consumidores y trabajar inicialmente en borrador. La
|
|
157
157
|
skill puede preparar entradas cuando el usuario conceda
|
|
158
|
-
`content_entries:manage
|
|
158
|
+
`content_entries:manage`. Si además se concede `content_media:upload`, debe
|
|
159
|
+
buscar y cargar imágenes por el contrato descrito en
|
|
160
|
+
[content-media.md](content-media.md). No publica sin el scope específico.
|
|
159
161
|
|
|
160
162
|
## 6. Cambio a Bacano real
|
|
161
163
|
|
|
@@ -189,7 +191,8 @@ escribir:
|
|
|
189
191
|
locale; crear las faltantes en borrador y actualizar únicamente con
|
|
190
192
|
`expectedUpdatedAt`.
|
|
191
193
|
7. No fusionar por nombres, no usar HTML crudo y no relacionar URLs remotas. La
|
|
192
|
-
media debe existir en Bacano y pertenecer a la empresa autorizada
|
|
194
|
+
media debe existir en Bacano y pertenecer a la empresa autorizada; resolver
|
|
195
|
+
su `fileId` mediante `listMedia()` o `uploadImage()`.
|
|
193
196
|
8. Para archivar o eliminar tipos, entradas, grupos o listas, inspeccionar
|
|
194
197
|
dependencias y confirmar nuevamente. Una actualización de lista debe enviar
|
|
195
198
|
todos los elementos que deben permanecer, incluidos sus IDs existentes.
|
|
@@ -198,5 +201,5 @@ escribir:
|
|
|
198
201
|
|
|
199
202
|
Nunca exponer el token en el navegador, commit, blueprint, salida de consola o
|
|
200
203
|
chat. Nunca convertir el blueprint en una importación implícita ni usar GraphQL
|
|
201
|
-
directo.
|
|
202
|
-
|
|
204
|
+
directo. Sin `content_media:upload`, toda media permanece bajo gestión
|
|
205
|
+
editorial en el backoffice.
|
|
@@ -51,6 +51,9 @@ de proponer cambios. Como mínimo revisar `STOREFRONT_LEGAL.md`,
|
|
|
51
51
|
`AUTHENTICATION.md`, `STOREFRONT_LEADS.md`, `STOREFRONT_CLARITY.md` y
|
|
52
52
|
otros contratos únicamente cuando esas capacidades existan. Leer
|
|
53
53
|
`STOREFRONT_CONSTRUCTION.md` cuando el website permita ocultarse temporalmente.
|
|
54
|
+
Si existen formularios administrados desde Bacano, leer obligatoriamente
|
|
55
|
+
`STOREFRONT_FORMS.md` y
|
|
56
|
+
[references/forms.md](references/forms.md) antes de auditarlos o modificarlos.
|
|
54
57
|
|
|
55
58
|
## 3. Auditar sin alterar el sistema
|
|
56
59
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Formularios públicos de Bacano
|
|
2
|
+
|
|
3
|
+
Aplicar este contrato cuando el sitio muestre al menos un formulario administrado
|
|
4
|
+
desde Bacano. Leer también `STOREFRONT_FORMS.md` completo desde el paquete SDK.
|
|
5
|
+
|
|
6
|
+
## Implementación oficial
|
|
7
|
+
|
|
8
|
+
- Clasificar formularios como `ACTIVA`, `INACTIVA` o `BLOQUEADA` en la matriz de
|
|
9
|
+
capacidades.
|
|
10
|
+
- Usar `BacanoForm` desde `@bacano/sdk/react/forms` o un renderer propio basado
|
|
11
|
+
exclusivamente en `useBacanoForm()`.
|
|
12
|
+
- Reutilizar el único `BacanoProvider` y cliente global del sitio. No crear otro
|
|
13
|
+
cliente, consultar GraphQL ni llamar endpoints internos.
|
|
14
|
+
- No duplicar keys, validaciones, opciones o estructura en un esquema local.
|
|
15
|
+
- En Static First, obtener la definición durante build/servidor y entregarla
|
|
16
|
+
como `initialForm`. La revalidación runtime no debe borrar el HTML inicial.
|
|
17
|
+
- Renderizar campos directos, secciones visuales y pasos según su semántica. Una
|
|
18
|
+
sección no es un paso y no debe inventar navegación.
|
|
19
|
+
- Las listas de Content llegan resueltas por Bacano; el storefront no reconstruye
|
|
20
|
+
opciones ni consulta Content por separado.
|
|
21
|
+
|
|
22
|
+
## Envío y seguridad
|
|
23
|
+
|
|
24
|
+
- Enviar mediante `client.forms.submitResponse()` o el envío interno de
|
|
25
|
+
`BacanoForm`.
|
|
26
|
+
- Conservar una `idempotencyKey` durante reintentos del mismo intento lógico.
|
|
27
|
+
- Enviar la versión y `definitionHash` exactos de la definición renderizada.
|
|
28
|
+
- Respetar estados `ACTIVE` y `PAUSED`; no renderizar borradores o archivados.
|
|
29
|
+
- No escribir respuestas o PII en consola, analítica, URLs o logs del storefront.
|
|
30
|
+
- No afirmar que se creó un lead, se envió un correo o se abrió una solicitud si
|
|
31
|
+
esa acción aún no está implementada y verificada en Bacano.
|
|
32
|
+
- Una brecha del SDK se reporta; nunca se resuelve con GraphQL directo ni una API
|
|
33
|
+
paralela del storefront.
|
|
34
|
+
|
|
35
|
+
## Verificaciones obligatorias
|
|
36
|
+
|
|
37
|
+
- Formulario activo: estructura, labels, required, ayudas, opciones y envío.
|
|
38
|
+
- Formulario pausado e inexistente: mensaje seguro y ningún envío.
|
|
39
|
+
- Versión desactualizada: refresh oficial y conservación controlada de datos.
|
|
40
|
+
- Doble clic y reintento: una sola respuesta por intento idempotente.
|
|
41
|
+
- Error de red: estado recuperable sin promesa falsa de recepción.
|
|
42
|
+
- Persistencia: la respuesta aparece una sola vez en Bacano con versión, origen y
|
|
43
|
+
snapshot correctos.
|
|
44
|
+
- Accesibilidad: label asociado, foco, teclado, mensajes de error y progreso de
|
|
45
|
+
pasos comprensibles.
|
|
46
|
+
- Static First: el HTML exportado conserva el formulario cuando corresponde.
|
|
47
|
+
|
|
48
|
+
Reportar evidencia con la key pública, ruta, archivo del renderer, requests
|
|
49
|
+
observados y prueba de persistencia. Nunca incluir valores personales reales en
|
|
50
|
+
el informe.
|
|
@@ -12,6 +12,9 @@ Aplicar además de los controles comunes.
|
|
|
12
12
|
|
|
13
13
|
## Formularios y contacto
|
|
14
14
|
|
|
15
|
+
- Los formularios administrados desde Bacano cumplen
|
|
16
|
+
[forms.md](forms.md), usan el renderer o hook oficial y conservan su definición
|
|
17
|
+
estática cuando corresponde.
|
|
15
18
|
- Distinguir formularios informativos/PQRS de capturas comerciales.
|
|
16
19
|
- Newsletter, cupón o interés comercial usan
|
|
17
20
|
`client.marketing.captureLead()` y el texto vigente de autorización.
|
package/types.d.ts
CHANGED
|
@@ -149,6 +149,89 @@ export interface WebsiteCampaignCaptureResult {
|
|
|
149
149
|
accepted: boolean;
|
|
150
150
|
message: string;
|
|
151
151
|
}
|
|
152
|
+
export type WebsiteFormAction = "STORE_RESPONSE" | "CAPTURE_LEAD";
|
|
153
|
+
export type WebsiteFormStatus = "ACTIVE" | "PAUSED";
|
|
154
|
+
export type WebsiteFormFieldType = "text" | "textarea" | "email" | "phone" | "number" | "date" | "select" | "radio" | "multi_select" | "checkbox";
|
|
155
|
+
export interface WebsiteFormOption {
|
|
156
|
+
value: string;
|
|
157
|
+
label: string;
|
|
158
|
+
}
|
|
159
|
+
export type WebsiteFormOptionSource = {
|
|
160
|
+
kind: "INLINE";
|
|
161
|
+
options: WebsiteFormOption[];
|
|
162
|
+
} | {
|
|
163
|
+
kind: "CONTENT_LIST";
|
|
164
|
+
listKey: string;
|
|
165
|
+
};
|
|
166
|
+
export interface WebsiteFormField {
|
|
167
|
+
key: string;
|
|
168
|
+
label: string;
|
|
169
|
+
type: WebsiteFormFieldType;
|
|
170
|
+
required: boolean;
|
|
171
|
+
placeholder?: string | null;
|
|
172
|
+
helpText?: string | null;
|
|
173
|
+
optionSource?: WebsiteFormOptionSource | null;
|
|
174
|
+
/** Resolved public options. Content List internals never need rebuilding by a storefront. */
|
|
175
|
+
options?: WebsiteFormOption[];
|
|
176
|
+
validation: {
|
|
177
|
+
min?: number | null;
|
|
178
|
+
max?: number | null;
|
|
179
|
+
minLength?: number | null;
|
|
180
|
+
maxLength?: number | null;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export interface WebsiteFormSection {
|
|
184
|
+
key: string;
|
|
185
|
+
title: string;
|
|
186
|
+
description?: string | null;
|
|
187
|
+
fields: WebsiteFormField[];
|
|
188
|
+
}
|
|
189
|
+
export interface WebsiteFormStep {
|
|
190
|
+
key: string;
|
|
191
|
+
title: string;
|
|
192
|
+
description?: string | null;
|
|
193
|
+
fields: WebsiteFormField[];
|
|
194
|
+
sections: WebsiteFormSection[];
|
|
195
|
+
}
|
|
196
|
+
export interface WebsiteFormDefinition {
|
|
197
|
+
schemaVersion: 1;
|
|
198
|
+
submitLabel: string;
|
|
199
|
+
successMessage: string;
|
|
200
|
+
unavailableMessage: string;
|
|
201
|
+
fields: WebsiteFormField[];
|
|
202
|
+
sections: WebsiteFormSection[];
|
|
203
|
+
steps: WebsiteFormStep[];
|
|
204
|
+
}
|
|
205
|
+
export interface WebsitePublishedForm {
|
|
206
|
+
schemaVersion: 1;
|
|
207
|
+
id: string;
|
|
208
|
+
key: string;
|
|
209
|
+
name: string;
|
|
210
|
+
description: string;
|
|
211
|
+
action: WebsiteFormAction;
|
|
212
|
+
status: WebsiteFormStatus;
|
|
213
|
+
version: number;
|
|
214
|
+
publishedAt: string;
|
|
215
|
+
definitionHash: string;
|
|
216
|
+
definition: WebsiteFormDefinition;
|
|
217
|
+
}
|
|
218
|
+
export type WebsiteFormResponseSource = "WEBSITE" | "QR";
|
|
219
|
+
export type WebsiteFormResponseValue = string | string[] | boolean;
|
|
220
|
+
export interface WebsiteFormResponseInput {
|
|
221
|
+
formKey: string;
|
|
222
|
+
formVersion: number;
|
|
223
|
+
definitionHash: string;
|
|
224
|
+
idempotencyKey: string;
|
|
225
|
+
values: Record<string, WebsiteFormResponseValue>;
|
|
226
|
+
source?: WebsiteFormResponseSource;
|
|
227
|
+
/** Bot-trap field. Storefronts should render it visually hidden and leave it empty. */
|
|
228
|
+
honeypot?: string;
|
|
229
|
+
}
|
|
230
|
+
export interface WebsiteFormResponseResult {
|
|
231
|
+
accepted: boolean;
|
|
232
|
+
responseId: string | null;
|
|
233
|
+
message: string;
|
|
234
|
+
}
|
|
152
235
|
export type ResolvedWebsitePublicConfig = WebsitePublicConfig & {
|
|
153
236
|
branding: WebsiteBrandingPublicConfig;
|
|
154
237
|
legal: WebsiteLegalPublicConfig;
|