@decocms/apps-commerce 7.2.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/package.json +44 -0
- package/src/app-types.ts +62 -0
- package/src/manifest-utils.test.ts +57 -0
- package/src/manifest-utils.ts +38 -0
- package/src/registry.ts +38 -0
- package/src/resolve.ts +114 -0
- package/src/sdk/analytics.ts +151 -0
- package/src/sdk/formatPrice.test.ts +59 -0
- package/src/sdk/formatPrice.ts +44 -0
- package/src/sdk/url.test.ts +125 -0
- package/src/sdk/url.ts +52 -0
- package/src/sdk/useOffer.ts +64 -0
- package/src/sdk/useVariantPossibilities.ts +43 -0
- package/src/types/commerce.ts +1228 -0
- package/src/utils/canonical.ts +8 -0
- package/src/utils/constants.ts +8 -0
- package/src/utils/filters.test.ts +34 -0
- package/src/utils/filters.ts +10 -0
- package/src/utils/productToAnalyticsItem.ts +63 -0
- package/src/utils/stateByZip.test.ts +56 -0
- package/src/utils/stateByZip.ts +50 -0
- package/tsconfig.json +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decocms/apps-commerce",
|
|
3
|
+
"version": "7.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deco commerce apps: shared types, app-registry, and portable commerce utilities",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/decocms/blocks.git",
|
|
9
|
+
"directory": "packages/apps-commerce"
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/types/commerce.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
"./types": "./src/types/commerce.ts",
|
|
14
|
+
"./app-types": "./src/app-types.ts",
|
|
15
|
+
"./resolve": "./src/resolve.ts",
|
|
16
|
+
"./manifest-utils": "./src/manifest-utils.ts",
|
|
17
|
+
"./utils/*": "./src/utils/*.ts",
|
|
18
|
+
"./sdk/*": "./src/sdk/*.ts",
|
|
19
|
+
"./registry": "./src/registry.ts"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"test": "vitest run --root ../.. packages/apps-commerce/",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"lint:unused": "knip"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@decocms/blocks": "7.2.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": "^19.0.0",
|
|
32
|
+
"react-dom": "^19.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "^19.0.0",
|
|
36
|
+
"@types/react-dom": "^19.0.0",
|
|
37
|
+
"knip": "^5.86.0",
|
|
38
|
+
"typescript": "^5.9.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"registry": "https://registry.npmjs.org",
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/app-types.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the Deco app system on TanStack Start.
|
|
3
|
+
*
|
|
4
|
+
* Each app (vtex, shopify, resend, etc.) exports a `configure` function
|
|
5
|
+
* from its `mod.ts` that returns an `AppDefinition`.
|
|
6
|
+
*
|
|
7
|
+
* The framework's `autoconfigApps()` calls these generically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ComponentType } from "react";
|
|
11
|
+
|
|
12
|
+
export type AppHandler = (props: any, request: Request) => Promise<any>;
|
|
13
|
+
|
|
14
|
+
export interface SectionModule {
|
|
15
|
+
default: ComponentType<Record<string, unknown>>;
|
|
16
|
+
loader?: (...args: unknown[]) => Promise<unknown> | unknown;
|
|
17
|
+
LoadingFallback?: ComponentType;
|
|
18
|
+
ErrorFallback?: ComponentType<{ error: Error }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AppManifest {
|
|
22
|
+
name: string;
|
|
23
|
+
/** Module namespace imports keyed by manifest path (e.g. "vtex/loaders/catalog"). */
|
|
24
|
+
loaders: Record<string, Record<string, unknown>>;
|
|
25
|
+
/** Module namespace imports keyed by manifest path (e.g. "vtex/actions/checkout"). */
|
|
26
|
+
actions: Record<string, Record<string, unknown>>;
|
|
27
|
+
/** Lazy-loaded section components keyed by manifest path (e.g. "vtex/sections/Analytics/Vtex"). */
|
|
28
|
+
sections?: Record<string, () => Promise<SectionModule>>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type AppMiddleware = (request: Request, next: () => Promise<Response>) => Promise<Response>;
|
|
32
|
+
|
|
33
|
+
export interface AppDefinition<TState = unknown> {
|
|
34
|
+
name: string;
|
|
35
|
+
manifest: AppManifest;
|
|
36
|
+
state: TState;
|
|
37
|
+
middleware?: AppMiddleware;
|
|
38
|
+
dependencies?: AppDefinition[];
|
|
39
|
+
resolvables?: Record<string, { __resolveType: string; [key: string]: unknown }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ResolveSecretFn = (value: unknown, envKey: string) => Promise<string | null>;
|
|
43
|
+
|
|
44
|
+
export interface AppPreview {
|
|
45
|
+
Component: ComponentType<Record<string, unknown>>;
|
|
46
|
+
props: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Standard contract for Deco apps with auto-configuration.
|
|
51
|
+
*
|
|
52
|
+
* Each app exports `configure` from its `mod.ts`.
|
|
53
|
+
* Apps that need invoke handlers (e.g. resend) also export a `handlers` map.
|
|
54
|
+
* The framework discovers and calls these generically.
|
|
55
|
+
*/
|
|
56
|
+
export interface AppModContract<TState = unknown> {
|
|
57
|
+
configure: (
|
|
58
|
+
blockData: any,
|
|
59
|
+
resolveSecret: ResolveSecretFn,
|
|
60
|
+
) => Promise<AppDefinition<TState> | null>;
|
|
61
|
+
handlers?: Record<string, AppHandler>;
|
|
62
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { AppManifest } from "./app-types";
|
|
3
|
+
import { extractHandlers } from "./manifest-utils";
|
|
4
|
+
|
|
5
|
+
describe("extractHandlers", () => {
|
|
6
|
+
it("flattens module namespaces into individual handler entries", () => {
|
|
7
|
+
const searchProducts = () => {};
|
|
8
|
+
const getProductById = () => {};
|
|
9
|
+
const addItem = () => {};
|
|
10
|
+
|
|
11
|
+
const manifest: AppManifest = {
|
|
12
|
+
name: "test",
|
|
13
|
+
loaders: {
|
|
14
|
+
"test/loaders/catalog": {
|
|
15
|
+
searchProducts,
|
|
16
|
+
getProductById,
|
|
17
|
+
SOME_CONSTANT: "not a function",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
actions: {
|
|
21
|
+
"test/actions/cart": {
|
|
22
|
+
addItem,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const handlers = extractHandlers(manifest);
|
|
28
|
+
|
|
29
|
+
expect(handlers["test/loaders/catalog/searchProducts"]).toBe(searchProducts);
|
|
30
|
+
expect(handlers["test/loaders/catalog/getProductById"]).toBe(getProductById);
|
|
31
|
+
expect(handlers).not.toHaveProperty("test/loaders/catalog/SOME_CONSTANT");
|
|
32
|
+
expect(handlers["test/actions/cart/addItem"]).toBe(addItem);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns empty object for empty manifest", () => {
|
|
36
|
+
const manifest: AppManifest = { name: "empty", loaders: {}, actions: {} };
|
|
37
|
+
expect(extractHandlers(manifest)).toEqual({});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("handles multiple modules per category", () => {
|
|
41
|
+
const fn1 = () => {};
|
|
42
|
+
const fn2 = () => {};
|
|
43
|
+
|
|
44
|
+
const manifest: AppManifest = {
|
|
45
|
+
name: "multi",
|
|
46
|
+
loaders: {
|
|
47
|
+
"app/loaders/a": { fn1 },
|
|
48
|
+
"app/loaders/b": { fn2 },
|
|
49
|
+
},
|
|
50
|
+
actions: {},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handlers = extractHandlers(manifest);
|
|
54
|
+
expect(handlers["app/loaders/a/fn1"]).toBe(fn1);
|
|
55
|
+
expect(handlers["app/loaders/b/fn2"]).toBe(fn2);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for extracting individual handlers from app manifests.
|
|
3
|
+
*
|
|
4
|
+
* Used by the framework to flatten module namespace imports into
|
|
5
|
+
* individual handler functions for setInvokeLoaders() / setInvokeActions().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AppManifest } from "./app-types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract individual handler functions from a manifest's module namespaces.
|
|
12
|
+
*
|
|
13
|
+
* Given a manifest with:
|
|
14
|
+
* loaders: { "vtex/loaders/catalog": { searchProducts, getProductByIdOrSku } }
|
|
15
|
+
*
|
|
16
|
+
* Returns:
|
|
17
|
+
* { "vtex/loaders/catalog/searchProducts": searchProducts, ... }
|
|
18
|
+
*/
|
|
19
|
+
type AnyFn = (...args: never[]) => unknown;
|
|
20
|
+
|
|
21
|
+
export function extractHandlers(manifest: AppManifest): Record<string, AnyFn> {
|
|
22
|
+
const result: Record<string, AnyFn> = {};
|
|
23
|
+
|
|
24
|
+
for (const category of ["loaders", "actions"] as const) {
|
|
25
|
+
const modules = manifest[category];
|
|
26
|
+
for (const [moduleKey, moduleNamespace] of Object.entries(modules)) {
|
|
27
|
+
for (const [exportName, handler] of Object.entries(
|
|
28
|
+
moduleNamespace as Record<string, unknown>,
|
|
29
|
+
)) {
|
|
30
|
+
if (typeof handler === "function") {
|
|
31
|
+
result[`${moduleKey}/${exportName}`] = handler as AnyFn;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result;
|
|
38
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the app-registry pattern consumed by
|
|
3
|
+
* `@decocms/blocks-admin/apps/autoconfig`'s `autoconfigApps()`.
|
|
4
|
+
*
|
|
5
|
+
* Each platform package with a registrable app (e.g. `@decocms/apps-vtex`)
|
|
6
|
+
* exports its own single-entry registry from its own `./registry` subpath —
|
|
7
|
+
* sites import only the platform entries they actually use and compose their
|
|
8
|
+
* own array. This file holds only the shared shape, not a static catalogue
|
|
9
|
+
* (previously it was — see git history — but that required every platform to
|
|
10
|
+
* live in one package; split by platform, no single package can hold a
|
|
11
|
+
* complete static array without depending on every other platform package).
|
|
12
|
+
*
|
|
13
|
+
* Import path: `@decocms/apps-commerce/registry`
|
|
14
|
+
*
|
|
15
|
+
* NOTE: the type is inlined rather than imported from
|
|
16
|
+
* `@decocms/blocks-admin/apps` so this file ships in `@decocms/apps-commerce`
|
|
17
|
+
* against any installed `@decocms/blocks-admin` version. Once callers pin a
|
|
18
|
+
* blocks-admin version that exposes `AppRegistry`, the type can be swapped.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface AppRegistryEntry {
|
|
22
|
+
/** Block key in the decofile, e.g. "deco-shopify". */
|
|
23
|
+
blockKey: string;
|
|
24
|
+
/** Lazy dynamic import of the app's mod module. */
|
|
25
|
+
module: () => Promise<any>;
|
|
26
|
+
/** Human-readable name shown in admin install UI. */
|
|
27
|
+
displayName?: string;
|
|
28
|
+
/** Icon URL (absolute or site-relative) shown in admin install UI. */
|
|
29
|
+
icon?: string;
|
|
30
|
+
/** Grouping label, e.g. "commerce", "email", "analytics". */
|
|
31
|
+
category?: string;
|
|
32
|
+
/** Short summary shown in admin install UI. */
|
|
33
|
+
description?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type AppRegistry = readonly AppRegistryEntry[];
|
|
37
|
+
|
|
38
|
+
export type { AppRegistryEntry, AppRegistry };
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App composition utilities.
|
|
3
|
+
*
|
|
4
|
+
* Merges manifests and chains middleware from multiple AppDefinitions
|
|
5
|
+
* into a single resolved structure.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { resolveApps } from "@decocms/apps/commerce/resolve";
|
|
10
|
+
* import * as vtexApp from "@decocms/apps/vtex/mod";
|
|
11
|
+
* import * as resendApp from "@decocms/apps/resend/mod";
|
|
12
|
+
*
|
|
13
|
+
* const apps = await Promise.all([
|
|
14
|
+
* vtexApp.configure(blocks.vtex, resolveSecret),
|
|
15
|
+
* resendApp.configure(blocks.resend, resolveSecret),
|
|
16
|
+
* ]);
|
|
17
|
+
*
|
|
18
|
+
* const resolved = resolveApps(apps.filter(Boolean));
|
|
19
|
+
* // resolved.manifest — merged manifest from all apps
|
|
20
|
+
* // resolved.middleware — chained middleware (or undefined)
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { AppDefinition, AppManifest, AppMiddleware } from "./app-types";
|
|
25
|
+
|
|
26
|
+
export interface ResolvedApps {
|
|
27
|
+
manifest: AppManifest;
|
|
28
|
+
middleware: AppMiddleware | undefined;
|
|
29
|
+
resolvables: Record<string, { __resolveType: string; [key: string]: unknown }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve an array of app definitions into a single merged structure.
|
|
34
|
+
*
|
|
35
|
+
* - Manifests are merged (loaders/actions from all apps).
|
|
36
|
+
* - Middleware is chained in array order (first app runs outermost).
|
|
37
|
+
*/
|
|
38
|
+
export function resolveApps(apps: AppDefinition[]): ResolvedApps {
|
|
39
|
+
const mergedManifest: AppManifest = {
|
|
40
|
+
name: "resolved",
|
|
41
|
+
loaders: {},
|
|
42
|
+
actions: {},
|
|
43
|
+
sections: {},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const middlewares: AppMiddleware[] = [];
|
|
47
|
+
const resolvables: Record<string, { __resolveType: string; [key: string]: unknown }> = {};
|
|
48
|
+
|
|
49
|
+
for (const app of flattenDependencies(apps)) {
|
|
50
|
+
Object.assign(mergedManifest.loaders, app.manifest.loaders);
|
|
51
|
+
Object.assign(mergedManifest.actions, app.manifest.actions);
|
|
52
|
+
|
|
53
|
+
if (app.manifest.sections) {
|
|
54
|
+
Object.assign(mergedManifest.sections!, app.manifest.sections);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (app.resolvables) {
|
|
58
|
+
Object.assign(resolvables, app.resolvables);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (app.middleware) {
|
|
62
|
+
middlewares.push(app.middleware);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
manifest: mergedManifest,
|
|
68
|
+
middleware: middlewares.length > 0 ? chainMiddleware(middlewares) : undefined,
|
|
69
|
+
resolvables,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Flatten the dependency graph (depth-first, dependencies before dependents).
|
|
75
|
+
* Deduplicates by app name.
|
|
76
|
+
*/
|
|
77
|
+
function flattenDependencies(apps: AppDefinition[]): AppDefinition[] {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
const result: AppDefinition[] = [];
|
|
80
|
+
|
|
81
|
+
function visit(app: AppDefinition) {
|
|
82
|
+
if (seen.has(app.name)) return;
|
|
83
|
+
seen.add(app.name);
|
|
84
|
+
|
|
85
|
+
if (app.dependencies) {
|
|
86
|
+
for (const dep of app.dependencies) {
|
|
87
|
+
visit(dep);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
result.push(app);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const app of apps) {
|
|
95
|
+
visit(app);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Chain multiple middleware functions into a single one.
|
|
103
|
+
* First middleware in the array runs outermost (wraps the rest).
|
|
104
|
+
*/
|
|
105
|
+
function chainMiddleware(middlewares: AppMiddleware[]): AppMiddleware {
|
|
106
|
+
return async (request, next) => {
|
|
107
|
+
const run = async (i: number): Promise<Response> => {
|
|
108
|
+
if (i < 0) return next();
|
|
109
|
+
return middlewares[i](request, () => run(i - 1));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return run(middlewares.length - 1);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics mappers — convert schema.org Product to GA4 AnalyticsItem.
|
|
3
|
+
*
|
|
4
|
+
* These are the generic, platform-independent mappers. Sites can wrap them
|
|
5
|
+
* to add custom fields (sellerP, etc.) via the `extend` option.
|
|
6
|
+
*/
|
|
7
|
+
import type { BreadcrumbList, Product } from "../types/commerce";
|
|
8
|
+
|
|
9
|
+
export interface AnalyticsItem {
|
|
10
|
+
item_id?: string;
|
|
11
|
+
item_name?: string;
|
|
12
|
+
affiliation?: string;
|
|
13
|
+
coupon?: string;
|
|
14
|
+
discount?: number;
|
|
15
|
+
index?: number;
|
|
16
|
+
item_group_id?: string;
|
|
17
|
+
item_url?: string;
|
|
18
|
+
item_brand?: string;
|
|
19
|
+
item_category?: string;
|
|
20
|
+
item_category2?: string;
|
|
21
|
+
item_category3?: string;
|
|
22
|
+
item_category4?: string;
|
|
23
|
+
item_category5?: string;
|
|
24
|
+
item_list_id?: string;
|
|
25
|
+
item_list_name?: string;
|
|
26
|
+
item_variant?: string;
|
|
27
|
+
location_id?: string;
|
|
28
|
+
price?: number;
|
|
29
|
+
quantity: number;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mapCategoriesToAnalyticsCategories(categories: string[]): Record<string, string> {
|
|
34
|
+
return categories.slice(0, 5).reduce(
|
|
35
|
+
(result, category, index) => {
|
|
36
|
+
result[`item_category${index === 0 ? "" : index + 1}`] = category;
|
|
37
|
+
return result;
|
|
38
|
+
},
|
|
39
|
+
{} as Record<string, string>,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mapProductCategoryToAnalyticsCategories(category: string): Record<string, string> {
|
|
44
|
+
return category.split(">").reduce(
|
|
45
|
+
(result, cat, index) => {
|
|
46
|
+
result[`item_category${index === 0 ? "" : index}`] = cat.trim();
|
|
47
|
+
return result;
|
|
48
|
+
},
|
|
49
|
+
{} as Record<string, string>,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MapProductToAnalyticsItemOptions {
|
|
54
|
+
product: Product;
|
|
55
|
+
breadcrumbList?: BreadcrumbList;
|
|
56
|
+
price?: number;
|
|
57
|
+
lowPrice?: number;
|
|
58
|
+
listPrice?: number;
|
|
59
|
+
index?: number;
|
|
60
|
+
quantity?: number;
|
|
61
|
+
coupon?: string;
|
|
62
|
+
/** Extend the result with custom fields (e.g., sellerP, sellerName) */
|
|
63
|
+
extend?: (product: Product, base: AnalyticsItem) => Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function mapProductToAnalyticsItem(opts: MapProductToAnalyticsItemOptions): AnalyticsItem {
|
|
67
|
+
const {
|
|
68
|
+
product,
|
|
69
|
+
breadcrumbList,
|
|
70
|
+
price,
|
|
71
|
+
lowPrice,
|
|
72
|
+
listPrice,
|
|
73
|
+
index = 0,
|
|
74
|
+
quantity = 1,
|
|
75
|
+
coupon = "",
|
|
76
|
+
extend,
|
|
77
|
+
} = opts;
|
|
78
|
+
|
|
79
|
+
const { name, productID, inProductGroupWithID, isVariantOf, url, sku } = product;
|
|
80
|
+
|
|
81
|
+
const categories = breadcrumbList?.itemListElement
|
|
82
|
+
? mapCategoriesToAnalyticsCategories(
|
|
83
|
+
breadcrumbList.itemListElement.map(({ name: n }) => n ?? "").filter(Boolean),
|
|
84
|
+
)
|
|
85
|
+
: mapProductCategoryToAnalyticsCategories(product.category ?? "");
|
|
86
|
+
|
|
87
|
+
const base: AnalyticsItem = {
|
|
88
|
+
item_id: productID,
|
|
89
|
+
item_group_id: inProductGroupWithID,
|
|
90
|
+
quantity,
|
|
91
|
+
coupon,
|
|
92
|
+
price: lowPrice,
|
|
93
|
+
index,
|
|
94
|
+
item_variant: sku,
|
|
95
|
+
discount: Number((price && listPrice ? listPrice - price : 0).toFixed(2)),
|
|
96
|
+
item_name: isVariantOf?.name ?? name ?? "",
|
|
97
|
+
item_brand: product.brand?.name ?? "",
|
|
98
|
+
item_url: url,
|
|
99
|
+
...categories,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (extend) {
|
|
103
|
+
return { ...base, ...extend(product, base) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return base;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface MapProductToAnalyticsItemListOptions {
|
|
110
|
+
product: Product;
|
|
111
|
+
breadcrumbList?: BreadcrumbList;
|
|
112
|
+
price?: number;
|
|
113
|
+
listPrice?: number;
|
|
114
|
+
index?: number;
|
|
115
|
+
quantity?: number;
|
|
116
|
+
coupon?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function mapProductToAnalyticsItemList(
|
|
120
|
+
opts: MapProductToAnalyticsItemListOptions,
|
|
121
|
+
): AnalyticsItem {
|
|
122
|
+
const { product, breadcrumbList, price, listPrice, index = 0, quantity = 1, coupon = "" } = opts;
|
|
123
|
+
|
|
124
|
+
const { name, productID, inProductGroupWithID, isVariantOf, url } = product;
|
|
125
|
+
|
|
126
|
+
const categories = breadcrumbList?.itemListElement
|
|
127
|
+
? mapCategoriesToAnalyticsCategories(
|
|
128
|
+
breadcrumbList.itemListElement.map(({ name: n }) => n ?? "").filter(Boolean),
|
|
129
|
+
)
|
|
130
|
+
: mapProductCategoryToAnalyticsCategories(product.category ?? "");
|
|
131
|
+
|
|
132
|
+
const finalPrice = typeof price === "number" ? price : 0;
|
|
133
|
+
const discount =
|
|
134
|
+
typeof listPrice === "number" && typeof price === "number" ? Math.max(0, listPrice - price) : 0;
|
|
135
|
+
|
|
136
|
+
const itemId = inProductGroupWithID ?? isVariantOf?.productGroupID ?? productID;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
item_id: itemId,
|
|
140
|
+
item_group_id: inProductGroupWithID,
|
|
141
|
+
quantity,
|
|
142
|
+
coupon,
|
|
143
|
+
price: finalPrice,
|
|
144
|
+
index,
|
|
145
|
+
discount: Number(discount.toFixed(2)),
|
|
146
|
+
item_name: isVariantOf?.name ?? name ?? "",
|
|
147
|
+
item_brand: product.brand?.name ?? "",
|
|
148
|
+
item_url: url,
|
|
149
|
+
...categories,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatPrice, formatPriceRange } from "./formatPrice";
|
|
3
|
+
|
|
4
|
+
describe("formatPrice", () => {
|
|
5
|
+
it("formats a number to BRL by default", () => {
|
|
6
|
+
const result = formatPrice(123.45);
|
|
7
|
+
// Intl-formatted with NBSP between "R$" and the number; just check the
|
|
8
|
+
// digits are present.
|
|
9
|
+
expect(result).toMatch(/123,45/);
|
|
10
|
+
expect(result).toMatch(/R\$/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns null for undefined/null", () => {
|
|
14
|
+
expect(formatPrice(undefined)).toBeNull();
|
|
15
|
+
expect(formatPrice(null)).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns null for non-finite numbers", () => {
|
|
19
|
+
expect(formatPrice(Number.NaN)).toBeNull();
|
|
20
|
+
expect(formatPrice(Number.POSITIVE_INFINITY)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("respects currency + locale overrides", () => {
|
|
24
|
+
const result = formatPrice(99, "USD", "en-US");
|
|
25
|
+
expect(result).toMatch(/\$99/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("formatPriceRange", () => {
|
|
30
|
+
it("formats a min:max string with the default currency", () => {
|
|
31
|
+
const result = formatPriceRange("10:50");
|
|
32
|
+
expect(result).toMatch(/10,00/);
|
|
33
|
+
expect(result).toMatch(/50,00/);
|
|
34
|
+
expect(result).toContain(" - ");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("respects currency / locale overrides", () => {
|
|
38
|
+
const result = formatPriceRange("10:50", "USD", "en-US");
|
|
39
|
+
expect(result).toMatch(/\$10/);
|
|
40
|
+
expect(result).toMatch(/\$50/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("respects a custom separator", () => {
|
|
44
|
+
const result = formatPriceRange("10:50", "BRL", "pt-BR", " a ");
|
|
45
|
+
expect(result).toContain(" a ");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns the input unchanged when there's no colon", () => {
|
|
49
|
+
expect(formatPriceRange("not-a-range")).toBe("not-a-range");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns the input unchanged when bounds aren't numeric", () => {
|
|
53
|
+
expect(formatPriceRange("foo:bar")).toBe("foo:bar");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns the input unchanged for non-string input", () => {
|
|
57
|
+
expect(formatPriceRange(undefined as unknown as string)).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const formatters = new Map<string, Intl.NumberFormat>();
|
|
2
|
+
|
|
3
|
+
const formatter = (currency: string, locale: string) => {
|
|
4
|
+
const key = `${currency}::${locale}`;
|
|
5
|
+
|
|
6
|
+
if (!formatters.has(key)) {
|
|
7
|
+
formatters.set(
|
|
8
|
+
key,
|
|
9
|
+
new Intl.NumberFormat(locale, {
|
|
10
|
+
style: "currency",
|
|
11
|
+
currency,
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return formatters.get(key)!;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const formatPrice = (
|
|
20
|
+
price: number | undefined | null,
|
|
21
|
+
currency = "BRL",
|
|
22
|
+
locale = "pt-BR",
|
|
23
|
+
) => (price != null && Number.isFinite(price) ? formatter(currency, locale).format(price) : null);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Formats a "min:max" range string (as VTEX/Shopify Intelligent Search
|
|
27
|
+
* facets emit) into a localised price range like "R$ 10,00 - R$ 50,00".
|
|
28
|
+
*
|
|
29
|
+
* Returns the original input untouched if either bound fails to parse,
|
|
30
|
+
* so this never crashes a filter UI on a bad facet value.
|
|
31
|
+
*/
|
|
32
|
+
export const formatPriceRange = (
|
|
33
|
+
value: string,
|
|
34
|
+
currency = "BRL",
|
|
35
|
+
locale = "pt-BR",
|
|
36
|
+
separator = " - ",
|
|
37
|
+
): string => {
|
|
38
|
+
if (typeof value !== "string" || !value.includes(":")) return value;
|
|
39
|
+
const [rawMin, rawMax] = value.split(":");
|
|
40
|
+
const min = formatPrice(Number(rawMin), currency, locale);
|
|
41
|
+
const max = formatPrice(Number(rawMax), currency, locale);
|
|
42
|
+
if (min == null || max == null) return value;
|
|
43
|
+
return `${min}${separator}${max}`;
|
|
44
|
+
};
|