@kotao/storefront 0.2.8 → 0.2.10

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.
Files changed (35) hide show
  1. package/dist/{chunk-RFM27BKZ.js → chunk-BV4OLXV4.js} +7 -3
  2. package/dist/chunk-BV4OLXV4.js.map +1 -0
  3. package/dist/{chunk-OGWYTYYS.js → chunk-GR4QJ7EC.js} +14 -22
  4. package/dist/chunk-GR4QJ7EC.js.map +1 -0
  5. package/dist/{chunk-3YQXJNR7.js → chunk-PWVXOMDE.js} +18 -9
  6. package/dist/chunk-PWVXOMDE.js.map +1 -0
  7. package/dist/{chunk-DTKLAOKR.js → chunk-XZVCUAZU.js} +14 -4
  8. package/dist/chunk-XZVCUAZU.js.map +1 -0
  9. package/dist/{client-CwOPPpFF.d.ts → client-D-M3Y1ul.d.ts} +2 -0
  10. package/dist/{createCartHandler-7DdKB-GF.d.ts → createCartHandler-DJEZW4o2.d.ts} +1 -1
  11. package/dist/editor.d.ts +1 -1
  12. package/dist/editor.js +1 -1
  13. package/dist/events-react.d.ts +1 -1
  14. package/dist/events-react.js +20 -9
  15. package/dist/events-react.js.map +1 -1
  16. package/dist/events.d.ts +3 -3
  17. package/dist/image.d.ts +1 -1
  18. package/dist/image.js +1 -1
  19. package/dist/index.d.ts +19 -12
  20. package/dist/index.js +29 -12
  21. package/dist/index.js.map +1 -1
  22. package/dist/react-router.d.ts +19 -7
  23. package/dist/react-router.js +43 -4
  24. package/dist/react-router.js.map +1 -1
  25. package/dist/react.d.ts +4 -5
  26. package/dist/react.js +144 -21
  27. package/dist/react.js.map +1 -1
  28. package/dist/{routing-Bz3Cg0BW.d.ts → routing-D2GFXAa0.d.ts} +8 -3
  29. package/dist/{shop-CnrnSAJw.d.ts → shop-4y-4z3oZ.d.ts} +5 -4
  30. package/dist/{types-DZX8ScsJ.d.ts → types-C4yXcret.d.ts} +2 -1
  31. package/package.json +2 -2
  32. package/dist/chunk-3YQXJNR7.js.map +0 -1
  33. package/dist/chunk-DTKLAOKR.js.map +0 -1
  34. package/dist/chunk-OGWYTYYS.js.map +0 -1
  35. package/dist/chunk-RFM27BKZ.js.map +0 -1
@@ -1,8 +1,8 @@
1
1
  import { S as StoredThemeSettings } from './fetch-Bq_cc8_G.js';
2
2
  import { C as CacheStrategy, K as KotaoCache, S as StorefrontClient, b as StorefrontCacheInput } from './client-D51WdvYR.js';
3
- import { C as CartHandler } from './createCartHandler-7DdKB-GF.js';
4
- import { E as EventsClient } from './client-CwOPPpFF.js';
5
- import { H as HydrogenSession } from './types-DZX8ScsJ.js';
3
+ import { C as CartHandler } from './createCartHandler-DJEZW4o2.js';
4
+ import { E as EventsClient } from './client-D-M3Y1ul.js';
5
+ import { H as HydrogenSession } from './types-C4yXcret.js';
6
6
 
7
7
  /**
8
8
  * Typed customer-account shapes for the `@kotao/storefront` SDK surface (#91). The names/signatures
@@ -165,6 +165,9 @@ interface Geo {
165
165
  colo: string | null;
166
166
  botScore: number | null;
167
167
  clientIp: string | null;
168
+ /** Approximate IP geolocation from the trusted gateway, never browser GPS. */
169
+ latitude?: number;
170
+ longitude?: number;
168
171
  }
169
172
  /** Gateway side (#81): produce the two header values for a geo payload. */
170
173
  declare function signGeo(geo: Geo, key: string): Promise<{
@@ -179,6 +182,8 @@ type WithCache = <T>(key: string, strategy: CacheStrategy, fn: () => Promise<T>)
179
182
  declare const createWithCache: (cache: KotaoCache) => WithCache;
180
183
 
181
184
  interface KotaoEnv {
185
+ /** Content-addressed static asset tree supplied by the build service. */
186
+ KOTAO_ASSET_BASE?: string;
182
187
  STOREFRONT_API_ENDPOINT: string;
183
188
  STOREFRONT_API_TOKEN: string;
184
189
  STOREFRONT_API_TOKEN_HEADER?: string;
@@ -1,4 +1,4 @@
1
- import { j as CartFormAction } from './types-DZX8ScsJ.js';
1
+ import { j as CartFormAction } from './types-C4yXcret.js';
2
2
  import { ReactNode } from 'react';
3
3
 
4
4
  interface CartFormProps {
@@ -33,8 +33,8 @@ declare const CartForm: ((_props: CartFormProps) => null) & {
33
33
  };
34
34
 
35
35
  /**
36
- * Legacy consent state. The visible Storefront consent UI is disabled until the replacement flow ships,
37
- * so runtime analytics treats every bucket as effectively granted and ignores stale consent cookies.
36
+ * Shared native-CMP consent state. Unknown choices deny optional categories; the versioned cookie
37
+ * is the same contract used by dispatch and checkout.
38
38
  */
39
39
  type ConsentBucket = 'analytics' | 'marketing' | 'preferences';
40
40
  type ConsentState = Readonly<Record<ConsentBucket, boolean>>;
@@ -166,6 +166,7 @@ interface PixelTransportOptions {
166
166
  /** Same-origin carve-out endpoint (default '/api/storefront/pixel') — never cross-origin. */
167
167
  readonly endpoint?: string;
168
168
  readonly batchSize?: number;
169
+ readonly flushAfterMs?: number;
169
170
  /**
170
171
  * The actual network send. Required + injected: the browser sender (`sendBeacon`/keepalive `fetch`)
171
172
  * is DOM-specific and lives in the React layer, keeping this batching core framework-agnostic and
@@ -179,7 +180,7 @@ interface PixelTransport {
179
180
  }
180
181
  /**
181
182
  * Batches pixel events and POSTs them to the same-origin carve-out. Flushes on `batchSize` or an
182
- * explicit `flush()` (the provider flushes on page-hide). The endpoint is same-origin so themes need
183
+ * explicit `flush()` (the provider flushes on page-hide), or after one second. The endpoint is same-origin so themes need
183
184
  * no CORS and no token is exposed in the browser.
184
185
  */
185
186
  declare function createPixelTransport(options: PixelTransportOptions): PixelTransport;
@@ -85,7 +85,8 @@ interface CartDiscountAllocation {
85
85
  interface CartCost {
86
86
  readonly subtotalAmount: Money;
87
87
  readonly totalAmount: Money;
88
- readonly totalTaxAmount: Money;
88
+ /** Null when destination taxes have not been configured. */
89
+ readonly totalTaxAmount: Money | null;
89
90
  readonly discountTotal: Money;
90
91
  readonly taxLines: readonly CartTaxLine[];
91
92
  readonly discountAllocations: readonly CartDiscountAllocation[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kotao/storefront",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Kotao Storefronts theme SDK — framework-agnostic server runtime, React Router 8 binding, and authenticated source-authoritative React/Hydrogen editor bridge (ADR-0003/ADR-0047). Leaf package (ADR-0006).",
@@ -72,7 +72,7 @@
72
72
  "LICENSE"
73
73
  ],
74
74
  "dependencies": {
75
- "@kotao/storefront-contracts": "^0.2.4"
75
+ "@kotao/storefront-contracts": "^0.2.5"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "react": "^18.2.0 || ^19.0.0",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/image/image-url.ts","../src/image/image-attrs.ts"],"names":[],"mappings":"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","file":"chunk-3YQXJNR7.js","sourcesContent":["/**\n * Framework-agnostic image-URL builders for the storefront SDK (ADR-0003 §SDK image-URL contract).\n * They build Cloudflare `/cdn-cgi/image/<opts>/<path>` path-form transform URLs for `https://cdn.kotao.*`\n * bases, allowing the CDN to serve optimized renderings. Non-CDN URLs (external, data:, theme `/assets/...`)\n * pass through unchanged. Wire-native params — `width`/`height`/`fit`/`format`/`quality` — matching\n * Cloudflare's native image resizing; a Hydrogen-shaped React `<Image>` (with `crop`, `srcSet`/`sizes`\n * props) layers on top in the framework presets (#92). Pure string building, no framework imports, so it\n * stays in the SDK core.\n */\n\n/** Cloudflare-native resize mode (the Gateway's `fit` param). */\nexport type ImageFit = 'scale-down' | 'contain' | 'pad' | 'squeeze' | 'cover' | 'crop'\n/** Output format query value (the Gateway maps it to a content-type). */\nexport type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png'\n\nexport interface ImageUrlOptions {\n width?: number\n height?: number\n fit?: ImageFit\n format?: ImageFormat\n /** 1–100. */\n quality?: number\n}\n\nconst CDN_CGI = '/cdn-cgi/image/'\nconst isCdnUrl = (url: string): boolean => /^https?:\\/\\/cdn\\.kotao\\./.test(url)\n\n/** Strip a `/cdn-cgi/image/<opts>/` segment back to `{origin}{path}`, returning [bareUrl, existingOpts]. */\nfunction splitCdnCgi(url: string): { bare: string; opts: URLSearchParams } {\n const u = new URL(url)\n if (!u.pathname.startsWith(CDN_CGI)) return { bare: url, opts: new URLSearchParams() }\n const rest = u.pathname.slice(CDN_CGI.length)\n const slash = rest.indexOf('/')\n const optStr = slash === -1 ? rest : rest.slice(0, slash)\n const path = slash === -1 ? '' : rest.slice(slash) // includes leading '/'\n const opts = new URLSearchParams()\n for (const pair of optStr.split(',')) {\n const [k, v] = pair.split('=')\n if (k && v) opts.set(k, v)\n }\n return { bare: `${u.origin}${path}`, opts }\n}\n\nconst OPT_ORDER = ['width', 'height', 'fit', 'format', 'quality'] as const\n\n/**\n * Build a Cloudflare `/cdn-cgi/image/` path-form transform URL for a CDN image. For transformable\n * URLs (absolute `https://cdn.kotao.*`), emits `/cdn-cgi/image/{opts}/{path}` where `{opts}` is\n * the comma-joined key=value list (in stable order: width,height,fit,format,quality) and `{path}`\n * is the original pathname. Chaining replaces any existing `/cdn-cgi/image/` segment.\n * Non-CDN URLs pass through unchanged.\n */\nexport function imageUrl(url: string, options: ImageUrlOptions = {}): string {\n if (!isCdnUrl(url)) return url\n const { bare, opts } = splitCdnCgi(url)\n if (options.width != null) opts.set('width', String(options.width))\n if (options.height != null) opts.set('height', String(options.height))\n if (options.fit != null) opts.set('fit', options.fit)\n if (options.format != null) opts.set('format', options.format)\n if (options.quality != null) opts.set('quality', String(options.quality))\n const ordered = OPT_ORDER.filter((k) => opts.has(k)).map((k) => `${k}=${opts.get(k)}`)\n if (ordered.length === 0) return bare\n const u = new URL(bare)\n return `${u.origin}${CDN_CGI}${ordered.join(',')}${u.pathname}`\n}\n\nexport interface ImageSrcSetOptions extends Omit<ImageUrlOptions, 'width'> {\n /** Candidate widths (px) for the `srcset` descriptors. */\n widths: number[]\n}\n\n/**\n * Build a `srcset` string — one `url width` candidate per width — for responsive `<img srcset>` /\n * `<source srcset>`. Pair with a `sizes` attribute the theme controls.\n */\nexport function imageSrcSet(url: string, options: ImageSrcSetOptions): string {\n const { widths, ...rest } = options\n return widths.map((width) => `${imageUrl(url, { ...rest, width })} ${width}w`).join(', ')\n}\n","/**\n * Framework-agnostic `<img>` attribute builder (ADR-0003 §SDK image-URL contract). Turns the\n * Hydrogen-shaped `<Image>` surface (data/aspectRatio/crop/sizes/width/height) into concrete img\n * attributes (src, srcSet, sizes, width, height, style) over the `cdn.kotao.*` R2-native transform\n * contract, via the pure `imageUrl`/`imageSrcSet` builders. The React `<Image>` is a thin wrapper\n * that spreads these — so all logic lives here, in the core, unit-tested without a DOM.\n */\nimport { imageUrl, type ImageFormat, type ImageUrlOptions } from './image-url'\n\nexport interface ImageData {\n url: string\n altText?: string | null\n width?: number | null\n height?: number | null\n}\n\n/** Hydrogen crop region. v1 maps any crop to a fill+crop (Cloudflare `fit=cover`); positional gravity\n * (top/bottom/left/right) is not on the wire yet, so all values currently centre-crop. */\nexport type ImageCrop = 'center' | 'top' | 'bottom' | 'left' | 'right'\n\n// Optionals are `| undefined` on purpose: the React <Image> feeds them straight from optional props,\n// so explicit `undefined` must be assignable under exactOptionalPropertyTypes.\nexport interface ImageAttrsOptions {\n data: ImageData\n /** Override `data.url`. */\n src?: string | undefined\n /** \"width/height\" (e.g. \"4/5\") — sets the intrinsic box for CLS-free layout. */\n aspectRatio?: string | undefined\n crop?: ImageCrop | undefined\n /** The responsive `sizes` attribute; the theme owns it. */\n sizes?: string | undefined\n /** Fixed render width (px). With it, the srcSet becomes 1x/2x/3x instead of the responsive set. */\n width?: number | undefined\n height?: number | undefined\n loading?: ('eager' | 'lazy') | undefined\n /** srcSet candidate widths; defaults to a responsive set (or 1x/2x/3x when `width` is fixed). */\n srcSetWidths?: number[] | undefined\n format?: ImageFormat | undefined\n quality?: number | undefined\n}\n\nexport interface ImageAttrs {\n src: string\n srcSet: string\n sizes?: string\n width?: number\n height?: number\n alt: string\n loading: 'eager' | 'lazy'\n style?: { aspectRatio: string }\n}\n\n/** Responsive widths when no fixed `width` is given (mirrors Hydrogen's default interval set). */\nconst DEFAULT_WIDTHS = [320, 640, 768, 1024, 1280, 1536]\n/** The `src` fallback width for browsers that ignore srcSet. */\nconst FALLBACK_WIDTH = 1280\n\nexport function buildImageAttrs(options: ImageAttrsOptions): ImageAttrs {\n const url = options.src ?? options.data.url\n\n const widths =\n options.srcSetWidths ??\n (options.width ? [options.width, options.width * 2, options.width * 3] : DEFAULT_WIDTHS)\n\n const aspectRatio =\n options.aspectRatio ??\n (options.data.width != null && options.data.height != null\n ? `${options.data.width}/${options.data.height}`\n : undefined)\n\n // Shared transform params — set only when present (exactOptionalPropertyTypes forbids explicit\n // `undefined`). A crop maps to Cloudflare `fit=cover` (fill+crop).\n const transform: ImageUrlOptions = {}\n if (options.crop) transform.fit = 'cover'\n if (options.format != null) transform.format = options.format\n if (options.quality != null) transform.quality = options.quality\n\n // For a fixed box (width+height), the srcSet candidates must scale BOTH dimensions so every DPR\n // variant keeps the same crop aspect (a uniform height would distort the 2x/3x candidates).\n const heightPerWidth =\n options.width != null && options.height != null ? options.height / options.width : null\n const candidate = (w: number): ImageUrlOptions => {\n const opts: ImageUrlOptions = { ...transform, width: w }\n if (heightPerWidth != null) opts.height = Math.round(w * heightPerWidth)\n return opts\n }\n\n const srcOptions: ImageUrlOptions = { ...transform, width: options.width ?? FALLBACK_WIDTH }\n if (options.height != null) srcOptions.height = options.height\n\n return {\n src: imageUrl(url, srcOptions),\n srcSet: widths.map((w) => `${imageUrl(url, candidate(w))} ${w}w`).join(', '),\n ...(options.sizes != null ? { sizes: options.sizes } : {}),\n ...(options.width != null ? { width: options.width } : {}),\n ...(options.height != null ? { height: options.height } : {}),\n alt: options.data.altText ?? '',\n loading: options.loading ?? 'lazy',\n ...(aspectRatio != null ? { style: { aspectRatio } } : {}),\n }\n}\n"]}