@asstio-dev/storefront 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
@@ -243,6 +243,56 @@ config. Holes in the data never throw: a missing image, description or price onl
243
243
 
244
244
  hreflang sets are only as reciprocal as the API's `alternateUrls`; the SDK renders what the API gives.
245
245
 
246
+ ### Product facts and GA4
247
+
248
+ Cards and cart lines carry the product facts the product page has: `brand`, `categories`, `shortDescription` and
249
+ `attributes` (minus `type: "html"` attributes, which stay on the product page). A card has them flat; a cart line under
250
+ `line.product`, with the line's own brand on `line.variant.brand`. An order line has the same `product`/`variant`
251
+ shape without `shortDescription` (`productFacts(orderLine).shortDescription` is `null`): its facts are the ones the
252
+ charged checkout snapshot captured, never re-resolved — `url`, `facet` and attribute `facetKey`/`valueKeys` are `null`
253
+ there, the blocks are empty on orders placed before they existed, and `null` on fee, shipping and adjustment lines.
254
+ `productFacts()` reads them from any source:
255
+
256
+ ```ts
257
+ const facts = productFacts(line); // card, product, { product, variant }, cart line or product order line
258
+ render(facts.brand?.label); // the item's brand: the variant's own, never a fallback to the family's
259
+ render(facts.productBrand?.label); // the family brand
260
+ ```
261
+
262
+ `brand` is the selected or purchased variant's brand, and `null` can mean the variant deliberately has none — do not
263
+ fall back to `productBrand`. **Categories carry no primary meaning**: `categories[0]` may be a gender, a brand or a
264
+ product-type node. Pick the one you want by its root.
265
+
266
+ `toGa4Item()` and `toGa4Ecommerce()` map the same sources to GA4 ecommerce data. They are pure: they send nothing and
267
+ know nothing about consent or a data layer.
268
+
269
+ | Source | `item_id` | `item_brand` | `price` | `quantity` | `item_variant` |
270
+ |---|---|---|---|---|---|
271
+ | card (impression) | the family `sku` | `brand.label` | from-price | 1 | — |
272
+ | `{ product, variant }` | the variant `sku` | the variant's brand | the variant's price | 1 | selected options |
273
+ | cart line | the line `sku` | the variant's brand | the line's unit price | the line's | selected options |
274
+ | order line (product) | the line `sku` | the variant's brand | the unit price | the line's | options |
275
+
276
+ - A card is a family impression; later events carry the purchased sku. Pass `itemId` to report at family level.
277
+ - `price` and `value` are **net of VAT** by default, as GA4 defines them: 2 × 125.00 at 25 % gives `price: 100`,
278
+ `value: 200`. `value` is the sum of the items' rounded prices, so it need not match accounting to the öre (3 × 9.99 at
279
+ 25 % gives `price: 7.99`, `value: 23.97`). Pass `tax: "include"` to report gross on purpose.
280
+ - For `add_to_cart` and `remove_from_cart`, pass `quantity`: the positive number of units added or removed, never the
281
+ line's new total.
282
+ - `item_category`…`item_category5` come from one category's `pathNames`: by default the longest, or the one your
283
+ `pickCategory` returns. A `pickCategory` that returns `undefined` means no category fields.
284
+ - No `discount`: `compareAt` is a recommended price, not a discount applied.
285
+ - A source without a price throws `AnalyticsError`, as does a non-product order line; never `price: 0`.
286
+ - For an order, `transaction_id` is `order.id` (stable from completion; `orderNumber` is null until export), `tax` the
287
+ recorded VAT and `shipping` the shipping lines' net.
288
+
289
+ ```ts
290
+ const byProductType = (cs: readonly CategoryRefDto[]) => cs.find((c) => c.pathNames[0] === "Produkttyp");
291
+ gtag("event", "view_item_list", { items: listing.items.map((card, index) => toGa4Item(card, { index, pickCategory: byProductType })) });
292
+ gtag("event", "add_to_cart", { currency: cart.currency, items: [toGa4Item(line, { quantity: added })] });
293
+ gtag("event", "purchase", toGa4Ecommerce(order, { pickCategory: byProductType }));
294
+ ```
295
+
246
296
  ### Search-as-you-type and search-in-facet
