@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/README.md
CHANGED
|
@@ -112,6 +112,12 @@ Commercial forms and newsletter subscriptions must follow
|
|
|
112
112
|
Bacano owns consent evidence, identity resolution, attribution and idempotent
|
|
113
113
|
lead capture through the SDK.
|
|
114
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
|
+
|
|
115
121
|
Static and hybrid stores must also follow
|
|
116
122
|
[`STOREFRONT_PERFORMANCE.md`](./STOREFRONT_PERFORMANCE.md). It defines the
|
|
117
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>;
|
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,
|
|
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),
|
package/index.d.ts
CHANGED
|
@@ -7,4 +7,4 @@ export type { MediaResponsiveSource, MediaResponsiveSourceOptions, MediaTransfor
|
|
|
7
7
|
export { buildStorageContentMediaUrls, buildStorageFileUrl, buildStorageMediaUrls, buildStorageResponsiveSources, buildStorageTransformedFileUrl, formatMediaSrcSet, } from "./media";
|
|
8
8
|
export { getWebsiteContextFromPublicConfig, getWebsitePublicConfig, } from "./modules/auth";
|
|
9
9
|
export { getPrimaryProductImage, getProductDetailVariants, getProductImages, getProductMediaByKind, getProductMediaKind, getProductVideos, resolveProductPricing, } from "./modules/catalog";
|
|
10
|
-
export type { AccessTokenProvider, AttributeFilter, BacanoClientConfig, BacanoClientContext, Brand, Cart, CartItem, CatalogAttributeList, CatalogAttributeListFilters, CatalogAttributeListItem, CatalogAttributeListValue, CatalogCategoryList, CatalogCategoryListItem, CatalogCategoryListItemSourceType, CheckoutResult, CheckoutSubmission, ContentEntry, ContentJson, ContentMedia, ContentTypeSummary, CustomerAddress, CustomerAddressType, CustomerProfile, CustomerProfileUpdate, GetAttributeListOptions, GetCategoryListOptions, GetContentOptions, GetOrdersOptions, GetProductFiltersOptions, GetProductSeoEntriesOptions, GetProductsOptions, GetSearchSuggestionsOptions, GetStaticCatalogBootstrapOptions, GetStaticCatalogSnapshotOptions, GetWebsitePublicConfigOptions, Order, OrderItem, Product, ProductAttribute, ProductAvailability, ProductCategory, ProductContentBlock, ProductFilters, ProductMedia, ProductMediaKind, ProductOptionGroup, ProductOptionGroupMember, ProductPrice, ProductPriceType, ProductRelatedProduct, ProductSeoEntriesResult, ProductSeoEntry, ProductSortBy, ProductSpecification, ProductSpecificationFacet, ProductSpecificationField, ProductSpecificationFilterSelection, ProductSpecificationMeasure, ProductSpecificationOption, ProductSpecificationSection, ProductSubcategory, ProductVariant, ProductVariantLiveState, ProductVariantPricing, PublicOrder, PublicOrderDeliverySummary, PublicOrderItem, PublicOrderLookupInput, PublicOrderPaymentSummary, ResolvedWebsitePublicConfig, SearchSuggestion, StartGuestCheckoutInput, StaticCatalogBootstrap, StaticCatalogSnapshot, VariantAttribute, WaitForPublicOrderPaymentInput, WebsiteBrandingMedia, WebsiteBrandingPublicConfig, WebsiteCampaignCaptureContact, WebsiteCampaignCaptureInput, WebsiteCampaignCaptureResult, WebsiteCampaignCaptureSource, WebsiteCheckoutAddress, WebsiteCheckoutItem, WebsiteCouponActionResult, WebsiteDeliveryOption, WebsiteDepartmentOption, WebsiteFreeShippingProgress, WebsiteFulfillmentFamily, WebsiteGuestContact, WebsiteLegalPublicConfig, WebsiteLocationOptions, WebsiteMunicipalityOption, WebsitePaymentMethod, WebsitePaymentMode, WebsitePaymentOption, WebsitePaymentOptionMethod, WebsitePaymentSessionResult, WebsitePickupInfo, WebsitePublicConfig, WebsiteShippingPolicy, WebsiteShippingQuote, } from "./types";
|
|
10
|
+
export type { AccessTokenProvider, AttributeFilter, BacanoClientConfig, BacanoClientContext, Brand, Cart, CartItem, CatalogAttributeList, CatalogAttributeListFilters, CatalogAttributeListItem, CatalogAttributeListValue, CatalogCategoryList, CatalogCategoryListItem, CatalogCategoryListItemSourceType, CheckoutResult, CheckoutSubmission, ContentEntry, ContentJson, ContentMedia, ContentTypeSummary, CustomerAddress, CustomerAddressType, CustomerProfile, CustomerProfileUpdate, GetAttributeListOptions, GetCategoryListOptions, GetContentOptions, GetOrdersOptions, GetProductFiltersOptions, GetProductSeoEntriesOptions, GetProductsOptions, GetSearchSuggestionsOptions, GetStaticCatalogBootstrapOptions, GetStaticCatalogSnapshotOptions, GetWebsitePublicConfigOptions, Order, OrderItem, Product, ProductAttribute, ProductAvailability, ProductCategory, ProductContentBlock, ProductFilters, ProductMedia, ProductMediaKind, ProductOptionGroup, ProductOptionGroupMember, ProductPrice, ProductPriceType, ProductRelatedProduct, ProductSeoEntriesResult, ProductSeoEntry, ProductSortBy, ProductSpecification, ProductSpecificationFacet, ProductSpecificationField, ProductSpecificationFilterSelection, ProductSpecificationMeasure, ProductSpecificationOption, ProductSpecificationSection, ProductSubcategory, ProductVariant, ProductVariantLiveState, ProductVariantPricing, PublicOrder, PublicOrderDeliverySummary, PublicOrderItem, PublicOrderLookupInput, PublicOrderPaymentSummary, ResolvedWebsitePublicConfig, SearchSuggestion, StartGuestCheckoutInput, StaticCatalogBootstrap, StaticCatalogSnapshot, VariantAttribute, WaitForPublicOrderPaymentInput, WebsiteBrandingMedia, WebsiteBrandingPublicConfig, WebsiteCampaignCaptureContact, WebsiteCampaignCaptureInput, WebsiteCampaignCaptureResult, WebsiteCampaignCaptureSource, WebsiteCheckoutAddress, WebsiteCheckoutItem, WebsiteCouponActionResult, WebsiteDeliveryOption, WebsiteDepartmentOption, WebsiteFormAction, WebsiteFormDefinition, WebsiteFormField, WebsiteFormFieldType, WebsiteFormOption, WebsiteFormOptionSource, WebsiteFormResponseInput, WebsiteFormResponseResult, WebsiteFormResponseSource, WebsiteFormResponseValue, WebsiteFormSection, WebsiteFormStatus, WebsiteFormStep, WebsiteFreeShippingProgress, WebsiteFulfillmentFamily, WebsiteGuestContact, WebsiteLegalPublicConfig, WebsiteLocationOptions, WebsiteMunicipalityOption, WebsitePaymentMethod, WebsitePaymentMode, WebsitePaymentOption, WebsitePaymentOptionMethod, WebsitePaymentSessionResult, WebsitePickupInfo, WebsitePublicConfig, WebsitePublishedForm, WebsiteShippingPolicy, WebsiteShippingQuote, } from "./types";
|
package/index.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
|
}
|
|
@@ -6061,6 +6061,171 @@ function createWebsiteMarketingRuntime(options) {
|
|
|
6061
6061
|
};
|
|
6062
6062
|
}
|
|
6063
6063
|
|
|
6064
|
+
// libs/sdk/src/modules/forms.ts
|
|
6065
|
+
function retryAfterSeconds(response) {
|
|
6066
|
+
const value = Number(response.headers.get("Retry-After") ?? "1");
|
|
6067
|
+
return Number.isFinite(value) && value >= 0 ? Math.ceil(value) : 1;
|
|
6068
|
+
}
|
|
6069
|
+
function isFormKey(value) {
|
|
6070
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
6071
|
+
}
|
|
6072
|
+
function createFormsModule(apiUrl, websiteSlug, getContext) {
|
|
6073
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6074
|
+
function getServerOrigin() {
|
|
6075
|
+
if (typeof window !== "undefined") return null;
|
|
6076
|
+
const domain = getContext().domain?.trim();
|
|
6077
|
+
if (!domain) return null;
|
|
6078
|
+
try {
|
|
6079
|
+
return new URL(domain.includes("://") ? domain : `https://${domain}`).origin;
|
|
6080
|
+
} catch {
|
|
6081
|
+
return null;
|
|
6082
|
+
}
|
|
6083
|
+
}
|
|
6084
|
+
return {
|
|
6085
|
+
async getDefinition(formKey, options) {
|
|
6086
|
+
const normalizedKey = formKey.trim();
|
|
6087
|
+
if (!isFormKey(normalizedKey)) {
|
|
6088
|
+
throw new WebsiteError(
|
|
6089
|
+
"formKey has an invalid format",
|
|
6090
|
+
"INVALID_FORM_KEY"
|
|
6091
|
+
);
|
|
6092
|
+
}
|
|
6093
|
+
const cached = cache.get(normalizedKey);
|
|
6094
|
+
if (!options?.force && cached && cached.expiresAt > Date.now()) {
|
|
6095
|
+
return cached.value;
|
|
6096
|
+
}
|
|
6097
|
+
const url = new URL(
|
|
6098
|
+
`${apiUrl.replace(/\/+$/, "")}/api/v1/website/forms/definition`
|
|
6099
|
+
);
|
|
6100
|
+
url.searchParams.set("websiteSlug", websiteSlug);
|
|
6101
|
+
url.searchParams.set("formKey", normalizedKey);
|
|
6102
|
+
let response;
|
|
6103
|
+
try {
|
|
6104
|
+
const serverOrigin = getServerOrigin();
|
|
6105
|
+
const headers = new Headers();
|
|
6106
|
+
if (serverOrigin) headers.set("Origin", serverOrigin);
|
|
6107
|
+
if (cached?.etag) headers.set("If-None-Match", cached.etag);
|
|
6108
|
+
response = await fetch(url, {
|
|
6109
|
+
...serverOrigin || cached?.etag ? { headers } : {},
|
|
6110
|
+
...options?.force ? { cache: "no-cache" } : {}
|
|
6111
|
+
});
|
|
6112
|
+
} catch (error) {
|
|
6113
|
+
throw new WebsiteError(
|
|
6114
|
+
`Could not load the website form: ${error instanceof Error ? error.message : String(error)}`,
|
|
6115
|
+
"NETWORK_ERROR"
|
|
6116
|
+
);
|
|
6117
|
+
}
|
|
6118
|
+
if (response.status === 304 && cached) {
|
|
6119
|
+
cache.set(normalizedKey, {
|
|
6120
|
+
...cached,
|
|
6121
|
+
expiresAt: Date.now() + 3e4
|
|
6122
|
+
});
|
|
6123
|
+
return cached.value;
|
|
6124
|
+
}
|
|
6125
|
+
if (response.status === 404) {
|
|
6126
|
+
cache.set(normalizedKey, {
|
|
6127
|
+
etag: null,
|
|
6128
|
+
expiresAt: Date.now() + 15e3,
|
|
6129
|
+
value: null
|
|
6130
|
+
});
|
|
6131
|
+
return null;
|
|
6132
|
+
}
|
|
6133
|
+
if (response.status === 429) {
|
|
6134
|
+
throw new RateLimitError(
|
|
6135
|
+
"Rate limit exceeded. Please try again later.",
|
|
6136
|
+
retryAfterSeconds(response)
|
|
6137
|
+
);
|
|
6138
|
+
}
|
|
6139
|
+
if (!response.ok) {
|
|
6140
|
+
throw new WebsiteError(
|
|
6141
|
+
`Could not load the website form (status ${response.status})`,
|
|
6142
|
+
response.status === 403 ? "ORIGIN_NOT_ALLOWED" : "FORM_DEFINITION_ERROR",
|
|
6143
|
+
{ status: response.status }
|
|
6144
|
+
);
|
|
6145
|
+
}
|
|
6146
|
+
const value = await response.json();
|
|
6147
|
+
if (value.schemaVersion !== 1 || value.key !== normalizedKey || !Array.isArray(value.definition?.fields) || !Array.isArray(value.definition?.sections) || !Array.isArray(value.definition?.steps)) {
|
|
6148
|
+
throw new WebsiteError(
|
|
6149
|
+
"Website form returned an invalid definition",
|
|
6150
|
+
"INVALID_FORM_DEFINITION"
|
|
6151
|
+
);
|
|
6152
|
+
}
|
|
6153
|
+
cache.set(normalizedKey, {
|
|
6154
|
+
etag: response.headers.get("ETag"),
|
|
6155
|
+
expiresAt: Date.now() + 3e4,
|
|
6156
|
+
value
|
|
6157
|
+
});
|
|
6158
|
+
return value;
|
|
6159
|
+
},
|
|
6160
|
+
async submitResponse(input) {
|
|
6161
|
+
const normalizedKey = input.formKey.trim();
|
|
6162
|
+
if (!isFormKey(normalizedKey)) {
|
|
6163
|
+
throw new WebsiteError(
|
|
6164
|
+
"formKey has an invalid format",
|
|
6165
|
+
"INVALID_FORM_KEY"
|
|
6166
|
+
);
|
|
6167
|
+
}
|
|
6168
|
+
const context = getContext();
|
|
6169
|
+
const attribution = getWebsiteMarketingAttribution(context.websiteId);
|
|
6170
|
+
const pageUrl = typeof window !== "undefined" ? window.location.href : void 0;
|
|
6171
|
+
const referrerUrl = typeof document !== "undefined" && document.referrer ? document.referrer : void 0;
|
|
6172
|
+
let response;
|
|
6173
|
+
try {
|
|
6174
|
+
response = await fetch(
|
|
6175
|
+
`${apiUrl.replace(/\/+$/, "")}/api/v1/website/forms/responses`,
|
|
6176
|
+
{
|
|
6177
|
+
method: "POST",
|
|
6178
|
+
headers: { "Content-Type": "application/json" },
|
|
6179
|
+
body: JSON.stringify({
|
|
6180
|
+
websiteSlug,
|
|
6181
|
+
formKey: normalizedKey,
|
|
6182
|
+
formVersion: input.formVersion,
|
|
6183
|
+
definitionHash: input.definitionHash,
|
|
6184
|
+
idempotencyKey: input.idempotencyKey,
|
|
6185
|
+
source: input.source ?? "WEBSITE",
|
|
6186
|
+
values: input.values,
|
|
6187
|
+
pageUrl,
|
|
6188
|
+
referrerUrl,
|
|
6189
|
+
attribution: attribution ? serializeWebsiteMarketingAttribution(attribution) : null,
|
|
6190
|
+
honeypot: input.honeypot ?? ""
|
|
6191
|
+
})
|
|
6192
|
+
}
|
|
6193
|
+
);
|
|
6194
|
+
} catch (error) {
|
|
6195
|
+
throw new WebsiteError(
|
|
6196
|
+
`Could not submit the website form: ${error instanceof Error ? error.message : String(error)}`,
|
|
6197
|
+
"NETWORK_ERROR"
|
|
6198
|
+
);
|
|
6199
|
+
}
|
|
6200
|
+
if (response.status === 429) {
|
|
6201
|
+
throw new RateLimitError(
|
|
6202
|
+
"Rate limit exceeded. Please try again later.",
|
|
6203
|
+
retryAfterSeconds(response)
|
|
6204
|
+
);
|
|
6205
|
+
}
|
|
6206
|
+
const result = await response.json().catch(() => null);
|
|
6207
|
+
if (!response.ok) {
|
|
6208
|
+
throw new WebsiteError(
|
|
6209
|
+
result?.message ?? `Could not submit the website form (status ${response.status})`,
|
|
6210
|
+
result?.code ?? (response.status === 403 ? "ORIGIN_NOT_ALLOWED" : "FORM_RESPONSE_ERROR"),
|
|
6211
|
+
{ status: response.status }
|
|
6212
|
+
);
|
|
6213
|
+
}
|
|
6214
|
+
if (typeof result?.accepted !== "boolean" || typeof result.message !== "string") {
|
|
6215
|
+
throw new WebsiteError(
|
|
6216
|
+
"Website form returned an invalid response",
|
|
6217
|
+
"INVALID_RESPONSE"
|
|
6218
|
+
);
|
|
6219
|
+
}
|
|
6220
|
+
return {
|
|
6221
|
+
accepted: result.accepted,
|
|
6222
|
+
responseId: typeof result.responseId === "string" ? result.responseId : null,
|
|
6223
|
+
message: result.message
|
|
6224
|
+
};
|
|
6225
|
+
}
|
|
6226
|
+
};
|
|
6227
|
+
}
|
|
6228
|
+
|
|
6064
6229
|
// libs/sdk/src/modules/marketing.ts
|
|
6065
6230
|
function readRetryAfter(response) {
|
|
6066
6231
|
const value = Number(response.headers.get("Retry-After") ?? "1");
|
|
@@ -6587,6 +6752,7 @@ function createBacanoClient(config) {
|
|
|
6587
6752
|
config.websiteSlug,
|
|
6588
6753
|
getContext
|
|
6589
6754
|
);
|
|
6755
|
+
const formsModule = createFormsModule(apiUrl, config.websiteSlug, getContext);
|
|
6590
6756
|
return {
|
|
6591
6757
|
async init() {
|
|
6592
6758
|
if (context) return;
|
|
@@ -6639,6 +6805,10 @@ function createBacanoClient(config) {
|
|
|
6639
6805
|
getByTag: contentModule.getByTag.bind(contentModule),
|
|
6640
6806
|
getForProduct: contentModule.getForProduct.bind(contentModule)
|
|
6641
6807
|
},
|
|
6808
|
+
forms: {
|
|
6809
|
+
getDefinition: formsModule.getDefinition.bind(formsModule),
|
|
6810
|
+
submitResponse: formsModule.submitResponse.bind(formsModule)
|
|
6811
|
+
},
|
|
6642
6812
|
cart: {
|
|
6643
6813
|
getOrCreate: cartModule.getOrCreate.bind(cartModule),
|
|
6644
6814
|
startCheckout: cartModule.startCheckout.bind(cartModule),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { BacanoClientContext, WebsiteFormResponseInput, WebsiteFormResponseResult, WebsitePublishedForm } from "../types";
|
|
2
|
+
export interface GetWebsiteFormOptions {
|
|
3
|
+
force?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface FormsModule {
|
|
6
|
+
getDefinition(formKey: string, options?: GetWebsiteFormOptions): Promise<WebsitePublishedForm | null>;
|
|
7
|
+
submitResponse(input: WebsiteFormResponseInput): Promise<WebsiteFormResponseResult>;
|
|
8
|
+
}
|
|
9
|
+
export declare function createFormsModule(apiUrl: string, websiteSlug: string, getContext: () => BacanoClientContext): FormsModule;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacano/sdk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.17",
|
|
4
4
|
"description": "TypeScript SDK for building websites on the Bacano ERP",
|
|
5
5
|
"main": "./index.cjs",
|
|
6
6
|
"module": "./index.js",
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
"types": "./react/preview.d.ts",
|
|
38
38
|
"import": "./react/preview.js",
|
|
39
39
|
"require": "./react/preview.cjs"
|
|
40
|
+
},
|
|
41
|
+
"./react/forms": {
|
|
42
|
+
"types": "./react/forms.d.ts",
|
|
43
|
+
"import": "./react/forms.js",
|
|
44
|
+
"require": "./react/forms.cjs"
|
|
40
45
|
}
|
|
41
46
|
},
|
|
42
47
|
"sideEffects": false,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WebsiteFormField } from "../types";
|
|
2
|
+
import type { BacanoFormValues } from "./forms";
|
|
3
|
+
type BacanoFormValue = BacanoFormValues[string];
|
|
4
|
+
export declare function BacanoFormFieldControl(props: {
|
|
5
|
+
field: WebsiteFormField;
|
|
6
|
+
className?: string;
|
|
7
|
+
value: BacanoFormValue;
|
|
8
|
+
onChange(value: BacanoFormValue): void;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
10
|
+
export {};
|