@asteby/storefront-react 0.4.0 → 0.6.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
@@ -149,6 +149,33 @@ defaults, coerces numeric strings, drops unknown props and skips a section
149
149
  missing a required prop. `registry.toJSONSchema()` / `sectionJsonSchema(def)`
150
150
  emit the JSON Schema the OPS editor reads (`section.schema.json`).
151
151
 
152
+ ### Sincronizar tipos de sección con OPS
153
+
154
+ Convierte los `defineSection` al formato del addon `storefront_content` y los
155
+ publica sin escribir conversores a mano:
156
+
157
+ ```ts
158
+ import { createStorefrontClient } from '@asteby/storefront'
159
+ import { syncSectionTypes } from '@asteby/storefront-react'
160
+
161
+ const sf = createStorefrontClient({ baseUrl: process.env.OPS_GW!, token: process.env.OPS_SK! }) // sk_ con content:write
162
+ const { created, updated, warnings } = await syncSectionTypes(sf, registry)
163
+ // o sin cliente: syncSectionTypes({ baseUrl, token, fetch? }, registry)
164
+ // o solo exportar: registry.toOpsSectionTypes() -> { types, warnings }
165
+ ```
166
+
167
+ Conversión: `items`→`fields` (recursivo), `url`/`color`→`text` (la URL viaja como
168
+ texto; `link` de OPS es `{label,url}`), `products`→`product_list`. `max` fija el
169
+ tope de texto en OPS. `defineSection` acepta `category` y `maxPerPage`.
170
+
171
+ Los tipos integrados de OPS (`hero, product_grid, collection_list, rich_text,
172
+ faq, image_with_text`) se excluyen (`excludeBuiltin`, por omisión `true`) y
173
+ generan `warnings`; pasa `builtinProps: { hero: ['title', ...] }` para avisar
174
+ solo de las props locales que no existen en OPS. Antes de llamar a OPS se valida
175
+ localmente (lista sin `fields`, `select` sin `options`, kind desconocido, clave no
176
+ snake_case, etiqueta vacía): lanza `SectionTypesValidationError` con `issues[]`
177
+ (`{path,message}`); también disponible como `validateOpsSectionTypes(types)`.
178
+
152
179
  ### Theme
153
180
 
154
181
  `themeToCss(theme, { selector?, naming?, fontFallback? })` turns
@@ -158,6 +185,23 @@ metacore-ui components pick it up), fonts as `--font-<role>` and `--radius`.
158
185
  Unsafe values (`;`, `{}`, `<`, `url(`) are dropped. `<ThemeStyle theme />`
159
186
  renders it as a `<style>`; `themeFontsHref(theme)` gives a Google Fonts URL.
160
187
 
188
+ ## Disponibilidad (headless)
189
+
190
+ Todo sale de `availability` del servidor (o del polyfill para servidores sin contrato 1.1); sin umbrales ni topes fijos en la tienda.
191
+
192
+ ```tsx
193
+ <AvailabilityBadge availability={variant.availability} source={variant} placement="card" /> {/* listado: nada si hay stock */}
194
+ <AvailabilityBadge source={variant} /> {/* ficha: Quedan N, ETA, máximo por pedido */}
195
+ <AddToCartButton variantId={variant.id} availabilitySource={variant} /> {/* «Agotado» deshabilitado, «Pedir bajo pedido»... */}
196
+ <VariantOptionState availability={value.availability}>{value.value}</VariantOptionState> {/* opción tachada + «agotado» sr-only */}
197
+ <RestockNotifyForm sku={variant.sku} availability={a} action="/avisame" /> {/* «Avísame»; <form method=post> sin JS */}
198
+ <StockConflictAlert /> {/* 409 recuperable, lee useCart().error */}
199
+ {cart.lines.map((l) => <CartLineProblem key={l.id} line={l} />)} {/* problema de línea al cargar el carrito */}
200
+ ```
201
+
202
+ Textos es/en por clave: `messages={{ 'status.low_stock_n': '¡Últimas {n}!' }}`. El JSON-LD (`productJsonLd`) publica
203
+ `schema.org availability` desde el estado del servidor y nunca la cantidad.
204
+
161
205
  ## Sucursales (headless)
162
206
 
163
207
  Primitivos sin estilos ni clases (render props), accesibles y SSR-safe. Con cero
@@ -396,6 +440,69 @@ larga; `mockupWordMax(ancho, letraMínima, ratio)` da el `wordMax` que todavía
396
440
  cabe. El suplemento base ya lo usa (`CREATINA MONOHIDRATADA` ya no queda en
397
441
  `MONOHIDRATA…`).
398
442
 
