@decocms/apps 0.21.4 → 0.23.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,4 +1,5 @@
1
1
  import type { ImgHTMLAttributes } from "react";
2
+ import { forwardRef } from "react";
2
3
 
3
4
  // -------------------------------------------------------------------------
4
5
  // Known asset prefixes that get stripped to produce a relative src path
@@ -34,7 +35,7 @@ export function getImageCdnDomain(): string {
34
35
  // Fit options & optimization types
35
36
  // -------------------------------------------------------------------------
36
37
 
37
- export type FitOptions = "contain" | "cover";
38
+ export type FitOptions = "contain" | "cover" | "fill";
38
39
 
39
40
  export const FACTORS = [1, 2];
40
41
 
@@ -163,92 +164,77 @@ export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "s
163
164
  height?: number;
164
165
  /** @description Object-fit */
165
166
  fit?: FitOptions;
166
- /** @description Preload the image (adds fetchPriority="high") */
167
+ /**
168
+ * @description Web Vitals (LCP). Injects a `<link rel="preload">` tag
169
+ * alongside the `<img>`, sets `fetchPriority="high"` and `loading="eager"`.
170
+ * Use once per page for the LCP image.
171
+ */
167
172
  preload?: boolean;
173
+ /** @description Media query for responsive preloading (e.g. "(min-width: 768px)") */
174
+ media?: string;
168
175
  }
169
176
 
170
- export function Image({
171
- src,
172
- width,
173
- height,
174
- fit = "cover",
175
- preload,
176
- loading,
177
- decoding,
178
- srcSet: srcSetProp,
179
- sizes,
180
- ...rest
181
- }: ImageProps) {
182
- if (!height && typeof process !== "undefined") {
183
- console.warn(`Missing height. This image will NOT be optimized: ${src}`);
184
- }
185
-
186
- const srcSet = srcSetProp ?? getSrcSet(src, width, height, fit);
187
-
188
- return (
189
- <img
190
- {...rest}
191
- src={src}
192
- srcSet={srcSet}
193
- sizes={srcSet ? (sizes ?? "(max-width: 768px) 100vw, 50vw") : undefined}
194
- width={width}
195
- height={height}
196
- loading={loading ?? (preload ? "eager" : "lazy")}
197
- decoding={decoding ?? "async"}
198
- fetchPriority={preload ? "high" : undefined}
199
- />
200
- );
201
- }
202
-
203
- // -------------------------------------------------------------------------
204
- // Picture (responsive art direction)
205
- // -------------------------------------------------------------------------
206
-
207
- export interface PictureSourceProps {
208
- src: string;
209
- width: number;
210
- height?: number;
211
- media: string;
212
- fit?: FitOptions;
213
- }
214
-
215
- export interface PictureProps extends Omit<ImageProps, "sizes"> {
216
- sources: PictureSourceProps[];
217
- }
177
+ export const Image = forwardRef<HTMLImageElement, ImageProps>(
178
+ function Image(
179
+ {
180
+ src,
181
+ width,
182
+ height,
183
+ fit = "cover",
184
+ preload,
185
+ media,
186
+ loading,
187
+ decoding,
188
+ srcSet: srcSetProp,
189
+ sizes,
190
+ fetchPriority,
191
+ ...rest
192
+ },
193
+ ref,
194
+ ) {
195
+ if (!height && typeof process !== "undefined") {
196
+ console.warn(`Missing height. This image will NOT be optimized: ${src}`);
197
+ }
218
198
 
219
- export function Picture({
220
- sources,
221
- src,
222
- width,
223
- height,
224
- fit = "cover",
225
- preload,
226
- ...rest
227
- }: PictureProps) {
228
- return (
229
- <picture>
230
- {sources.map((source, i) => {
231
- const srcSet = getSrcSet(source.src, source.width, source.height, source.fit ?? fit);
232
- return (
233
- <source
234
- key={i}
235
- srcSet={srcSet}
236
- media={source.media}
237
- width={source.width}
238
- height={source.height}
199
+ const optimizedSrc = getOptimizedMediaUrl({
200
+ originalSrc: src,
201
+ width,
202
+ height,
203
+ fit,
204
+ });
205
+ const srcSet = srcSetProp ?? getSrcSet(src, width, height, fit);
206
+ const resolvedSizes = srcSet
207
+ ? (sizes ?? "(max-width: 768px) 100vw, 50vw")
208
+ : undefined;
209
+
210
+ return (
211
+ <>
212
+ {preload && (
213
+ <link
214
+ as="image"
215
+ rel="preload"
216
+ href={optimizedSrc}
217
+ imageSrcSet={srcSet}
218
+ imageSizes={resolvedSizes}
219
+ fetchPriority={fetchPriority ?? "high"}
220
+ media={media}
239
221
  />
240
- );
241
- })}
242
- <Image
243
- src={src}
244
- width={width}
245
- height={height}
246
- fit={fit}
247
- preload={preload}
248
- {...rest}
249
- />
250
- </picture>
251
- );
252
- }
222
+ )}
223
+ <img
224
+ {...rest}
225
+ src={optimizedSrc}
226
+ srcSet={srcSet}
227
+ sizes={resolvedSizes}
228
+ width={width}
229
+ height={height}
230
+ loading={loading ?? (preload ? "eager" : "lazy")}
231
+ decoding={decoding ?? "async"}
232
+ fetchPriority={preload ? "high" : fetchPriority}
233
+ ref={ref}
234
+ />
235
+ </>
236
+ );
237
+ },
238
+ );
253
239
 
