@decocms/apps 1.0.0 → 1.1.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.
@@ -1,24 +1,165 @@
1
- import type { AnalyticsEvent } from "../types/commerce";
2
-
3
- declare global {
4
- interface Window {
5
- DECO: { events: { dispatch: (event: any) => void } };
6
- DECO_SITES_STD: {
7
- sendAnalyticsEvent: (args: AnalyticsEvent) => void;
8
- };
9
- }
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";
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(
34
+ categories: string[],
35
+ ): Record<string, string> {
36
+ return categories.slice(0, 5).reduce(
37
+ (result, category, index) => {
38
+ result[`item_category${index === 0 ? "" : index + 1}`] = category;
39
+ return result;
40
+ },
41
+ {} as Record<string, string>,
42
+ );
43
+ }
44
+
45
+ export function mapProductCategoryToAnalyticsCategories(category: string): Record<string, string> {
46
+ return category.split(">").reduce(
47
+ (result, cat, index) => {
48
+ result[`item_category${index === 0 ? "" : index}`] = cat.trim();
49
+ return result;
50
+ },
51
+ {} as Record<string, string>,
52
+ );
53
+ }
54
+
55
+ export interface MapProductToAnalyticsItemOptions {
56
+ product: Product;
57
+ breadcrumbList?: BreadcrumbList;
58
+ price?: number;
59
+ lowPrice?: number;
60
+ listPrice?: number;
61
+ index?: number;
62
+ quantity?: number;
63
+ coupon?: string;
64
+ /** Extend the result with custom fields (e.g., sellerP, sellerName) */
65
+ extend?: (product: Product, base: AnalyticsItem) => Record<string, unknown>;
66
+ }
67
+
68
+ export function mapProductToAnalyticsItem(opts: MapProductToAnalyticsItemOptions): AnalyticsItem {
69
+ const {
70
+ product,
71
+ breadcrumbList,
72
+ price,
73
+ lowPrice,
74
+ listPrice,
75
+ index = 0,
76
+ quantity = 1,
77
+ coupon = "",
78
+ extend,
79
+ } = opts;
80
+
81
+ const { name, productID, inProductGroupWithID, isVariantOf, url, sku } = product;
82
+
83
+ const categories = breadcrumbList?.itemListElement
84
+ ? mapCategoriesToAnalyticsCategories(
85
+ breadcrumbList.itemListElement
86
+ .map(({ name: n }) => n ?? "")
87
+ .filter(Boolean),
88
+ )
89
+ : mapProductCategoryToAnalyticsCategories(product.category ?? "");
90
+
91
+ const base: AnalyticsItem = {
92
+ item_id: productID,
93
+ item_group_id: inProductGroupWithID,
94
+ quantity,
95
+ coupon,
96
+ price: lowPrice,
97
+ index,
98
+ item_variant: sku,
99
+ discount: Number((price && listPrice ? listPrice - price : 0).toFixed(2)),
100
+ item_name: isVariantOf?.name ?? name ?? "",
101
+ item_brand: product.brand?.name ?? "",
102
+ item_url: url,
103
+ ...categories,
104
+ };
105
+
106
+ if (extend) {
107
+ return { ...base, ...extend(product, base) };
108
+ }
109
+
110
+ return base;
111
+ }
112
+
113
+ export interface MapProductToAnalyticsItemListOptions {
114
+ product: Product;
115
+ breadcrumbList?: BreadcrumbList;
116
+ price?: number;
117
+ listPrice?: number;
118
+ index?: number;
119
+ quantity?: number;
120
+ coupon?: string;
10
121
  }
11
122
 
12
- export const sendEvent = <E extends AnalyticsEvent>(event: E) => {
13
- if (typeof globalThis.window?.DECO?.events?.dispatch === "function") {
14
- globalThis.window.DECO.events.dispatch(event);
15
- return;
16
- }
123
+ export function mapProductToAnalyticsItemList(opts: MapProductToAnalyticsItemListOptions): AnalyticsItem {
124
+ const {
125
+ product,
126
+ breadcrumbList,
127
+ price,
128
+ listPrice,
129
+ index = 0,
130
+ quantity = 1,
131
+ coupon = "",
132
+ } = opts;
133
+
134
+ const { name, productID, inProductGroupWithID, isVariantOf, url } = product;
135
+
136
+ const categories = breadcrumbList?.itemListElement
137
+ ? mapCategoriesToAnalyticsCategories(
138
+ breadcrumbList.itemListElement
139
+ .map(({ name: n }) => n ?? "")
140
+ .filter(Boolean),
141
+ )
142
+ : mapProductCategoryToAnalyticsCategories(product.category ?? "");
17
143
 
18
- if (typeof globalThis.window?.DECO_SITES_STD?.sendAnalyticsEvent === "function") {
19
- globalThis.window.DECO_SITES_STD.sendAnalyticsEvent(event);
20
- return;
21
- }
144
+ const finalPrice = typeof price === "number" ? price : 0;
145
+ const discount =
146
+ typeof listPrice === "number" && typeof price === "number"
147
+ ? Math.max(0, listPrice - price)
148
+ : 0;
22
149
 
23
- console.info("[analytics] No event dispatcher found. Event not sent:", event.name);
24
- };
150
+ const itemId = inProductGroupWithID ?? isVariantOf?.productGroupID ?? productID;
151
+
152
+ return {
153
+ item_id: itemId,
154
+ item_group_id: inProductGroupWithID,
155
+ quantity,
156
+ coupon,
157
+ price: finalPrice,
158
+ index,
159
+ discount: Number(discount.toFixed(2)),
160
+ item_name: isVariantOf?.name ?? name ?? "",
161
+ item_brand: product.brand?.name ?? "",
162
+ item_url: url,
163
+ ...categories,
164
+ };
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
@@ -209,3 +209,33 @@ export async function updateAddress(
209
209
  );
210
210
  return result;
211
211
  }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Request-aware wrappers (for COMMERCE_LOADERS / invoke proxy)
215
+ // Handle cookie extraction, postalCode sanitization, and field defaults.
216
+ // ---------------------------------------------------------------------------
217
+
218
+ import { getVtexCookies, ensureUnsuffixedAuthCookie } from "../utils/cookies";
219
+
220
+ function sanitizeAddressInput(props: Record<string, any>): Record<string, any> {
221
+ if (props.postalCode) props.postalCode = props.postalCode.replace(/\D/g, "");
222
+ if (!props.addressName) props.addressName = props.receiverName || `Address ${Date.now()}`;
223
+ if (!props.addressType) props.addressType = "residential";
224
+ return props;
225
+ }
226
+
227
+ export async function createAddressFromRequest(props: Record<string, any>, request: Request): Promise<SavedAddress> {
228
+ const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
229
+ return createAddress(sanitizeAddressInput(props) as AddressInput, cookie);
230
+ }
231
+
232
+ export async function updateAddressFromRequest(props: Record<string, any>, request: Request): Promise<UpdateAddressResult> {
233
+ const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
234
+ const { addressId, ...fields } = props;
235
+ return updateAddress(addressId, fields, cookie);
236
+ }
237
+
238
+ export async function deleteAddressFromRequest(props: Record<string, any>, request: Request): Promise<DeleteAddressResult> {
239
+ const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
240
+ return deleteAddress(props.addressId, cookie);
241
+ }
@@ -117,3 +117,72 @@ export async function updateProfile(
117
117
  );
118
118
  return profile;
119
119
  }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Request-aware wrappers (for COMMERCE_LOADERS / invoke proxy)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ import { getVtexCookies } from "../utils/cookies";