247
297
 
248
298
  `/v1/search/suggest` answers three short lists for what the shopper has typed so far: up to 5 product cards
@@ -412,6 +462,44 @@ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `c
412
462
 
413
463
  `https://*.kustom.co` covers the playground (`js.playground.kustom.co`) and production (`js.kustom.co`).
414
464
 
465
+ ### Shipping
466
+
467
+ The shopper chooses delivery inside the provider's widget (Kustom's shipping assistant, Qliro's shipping step), so
468
+ there is **no shipping picker to build and no price, VAT or method id to send**. The `klarna-kco` (Kustom) and `qliro-q1`
469
+ controllers answer for the widget; the page's part is the estimate before the checkout and the shipping block on the order.
470
+
471
+ ```ts
472
+ // The estimate: an estimate, never a quote (the destination is not known yet). No lookup, `no-store`.
473
+ const { data: estimate } = await storefront.GET("/v1/cart/shipping-estimate", {
474
+ params: { header: { "X-Cart-Token": token } },
475
+ });
476
+ if (estimate?.available) {
477
+ const headline = estimate.options.find((option) => option.preselected); // `fee` is gross kronor, "49.00"
478
+ // estimate.freeShipping: { free, threshold, remaining } — progress comes from here, never a hard-coded threshold
479
+ }
480
+ // `available: false` means show nothing about shipping. `reason: null`: no block published on the market, and the
481
+ // checkout works as before. `reason: "unavailable"`: a block that cannot be served now; the cart works, but
482
+ // POST /v1/checkout refuses a new attempt with 503 SHIPPING_UNAVAILABLE (Retry-After 60) until it is fixed.
483
+
484
+ // The order page: `order.shipping` is `OrderShippingDto | null`.
485
+ // null = the selection was not matched or confirmed (the charged shipping line is still in `order.lines`), or the
486
+ // market has no block. Show the block only when it is present; never read null as "no shipping was paid".
487
+ ```
488
+
489
+ - `ShippingEstimateOptionDto`: `key`, `name`, `deliveryType` (`delivery`, `pickup` or `mailbox`), `fee`, `regularFee`
490
+ (the price without the free-shipping rule; set only when `fee` is `"0.00"` and that price is above 0, so `null`
491
+ for an always-free method: render no comparison price then), `labels`, `pointsAtCheckout` (the pickup point is
492
+ chosen in the checkout; none is listed here) and `preselected`. The estimate has **no delivery time**.
493
+ - `OrderShippingDto`: `option`, `name`, `deliveryType`, `carrier`, `fee`, `point` (name, street, postal code, city; null
494
+ when the carrier assigned it or after the privacy scrub) and `deliveryTime` (`mode` `carrierEstimate`, `fixed` or
495
+ `text`; `minDays` and `maxDays` are each nullable: render only the bounds that are present, or `text`).
496
+ - **Do not re-render the widget** when shipping changes, and do not write your own Qliro unlock: on a market with a
497
+ shipping block Qliro's total includes the fee and so can differ from the cart's, and the `qliro-q1` controller unlocks on the echoed cart version
498
+ (`MerchantUpdateVersion`) or on the confirmed total (see the Qliro notes above).
499
+ - Shipping never blocks a purchase. If a selection cannot be matched when the shopper pays, the order is accepted and
500
+ normally held in Asstio with its reason (rarely the export waits for an operator instead); the page has nothing to
501
+ do for it.
502
+
415
503
  ## Headers
416
504
 
417
505
  Every call the client makes carries these; you never set them by hand.
