@decocms/blocks 7.1.2 → 7.2.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.
- package/package.json +10 -1
- package/src/flags/audience.ts +56 -0
- package/src/flags/everyone.ts +29 -0
- package/src/flags/flag.ts +15 -0
- package/src/flags/flags.test.ts +148 -0
- package/src/flags/multivariate/image.ts +11 -0
- package/src/flags/multivariate/message.ts +11 -0
- package/src/flags/multivariate/page.ts +16 -0
- package/src/flags/multivariate/section.ts +16 -0
- package/src/flags/multivariate.ts +1 -0
- package/src/flags/types.ts +59 -0
- package/src/flags/utils/multivariate.ts +20 -0
- package/src/hooks/Image.test.ts +100 -0
- package/src/hooks/Image.tsx +250 -0
- package/src/hooks/JsonLd.tsx +353 -0
- package/src/hooks/Picture.tsx +104 -0
- package/src/hooks/index.ts +26 -0
- package/src/matchers/builtins.test.ts +68 -1
- package/src/matchers/builtins.ts +24 -2
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { ImgHTMLAttributes } from "react";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
|
|
4
|
+
// -------------------------------------------------------------------------
|
|
5
|
+
// Known asset prefixes that get stripped to produce a relative src path
|
|
6
|
+
// -------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const DECO_CACHE_URL = "https://assets.decocache.com/";
|
|
9
|
+
const S3_URL = "https://deco-sites-assets.s3.sa-east-1.amazonaws.com/";
|
|
10
|
+
|
|
11
|
+
// -------------------------------------------------------------------------
|
|
12
|
+
// Configurable CDN domain
|
|
13
|
+
// -------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
let imageCdnDomain = "decoims.com";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register the image CDN domain used by `getOptimizedMediaUrl`.
|
|
19
|
+
* Call once in your site's setup.ts before any page loads.
|
|
20
|
+
*
|
|
21
|
+
* Available domains:
|
|
22
|
+
* - `decoims.com` (Cloudflare, default — best compression, same edge as Workers)
|
|
23
|
+
* - `deco-assets.edgedeco.com` (Azion IMS)
|
|
24
|
+
* - `deco-assets.decoazn.com` (Azion IMS, legacy)
|
|
25
|
+
*/
|
|
26
|
+
export function registerImageCdnDomain(domain: string) {
|
|
27
|
+
imageCdnDomain = domain.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getImageCdnDomain(): string {
|
|
31
|
+
return imageCdnDomain;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// -------------------------------------------------------------------------
|
|
35
|
+
// Fit options & optimization types
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export type FitOptions = "contain" | "cover" | "fill";
|
|
39
|
+
|
|
40
|
+
export const FACTORS = [1, 2];
|
|
41
|
+
|
|
42
|
+
interface OptimizationOptions {
|
|
43
|
+
originalSrc: string;
|
|
44
|
+
width: number;
|
|
45
|
+
height?: number;
|
|
46
|
+
fit: FitOptions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// -------------------------------------------------------------------------
|
|
50
|
+
// Platform-specific URL optimizers (fallbacks when the CDN can handle
|
|
51
|
+
// the native platform's resize syntax directly)
|
|
52
|
+
// -------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function optimizeVTEX(originalSrc: string, width: number, height?: number): string {
|
|
55
|
+
const src = new URL(originalSrc);
|
|
56
|
+
const [slash, arquivos, ids, rawId, ...rest] = src.pathname.split("/");
|
|
57
|
+
const [trueId] = rawId.split("-");
|
|
58
|
+
|
|
59
|
+
src.pathname = [slash, arquivos, ids, `${trueId}-${width}-${height ?? width}`, ...rest].join("/");
|
|
60
|
+
|
|
61
|
+
return src.href;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function optimizeShopify(originalSrc: string, width: number, height?: number): string {
|
|
65
|
+
const url = new URL(originalSrc);
|
|
66
|
+
url.searchParams.set("width", `${width}`);
|
|
67
|
+
if (height) url.searchParams.set("height", `${height}`);
|
|
68
|
+
url.searchParams.set("crop", "center");
|
|
69
|
+
return url.href;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
// Core optimization function
|
|
74
|
+
// Ported from deco-cx/apps website/components/Image.tsx
|
|
75
|
+
// -------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds an optimized image URL.
|
|
79
|
+
*
|
|
80
|
+
* For Deco-hosted images (decocache / S3), strips the known prefix and
|
|
81
|
+
* routes through the Deco image CDN for edge resize + format conversion.
|
|
82
|
+
*
|
|
83
|
+
* For platform-specific images (VTEX, Shopify), rewrites the URL using
|
|
84
|
+
* the platform's native resize params — no CDN proxy needed.
|
|
85
|
+
*
|
|
86
|
+
* Data URIs are returned as-is.
|
|
87
|
+
*/
|
|
88
|
+
export function getOptimizedMediaUrl(opts: OptimizationOptions): string {
|
|
89
|
+
const { originalSrc, width, height, fit } = opts;
|
|
90
|
+
|
|
91
|
+
// Defensive: an upstream CMS payload occasionally has missing/null image
|
|
92
|
+
// fields. Crashing the entire React tree on `undefined.startsWith` would
|
|
93
|
+
// take down the whole page. Return an empty string so the resulting
|
|
94
|
+
// `<img>` is rendered with no src — the browser shows the broken-image
|
|
95
|
+
// placeholder and SSR completes cleanly.
|
|
96
|
+
if (typeof originalSrc !== "string" || originalSrc.length === 0) {
|
|
97
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
98
|
+
console.warn(
|
|
99
|
+
`[Image] getOptimizedMediaUrl called with empty/undefined src — rendering empty src instead of crashing.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (originalSrc.startsWith("data:")) {
|
|
106
|
+
return originalSrc;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (/(vteximg\.com\.br|vtexassets\.com|myvtex\.com)\/arquivos\/ids\/\d+/.test(originalSrc)) {
|
|
110
|
+
return optimizeVTEX(originalSrc, width, height);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (originalSrc.startsWith("https://cdn.shopify.com")) {
|
|
114
|
+
return optimizeShopify(originalSrc, width, height);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let imageSource = originalSrc.replace(DECO_CACHE_URL, "").replace(S3_URL, "").split("?")[0];
|
|
118
|
+
|
|
119
|
+
// Already on the image CDN — strip the host so we don't proxy through ourselves.
|
|
120
|
+
const cdnPrefix = `https://${imageCdnDomain}/`;
|
|
121
|
+
if (imageSource.startsWith(cdnPrefix)) {
|
|
122
|
+
imageSource = imageSource.slice(cdnPrefix.length);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const params = new URLSearchParams();
|
|
126
|
+
params.set("fit", fit);
|
|
127
|
+
params.set("width", `${width}`);
|
|
128
|
+
if (height) params.set("height", `${height}`);
|
|
129
|
+
|
|
130
|
+
return `https://${imageCdnDomain}/image?${params}&src=${imageSource}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generates a srcset string with responsive multipliers.
|
|
135
|
+
*/
|
|
136
|
+
export function getSrcSet(
|
|
137
|
+
originalSrc: string,
|
|
138
|
+
width: number,
|
|
139
|
+
height?: number,
|
|
140
|
+
fit?: FitOptions,
|
|
141
|
+
factors: number[] = FACTORS,
|
|
142
|
+
): string | undefined {
|
|
143
|
+
if (typeof originalSrc !== "string" || originalSrc.length === 0) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const entries: string[] = [];
|
|
148
|
+
|
|
149
|
+
for (const factor of factors) {
|
|
150
|
+
const w = Math.trunc(factor * width);
|
|
151
|
+
const h = height ? Math.trunc(factor * height) : undefined;
|
|
152
|
+
|
|
153
|
+
const src = getOptimizedMediaUrl({
|
|
154
|
+
originalSrc,
|
|
155
|
+
width: w,
|
|
156
|
+
height: h,
|
|
157
|
+
fit: fit ?? "cover",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (src) {
|
|
161
|
+
entries.push(`${src} ${w}w`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return entries.length > 0 ? entries.join(", ") : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// -------------------------------------------------------------------------
|
|
169
|
+
// Image component
|
|
170
|
+
// -------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
export interface ImageProps
|
|
173
|
+
extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "width" | "height"> {
|
|
174
|
+
src: string;
|
|
175
|
+
/** @description Improves Web Vitals (CLS/LCP) */
|
|
176
|
+
width: number;
|
|
177
|
+
/** @description Improves Web Vitals (CLS/LCP) */
|
|
178
|
+
height?: number;
|
|
179
|
+
/** @description Object-fit */
|
|
180
|
+
fit?: FitOptions;
|
|
181
|
+
/**
|
|
182
|
+
* @description Web Vitals (LCP). Injects a `<link rel="preload">` tag
|
|
183
|
+
* alongside the `<img>`, sets `fetchPriority="high"` and `loading="eager"`.
|
|
184
|
+
* Use once per page for the LCP image.
|
|
185
|
+
*/
|
|
186
|
+
preload?: boolean;
|
|
187
|
+
/** @description Media query for responsive preloading (e.g. "(min-width: 768px)") */
|
|
188
|
+
media?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
|
|
192
|
+
{
|
|
193
|
+
src,
|
|
194
|
+
width,
|
|
195
|
+
height,
|
|
196
|
+
fit = "cover",
|
|
197
|
+
preload,
|
|
198
|
+
media,
|
|
199
|
+
loading,
|
|
200
|
+
decoding,
|
|
201
|
+
srcSet: srcSetProp,
|
|
202
|
+
sizes,
|
|
203
|
+
fetchPriority,
|
|
204
|
+
...rest
|
|
205
|
+
},
|
|
206
|
+
ref,
|
|
207
|
+
) {
|
|
208
|
+
if (!height && typeof process !== "undefined") {
|
|
209
|
+
console.warn(`Missing height. This image will NOT be optimized: ${src}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const optimizedSrc = getOptimizedMediaUrl({
|
|
213
|
+
originalSrc: src,
|
|
214
|
+
width,
|
|
215
|
+
height,
|
|
216
|
+
fit,
|
|
217
|
+
});
|
|
218
|
+
const srcSet = srcSetProp ?? getSrcSet(src, width, height, fit);
|
|
219
|
+
const resolvedSizes = srcSet ? (sizes ?? "(max-width: 768px) 100vw, 50vw") : undefined;
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<>
|
|
223
|
+
{preload && (
|
|
224
|
+
<link
|
|
225
|
+
as="image"
|
|
226
|
+
rel="preload"
|
|
227
|
+
href={optimizedSrc}
|
|
228
|
+
imageSrcSet={srcSet}
|
|
229
|
+
imageSizes={resolvedSizes}
|
|
230
|
+
fetchPriority={fetchPriority ?? "high"}
|
|
231
|
+
media={media}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
<img
|
|
235
|
+
{...rest}
|
|
236
|
+
src={optimizedSrc}
|
|
237
|
+
srcSet={srcSet}
|
|
238
|
+
sizes={resolvedSizes}
|
|
239
|
+
width={width}
|
|
240
|
+
height={height}
|
|
241
|
+
loading={loading ?? (preload ? "eager" : "lazy")}
|
|
242
|
+
decoding={decoding ?? "async"}
|
|
243
|
+
fetchPriority={preload ? "high" : fetchPriority}
|
|
244
|
+
ref={ref}
|
|
245
|
+
/>
|
|
246
|
+
</>
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
export default Image;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO JSON-LD structured data components.
|
|
3
|
+
*
|
|
4
|
+
* Generates JSON-LD script tags for Product (PDP), ProductList (PLP),
|
|
5
|
+
* and BreadcrumbList schemas. Compatible with Google's Rich Results
|
|
6
|
+
* requirements.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { ProductJsonLd, PLPJsonLd, BreadcrumbJsonLd } from "@decocms/blocks/hooks";
|
|
11
|
+
*
|
|
12
|
+
* // In a PDP route
|
|
13
|
+
* <ProductJsonLd product={product} />
|
|
14
|
+
*
|
|
15
|
+
* // In a PLP route
|
|
16
|
+
* <PLPJsonLd page={productListingPage} />
|
|
17
|
+
*
|
|
18
|
+
* // Anywhere with breadcrumbs
|
|
19
|
+
* <BreadcrumbJsonLd breadcrumb={breadcrumbList} />
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Type note: originally these components imported `Product`,
|
|
23
|
+
* `ProductListingPage`, `BreadcrumbList`, etc. from apps-start's
|
|
24
|
+
* commerce types module (now `@decocms/apps-commerce/types`).
|
|
25
|
+
* `@decocms/blocks` must not depend on any `apps-*` package (one-way
|
|
26
|
+
* dependency rule: `apps-*` depends on `blocks`, never the reverse), so
|
|
27
|
+
* that import can't carry over as-is. Each function here only reads a
|
|
28
|
+
* small, flat subset of the full schema.org Product/Offer/ListItem
|
|
29
|
+
* graph (the original code already didn't fully trust the nominal
|
|
30
|
+
* `Product.offers` type either — see the `as Offer[] | AggregateOffer`
|
|
31
|
+
* casts it used). The types below are local, minimal, structural
|
|
32
|
+
* equivalents of just that subset: any commerce `Product` /
|
|
33
|
+
* `ProductListingPage` / `BreadcrumbList` value is a structural
|
|
34
|
+
* superset and can be passed in directly without a cast.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// -------------------------------------------------------------------------
|
|
38
|
+
// Minimal structural types (see file header for why these aren't imported
|
|
39
|
+
// from @decocms/apps-commerce/types)
|
|
40
|
+
// -------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
interface JsonLdOffer {
|
|
43
|
+
price?: number;
|
|
44
|
+
priceCurrency?: string;
|
|
45
|
+
availability?: string;
|
|
46
|
+
seller?: string;
|
|
47
|
+
priceValidUntil?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface JsonLdAggregateOffer {
|
|
51
|
+
"@type"?: string;
|
|
52
|
+
lowPrice?: number;
|
|
53
|
+
priceCurrency?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface JsonLdPriceSpecification {
|
|
57
|
+
priceType?: string;
|
|
58
|
+
price?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface JsonLdAggregateRating {
|
|
62
|
+
ratingValue?: number;
|
|
63
|
+
reviewCount?: number;
|
|
64
|
+
ratingCount?: number;
|
|
65
|
+
bestRating?: number;
|
|
66
|
+
worstRating?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface JsonLdImage {
|
|
70
|
+
url?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface JsonLdBrand {
|
|
74
|
+
name?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface JsonLdProduct {
|
|
78
|
+
name?: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
url?: string;
|
|
81
|
+
sku?: string;
|
|
82
|
+
productID?: string;
|
|
83
|
+
gtin?: string;
|
|
84
|
+
brand?: JsonLdBrand | null;
|
|
85
|
+
image?: JsonLdImage[] | null;
|
|
86
|
+
offers?: JsonLdOffer[] | JsonLdAggregateOffer;
|
|
87
|
+
aggregateRating?: JsonLdAggregateRating;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface JsonLdSeo {
|
|
91
|
+
canonical?: string;
|
|
92
|
+
title?: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface JsonLdProductListingPage {
|
|
97
|
+
products?: JsonLdProduct[];
|
|
98
|
+
seo?: JsonLdSeo | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface JsonLdListItem {
|
|
102
|
+
position?: number;
|
|
103
|
+
name?: string;
|
|
104
|
+
item?: string;
|
|
105
|
+
url?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface JsonLdBreadcrumbList {
|
|
109
|
+
itemListElement?: JsonLdListItem[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
// JSON-LD script renderer
|
|
114
|
+
// -------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function JsonLdScript({ data }: { data: unknown }) {
|
|
117
|
+
return (
|
|
118
|
+
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -------------------------------------------------------------------------
|
|
123
|
+
// Product (PDP)
|
|
124
|
+
// -------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export interface ProductJsonLdProps {
|
|
127
|
+
product: JsonLdProduct;
|
|
128
|
+
/** Override the canonical URL. Defaults to product.url. */
|
|
129
|
+
url?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getBestOffer(offers: JsonLdOffer[] | JsonLdAggregateOffer | undefined): {
|
|
133
|
+
price?: number;
|
|
134
|
+
priceCurrency?: string;
|
|
135
|
+
availability?: string;
|
|
136
|
+
seller?: string;
|
|
137
|
+
priceValidUntil?: string;
|
|
138
|
+
} {
|
|
139
|
+
if (!offers) return {};
|
|
140
|
+
|
|
141
|
+
if ("@type" in offers && offers["@type"] === "AggregateOffer") {
|
|
142
|
+
const agg = offers as JsonLdAggregateOffer;
|
|
143
|
+
return {
|
|
144
|
+
price: agg.lowPrice,
|
|
145
|
+
priceCurrency: agg.priceCurrency,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (Array.isArray(offers) && offers.length > 0) {
|
|
150
|
+
const best = offers.reduce((a, b) => {
|
|
151
|
+
const ap = a.price ?? Infinity;
|
|
152
|
+
const bp = b.price ?? Infinity;
|
|
153
|
+
return ap <= bp ? a : b;
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
price: best.price,
|
|
157
|
+
priceCurrency: best.priceCurrency,
|
|
158
|
+
availability: best.availability,
|
|
159
|
+
seller: best.seller,
|
|
160
|
+
priceValidUntil: best.priceValidUntil,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _getListPrice(priceSpec: JsonLdPriceSpecification[] | undefined): number | undefined {
|
|
168
|
+
if (!priceSpec) return undefined;
|
|
169
|
+
const list = priceSpec.find(
|
|
170
|
+
(p) =>
|
|
171
|
+
p.priceType === "https://schema.org/ListPrice" || p.priceType === "https://schema.org/SRP",
|
|
172
|
+
);
|
|
173
|
+
return list?.price;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function ProductJsonLd({ product, url }: ProductJsonLdProps) {
|
|
177
|
+
const offer = getBestOffer(product.offers);
|
|
178
|
+
const images = product.image?.map((img) => img.url).filter(Boolean) ?? [];
|
|
179
|
+
const rating = product.aggregateRating;
|
|
180
|
+
|
|
181
|
+
const data: Record<string, unknown> = {
|
|
182
|
+
"@context": "https://schema.org",
|
|
183
|
+
"@type": "Product",
|
|
184
|
+
name: product.name,
|
|
185
|
+
description: product.description,
|
|
186
|
+
image: images.length === 1 ? images[0] : images,
|
|
187
|
+
url: url ?? product.url,
|
|
188
|
+
sku: product.sku,
|
|
189
|
+
productID: product.productID,
|
|
190
|
+
brand: product.brand ? { "@type": "Brand", name: product.brand.name } : undefined,
|
|
191
|
+
gtin: product.gtin,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (offer.price != null) {
|
|
195
|
+
data.offers = {
|
|
196
|
+
"@type": "Offer",
|
|
197
|
+
price: offer.price,
|
|
198
|
+
priceCurrency: offer.priceCurrency ?? "BRL",
|
|
199
|
+
availability: offer.availability ?? "https://schema.org/InStock",
|
|
200
|
+
seller: offer.seller ? { "@type": "Organization", name: offer.seller } : undefined,
|
|
201
|
+
priceValidUntil: offer.priceValidUntil,
|
|
202
|
+
url: url ?? product.url,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (rating?.ratingValue) {
|
|
207
|
+
data.aggregateRating = {
|
|
208
|
+
"@type": "AggregateRating",
|
|
209
|
+
ratingValue: rating.ratingValue,
|
|
210
|
+
reviewCount: rating.reviewCount ?? rating.ratingCount ?? 0,
|
|
211
|
+
bestRating: rating.bestRating ?? 5,
|
|
212
|
+
worstRating: rating.worstRating ?? 1,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return <JsonLdScript data={data} />;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// -------------------------------------------------------------------------
|
|
220
|
+
// Product Listing Page (PLP)
|
|
221
|
+
// -------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export interface PLPJsonLdProps {
|
|
224
|
+
page: JsonLdProductListingPage;
|
|
225
|
+
/** Override the canonical URL. */
|
|
226
|
+
url?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function PLPJsonLd({ page, url }: PLPJsonLdProps) {
|
|
230
|
+
const items = (page.products ?? []).map((product, index) => {
|
|
231
|
+
const offer = getBestOffer(product.offers);
|
|
232
|
+
return {
|
|
233
|
+
"@type": "ListItem" as const,
|
|
234
|
+
position: index + 1,
|
|
235
|
+
item: {
|
|
236
|
+
"@type": "Product" as const,
|
|
237
|
+
name: product.name,
|
|
238
|
+
url: product.url,
|
|
239
|
+
image: product.image?.[0]?.url,
|
|
240
|
+
offers:
|
|
241
|
+
offer.price != null
|
|
242
|
+
? {
|
|
243
|
+
"@type": "Offer" as const,
|
|
244
|
+
price: offer.price,
|
|
245
|
+
priceCurrency: offer.priceCurrency ?? "BRL",
|
|
246
|
+
availability: offer.availability ?? "https://schema.org/InStock",
|
|
247
|
+
}
|
|
248
|
+
: undefined,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const data = {
|
|
254
|
+
"@context": "https://schema.org",
|
|
255
|
+
"@type": "ItemList",
|
|
256
|
+
url: url ?? page.seo?.canonical,
|
|
257
|
+
name: page.seo?.title,
|
|
258
|
+
description: page.seo?.description,
|
|
259
|
+
numberOfItems: page.products?.length ?? 0,
|
|
260
|
+
itemListElement: items,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return <JsonLdScript data={data} />;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// -------------------------------------------------------------------------
|
|
267
|
+
// Breadcrumb
|
|
268
|
+
// -------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
export interface BreadcrumbJsonLdProps {
|
|
271
|
+
breadcrumb: JsonLdBreadcrumbList;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function BreadcrumbJsonLd({ breadcrumb }: BreadcrumbJsonLdProps) {
|
|
275
|
+
const items = (breadcrumb.itemListElement ?? []).map((item, index) => {
|
|
276
|
+
return {
|
|
277
|
+
"@type": "ListItem" as const,
|
|
278
|
+
position: item.position ?? index + 1,
|
|
279
|
+
name: item.name,
|
|
280
|
+
item: item.item ?? item.url,
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const data = {
|
|
285
|
+
"@context": "https://schema.org",
|
|
286
|
+
"@type": "BreadcrumbList",
|
|
287
|
+
itemListElement: items,
|
|
288
|
+
numberOfItems: items.length,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return <JsonLdScript data={data} />;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// -------------------------------------------------------------------------
|
|
295
|
+
// Generic SEO Meta
|
|
296
|
+
// -------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
export interface SeoMetaProps {
|
|
299
|
+
title?: string;
|
|
300
|
+
description?: string;
|
|
301
|
+
canonical?: string;
|
|
302
|
+
image?: string;
|
|
303
|
+
noIndex?: boolean;
|
|
304
|
+
type?: "website" | "article" | "product";
|
|
305
|
+
siteName?: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Generates Open Graph and Twitter Card meta tags.
|
|
310
|
+
*
|
|
311
|
+
* Use this in combination with TanStack Router's `meta()` route option,
|
|
312
|
+
* or render directly in the component tree (tags will be hoisted to <head>
|
|
313
|
+
* by React's built-in behavior with TanStack Start).
|
|
314
|
+
*/
|
|
315
|
+
export function seoMetaTags(props: SeoMetaProps): Array<Record<string, string>> {
|
|
316
|
+
const tags: Array<Record<string, string>> = [];
|
|
317
|
+
|
|
318
|
+
if (props.title) {
|
|
319
|
+
tags.push({ title: props.title });
|
|
320
|
+
tags.push({ property: "og:title", content: props.title });
|
|
321
|
+
tags.push({ name: "twitter:title", content: props.title });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (props.description) {
|
|
325
|
+
tags.push({ name: "description", content: props.description });
|
|
326
|
+
tags.push({ property: "og:description", content: props.description });
|
|
327
|
+
tags.push({ name: "twitter:description", content: props.description });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (props.canonical) {
|
|
331
|
+
tags.push({ property: "og:url", content: props.canonical });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (props.image) {
|
|
335
|
+
tags.push({ property: "og:image", content: props.image });
|
|
336
|
+
tags.push({ name: "twitter:image", content: props.image });
|
|
337
|
+
tags.push({ name: "twitter:card", content: "summary_large_image" });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (props.type) {
|
|
341
|
+
tags.push({ property: "og:type", content: props.type });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (props.siteName) {
|
|
345
|
+
tags.push({ property: "og:site_name", content: props.siteName });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (props.noIndex) {
|
|
349
|
+
tags.push({ name: "robots", content: "noindex, nofollow" });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return tags;
|
|
353
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ComponentPropsWithoutRef,
|
|
3
|
+
createContext,
|
|
4
|
+
forwardRef,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { type FitOptions, getOptimizedMediaUrl, getSrcSet } 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<ComponentPropsWithoutRef<"source">, "width" | "height"> & {
|
|
29
|
+
src: string;
|
|
30
|
+
/** @description Improves Web Vitals (CLS|LCP) */
|
|
31
|
+
width: number;
|
|
32
|
+
/** @description Improves Web Vitals (CLS|LCP) */
|
|
33
|
+
height?: number;
|
|
34
|
+
/** @description Improves Web Vitals (LCP). Use high for LCP image. */
|
|
35
|
+
fetchPriority?: "high" | "low" | "auto";
|
|
36
|
+
/** @description Object-fit */
|
|
37
|
+
fit?: FitOptions;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const Source = forwardRef<HTMLSourceElement, SourceProps>(function Source(
|
|
41
|
+
{ src, width, height, fetchPriority, fit = "cover", ...rest },
|
|
42
|
+
ref,
|
|
43
|
+
) {
|
|
44
|
+
const { preload } = useContext(PreloadContext);
|
|
45
|
+
|
|
46
|
+
const optimizedSrc = getOptimizedMediaUrl({
|
|
47
|
+
originalSrc: src,
|
|
48
|
+
width,
|
|
49
|
+
height,
|
|
50
|
+
fit,
|
|
51
|
+
});
|
|
52
|
+
const srcSet = rest.srcSet ?? getSrcSet(src, width, height, fit);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
{preload && (
|
|
57
|
+
<link
|
|
58
|
+
as="image"
|
|
59
|
+
rel="preload"
|
|
60
|
+
href={optimizedSrc}
|
|
61
|
+
imageSrcSet={srcSet}
|
|
62
|
+
fetchPriority={fetchPriority ?? "high"}
|
|
63
|
+
media={rest.media}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
<source {...rest} srcSet={srcSet ?? optimizedSrc} width={width} height={height} ref={ref} />
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// -------------------------------------------------------------------------
|
|
72
|
+
// Picture — composable wrapper that provides preload context to children.
|
|
73
|
+
//
|
|
74
|
+
// Usage:
|
|
75
|
+
// <Picture preload={isLcp}>
|
|
76
|
+
// <Source media="(max-width: 767px)" src={mobile} width={320} height={280} />
|
|
77
|
+
// <Source media="(min-width: 768px)" src={desktop} width={1280} height={280} />
|
|
78
|
+
// <Image src={desktop} width={1280} height={280} />
|
|
79
|
+
// </Picture>
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export type PictureProps = ComponentPropsWithoutRef<"picture"> & {
|
|
83
|
+
children: ReactNode;
|
|
84
|
+
/**
|
|
85
|
+
* @description When true, child <Source> and <Image> elements inject
|
|
86
|
+
* `<link rel="preload">` tags for their respective media queries.
|
|
87
|
+
*/
|
|
88
|
+
preload?: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const Picture = forwardRef<HTMLPictureElement, PictureProps>(function Picture(
|
|
92
|
+
{ children, preload = false, ...props },
|
|
93
|
+
ref,
|
|
94
|
+
) {
|
|
95
|
+
const value = useMemo(() => ({ preload }), [preload]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<PreloadContext.Provider value={value}>
|
|
99
|
+
<picture {...props} ref={ref}>
|
|
100
|
+
{children}
|
|
101
|
+
</picture>
|
|
102
|
+
</PreloadContext.Provider>
|
|
103
|
+
);
|
|
104
|
+
});
|