126
+ import { updateNewsletterOptIn } from "./newsletter";
127
+ import { deletePaymentToken } from "./misc";
128
+ import { getCurrentProfile } from "../loaders/profile";
129
+
130
+ /**
131
+ * Normalize birthDate strings to ISO 8601.
132
+ * Handles dd/mm/yyyy (Brazilian format), yyyy-mm-dd, and full ISO.
133
+ */
134
+ function normalizeBirthDate(profile: Record<string, any>): void {
135
+ if (!profile.birthDate || typeof profile.birthDate !== "string") return;
136
+ const ddmmyyyy = profile.birthDate.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
137
+ if (ddmmyyyy) {
138
+ profile.birthDate = `${ddmmyyyy[3]}-${ddmmyyyy[2]}-${ddmmyyyy[1]}T00:00:00.000Z`;
139
+ } else if (!profile.birthDate.includes("T")) {
140
+ const isoMatch = profile.birthDate.match(/(\d{4})-(\d{2})-(\d{2})/);
141
+ if (isoMatch) {
142
+ profile.birthDate = `${isoMatch[0]}T00:00:00.000Z`;
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Update user profile via VTEX IO GraphQL. Handles cookie extraction,
149
+ * birthDate normalization, and undefined-key cleanup.
150
+ */
151
+ export async function updateProfileFromRequest(props: Record<string, any>, request: Request): Promise<any> {
152
+ const { account } = getVtexConfig();
153
+ const cookie = getVtexCookies(request);
154
+ const profile = { ...props };
155
+ normalizeBirthDate(profile);
156
+ for (const key of Object.keys(profile)) {
157
+ if (profile[key] === undefined) delete profile[key];
158
+ }
159
+ const QUERY = `mutation UpdateProfile($profile: ProfileInput!) {
160
+ updateProfile(fields: $profile) @context(provider: "vtex.store-graphql@2.x") {
161
+ cacheId firstName lastName email document gender homePhone
162
+ businessPhone birthDate isCorporate corporateName
163
+ corporateDocument tradeName stateRegistration
164
+ }
165
+ }`;
166
+ const res = await fetch(`https://${account}.myvtex.com/_v/private/graphql/v1`, {
167
+ method: "POST",
168
+ body: JSON.stringify({ query: QUERY, variables: { profile } }),
169
+ headers: { "Content-Type": "application/json", cookie },
170
+ });
171
+ return res.json();
172
+ }
173
+
174
+ export async function newsletterProfileFromRequest(props: Record<string, any>, request: Request): Promise<any> {
175
+ const cookie = request.headers.get("cookie") ?? "";
176
+ return updateNewsletterOptIn(props.isNewsletterOptIn, props.email, cookie);
177
+ }
178
+
179
+ export async function deletePaymentFromRequest(props: Record<string, any>, request: Request): Promise<any> {
180
+ const cookie = getVtexCookies(request);
181
+ return deletePaymentToken(props.id, cookie);
182
+ }
183
+
184
+ export async function getPasswordLastUpdate(_props: Record<string, any>, request: Request): Promise<string | null> {
185
+ const cookie = getVtexCookies(request);
186
+ const profile = await getCurrentProfile(cookie);
187
+ return profile?.passwordLastUpdate ?? null;
188
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * VTEX Intelligent Search autocomplete — suggestions + product results.
3
+ *
4
+ * Combines /autocomplete_suggestions/ and /product_search/ in parallel,
5
+ * transforms IS products to schema.org via pickSku + toProduct.
6
+ */
7
+ import { getVtexConfig, intelligentSearch as vtexIS } from "../client";
8
+ import { pickSku, toProduct as toSchemaProduct } from "../utils/transform";
9
+
10
+ export interface AutocompleteProps {
11
+ query: string;
12
+ count?: number;
13
+ showSponsored?: boolean;
14
+ placement?: string;
15
+ fuzzy?: string;
16
+ }
17
+
18
+ export interface AutocompleteResult {
19
+ searches: Array<{ term: string; count: number; attributes?: any[] }>;
20
+ products: any[];
21
+ }
22
+
23
+ export async function autocompleteSearch(props: AutocompleteProps): Promise<AutocompleteResult> {
24
+ const query = props.query || "";
25
+ const count = props.count ?? 4;
26
+ if (!query.trim()) return { searches: [], products: [] };
27
+
28
+ try {
29
+ const [suggestionsData, productsData] = await Promise.all([
30
+ vtexIS<{
31
+ searches: Array<{ term: string; count: number; attributes?: any[] }>;
32
+ }>("/autocomplete_suggestions/", { query }),
33
+ vtexIS<{ products: any[] }>("/product_search/", {
34
+ query,
35
+ count: String(count),
36
+ showSponsored: props.showSponsored !== false ? "true" : "false",
37
+ placement: props.placement ?? "top-search",
38
+ fuzzy: props.fuzzy ?? "0",
39
+ }),
40
+ ]);
41
+
42
+ const config = getVtexConfig();
43
+ const baseUrl = config.publicUrl
44
+ ? `https://${config.publicUrl}`
45
+ : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
46
+
47
+ return {
48
+ searches: suggestionsData.searches ?? [],
49
+ products: (productsData.products ?? []).slice(0, count).map((p: any) => {
50
+ const sku = pickSku(p);
51
+ return toSchemaProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
52
+ }),
53
+ };
54
+ } catch (error) {
55
+ console.error("[vtex] autocompleteSearch error:", error);
56
+ return { searches: [], products: [] };
57
+ }
58
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * VTEX auth helpers — pure functions for cookie extraction, JWT parsing,
3
+ * Set-Cookie forwarding, and logout.
4
+ *
5
+ * These are consumed by site-level createServerFn wrappers in invoke.ts.
6
+ * createServerFn itself must live in site source (not node_modules) because
7
+ * TanStack Start's Vite plugin only transforms source files.
8
+ */
9
+ import { getVtexConfig } from "../client";
10
+
11
+ const DOMAIN_RE = /;\s*domain=[^;]*/gi;
12
+
13
+ const VTEX_COOKIE_PREFIXES = [
14
+ "vtex_session=",
15
+ "vtex_segment=",
16
+ "VtexIdclientAutCookie",
17
+ "checkout.vtex.com",
18
+ "CheckoutOrderFormOwnership",
19
+ ];
20
+
21
+ /**
22
+ * Extract VTEX-relevant cookies from a raw Cookie header string.
23
+ * Filters out analytics/CF cookies that can cause VTEX 503 errors.
24
+ */
25
+ export function extractVtexCookiesFromHeader(raw: string): string {
26
+ return raw
27
+ .split(";")
28
+ .map((c) => c.trim())
29
+ .filter((c) => VTEX_COOKIE_PREFIXES.some((prefix) => c.startsWith(prefix)))
30
+ .join("; ");
31
+ }
32
+
33
+ /**
34
+ * Strip Domain= from Set-Cookie headers so cookies are associated
35
+ * with the storefront domain instead of the VTEX domain.
36
+ */
37
+ export function stripCookieDomain(cookies: string[]): string[] {
38
+ return cookies.map((c) => c.replace(DOMAIN_RE, ""));
39
+ }
40
+
41
+ /** Standard VTEX cookies to expire on logout. */
42
+ export const VTEX_LOGOUT_COOKIES = [
43
+ "checkout.vtex.com=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
44
+ "CheckoutOrderFormOwnership=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
45
+ "checkout.vtex.com__orderFormId=; Path=/; Max-Age=0",
46
+ "vtex_session=; Path=/; Max-Age=0",
47
+ "vtex_segment=; Path=/; Max-Age=0",
48
+ ];
49
+
50
+ /**
51
+ * Perform VTEX logout — calls the VTEX ID logout endpoint and returns
52
+ * the Set-Cookie headers (with domain stripped) to expire auth cookies.
53
+ */
54
+ export async function performVtexLogout(cookies: string): Promise<{ setCookies: string[] }> {
55
+ const config = getVtexConfig();
56
+ const domain = config.domain ?? "com.br";
57
+ const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`;
58
+
59
+ const res = await fetch(logoutUrl, {
60
+ method: "GET",
61
+ headers: { cookie: cookies },
62
+ redirect: "manual",
63
+ });
64
+
65
+ const upstreamCookies = res.headers.getSetCookie?.() ?? [];
66
+
67
+ return {
68
+ setCookies: [
69
+ ...stripCookieDomain(upstreamCookies),
70
+ ...VTEX_LOGOUT_COOKIES,
71
+ ],
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Parse VTEX auth JWT to extract email and userId.
77
+ * Reads the VtexIdclientAutCookie_* cookie from a raw Cookie header.
78
+ */
79
+ export function parseVtexAuthJwt(rawCookies: string): { email: string; userId: string } | null {
80
+ try {
81
+ const match = rawCookies.match(/VtexIdclientAutCookie_[^=]+=([^;]+)/);
82
+ if (!match) return null;
83
+ const token = match[1];
84
+ const parts = token.split(".");
85
+ if (parts.length < 2) return null;
86
+ const payload = JSON.parse(
87
+ Buffer.from(
88
+ parts[1].replace(/-/g, "+").replace(/_/g, "/"),
89
+ "base64",
90
+ ).toString("utf-8"),
91
+ );
92
+ if (!payload.sub) return null;
93
+ return { email: payload.sub, userId: payload.userId ?? "" };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }