@asteby/storefront-react 0.16.1 → 0.17.0

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 CHANGED
@@ -525,6 +525,63 @@ export const pay = createEmbeddedPayHandlers({
525
525
  - `cashPointsVisible={4}` deja a la vista los primeros puntos de efectivo y manda el resto a «Ver más puntos de pago» (se abre solo si el punto recordado está ahí).
526
526
  - Botón: «Generar ficha de pago $X» con efectivo (`placeOrderCash`) y «Elige la entrega para continuar» mientras falta la entrega (`chooseDelivery`; al pulsarlo señala el campo que falta en vez de pagar).
527
527
 
528
+ ### Cobro resiliente: reintento, verificación, formulario estable y aviso con salida
529
+
530
+ Un cobro con tarjeta puede quedar **indeterminado** (la respuesta se perdió, el proxy devolvió HTML, OPS tardó). La regla que nunca cambia: **un resultado indeterminado jamás se cobra con otra clave de idempotencia**; se repite con la MISMA clave o se consulta el estado. Lo de abajo se adopta de forma opcional, pero **no es solo aditivo** (ver «Cambios que se notan» al final de esta sección), y está pensado para que ninguna tienda tenga que copiar código de otra.
531
+
532
+ **1. Servidor (`/payments/server`).** `createEmbeddedPayHandlers` ya responde SIEMPRE JSON `{ error: { code, message, retryable, kind } }` (clasificado con `classifyPaymentError` de `@asteby/storefront`) y devuelve `500` en lugar de `502-504` (Traefik/CDN los cambian por HTML y se pierde el `Set-Cookie` de la sesión de pago). `/estado` sin cookie `sf_pay` responde `200 { payment: { status: 'none' }, recoverable }` en vez de `404`: `none` no es un resultado, así que un cobro indeterminado sigue con la misma clave; `recoverable` dice si la tienda aún tiene el carrito del comprador. Para rutas propias: `jsonPayRoute(run, 'pay', { locale, messages })`. Los textos de los errores salen del catálogo `error.<kind>` y se reemplazan con `errorMessages` (o `messages`/`locale` de `jsonPayRoute`).
533
+
534
+ **2. Navegador.**
535
+
536
+ ```tsx
537
+ import { PaymentMethodSection, PaymentRecovery, alternativeChoices, createResilientTransport, pickChoice, usePaymentRecovery } from '@asteby/storefront-react/payments'
538
+
539
+ const recovery = usePaymentRecovery({ scope: cart.id, onRevalidate: revalidate })
540
+ const transport = useMemo(
541
+ () => createResilientTransport({ payUrl: '/pagar/cobrar', statusUrl: '/pagar/estado', fields: () => formFields(formRef.current), onIssue: recovery.onIssue }),
542
+ [],
543
+ )
544
+
545
+ <PaymentRecovery
546
+ notice={recovery.notice} alternatives={alternativeChoices(choices.choices)}
547
+ onRetry={recovery.retry} onRefresh={() => location.assign('/pagar')}
548
+ onPick={(c) => { recovery.clear(); pickChoice(formRef.current, c.value) }} />
549
+ <PaymentMethodSection key={recovery.sectionKey} transport={transport} brickProps={{ autoRetries: 0 }} onEvent={recovery.onEvent} ... />
550
+ ```
551
+
552
+ - `createResilientTransport({ payUrl, statusUrl, fields, onIssue, retries = 2, backoffMs = [800, 2000], statusPolls = 3, statusPollMs = 2000, headers, locale, messages, fetch, sleep })`: reintenta los fallos transitorios con la misma clave, consulta `/estado` y, si sigue sin resolverse, lanza `PaymentHttpError(503, 'network', …, { retryable: true, kind: 'transient' })` (el hook conserva el intento y deja «Reintentar»). Con `retryable: false` de OPS hace UNA petición. Un cuerpo que no es JSON se trata como transitorio. `PaymentHttpError` expone `retryable` y `kind`. Con `autoRetries: 0` en `brickProps` el hook no duplica la política del transporte.
553
+ - `usePaymentRecovery({ scope, storage, formAutoRetries = 2, onRevalidate })` devuelve `notice`, `sectionKey` (úsalo como `key` de la sección), `onIssue`, `onEvent`, `retry()` y `clear()`. `retry()` quita la clave del intento y monta una sección nueva SOLO tras un fallo definitivo; mientras el resultado es desconocido (confirmando, verificando, transitorio) no hace nada y devuelve `false`. Un formulario de tarjeta que no carga se vuelve a montar solo `formAutoRetries` veces antes de mostrar el aviso.
554
+ - `<PaymentRecovery notice alternatives onRetry onPick onRefresh locale messages alternativeLabel classNames testIds>`: nunca deja un panel vacío; un fallo del comercio (credenciales, configuración) no culpa al comprador y pone primero las alternativas, que salen de las `choices` publicadas (nada de marcas en los textos). Textos en español de México por omisión (`en*` disponible), todos reemplazables con `messages` (`notice.<tipo>.title|body`, `action.retry|retry_form|refresh|alternative` con `{label}`, `error.<tipo>`; ver `paymentRecoveryMessages`), clases con `classNames` (`root title body actions primary secondary`).
555
+ - **Consulta de estado tras «verificando»**: `useEmbeddedPayment` sigue leyendo `transport.refresh` con espera creciente (2 s, 4 s, 8 s… hasta 15 s) durante `verifyTimeoutMs` (120 s). Solo un resultado resuelto (pagado, rechazado, ficha, reto, redirección) lo termina; `pending` y `none` no. Expone `verifying` y la clave del intento no cambia. Opciones: `verifyTimeoutMs`, `verifyIntervalMs`.
556
+ - `checkoutShouldRevalidate({ paths: ['/pagar'] })`: `shouldRevalidate` para las rutas de ALREDEDOR (root, encabezado…): el `fetcher.submit` de la entrega a `/pagar` ya no vuelve a ejecutar todos los loaders. No lo uses en la ruta de checkout (su loader debe releer el total).
557
+
558
+ **3. Formulario de tarjeta estable.** El contenedor se monta UNA vez por vida del formulario:
559
+
560
+ - Un cambio de total NO remonta: el hook llama `MountedPaymentForm.update({ amount })` (en Mercado Pago, `controller.update` del Card Payment Brick). Si el proveedor no lo tiene o falla, se remonta UNA vez, en cola. Varios cambios seguidos se fusionan en el último.
561
+ - Nunca se actualiza ni remonta mientras `onSubmit` va en vuelo: se aplica después de la respuesta, y un `submit` espera a que termine la actualización en curso.
562
+ - Escribir el correo ya no remonta (se usa al crear el formulario).
563
+ - `PaymentMethodSection`: mientras `syncDelivery` no termina, el formulario se queda montado con la capa «Actualizando el total…» (`data-sf-pay-updating`, `payments.css`) y el botón bloqueado (`aria-disabled`).
564
+ - Cola de montajes (`mountQueued` / `withMountQueue`, la usa el hook con cualquier adaptador): uno a la vez por contenedor, `await unmount` antes del siguiente `create`, nodo interno con id único, cancelación (`signal` en `PaymentFormMountOptions`) del montaje viejo y de uno cuyo contenedor salió del DOM, y tiempo límite de `create` (`createTimeoutMs`, por omisión `sdkTimeoutMs`) para que la cadena no se cuelgue (cae a la alternativa de redirección).
565
+ - Adaptadores propios: implementa `update?` si el proveedor sabe cambiar el monto; si no, el hook remonta.
566
+
567
+ **4. `AddressFields` (checkout).** Un solo escritor en cola; no escribe en un campo con foco ni en uno que el comprador editó (evento `input` con `isTrusted`); la colonia solo se rellena si el código postal tiene UNA sola; los eventos `input` que dispara el autollenado llevan `event.sfSynthetic === true` (`isAddressFillEvent(event)`) para que la tienda los ignore.
568
+
569
+ **Migrar una tienda que copió esto** (p. ej. `store-protekauto`): borra su `pay-errors`, `pay-transport`, `pay-route.server`, `brick-adapter` y `pay-recovery`; usa `createResilientTransport`, `usePaymentRecovery`, `<PaymentRecovery>` y los handlers del servidor; quita el `key` de `PaymentBrick` si lo tenía y el adaptador envuelto. Los textos propios van en `messages`.
570
+
571
+ ### Cambios que se notan
572
+
573
+ Al subir el pin de `@asteby/storefront` / `@asteby/storefront-react` (en 0.x un caret no adopta un minor solo):
574
+
575
+ - Los handlers de pago responden **500** donde antes respondían 502-504 (y `payErrorResponse({})` pasa de 503 a 500). La señal de «indeterminado» viaja en el cuerpo (`retryable`, `kind`); un 5xx sin cuerpo legible se trata como indeterminado (misma clave), nunca como rechazo.
576
+ - Los mensajes salen del catálogo es-MX/en y no del texto de OPS para los tipos transitorio, en curso, límite de intentos, configuración, rechazo del proveedor, pedido ya registrado y tienda cerrada. Los textos propios de la tienda (`messages.tooManyRequests`, `fillFields`, etc.) se conservan.
577
+ - `/estado` sin sesión o con un checkout inexistente responde **200** `{ status: 'none', recoverable }` en lugar de 404. `'none'` se suma a `PaymentAttemptStatus`.
578
+ - Escribir el correo ya no remonta el formulario de tarjeta: un invitado que lo escribe después del montaje ve el campo de correo dentro del Brick.
579
+ - Un cambio de total actualiza el formulario en su sitio (no lo remonta) y el formulario queda dentro de un `div` interno: un contenedor propio de `useEmbeddedPayment` no debe tener hijos renderizados por React (`replaceChildren`).
580
+ - Con resultado indeterminado, PAGAR reintenta con los mismos datos y la misma clave (no vuelve a tokenizar), y un cambio de total espera a que se resuelva.
581
+ - `createTimeoutMs` toma por omisión `sdkTimeoutMs`.
582
+ - `AddressFields` rellena la colonia cuando el código postal tiene una sola, rellena un campo enfocado solo si está vacío y no lo editó el comprador, y marca sus eventos (`isAddressFillEvent`).
583
+ - Tipos: `PayErrorBody` exige `retryable` y `kind`; `paymentErrorView` trata un 5xx (salvo `retryable:false`) como `network`.
584
+
528
585
  ## Entrega y marcas (`@asteby/storefront-react/checkout`)
529
586
 
530
587
  Las tarifas de envío y las sucursales de «Entrega» como radios nativos, iguales en todas las tiendas: marca y nombre comercial de la paquetería (`fedex` → FedEx), servicio sin repetir la paquetería, llegada en días hábiles («Llega en 4 días hábiles»), insignias discretas de «Más económico» / «Más rápido» (solo si una tarifa lo es estrictamente) y el precio tal cual lo manda OPS. La vigencia de la cotización es UNA sola nota para toda la lista, en la zona horaria del comprador («Precios de envío vigentes hasta las 9:39 p.m.»), que aparece tras hidratar en una línea reservada (sin salto de diseño). `loading` pinta filas esqueleto al recotizar.
@@ -690,7 +747,7 @@ La cookie, el formulario y el redirect son de la tienda; `safeRedirect` valida e
690
747
  | `defineSection`, `SectionRegistry`, `createSectionRegistry`, `Sections`, `resolveSectionProps`, `sectionJsonSchema` | Content sections |
691
748
  | `themeToCss`, `themeToCssVariables`, `ThemeStyle`, `themeFontsHref`, `tokenName` | SSR theme |
692
749
  | `ProductImage`, `ProductMockup`, `MockupProvider`, `createMockupRegistry`, `registerMockup`, `BUILTIN_MOCKUPS`, `MockupFrame`, `MockupText`, `TireMockup`, `PartMockup`, `SupplementMockup`, `GenericMockup` | Mockups de respaldo (subpath `/mockups`) |
693
- | `PaymentMethodSection`, `buildPayChoices`, `createEmbeddedPayHandlers` (`/payments/server`), `PaymentBrick`, `useEmbeddedPayment`, `VoucherCard`, `PaymentChallenge`, `createFetchTransport`, `mercadoPagoBricksAdapter`, `loadPaymentSdk`, `paymentCsp`, `readPaymentTheme` | Pago integrado (subpath `/payments`) |
750
+ | `PaymentMethodSection`, `buildPayChoices`, `createEmbeddedPayHandlers` + `jsonPayRoute` (`/payments/server`), `createResilientTransport`, `usePaymentRecovery`, `PaymentRecovery`, `mountQueued`, `checkoutShouldRevalidate`, `PaymentBrick`, `useEmbeddedPayment`, `VoucherCard`, `PaymentChallenge`, `createFetchTransport`, `mercadoPagoBricksAdapter`, `loadPaymentSdk`, `paymentCsp`, `readPaymentTheme` | Pago integrado (subpath `/payments`) |
694
751
  | `PromoPopup`, `CartProgressBars`, `PromoProgressBar`, `CrossSellModal`, `OrderNote`, `isExcludedPath`, `frequencyAllows`, `isCrawler` | Promociones y venta cruzada (subpath `/promotions`) |
695
752
 
696
753
  ## `@asteby/storefront-react/ui`
package/dist/checkout.cjs CHANGED
@@ -297,6 +297,10 @@ function lockField(el) {
297
297
  el.readOnly = true;
298
298
  }
299
299
  }
300
+ var ADDRESS_FILL_EVENT_FLAG = "sfSynthetic";
301
+ function isAddressFillEvent(event) {
302
+ return event[ADDRESS_FILL_EVENT_FLAG] === true;
303
+ }
300
304
  function AddressFields(props) {
301
305
  const { defaults, names = {}, errors = {}, countryCode, provinces, postalCode, lookupPostalCode, lockProvinceOnFill, locale, messages, fieldMessages: fm, classNames: cx = {}, children } = props;
302
306
  const showNeighborhood = props.neighborhood ?? true;
@@ -311,6 +315,29 @@ function AddressFields(props) {
311
315
  const zipRef = react.useRef(null);
312
316
  const lookupRef = react.useRef(lookupPostalCode);
313
317
  lookupRef.current = lookupPostalCode;
318
+ const writer = react.useRef(Promise.resolve());
319
+ const enqueue = (task) => {
320
+ writer.current = writer.current.then(task).catch(() => {
321
+ });
322
+ };
323
+ react.useEffect(() => {
324
+ const form = zipRef.current?.closest("form");
325
+ if (!form) return;
326
+ const mark = (e) => {
327
+ if (!e.isTrusted) return;
328
+ const el = e.target;
329
+ if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement) {
330
+ el.dataset.sfUserEdited = "1";
331
+ delete el.dataset.sfAutofilled;
332
+ }
333
+ };
334
+ form.addEventListener("input", mark, true);
335
+ form.addEventListener("change", mark, true);
336
+ return () => {
337
+ form.removeEventListener("input", mark, true);
338
+ form.removeEventListener("change", mark, true);
339
+ };
340
+ }, []);
314
341
  const runLookup = (zip) => {
315
342
  const lookup = lookupRef.current;
316
343
  if (!lookup || !zip || zip === lastZip.current) return;
@@ -319,23 +346,31 @@ function AddressFields(props) {
319
346
  const form = zipRef.current?.closest("form");
320
347
  void lookup(zip).then(
321
348
  (info) => {
322
- if (!info || lastZip.current !== zip) return;
323
- const fill2 = (name, value, lock) => {
324
- const el = form?.elements.namedItem(name);
325
- if (!el || !value || "length" in el && !(el instanceof HTMLSelectElement)) return false;
326
- if (el.dataset.sfLocked === "1") return false;
327
- if (el.value && el.dataset.sfAutofilled !== "1") return false;
328
- if (el instanceof HTMLSelectElement && ![...el.options].some((o) => o.value === value)) return false;
329
- el.value = value;
330
- el.dataset.sfAutofilled = "1";
331
- el.dispatchEvent(new Event("input", { bubbles: true }));
332
- if (lock) lockField(el);
333
- return true;
334
- };
335
- const a = fill2(n("province"), info.province, lockProvinceOnFill);
336
- const b = fill2(n("city"), info.city);
337
- setColonias(info.neighborhoods ?? []);
338
- setFilled(a || b);
349
+ if (!info) return;
350
+ enqueue(() => {
351
+ if (lastZip.current !== zip) return;
352
+ const fill2 = (name, value, lock) => {
353
+ const el = form?.elements.namedItem(name);
354
+ if (!el || !value || "length" in el && !(el instanceof HTMLSelectElement)) return false;
355
+ if (el.dataset.sfLocked === "1") return false;
356
+ if (el.dataset.sfUserEdited === "1" || el.ownerDocument.activeElement === el && el.value) return false;
357
+ if (el.value && el.dataset.sfAutofilled !== "1") return false;
358
+ if (el instanceof HTMLSelectElement && ![...el.options].some((o) => o.value === value)) return false;
359
+ el.value = value;
360
+ el.dataset.sfAutofilled = "1";
361
+ const event = new Event("input", { bubbles: true });
362
+ Object.defineProperty(event, ADDRESS_FILL_EVENT_FLAG, { value: true });
363
+ el.dispatchEvent(event);
364
+ if (lock) lockField(el);
365
+ return true;
366
+ };
367
+ const a = fill2(n("province"), info.province, lockProvinceOnFill);
368
+ const b = fill2(n("city"), info.city);
369
+ const only = info.neighborhoods?.length === 1 ? info.neighborhoods[0] : null;
370
+ const c = showNeighborhood ? fill2(n("neighborhood"), only) : false;
371
+ setColonias(info.neighborhoods ?? []);
372
+ setFilled(a || b || c);
373
+ });
339
374
  },
340
375
  () => {
341
376
  }
@@ -1163,6 +1198,7 @@ function DeliveryOptions(props) {
1163
1198
  ] });
1164
1199
  }
1165
1200
 
1201
+ exports.ADDRESS_FILL_EVENT_FLAG = ADDRESS_FILL_EVENT_FLAG;
1166
1202
  exports.AddressFields = AddressFields;
1167
1203
  exports.BrandMark = BrandMark;
1168
1204
  exports.CheckoutField = CheckoutField;
@@ -1180,6 +1216,7 @@ exports.deliveryMessages = deliveryMessages;
1180
1216
  exports.fieldMessages = fieldMessages;
1181
1217
  exports.findBrand = findBrand;
1182
1218
  exports.flatRates = flatRates;
1219
+ exports.isAddressFillEvent = isAddressFillEvent;
1183
1220
  exports.isQuoteChange = isQuoteChange;
1184
1221
  exports.listBrands = listBrands;
1185
1222
  exports.matchRate = matchRate;