@decocms/apps 0.20.1

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.
Files changed (113) hide show
  1. package/.github/workflows/release.yml +34 -0
  2. package/.releaserc.json +25 -0
  3. package/commerce/components/Image.tsx +209 -0
  4. package/commerce/components/JsonLd.tsx +285 -0
  5. package/commerce/sdk/analytics.ts +24 -0
  6. package/commerce/sdk/formatPrice.ts +23 -0
  7. package/commerce/sdk/url.ts +9 -0
  8. package/commerce/sdk/useOffer.ts +75 -0
  9. package/commerce/sdk/useVariantPossibilities.ts +43 -0
  10. package/commerce/types/commerce.ts +1105 -0
  11. package/commerce/utils/canonical.ts +11 -0
  12. package/commerce/utils/constants.ts +9 -0
  13. package/commerce/utils/filters.ts +10 -0
  14. package/commerce/utils/productToAnalyticsItem.ts +67 -0
  15. package/commerce/utils/stateByZip.ts +50 -0
  16. package/knip.json +19 -0
  17. package/package.json +77 -0
  18. package/shopify/actions/cart/addItems.ts +37 -0
  19. package/shopify/actions/cart/updateCoupons.ts +32 -0
  20. package/shopify/actions/cart/updateItems.ts +32 -0
  21. package/shopify/actions/user/signIn.ts +45 -0
  22. package/shopify/actions/user/signUp.ts +36 -0
  23. package/shopify/client.ts +58 -0
  24. package/shopify/index.ts +32 -0
  25. package/shopify/init.ts +40 -0
  26. package/shopify/loaders/ProductDetailsPage.ts +35 -0
  27. package/shopify/loaders/ProductList.ts +101 -0
  28. package/shopify/loaders/ProductListingPage.ts +180 -0
  29. package/shopify/loaders/RelatedProducts.ts +45 -0
  30. package/shopify/loaders/cart.ts +73 -0
  31. package/shopify/loaders/shop.ts +40 -0
  32. package/shopify/loaders/user.ts +44 -0
  33. package/shopify/utils/admin/admin.ts +57 -0
  34. package/shopify/utils/admin/queries.ts +29 -0
  35. package/shopify/utils/cart.ts +28 -0
  36. package/shopify/utils/cookies.ts +85 -0
  37. package/shopify/utils/enums.ts +438 -0
  38. package/shopify/utils/graphql.ts +69 -0
  39. package/shopify/utils/storefront/queries.ts +530 -0
  40. package/shopify/utils/storefront/storefront.graphql.gen.ts +113 -0
  41. package/shopify/utils/transform.ts +436 -0
  42. package/shopify/utils/types.ts +191 -0
  43. package/shopify/utils/user.ts +23 -0
  44. package/shopify/utils/utils.ts +164 -0
  45. package/tsconfig.json +11 -0
  46. package/vtex/README.md +6 -0
  47. package/vtex/actions/address.ts +211 -0
  48. package/vtex/actions/auth.ts +337 -0
  49. package/vtex/actions/checkout.ts +497 -0
  50. package/vtex/actions/index.ts +11 -0
  51. package/vtex/actions/masterData.ts +170 -0
  52. package/vtex/actions/misc.ts +196 -0
  53. package/vtex/actions/newsletter.ts +108 -0
  54. package/vtex/actions/orders.ts +37 -0
  55. package/vtex/actions/profile.ts +119 -0
  56. package/vtex/actions/session.ts +87 -0
  57. package/vtex/actions/trigger.ts +43 -0
  58. package/vtex/actions/wishlist.ts +116 -0
  59. package/vtex/client.ts +423 -0
  60. package/vtex/hooks/index.ts +4 -0
  61. package/vtex/hooks/useAutocomplete.ts +89 -0
  62. package/vtex/hooks/useCart.ts +219 -0
  63. package/vtex/hooks/useUser.ts +78 -0
  64. package/vtex/hooks/useWishlist.ts +119 -0
  65. package/vtex/index.ts +14 -0
  66. package/vtex/inline-loaders/productDetailsPage.ts +75 -0
  67. package/vtex/inline-loaders/productList.ts +163 -0
  68. package/vtex/inline-loaders/productListingPage.ts +447 -0
  69. package/vtex/inline-loaders/relatedProducts.ts +83 -0
  70. package/vtex/inline-loaders/suggestions.ts +49 -0
  71. package/vtex/inline-loaders/workflowProducts.ts +68 -0
  72. package/vtex/invoke.ts +202 -0
  73. package/vtex/loaders/address.ts +120 -0
  74. package/vtex/loaders/brands.ts +51 -0
  75. package/vtex/loaders/cart.ts +49 -0
  76. package/vtex/loaders/catalog.ts +165 -0
  77. package/vtex/loaders/collections.ts +57 -0
  78. package/vtex/loaders/index.ts +19 -0
  79. package/vtex/loaders/legacy.ts +671 -0
  80. package/vtex/loaders/logistics.ts +115 -0
  81. package/vtex/loaders/navbar.ts +29 -0
  82. package/vtex/loaders/orders.ts +103 -0
  83. package/vtex/loaders/pageType.ts +62 -0
  84. package/vtex/loaders/payment.ts +107 -0
  85. package/vtex/loaders/profile.ts +138 -0
  86. package/vtex/loaders/promotion.ts +33 -0
  87. package/vtex/loaders/search.ts +127 -0
  88. package/vtex/loaders/session.ts +91 -0
  89. package/vtex/loaders/user.ts +89 -0
  90. package/vtex/loaders/wishlist.ts +89 -0
  91. package/vtex/loaders/wishlistProducts.ts +81 -0
  92. package/vtex/loaders/workflow.ts +323 -0
  93. package/vtex/logo.png +0 -0
  94. package/vtex/middleware.ts +229 -0
  95. package/vtex/types.ts +248 -0
  96. package/vtex/utils/batch.ts +21 -0
  97. package/vtex/utils/cookies.ts +76 -0
  98. package/vtex/utils/enrichment.ts +540 -0
  99. package/vtex/utils/fetchCache.ts +150 -0
  100. package/vtex/utils/index.ts +17 -0
  101. package/vtex/utils/intelligentSearch.ts +84 -0
  102. package/vtex/utils/legacy.ts +155 -0
  103. package/vtex/utils/pickAndOmit.ts +30 -0
  104. package/vtex/utils/proxy.ts +196 -0
  105. package/vtex/utils/resourceRange.ts +10 -0
  106. package/vtex/utils/segment.ts +163 -0
  107. package/vtex/utils/similars.ts +38 -0
  108. package/vtex/utils/sitemap.ts +133 -0
  109. package/vtex/utils/slugCache.ts +32 -0
  110. package/vtex/utils/slugify.ts +13 -0
  111. package/vtex/utils/transform.ts +1331 -0
  112. package/vtex/utils/types.ts +1884 -0
  113. package/vtex/utils/vtexId.ts +103 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Client-side cart hook for VTEX.
