@bacano/sdk 1.2.7 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/react.js CHANGED
@@ -5877,6 +5877,78 @@ function hasRecoverableCartSessionError(errors) {
5877
5877
  );
5878
5878
  }
5879
5879
 
5880
+ // libs/sdk/src/modules/checkout-contact.ts
5881
+ var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5882
+ var PHONE_PATTERN = /^\+?\d{7,15}$/;
5883
+ function validEmail(value) {
5884
+ if (value.length > 320 || !EMAIL_PATTERN.test(value)) return false;
5885
+ const [local, domain] = value.split("@");
5886
+ if (!local || !domain || local.length > 64 || domain.length > 255 || local.startsWith(".") || local.endsWith(".") || local.includes("..")) {
5887
+ return false;
5888
+ }
5889
+ return domain.split(".").every((label) => /^[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(label));
5890
+ }
5891
+ function normalizePhone(value) {
5892
+ return value.trim().replace(/[\s().-]/g, "");
5893
+ }
5894
+ function invalidContact(field) {
5895
+ const messages = {
5896
+ email: "Revisa el correo electr\xF3nico: debe estar completo y sin espacios.",
5897
+ phone: "Revisa el n\xFAmero de tel\xE9fono: usa entre 7 y 15 d\xEDgitos.",
5898
+ contact: "Ingresa un correo electr\xF3nico o un n\xFAmero de tel\xE9fono v\xE1lido."
5899
+ };
5900
+ return new WebsiteError(
5901
+ messages[field],
5902
+ `INVALID_CHECKOUT_${field.toUpperCase()}`
5903
+ );
5904
+ }
5905
+ function normalizeCheckoutContact(contact, mode) {
5906
+ const email = contact.email?.trim() || void 0;
5907
+ const phone = contact.phone ? normalizePhone(contact.phone) : void 0;
5908
+ const cellphone = contact.cellphone ? normalizePhone(contact.cellphone) : void 0;
5909
+ const emailValid = !email || validEmail(email);
5910
+ const phoneValid = !phone || PHONE_PATTERN.test(phone);
5911
+ const cellphoneValid = !cellphone || PHONE_PATTERN.test(cellphone);
5912
+ if (mode === "submit") {
5913
+ if (!emailValid) throw invalidContact("email");
5914
+ if (!phoneValid || !cellphoneValid) throw invalidContact("phone");
5915
+ }
5916
+ const normalized = {
5917
+ ...contact,
5918
+ name: contact.name?.trim() || void 0,
5919
+ firstName: contact.firstName?.trim() || void 0,
5920
+ lastName: contact.lastName?.trim() || void 0,
5921
+ email: emailValid ? email : void 0,
5922
+ phone: phoneValid ? phone : void 0,
5923
+ cellphone: cellphoneValid ? cellphone : void 0
5924
+ };
5925
+ if ((normalized.name?.length ?? 0) > 160 || (normalized.firstName?.length ?? 0) > 80 || (normalized.lastName?.length ?? 0) > 80) {
5926
+ throw new WebsiteError(
5927
+ "Revisa los datos de contacto: el nombre es demasiado largo.",
5928
+ "INVALID_CHECKOUT_CONTACT"
5929
+ );
5930
+ }
5931
+ if (!normalized.email && !normalized.phone && !normalized.cellphone) {
5932
+ throw invalidContact(
5933
+ !emailValid ? "email" : !phoneValid || !cellphoneValid ? "phone" : "contact"
5934
+ );
5935
+ }
5936
+ return normalized;
5937
+ }
5938
+ function explainCheckoutRequestError(error) {
5939
+ if (error instanceof GraphQLRequestError && error.graphqlErrors.some(
5940
+ (item) => /invalid request payload|hay un error en los datos del pedido/i.test(
5941
+ item.message
5942
+ )
5943
+ )) {
5944
+ throw new WebsiteError(
5945
+ "Hay un error en los datos del pedido. Rev\xEDsalos e intenta de nuevo.",
5946
+ "INVALID_CHECKOUT_DATA"
5947
+ );
5948
+ }
5949
+ throw error;
5950
+ }
5951
+
5880
5952
  // libs/sdk/src/modules/cart.ts
5881
5953
  function mapCartItem(raw) {
5882
5954
  return {
@@ -5952,6 +6024,15 @@ function requireCartSession(storage) {
5952
6024
  return session;
5953
6025
  }
5954
6026
  function createCartModule(graphqlUrl, getHeaders, getContext, cartSessionStorage) {
6027
+ let pendingCartSessionOperation = Promise.resolve();
6028
+ function enqueueCartSessionOperation(run) {
6029
+ const operation = pendingCartSessionOperation.then(run, run);
6030
+ pendingCartSessionOperation = operation.then(
6031
+ () => void 0,
6032
+ () => void 0
6033
+ );
6034
+ return operation;
6035
+ }
5955
6036
  async function fetchCart(orderId, cartToken) {
5956
6037
  const data = await executeGraphQL(graphqlUrl, GET_CART, { orderId, cartToken }, getHeaders);
5957
6038
  const result = data.websiteGetCart;
@@ -5974,72 +6055,80 @@ function createCartModule(graphqlUrl, getHeaders, getContext, cartSessionStorage
5974
6055
  }
5975
6056
  return {
5976
6057
  async getOrCreate(contact, fulfillmentFamily) {
5977
- const ctx = getContext();
5978
- if (!ctx.chainId) {
5979
- throw new WebsiteError(
5980
- "This website has no chain configured. Cart requires a chain_id.",
5981
- "NO_CHAIN"
5982
- );
5983
- }
5984
- const existing = cartSessionStorage.get();
5985
- const data = await executeGraphQL(
5986
- graphqlUrl,
5987
- CREATE_CART,
5988
- {
5989
- websiteId: ctx.websiteId,
5990
- cartToken: existing?.cartToken,
5991
- contact,
5992
- fulfillmentFamily
5993
- },
5994
- getHeaders
5995
- );
5996
- const result = data.websiteCreateCart;
5997
- assertActionSuccess(result, "CART_CREATE_ERROR");
5998
- cartSessionStorage.set({
5999
- orderId: result.order_id,
6000
- cartToken: result.cart_token
6001
- });
6002
- return mapCart(result.cart, result.cart_token);
6003
- },
6004
- async startCheckout(input) {
6005
- const ctx = getContext();
6006
- if (!ctx.chainId) {
6007
- throw new WebsiteError(
6008
- "This website has no chain configured. Checkout requires a chain_id.",
6009
- "NO_CHAIN"
6010
- );
6011
- }
6012
- const requestStartCheckout = async (cartToken) => {
6058
+ return enqueueCartSessionOperation(async () => {
6059
+ const normalizedContact = contact ? normalizeCheckoutContact(contact, "draft") : void 0;
6060
+ const ctx = getContext();
6061
+ if (!ctx.chainId) {
6062
+ throw new WebsiteError(
6063
+ "This website has no chain configured. Cart requires a chain_id.",
6064
+ "NO_CHAIN"
6065
+ );
6066
+ }
6067
+ const existing = cartSessionStorage.get();
6013
6068
  const data = await executeGraphQL(
6014
6069
  graphqlUrl,
6015
- START_GUEST_CHECKOUT,
6070
+ CREATE_CART,
6016
6071
  {
6017
6072
  websiteId: ctx.websiteId,
6018
- cartToken,
6019
- contact: input.contact,
6020
- fulfillmentFamily: input.fulfillmentFamily,
6021
- items: input.items.map((item) => ({
6022
- product_variant_id: item.productVariantId,
6023
- quantity: item.quantity
6024
- })),
6025
- marketingAttribution: input.marketingAttribution ? serializeWebsiteMarketingAttribution(input.marketingAttribution) : void 0
6073
+ cartToken: existing?.cartToken,
6074
+ contact: normalizedContact,
6075
+ fulfillmentFamily
6026
6076
  },
6027
6077
  getHeaders
6028
- );
6029
- return data.websiteStartGuestCheckout;
6030
- };
6031
- const existing = cartSessionStorage.get();
6032
- let result = await requestStartCheckout(existing?.cartToken);
6033
- if (existing && !result.success && hasRecoverableCartSessionError(result.errors)) {
6034
- cartSessionStorage.clear();
6035
- result = await requestStartCheckout();
6036
- }
6037
- assertActionSuccess(result, "CHECKOUT_START_ERROR");
6038
- cartSessionStorage.set({
6039
- orderId: result.order_id,
6040
- cartToken: result.cart_token
6078
+ ).catch(explainCheckoutRequestError);
6079
+ const result = data.websiteCreateCart;
6080
+ assertActionSuccess(result, "CART_CREATE_ERROR");
6081
+ cartSessionStorage.set({
6082
+ orderId: result.order_id,
6083
+ cartToken: result.cart_token
6084
+ });
6085
+ return mapCart(result.cart, result.cart_token);
6086
+ });
6087
+ },
6088
+ async startCheckout(input) {
6089
+ return enqueueCartSessionOperation(async () => {
6090
+ const contact = normalizeCheckoutContact(input.contact, "draft");
6091
+ const ctx = getContext();
6092
+ if (!ctx.chainId) {
6093
+ throw new WebsiteError(
6094
+ "This website has no chain configured. Checkout requires a chain_id.",
6095
+ "NO_CHAIN"
6096
+ );
6097
+ }
6098
+ const requestStartCheckout = async (cartToken) => {
6099
+ const data = await executeGraphQL(
6100
+ graphqlUrl,
6101
+ START_GUEST_CHECKOUT,
6102
+ {
6103
+ websiteId: ctx.websiteId,
6104
+ cartToken,
6105
+ contact,
6106
+ fulfillmentFamily: input.fulfillmentFamily,
6107
+ items: input.items.map((item) => ({
6108
+ product_variant_id: item.productVariantId,
6109
+ quantity: item.quantity
6110
+ })),
6111
+ marketingAttribution: input.marketingAttribution ? serializeWebsiteMarketingAttribution(
6112
+ input.marketingAttribution
6113
+ ) : void 0
6114
+ },
6115
+ getHeaders
6116
+ ).catch(explainCheckoutRequestError);
6117
+ return data.websiteStartGuestCheckout;
6118
+ };
6119
+ const existing = cartSessionStorage.get();
6120
+ let result = await requestStartCheckout(existing?.cartToken);
6121
+ if (existing && !result.success && hasRecoverableCartSessionError(result.errors)) {
6122
+ cartSessionStorage.clear();
6123
+ result = await requestStartCheckout();
6124
+ }
6125
+ assertActionSuccess(result, "CHECKOUT_START_ERROR");
6126
+ cartSessionStorage.set({
6127
+ orderId: result.order_id,
6128
+ cartToken: result.cart_token
6129
+ });
6130
+ return mapCart(result.cart, result.cart_token);
6041
6131
  });
6042
- return mapCart(result.cart, result.cart_token);
6043
6132
  },
6044
6133
  async get() {
6045
6134
  const session = cartSessionStorage.get();
@@ -6578,6 +6667,7 @@ function createCheckoutModule(graphqlUrl, getHeaders, getContext, cartSessionSto
6578
6667
  return mapCouponResult(data.websiteRemoveCoupon);
6579
6668
  },
6580
6669
  async submit(input) {
6670
+ const contact = normalizeCheckoutContact(input.contact, "submit");
6581
6671
  const session = requireCartSession2(cartSessionStorage);
6582
6672
  const data = await executeGraphQL(
6583
6673
  graphqlUrl,
@@ -6585,7 +6675,7 @@ function createCheckoutModule(graphqlUrl, getHeaders, getContext, cartSessionSto
6585
6675
  {
6586
6676
  orderId: session.orderId,
6587
6677
  cartToken: session.cartToken,
6588
- contact: input.contact,
6678
+ contact,
6589
6679
  fulfillmentFamily: input.fulfillmentFamily,
6590
6680
  address: input.address,
6591
6681
  shippingQuoteId: input.shippingQuoteId,
@@ -6605,7 +6695,7 @@ function createCheckoutModule(graphqlUrl, getHeaders, getContext, cartSessionSto
6605
6695
  } : void 0
6606
6696
  },
6607
6697
  getHeaders
6608
- );
6698
+ ).catch(explainCheckoutRequestError);
6609
6699
  const result = data.websiteCheckout;
6610
6700
  if (!result.success && result.errors && result.errors.length > 0) {
6611
6701
  throw new CheckoutError(result.errors);
@@ -6646,7 +6736,7 @@ function createCheckoutModule(graphqlUrl, getHeaders, getContext, cartSessionSto
6646
6736
  returnUrl: input.returnUrl
6647
6737
  },
6648
6738
  getHeaders
6649
- );
6739
+ ).catch(explainCheckoutRequestError);
6650
6740
  const result = data.websiteCreatePaymentSession;
6651
6741
  if (result.success && result.payment_session_id && (result.integration_mode !== "redirect" || !result.redirect_url)) {
6652
6742
  throw new WebsiteError(
@@ -6681,7 +6771,7 @@ function createCheckoutModule(graphqlUrl, getHeaders, getContext, cartSessionSto
6681
6771
  gatewayTransactionId: input.gatewayTransactionId
6682
6772
  },
6683
6773
  getHeaders
6684
- );
6774
+ ).catch(explainCheckoutRequestError);
6685
6775
  const result = data.websiteReconcilePaymentSession;
6686
6776
  return {
6687
6777
  success: result.success,
@@ -54,9 +54,16 @@ otros contratos únicamente cuando esas capacidades existan. Leer
54
54
  `STOREFRONT_CONSTRUCTION.md` cuando el website permita ocultarse temporalmente.
55
55
  Si la tienda acepta pagos, leer obligatoriamente `STOREFRONT_PAYMENTS.md` antes
56
56
  de implementar, depurar o auditar checkout, retorno, reintentos o webhooks.
57
- Si existen formularios administrados desde Bacano, leer obligatoriamente
58
- `STOREFRONT_FORMS.md` y
59
- [references/forms.md](references/forms.md) antes de auditarlos o modificarlos.
57
+ En un checkout con autoguardado, comprobar además que no cree pedidos con
58
+ contactos parciales ni solicitudes simultáneas; tras un rechazo, el reintento
59
+ debe conservar el mismo pedido incluso si la sesión de pasarela sigue
60
+ `pending`. Aplicar los casos de [references/storefront.md](references/storefront.md).
61
+ Si existe cualquier formulario visible, leer obligatoriamente
62
+ `STOREFRONT_FORMS.md`. Para formularios propios del storefront, comprobar que
63
+ Zod valida el flujo real, con pruebas de errores por campo y bloqueo del envío
64
+ inválido; su mera presencia en `package.json` no basta. Si el formulario es
65
+ administrado desde Bacano, leer además [references/forms.md](references/forms.md)
66
+ y no duplicar su definición publicada en Zod.
60
67
  Cuando marketing esté activo y existan CTA o formularios, leer
61
68
  [references/interactions.md](references/interactions.md): inventariar los CTA,
62
69
  asignar un propietario por evento y usar los helpers oficiales del SDK. Esta
@@ -76,6 +76,11 @@ función apagada en un bloqueo artificial.
76
76
  - Navegación por teclado, foco visible, landmarks, labels, contraste y zoom.
77
77
  - Formularios con errores específicos, estados de envío y prevención de doble
78
78
  envío. No usar solo color para comunicar estado.
79
+ - En formularios propios del storefront, verificar Zod en dependencias **y** en
80
+ el código que valida antes del envío; comprobar pruebas para valores válidos,
81
+ inválidos y corrección del error. Si falta, registrar `P1 · Calidad`; si un
82
+ dato inválido llega a checkout o impide comprar, evaluar además el flujo como
83
+ `P0 · Gate`. No exigir un esquema Zod paralelo a `BacanoForm`.
79
84
  - Probar anchos móviles, tablet y escritorio sin scroll interno accidental.
80
85
 
81
86
  ## Legal y privacidad
@@ -25,6 +25,8 @@ ya resuelve la identidad; no ejecutar una segunda captura por campaña.
25
25
  - Reutilizar el único `BacanoProvider` y cliente global del sitio. No crear otro
26
26
  cliente, consultar GraphQL ni llamar endpoints internos.
27
27
  - No duplicar keys, validaciones, opciones o estructura en un esquema local.
28
+ En particular, no imponer un esquema Zod paralelo a la definición publicada;
29
+ Zod corresponde a formularios propios del storefront (ver `STOREFRONT_FORMS.md`).
28
30
  - En Static First, obtener la definición durante build/servidor y entregarla
29
31
  como `initialForm`. La revalidación runtime no debe borrar el HTML inicial.
30
32
  - Renderizar campos directos, secciones visuales y pasos según su semántica. Una
@@ -56,8 +56,18 @@ Aplicar además de los controles comunes.
56
56
  ## Carrito y checkout
57
57
 
58
58
  - Carrito invitado persiste y sincroniza sin duplicar líneas.
59
+ - Los campos propios de checkout se validan con Zod antes de llamar al SDK.
60
+ Inspeccionar el esquema, el punto de envío y pruebas de errores por campo:
61
+ tener Zod instalado o usar solo `type="email"`/`required` no basta. El SDK y
62
+ Bacano mantienen la validación autoritativa; la web no inventa reglas de pago.
59
63
  - Si una operación bloquea temporalmente otras mutaciones del carrito, mostrar
60
64
  el indicador de carga en el producto accionado, no en todas las tarjetas.
65
+ - Si hay autoguardado, no crear un pedido con datos de contacto parciales.
66
+ Validar los campos requeridos por ese checkout y probar escritura rápida del
67
+ teléfono/correo: una intención de compra mantiene un `orderId` estable.
68
+ - Serializar los inicios de checkout que pueden solaparse (autoguardado, cupón,
69
+ envío y botón final). Confirmar en un trace que el segundo usa el token
70
+ entregado por el primero; no asumir que un debounce evita todas las carreras.
61
71
  - Probar alta, baja, cambios de cantidad, límites de stock, cupón, envío gratis,
62
72
  impuestos, moneda y redondeo.
63
73
  - Probar solo los modos activos entre COUNTER, PICKUP y SHIPPING; propia y
@@ -78,6 +88,11 @@ Aplicar además de los controles comunes.
78
88
  de riesgo.
79
89
  - La página de respuesta no emite `purchase`; Bacano lo hace desde hechos
80
90
  autoritativos. Repetir webhook/conciliación no duplica conversiones.
91
+ - Después de abrir la pasarela, comprobar que el storefront conserva el enlace
92
+ al pedido pero no un carrito local que cree otro pedido al volver. Ante un
93
+ rechazo con sesión todavía `pending`, la respuesta debe ofrecer
94
+ `RETRY_PAYMENT` en el mismo pedido; nunca inferir aprobación o rechazo solo
95
+ del retorno del navegador.
81
96
  - Pedido, pago, entrega y desembolso permanecen conceptos separados.
82
97
  - Probar consulta pública segura y cuenta autenticada sin filtrar pedidos de
83
98
  otra persona, website o canal.
@@ -6,6 +6,7 @@ import {
6
6
  existsSync,
7
7
  lstatSync,
8
8
  mkdirSync,
9
+ readdirSync,
9
10
  readFileSync,
10
11
  readlinkSync,
11
12
  rmSync,
@@ -17,7 +18,6 @@ import { fileURLToPath } from "node:url";
17
18
 
18
19
  const scriptDirectory = dirname(fileURLToPath(import.meta.url));
19
20
  const sourceSkill = resolve(scriptDirectory, "..");
20
- const sourceSkillFile = resolve(sourceSkill, "SKILL.md");
21
21
  const projectRoot = process.cwd();
22
22
  const canonicalSkill = resolve(projectRoot, ".agents", "skills", "bacano-web");
23
23
 
@@ -43,8 +43,27 @@ function option(name) {
43
43
  return index >= 0 ? process.argv[index + 1] : null;
44
44
  }
45
45
 
46
- function hash(file) {
47
- return createHash("sha256").update(readFileSync(file)).digest("hex");
46
+ function skillFingerprint(directory) {
47
+ const digest = createHash("sha256");
48
+
49
+ function visit(current, prefix = "") {
50
+ for (const entry of readdirSync(current, { withFileTypes: true }).sort(
51
+ (first, second) => first.name.localeCompare(second.name),
52
+ )) {
53
+ const name = prefix ? `${prefix}/${entry.name}` : entry.name;
54
+ const path = resolve(current, entry.name);
55
+ if (entry.isDirectory()) {
56
+ visit(path, name);
57
+ } else if (entry.isFile()) {
58
+ digest.update(name).update("\0").update(readFileSync(path));
59
+ } else if (entry.isSymbolicLink()) {
60
+ digest.update(name).update("\0").update(readlinkSync(path));
61
+ }
62
+ }
63
+ }
64
+
65
+ visit(directory);
66
+ return digest.digest("hex");
48
67
  }
49
68
 
50
69
  function skillFile(directory) {
@@ -70,7 +89,8 @@ function installCanonical(force) {
70
89
  if (resolve(sourceSkill) === canonicalSkill) return;
71
90
 
72
91
  if (existsSync(skillFile(canonicalSkill))) {
73
- if (hash(skillFile(canonicalSkill)) === hash(sourceSkillFile)) return;
92
+ if (skillFingerprint(canonicalSkill) === skillFingerprint(sourceSkill))
93
+ return;
74
94
  removeExisting(canonicalSkill, force);
75
95
  } else if (existsSync(canonicalSkill)) {
76
96
  removeExisting(canonicalSkill, force);
@@ -170,7 +190,10 @@ function checkPath(name, path) {
170
190
  return {
171
191
  name,
172
192
  path: relativeDisplay(path),
173
- status: hash(file) === hash(sourceSkillFile) ? "current" : "outdated",
193
+ status:
194
+ skillFingerprint(path) === skillFingerprint(sourceSkill)
195
+ ? "current"
196
+ : "outdated",
174
197
  link: lstatSync(path).isSymbolicLink(),
175
198
  };
176
199
  }