443
+ ## Pago integrado (`@asteby/storefront-react/payments`)
444
+
445
+ Formulario de tarjeta dentro de la página (Mercado Pago Payment/Card Brick u otro proveedor), sin que el comprador salga. Sin estilos propios: `data-*` y variables CSS `--sf-pay-*`. El cliente **nunca calcula montos**: el total lo manda el servidor, se pasa al Brick y se devuelve como `expected_total`.
446
+
447
+ Dos puertos reemplazables (`PaymentFormAdapter` = SDK del proveedor en el navegador; `PaymentTransport` = cómo llega el navegador al servidor de la tienda). El navegador nunca llama a OPS: la ruta de la tienda reenvía con `client.checkouts.pay(...)` y su token `sk_`.
448
+
449
+ ```tsx
450
+ // app/routes/checkout.tsx (navegador)
451
+ import { PaymentBrick, createFetchTransport } from '@asteby/storefront-react/payments'
452
+
453
+ const transport = createFetchTransport('/pagar', { refreshUrl: '/pagar/estado' }) // misma-origen
454
+
455
+ <PaymentBrick
456
+ client={card.client} // paymentMethods[].client (public_key, sdk_url, options, csp)
457
+ amount={cart.cost.total} // total de OPS; si cambia, el Brick se vuelve a montar
458
+ transport={transport}
459
+ nonce={nonce} // nonce de la CSP
460
+ locale="es-MX"
461
+ scope={cart.id} // la intención de pago se guarda por carrito
462
+ submitLabel="Realizar el pedido"
463
+ onResult={(o) => o.kind === 'approved' && navigate('/pagar/gracias')}
464
+ onFallback={() => setOption('card_redirect')} // SDK bloqueado/timeout: Checkout Pro
465
+ />
466
+ ```
467
+
468
+ ```ts
469
+ // servidor de la tienda (action de /pagar): traduce al contrato de OPS
470
+ import { paymentIdempotencyKey } from '@asteby/storefront'
471
+ const key = paymentIdempotencyKey(checkoutId, body.attempt) // misma llave = mismo cobro
472
+ const result = await sf.checkouts.pay(checkoutId, { method: 'card', provider_data: body.provider_data,
473
+ device_session_id: body.device_session_id, expected_total: body.expected_total }, { idempotencyKey: key })
474
+ return Response.json(result) // PaymentAttemptResult; 409/422/429 se devuelven como { error: { code } } con su status
475
+ ```
476
+
477
+ - **Estados** (`useEmbeddedPayment` / `PaymentBrick` por `ref`): `loading → ready → processing → approved | in_review | voucher (efectivo con referencia y vencimiento) | challenge (3DS en `<dialog>`) | redirect | rejected | network_error | blocked | fallback`.
478
+ - **Idempotencia**: un `attempt` por intento (sessionStorage, sin datos de tarjeta); el reintento por red reutiliza el mismo `attempt`, la misma llave y el mismo token (en memoria). Tras un rechazo hay intento nuevo y el formulario se vuelve a montar (el token es de un solo uso). El doble clic devuelve la petición en vuelo.
479
+ - **Rechazos**: texto accionable por `decline_code` neutro (`paymentMessages`, es/en, sobrescribible por clave); nunca el código crudo del proveedor.
480
+ - **Seguridad**: el adaptador de Mercado Pago solo deja salir `token`, `payment_method_id`, `issuer_id`, `installments` e identificación (descarta `transaction_amount`); el script solo carga de `https://sdk.mercadopago.com`; `paymentCsp([client], { nonce })` arma la CSP de `/pagar` con lo que declara el proveedor. Sin SRI a propósito (URL sin versión).
481
+ - **Tema**: `--sf-pay-text`, `-text-muted`, `-background`, `-input-background`, `-primary`, `-primary-contrast`, `-error`, `-success`, `-outline`, `-radius` se leen al montar y se pasan al Brick; `--sf-pay-min-height` reserva el alto (sin saltos).
482
+ - **Otro proveedor**: implementa `PaymentFormAdapter` (`load`, `mount → { collect, unmount }`) y pásalo en `adapter`/`adapters={{ stripe_elements: … }}`.
483
+
484
+ ## Promociones y venta cruzada (`@asteby/storefront-react/promotions`)
485
+
486
+ Mecanismos sin contenido: los textos y montos llegan como datos de OPS (`Cart.promotions`, `GET /storefront/promotions`, `carts.recommendations`).
487
+
488
+ ```tsx
489
+ import { CartProgressBars, CrossSellModal, OrderNote, PromoPopup } from '@asteby/storefront-react/promotions'
490
+
491
+ <PromoPopup announcement={announcement} pathname={location.pathname}
492
+ onEvent={track} renderCta={({ href, label, onClick }) => <Link to={href} onClick={onClick}>{label}</Link>} />
493
+ <CartProgressBars promotions={cart.promotions} />
494
+ <CrossSellModal open={open} promotions={cart.promotions} recommendations={recs.nodes} totalSaved={cart.cost.discount}
495
+ onAdd={async ({ variantId }) => addToCart(variantId)} // useFetcher; devuelve { error: 'sold_out' } en 409
496
+ onContinue={goToPayment} onSkip={(why) => { markSeen(); goToPayment() }} />
497
+ <OrderNote value={cart.note} onSave={(n) => saveNote(n)} form={{ action: '/carrito', intent: 'note' }} />
498
+ ```
499
+
500
+ - `PromoPopup`: solo cliente tras hidratar (no existe en el SSR), frecuencia `session` / cada N días (7) / una vez por versión, exclusiones de ruta (carrito, pago, gracias, cuenta, login, legales, editor; con o sin prefijo de mercado), sin rastreadores ni `navigator.webdriver`, dentro de la vigencia, disparadores 5 s / 2.ª página / 40 % de scroll (el primero), `<dialog>` con foco, Esc, fondo y X de 44 px.
501
+ - `CartProgressBars`: `role="progressbar"` con `aria-valuetext`; usa `remaining`, `percent` y `reached` del servidor; umbral 0 = «Envío gratis en todo tu pedido» sin barra.
502
+ - `CrossSellModal`: una vez por carrito lo decide la tienda; Esc/X/fondo/«Continuar sin agregar» = `onSkip`; errores de «agregar» en la tarjeta.
503
+ - `OrderNote`: `<textarea>` con etiqueta, 1000 caracteres, guardado con *debounce* (800 ms) y al salir del campo; sin JS es un `<form method="post">` con botón «Guardar nota».
504
+ - Funciones puras testeables: `isExcludedPath`, `frequencyAllows`, `isCrawler`, `withinWindow`, `resolveTrigger`, `safeInternalPath`.
505
+
399
506
  ## API
400
507
 
401
508
  | Export | |