@@ -0,0 +1,13 @@
1
+ /** An exact rational `n / d` (d > 0). Every API amount is a decimal string; nothing here ever goes through a float. */
2
+ export type Rational = {
3
+ n: bigint;
4
+ d: bigint;
5
+ };
6
+ /** `"125.00"`, `"25"`, `"-3.5"` → an exact rational. Anything else (exponents, commas, blanks) throws. */
7
+ export declare function parseDecimal(s: string): Rational;
8
+ /** The net of a VAT-inclusive amount: `gross / (1 + rate / 100)`, exactly. */
9
+ export declare function netOf(gross: Rational, vatRatePercent: Rational): Rational;
10
+ export declare function add(a: Rational, b: Rational): Rational;
11
+ /** Rounded to whole cents (öre), half away from zero. */
12
+ export declare function toCents(r: Rational): bigint;
13
+ export declare const centsToNumber: (cents: bigint) => number;
@@ -0,0 +1,29 @@
1
+ import { AnalyticsError } from "./types.js";
2
+ const DECIMAL = /^-?\d+(\.\d+)?$/;
3
+ /** `"125.00"`, `"25"`, `"-3.5"` → an exact rational. Anything else (exponents, commas, blanks) throws. */
4
+ export function parseDecimal(s) {
5
+ if (typeof s !== "string" || !DECIMAL.test(s))
6
+ throw new AnalyticsError(`not a decimal amount: "${s}"`);
7
+ const negative = s.startsWith("-");
8
+ const [whole, fraction = ""] = (negative ? s.slice(1) : s).split(".");
9
+ const n = BigInt(whole + fraction);
10
+ return { n: negative ? -n : n, d: 10n ** BigInt(fraction.length) };
11
+ }
12
+ /** The net of a VAT-inclusive amount: `gross / (1 + rate / 100)`, exactly. */
13
+ export function netOf(gross, vatRatePercent) {
14
+ return { n: gross.n * 100n * vatRatePercent.d, d: gross.d * (100n * vatRatePercent.d + vatRatePercent.n) };
15
+ }
16
+ export function add(a, b) {
17
+ return { n: a.n * b.d + b.n * a.d, d: a.d * b.d };
18
+ }
19
+ /** Rounded to whole cents (öre), half away from zero. */
20
+ export function toCents(r) {
21
+ const num = r.n * 100n;
22
+ let q = num / r.d; // truncates toward zero
23
+ const rem = num % r.d;
24
+ const twice = 2n * (rem < 0n ? -rem : rem);
25
+ if (twice >= r.d)
26
+ q += num < 0n ? -1n : 1n;
27
+ return q;
28
+ }
29
+ export const centsToNumber = (cents) => Number(cents) / 100;
@@ -0,0 +1,12 @@
1
+ import { type CartLineDto, type OrderLineDto, type ProductCardDto, type ProductDto, type ProductFacts, type SelectedVariant } from "./types.js";
2
+ /**
3
+ * The product facts of a card, a product page (with or without a selected variant), a cart line or an order line, so a
4
+ * frontend never needs to know where a surface keeps them. Returns the DTO's own arrays and objects — never copies,
5
+ * filters or mutates them. `brand` is the item's brand: a selected or purchased variant's own, with no fallback to the
6
+ * family brand (null can mean an explicit "none"). An order line that is not a product line throws.
7
+ */
8
+ export declare function productFacts(source: ProductCardDto): ProductFacts;
9
+ export declare function productFacts(source: ProductDto): ProductFacts;
10
+ export declare function productFacts(source: SelectedVariant): ProductFacts;
11
+ export declare function productFacts(source: CartLineDto): ProductFacts;
12
+ export declare function productFacts(source: OrderLineDto): ProductFacts;
@@ -0,0 +1,30 @@
1
+ import { AnalyticsError, } from "./types.js";
2
+ export function productFacts(source) {
3
+ const s = source;
4
+ if (typeof s.type === "string") {
5
+ // An order line (checked first): the generated `type` is a plain string, so the product-line rule is checked here.
6
+ const line = source;
7
+ if (line.type !== "product" || !line.product || !line.variant)
8
+ throw new AnalyticsError(`an order line of type "${line.type}" has no product facts`);
9
+ return {
10
+ brand: line.variant.brand, productBrand: line.product.brand, categories: line.product.categories,
11
+ attributes: line.product.attributes, shortDescription: null,
12
+ };
13
+ }
14
+ if (isObject(s.product) && isObject(s.variant)) {
15
+ // A cart line or a selected-variant wrapper: the same fact paths.
16
+ const { product, variant } = source;
17
+ return {
18
+ brand: variant.brand, productBrand: product.brand, categories: product.categories,
19
+ attributes: product.attributes, shortDescription: product.shortDescription ?? null,
20
+ };
21
+ }
22
+ const flat = source;
23
+ if (!Array.isArray(flat.categories) || !Array.isArray(flat.attributes))
24
+ throw new AnalyticsError("not a product facts source");
25
+ return {
26
+ brand: flat.brand, productBrand: flat.brand, categories: flat.categories, attributes: flat.attributes,
27
+ shortDescription: flat.shortDescription ?? null,
28
+ };
29
+ }
30
+ const isObject = (v) => typeof v === "object" && v !== null;
@@ -0,0 +1,21 @@
1
+ import { type CartDto, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type OrderDto } from "./types.js";
2
+ /** An item and its unit price in cents, so a payload's `value` sums exact cents rather than re-reading floats. */
3
+ export type MappedItem = {
4
+ item: Ga4Item;
5
+ priceCents: bigint;
6
+ };
7
+ /**
8
+ * One GA4 item from a card (an impression: the family sku and from-price), a product with its selected variant, a cart
9
+ * line or a product order line. `item_brand` and the categories come from `productFacts`, so they never disagree with it.
10
+ * A source without a price throws — never `price: 0`.
11
+ */
12
+ export declare function toGa4Item(source: Ga4ItemSource, opts?: Ga4ItemOptions): Ga4Item;
13
+ export declare function mapItem(source: Ga4ItemSource, opts?: Ga4ItemOptions): MappedItem;
14
+ /**
15
+ * A whole GA4 payload: `{ currency, value, items }` for a cart; plus `transaction_id`, `tax` and `shipping` for an order
16
+ * (`purchase`). Each line keeps its own quantity and id; `index` is its position. Only product lines are items.
17
+ * `value` is the sum of the emitted items' rounded prices × quantities, not gross minus tax. An order's `tax` is its
18
+ * recorded VAT and its `shipping` comes from its shipping lines, each netted at its own rate and rounded once.
19
+ */
20
+ export declare function toGa4Ecommerce(source: CartDto, opts?: Ga4SharedOptions): Ga4Ecommerce;
21
+ export declare function toGa4Ecommerce(source: OrderDto, opts?: Ga4SharedOptions): Ga4Purchase;
@@ -0,0 +1,111 @@
1
+ import { add, centsToNumber, netOf, parseDecimal, toCents } from "./decimal.js";
2
+ import { productFacts } from "./facts.js";
3
+ import { AnalyticsError, } from "./types.js";
4
+ /**
5
+ * One GA4 item from a card (an impression: the family sku and from-price), a product with its selected variant, a cart
6
+ * line or a product order line. `item_brand` and the categories come from `productFacts`, so they never disagree with it.
7
+ * A source without a price throws — never `price: 0`.
8
+ */
9
+ export function toGa4Item(source, opts = {}) {
10
+ return mapItem(source, opts).item;
11
+ }
12
+ export function mapItem(source, opts = {}) {
13
+ const line = lineOf(source);
14
+ const facts = productFacts(source);
15
+ const quantity = opts.quantity === undefined ? line.quantity : checkedQuantity(opts.quantity);
16
+ const priceCents = toCents(opts.tax === "include" ? line.gross : netOf(line.gross, line.vatRate));
17
+ const item = { item_id: opts.itemId ?? line.id, item_name: line.name, price: centsToNumber(priceCents), quantity };
18
+ if (facts.brand)
19
+ item.item_brand = facts.brand.label;
20
+ const variant = itemVariant(line.options);
21
+ if (variant !== undefined)
22
+ item.item_variant = variant;
23
+ const picked = opts.pickCategory ? opts.pickCategory(facts.categories) : longest(facts.categories);
24
+ if (picked) {
25
+ const keys = ["item_category", "item_category2", "item_category3", "item_category4", "item_category5"];
26
+ picked.pathNames.slice(0, keys.length).forEach((name, i) => { item[keys[i]] = name; });
27
+ }
28
+ if (opts.index !== undefined)
29
+ item.index = opts.index;
30
+ if (opts.affiliation !== undefined)
31
+ item.affiliation = opts.affiliation;
32
+ if (opts.itemListId !== undefined)
33
+ item.item_list_id = opts.itemListId;
34
+ if (opts.itemListName !== undefined)
35
+ item.item_list_name = opts.itemListName;
36
+ return { item, priceCents };
37
+ }
38
+ export function toGa4Ecommerce(source, opts = {}) {
39
+ const isOrder = "totals" in source && "reference" in source;
40
+ const lines = isOrder ? source.lines.filter(l => l.type === "product") : source.lines;
41
+ // Only the shared options: an options object that also carries item overrides (quantity, itemId, index) must never
42
+ // rewrite every line's identity or quantity.
43
+ const shared = {
44
+ tax: opts.tax, affiliation: opts.affiliation, itemListId: opts.itemListId, itemListName: opts.itemListName, pickCategory: opts.pickCategory,
45
+ };
46
+ const mapped = lines.map((l, index) => mapItem(l, { ...shared, index }));
47
+ const valueCents = mapped.reduce((sum, m) => sum + m.priceCents * BigInt(m.item.quantity), 0n);
48
+ const payload = { currency: source.currency, value: centsToNumber(valueCents), items: mapped.map(m => m.item) };
49
+ if (!isOrder)
50
+ return payload;
51
+ const order = source;
52
+ const include = opts.tax === "include";
53
+ const tax = include ? 0n : order.totals.vat.reduce((sum, v) => sum + toCents(parseDecimal(v.amount)), 0n);
54
+ const shippingLines = order.lines.filter(l => l.type === "shipping");
55
+ const shipping = include
56
+ ? shippingLines.reduce((sum, l) => sum + toCents(parseDecimal(l.lineTotalInc)), 0n)
57
+ : toCents(shippingLines.reduce((sum, l) => add(sum, netOf(parseDecimal(l.lineTotalInc), parseDecimal(l.vatRate))), { n: 0n, d: 1n }));
58
+ return { ...payload, transaction_id: order.id, tax: centsToNumber(tax), shipping: centsToNumber(shipping) };
59
+ }
60
+ function lineOf(source) {
61
+ const s = source;
62
+ if (typeof s.type === "string") {
63
+ const l = source;
64
+ if (l.type !== "product")
65
+ throw new AnalyticsError(`an order line of type "${l.type}" is not an item`);
66
+ return { id: l.sku, name: l.name, gross: parseDecimal(l.unitPriceInc), vatRate: parseDecimal(l.vatRate), quantity: sourceQuantity(l.quantity), options: l.options };
67
+ }
68
+ if (typeof s.variant === "object" && s.variant !== null && "selectedOptions" in s.variant) {
69
+ if ("quantity" in s) {
70
+ const l = source;
71
+ return { id: l.sku, name: l.product.name, ...money(l.price), quantity: sourceQuantity(l.quantity), options: l.variant.selectedOptions };
72
+ }
73
+ const { product, variant } = source;
74
+ if (!variant.price)
75
+ throw new AnalyticsError(`variant ${variant.sku} has no price on this market`);
76
+ return { id: variant.sku, name: product.name, ...money(variant.price), quantity: 1, options: variant.selectedOptions };
77
+ }
78
+ const c = source;
79
+ if (!c.price)
80
+ throw new AnalyticsError(`product ${c.sku} has no price on this market`);
81
+ return { id: c.sku, name: c.name, ...money(c.price), quantity: 1, options: null };
82
+ }
83
+ const money = (p) => ({ gross: parseDecimal(p.amount), vatRate: parseDecimal(p.vatRate) });
84
+ /** The generated quantity is `number | string`. */
85
+ function sourceQuantity(q) {
86
+ const n = typeof q === "number" ? q : Number(q);
87
+ if (!Number.isFinite(n))
88
+ throw new AnalyticsError(`not a quantity: ${q}`);
89
+ return n;
90
+ }
91
+ /** add_to_cart/remove_from_cart: the positive whole number of units added or removed. */
92
+ function checkedQuantity(q) {
93
+ if (!Number.isInteger(q) || q <= 0)
94
+ throw new AnalyticsError(`quantity must be a positive whole number, got ${q}`);
95
+ return q;
96
+ }
97
+ /** Options sorted ordinally by axis (never DTO order: jsonb does not keep key order), `"axis: value"` joined with ", ". */
98
+ function itemVariant(options) {
99
+ if (!options)
100
+ return undefined;
101
+ const axes = Object.keys(options).sort();
102
+ return axes.length === 0 ? undefined : axes.map(a => `${a}: ${options[a]}`).join(", ");
103
+ }
104
+ /** The default pick: the longest path, the first of equals. Never mutates the array. */
105
+ function longest(categories) {
106
+ let best;
107
+ for (const c of categories)
108
+ if (!best || c.pathNames.length > best.pathNames.length)
109
+ best = c;
110
+ return best;
111
+ }
@@ -0,0 +1,86 @@
1
+ import type { components } from "../schema";
2
+ type Schemas = components["schemas"];
3
+ export type ProductCardDto = Schemas["ProductCardDto"];
4
+ export type ProductDto = Schemas["ProductDto"];
5
+ export type VariantDto = Schemas["VariantDto"];
6
+ export type CartDto = Schemas["CartDto"];
7
+ export type CartLineDto = Schemas["CartLineDto"];
8
+ export type OrderDto = Schemas["OrderDto"];
9
+ export type OrderLineDto = Schemas["OrderLineDto"];
10
+ export type BrandDto = Schemas["BrandDto"];
11
+ export type CategoryRefDto = Schemas["CategoryRefDto"];
12
+ export type AttributeDto = Schemas["AttributeDto"];
13
+ export type PriceDto = Schemas["PriceDto"];
14
+ /** A product page with the variant the shopper selected. */
15
+ export type SelectedVariant = {
16
+ product: ProductDto;
17
+ variant: VariantDto;
18
+ };
19
+ /** The product facts of one source, whatever surface it came from (product facts spec §5.0). */
20
+ export type ProductFacts = {
21
+ /** The item's brand: a card's (family) brand, else the selected or purchased variant's — never a fallback to the family's. */
22
+ brand: BrandDto | null;
23
+ /** The family brand on every source (on a card, the same as `brand`). */
24
+ productBrand: BrandDto | null;
25
+ /** Classification memberships with no primary meaning; `categories[0]` may be any root. */
26
+ categories: CategoryRefDto[];
27
+ /** As the source carries them: a `ProductDto` may include `html` attributes; cards, cart and order lines do not. */
28
+ attributes: AttributeDto[];
29
+ /** Null on order lines. */
30
+ shortDescription: string | null;
31
+ };
32
+ /** A source the analytics helpers cannot map: a non-product order line, an unpriced variant, an invalid quantity. */
33
+ export declare class AnalyticsError extends Error {
34
+ name: string;
35
+ }
36
+ /** One GA4 ecommerce item. Members without a value are omitted, never `undefined`. */
37
+ export type Ga4Item = {
38
+ item_id: string;
39
+ item_name: string;
40
+ item_brand?: string;
41
+ item_variant?: string;
42
+ item_category?: string;
43
+ item_category2?: string;
44
+ item_category3?: string;
45
+ item_category4?: string;
46
+ item_category5?: string;
47
+ /** Net of VAT by default (GA4 defines price without tax); gross with `tax: "include"`. */
48
+ price: number;
49
+ quantity: number;
50
+ index?: number;
51
+ affiliation?: string;
52
+ item_list_id?: string;
53
+ item_list_name?: string;
54
+ };
55
+ /** Options shared by every item of a payload. */
56
+ export type Ga4SharedOptions = {
57
+ /** `"exclude"` (default): prices and value net of VAT, as GA4 defines them. `"include"`: gross, for shops that report gross revenue. */
58
+ tax?: "exclude" | "include";
59
+ affiliation?: string;
60
+ itemListId?: string;
61
+ itemListName?: string;
62
+ /** Which category feeds `item_category`…`item_category5`. Default: the longest path, ties in the order given. Returning
63
+ * `undefined` means no category fields — never a fallback to the default. Categories carry no primary meaning. */
64
+ pickCategory?: (categories: readonly CategoryRefDto[]) => CategoryRefDto | undefined;
65
+ };
66
+ /** One item's options: `quantity` overrides the source's (for add/remove events: the positive number of units added or
67
+ * removed), `itemId` its id (e.g. for family-level reporting), `index` its position in a list. */
68
+ export type Ga4ItemOptions = Ga4SharedOptions & {
69
+ quantity?: number;
70
+ itemId?: string;
71
+ index?: number;
72
+ };
73
+ export type Ga4ItemSource = ProductCardDto | SelectedVariant | CartLineDto | OrderLineDto;
74
+ /** `view_cart`, `begin_checkout` and the like. */
75
+ export type Ga4Ecommerce = {
76
+ currency: string;
77
+ value: number;
78
+ items: Ga4Item[];
79
+ };
80
+ /** `purchase`: the order's payload. */
81
+ export type Ga4Purchase = Ga4Ecommerce & {
82
+ transaction_id: string;
83
+ tax: number;
84
+ shipping: number;
85
+ };
86
+ export {};
@@ -0,0 +1,4 @@
1
+ /** A source the analytics helpers cannot map: a non-product order line, an unpriced variant, an invalid quantity. */
2
+ export class AnalyticsError extends Error {
3
+ name = "AnalyticsError";
4
+ }
package/dist/index.d.ts CHANGED
@@ -56,4 +56,7 @@ export { brandHead, collectionHead, productHead, toNextMetadata } from "./seo/he
56
56
  export { gtinJsonLd } from "./gtin.js";
