@decocms/apps-magento 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 +47 -0
- package/src/README.md +65 -0
- package/src/__tests__/cart.test.ts +139 -0
- package/src/__tests__/client.test.ts +252 -0
- package/src/__tests__/features.test.ts +51 -0
- package/src/__tests__/graphql.test.ts +183 -0
- package/src/__tests__/newsletter-subscribe.test.ts +86 -0
- package/src/__tests__/product-stockAlert.test.ts +80 -0
- package/src/__tests__/stringifySearchCriteria.test.ts +56 -0
- package/src/__tests__/transform.test.ts +346 -0
- package/src/__tests__/user-loader.test.ts +134 -0
- package/src/__tests__/wishlist-actions.test.ts +140 -0
- package/src/__tests__/wishlist-loader.test.ts +97 -0
- package/src/actions/newsletter/subscribe.ts +40 -0
- package/src/actions/product/stockAlert.ts +67 -0
- package/src/actions/wishlist/addItem.ts +57 -0
- package/src/actions/wishlist/removeItem.ts +59 -0
- package/src/client.ts +228 -0
- package/src/index.ts +11 -0
- package/src/loaders/cart.ts +64 -0
- package/src/loaders/features.ts +16 -0
- package/src/loaders/user.ts +60 -0
- package/src/loaders/wishlist.ts +29 -0
- package/src/middleware.ts +19 -0
- package/src/types.ts +51 -0
- package/src/utils/cacheTimeControl.ts +69 -0
- package/src/utils/client/types.ts +270 -0
- package/src/utils/constants.ts +101 -0
- package/src/utils/graphql-types.ts +99 -0
- package/src/utils/graphql.ts +155 -0
- package/src/utils/stringifySearchCriteria.ts +62 -0
- package/src/utils/transform.ts +283 -0
- package/src/utils/user.ts +16 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL helpers for Magento product loaders.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `deco-cx/apps/magento/utils/graphql.ts` (Fresh/Deno
|
|
5
|
+
* prod). Pure functions — no I/O, no client state — so behavior is
|
|
6
|
+
* pinned by `__tests__/graphql.test.ts` and shared across PDP, PLP,
|
|
7
|
+
* list, and relatedProducts loaders.
|
|
8
|
+
*
|
|
9
|
+
* - `transformSortGraphQL(ProductSort)` → `ProductSortInput`
|
|
10
|
+
* - `transformFilterGraphQL(url, customFilters, fromLoader)` →
|
|
11
|
+
* `ProductFilterInput` — merges URL-derived + loader-derived filters.
|
|
12
|
+
* - `transformFilterValueGraphQL(value, type)` → typed Filter*Input.
|
|
13
|
+
* - `formatUrlSuffix(str)` → ensures a path ends with `/` (used as
|
|
14
|
+
* `defaultPath` for the Magento URL rewrite resolver).
|
|
15
|
+
* - `getCustomFields(CustomFields, fallback)` → resolved custom
|
|
16
|
+
* attribute list for product projection.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { FiltersGraphQL } from "../client";
|
|
20
|
+
import { DEFAULT_GRAPHQL_FILTERS } from "./constants";
|
|
21
|
+
import type {
|
|
22
|
+
CustomFields,
|
|
23
|
+
FilterEqualTypeInput,
|
|
24
|
+
FilterMatchTypeInput,
|
|
25
|
+
FilterProps,
|
|
26
|
+
FilterRangeTypeInput,
|
|
27
|
+
ProductFilterInput,
|
|
28
|
+
ProductSort,
|
|
29
|
+
ProductSortInput,
|
|
30
|
+
} from "./graphql-types";
|
|
31
|
+
|
|
32
|
+
export const typeChecker = <T extends object>(v: T, prop: keyof T): boolean => prop in v;
|
|
33
|
+
|
|
34
|
+
export const transformSortGraphQL = ({
|
|
35
|
+
sortBy,
|
|
36
|
+
order,
|
|
37
|
+
}: Partial<ProductSort>): ProductSortInput | undefined => {
|
|
38
|
+
if (!sortBy) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
[sortBy.value]: order ?? "ASC",
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compose the GraphQL `filter` payload from two sources, in order:
|
|
48
|
+
*
|
|
49
|
+
* 1. URL search params crossed against `DEFAULT_GRAPHQL_FILTERS`
|
|
50
|
+
* (+ any `customFilters` the site extends with).
|
|
51
|
+
* 2. Explicit `fromLoader` filters the CMS section pinned at config
|
|
52
|
+
* time.
|
|
53
|
+
*
|
|
54
|
+
* Loader-derived filters shadow URL-derived ones on key collisions
|
|
55
|
+
* (intentional — a section that hard-codes `sale=true` should ignore
|
|
56
|
+
* any conflicting URL hint).
|
|
57
|
+
*/
|
|
58
|
+
export const transformFilterGraphQL = (
|
|
59
|
+
url: URL,
|
|
60
|
+
customFilters?: Array<FiltersGraphQL>,
|
|
61
|
+
fromLoader?: Array<FilterProps>,
|
|
62
|
+
): ProductFilterInput | undefined => ({
|
|
63
|
+
...filtersFromUrlGraphQL(url, customFilters),
|
|
64
|
+
...filtersFromLoaderGraphQL(fromLoader),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const filtersFromLoaderGraphQL = (
|
|
68
|
+
fromLoader?: Array<FilterProps>,
|
|
69
|
+
): ProductFilterInput | undefined =>
|
|
70
|
+
fromLoader?.reduce<ProductFilterInput>(
|
|
71
|
+
(acc, f) => ({
|
|
72
|
+
...acc,
|
|
73
|
+
[f.name]: f.type,
|
|
74
|
+
}),
|
|
75
|
+
{},
|
|
76
|
+
) ?? {};
|
|
77
|
+
|
|
78
|
+
export const filtersFromUrlGraphQL = (
|
|
79
|
+
url: URL,
|
|
80
|
+
customFilters?: Array<FiltersGraphQL>,
|
|
81
|
+
): ProductFilterInput =>
|
|
82
|
+
DEFAULT_GRAPHQL_FILTERS.concat(customFilters ?? []).reduce<ProductFilterInput>(
|
|
83
|
+
(acc, { type, value }) => {
|
|
84
|
+
const fromUrl = url.searchParams.get(value);
|
|
85
|
+
if (!fromUrl) {
|
|
86
|
+
return acc;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
...acc,
|
|
90
|
+
[value]: transformFilterValueGraphQL(fromUrl, type),
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
{},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export const transformFilterValueGraphQL = (
|
|
97
|
+
value: string,
|
|
98
|
+
type: "EQUAL" | "MATCH" | "RANGE",
|
|
99
|
+
): FilterEqualTypeInput | FilterMatchTypeInput | FilterRangeTypeInput => {
|
|
100
|
+
if (type === "EQUAL") {
|
|
101
|
+
return { eq: value } as FilterEqualTypeInput;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (type === "MATCH") {
|
|
105
|
+
return { match: value } as FilterMatchTypeInput;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (type === "RANGE") {
|
|
109
|
+
const splitterIndex = value.indexOf("_");
|
|
110
|
+
return {
|
|
111
|
+
from: value.substring(0, splitterIndex),
|
|
112
|
+
to: value.substring(splitterIndex + 1),
|
|
113
|
+
} as FilterRangeTypeInput;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {} as FilterEqualTypeInput;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize a URL path into the form Magento's
|
|
121
|
+
* `urlResolver(url: "<path>/")` expects:
|
|
122
|
+
* - Strip a single leading slash (the resolver doesn't want it).
|
|
123
|
+
* - Ensure the path ends with `/`.
|
|
124
|
+
*
|
|
125
|
+
* Used by PLP / PDP / list / relatedProducts loaders as `defaultPath`
|
|
126
|
+
* when `useSuffix` is enabled.
|
|
127
|
+
*/
|
|
128
|
+
export const formatUrlSuffix = (str: string): string => {
|
|
129
|
+
let s = str;
|
|
130
|
+
if (s.startsWith("/")) s = s.slice(1);
|
|
131
|
+
if (!s.endsWith("/")) s = `${s}/`;
|
|
132
|
+
return s;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve which custom-attribute list a loader should request:
|
|
137
|
+
*
|
|
138
|
+
* - disabled (`active: false`) → undefined (loader projects nothing custom).
|
|
139
|
+
* - explicit override list set → return that list as-is.
|
|
140
|
+
* - otherwise → fall back to the global list provided by the loader.
|
|
141
|
+
*/
|
|
142
|
+
export const getCustomFields = (
|
|
143
|
+
{ active, overrideList }: CustomFields = { active: false, overrideList: [] },
|
|
144
|
+
customFields?: Array<string>,
|
|
145
|
+
): Array<string> | undefined => {
|
|
146
|
+
if (!active) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (overrideList && overrideList.length > 0) {
|
|
151
|
+
return overrideList;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return customFields;
|
|
155
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten a Magento REST `searchCriteria` object into a bracketed
|
|
3
|
+
* query-string key/value map.
|
|
4
|
+
*
|
|
5
|
+
* Magento's search-criteria payload is deeply nested (filterGroups →
|
|
6
|
+
* filters → field/value), but the REST API accepts it only as flat
|
|
7
|
+
* `searchCriteria[filterGroups][0][filters][0][field]=name` style
|
|
8
|
+
* query parameters. This helper walks the tree once and emits that
|
|
9
|
+
* shape.
|
|
10
|
+
*
|
|
11
|
+
* Ported verbatim from `deco-cx/apps/magento/utils/stringifySearchCriteria.ts`
|
|
12
|
+
* — the Fresh implementation is self-contained (no Deno-isms) and
|
|
13
|
+
* produces output that prod consumers already depend on. Behavior
|
|
14
|
+
* pinned by `__tests__/stringifySearchCriteria.test.ts`.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* stringifySearchCriteria({
|
|
18
|
+
* filterGroups: [{ filters: [{ field: "sku", value: "ABC" }] }],
|
|
19
|
+
* })
|
|
20
|
+
* // ⇒ { "searchCriteria[filterGroups][0][filters][0][field]": "sku",
|
|
21
|
+
* // "searchCriteria[filterGroups][0][filters][0][value]": "ABC" }
|
|
22
|
+
*/
|
|
23
|
+
interface Filter {
|
|
24
|
+
field: string;
|
|
25
|
+
value: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FilterGroup {
|
|
29
|
+
[key: string]: Filter[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SearchCriteria {
|
|
33
|
+
[key: string]: string | number | FilterGroup[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Path = string;
|
|
37
|
+
type TraverseObj = SearchCriteria | FilterGroup[];
|
|
38
|
+
|
|
39
|
+
function traverse(data: TraverseObj, result: Record<Path, string>, path: Path) {
|
|
40
|
+
if (Array.isArray(data)) {
|
|
41
|
+
data.forEach((item, index) => {
|
|
42
|
+
traverse(item as unknown as TraverseObj, result, `${path}[${index}]`);
|
|
43
|
+
});
|
|
44
|
+
} else if (typeof data === "object" && data !== null) {
|
|
45
|
+
for (const key in data) {
|
|
46
|
+
if (Object.hasOwn(data, key)) {
|
|
47
|
+
// @ts-expect-error recursive function with heterogeneous values
|
|
48
|
+
traverse(data[key], result, `${path}[${key}]`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
result[path] = data;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default function stringifySearchCriteria(
|
|
57
|
+
searchCriteriaObj: SearchCriteria,
|
|
58
|
+
): Record<Path, string> {
|
|
59
|
+
const result: Record<Path, string> = {};
|
|
60
|
+
traverse(searchCriteriaObj, result, "searchCriteria");
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento product transform helpers — maps the REST/GraphQL payloads
|
|
3
|
+
* into schema.org `Product` / `Offer` / `Seo` shapes that
|
|
4
|
+
* `@decocms/apps-commerce` consumers expect.
|
|
5
|
+
*
|
|
6
|
+
* Subset of `deco-cx/apps/magento/utils/transform.ts` — only the
|
|
7
|
+
* functions the PDP loader needs (toProduct, toOffer, toImages, toURL,
|
|
8
|
+
* toBreadcrumbList, toSeo). The GraphQL-side helpers (toProductGraphQL,
|
|
9
|
+
* toAggOfferGraphQL, toProductListingPageGraphQL, …) and the Granado-
|
|
10
|
+
* specific helpers (toReviewAmasty, toLiveloPoints) are intentionally
|
|
11
|
+
* excluded — they land in separate follow-up PRs alongside the loaders
|
|
12
|
+
* that consume them.
|
|
13
|
+
*
|
|
14
|
+
* Behavior is pinned by `__tests__/transform.test.ts`. Every change to
|
|
15
|
+
* this file should ship a regression test.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
ImageObject,
|
|
20
|
+
ListItem,
|
|
21
|
+
Offer,
|
|
22
|
+
Product,
|
|
23
|
+
PropertyValue,
|
|
24
|
+
Seo,
|
|
25
|
+
UnitPriceSpecification,
|
|
26
|
+
} from "@decocms/apps-commerce/types";
|
|
27
|
+
import type { CustomAttribute, MagentoCategory, MagentoProduct } from "./client/types";
|
|
28
|
+
import { IN_STOCK, OUT_OF_STOCK } from "./constants";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Product → schema.org Product
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export const toProduct = ({
|
|
35
|
+
product,
|
|
36
|
+
options,
|
|
37
|
+
}: {
|
|
38
|
+
product: MagentoProduct;
|
|
39
|
+
options: {
|
|
40
|
+
currencyCode?: string;
|
|
41
|
+
imagesUrl?: string;
|
|
42
|
+
maxInstallments: number;
|
|
43
|
+
minInstallmentValue: number;
|
|
44
|
+
};
|
|
45
|
+
}): Product => {
|
|
46
|
+
const offers = toOffer(product, options.minInstallmentValue, options.maxInstallments);
|
|
47
|
+
const sku = product.sku;
|
|
48
|
+
const productID = product.id.toString();
|
|
49
|
+
const productPrice = product.price_info;
|
|
50
|
+
|
|
51
|
+
const additionalProperty: PropertyValue[] = product.custom_attributes?.map((attr) => ({
|
|
52
|
+
"@type": "PropertyValue",
|
|
53
|
+
name: attr.attribute_code,
|
|
54
|
+
value: String(attr.value),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"@type": "Product",
|
|
59
|
+
productID,
|
|
60
|
+
sku,
|
|
61
|
+
url: product.url,
|
|
62
|
+
name: product.name,
|
|
63
|
+
gtin: sku,
|
|
64
|
+
isVariantOf: {
|
|
65
|
+
"@type": "ProductGroup",
|
|
66
|
+
productGroupID: productID,
|
|
67
|
+
url: product.url,
|
|
68
|
+
name: product.name,
|
|
69
|
+
model: "",
|
|
70
|
+
additionalProperty: additionalProperty,
|
|
71
|
+
hasVariant: [
|
|
72
|
+
{
|
|
73
|
+
"@type": "Product",
|
|
74
|
+
productID,
|
|
75
|
+
sku,
|
|
76
|
+
url: product.url,
|
|
77
|
+
name: product.name,
|
|
78
|
+
gtin: sku,
|
|
79
|
+
offers: {
|
|
80
|
+
"@type": "AggregateOffer",
|
|
81
|
+
// biome-ignore lint/style/noNonNullAssertion: matches prod fallback to price
|
|
82
|
+
highPrice: productPrice?.max_price ?? product.price!,
|
|
83
|
+
// biome-ignore lint/style/noNonNullAssertion: matches prod fallback to price
|
|
84
|
+
lowPrice: productPrice?.minimal_price ?? product.price!,
|
|
85
|
+
offerCount: offers.length,
|
|
86
|
+
offers: offers,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
additionalProperty: additionalProperty,
|
|
92
|
+
image: toImages(product, options.imagesUrl ?? ""),
|
|
93
|
+
offers: {
|
|
94
|
+
"@type": "AggregateOffer",
|
|
95
|
+
// biome-ignore lint/style/noNonNullAssertion: matches prod fallback to price
|
|
96
|
+
highPrice: productPrice?.max_price ?? product.price!,
|
|
97
|
+
// biome-ignore lint/style/noNonNullAssertion: matches prod fallback to price
|
|
98
|
+
lowPrice: productPrice?.minimal_price ?? product.price!,
|
|
99
|
+
offerCount: offers.length,
|
|
100
|
+
offers: offers,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Product → Offer[]
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export const toOffer = (
|
|
110
|
+
{ price_info, extension_attributes, sku, currency_code }: MagentoProduct,
|
|
111
|
+
minInstallmentValue: number,
|
|
112
|
+
maxInstallments: number,
|
|
113
|
+
): Offer[] => {
|
|
114
|
+
if (!price_info) {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
const { final_price, max_price, max_regular_price } = price_info;
|
|
118
|
+
const { stock_item } = extension_attributes;
|
|
119
|
+
const inStock = stock_item?.is_in_stock;
|
|
120
|
+
const qtyStock = stock_item?.qty ?? 0;
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
"@type": "Offer",
|
|
125
|
+
availability: inStock ? IN_STOCK : OUT_OF_STOCK,
|
|
126
|
+
inventoryLevel: {
|
|
127
|
+
value: inStock ? qtyStock || 999 : 0,
|
|
128
|
+
},
|
|
129
|
+
itemCondition: "https://schema.org/NewCondition",
|
|
130
|
+
price: final_price,
|
|
131
|
+
priceCurrency: currency_code,
|
|
132
|
+
priceSpecification: [
|
|
133
|
+
{
|
|
134
|
+
"@type": "UnitPriceSpecification",
|
|
135
|
+
priceType: "https://schema.org/ListPrice",
|
|
136
|
+
price: max_price ?? max_regular_price,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"@type": "UnitPriceSpecification",
|
|
140
|
+
priceType: "https://schema.org/SalePrice",
|
|
141
|
+
price: final_price,
|
|
142
|
+
},
|
|
143
|
+
...calculateInstallments(final_price, minInstallmentValue, maxInstallments),
|
|
144
|
+
],
|
|
145
|
+
sku: sku,
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the installment ladder for a sale price. `À vista` is always
|
|
152
|
+
* the first entry, then 2x..Nx where N is bounded by
|
|
153
|
+
* `min(floor(finalPrice/minInstallmentValue), maxInstallments)`.
|
|
154
|
+
* Each entry is a `UnitPriceSpecification` so storefront UIs render
|
|
155
|
+
* "10x de R$ 12,50 sem juros" without further math.
|
|
156
|
+
*/
|
|
157
|
+
const calculateInstallments = (
|
|
158
|
+
finalPrice: number,
|
|
159
|
+
minInstallmentValue: number,
|
|
160
|
+
maxInstallments: number,
|
|
161
|
+
): UnitPriceSpecification[] => {
|
|
162
|
+
const possibleInstallmentsCount = Math.floor(finalPrice / minInstallmentValue) || 1;
|
|
163
|
+
const actualInstallmentsCount = Array.from(
|
|
164
|
+
{ length: Math.min(possibleInstallmentsCount, maxInstallments) },
|
|
165
|
+
(_v, i) => +(finalPrice / (i + 1)).toFixed(2),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return actualInstallmentsCount.map<UnitPriceSpecification>((value, i) => {
|
|
169
|
+
const [description, billingIncrement] = !i
|
|
170
|
+
? ["À vista", finalPrice]
|
|
171
|
+
: [`${i + 1}x sem juros`, value];
|
|
172
|
+
return {
|
|
173
|
+
"@type": "UnitPriceSpecification",
|
|
174
|
+
priceType: "https://schema.org/SalePrice",
|
|
175
|
+
priceComponentType: "https://schema.org/Installment",
|
|
176
|
+
description,
|
|
177
|
+
billingDuration: i + 1,
|
|
178
|
+
billingIncrement,
|
|
179
|
+
price: finalPrice,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Product → ImageObject[]
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
export const toImages = (product: MagentoProduct, imageUrl: string): ImageObject[] | undefined => {
|
|
189
|
+
if (imageUrl) {
|
|
190
|
+
return product.media_gallery_entries?.map((img) => ({
|
|
191
|
+
"@type": "ImageObject" as const,
|
|
192
|
+
encodingFormat: "image",
|
|
193
|
+
alternateName: `${img.file}`,
|
|
194
|
+
url: `${toURL(imageUrl)}${img.file}`,
|
|
195
|
+
disabled: img.disabled || false,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return product.images?.map((img) => ({
|
|
200
|
+
"@type": "ImageObject" as const,
|
|
201
|
+
encodingFormat: "image",
|
|
202
|
+
alternateName: `${img.label}`,
|
|
203
|
+
disabled: img.disabled || false,
|
|
204
|
+
url: `${img.url}`,
|
|
205
|
+
}));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Protocol-relative URLs (`//cdn.magento.example/...`) come back from
|
|
210
|
+
* some Magento installations. Promote to https so the browser doesn't
|
|
211
|
+
* try to inherit a non-https scheme.
|
|
212
|
+
*/
|
|
213
|
+
export const toURL = (src: string): string => (src.startsWith("//") ? `https:${src}` : src);
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Categories → ListItem[] (breadcrumb)
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
export const toBreadcrumbList = (
|
|
220
|
+
categories: (MagentoCategory | null)[],
|
|
221
|
+
isBreadcrumbProductName: boolean,
|
|
222
|
+
product: Product,
|
|
223
|
+
url: URL,
|
|
224
|
+
): ListItem<string>[] => {
|
|
225
|
+
if (isBreadcrumbProductName && categories?.length === 0) {
|
|
226
|
+
return [
|
|
227
|
+
{
|
|
228
|
+
"@type": "ListItem",
|
|
229
|
+
name: product.name,
|
|
230
|
+
position: 1,
|
|
231
|
+
item: new URL(`/${product.name}`, url).href,
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const itemListElement = categories
|
|
237
|
+
.map((category) => {
|
|
238
|
+
if (!category || !category.name || !category.position) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
"@type": "ListItem",
|
|
243
|
+
name: category.name,
|
|
244
|
+
position: category.position,
|
|
245
|
+
item: new URL(`/${category.name}`, url).href,
|
|
246
|
+
};
|
|
247
|
+
})
|
|
248
|
+
.filter(Boolean) as ListItem<string>[];
|
|
249
|
+
|
|
250
|
+
return itemListElement;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Custom attributes → Seo
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Build a `Seo` block from a product's custom_attributes. Magento
|
|
259
|
+
* exposes title/meta_title/meta_description as separate attribute_code
|
|
260
|
+
* entries; this maps them with explicit fallback (meta_title wins over
|
|
261
|
+
* title; description is meta_description only — there's no fallback
|
|
262
|
+
* field in the legacy code).
|
|
263
|
+
*/
|
|
264
|
+
export const toSeo = (customAttributes: CustomAttribute[], productURL: string): Seo => {
|
|
265
|
+
const findAttribute = (attrCode: string): string => {
|
|
266
|
+
const attribute = customAttributes.find((attr) => attr.attribute_code === attrCode);
|
|
267
|
+
if (!attribute) return "";
|
|
268
|
+
if (Array.isArray(attribute.value)) {
|
|
269
|
+
return attribute.value.join(", ");
|
|
270
|
+
}
|
|
271
|
+
return attribute.value;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const title = findAttribute("title");
|
|
275
|
+
const metaTitle = findAttribute("meta_title");
|
|
276
|
+
const metaDescription = findAttribute("meta_description");
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
title: metaTitle || title || "",
|
|
280
|
+
description: metaDescription || "",
|
|
281
|
+
canonical: productURL,
|
|
282
|
+
};
|
|
283
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the Magento PHP session cookie from a request's headers.
|
|
3
|
+
* Returns undefined when the cookie is absent (anonymous visitor).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `deco-cx/apps/magento/utils/user.ts` — the Fresh version
|
|
6
|
+
* used Deno's `std/http/cookie` getCookies; the port goes through
|
|
7
|
+
* `@decocms/blocks/sdk/cookie` so it works on Cloudflare Workers and
|
|
8
|
+
* Node alike.
|
|
9
|
+
*/
|
|
10
|
+
import { getCookies } from "@decocms/blocks/sdk/cookie";
|
|
11
|
+
import { SESSION_COOKIE } from "./constants";
|
|
12
|
+
|
|
13
|
+
export const getUserCookie = (headers: Headers): string | undefined => {
|
|
14
|
+
const cookies = getCookies(headers);
|
|
15
|
+
return cookies[SESSION_COOKIE];
|
|
16
|
+
};
|