@@ -410,3 +517,5 @@ cabe. El suplemento base ya lo usa (`CREATINA MONOHIDRATADA` ya no queda en
410
517
  | `defineSection`, `SectionRegistry`, `createSectionRegistry`, `Sections`, `resolveSectionProps`, `sectionJsonSchema` | Content sections |
411
518
  | `themeToCss`, `themeToCssVariables`, `ThemeStyle`, `themeFontsHref`, `tokenName` | SSR theme |
412
519
  | `ProductImage`, `ProductMockup`, `MockupProvider`, `createMockupRegistry`, `registerMockup`, `BUILTIN_MOCKUPS`, `MockupFrame`, `MockupText`, `TireMockup`, `PartMockup`, `SupplementMockup`, `GenericMockup` | Mockups de respaldo (subpath `/mockups`) |
520
+ | `PaymentBrick`, `useEmbeddedPayment`, `VoucherCard`, `PaymentChallenge`, `createFetchTransport`, `mercadoPagoBricksAdapter`, `loadPaymentSdk`, `paymentCsp`, `readPaymentTheme` | Pago integrado (subpath `/payments`) |
521
+ | `PromoPopup`, `CartProgressBars`, `PromoProgressBar`, `CrossSellModal`, `OrderNote`, `isExcludedPath`, `frequencyAllows`, `isCrawler` | Promociones y venta cruzada (subpath `/promotions`) |
@@ -0,0 +1,75 @@
1
+ import { useOptionalStorefront } from './chunk-DQBL2DFJ.js';
2
+ import { useRef, useEffect, useCallback } from 'react';
3
+
4
+ function useModalDialog({ open, onClose, initialFocus }) {
5
+ const ref = useRef(null);
6
+ const opener = useRef(null);
7
+ const onCloseRef = useRef(onClose);
8
+ onCloseRef.current = onClose;
9
+ useEffect(() => {
10
+ const el = ref.current;
11
+ if (!el || !open) return;
12
+ opener.current = typeof document !== "undefined" ? document.activeElement : null;
13
+ const hasModal = typeof el.showModal === "function";
14
+ if (!el.open) {
15
+ if (hasModal) {
16
+ try {
17
+ el.showModal();
18
+ } catch {
19
+ el.setAttribute("open", "");
20
+ }
21
+ } else el.setAttribute("open", "");
22
+ }
23
+ const target = initialFocus?.current ?? el.querySelector("[data-sf-autofocus]") ?? el;
24
+ if (!target.hasAttribute("tabindex") && target === el) el.setAttribute("tabindex", "-1");
25
+ target.focus({ preventScroll: true });
26
+ const onCancel = (e) => {
27
+ e.preventDefault();
28
+ onCloseRef.current("esc");
29
+ };
30
+ const onKey = (e) => {
31
+ if (e.key === "Escape") {
32
+ e.preventDefault();
33
+ onCloseRef.current("esc");
34
+ }
35
+ };
36
+ el.addEventListener("cancel", onCancel);
37
+ if (!hasModal) el.addEventListener("keydown", onKey);
38
+ return () => {
39
+ el.removeEventListener("cancel", onCancel);
40
+ el.removeEventListener("keydown", onKey);
41
+ if (el.open) {
42
+ if (typeof el.close === "function") {
43
+ try {
44
+ el.close();
45
+ } catch {
46
+ el.removeAttribute("open");
47
+ }
48
+ } else el.removeAttribute("open");
49
+ }
50
+ const back = opener.current;
51
+ if (back instanceof HTMLElement && back.isConnected) back.focus({ preventScroll: true });
52
+ };
53
+ }, [open]);
54
+ const onBackdropClick = useCallback((e) => {
55
+ if (e.target === e.currentTarget) onCloseRef.current("backdrop");
56
+ }, []);
57
+ return { ref, onBackdropClick };
58
+ }
59
+ function prefersReducedMotion() {
60
+ try {
61
+ return typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ // src/locale.ts
68
+ function useResolvedLocale(locale) {
69
+ const sf = useOptionalStorefront();
70
+ return locale ?? sf?.locale ?? "es-MX";
71
+ }
72
+
73
+ export { prefersReducedMotion, useModalDialog, useResolvedLocale };
74
+ //# sourceMappingURL=chunk-2WZIWIBW.js.map
75
+ //# sourceMappingURL=chunk-2WZIWIBW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/dialog.tsx","../src/locale.ts"],"names":[],"mappings":"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","file":"chunk-2WZIWIBW.js","sourcesContent":["import { useCallback, useEffect, useRef, type RefObject } from 'react'\n\nexport type DialogCloseReason = 'esc' | 'x' | 'backdrop' | 'action'\n\ntype UseModalDialogOptions = {\n open: boolean\n /** Called for Esc and backdrop clicks. The owner decides whether to close. */\n onClose: (reason: DialogCloseReason) => void\n /** Element focused when the dialog opens. Default: the element marked `data-sf-autofocus`, else the dialog itself. */\n initialFocus?: RefObject<HTMLElement | null>\n}\n\n/**\n * Native `<dialog>` wiring: `showModal()` (focus trap and inert background come from the browser),\n * Esc and backdrop clicks routed to `onClose`, and focus restored to the opener on close.\n * Falls back to the `open` attribute where `showModal` does not exist (old browsers, some test DOMs)\n * with an Esc key handler.\n */\nexport function useModalDialog({ open, onClose, initialFocus }: UseModalDialogOptions) {\n const ref = useRef<HTMLDialogElement | null>(null)\n const opener = useRef<Element | null>(null)\n const onCloseRef = useRef(onClose)\n onCloseRef.current = onClose\n\n useEffect(() => {\n const el = ref.current\n if (!el || !open) return\n opener.current = typeof document !== 'undefined' ? document.activeElement : null\n const hasModal = typeof el.showModal === 'function'\n if (!el.open) {\n if (hasModal) {\n try {\n el.showModal()\n } catch {\n el.setAttribute('open', '')\n }\n } else el.setAttribute('open', '')\n }\n const target = initialFocus?.current ?? el.querySelector<HTMLElement>('[data-sf-autofocus]') ?? el\n if (!target.hasAttribute('tabindex') && target === el) el.setAttribute('tabindex', '-1')\n target.focus({ preventScroll: true })\n\n const onCancel = (e: Event) => {\n e.preventDefault() // we close through state so the owner can navigate/track first\n onCloseRef.current('esc')\n }\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n e.preventDefault()\n onCloseRef.current('esc')\n }\n }\n el.addEventListener('cancel', onCancel)\n if (!hasModal) el.addEventListener('keydown', onKey)\n return () => {\n el.removeEventListener('cancel', onCancel)\n el.removeEventListener('keydown', onKey)\n if (el.open) {\n if (typeof el.close === 'function') {\n try {\n el.close()\n } catch {\n el.removeAttribute('open')\n }\n } else el.removeAttribute('open')\n }\n const back = opener.current\n if (back instanceof HTMLElement && back.isConnected) back.focus({ preventScroll: true })\n }\n }, [open])\n\n /** Click on the backdrop (the dialog element itself, outside its content box). */\n const onBackdropClick = useCallback((e: { target: EventTarget; currentTarget: EventTarget }) => {\n if (e.target === e.currentTarget) onCloseRef.current('backdrop')\n }, [])\n\n return { ref, onBackdropClick }\n}\n\n/** `prefers-reduced-motion: reduce`, read after hydration (false on the server). */\nexport function prefersReducedMotion(): boolean {\n try {\n return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches\n } catch {\n return false\n }\n}\n","import { useOptionalStorefront } from './context.js'\n\n/** Locale of a component: its own prop, else the `StorefrontProvider`'s, else `es-MX`. */\nexport function useResolvedLocale(locale?: string | null): string {\n const sf = useOptionalStorefront()\n return locale ?? sf?.locale ?? 'es-MX'\n}\n"]}
@@ -0,0 +1,60 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+
3
+ // src/theme.tsx
4
+ var DEFAULT_FONT_FALLBACK = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
5
+ var MONO_FALLBACK = "ui-monospace, SFMono-Regular, Menlo, monospace";
6
+ function tokenName(key) {
7
+ return key.trim().replace(/^-+/, "").replace(/^color-/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[\s_.]+/g, "-").toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
8
+ }
9
+ function isSafeCssValue(value) {
10
+ if (!value || value.length > 200) return false;
11
+ if (/[;{}<>\\]/.test(value)) return false;
12
+ if (/url\s*\(|expression\s*\(|@import/i.test(value)) return false;
13
+ return true;
14
+ }
15
+ function fontStack(family, role, fallback) {
16
+ const name = family.replace(/["']/g, "").trim();
17
+ if (!name || !isSafeCssValue(name)) return null;
18
+ return `"${name}", ${role === "mono" ? MONO_FALLBACK : fallback}`;
19
+ }
20
+ function themeToCssVariables(theme, options = {}) {
21
+ const naming = options.naming ?? "both";
22
+ const vars = {};
23
+ for (const [key, raw] of Object.entries(theme?.tokens ?? {})) {
24
+ const name = tokenName(key);
25
+ const value = String(raw).trim();
26
+ if (!name || !isSafeCssValue(value)) continue;
27
+ if (naming !== "metacore") vars[`--color-${name}`] = value;
28
+ if (naming !== "color") vars[`--${name}`] = value;
29
+ }
30
+ const fallback = options.fontFallback ?? DEFAULT_FONT_FALLBACK;
31
+ for (const [role, family] of Object.entries(theme?.fonts ?? {})) {
32
+ const name = tokenName(role);
33
+ const stack = typeof family === "string" ? fontStack(family, name, fallback) : null;
34
+ if (name && stack) vars[`--font-${name}`] = stack;
35
+ }
36
+ if (theme?.radius && isSafeCssValue(theme.radius.trim())) vars["--radius"] = theme.radius.trim();
37
+ return vars;
38
+ }
39
+ function themeToCss(theme, options = {}) {
40
+ const vars = themeToCssVariables(theme, options);
41
+ const body = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(";");
42
+ if (!body) return "";
43
+ const selector = (options.selector ?? ":root").replace(/[{}<>]/g, "");
44
+ return `${selector}{${body}}`;
45
+ }
46
+ function themeFontsHref(theme, weights = [400, 500, 600, 700]) {
47
+ const families = [...new Set(Object.values(theme?.fonts ?? {}).map((f) => String(f).replace(/["']/g, "").trim()).filter((f) => f && isSafeCssValue(f)))];
48
+ if (!families.length) return null;
49
+ const params = families.map((f) => `family=${encodeURIComponent(f).replace(/%20/g, "+")}:wght@${weights.join(";")}`).join("&");
50
+ return `https://fonts.googleapis.com/css2?${params}&display=swap`;
51
+ }
52
+ function ThemeStyle({ theme, nonce, id = "storefront-theme", ...options }) {
53
+ const css = themeToCss(theme, options);
54
+ if (!css) return null;
55
+ return /* @__PURE__ */ jsx("style", { id, nonce, "data-storefront-theme": "", dangerouslySetInnerHTML: { __html: css } });
56
+ }
57
+
58
+ export { ThemeStyle, isSafeCssValue, themeFontsHref, themeToCss, themeToCssVariables, tokenName };
59
+ //# sourceMappingURL=chunk-672OTUM2.js.map
60
+ //# sourceMappingURL=chunk-672OTUM2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/theme.tsx"],"names":[],"mappings":"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","file":"chunk-672OTUM2.js","sourcesContent":["/**\n * Server-side theme (S3): turns the content theme from\n * `content.theme()` into CSS custom properties so the first SSR byte already\n * carries the store's brand — no localStorage, no anti-flicker script.\n *\n * Token naming follows `@asteby/metacore-theme`: camelCase keys become\n * kebab-case (`primaryForeground` → `--primary-foreground`), and each color is\n * also exposed as a Tailwind 4 theme variable (`--color-primary-foreground`).\n * metacore-theme ships its tokens as static CSS with no reusable naming\n * helper, so this module is standalone and does not depend on it.\n */\nimport type { Theme } from '@asteby/storefront'\n\nexport type ThemeCssOptions = {\n /** Selector that receives the variables (default `:root`). */\n selector?: string\n /**\n * Variable names for color tokens:\n * `both` (default) → `--color-<name>` and `--<name>`;\n * `color` → only `--color-<name>` (Tailwind 4); `metacore` → only `--<name>`.\n */\n naming?: 'both' | 'color' | 'metacore'\n /** Fallback stack appended to every font (default system sans-serif). */\n fontFallback?: string\n}\n\nconst DEFAULT_FONT_FALLBACK = 'ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif'\nconst MONO_FALLBACK = 'ui-monospace, SFMono-Regular, Menlo, monospace'\n\n/** `primaryForeground`, `primary_foreground`, `--color-primary-foreground` → `primary-foreground`. */\nexport function tokenName(key: string): string {\n return key\n .trim()\n .replace(/^-+/, '')\n .replace(/^color-/, '')\n .replace(/([a-z0-9])([A-Z])/g, '$1-$2')\n .replace(/[\\s_.]+/g, '-')\n .toLowerCase()\n .replace(/[^a-z0-9-]/g, '')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '')\n}\n\n/** Rejects values that could escape the declaration or the `<style>` element. */\nexport function isSafeCssValue(value: string): boolean {\n if (!value || value.length > 200) return false\n if (/[;{}<>\\\\]/.test(value)) return false\n if (/url\\s*\\(|expression\\s*\\(|@import/i.test(value)) return false\n return true\n}\n\nfunction fontStack(family: string, role: string, fallback: string): string | null {\n const name = family.replace(/[\"']/g, '').trim()\n if (!name || !isSafeCssValue(name)) return null\n return `\"${name}\", ${role === 'mono' ? MONO_FALLBACK : fallback}`\n}\n\n/** CSS declarations (`--x:value`) for a theme, without selector. */\nexport function themeToCssVariables(theme: Partial<Theme> | null | undefined, options: ThemeCssOptions = {}): Record<string, string> {\n const naming = options.naming ?? 'both'\n const vars: Record<string, string> = {}\n for (const [key, raw] of Object.entries(theme?.tokens ?? {})) {\n const name = tokenName(key)\n const value = String(raw).trim()\n if (!name || !isSafeCssValue(value)) continue\n if (naming !== 'metacore') vars[`--color-${name}`] = value\n if (naming !== 'color') vars[`--${name}`] = value\n }\n const fallback = options.fontFallback ?? DEFAULT_FONT_FALLBACK\n for (const [role, family] of Object.entries(theme?.fonts ?? {})) {\n const name = tokenName(role)\n const stack = typeof family === 'string' ? fontStack(family, name, fallback) : null\n if (name && stack) vars[`--font-${name}`] = stack\n }\n if (theme?.radius && isSafeCssValue(theme.radius.trim())) vars['--radius'] = theme.radius.trim()\n return vars\n}\n\n/** `:root{--color-primary:#0f766e;--primary:#0f766e;...}` from a content theme. */\nexport function themeToCss(theme: Partial<Theme> | null | undefined, options: ThemeCssOptions = {}): string {\n const vars = themeToCssVariables(theme, options)\n const body = Object.entries(vars)\n .map(([k, v]) => `${k}:${v}`)\n .join(';')\n if (!body) return ''\n const selector = (options.selector ?? ':root').replace(/[{}<>]/g, '')\n return `${selector}{${body}}`\n}\n\n/** Google Fonts stylesheet URL for the theme fonts (or `null` when there are none). */\nexport function themeFontsHref(theme: Partial<Theme> | null | undefined, weights: number[] = [400, 500, 600, 700]): string | null {\n const families = [...new Set(Object.values(theme?.fonts ?? {}).map((f) => String(f).replace(/[\"']/g, '').trim()).filter((f) => f && isSafeCssValue(f)))]\n if (!families.length) return null\n const params = families.map((f) => `family=${encodeURIComponent(f).replace(/%20/g, '+')}:wght@${weights.join(';')}`).join('&')\n return `https://fonts.googleapis.com/css2?${params}&display=swap`\n}\n\nexport type ThemeStyleProps = ThemeCssOptions & {\n theme: Partial<Theme> | null | undefined\n nonce?: string\n id?: string\n}\n\n/** Emits the theme variables as a `<style>` (render it in `<head>` from the root route). */\nexport function ThemeStyle({ theme, nonce, id = 'storefront-theme', ...options }: ThemeStyleProps) {\n const css = themeToCss(theme, options)\n if (!css) return null\n return <style id={id} nonce={nonce} data-storefront-theme=\"\" dangerouslySetInnerHTML={{ __html: css }} />\n}\n"]}
@@ -1,39 +1,8 @@
1
- import { createContext, useMemo, useContext, createElement } from 'react';
2
- import { createStorefrontClient, formatMoney, parseMoney } from '@asteby/storefront';
1
+ import { useOptionalStorefront } from './chunk-DQBL2DFJ.js';
2
+ import { createElement } from 'react';
3
+ import { formatMoney, parseMoney } from '@asteby/storefront';
3
4
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
5
 
5
- // src/context.tsx
6
- var StorefrontContext = createContext(null);
7
- function StorefrontProvider({ client, config, shop = null, locale, currency, children }) {
8
- const configKey = config ? `${config.baseUrl}|${config.token}|${config.locale ?? ""}|${config.customerToken ?? ""}` : "";
9
- const resolvedClient = useMemo(
10
- () => client ?? (config ? createStorefrontClient(config) : null),
11
- [client, configKey]
12
- );
13
- const value = useMemo(
14
- () => ({
15
- client: resolvedClient,
16
- shop,
17
- locale: locale ?? config?.locale ?? shop?.locale ?? "es-MX",
18
- currency: currency ?? shop?.currency_code ?? "MXN"
19
- }),
20
- [resolvedClient, shop, locale, currency, config?.locale]
21
- );
22
- return /* @__PURE__ */ jsx(StorefrontContext.Provider, { value, children });
23
- }
24
- function useStorefront() {
25
- const ctx = useContext(StorefrontContext);
26
- if (!ctx) throw new Error("useStorefront must be used inside <StorefrontProvider>");
27
- return ctx;
28
- }
29
- function useOptionalStorefront() {
30
- return useContext(StorefrontContext);
31
- }
32
- function useStorefrontClient() {
33
- const { client } = useStorefront();
34
- if (!client) throw new Error("<StorefrontProvider> needs a `client` or `config` for browser-side calls");
35
- return client;
36
- }
37
6
  function Money({
38
7
  data,
39
8
  as,
@@ -157,6 +126,6 @@ function ProductPrice({
157
126
  ] });
158
127
  }
159
128
 
160
- export { DEFAULT_IMAGE_WIDTHS, Image, Money, ProductPrice, StorefrontProvider, defaultImageLoader, useOptionalStorefront, useStorefront, useStorefrontClient };
161
- //# sourceMappingURL=chunk-C6QZNKRE.js.map
162
- //# sourceMappingURL=chunk-C6QZNKRE.js.map
129
+ export { DEFAULT_IMAGE_WIDTHS, Image, Money, ProductPrice, defaultImageLoader };
130
+ //# sourceMappingURL=chunk-6CVKW7F3.js.map
131
+ //# sourceMappingURL=chunk-6CVKW7F3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components.tsx"],"names":[],"mappings":"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","file":"chunk-6CVKW7F3.js","sourcesContent":["import { createElement, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from 'react'\nimport { formatMoney, parseMoney, type FormatMoneyOptions, type Image as ImageData, type Money as MoneyData, type Product, type ProductVariant } from '@asteby/storefront'\nimport { useOptionalStorefront } from './context.js'\n\n// ---------------------------------------------------------------------------\n// Money\n// ---------------------------------------------------------------------------\n\nexport type MoneyProps<As extends ElementType = 'span'> = FormatMoneyOptions & {\n data: MoneyData\n /** Element to render (default `span`). */\n as?: As\n /** Overrides the provider locale. */\n locale?: string\n} & Omit<ComponentPropsWithoutRef<As>, 'children' | 'as'>\n\n/** Formats `Money` with the provider locale (or `locale`). */\nexport function Money<As extends ElementType = 'span'>({\n data,\n as,\n locale,\n withoutTrailingZeros,\n withoutCurrency,\n currencyDisplay,\n ...rest\n}: MoneyProps<As>) {\n const sf = useOptionalStorefront()\n const text = formatMoney(data, locale ?? sf?.locale, {\n ...(withoutTrailingZeros !== undefined ? { withoutTrailingZeros } : {}),\n ...(withoutCurrency !== undefined ? { withoutCurrency } : {}),\n ...(currencyDisplay !== undefined ? { currencyDisplay } : {}),\n })\n return createElement(as ?? 'span', rest, text)\n}\n\n// ---------------------------------------------------------------------------\n// Image\n// ---------------------------------------------------------------------------\n\nexport const DEFAULT_IMAGE_WIDTHS = [320, 480, 640, 768, 1024, 1280, 1600] as const\n\n/** Builds the URL for one srcset width. */\nexport type ImageLoader = (src: string, width: number, height?: number) => string\n\n/**\n * Default loader: adds `?width=<w>` (the convention of most image CDNs and of\n * the OPS image proxy). URLs that cannot be parsed are returned unchanged.\n */\nexport const defaultImageLoader: ImageLoader = (src, width, height) => {\n try {\n const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(src)\n const url = new URL(src, 'http://relative.invalid')\n url.searchParams.set('width', String(width))\n if (height) url.searchParams.set('height', String(height))\n return isAbsolute ? url.toString() : url.pathname + url.search + url.hash\n } catch {\n return src\n }\n}\n\nexport type ImageProps = Omit<ComponentPropsWithoutRef<'img'>, 'src' | 'srcSet' | 'width' | 'height'> & {\n data: ImageData\n /** `sizes` attribute (default `100vw`). */\n sizes?: string\n /** Candidate widths for `srcset` (capped at the intrinsic width when known). */\n widths?: readonly number[]\n /** URL builder per width (default adds `?width=`). Pass `null` to disable srcset. */\n loader?: ImageLoader | null\n /** Above-the-fold image: eager loading and `fetchpriority=\"high\"`. */\n priority?: boolean\n /** Forces an aspect ratio, e.g. `1/1` or `4/3`, used for width/height and srcset heights. */\n aspectRatio?: string\n}\n\nfunction parseRatio(ratio: string | undefined): number | null {\n if (!ratio) return null\n const [w, h] = ratio.split(/[/:]/).map((n) => Number.parseFloat(n))\n return w && h ? w / h : null\n}\n\n/** Responsive `<img>` with `srcset`, lazy by default, intrinsic size to avoid layout shift. */\nexport function Image({\n data,\n sizes = '100vw',\n widths = DEFAULT_IMAGE_WIDTHS,\n loader = defaultImageLoader,\n priority = false,\n aspectRatio,\n alt,\n loading,\n decoding,\n ...rest\n}: ImageProps) {\n const ratio = parseRatio(aspectRatio) ?? (data.width && data.height ? data.width / data.height : null)\n let candidates = [...widths].sort((a, b) => a - b)\n if (data.width) {\n const capped = candidates.filter((w) => w <= data.width!)\n candidates = capped.length ? capped : [data.width]\n }\n const srcSet = loader\n ? candidates.map((w) => `${loader(data.url, w, ratio ? Math.round(w / ratio) : undefined)} ${w}w`).join(', ')\n : undefined\n const width = data.width ?? candidates[candidates.length - 1]\n const height = data.height ?? (ratio && width ? Math.round(width / ratio) : undefined)\n return (\n <img\n src={data.url}\n alt={alt ?? data.alt ?? ''}\n {...(srcSet ? { srcSet, sizes } : {})}\n {...(width ? { width } : {})}\n {...(height ? { height } : {})}\n loading={loading ?? (priority ? 'eager' : 'lazy')}\n decoding={decoding ?? (priority ? 'auto' : 'async')}\n {...(priority ? { fetchPriority: 'high' as const } : {})}\n {...rest}\n />\n )\n}\n\n// ---------------------------------------------------------------------------\n// ProductPrice\n// ---------------------------------------------------------------------------\n\nconst VISUALLY_HIDDEN = {\n position: 'absolute',\n width: 1,\n height: 1,\n padding: 0,\n margin: -1,\n overflow: 'hidden',\n clip: 'rect(0, 0, 0, 0)',\n whiteSpace: 'nowrap',\n border: 0,\n} as const\n\nexport type ProductPriceProps = Omit<ComponentPropsWithoutRef<'div'>, 'children'> & {\n product?: Pick<Product, 'price_range' | 'compare_at_price_range'> | null\n variant?: Pick<ProductVariant, 'price' | 'compare_at_price'> | null\n /** Prefix shown when the product has a price range and no variant is selected (default `Desde`). */\n fromLabel?: ReactNode\n /** Accessible label for the compare-at price (default `Precio anterior`). */\n compareAtLabel?: string\n locale?: string\n withoutTrailingZeros?: boolean\n}\n\n/**\n * Price with optional compare-at (strikethrough). With a `variant` it shows\n * that variant; otherwise the product's minimum price (prefixed with\n * `fromLabel` when prices vary).\n */\nexport function ProductPrice({\n product,\n variant,\n fromLabel = 'Desde',\n compareAtLabel = 'Precio anterior',\n locale,\n withoutTrailingZeros,\n ...rest\n}: ProductPriceProps) {\n let price: MoneyData | null = null\n let compareAt: MoneyData | null = null\n let isRange = false\n if (variant) {\n price = variant.price\n compareAt = variant.compare_at_price\n } else if (product) {\n price = product.price_range.min\n compareAt = product.compare_at_price_range?.min ?? null\n isRange = product.price_range.min.amount !== product.price_range.max.amount\n }\n if (!price) return null\n const onSale = compareAt !== null && parseMoney(compareAt) > parseMoney(price)\n const opts = { ...(locale ? { locale } : {}), ...(withoutTrailingZeros !== undefined ? { withoutTrailingZeros } : {}) }\n return (\n <div data-on-sale={onSale || undefined} {...rest}>\n {isRange ? <span data-price-prefix=\"\">{fromLabel} </span> : null}\n <Money data={price} data-price=\"\" {...opts} />\n {onSale && compareAt ? (\n <>\n {' '}\n <s data-compare-at-price=\"\">\n <span style={VISUALLY_HIDDEN}>{compareAtLabel}: </span>\n <Money data={compareAt} {...opts} />\n </s>\n </>\n ) : null}\n </div>\n )\n}\n"]}
@@ -0,0 +1,40 @@
1
+ import { createContext, useMemo, useContext } from 'react';
2
+ import { createStorefrontClient } from '@asteby/storefront';
3
+ import { jsx } from 'react/jsx-runtime';
4
+
5
+ // src/context.tsx
6
+ var StorefrontContext = createContext(null);
7
+ function StorefrontProvider({ client, config, shop = null, locale, currency, children }) {
8
+ const configKey = config ? `${config.baseUrl}|${config.token}|${config.locale ?? ""}|${config.customerToken ?? ""}` : "";
9
+ const resolvedClient = useMemo(
10
+ () => client ?? (config ? createStorefrontClient(config) : null),
11
+ [client, configKey]
12
+ );
13
+ const value = useMemo(
14
+ () => ({
15
+ client: resolvedClient,
16
+ shop,
17
+ locale: locale ?? config?.locale ?? shop?.locale ?? "es-MX",
18
+ currency: currency ?? shop?.currency_code ?? "MXN"
19
+ }),
20
+ [resolvedClient, shop, locale, currency, config?.locale]
21
+ );
22
+ return /* @__PURE__ */ jsx(StorefrontContext.Provider, { value, children });
23
+ }
24
+ function useStorefront() {
25
+ const ctx = useContext(StorefrontContext);
26
+ if (!ctx) throw new Error("useStorefront must be used inside <StorefrontProvider>");
27
+ return ctx;
28
+ }
29
+ function useOptionalStorefront() {
30
+ return useContext(StorefrontContext);
31
+ }
32
+ function useStorefrontClient() {
33
+ const { client } = useStorefront();
34
+ if (!client) throw new Error("<StorefrontProvider> needs a `client` or `config` for browser-side calls");
35
+ return client;
36
+ }
37
+
38
+ export { StorefrontProvider, useOptionalStorefront, useStorefront, useStorefrontClient };
39
+ //# sourceMappingURL=chunk-DQBL2DFJ.js.map
40
+ //# sourceMappingURL=chunk-DQBL2DFJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/context.tsx"],"names":[],"mappings":";;;;;AAaA,IAAM,iBAAA,GAAoB,cAA6C,IAAI,CAAA;AAkBpE,SAAS,kBAAA,CAAmB,EAAE,MAAA,EAAQ,MAAA,EAAQ,OAAO,IAAA,EAAM,MAAA,EAAQ,QAAA,EAAU,QAAA,EAAS,EAA4B;AACvH,EAAA,MAAM,YAAY,MAAA,GAAS,CAAA,EAAG,MAAA,CAAO,OAAO,IAAI,MAAA,CAAO,KAAK,CAAA,CAAA,EAAI,MAAA,CAAO,UAAU,EAAE,CAAA,CAAA,EAAI,MAAA,CAAO,aAAA,IAAiB,EAAE,CAAA,CAAA,GAAK,EAAA;AACtH,EAAA,MAAM,cAAA,GAAiB,OAAA;AAAA,IACrB,MAAM,MAAA,KAAW,MAAA,GAAS,sBAAA,CAAuB,MAAM,CAAA,GAAI,IAAA,CAAA;AAAA,IAC3D,CAAC,QAAQ,SAAS;AAAA,GACpB;AACA,EAAA,MAAM,KAAA,GAAQ,OAAA;AAAA,IACZ,OAAO;AAAA,MACL,MAAA,EAAQ,cAAA;AAAA,MACR,IAAA;AAAA,MACA,MAAA,EAAQ,MAAA,IAAU,MAAA,EAAQ,MAAA,IAAU,MAAM,MAAA,IAAU,OAAA;AAAA,MACpD,QAAA,EAAU,QAAA,IAAY,IAAA,EAAM,aAAA,IAAiB;AAAA,KAC/C,CAAA;AAAA,IACA,CAAC,cAAA,EAAgB,IAAA,EAAM,MAAA,EAAQ,QAAA,EAAU,QAAQ,MAAM;AAAA,GACzD;AACA,EAAA,uBAAO,GAAA,CAAC,iBAAA,CAAkB,QAAA,EAAlB,EAA2B,OAAe,QAAA,EAAS,CAAA;AAC7D;AAGO,SAAS,aAAA,GAAwC;AACtD,EAAA,MAAM,GAAA,GAAM,WAAW,iBAAiB,CAAA;AACxC,EAAA,IAAI,CAAC,GAAA,EAAK,MAAM,IAAI,MAAM,wDAAwD,CAAA;AAClF,EAAA,OAAO,GAAA;AACT;AAGO,SAAS,qBAAA,GAAuD;AACrE,EAAA,OAAO,WAAW,iBAAiB,CAAA;AACrC;AAGO,SAAS,mBAAA,GAAwC;AACtD,EAAA,MAAM,EAAE,MAAA,EAAO,GAAI,aAAA,EAAc;AACjC,EAAA,IAAI,CAAC,MAAA,EAAQ,MAAM,IAAI,MAAM,0EAA0E,CAAA;AACvG,EAAA,OAAO,MAAA;AACT","file":"chunk-DQBL2DFJ.js","sourcesContent":["import { createContext, useContext, useMemo, type ReactNode } from 'react'\nimport { createStorefrontClient, type Shop, type StorefrontClient, type StorefrontClientOptions } from '@asteby/storefront'\n\nexport type StorefrontContextValue = {\n /** Client for browser-side calls (use a public `pk_` token). */\n client: StorefrontClient | null\n shop: Shop | null\n /** BCP 47 locale used for formatting (defaults to `shop.locale`, then `es-MX`). */\n locale: string\n /** ISO 4217 currency (defaults to `shop.currency_code`, then `MXN`). */\n currency: string\n}\n\nconst StorefrontContext = createContext<StorefrontContextValue | null>(null)\n\nexport type StorefrontProviderProps = {\n /** A ready client. Takes precedence over `config`. */\n client?: StorefrontClient\n /** Options to create the browser client (use a `pk_` token). */\n config?: StorefrontClientOptions\n /** Shop info loaded on the server (root loader). */\n shop?: Shop | null\n locale?: string\n currency?: string\n children?: ReactNode\n}\n\n/**\n * Makes the storefront client, shop, locale and currency available to the\n * tree. SSR-safe: creating the client does not touch `window`.\n */\nexport function StorefrontProvider({ client, config, shop = null, locale, currency, children }: StorefrontProviderProps) {\n const configKey = config ? `${config.baseUrl}|${config.token}|${config.locale ?? ''}|${config.customerToken ?? ''}` : ''\n const resolvedClient = useMemo(\n () => client ?? (config ? createStorefrontClient(config) : null),\n [client, configKey],\n )\n const value = useMemo<StorefrontContextValue>(\n () => ({\n client: resolvedClient,\n shop,\n locale: locale ?? config?.locale ?? shop?.locale ?? 'es-MX',\n currency: currency ?? shop?.currency_code ?? 'MXN',\n }),\n [resolvedClient, shop, locale, currency, config?.locale],\n )\n return <StorefrontContext.Provider value={value}>{children}</StorefrontContext.Provider>\n}\n\n/** Storefront context. Throws outside `<StorefrontProvider>`. */\nexport function useStorefront(): StorefrontContextValue {\n const ctx = useContext(StorefrontContext)\n if (!ctx) throw new Error('useStorefront must be used inside <StorefrontProvider>')\n return ctx\n}\n\n/** Storefront context or `null` outside a provider (for components that work standalone). */\nexport function useOptionalStorefront(): StorefrontContextValue | null {\n return useContext(StorefrontContext)\n}\n\n/** The client from context; throws when the provider has none. */\nexport function useStorefrontClient(): StorefrontClient {\n const { client } = useStorefront()\n if (!client) throw new Error('<StorefrontProvider> needs a `client` or `config` for browser-side calls')\n return client\n}\n"]}