57
57
  export { productJsonLd } from "./seo/product.js";
58
58
  export { jsonLdScript } from "./seo/serialize.js";
59
+ export { productFacts } from "./analytics/facts.js";
60
+ export { toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
61
+ export { AnalyticsError, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type ProductFacts, type SelectedVariant, } from "./analytics/types.js";
59
62
  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
@@ -45,4 +45,7 @@ export { brandHead, collectionHead, productHead, toNextMetadata } from "./seo/he
45
45
  export { gtinJsonLd } from "./gtin.js";
46
46
  export { productJsonLd } from "./seo/product.js";
47
47
  export { jsonLdScript } from "./seo/serialize.js";
48
+ export { productFacts } from "./analytics/facts.js";
49
+ export { toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
50
+ export { AnalyticsError, } from "./analytics/types.js";
48
51
  export { SeoError, } from "./seo/types.js";
package/dist/schema.d.ts CHANGED
@@ -496,11 +496,16 @@ export interface components {
496
496
  variant: components["schemas"]["CartLineVariantDto"];
497
497
  };
498
498
  CartLineProductDto: {
499
+ attributes: components["schemas"]["AttributeDto"][];
500
+ brand: null | components["schemas"]["BrandDto"];
501
+ categories: components["schemas"]["CategoryRefDto"][];
499
502
  id: string;
500
503
  name: string;
504
+ shortDescription: null | string;
501
505
  slug: string;
502
506
  };
503
507
  CartLineVariantDto: {
508
+ brand: null | components["schemas"]["BrandDto"];
504
509
  id: string;
505
510
  selectedOptions: {
506
511
  [key: string]: string;
@@ -833,15 +838,25 @@ export interface components {
833
838
  options: {
834
839
  [key: string]: string;
835
840
  };
841
+ product: null | components["schemas"]["OrderLineProductDto"];
836
842
  productId: null | string;
837
843
  /** Format: int32 */
838
844
  quantity: number | string;
839
845
  sku: string;
840
846
  type: string;
841
847
  unitPriceInc: string;
848
+ variant: null | components["schemas"]["OrderLineVariantDto"];
842
849
  variantId: null | string;
843
850
  vatRate: string;
844
851
  };
852
+ OrderLineProductDto: {
853
+ attributes: components["schemas"]["AttributeDto"][];
854
+ brand: null | components["schemas"]["BrandDto"];
855
+ categories: components["schemas"]["CategoryRefDto"][];
856
+ };
857
+ OrderLineVariantDto: {
858
+ brand: null | components["schemas"]["BrandDto"];
859
+ };
845
860
  OrderShippingDto: {
846
861
  carrier: string;
847
862
  deliveryTime: null | components["schemas"]["DeliveryTimeDto"];
@@ -887,8 +902,10 @@ export interface components {
887
902
  vatRate: string;
888
903
  };
889
904
  ProductCardDto: {
905
+ attributes: components["schemas"]["AttributeDto"][];
890
906
  brand: null | components["schemas"]["BrandDto"];
891
907
  buyable: boolean;
908
+ categories: components["schemas"]["CategoryRefDto"][];
892
909
  collections: string[];
893
910
  id: string;
894
911
  image: null | components["schemas"]["MediaDescriptorDto"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asstio-dev/storefront",
3
- "version": "0.16.1",
3
+ "version": "0.17.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\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\"]) 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\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"AnalyticsError\\\"]) 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
@@ -496,11 +496,16 @@ export interface components {
496
496
  variant: components["schemas"]["CartLineVariantDto"];
497
497
  };
498
498
  CartLineProductDto: {
499
+ attributes: components["schemas"]["AttributeDto"][];
500
+ brand: null | components["schemas"]["BrandDto"];
501
+ categories: components["schemas"]["CategoryRefDto"][];
499
502
  id: string;
500
503
  name: string;
504
+ shortDescription: null | string;
501
505
  slug: string;
502
506
  };
503
507
  CartLineVariantDto: {
508
+ brand: null | components["schemas"]["BrandDto"];
504
509
  id: string;
505
510
  selectedOptions: {
506
511
  [key: string]: string;
@@ -833,15 +838,25 @@ export interface components {
833
838
  options: {
834
839
  [key: string]: string;
835
840
  };
841
+ product: null | components["schemas"]["OrderLineProductDto"];
836
842
  productId: null | string;
837
843
  /** Format: int32 */
838
844
  quantity: number | string;
839
845
  sku: string;
840
846
  type: string;
841
847
  unitPriceInc: string;
848
+ variant: null | components["schemas"]["OrderLineVariantDto"];
842
849
  variantId: null | string;
843
850
  vatRate: string;
844
851
  };
852
+ OrderLineProductDto: {
853
+ attributes: components["schemas"]["AttributeDto"][];
854
+ brand: null | components["schemas"]["BrandDto"];
855
+ categories: components["schemas"]["CategoryRefDto"][];
856
+ };
857
+ OrderLineVariantDto: {
858
+ brand: null | components["schemas"]["BrandDto"];
859
+ };
845
860
  OrderShippingDto: {
846
861
  carrier: string;
847
862
  deliveryTime: null | components["schemas"]["DeliveryTimeDto"];
@@ -887,8 +902,10 @@ export interface components {
887
902
  vatRate: string;
888
903
  };
889
904
  ProductCardDto: {
905
+ attributes: components["schemas"]["AttributeDto"][];
890
906
  brand: null | components["schemas"]["BrandDto"];
891
907
  buyable: boolean;
908
+ categories: components["schemas"]["CategoryRefDto"][];
892
909
  collections: string[];
893
910
  id: string;
894
911
  image: null | components["schemas"]["MediaDescriptorDto"];