@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 +88 -0
- package/dist/analytics/decimal.d.ts +13 -0
- package/dist/analytics/decimal.js +29 -0
- package/dist/analytics/facts.d.ts +12 -0
- package/dist/analytics/facts.js +30 -0
- package/dist/analytics/ga4.d.ts +21 -0
- package/dist/analytics/ga4.js +111 -0
- package/dist/analytics/types.d.ts +86 -0
- package/dist/analytics/types.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/schema.d.ts +17 -0
- package/package.json +2 -2
- package/src/schema.d.ts +17 -0
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 {};
|
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.
|
|
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"];
|