3
+ *
4
+ * Uses TanStack Query for SWR, optimistic updates, and cache
5
+ * invalidation. Wraps the VTEX orderForm API.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * import { useCart } from "@decocms/apps/vtex/hooks/useCart";
10
+ *
11
+ * function CartButton() {
12
+ * const { cart, addItems, isLoading } = useCart();
13
+ * const count = cart?.items?.length ?? 0;
14
+ * return <button disabled={isLoading}>{count} items</button>;
15
+ * }
16
+ * ```
17
+ */
18
+
19
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
20
+
21
+ export interface CartItem {
22
+ id: string;
23
+ quantity: number;
24
+ seller: string;
25
+ }
26
+
27
+ export interface OrderForm {
28
+ orderFormId: string;
29
+ items: CartItem[];
30
+ totalizers: Array<{ id: string; name: string; value: number }>;
31
+ value: number;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ const CART_QUERY_KEY = ["vtex", "cart"] as const;
36
+
37
+ const DEFAULT_EXPECTED_SECTIONS = [
38
+ "items",
39
+ "totalizers",
40
+ "clientProfileData",
41
+ "shippingData",
42
+ "paymentData",
43
+ "sellers",
44
+ "messages",
45
+ "marketingData",
46
+ "clientPreferencesData",
47
+ "storePreferencesData",
48
+ "giftRegistryData",
49
+ "ratesAndBenefitsData",
50
+ "openTextField",
51
+ "commercialConditionData",
52
+ "customData",
53
+ ];
54
+
55
+ function getScParam(): string {
56
+ if (typeof window !== "undefined") {
57
+ const match = document.cookie.match(/(?:^|;\s*)VTEXSC=([^;]+)/);
58
+ return match?.[1] ?? "";
59
+ }
60
+ return "";
61
+ }
62
+
63
+ function appendSc(url: string): string {
64
+ const sc = getScParam();
65
+ if (!sc) return url;
66
+ return url.includes("?") ? `${url}&sc=${sc}` : `${url}?sc=${sc}`;
67
+ }
68
+
69
+ async function fetchCart(): Promise<OrderForm> {
70
+ const res = await fetch(appendSc("/api/checkout/pub/orderForm"), {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify({ expectedOrderFormSections: DEFAULT_EXPECTED_SECTIONS }),
74
+ credentials: "include",
75
+ });
76
+ if (!res.ok) throw new Error(`Cart fetch failed: ${res.status}`);
77
+ return res.json();
78
+ }
79
+
80
+ async function addItemsToCart(
81
+ orderFormId: string,
82
+ items: Array<{ id: string; quantity: number; seller: string }>,
83
+ ): Promise<OrderForm> {
84
+ const params = new URLSearchParams();
85
+ params.append("allowedOutdatedData", "paymentData");
86
+ const sc = getScParam();
87
+ if (sc) params.set("sc", sc);
88
+
89
+ const res = await fetch(
90
+ `/api/checkout/pub/orderForm/${orderFormId}/items?${params}`,
91
+ {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({ orderItems: items }),
95
+ credentials: "include",
96
+ },
97
+ );
98
+ if (!res.ok) throw new Error(`Add to cart failed: ${res.status}`);
99
+ return res.json();
100
+ }
101
+
102
+ async function addCouponsToCart(
103
+ orderFormId: string,
104
+ text: string,
105
+ ): Promise<OrderForm> {
106
+ const params = new URLSearchParams();
107
+ const sc = getScParam();
108
+ if (sc) params.set("sc", sc);
109
+
110
+ const res = await fetch(
111
+ `/api/checkout/pub/orderForm/${orderFormId}/coupons?${params}`,
112
+ {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ text }),
116
+ credentials: "include",
117
+ },
118
+ );
119
+ if (!res.ok) throw new Error(`Add coupon failed: ${res.status}`);
120
+ return res.json();
121
+ }
122
+
123
+ async function updateItemQuantity(
124
+ orderFormId: string,
125
+ index: number,
126
+ quantity: number,
127
+ ): Promise<OrderForm> {
128
+ const params = new URLSearchParams();
129
+ params.append("allowedOutdatedData", "paymentData");
130
+ const sc = getScParam();
131
+ if (sc) params.set("sc", sc);
132
+
133
+ const res = await fetch(
134
+ `/api/checkout/pub/orderForm/${orderFormId}/items/update?${params}`,
135
+ {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({ orderItems: [{ index, quantity }] }),
139
+ credentials: "include",
140
+ },
141
+ );
142
+ if (!res.ok) throw new Error(`Update quantity failed: ${res.status}`);
143
+ return res.json();
144
+ }
145
+
146
+ export interface UseCartOptions {
147
+ /** Enable automatic refetching. @default true */
148
+ enabled?: boolean;
149
+ /** Stale time in ms. @default 0 (always refetch) */
150
+ staleTime?: number;
151
+ }
152
+
153
+ export function useCart(options?: UseCartOptions) {
154
+ const queryClient = useQueryClient();
155
+
156
+ const query = useQuery({
157
+ queryKey: CART_QUERY_KEY,
158
+ queryFn: fetchCart,
159
+ staleTime: options?.staleTime ?? 0,
160
+ enabled: options?.enabled !== false,
161
+ });
162
+
163
+ const addItems = useMutation({
164
+ mutationFn: (items: Array<{ id: string; quantity: number; seller: string }>) => {
165
+ const orderFormId = query.data?.orderFormId;
166
+ if (!orderFormId) throw new Error("Cart not loaded");
167
+ return addItemsToCart(orderFormId, items);
168
+ },
169
+ onSuccess: (data) => {
170
+ queryClient.setQueryData(CART_QUERY_KEY, data);
171
+ },
172
+ });
173
+
174
+ const updateQuantity = useMutation({
175
+ mutationFn: ({ index, quantity }: { index: number; quantity: number }) => {
176
+ const orderFormId = query.data?.orderFormId;
177
+ if (!orderFormId) throw new Error("Cart not loaded");
178
+ return updateItemQuantity(orderFormId, index, quantity);
179
+ },
180
+ onSuccess: (data) => {
181
+ queryClient.setQueryData(CART_QUERY_KEY, data);
182
+ },
183
+ });
184
+
185
+ const removeItem = useMutation({
186
+ mutationFn: (index: number) => {
187
+ const orderFormId = query.data?.orderFormId;
188
+ if (!orderFormId) throw new Error("Cart not loaded");
189
+ return updateItemQuantity(orderFormId, index, 0);
190
+ },
191
+ onSuccess: (data) => {
192
+ queryClient.setQueryData(CART_QUERY_KEY, data);
193
+ },
194
+ });
195
+
196
+ const addCoupons = useMutation({
197
+ mutationFn: (text: string) => {
198
+ const orderFormId = query.data?.orderFormId;
199
+ if (!orderFormId) throw new Error("Cart not loaded");
200
+ return addCouponsToCart(orderFormId, text);
201
+ },
202
+ onSuccess: (data) => {
203
+ queryClient.setQueryData(CART_QUERY_KEY, data);
204
+ },
205
+ });
206
+
207
+ return {
208
+ cart: query.data ?? null,
209
+ isLoading: query.isLoading,
210
+ isError: query.isError,
211
+ error: query.error,
212
+ refetch: query.refetch,
213
+ addItems,
214
+ addCoupons,
215
+ updateQuantity,
216
+ removeItem,
217
+ itemCount: query.data?.items?.length ?? 0,
218
+ };
219
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Client-side user/session hook for VTEX.
3
+ *
4
+ * Detects login state via the VTEX Sessions API (server-side accessible).
5
+ * Does NOT attempt to read VtexIdclientAutCookie client-side — that cookie
6
+ * is HttpOnly and inaccessible via document.cookie.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * import { useUser } from "@decocms/apps/vtex/hooks/useUser";
11
+ *
12
+ * function UserGreeting() {
13
+ * const { user, isLoggedIn } = useUser();
14
+ * if (!isLoggedIn) return <a href="/account">Sign In</a>;
15
+ * return <span>Hello, {user?.email}</span>;
16
+ * }
17
+ * ```
18
+ */
19
+
20
+ import { useQuery } from "@tanstack/react-query";
21
+
22
+ export interface VtexUser {
23
+ email?: string;
24
+ firstName?: string;
25
+ lastName?: string;
26
+ userId?: string;
27
+ isLoggedIn: boolean;
28
+ }
29
+
30
+ const USER_QUERY_KEY = ["vtex", "user"] as const;
31
+
32
+ async function fetchUser(): Promise<VtexUser> {
33
+ try {
34
+ const res = await fetch(
35
+ "/api/sessions?items=profile.email,profile.firstName,profile.lastName,profile.id",
36
+ { credentials: "include" },
37
+ );
38
+ if (!res.ok) return { isLoggedIn: false };
39
+
40
+ const data = await res.json();
41
+ const profile = data?.namespaces?.profile;
42
+
43
+ const email = profile?.email?.value;
44
+ if (!email) return { isLoggedIn: false };
45
+
46
+ return {
47
+ email,
48
+ firstName: profile?.firstName?.value,
49
+ lastName: profile?.lastName?.value,
50
+ userId: profile?.id?.value,
51
+ isLoggedIn: true,
52
+ };
53
+ } catch {
54
+ return { isLoggedIn: false };
55
+ }
56
+ }
57
+
58
+ export interface UseUserOptions {
59
+ enabled?: boolean;
60
+ staleTime?: number;
61
+ }
62
+
63
+ export function useUser(options?: UseUserOptions) {
64
+ const query = useQuery({
65
+ queryKey: USER_QUERY_KEY,
66
+ queryFn: fetchUser,
67
+ staleTime: options?.staleTime ?? 30_000,
68
+ enabled: options?.enabled !== false,
69
+ });
70
+
71
+ return {
72
+ user: query.data ?? null,
73
+ isLoggedIn: query.data?.isLoggedIn ?? false,
74
+ isLoading: query.isLoading,
75
+ isError: query.isError,
76
+ refetch: query.refetch,
77
+ };
78
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Client-side wishlist hook for VTEX.
3
+ *
4
+ * Reads the wishlist via the invoke proxy and provides
5
+ * add/remove mutations with automatic cache invalidation.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * import { useWishlist } from "@decocms/apps/vtex/hooks/useWishlist";
10
+ *
11
+ * function WishlistButton({ productId, sku }: Props) {
12
+ * const { isInWishlist, toggle, isLoading } = useWishlist();
13
+ * const wishlisted = isInWishlist(productId);
14
+ * return (
15
+ * <button onClick={() => toggle({ productId, sku })} disabled={isLoading}>
16
+ * {wishlisted ? "♥" : "♡"}
17
+ * </button>
18
+ * );
19
+ * }
20
+ * ```
21
+ */
22
+
23
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
24
+
25
+ export interface WishlistItem {
26
+ id: string;
27
+ productId: string;
28
+ sku: string;
29
+ }
30
+
31
+ const WISHLIST_QUERY_KEY = ["vtex", "wishlist"] as const;
32
+
33
+ async function fetchWishlist(): Promise<WishlistItem[]> {
34
+ const res = await fetch("/deco/invoke/vtex/loaders/wishlist", {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify({}),
38
+ credentials: "include",
39
+ });
40
+ if (!res.ok) {
41
+ if (res.status === 401) return [];
42
+ throw new Error(`Wishlist fetch failed: ${res.status}`);
43
+ }
44
+ return res.json();
45
+ }
46
+
47
+ async function addToWishlist(item: { productId: string; sku: string }): Promise<void> {
48
+ const res = await fetch("/deco/invoke/vtex/actions/wishlist/addItem", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify(item),
52
+ credentials: "include",
53
+ });
54
+ if (!res.ok) throw new Error(`Add to wishlist failed: ${res.status}`);
55
+ }
56
+
57
+ async function removeFromWishlist(id: string): Promise<void> {
58
+ const res = await fetch("/deco/invoke/vtex/actions/wishlist/removeItem", {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({ id }),
62
+ credentials: "include",
63
+ });
64
+ if (!res.ok) throw new Error(`Remove from wishlist failed: ${res.status}`);
65
+ }
66
+
67
+ export interface UseWishlistOptions {
68
+ enabled?: boolean;
69
+ staleTime?: number;
70
+ }
71
+
72
+ export function useWishlist(options?: UseWishlistOptions) {
73
+ const queryClient = useQueryClient();
74
+
75
+ const query = useQuery({
76
+ queryKey: WISHLIST_QUERY_KEY,
77
+ queryFn: fetchWishlist,
78
+ staleTime: options?.staleTime ?? 60_000,
79
+ enabled: options?.enabled !== false,
80
+ });
81
+
82
+ const items = query.data ?? [];
83
+ const productIdSet = new Set(items.map((i) => i.productId));
84
+
85
+ const addMutation = useMutation({
86
+ mutationFn: addToWishlist,
87
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: WISHLIST_QUERY_KEY }),
88
+ });
89
+
90
+ const removeMutation = useMutation({
91
+ mutationFn: removeFromWishlist,
92
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: WISHLIST_QUERY_KEY }),
93
+ });
94
+
95
+ function isInWishlist(productId: string): boolean {
96
+ return productIdSet.has(productId);
97
+ }
98
+
99
+ function toggle(item: { productId: string; sku: string }) {
100
+ const existing = items.find((i) => i.productId === item.productId);
101
+ if (existing) {
102
+ removeMutation.mutate(existing.id);
103
+ } else {
104
+ addMutation.mutate(item);
105
+ }
106
+ }
107
+
108
+ return {
109
+ items,
110
+ isLoading: query.isLoading || addMutation.isPending || removeMutation.isPending,
111
+ isError: query.isError,
112
+ isInWishlist,
113
+ toggle,
114
+ add: addMutation,
115
+ remove: removeMutation,
116
+ refetch: query.refetch,
117
+ count: items.length,
118
+ };
119
+ }
package/vtex/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * VTEX app entry point for @decocms/apps.
3
+ * Re-exports client config + initializer.
4
+ *
5
+ * For actions/loaders/utils, use sub-path imports:
6
+ * import { addItemsToCart } from "@decocms/apps/vtex/actions/checkout"
7
+ * import { searchProducts } from "@decocms/apps/vtex/loaders/catalog"
8
+ * import { slugify } from "@decocms/apps/vtex/utils/slugify"
9
+ *
10
+ * Or barrel imports:
11
+ * import { addItemsToCart } from "@decocms/apps/vtex/actions"
12
+ * import { searchProducts } from "@decocms/apps/vtex/loaders"
13
+ */
14
+ export * from "./client";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * PDP loader using Legacy Catalog API + shared transform pipeline.
3
+ * Maps VTEX catalog response to schema.org ProductDetailsPage
4
+ * following the same pattern as deco-cx/apps.
5
+ */
6
+
7
+ import type { ProductDetailsPage } from "../../commerce/types/commerce";
8
+ import { getVtexConfig, vtexCachedFetch } from "../client";
9
+ import { searchBySlug } from "../utils/slugCache";
10
+ import { pickSku, toProductPage } from "../utils/transform";
11
+ import type { LegacyProduct } from "../utils/types";
12
+
13
+ export interface PDPProps {
14
+ slug?: string;
15
+ skuId?: string;
16
+ /** When true, PDP pages with ?skuId remain indexable */
17
+ indexingSkus?: boolean;
18
+ /** Use product.description instead of metaTagDescription for SEO */
19
+ preferDescription?: boolean;
20
+ }
21
+
22
+ export default async function vtexProductDetailsPage(
23
+ props: PDPProps,
24
+ ): Promise<ProductDetailsPage | null> {
25
+ const { slug, skuId, indexingSkus, preferDescription } = props;
26
+ if (!slug) return null;
27
+
28
+ try {
29
+ const linkText = slug.replace(/\/p$/, "").replace(/^\//, "").toLowerCase();
30
+ const config = getVtexConfig();
31
+ const sc = config.salesChannel;
32
+
33
+ const products = await searchBySlug(linkText);
34
+
35
+ if (!products || products.length === 0) {
36
+ return null;
37
+ }
38
+
39
+ const product = products[0];
40
+ const baseUrl = config.publicUrl
41
+ ? `https://${config.publicUrl}`
42
+ : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
43
+
44
+ const sku = pickSku(product, skuId);
45
+
46
+ const kitItems: LegacyProduct[] =
47
+ Array.isArray(sku.kitItems) && sku.kitItems.length > 0
48
+ ? ((await vtexCachedFetch<LegacyProduct[]>(
49
+ `/api/catalog_system/pub/products/search/?fq=${sku.kitItems.map((item: any) => `skuId:${item.itemId}`).join("&fq=")}&_from=0&_to=49${sc ? `&sc=${sc}` : ""}`,
50
+ )) ?? [])
51
+ : [];
52
+
53
+ const page = toProductPage(product, sku, kitItems, {
54
+ baseUrl,
55
+ priceCurrency: "BRL",
56
+ });
57
+
58
+ return {
59
+ ...page,
60
+ seo: {
61
+ title: product.productTitle || product.productName,
62
+ description: preferDescription
63
+ ? product.description
64
+ : product.metaTagDescription ||
65
+ product.description?.substring(0, 160) ||
66
+ "",
67
+ canonical: `/${product.linkText}/p`,
68
+ noIndexing: indexingSkus ? false : !!skuId,
69
+ },
70
+ };
71
+ } catch (error) {
72
+ console.error("[VTEX] PDP error:", error);
73
+ return null;
74
+ }
75
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Product list loader using VTEX Intelligent Search + shared transform pipeline.
3
+ * Maps IS response to schema.org Product[] following deco-cx/apps pattern.
4
+ *
5
+ * Supports: query search, collection IDs, SKU IDs, facets, sort, and
6
+ * hideUnavailableItems — matching the original productList.ts behavior.
7
+ */
8
+ import { intelligentSearch, getVtexConfig, toFacetPath } from "../client";
9
+ import { toProduct, pickSku, sortProducts } from "../utils/transform";
10
+ import type { Product as ProductVTEX } from "../utils/types";
11
+ import type { Product } from "../../commerce/types/commerce";
12
+
13
+ export interface ProductListProps {
14
+ props?: CollectionProps | QueryProps | ProductIDProps | FacetsProps;
15
+ query?: string;
16
+ count?: number;
17
+ sort?: string;
18
+ collection?: string;
19
+ hideUnavailableItems?: boolean;
20
+ }
21
+
22
+ interface CollectionProps {
23
+ collection: string;
24
+ count?: number;
25
+ sort?: string;
26
+ hideUnavailableItems?: boolean;
27
+ }
28
+
29
+ interface QueryProps {
30
+ query: string;
31
+ count?: number;
32
+ sort?: string;
33
+ fuzzy?: string;
34
+ hideUnavailableItems?: boolean;
35
+ }
36
+
37
+ interface ProductIDProps {
38
+ ids: string[];
39
+ hideUnavailableItems?: boolean;
40
+ }
41
+
42
+ interface FacetsProps {
43
+ query?: string;
44
+ facets: string;
45
+ count?: number;
46
+ sort?: string;
47
+ hideUnavailableItems?: boolean;
48
+ }
49
+
50
+ function isCollectionProps(p: any): p is CollectionProps {
51
+ return typeof p?.collection === "string";
52
+ }
53
+ function isProductIDProps(p: any): p is ProductIDProps {
54
+ return Array.isArray(p?.ids) && p.ids.length > 0;
55
+ }
56
+ function isFacetsProps(p: any): p is FacetsProps {
57
+ return typeof p?.facets === "string";
58
+ }
59
+
60
+ function resolveParams(props: ProductListProps): {
61
+ query: string;
62
+ count: number;
63
+ sort: string;
64
+ facetPath: string;
65
+ fuzzy?: string;
66
+ hideUnavailableItems: boolean;
67
+ ids?: string[];
68
+ } {
69
+ const inner = props.props ?? props;
70
+
71
+ if (isProductIDProps(inner)) {
72
+ return {
73
+ query: `sku:${inner.ids.join(";")}`,
74
+ count: inner.ids.length,
75
+ sort: "",
76
+ facetPath: "",
77
+ hideUnavailableItems: inner.hideUnavailableItems ?? false,
78
+ ids: inner.ids,
79
+ };
80
+ }
81
+
82
+ if (isFacetsProps(inner)) {
83
+ return {
84
+ query: inner.query ?? "",
85
+ count: inner.count ?? 12,
86
+ sort: inner.sort ?? "",
87
+ facetPath: inner.facets,
88
+ hideUnavailableItems: inner.hideUnavailableItems ?? false,
89
+ };
90
+ }
91
+
92
+ if (isCollectionProps(inner)) {
93
+ return {
94
+ query: "",
95
+ count: inner.count ?? 12,
96
+ sort: inner.sort ?? "",
97
+ facetPath: toFacetPath([{ key: "productClusterIds", value: inner.collection }]),
98
+ hideUnavailableItems: inner.hideUnavailableItems ?? false,
99
+ };
100
+ }
101
+
102
+ return {
103
+ query: (inner as any).query ?? "",
104
+ count: (inner as any).count ?? 12,
105
+ sort: (inner as any).sort ?? "",
106
+ facetPath: "",
107
+ fuzzy: (inner as any).fuzzy,
108
+ hideUnavailableItems: (inner as any).hideUnavailableItems ?? false,
109
+ };
110
+ }
111
+
112
+ export default async function vtexProductList(
113
+ props: ProductListProps,
114
+ ): Promise<Product[] | null> {
115
+ try {
116
+ const { query, count, sort, facetPath, fuzzy, hideUnavailableItems, ids } =
117
+ resolveParams(props);
118
+
119
+ const config = getVtexConfig();
120
+ const locale = config.locale ?? "pt-BR";
121
+
122
+ const params: Record<string, string> = {
123
+ page: "1",
124
+ count: String(count),
125
+ locale,
126
+ hideUnavailableItems: String(hideUnavailableItems),
127
+ };
128
+ if (query) params.query = query;
129
+ if (sort) params.sort = sort;
130
+ if (fuzzy) params.fuzzy = fuzzy;
131
+
132
+ const endpoint = facetPath
133
+ ? `/product_search/${facetPath}`
134
+ : "/product_search/";
135
+
136
+ const data = await intelligentSearch<{ products: ProductVTEX[] }>(
137
+ endpoint,
138
+ params,
139
+ );
140
+
141
+ const vtexProducts = data.products ?? [];
142
+ const baseUrl = config.publicUrl
143
+ ? `https://${config.publicUrl}`
144
+ : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
145
+
146
+ let products = vtexProducts.map((p) => {
147
+ const fetchedSkus = ids ? new Set(ids) : null;
148
+ const preferredSku = fetchedSkus
149
+ ? p.items.find((item) => fetchedSkus.has(item.itemId)) ?? pickSku(p)
150
+ : pickSku(p);
151
+ return toProduct(p, preferredSku, 0, { baseUrl, priceCurrency: "BRL" });
152
+ });
153
+
154
+ if (ids) {
155
+ products = sortProducts(products, ids, "sku");
156
+ }
157
+
158
+ return products;
159
+ } catch (error) {
160
+ console.error("[VTEX] ProductList error:", error);
161
+ return null;
162
+ }
163
+ }