254
240
  export default Image;
@@ -0,0 +1,114 @@
1
+ import {
2
+ createContext,
3
+ forwardRef,
4
+ useContext,
5
+ useMemo,
6
+ type ComponentPropsWithoutRef,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { getOptimizedMediaUrl, getSrcSet, type FitOptions } from "./Image";
10
+
11
+ // -------------------------------------------------------------------------
12
+ // Preload context — flows from <Picture preload> to child <Source> elements
13
+ // so each source can inject its own <link rel="preload"> with the correct
14
+ // media query for responsive art direction.
15
+ // -------------------------------------------------------------------------
16
+
17
+ interface PreloadContextValue {
18
+ preload: boolean;
19
+ }
20
+
21
+ const PreloadContext = createContext<PreloadContextValue>({ preload: false });
22
+
23
+ // -------------------------------------------------------------------------
24
+ // Source — composable <source> with automatic srcSet optimization and
25
+ // preload link injection when inside a <Picture preload>.
26
+ // -------------------------------------------------------------------------
27
+
28
+ export type SourceProps = Omit<
29
+ ComponentPropsWithoutRef<"source">,
30
+ "width" | "height"
31
+ > & {
32
+ src: string;
33
+ /** @description Improves Web Vitals (CLS|LCP) */
34
+ width: number;
35
+ /** @description Improves Web Vitals (CLS|LCP) */
36
+ height?: number;
37
+ /** @description Improves Web Vitals (LCP). Use high for LCP image. */
38
+ fetchPriority?: "high" | "low" | "auto";
39
+ /** @description Object-fit */
40
+ fit?: FitOptions;
41
+ };
42
+
43
+ export const Source = forwardRef<HTMLSourceElement, SourceProps>(
44
+ function Source(
45
+ { src, width, height, fetchPriority, fit = "cover", ...rest },
46
+ ref,
47
+ ) {
48
+ const { preload } = useContext(PreloadContext);
49
+
50
+ const optimizedSrc = getOptimizedMediaUrl({
51
+ originalSrc: src,
52
+ width,
53
+ height,
54
+ fit,
55
+ });
56
+ const srcSet = rest.srcSet ?? getSrcSet(src, width, height, fit);
57
+
58
+ return (
59
+ <>
60
+ {preload && (
61
+ <link
62
+ as="image"
63
+ rel="preload"
64
+ href={optimizedSrc}
65
+ imageSrcSet={srcSet}
66
+ fetchPriority={fetchPriority ?? "high"}
67
+ media={rest.media}
68
+ />
69
+ )}
70
+ <source
71
+ {...rest}
72
+ srcSet={srcSet ?? optimizedSrc}
73
+ width={width}
74
+ height={height}
75
+ ref={ref}
76
+ />
77
+ </>
78
+ );
79
+ },
80
+ );
81
+
82
+ // -------------------------------------------------------------------------
83
+ // Picture — composable wrapper that provides preload context to children.
84
+ //
85
+ // Usage:
86
+ // <Picture preload={isLcp}>
87
+ // <Source media="(max-width: 767px)" src={mobile} width={320} height={280} />
88
+ // <Source media="(min-width: 768px)" src={desktop} width={1280} height={280} />
89
+ // <Image src={desktop} width={1280} height={280} />
90
+ // </Picture>
91
+ // -------------------------------------------------------------------------
92
+
93
+ export type PictureProps = ComponentPropsWithoutRef<"picture"> & {
94
+ children: ReactNode;
95
+ /**
96
+ * @description When true, child <Source> and <Image> elements inject
97
+ * `<link rel="preload">` tags for their respective media queries.
98
+ */
99
+ preload?: boolean;
100
+ };
101
+
102
+ export const Picture = forwardRef<HTMLPictureElement, PictureProps>(
103
+ function Picture({ children, preload = false, ...props }, ref) {
104
+ const value = useMemo(() => ({ preload }), [preload]);
105
+
106
+ return (
107
+ <PreloadContext.Provider value={value}>
108
+ <picture {...props} ref={ref}>
109
+ {children}
110
+ </picture>
111
+ </PreloadContext.Provider>
112
+ );
113
+ },
114
+ );
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "0.21.4",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
7
7
  "./commerce/types": "./commerce/types/commerce.ts",
8
8
  "./commerce/components/JsonLd": "./commerce/components/JsonLd.tsx",
9
9
  "./commerce/components/Image": "./commerce/components/Image.tsx",
10
+ "./commerce/components/Picture": "./commerce/components/Picture.tsx",
10
11
  "./commerce/utils/*": "./commerce/utils/*.ts",
11
12
  "./commerce/sdk/*": "./commerce/sdk/*.ts",
12
13
  "./shopify": "./shopify/index.ts",
package/vtex/client.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
7
+ import { SEGMENT_COOKIE_NAME, parseSegment } from "./utils/segment";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // URL sanitization (ported from deco-cx/apps vtex/utils/fetchVTEX.ts)
@@ -176,6 +177,22 @@ function authHeaders(): Record<string, string> {
176
177
  return headers;
177
178
  }
178
179
 
180
+ /**
181
+ * Read regionId from the current request's vtex_segment cookie.
182
+ * Returns null when no cookie provider is registered or no regionId is set.
183
+ */
184
+ function extractRegionIdFromCookies(): string | null {
185
+ if (!_getCookieHeader) return null;
186
+ const cookies = _getCookieHeader();
187
+ if (!cookies) return null;
188
+ const match = cookies.match(
189
+ new RegExp(`(?:^|;\\s*)${SEGMENT_COOKIE_NAME}=([^;]+)`),
190
+ );
191
+ if (!match?.[1]) return null;
192
+ const segment = parseSegment(match[1]);
193
+ return segment?.regionId ?? null;
194
+ }
195
+
179
196
  export async function vtexFetchResponse(
180
197
  path: string,
181
198
  init?: RequestInit,
@@ -293,7 +310,7 @@ export async function vtexFetchWithCookies<T>(
293
310
  export async function intelligentSearch<T>(
294
311
  path: string,
295
312
  params?: Record<string, string>,
296
- opts?: { cookieHeader?: string; locale?: string },
313
+ opts?: { cookieHeader?: string; locale?: string; regionId?: string },
297
314
  ): Promise<T> {
298
315
  const url = new URL(`${isUrl()}${path}`);
299
316
  if (params) {
@@ -309,6 +326,11 @@ export async function intelligentSearch<T>(
309
326
  url.searchParams.set("locale", locale);
310
327
  }
311
328
 
329
+ const regionId = opts?.regionId ?? extractRegionIdFromCookies();
330
+ if (regionId) {
331
+ url.searchParams.set("regionId", regionId);
332
+ }
333
+
312
334
  const headers: Record<string, string> = { ...authHeaders() };
313
335
  if (opts?.cookieHeader) {
314
336
  headers.cookie = opts.cookieHeader;
@@ -316,8 +338,6 @@ export async function intelligentSearch<T>(
316
338
 
317
339
  const fullUrl = url.toString();
318
340
 
319
- // IS GET requests go through SWR cache (3 min TTL via status-based defaults).
320
- // The doFetch callback throws on non-ok responses, so null is never returned.
321
341
  return fetchWithCache<T>(fullUrl, async () => {
322
342
  const response = await _fetch(fullUrl, { headers });
323
343
  if (!response.ok) {
package/vtex/invoke.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  type UploadAttachmentOpts,
43
43
  type CreateDocumentResult,
44
44
  } from "./actions/masterData";
45
- import { createSession } from "./actions/session";
45
+ import { createSession, editSession, type SessionData } from "./actions/session";
46
46
  import { subscribe, type SubscribeProps } from "./actions/newsletter";
47
47
  import { notifyMe, type NotifyMeProps } from "./actions/misc";
48
48
  import type { OrderForm } from "./types";
@@ -168,6 +168,14 @@ export const invoke = {
168
168
  { unwrap: true },
169
169
  ),
170
170
 
171
+ editSession: createInvokeFn(
172
+ (input: { public: Record<string, { value: string }> }) =>
173
+ editSession(input.public),
174
+ { unwrap: true },
175
+ ) as unknown as (ctx: {
176
+ data: { public: Record<string, { value: string }> };
177
+ }) => Promise<SessionData>,
178
+
171
179
  // -- MasterData -------------------------------------------------------
172
180
 
173
181
  createDocument: createInvokeFn(
@@ -52,6 +52,12 @@ export interface VtexRequestContext {
52
52
  email?: string;
53
53
  /** Sales channel derived from segment. */
54
54
  salesChannel: string;
55
+ /**
56
+ * VTEX region ID from the segment cookie.
57
+ * Present when the user has set a postal code (CEP) for regionalization.
58
+ * Null when no region is set (anonymous default segment).
59
+ */
60
+ regionId: string | null;
55
61
  /** Whether this request carries price tables (B2B). */
56
62
  hasCustomPricing: boolean;
57
63
  /** Intelligent Search session cookie. */
@@ -123,6 +129,7 @@ export function extractVtexContext(request: Request): VtexRequestContext {
123
129
  isLoggedIn: authInfo?.isLoggedIn ?? false,
124
130
  email: authInfo?.email,
125
131
  salesChannel: segment.channel ?? "1",
132
+ regionId: segment.regionId ?? null,
126
133
  hasCustomPricing: Boolean(
127
134
  segment.priceTables && segment.priceTables.length > 0,
128
135
  ),
@@ -217,7 +224,9 @@ export function buildSegmentSetCookie(
217
224
  */
218
225
  export function vtexCacheKeySuffix(ctx: VtexRequestContext): string {
219
226
  if (ctx.isLoggedIn) return "__vtex_auth";
220
- return `__vtex_sc=${ctx.salesChannel}`;
227
+ const parts = [`sc=${ctx.salesChannel}`];
228
+ if (ctx.regionId) parts.push(`r=${ctx.regionId}`);
229
+ return `__vtex_${parts.join("_")}`;
221
230
  }
222
231
 
223
232
  // -------------------------------------------------------------------------
@@ -10,32 +10,73 @@ interface Cookie {
10
10
  sameSite?: "Strict" | "Lax" | "None";
11
11
  }
12
12
 
13
+ function parseSingleSetCookie(raw: string): Cookie | null {
14
+ const parts = raw.split(";").map((p) => p.trim());
15
+ const [nameValue, ...attrs] = parts;
16
+ const eqIdx = nameValue.indexOf("=");
17
+ if (eqIdx < 0) return null;
18
+ const cookie: Cookie = {
19
+ name: nameValue.slice(0, eqIdx),
20
+ value: nameValue.slice(eqIdx + 1),
21
+ };
22
+ for (const attr of attrs) {
23
+ const [k, v] = attr.split("=").map((s) => s.trim());
24
+ const lower = k.toLowerCase();
25
+ if (lower === "domain") cookie.domain = v;
26
+ else if (lower === "path") cookie.path = v;
27
+ else if (lower === "secure") cookie.secure = true;
28
+ else if (lower === "httponly") cookie.httpOnly = true;
29
+ else if (lower === "samesite") cookie.sameSite = v as Cookie["sameSite"];
30
+ }
31
+ return cookie;
32
+ }
33
+
34
+ /**
35
+ * Extract individual Set-Cookie values from a Headers object.
36
+ *
37
+ * Uses Headers.getSetCookie() (available in Cloudflare Workers and Node 18+)
38
+ * which returns each Set-Cookie as a separate string — unlike Headers.get()
39
+ * or Headers.forEach() which join multiple values with ", " and corrupt
40
+ * cookie strings that contain commas in Expires dates.
41
+ */
13
42
  function getSetCookies(headers: Headers): Cookie[] {
43
+ const rawCookies: string[] =
44
+ typeof headers.getSetCookie === "function"
45
+ ? headers.getSetCookie()
46
+ : getRawSetCookiesFallback(headers);
47
+
14
48
  const cookies: Cookie[] = [];
15
- headers.forEach((value, key) => {
16
- if (key.toLowerCase() !== "set-cookie") return;
17
- const parts = value.split(";").map((p) => p.trim());
18
- const [nameValue, ...attrs] = parts;
19
- const eqIdx = nameValue.indexOf("=");
20
- if (eqIdx < 0) return;
21
- const cookie: Cookie = {
22
- name: nameValue.slice(0, eqIdx),
23
- value: nameValue.slice(eqIdx + 1),
24
- };
25
- for (const attr of attrs) {
26
- const [k, v] = attr.split("=").map((s) => s.trim());
27
- const lower = k.toLowerCase();
28
- if (lower === "domain") cookie.domain = v;
29
- else if (lower === "path") cookie.path = v;
30
- else if (lower === "secure") cookie.secure = true;
31
- else if (lower === "httponly") cookie.httpOnly = true;
32
- else if (lower === "samesite") cookie.sameSite = v as Cookie["sameSite"];
33
- }
34
- cookies.push(cookie);
35
- });
49
+ for (const raw of rawCookies) {
50
+ const cookie = parseSingleSetCookie(raw);
51
+ if (cookie) cookies.push(cookie);
52
+ }
36
53
  return cookies;
37
54
  }
38
55
 
56
+ /**
57
+ * Fallback for runtimes without Headers.getSetCookie().
58
+ * Splits the comma-joined string heuristically — not perfect for cookies
59
+ * with Expires containing commas, but better than the old approach.
60
+ */
61
+ function getRawSetCookiesFallback(headers: Headers): string[] {
62
+ const joined = headers.get("set-cookie");
63
+ if (!joined) return [];
64
+ const results: string[] = [];
65
+ let current = "";
66
+ for (const segment of joined.split(",")) {
67
+ const trimmed = segment.trimStart();
68
+ const looksLikeNewCookie = /^[^=;]+=[^;]/.test(trimmed) && current.length > 0;
69
+ if (looksLikeNewCookie) {
70
+ results.push(current.trim());
71
+ current = trimmed;
72
+ } else {
73
+ current += (current ? "," : "") + segment;
74
+ }
75
+ }
76
+ if (current.trim()) results.push(current.trim());
77
+ return results;
78
+ }
79
+
39
80
  function setCookie(headers: Headers, cookie: Cookie): void {
40
81
  let str = `${cookie.name}=${cookie.value}`;
41
82
  if (cookie.domain) str += `; Domain=${cookie.domain}`;
@@ -104,10 +104,20 @@ function buildOriginUrl(
104
104
  return new URL(`https://${originHost}${url.pathname}${url.search}`);
105
105
  }
106
106
 
107
+ /**
108
+ * Copy headers excluding hop-by-hop and Set-Cookie.
109
+ *
110
+ * Set-Cookie is excluded intentionally: Headers.forEach / .set() joins
111
+ * multiple Set-Cookie values with ", " which corrupts cookies containing
112
+ * commas (e.g. Expires dates). proxySetCookie handles Set-Cookie
113
+ * separately using Headers.getSetCookie() for correct multi-cookie support.
114
+ */
107
115
  function filterHeaders(headers: Headers): Headers {
108
116
  const filtered = new Headers();
109
117
  headers.forEach((value, key) => {
110
- if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
118
+ const lower = key.toLowerCase();
119
+ if (lower === "set-cookie") return;
120
+ if (!HOP_BY_HOP_HEADERS.has(lower)) {
111
121
  filtered.set(key, value);
112
122
  }
113
123
  });
@@ -169,13 +179,13 @@ export async function proxyToVtex(
169
179
 
170
180
  const responseHeaders = filterHeaders(new Headers(originResponse.headers));
171
181
 
172
- if (options?.rewriteCookieDomain !== false) {
173
- proxySetCookie(
174
- originResponse.headers,
175
- responseHeaders,
176
- new URL(request.url).origin,
177
- );
178
- }
182
+ proxySetCookie(
183
+ originResponse.headers,
184
+ responseHeaders,
185
+ options?.rewriteCookieDomain !== false
186
+ ? new URL(request.url).origin
187
+ : undefined,
188
+ );
179
189
 
180
190
  if (originResponse.status >= 300 && originResponse.status < 400) {
181
191
  const location = originResponse.headers.get("location");