@asstio-dev/storefront 0.21.0 → 0.24.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
@@ -282,11 +282,16 @@ differ from what the order later freezes. `productFacts()` reads them from any s
282
282
  const facts = productFacts(line); // card, product, { product, variant }, cart line, checkout cart line or product order line
283
283
  render(facts.brand?.label); // the item's brand: the variant's own, never a fallback to the family's
284
284
  render(facts.productBrand?.label); // the family brand
285
+ render(facts.primaryCategory?.pathNames.join(" > ")); // the one category to use for analytics, breadcrumbs and feeds
285
286
  ```
286
287
 
287
288
  `brand` is the selected or purchased variant's brand, and `null` can mean the variant deliberately has none — do not
288
- fall back to `productBrand`. **Categories carry no primary meaning**: `categories[0]` may be a gender, a brand or a
289
- product-type node. Pick the one you want by its root.
289
+ fall back to `productBrand`. **`categories` carry no primary meaning**: `categories[0]` may be a gender, a brand or a
290
+ product-type node. `primaryCategory` is the one to use: the storefront picks it under the site's product-taxonomy root, one
291
+ of `categories`, and it is `null` when the site has no root, the product has no category under it, or the line was frozen
292
+ before it existed. Identify a root by `pathIds[0]`, never by its translated name. `pathIds` and `primaryCategory` need
293
+ API v0.24.0 or later; on order lines and checkout lines frozen from a snapshot taken before v0.24.0, `pathIds` is `[]` and
294
+ `primaryCategory` is `null`.
290
295
 
291
296
  `toGa4Item()` and `toGa4Ecommerce()` map the same sources to GA4 ecommerce data; `toGa4Ecommerce(checkout.cart)` is the
292
297
  `begin_checkout` payload. They are pure: they send nothing and
@@ -306,20 +311,28 @@ know nothing about consent or a data layer.
306
311
  25 % gives `price: 7.99`, `value: 23.97`). Pass `tax: "include"` to report gross on purpose.
307
312
  - For `add_to_cart` and `remove_from_cart`, pass `quantity`: the positive number of units added or removed, never the
308
313
  line's new total.
309
- - `item_category`…`item_category5` come from one category's `pathNames`: by default the longest, or the one your
310
- `pickCategory` returns. A `pickCategory` that returns `undefined` means no category fields.
314
+ - `item_category`…`item_category5` come from `primaryCategory`'s `pathNames` (the full path, root included). When
315
+ `primaryCategory` is `null` the item has **no** category fields: null can mean "this product has no category under the
316
+ taxonomy root", and a fallback would put a brand or series root into GA4. Since v0.24.0 this is the default; before it
317
+ was the longest path, which `pickCategory: longestCategory` still gives. A `pickCategory` gets the categories and the
318
+ line's facts, `(categories, facts) => CategoryRefDto | undefined`; returning `undefined` means no category fields.
319
+ - Identify a root by id (`pathIds[0]`), never by its translated name.
311
320
  - No `discount`: `compareAt` is a recommended price, not a discount applied.
312
321
  - A source without a price throws `AnalyticsError`, as does a non-product order line; never `price: 0`.
313
322
  - For an order, `transaction_id` is `order.id` (stable from completion; `orderNumber` is null until export), `tax` the
314
323
  recorded VAT and `shipping` the shipping lines' net.
315
324
 
316
325
  ```ts
317
- const byProductType = (cs: readonly CategoryRefDto[]) => cs.find((c) => c.pathNames[0] === "Produkttyp");
318
- gtag("event", "view_item_list", { items: listing.items.map((card, index) => toGa4Item(card, { index, pickCategory: byProductType })) });
326
+ // The primary category is the default; this drops a top-level taxonomy root from the emitted path.
327
+ const belowRoot = (_: readonly CategoryRefDto[], f: ProductFacts) =>
328
+ f.primaryCategory ? { ...f.primaryCategory, pathNames: f.primaryCategory.pathNames.slice(1) } : undefined;
329
+ gtag("event", "view_item_list", { items: listing.items.map((card, index) => toGa4Item(card, { index, pickCategory: belowRoot })) });
319
330
  gtag("event", "add_to_cart", { currency: cart.currency, items: [toGa4Item(line, { quantity: added })] });
320
- gtag("event", "purchase", toGa4Ecommerce(order, { pickCategory: byProductType }));
331
+ gtag("event", "purchase", toGa4Ecommerce(order, { pickCategory: belowRoot }));
321
332
  ```
322
333
 
334
+ `slice(1)` is right only when the site's taxonomy root is a top-level category.
335
+
323
336
  ### Search-as-you-type and search-in-facet
324
337
 
325
338
  `/v1/search/suggest` answers three short lists for what the shopper has typed so far: up to 5 product cards
@@ -8,6 +8,7 @@ export function productFacts(source) {
8
8
  throw new AnalyticsError(`an order line of type "${line.type}" has no product facts`);
9
9
  return {
10
10
  brand: line.variant.brand, productBrand: line.product.brand, categories: line.product.categories,
11
+ primaryCategory: line.product.primaryCategory ?? null,
11
12
  attributes: line.product.attributes, shortDescription: null,
12
13
  };
13
14
  }
@@ -17,7 +18,7 @@ export function productFacts(source) {
17
18
  const { product, variant } = source;
18
19
  const short = product.shortDescription;
19
20
  return {
20
- brand: variant.brand, productBrand: product.brand, categories: product.categories,
21
+ brand: variant.brand, productBrand: product.brand, categories: product.categories, primaryCategory: product.primaryCategory ?? null,
21
22
  attributes: product.attributes, shortDescription: short ?? null,
22
23
  };
23
24
  }
@@ -25,7 +26,7 @@ export function productFacts(source) {
25
26
  if (!Array.isArray(flat.categories) || !Array.isArray(flat.attributes))
26
27
  throw new AnalyticsError("not a product facts source");
27
28
  return {
28
- brand: flat.brand, productBrand: flat.brand, categories: flat.categories, attributes: flat.attributes,
29
+ brand: flat.brand, productBrand: flat.brand, categories: flat.categories, primaryCategory: flat.primaryCategory ?? null, attributes: flat.attributes,
29
30
  shortDescription: flat.shortDescription ?? null,
30
31
  };
31
32
  }
@@ -1,4 +1,4 @@
1
- import { type CartDto, type CheckoutCartDto, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type OrderDto } from "./types.js";
1
+ import { type CartDto, type CategoryRefDto, type CheckoutCartDto, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type OrderDto } from "./types.js";
2
2
  /** An item and its unit price in cents, so a payload's `value` sums exact cents rather than re-reading floats. */
3
3
  export type MappedItem = {
4
4
  item: Ga4Item;
@@ -20,3 +20,5 @@ export declare function mapItem(source: Ga4ItemSource, opts?: Ga4ItemOptions): M
20
20
  export declare function toGa4Ecommerce(source: CartDto, opts?: Ga4SharedOptions): Ga4Ecommerce;
21
21
  export declare function toGa4Ecommerce(source: CheckoutCartDto, opts?: Ga4SharedOptions): Ga4Ecommerce;
22
22
  export declare function toGa4Ecommerce(source: OrderDto, opts?: Ga4SharedOptions): Ga4Purchase;
23
+ /** The longest path, the first of equals — the default before `primaryCategory` existed. Never mutates the array. */
24
+ export declare function longestCategory(categories: readonly CategoryRefDto[]): CategoryRefDto | undefined;
@@ -20,7 +20,7 @@ export function mapItem(source, opts = {}) {
20
20
  const variant = itemVariant(line.options);
21
21
  if (variant !== undefined)
22
22
  item.item_variant = variant;
23
- const picked = opts.pickCategory ? opts.pickCategory(facts.categories) : longest(facts.categories);
23
+ const picked = opts.pickCategory ? opts.pickCategory(facts.categories, facts) : facts.primaryCategory ?? undefined;
24
24
  if (picked) {
25
25
  const keys = ["item_category", "item_category2", "item_category3", "item_category4", "item_category5"];
26
26
  picked.pathNames.slice(0, keys.length).forEach((name, i) => { item[keys[i]] = name; });
@@ -106,8 +106,8 @@ function itemVariant(options) {
106
106
  const axes = Object.keys(options).sort();
107
107
  return axes.length === 0 ? undefined : axes.map(a => `${a}: ${options[a]}`).join(", ");
108
108
  }
109
- /** The default pick: the longest path, the first of equals. Never mutates the array. */
110
- function longest(categories) {
109
+ /** The longest path, the first of equals — the default before `primaryCategory` existed. Never mutates the array. */
110
+ export function longestCategory(categories) {
111
111
  let best;
112
112
  for (const c of categories)
113
113
  if (!best || c.pathNames.length > best.pathNames.length)
@@ -26,6 +26,10 @@ export type ProductFacts = {
26
26
  productBrand: BrandDto | null;
27
27
  /** Classification memberships with no primary meaning; `categories[0]` may be any root. */
28
28
  categories: CategoryRefDto[];
29
+ /** The one category to use for analytics, breadcrumbs and feeds: one of `categories`, chosen by the storefront under the
30
+ * site's product-taxonomy root. `null` when the site has none, the product has no category under it, or the line was frozen
31
+ * before it existed. */
32
+ primaryCategory: CategoryRefDto | null;
29
33
  /** As the source carries them: a `ProductDto` may include `html` attributes; cards, cart and order lines do not. */
30
34
  attributes: AttributeDto[];
31
35
  /** Null on order lines and checkout cart lines. */
@@ -61,9 +65,10 @@ export type Ga4SharedOptions = {
61
65
  affiliation?: string;
62
66
  itemListId?: string;
63
67
  itemListName?: string;
64
- /** Which category feeds `item_category`…`item_category5`. Default: the longest path, ties in the order given. Returning
65
- * `undefined` means no category fields — never a fallback to the default. Categories carry no primary meaning. */
66
- pickCategory?: (categories: readonly CategoryRefDto[]) => CategoryRefDto | undefined;
68
+ /** Which category feeds `item_category`…`item_category5`. Default: `facts.primaryCategory`, and no category fields when it is
69
+ * null (never a fallback: null can mean "this product has no category under the taxonomy root"). Pass `longestCategory` for
70
+ * the longest path. Returning `undefined` means no category fields. The second argument is the line's own facts. */
71
+ pickCategory?: (categories: readonly CategoryRefDto[], facts: ProductFacts) => CategoryRefDto | undefined;
67
72
  };
68
73
  /** One item's options: `quantity` overrides the source's (for add/remove events: the positive number of units added or
69
74
  * removed), `itemId` its id (e.g. for family-level reporting), `index` its position in a list. */
package/dist/index.d.ts CHANGED
@@ -57,7 +57,7 @@ export { gtinJsonLd } from "./gtin.js";
57
57
  export { productJsonLd } from "./seo/product.js";
58
58
  export { jsonLdScript } from "./seo/serialize.js";
59
59
  export { productFacts } from "./analytics/facts.js";
60
- export { toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
60
+ export { longestCategory, toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
61
61
  export { trackSearchEvents, type SearchEvent, type SearchEventsResponse } from "./analytics/events.js";
62
62
  export { AnalyticsError, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type ProductFacts, type SelectedVariant, } from "./analytics/types.js";
63
63
  export { SeoError, type BreadcrumbLink, type CollectionHeadOptions, type HeadOptions, type JsonLd, type NextMetadataLike, type ProductJsonLdOptions, type SchemaProperty, type SeoContext, type SeoHead, type SeoMeta, type VariantAvailability, } from "./seo/types.js";
package/dist/index.js CHANGED
@@ -46,7 +46,7 @@ export { gtinJsonLd } from "./gtin.js";
46
46
  export { productJsonLd } from "./seo/product.js";
47
47
  export { jsonLdScript } from "./seo/serialize.js";
48
48
  export { productFacts } from "./analytics/facts.js";
49
- export { toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
49
+ export { longestCategory, toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
50
50
  export { trackSearchEvents } from "./analytics/events.js";
51
51
  export { AnalyticsError, } from "./analytics/types.js";
52
52
  export { SeoError, } from "./seo/types.js";
package/dist/schema.d.ts CHANGED
@@ -536,6 +536,7 @@ export interface components {
536
536
  categories: components["schemas"]["CategoryRefDto"][];
537
537
  id: string;
538
538
  name: string;
539
+ primaryCategory: null | components["schemas"]["CategoryRefDto"];
539
540
  shortDescription: null | string;
540
541
  slug: string;
541
542
  };
@@ -556,6 +557,7 @@ export interface components {
556
557
  CategoryRefDto: {
557
558
  id: string;
558
559
  name: string;
560
+ pathIds: string[];
559
561
  pathNames: string[];
560
562
  };
561
563
  CheckoutCartDto: {
@@ -917,6 +919,7 @@ export interface components {
917
919
  attributes: components["schemas"]["AttributeDto"][];
918
920
  brand: null | components["schemas"]["BrandDto"];
919
921
  categories: components["schemas"]["CategoryRefDto"][];
922
+ primaryCategory: null | components["schemas"]["CategoryRefDto"];
920
923
  };
921
924
  OrderLineVariantDto: {
922
925
  brand: null | components["schemas"]["BrandDto"];
@@ -977,6 +980,7 @@ export interface components {
977
980
  name: string;
978
981
  price: null | components["schemas"]["PriceDto"];
979
982
  priceVaries: boolean;
983
+ primaryCategory: null | components["schemas"]["CategoryRefDto"];
980
984
  shortDescription: null | string;
981
985
  sku: string;
982
986
  slug: string;
@@ -1003,6 +1007,7 @@ export interface components {
1003
1007
  matchedVariantId: null | string;
1004
1008
  name: string;
1005
1009
  options: components["schemas"]["OptionDto"][];
1010
+ primaryCategory: null | components["schemas"]["CategoryRefDto"];
1006
1011
  redirectTo: null | string;
1007
1012
  related: components["schemas"]["RelatedGroupDto"][];
1008
1013
  responsible: components["schemas"]["ResponsibleDto"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asstio-dev/storefront",
3
- "version": "0.21.0",
3
+ "version": "0.24.0",
4
4
  "description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  "test": "vitest run",
22
22
  "test:browser": "playwright test -c browser/playwright.config.ts",
23
23
  "check": "npm run generate && npm run typecheck-examples && npm test && git diff --exit-code -- src/schema.d.ts && test -z \"$(git status --porcelain -- src/schema.d.ts)\"",
24
- "smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"AnalyticsError\\\", \\\"trackSearchEvents\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
24
+ "smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"longestCategory\\\", \\\"AnalyticsError\\\", \\\"trackSearchEvents\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
25
25
  },
26
26
  "dependencies": {
27
27
  "openapi-fetch": "0.17.0"
package/src/schema.d.ts CHANGED
@@ -536,6 +536,7 @@ export interface components {
536
536
  categories: components["schemas"]["CategoryRefDto"][];
537
537
  id: string;
538
538
  name: string;
539
+ primaryCategory: null | components["schemas"]["CategoryRefDto"];
539
540
  shortDescription: null | string;
540
541
  slug: string;
541
542
  };
@@ -556,6 +557,7 @@ export interface components {
556
557
  CategoryRefDto: {
557
558
  id: string;
558
559
  name: string;
560
+ pathIds: string[];
559
561
  pathNames: string[];
560
562
  };
561
563
  CheckoutCartDto: {
@@ -917,6 +919,7 @@ export interface components {
917
919
  attributes: components["schemas"]["AttributeDto"][];
918
920
  brand: null | components["schemas"]["BrandDto"];
919
921
  categories: components["schemas"]["CategoryRefDto"][];
922
+ primaryCategory: null | components["schemas"]["CategoryRefDto"];
920
923
  };
921
924
  OrderLineVariantDto: {
922
925
  brand: null | components["schemas"]["BrandDto"];
@@ -977,6 +980,7 @@ export interface components {
977
980
  name: string;
978
981
  price: null | components["schemas"]["PriceDto"];
979
982
  priceVaries: boolean;
983
+ primaryCategory: null | components["schemas"]["CategoryRefDto"];
980
984
  shortDescription: null | string;
981
985
  sku: string;
982
986
  slug: string;
@@ -1003,6 +1007,7 @@ export interface components {
1003
1007
  matchedVariantId: null | string;
1004
1008
  name: string;
1005
1009
  options: components["schemas"]["OptionDto"][];
1010
+ primaryCategory: null | components["schemas"]["CategoryRefDto"];
1006
1011
  redirectTo: null | string;
1007
1012
  related: components["schemas"]["RelatedGroupDto"][];
1008
1013
  responsible: components["schemas"]["ResponsibleDto"];